diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe07ce..8f76242 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ and [`agents/peggy/CHANGELOG.md`](agents/peggy/CHANGELOG.md). ## Unreleased +- **`glue run --continue` reopens the most recently updated session.** + Resume previously existed only inside the TUI (`/resume` picker); + from the CLI you had to remember the `--id` you used. `--continue` + asks the store for the newest session, prints which one it picked + (id, message count, last-updated), and works for both the TUI and + one-shot `--prompt` runs. Mutually exclusive with `--id`. (#366) + - **`find_files`, `grep`, and the TUI `@`-picker respect `.gitignore` (`tools/fs.IgnoreMatcher`).** The recursive walkers previously skipped only `.git`, so real projects burned tokens walking diff --git a/cmd/glue/main.go b/cmd/glue/main.go index a4e7a2a..539a3cb 100644 --- a/cmd/glue/main.go +++ b/cmd/glue/main.go @@ -242,7 +242,7 @@ func printVersion(w io.Writer) { func printUsage(w io.Writer) { fmt.Fprint(w, `Usage: - glue run --prompt [--provider ] [--id ] [--model ] [--store ] [--work ] [--coding] [--env ] + glue run --prompt [--provider ] [--id | --continue] [--model ] [--store ] [--work ] [--coding] [--env ] glue goal "" [--provider ] [--model ] [--store ] [--work ] [--coding] [--yolo] [--worktree] [--max-iterations ] [--budget ] [--env ] glue goal --resume [] | --list [--store ] glue serve [--provider ] [--listen 127.0.0.1:0] [--metadata ] [--model ] [--store ] [--work ] [--coding] [--env ] diff --git a/cmd/glue/run.go b/cmd/glue/run.go index f15fe62..b36ce1a 100644 --- a/cmd/glue/run.go +++ b/cmd/glue/run.go @@ -48,6 +48,7 @@ func runCommand(ctx context.Context, args []string, stdin io.Reader, stdout io.W flags.SetOutput(stderr) id := flags.String("id", "default", "session id") + continueLast := flags.Bool("continue", false, "resume the most recently updated session in the store (mutually exclusive with --id)") prompt := flags.String("prompt", "", "prompt text") provider := flags.String("provider", defaultProvider, "provider name: codex, gemini, nvidia, or openrouter") model := flags.String("model", "", "model id (default: the provider's default model); gemini/ accepted") @@ -73,6 +74,17 @@ func runCommand(ctx context.Context, args []string, stdin io.Reader, stdout io.W if err := validateUsagePricing(*usagePricing); err != nil { return err } + if *continueLast { + idExplicit := false + flags.Visit(func(f *flag.Flag) { + if f.Name == "id" { + idExplicit = true + } + }) + if idExplicit { + return errors.New("--continue and --id are mutually exclusive (--continue picks the session for you)") + } + } agentName := "default" if flags.NArg() > 0 { @@ -193,6 +205,21 @@ func runCommand(ctx context.Context, args []string, stdin io.Reader, stdout io.W return err } storeImpl := filestore.New(*storeDir) + // --continue resolves to the store's most recently updated session. + // The store orders by UpdatedAt descending, so the first row wins. + effectiveID := *id + if *continueLast { + summaries, err := storeImpl.ListSessions(ctx, glue.ListSessionsOptions{Limit: 1}) + if err != nil { + return fmt.Errorf("--continue: list sessions: %w", err) + } + if len(summaries) == 0 { + return fmt.Errorf("--continue: no sessions found in %s", *storeDir) + } + effectiveID = summaries[0].ID + fmt.Fprintf(stderr, "glue run: continuing session %q (%d messages, last updated %s)\n", + effectiveID, summaries[0].Messages, summaries[0].UpdatedAt.Local().Format("2006-01-02 15:04")) + } systemPrompt, autoContinue := capabilityDefaults(providerName, tools, *coding) // Coding runs get the refreshable environment block (cwd, git // state, date) so the model doesn't spend turns rediscovering it. @@ -234,7 +261,7 @@ func runCommand(ctx context.Context, args []string, stdin io.Reader, stdout io.W } return tui.Run(ctx, tui.Config{ Agent: agent, - SessionID: *id, + SessionID: effectiveID, Provider: providerName, Model: effectiveModel, WorkDir: *workDir, @@ -245,13 +272,13 @@ func runCommand(ctx context.Context, args []string, stdin io.Reader, stdout io.W BuildTools: buildToolsAt, }) } - session, err := agent.Session(ctx, *id) + session, err := agent.Session(ctx, effectiveID) if err != nil { return err } if jsonMode { - return runOneShotJSON(ctx, session, effectivePrompt, *id, providerName, effectiveModel, stdout, *showUsage, usagePricing, stderr) + return runOneShotJSON(ctx, session, effectivePrompt, effectiveID, providerName, effectiveModel, stdout, *showUsage, usagePricing, stderr) } wroteDelta := false diff --git a/cmd/glue/run_test.go b/cmd/glue/run_test.go index 6382984..308adb7 100644 --- a/cmd/glue/run_test.go +++ b/cmd/glue/run_test.go @@ -330,3 +330,64 @@ func TestRunCLIMissingPrompt(t *testing.T) { t.Fatalf("stderr = %q, want missing prompt", stderr.String()) } } + +func TestRunCLIContinuePicksMostRecentSession(t *testing.T) { + t.Parallel() + + storeDir := t.TempDir() + + // Seed two sessions; "older" first so "newer" has the later UpdatedAt. + for _, id := range []string{"older", "newer"} { + p := &scriptedProvider{turns: [][]glue.ProviderEvent{textTurn("seed " + id)}} + var stdout, stderr bytes.Buffer + if code := runCLI(context.Background(), []string{ + "run", "--id", id, "--prompt", "seed", "--store", storeDir, + }, &stdout, &stderr, fakeFactory(p)); code != 0 { + t.Fatalf("seed %s code = %d stderr=%q", id, code, stderr.String()) + } + } + + cont := &scriptedProvider{turns: [][]glue.ProviderEvent{textTurn("continued")}} + var stdout, stderr bytes.Buffer + code := runCLI(context.Background(), []string{ + "run", "--continue", "--prompt", "and then", "--store", storeDir, + }, &stdout, &stderr, fakeFactory(cont)) + if code != 0 { + t.Fatalf("continue code = %d stderr=%q", code, stderr.String()) + } + if !strings.Contains(stderr.String(), `continuing session "newer"`) { + t.Fatalf("stderr = %q, want continuing session \"newer\"", stderr.String()) + } + // Resumed transcript (user+assistant) plus the new user message. + if got := len(cont.requests[0].Messages); got != 3 { + t.Fatalf("request msg count = %d, want 3", got) + } +} + +func TestRunCLIContinueRejectsExplicitID(t *testing.T) { + t.Parallel() + var stdout, stderr bytes.Buffer + code := runCLI(context.Background(), []string{ + "run", "--continue", "--id", "x", "--prompt", "hi", "--store", t.TempDir(), + }, &stdout, &stderr, fakeFactory(&scriptedProvider{})) + if code == 0 { + t.Fatal("code = 0, want nonzero for --continue with --id") + } + if !strings.Contains(stderr.String(), "mutually exclusive") { + t.Fatalf("stderr = %q, want mutually exclusive error", stderr.String()) + } +} + +func TestRunCLIContinueEmptyStoreErrors(t *testing.T) { + t.Parallel() + var stdout, stderr bytes.Buffer + code := runCLI(context.Background(), []string{ + "run", "--continue", "--prompt", "hi", "--store", t.TempDir(), + }, &stdout, &stderr, fakeFactory(&scriptedProvider{})) + if code == 0 { + t.Fatal("code = 0, want nonzero for --continue with empty store") + } + if !strings.Contains(stderr.String(), "no sessions found") { + t.Fatalf("stderr = %q, want no sessions found", stderr.String()) + } +}