detached 4bf48bc

Merge pull request #77 from bnema/feat/resume-session-id

feat(session): resume sessions by UUID
commit
4bf48bce77bf8c332a7189d02247ef86a6f527df
author
bnema · · 2026-08-08 09:43 UTC
committer
GitHub · · 2026-08-08 09:43 UTC
merge of
63f7001 + aca70bb · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
10 files · +527 -16
M README.md +2 -1
@@ -194,6 +194,7 @@ zut -p --stats stats.json "task" # print final text and write generation stats
194194 zut --json "refactor main.go" # newline-delimited json events, exit
195195 zut --continue # resume the most recent session for this cwd
196196 zut --resume # pick a session to resume
197+zut --resume <UUID> # resume a persisted session by UUID
197198 zut --list-models # show supported models
198199 zut --help
199200 ```
@@ -214,7 +215,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
214215 | `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set the reasoning level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. |
215216 | `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON. |
216217 | `-c`, `--continue` | Resume the latest session for this cwd. |
217-| `-r`, `--resume` | Pick a session to resume. |
218+| `-r`, `--resume [UUID]` | Pick a session to resume, or resume a persisted session by UUID. |
218219 | `--session <path>` | Resume a specific session file. |
219220 | `--no-session` | Don't read or write session files. |
220221 | `--cwd <path>` | Use `<path>` as the working directory. |
@@ -194,6 +194,7 @@ zut -p --stats stats.json "task" # print final text and write generation stats
194 zut --json "refactor main.go" # newline-delimited json events, exit194 zut --json "refactor main.go" # newline-delimited json events, exit
195 zut --continue # resume the most recent session for this cwd195 zut --continue # resume the most recent session for this cwd
196 zut --resume # pick a session to resume196 zut --resume # pick a session to resume
197+zut --resume <UUID> # resume a persisted session by UUID
197 zut --list-models # show supported models198 zut --list-models # show supported models
198 zut --help199 zut --help
199 ```200 ```
@@ -214,7 +215,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
214 | `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set the reasoning level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. |215 | `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set the reasoning level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. |
215 | `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON. |216 | `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON. |
216 | `-c`, `--continue` | Resume the latest session for this cwd. |217 | `-c`, `--continue` | Resume the latest session for this cwd. |
217-| `-r`, `--resume` | Pick a session to resume. |218+| `-r`, `--resume [UUID]` | Pick a session to resume, or resume a persisted session by UUID. |
218 | `--session <path>` | Resume a specific session file. |219 | `--session <path>` | Resume a specific session file. |
219 | `--no-session` | Don't read or write session files. |220 | `--no-session` | Don't read or write session files. |
220 | `--cwd <path>` | Use `<path>` as the working directory. |221 | `--cwd <path>` | Use `<path>` as the working directory. |
M packages/agent/args.go +13 -5
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/zut/packages/agent/subagents"
1010 "github.com/bnema/zut/packages/agent/tools"
1111 "github.com/bnema/zut/packages/tui"
12+ "github.com/google/uuid"
1213 "golang.org/x/term"
1314 )
1415
@@ -57,10 +58,11 @@ type Args struct {
5758 // survive a parent config change.
5859 FastModeSet bool
5960
60- Continue bool
61- Resume bool
62- Session string
63- NoSess bool
61+ Continue bool
62+ Resume bool
63+ ResumeSessionID string
64+ Session string
65+ NoSess bool
6466
6567 CWD string
6668 NoTools bool
@@ -200,6 +202,12 @@ func ParseArgs(in []string) (Args, error) {
200202 a.Continue = true
201203 case "-r", "--resume":
202204 a.Resume = true
205+ if i+1 < len(in) {
206+ if id, err := uuid.Parse(in[i+1]); err == nil {
207+ a.ResumeSessionID = id.String()
208+ i++
209+ }
210+ }
203211 case "--no-session":
204212 a.NoSess = true
205213 case "--no-tools":
@@ -535,7 +543,7 @@ func PrintHelp(version string) {
535543 row{"--system-prompt TEXT", "replace the default system prompt"},
536544 row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"},
537545 row{"-c, --continue", "continue the most recent session for this cwd"},
538- row{"-r, --resume", "pick a session to resume"},
546+ row{"-r, --resume [UUID]", "pick a session, or resume the persisted UUID"},
539547 row{"--session PATH", "resume a specific session file"},
540548 row{"--no-session", "do not read or write a session file"},
541549 )
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/zut/packages/agent/subagents"9 "github.com/bnema/zut/packages/agent/subagents"
10 "github.com/bnema/zut/packages/agent/tools"10 "github.com/bnema/zut/packages/agent/tools"
11 "github.com/bnema/zut/packages/tui"11 "github.com/bnema/zut/packages/tui"
12+ "github.com/google/uuid"
12 "golang.org/x/term"13 "golang.org/x/term"
13 )14 )
14 15
@@ -57,10 +58,11 @@ type Args struct {
57 // survive a parent config change.58 // survive a parent config change.
58 FastModeSet bool59 FastModeSet bool
59 60
60- Continue bool61+ Continue bool
61- Resume bool62+ Resume bool
62- Session string63+ ResumeSessionID string
63- NoSess bool64+ Session string
65+ NoSess bool
64 66
65 CWD string67 CWD string
66 NoTools bool68 NoTools bool
@@ -200,6 +202,12 @@ func ParseArgs(in []string) (Args, error) {
200 a.Continue = true202 a.Continue = true
201 case "-r", "--resume":203 case "-r", "--resume":
202 a.Resume = true204 a.Resume = true
205+ if i+1 < len(in) {
206+ if id, err := uuid.Parse(in[i+1]); err == nil {
207+ a.ResumeSessionID = id.String()
208+ i++
209+ }
210+ }
203 case "--no-session":211 case "--no-session":
204 a.NoSess = true212 a.NoSess = true
205 case "--no-tools":213 case "--no-tools":
@@ -535,7 +543,7 @@ func PrintHelp(version string) {
535 row{"--system-prompt TEXT", "replace the default system prompt"},543 row{"--system-prompt TEXT", "replace the default system prompt"},
536 row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"},544 row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"},
537 row{"-c, --continue", "continue the most recent session for this cwd"},545 row{"-c, --continue", "continue the most recent session for this cwd"},
538- row{"-r, --resume", "pick a session to resume"},546+ row{"-r, --resume [UUID]", "pick a session, or resume the persisted UUID"},
539 row{"--session PATH", "resume a specific session file"},547 row{"--session PATH", "resume a specific session file"},
540 row{"--no-session", "do not read or write a session file"},548 row{"--no-session", "do not read or write a session file"},
541 )549 )
M packages/agent/args_test.go +29 -0
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 "github.com/bnema/zut/packages/agent/subagents"
7+ "github.com/google/uuid"
78 )
89
910 func TestParseArgsSubagentAndReasoning(t *testing.T) {
@@ -26,6 +27,34 @@ func TestParseArgsNoLSP(t *testing.T) {
2627 }
2728 }
2829
30+func TestParseArgsResumeOptionalSessionID(t *testing.T) {
31+ id := uuid.New()
32+
33+ picker, err := ParseArgs([]string{"--resume"})
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ if !picker.Resume || picker.ResumeSessionID != "" || picker.Prompt != "" {
38+ t.Fatalf("no-argument resume = %#v, want picker behavior", picker)
39+ }
40+
41+ args, err := ParseArgs([]string{"--resume", id.String(), "continue this"})
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ if !args.Resume || args.ResumeSessionID != id.String() || args.Prompt != "continue this" {
46+ t.Fatalf("resume UUID args = %#v", args)
47+ }
48+
49+ prompt, err := ParseArgs([]string{"--resume", "not-a-session-id"})
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ if prompt.ResumeSessionID != "" || prompt.Prompt != "not-a-session-id" {
54+ t.Fatalf("non-UUID resume argument = %#v, want existing prompt behavior", prompt)
55+ }
56+}
57+
2958 func TestParseArgsAllowsLeadingDashPromptAfterTerminator(t *testing.T) {
3059 args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--", "--inspect", "auth flow"})
3160 if err != nil {
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/bnema/zut/packages/agent/subagents"6 "github.com/bnema/zut/packages/agent/subagents"
7+ "github.com/google/uuid"
7 )8 )
8 9
9 func TestParseArgsSubagentAndReasoning(t *testing.T) {10 func TestParseArgsSubagentAndReasoning(t *testing.T) {
@@ -26,6 +27,34 @@ func TestParseArgsNoLSP(t *testing.T) {
26 }27 }
27 }28 }
28 29
30+func TestParseArgsResumeOptionalSessionID(t *testing.T) {
31+ id := uuid.New()
32+
33+ picker, err := ParseArgs([]string{"--resume"})
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ if !picker.Resume || picker.ResumeSessionID != "" || picker.Prompt != "" {
38+ t.Fatalf("no-argument resume = %#v, want picker behavior", picker)
39+ }
40+
41+ args, err := ParseArgs([]string{"--resume", id.String(), "continue this"})
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ if !args.Resume || args.ResumeSessionID != id.String() || args.Prompt != "continue this" {
46+ t.Fatalf("resume UUID args = %#v", args)
47+ }
48+
49+ prompt, err := ParseArgs([]string{"--resume", "not-a-session-id"})
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ if prompt.ResumeSessionID != "" || prompt.Prompt != "not-a-session-id" {
54+ t.Fatalf("non-UUID resume argument = %#v, want existing prompt behavior", prompt)
55+ }
56+}
57+
29 func TestParseArgsAllowsLeadingDashPromptAfterTerminator(t *testing.T) {58 func TestParseArgsAllowsLeadingDashPromptAfterTerminator(t *testing.T) {
30 args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--", "--inspect", "auth flow"})59 args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--", "--inspect", "auth flow"})
31 if err != nil {60 if err != nil {
M packages/agent/cli.go +64 -8
@@ -910,7 +910,7 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error)
910910 }
911911 }()
912912 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
913- sess, err := openOrCreateSession(args, r, ag, version)
913+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
914914 if err != nil {
915915 return err
916916 }
@@ -988,7 +988,7 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error
988988 }
989989 }()
990990 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
991- sess, err := openOrCreateSession(args, r, ag, version)
991+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
992992 if err != nil {
993993 return err
994994 }
@@ -1105,7 +1105,7 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error)
11051105 }
11061106 }()
11071107 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1108- sess, err := openOrCreateSession(args, r, ag, version)
1108+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
11091109 if err != nil {
11101110 return err
11111111 }
@@ -1738,7 +1738,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
17381738 var sessionTitlePending bool
17391739 if !args.NoSess && ag != nil {
17401740 var sessErr error
1741- sess, sessErr = openOrCreateSession(args, r, ag, version)
1741+ sess, sessErr = openOrCreateSession(ctx, args, r, ag, version)
17421742 if sessErr != nil {
17431743 return sessErr
17441744 }
@@ -1766,6 +1766,20 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
17661766 }
17671767 }
17681768 announceSession(extMgr, sess)
1769+ // Print the hint after the session close defer below and all terminal/TUI
1770+ // cleanup defers. Close removes a fresh empty session, so only a session
1771+ // that remains discoverable through the hinted command gets a hint.
1772+ defer func() {
1773+ if !iv.ExitedViaCtrlC() {
1774+ return
1775+ }
1776+ persistMu.Lock()
1777+ current := sess
1778+ persistMu.Unlock()
1779+ if resumeID := resumableSessionID(ZutHome(), current); resumeID != "" {
1780+ fmt.Print(resumeSessionHint(resumeID))
1781+ }
1782+ }()
17691783 defer func() {
17701784 persistMu.Lock()
17711785 defer persistMu.Unlock()
@@ -2634,6 +2648,34 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
26342648 return runErr
26352649 }
26362650
2651+func resumableSessionID(root string, sess *core.Session) string {
2652+ if sess == nil || sess.ID == "" || sess.Path == "" {
2653+ return ""
2654+ }
2655+ path, err := core.FindManagedSessionByID(context.Background(), root, sess.ID)
2656+ if err != nil || !sameSessionPath(path, sess.Path) {
2657+ return ""
2658+ }
2659+ snapshot, err := core.ReadSessionSnapshot(path)
2660+ if err != nil || len(snapshot.Messages) == 0 {
2661+ return ""
2662+ }
2663+ return sess.ID
2664+}
2665+
2666+func sameSessionPath(left, right string) bool {
2667+ leftAbs, leftErr := filepath.Abs(left)
2668+ rightAbs, rightErr := filepath.Abs(right)
2669+ if leftErr == nil && rightErr == nil {
2670+ return filepath.Clean(leftAbs) == filepath.Clean(rightAbs)
2671+ }
2672+ return filepath.Clean(left) == filepath.Clean(right)
2673+}
2674+
2675+func resumeSessionHint(id string) string {
2676+ return fmt.Sprintf("Resume this session with: zut --resume %s\n", id)
2677+}
2678+
26372679 func agentSessionsRoot(root string, args Args) string {
26382680 if args.AgentName == "" {
26392681 return root
@@ -2643,15 +2685,18 @@ func agentSessionsRoot(root string, args Args) string {
26432685
26442686 // openOrCreateSession returns a session for the run. sess may be nil
26452687 // with a nil error if session persistence is disabled.
2646-func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
2688+func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
26472689 if args.NoSess {
26482690 return nil, nil
26492691 }
26502692 // Sweep meta-only files left over from older zut versions (and from
2651- // any session that crashed before its first AppendMessage). Cheap;
2652- // reads the first few bytes of each file in the cwd's session dir.
2693+ // any session that crashed before its first AppendMessage) for the
2694+ // cwd-scoped picker paths. Explicit UUID lookup stays strict so it
2695+ // can report metadata failures instead of turning them into misses.
26532696 sessionsRoot := agentSessionsRoot(ZutHome(), args)
2654- core.PruneEmptySessions(sessionsRoot, args.CWD)
2697+ if args.ResumeSessionID == "" {
2698+ core.PruneEmptySessions(sessionsRoot, args.CWD)
2699+ }
26552700 var (
26562701 s *core.Session
26572702 msgs []provider.Message
@@ -2678,6 +2723,17 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
26782723 s, msgs, err = core.OpenSession(latest)
26792724 }
26802725 case args.Resume:
2726+ if args.ResumeSessionID != "" {
2727+ picked, lookupErr := core.FindManagedSessionByID(ctx, ZutHome(), args.ResumeSessionID)
2728+ if lookupErr != nil {
2729+ return nil, lookupErr
2730+ }
2731+ if picked == "" {
2732+ return nil, fmt.Errorf("session %q not found", args.ResumeSessionID)
2733+ }
2734+ s, msgs, err = core.OpenSession(picked)
2735+ break
2736+ }
26812737 picked, perr := pickSession(sessionsRoot, args.CWD)
26822738 if perr != nil {
26832739 return nil, perr
@@ -910,7 +910,7 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error)
910 }910 }
911 }()911 }()
912 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)912 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
913- sess, err := openOrCreateSession(args, r, ag, version)913+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
914 if err != nil {914 if err != nil {
915 return err915 return err
916 }916 }
@@ -988,7 +988,7 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error
988 }988 }
989 }()989 }()
990 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)990 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
991- sess, err := openOrCreateSession(args, r, ag, version)991+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
992 if err != nil {992 if err != nil {
993 return err993 return err
994 }994 }
@@ -1105,7 +1105,7 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error)
1105 }1105 }
1106 }()1106 }()
1107 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)1107 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1108- sess, err := openOrCreateSession(args, r, ag, version)1108+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
1109 if err != nil {1109 if err != nil {
1110 return err1110 return err
1111 }1111 }
@@ -1738,7 +1738,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1738 var sessionTitlePending bool1738 var sessionTitlePending bool
1739 if !args.NoSess && ag != nil {1739 if !args.NoSess && ag != nil {
1740 var sessErr error1740 var sessErr error
1741- sess, sessErr = openOrCreateSession(args, r, ag, version)1741+ sess, sessErr = openOrCreateSession(ctx, args, r, ag, version)
1742 if sessErr != nil {1742 if sessErr != nil {
1743 return sessErr1743 return sessErr
1744 }1744 }
@@ -1766,6 +1766,20 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1766 }1766 }
1767 }1767 }
1768 announceSession(extMgr, sess)1768 announceSession(extMgr, sess)
1769+ // Print the hint after the session close defer below and all terminal/TUI
1770+ // cleanup defers. Close removes a fresh empty session, so only a session
1771+ // that remains discoverable through the hinted command gets a hint.
1772+ defer func() {
1773+ if !iv.ExitedViaCtrlC() {
1774+ return
1775+ }
1776+ persistMu.Lock()
1777+ current := sess
1778+ persistMu.Unlock()
1779+ if resumeID := resumableSessionID(ZutHome(), current); resumeID != "" {
1780+ fmt.Print(resumeSessionHint(resumeID))
1781+ }
1782+ }()
1769 defer func() {1783 defer func() {
1770 persistMu.Lock()1784 persistMu.Lock()
1771 defer persistMu.Unlock()1785 defer persistMu.Unlock()
@@ -2634,6 +2648,34 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2634 return runErr2648 return runErr
2635 }2649 }
2636 2650
2651+func resumableSessionID(root string, sess *core.Session) string {
2652+ if sess == nil || sess.ID == "" || sess.Path == "" {
2653+ return ""
2654+ }
2655+ path, err := core.FindManagedSessionByID(context.Background(), root, sess.ID)
2656+ if err != nil || !sameSessionPath(path, sess.Path) {
2657+ return ""
2658+ }
2659+ snapshot, err := core.ReadSessionSnapshot(path)
2660+ if err != nil || len(snapshot.Messages) == 0 {
2661+ return ""
2662+ }
2663+ return sess.ID
2664+}
2665+
2666+func sameSessionPath(left, right string) bool {
2667+ leftAbs, leftErr := filepath.Abs(left)
2668+ rightAbs, rightErr := filepath.Abs(right)
2669+ if leftErr == nil && rightErr == nil {
2670+ return filepath.Clean(leftAbs) == filepath.Clean(rightAbs)
2671+ }
2672+ return filepath.Clean(left) == filepath.Clean(right)
2673+}
2674+
2675+func resumeSessionHint(id string) string {
2676+ return fmt.Sprintf("Resume this session with: zut --resume %s\n", id)
2677+}
2678+
2637 func agentSessionsRoot(root string, args Args) string {2679 func agentSessionsRoot(root string, args Args) string {
2638 if args.AgentName == "" {2680 if args.AgentName == "" {
2639 return root2681 return root
@@ -2643,15 +2685,18 @@ func agentSessionsRoot(root string, args Args) string {
2643 2685
2644 // openOrCreateSession returns a session for the run. sess may be nil2686 // openOrCreateSession returns a session for the run. sess may be nil
2645 // with a nil error if session persistence is disabled.2687 // with a nil error if session persistence is disabled.
2646-func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {2688+func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
2647 if args.NoSess {2689 if args.NoSess {
2648 return nil, nil2690 return nil, nil
2649 }2691 }
2650 // Sweep meta-only files left over from older zut versions (and from2692 // Sweep meta-only files left over from older zut versions (and from
2651- // any session that crashed before its first AppendMessage). Cheap;2693+ // any session that crashed before its first AppendMessage) for the
2652- // reads the first few bytes of each file in the cwd's session dir.2694+ // cwd-scoped picker paths. Explicit UUID lookup stays strict so it
2695+ // can report metadata failures instead of turning them into misses.
2653 sessionsRoot := agentSessionsRoot(ZutHome(), args)2696 sessionsRoot := agentSessionsRoot(ZutHome(), args)
2654- core.PruneEmptySessions(sessionsRoot, args.CWD)2697+ if args.ResumeSessionID == "" {
2698+ core.PruneEmptySessions(sessionsRoot, args.CWD)
2699+ }
2655 var (2700 var (
2656 s *core.Session2701 s *core.Session
2657 msgs []provider.Message2702 msgs []provider.Message
@@ -2678,6 +2723,17 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
2678 s, msgs, err = core.OpenSession(latest)2723 s, msgs, err = core.OpenSession(latest)
2679 }2724 }
2680 case args.Resume:2725 case args.Resume:
2726+ if args.ResumeSessionID != "" {
2727+ picked, lookupErr := core.FindManagedSessionByID(ctx, ZutHome(), args.ResumeSessionID)
2728+ if lookupErr != nil {
2729+ return nil, lookupErr
2730+ }
2731+ if picked == "" {
2732+ return nil, fmt.Errorf("session %q not found", args.ResumeSessionID)
2733+ }
2734+ s, msgs, err = core.OpenSession(picked)
2735+ break
2736+ }
2681 picked, perr := pickSession(sessionsRoot, args.CWD)2737 picked, perr := pickSession(sessionsRoot, args.CWD)
2682 if perr != nil {2738 if perr != nil {
2683 return nil, perr2739 return nil, perr
M packages/agent/cli_session_test.go +223 -0
@@ -6,6 +6,8 @@ import (
66 "errors"
77 "fmt"
88 "os"
9+ "path/filepath"
10+ "strings"
911 "sync"
1012 "testing"
1113 "time"
@@ -13,8 +15,229 @@ import (
1315 "github.com/bnema/zut/packages/agent/modes"
1416 "github.com/bnema/zut/packages/core"
1517 "github.com/bnema/zut/packages/provider"
18+ "github.com/google/uuid"
1619 )
1720
21+func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) {
22+ root := t.TempDir()
23+ cwd := t.TempDir()
24+ otherCWD := t.TempDir()
25+ t.Setenv("ZUT_HOME", root)
26+
27+ session, err := core.NewSession(root, cwd, "provider", "model", "test")
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ if err := session.AppendMessage(provider.Message{
32+ Role: provider.RoleUser,
33+ Content: []provider.Content{provider.TextBlock{Text: "resume me"}},
34+ }); err != nil {
35+ t.Fatal(err)
36+ }
37+ id := session.ID
38+ if err := session.Close(); err != nil {
39+ t.Fatal(err)
40+ }
41+
42+ ag := core.NewAgent(nil, "model", "", nil)
43+ got, err := openOrCreateSession(context.Background(), Args{
44+ CWD: otherCWD,
45+ Resume: true,
46+ ResumeSessionID: id,
47+ }, Resolved{Provider: "provider", Model: "model"}, ag, "test")
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ if got == nil || got.ID != id || got.Meta.CWD != cwd {
52+ t.Fatalf("resumed session = %#v, want id=%q cwd=%q", got, id, cwd)
53+ }
54+ if got := firstMessageText(ag.Messages()); got != "resume me" {
55+ t.Fatalf("resumed message = %q, want resume me", got)
56+ }
57+ if err := got.Close(); err != nil {
58+ t.Fatal(err)
59+ }
60+
61+ namedRoot := filepath.Join(root, "sessions", "agents", "demo")
62+ named, err := core.NewSession(namedRoot, cwd, "provider", "model", "test")
63+ if err != nil {
64+ t.Fatal(err)
65+ }
66+ if err := named.AppendMessage(provider.Message{
67+ Role: provider.RoleUser,
68+ Content: []provider.Content{provider.TextBlock{Text: "named resume me"}},
69+ }); err != nil {
70+ t.Fatal(err)
71+ }
72+ namedID := named.ID
73+ if err := named.Close(); err != nil {
74+ t.Fatal(err)
75+ }
76+
77+ namedAgent := core.NewAgent(nil, "model", "", nil)
78+ namedGot, err := openOrCreateSession(context.Background(), Args{
79+ CWD: otherCWD,
80+ Resume: true,
81+ ResumeSessionID: namedID,
82+ }, Resolved{Provider: "provider", Model: "model"}, namedAgent, "test")
83+ if err != nil {
84+ t.Fatal(err)
85+ }
86+ if namedGot == nil || namedGot.ID != namedID || namedGot.Path != named.Path {
87+ t.Fatalf("named resumed session = %#v, want path %q", namedGot, named.Path)
88+ }
89+ if got := firstMessageText(namedAgent.Messages()); got != "named resume me" {
90+ t.Fatalf("named resumed message = %q, want named resume me", got)
91+ }
92+ if err := namedGot.Close(); err != nil {
93+ t.Fatal(err)
94+ }
95+}
96+
97+func TestResumeHintSuppressesEmptyAndArbitrarySessions(t *testing.T) {
98+ root := t.TempDir()
99+ cwd := t.TempDir()
100+
101+ empty, err := core.NewSession(root, cwd, "provider", "model", "test")
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ if err := empty.Close(); err != nil {
106+ t.Fatal(err)
107+ }
108+ if _, err := os.Stat(empty.Path); !os.IsNotExist(err) {
109+ t.Fatalf("empty session stat error = %v, want deleted", err)
110+ }
111+ if got := resumableSessionID(root, empty); got != "" {
112+ t.Fatalf("empty session hint ID = %q, want empty", got)
113+ }
114+
115+ arbitraryPath := filepath.Join(t.TempDir(), "session.jsonl")
116+ arbitrary, err := core.NewSessionAtPath(arbitraryPath, cwd, "provider", "model", "test")
117+ if err != nil {
118+ t.Fatal(err)
119+ }
120+ if err := arbitrary.AppendMessage(provider.Message{
121+ Role: provider.RoleUser,
122+ Content: []provider.Content{provider.TextBlock{Text: "arbitrary"}},
123+ }); err != nil {
124+ t.Fatal(err)
125+ }
126+ if err := arbitrary.Close(); err != nil {
127+ t.Fatal(err)
128+ }
129+ if got := resumableSessionID(root, arbitrary); got != "" {
130+ t.Fatalf("arbitrary session hint ID = %q, want empty", got)
131+ }
132+}
133+
134+func TestResumeHintSuppressesPersistedEmptyExplicitUUIDSession(t *testing.T) {
135+ root := t.TempDir()
136+ cwd := t.TempDir()
137+ otherCWD := t.TempDir()
138+ t.Setenv("ZUT_HOME", root)
139+
140+ dir := core.SessionsDir(root, cwd)
141+ if err := os.MkdirAll(dir, 0o755); err != nil {
142+ t.Fatal(err)
143+ }
144+ id := uuid.NewString()
145+ metaLine := struct {
146+ Type string `json:"type"`
147+ Meta core.SessionMeta `json:"meta"`
148+ }{
149+ Type: "meta",
150+ Meta: core.SessionMeta{
151+ ID: id,
152+ CWD: cwd,
153+ Provider: "provider",
154+ Model: "model",
155+ Started: time.Now().UTC(),
156+ Version: "test",
157+ },
158+ }
159+ data, err := json.Marshal(metaLine)
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ path := filepath.Join(dir, "persisted-empty.jsonl")
164+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
165+ t.Fatal(err)
166+ }
167+
168+ sess, err := openOrCreateSession(context.Background(), Args{
169+ CWD: otherCWD,
170+ Resume: true,
171+ ResumeSessionID: id,
172+ }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
173+ if err != nil {
174+ t.Fatal(err)
175+ }
176+ if sess == nil || sess.Path != path {
177+ t.Fatalf("resumed persisted empty session = %#v, want path %q", sess, path)
178+ }
179+ if got := resumableSessionID(root, sess); got != "" {
180+ t.Fatalf("persisted empty session hint ID = %q, want empty", got)
181+ }
182+ if err := sess.Close(); err != nil {
183+ t.Fatal(err)
184+ }
185+}
186+
187+func TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) {
188+ root := t.TempDir()
189+ cwd := t.TempDir()
190+ t.Setenv("ZUT_HOME", root)
191+ id := uuid.NewString()
192+
193+ _, err := openOrCreateSession(context.Background(), Args{
194+ CWD: cwd,
195+ Resume: true,
196+ ResumeSessionID: id,
197+ }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
198+ if err == nil {
199+ t.Fatal("unknown UUID resume unexpectedly succeeded")
200+ }
201+ if !strings.Contains(err.Error(), id) {
202+ t.Fatalf("unknown UUID error = %v, want session ID", err)
203+ }
204+ if sessions := core.ListSessions(root, cwd); len(sessions) != 0 {
205+ t.Fatalf("unknown UUID created sessions: %v", sessions)
206+ }
207+}
208+
209+func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) {
210+ root := t.TempDir()
211+ cwd := t.TempDir()
212+ t.Setenv("ZUT_HOME", root)
213+ if err := os.MkdirAll(core.SessionsDir(root, cwd), 0o755); err != nil {
214+ t.Fatal(err)
215+ }
216+ if err := os.WriteFile(filepath.Join(core.SessionsDir(root, cwd), "bad.jsonl"), []byte("not json\n"), 0o644); err != nil {
217+ t.Fatal(err)
218+ }
219+
220+ _, err := openOrCreateSession(context.Background(), Args{
221+ CWD: cwd,
222+ Resume: true,
223+ ResumeSessionID: uuid.NewString(),
224+ }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
225+ if err == nil {
226+ t.Fatal("malformed UUID lookup unexpectedly succeeded")
227+ }
228+ if !strings.Contains(err.Error(), "metadata") || strings.Contains(err.Error(), "not found") {
229+ t.Fatalf("malformed UUID lookup error = %v, want strict metadata error", err)
230+ }
231+}
232+
233+func TestResumeSessionHint(t *testing.T) {
234+ id := uuid.NewString()
235+ want := "Resume this session with: zut --resume " + id + "\n"
236+ if got := resumeSessionHint(id); got != want {
237+ t.Fatalf("resume hint = %q, want %q", got, want)
238+ }
239+}
240+
18241 func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {
19242 msgs := make([]provider.Message, 0, 101)
20243 msgs = append(msgs, provider.Message{
@@ -6,6 +6,8 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "os"8 "os"
9+ "path/filepath"
10+ "strings"
9 "sync"11 "sync"
10 "testing"12 "testing"
11 "time"13 "time"
@@ -13,8 +15,229 @@ import (
13 "github.com/bnema/zut/packages/agent/modes"15 "github.com/bnema/zut/packages/agent/modes"
14 "github.com/bnema/zut/packages/core"16 "github.com/bnema/zut/packages/core"
15 "github.com/bnema/zut/packages/provider"17 "github.com/bnema/zut/packages/provider"
18+ "github.com/google/uuid"
16 )19 )
17 20
21+func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) {
22+ root := t.TempDir()
23+ cwd := t.TempDir()
24+ otherCWD := t.TempDir()
25+ t.Setenv("ZUT_HOME", root)
26+
27+ session, err := core.NewSession(root, cwd, "provider", "model", "test")
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ if err := session.AppendMessage(provider.Message{
32+ Role: provider.RoleUser,
33+ Content: []provider.Content{provider.TextBlock{Text: "resume me"}},
34+ }); err != nil {
35+ t.Fatal(err)
36+ }
37+ id := session.ID
38+ if err := session.Close(); err != nil {
39+ t.Fatal(err)
40+ }
41+
42+ ag := core.NewAgent(nil, "model", "", nil)
43+ got, err := openOrCreateSession(context.Background(), Args{
44+ CWD: otherCWD,
45+ Resume: true,
46+ ResumeSessionID: id,
47+ }, Resolved{Provider: "provider", Model: "model"}, ag, "test")
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ if got == nil || got.ID != id || got.Meta.CWD != cwd {
52+ t.Fatalf("resumed session = %#v, want id=%q cwd=%q", got, id, cwd)
53+ }
54+ if got := firstMessageText(ag.Messages()); got != "resume me" {
55+ t.Fatalf("resumed message = %q, want resume me", got)
56+ }
57+ if err := got.Close(); err != nil {
58+ t.Fatal(err)
59+ }
60+
61+ namedRoot := filepath.Join(root, "sessions", "agents", "demo")
62+ named, err := core.NewSession(namedRoot, cwd, "provider", "model", "test")
63+ if err != nil {
64+ t.Fatal(err)
65+ }
66+ if err := named.AppendMessage(provider.Message{
67+ Role: provider.RoleUser,
68+ Content: []provider.Content{provider.TextBlock{Text: "named resume me"}},
69+ }); err != nil {
70+ t.Fatal(err)
71+ }
72+ namedID := named.ID
73+ if err := named.Close(); err != nil {
74+ t.Fatal(err)
75+ }
76+
77+ namedAgent := core.NewAgent(nil, "model", "", nil)
78+ namedGot, err := openOrCreateSession(context.Background(), Args{
79+ CWD: otherCWD,
80+ Resume: true,
81+ ResumeSessionID: namedID,
82+ }, Resolved{Provider: "provider", Model: "model"}, namedAgent, "test")
83+ if err != nil {
84+ t.Fatal(err)
85+ }
86+ if namedGot == nil || namedGot.ID != namedID || namedGot.Path != named.Path {
87+ t.Fatalf("named resumed session = %#v, want path %q", namedGot, named.Path)
88+ }
89+ if got := firstMessageText(namedAgent.Messages()); got != "named resume me" {
90+ t.Fatalf("named resumed message = %q, want named resume me", got)
91+ }
92+ if err := namedGot.Close(); err != nil {
93+ t.Fatal(err)
94+ }
95+}
96+
97+func TestResumeHintSuppressesEmptyAndArbitrarySessions(t *testing.T) {
98+ root := t.TempDir()
99+ cwd := t.TempDir()
100+
101+ empty, err := core.NewSession(root, cwd, "provider", "model", "test")
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ if err := empty.Close(); err != nil {
106+ t.Fatal(err)
107+ }
108+ if _, err := os.Stat(empty.Path); !os.IsNotExist(err) {
109+ t.Fatalf("empty session stat error = %v, want deleted", err)
110+ }
111+ if got := resumableSessionID(root, empty); got != "" {
112+ t.Fatalf("empty session hint ID = %q, want empty", got)
113+ }
114+
115+ arbitraryPath := filepath.Join(t.TempDir(), "session.jsonl")
116+ arbitrary, err := core.NewSessionAtPath(arbitraryPath, cwd, "provider", "model", "test")
117+ if err != nil {
118+ t.Fatal(err)
119+ }
120+ if err := arbitrary.AppendMessage(provider.Message{
121+ Role: provider.RoleUser,
122+ Content: []provider.Content{provider.TextBlock{Text: "arbitrary"}},
123+ }); err != nil {
124+ t.Fatal(err)
125+ }
126+ if err := arbitrary.Close(); err != nil {
127+ t.Fatal(err)
128+ }
129+ if got := resumableSessionID(root, arbitrary); got != "" {
130+ t.Fatalf("arbitrary session hint ID = %q, want empty", got)
131+ }
132+}
133+
134+func TestResumeHintSuppressesPersistedEmptyExplicitUUIDSession(t *testing.T) {
135+ root := t.TempDir()
136+ cwd := t.TempDir()
137+ otherCWD := t.TempDir()
138+ t.Setenv("ZUT_HOME", root)
139+
140+ dir := core.SessionsDir(root, cwd)
141+ if err := os.MkdirAll(dir, 0o755); err != nil {
142+ t.Fatal(err)
143+ }
144+ id := uuid.NewString()
145+ metaLine := struct {
146+ Type string `json:"type"`
147+ Meta core.SessionMeta `json:"meta"`
148+ }{
149+ Type: "meta",
150+ Meta: core.SessionMeta{
151+ ID: id,
152+ CWD: cwd,
153+ Provider: "provider",
154+ Model: "model",
155+ Started: time.Now().UTC(),
156+ Version: "test",
157+ },
158+ }
159+ data, err := json.Marshal(metaLine)
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ path := filepath.Join(dir, "persisted-empty.jsonl")
164+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
165+ t.Fatal(err)
166+ }
167+
168+ sess, err := openOrCreateSession(context.Background(), Args{
169+ CWD: otherCWD,
170+ Resume: true,
171+ ResumeSessionID: id,
172+ }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
173+ if err != nil {
174+ t.Fatal(err)
175+ }
176+ if sess == nil || sess.Path != path {
177+ t.Fatalf("resumed persisted empty session = %#v, want path %q", sess, path)
178+ }
179+ if got := resumableSessionID(root, sess); got != "" {
180+ t.Fatalf("persisted empty session hint ID = %q, want empty", got)
181+ }
182+ if err := sess.Close(); err != nil {
183+ t.Fatal(err)
184+ }
185+}
186+
187+func TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) {
188+ root := t.TempDir()
189+ cwd := t.TempDir()
190+ t.Setenv("ZUT_HOME", root)
191+ id := uuid.NewString()
192+
193+ _, err := openOrCreateSession(context.Background(), Args{
194+ CWD: cwd,
195+ Resume: true,
196+ ResumeSessionID: id,
197+ }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
198+ if err == nil {
199+ t.Fatal("unknown UUID resume unexpectedly succeeded")
200+ }
201+ if !strings.Contains(err.Error(), id) {
202+ t.Fatalf("unknown UUID error = %v, want session ID", err)
203+ }
204+ if sessions := core.ListSessions(root, cwd); len(sessions) != 0 {
205+ t.Fatalf("unknown UUID created sessions: %v", sessions)
206+ }
207+}
208+
209+func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) {
210+ root := t.TempDir()
211+ cwd := t.TempDir()
212+ t.Setenv("ZUT_HOME", root)
213+ if err := os.MkdirAll(core.SessionsDir(root, cwd), 0o755); err != nil {
214+ t.Fatal(err)
215+ }
216+ if err := os.WriteFile(filepath.Join(core.SessionsDir(root, cwd), "bad.jsonl"), []byte("not json\n"), 0o644); err != nil {
217+ t.Fatal(err)
218+ }
219+
220+ _, err := openOrCreateSession(context.Background(), Args{
221+ CWD: cwd,
222+ Resume: true,
223+ ResumeSessionID: uuid.NewString(),
224+ }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
225+ if err == nil {
226+ t.Fatal("malformed UUID lookup unexpectedly succeeded")
227+ }
228+ if !strings.Contains(err.Error(), "metadata") || strings.Contains(err.Error(), "not found") {
229+ t.Fatalf("malformed UUID lookup error = %v, want strict metadata error", err)
230+ }
231+}
232+
233+func TestResumeSessionHint(t *testing.T) {
234+ id := uuid.NewString()
235+ want := "Resume this session with: zut --resume " + id + "\n"
236+ if got := resumeSessionHint(id); got != want {
237+ t.Fatalf("resume hint = %q, want %q", got, want)
238+ }
239+}
240+
18 func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {241 func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {
19 msgs := make([]provider.Message, 0, 101)242 msgs := make([]provider.Message, 0, 101)
20 msgs = append(msgs, provider.Message{243 msgs = append(msgs, provider.Message{
M packages/agent/modes/double_escape_test.go +17 -0
@@ -10,6 +10,23 @@ import (
1010 "github.com/bnema/zut/packages/tui"
1111 )
1212
13+func TestInteractiveCtrlCExitMarker(t *testing.T) {
14+ i := NewInteractive(InteractiveConfig{})
15+ key := tui.Key{Kind: tui.KeyCtrlC}
16+ if i.handleKey(context.Background(), key) {
17+ t.Fatal("first Ctrl+C exited interactive mode")
18+ }
19+ if i.ExitedViaCtrlC() {
20+ t.Fatal("first Ctrl+C marked an exit")
21+ }
22+ if !i.handleKey(context.Background(), key) {
23+ t.Fatal("second Ctrl+C did not exit interactive mode")
24+ }
25+ if !i.ExitedViaCtrlC() {
26+ t.Fatal("Ctrl+C exit was not marked")
27+ }
28+}
29+
1330 func TestDoubleEscapeTrackerWindowAndReset(t *testing.T) {
1431 base := time.Unix(100, 0)
1532 var tracker doubleEscapeTracker
@@ -10,6 +10,23 @@ import (
10 "github.com/bnema/zut/packages/tui"10 "github.com/bnema/zut/packages/tui"
11 )11 )
12 12
13+func TestInteractiveCtrlCExitMarker(t *testing.T) {
14+ i := NewInteractive(InteractiveConfig{})
15+ key := tui.Key{Kind: tui.KeyCtrlC}
16+ if i.handleKey(context.Background(), key) {
17+ t.Fatal("first Ctrl+C exited interactive mode")
18+ }
19+ if i.ExitedViaCtrlC() {
20+ t.Fatal("first Ctrl+C marked an exit")
21+ }
22+ if !i.handleKey(context.Background(), key) {
23+ t.Fatal("second Ctrl+C did not exit interactive mode")
24+ }
25+ if !i.ExitedViaCtrlC() {
26+ t.Fatal("Ctrl+C exit was not marked")
27+ }
28+}
29+
13 func TestDoubleEscapeTrackerWindowAndReset(t *testing.T) {30 func TestDoubleEscapeTrackerWindowAndReset(t *testing.T) {
14 base := time.Unix(100, 0)31 base := time.Unix(100, 0)
15 var tracker doubleEscapeTracker32 var tracker doubleEscapeTracker
M packages/agent/modes/interactive.go +19 -0
@@ -667,6 +667,7 @@ type Interactive struct {
667667 cumUsage provider.Usage
668668 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
669669 busy bool
670+ ctrlCExit bool
670671 activity agentActivity
671672 subagentActivityActive bool
672673 pendingIdleWork []func()
@@ -1008,6 +1009,21 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
10081009 return i
10091010 }
10101011
1012+// ExitedViaCtrlC reports whether the last Run returned because the user
1013+// deliberately exited with Ctrl+C.
1014+func (i *Interactive) ExitedViaCtrlC() bool {
1015+ i.mu.Lock()
1016+ defer i.mu.Unlock()
1017+ return i.ctrlCExit
1018+}
1019+
1020+// markCtrlCExit records that the current Run is ending via Ctrl+C.
1021+func (i *Interactive) markCtrlCExit() {
1022+ i.mu.Lock()
1023+ i.ctrlCExit = true
1024+ i.mu.Unlock()
1025+}
1026+
10111027 // Run blocks until the user quits.
10121028 func (i *Interactive) Run(ctx context.Context) error {
10131029 i.runCtx = ctx
@@ -3176,6 +3192,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
31763192 loadingSession := i.sessionLoading
31773193 i.mu.Unlock()
31783194 if loadingSession {
3195+ i.markCtrlCExit()
31793196 return true
31803197 }
31813198 // While busy: do NOT cancel the turn. ctrl+c during a
@@ -3188,6 +3205,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
31883205 // ctrlCExitWindow quits.
31893206 if i.busy {
31903207 if i.ctrlCExitArmed() {
3208+ i.markCtrlCExit()
31913209 return true
31923210 }
31933211 i.mu.Lock()
@@ -3223,6 +3241,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
32233241 return false
32243242 }
32253243 if i.ctrlCExitArmed() {
3244+ i.markCtrlCExit()
32263245 return true
32273246 }
32283247 i.mu.Lock()
@@ -667,6 +667,7 @@ type Interactive struct {
667 cumUsage provider.Usage667 cumUsage provider.Usage
668 lastCtxInput int // input_tokens of the most recent turn — approximates current context size668 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
669 busy bool669 busy bool
670+ ctrlCExit bool
670 activity agentActivity671 activity agentActivity
671 subagentActivityActive bool672 subagentActivityActive bool
672 pendingIdleWork []func()673 pendingIdleWork []func()
@@ -1008,6 +1009,21 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
1008 return i1009 return i
1009 }1010 }
1010 1011
1012+// ExitedViaCtrlC reports whether the last Run returned because the user
1013+// deliberately exited with Ctrl+C.
1014+func (i *Interactive) ExitedViaCtrlC() bool {
1015+ i.mu.Lock()
1016+ defer i.mu.Unlock()
1017+ return i.ctrlCExit
1018+}
1019+
1020+// markCtrlCExit records that the current Run is ending via Ctrl+C.
1021+func (i *Interactive) markCtrlCExit() {
1022+ i.mu.Lock()
1023+ i.ctrlCExit = true
1024+ i.mu.Unlock()
1025+}
1026+
1011 // Run blocks until the user quits.1027 // Run blocks until the user quits.
1012 func (i *Interactive) Run(ctx context.Context) error {1028 func (i *Interactive) Run(ctx context.Context) error {
1013 i.runCtx = ctx1029 i.runCtx = ctx
@@ -3176,6 +3192,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3176 loadingSession := i.sessionLoading3192 loadingSession := i.sessionLoading
3177 i.mu.Unlock()3193 i.mu.Unlock()
3178 if loadingSession {3194 if loadingSession {
3195+ i.markCtrlCExit()
3179 return true3196 return true
3180 }3197 }
3181 // While busy: do NOT cancel the turn. ctrl+c during a3198 // While busy: do NOT cancel the turn. ctrl+c during a
@@ -3188,6 +3205,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3188 // ctrlCExitWindow quits.3205 // ctrlCExitWindow quits.
3189 if i.busy {3206 if i.busy {
3190 if i.ctrlCExitArmed() {3207 if i.ctrlCExitArmed() {
3208+ i.markCtrlCExit()
3191 return true3209 return true
3192 }3210 }
3193 i.mu.Lock()3211 i.mu.Lock()
@@ -3223,6 +3241,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3223 return false3241 return false
3224 }3242 }
3225 if i.ctrlCExitArmed() {3243 if i.ctrlCExitArmed() {
3244+ i.markCtrlCExit()
3226 return true3245 return true
3227 }3246 }
3228 i.mu.Lock()3247 i.mu.Lock()
M packages/agent/subagent_worker.go +1 -1
@@ -124,7 +124,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
124124 }
125125 }()
126126 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
127- sess, err := openOrCreateSession(args, r, ag, version)
127+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
128128 if err != nil {
129129 return err
130130 }
@@ -124,7 +124,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
124 }124 }
125 }()125 }()
126 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)126 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
127- sess, err := openOrCreateSession(args, r, ag, version)127+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
128 if err != nil {128 if err != nil {
129 return err129 return err
130 }130 }
M packages/core/session_portable.go +112 -1
@@ -3,6 +3,7 @@ package core
33 import (
44 "bufio"
55 "bytes"
6+ "context"
67 "encoding/json"
78 "errors"
89 "fmt"
@@ -803,6 +804,13 @@ func linkSessionTreeNodes(nodes map[string]*TreeNode, order []string) []*TreeNod
803804 // small projection; full snapshot validation remains the responsibility of
804805 // resume, export, branching, and tree preflight callers.
805806 func scanSessionMeta(path string) (meta SessionMeta, err error) {
807+ return scanSessionMetaContext(context.Background(), path)
808+}
809+
810+func scanSessionMetaContext(ctx context.Context, path string) (meta SessionMeta, err error) {
811+ if err := contextErr(ctx); err != nil {
812+ return SessionMeta{}, err
813+ }
806814 f, err := os.Open(path)
807815 if err != nil {
808816 return SessionMeta{}, err
@@ -810,7 +818,7 @@ func scanSessionMeta(path string) (meta SessionMeta, err error) {
810818 defer f.Close()
811819
812820 var sawMeta bool
813- err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
821+ err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error {
814822 var head sessionLineHead
815823 if err := json.Unmarshal(line, &head); err != nil {
816824 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
@@ -882,6 +890,109 @@ func FindSessionByID(root, cwd, id string) string {
882890 return ""
883891 }
884892
893+// FindManagedSessionByID looks up a session UUID across zut's managed
894+// session stores, independent of cwd. A nil error and empty path mean no
895+// matching session was found. Read, storage, metadata, and duplicate-ID
896+// failures are returned to the caller instead of being reported as a miss.
897+func FindManagedSessionByID(ctx context.Context, root, id string) (string, error) {
898+ if strings.TrimSpace(root) == "" {
899+ return "", errors.New("find managed session: root is empty")
900+ }
901+ if strings.TrimSpace(id) == "" {
902+ return "", errors.New("find managed session: id is empty")
903+ }
904+
905+ stores := []string{root}
906+ agentsDir := filepath.Join(root, "sessions", "agents")
907+ agents, err := os.ReadDir(agentsDir)
908+ if err != nil && !errors.Is(err, os.ErrNotExist) {
909+ return "", fmt.Errorf("find managed session: read agent stores: %w", err)
910+ }
911+ if err == nil {
912+ for _, entry := range agents {
913+ if entry.IsDir() {
914+ stores = append(stores, filepath.Join(agentsDir, entry.Name()))
915+ }
916+ }
917+ }
918+
919+ var matches []string
920+ for _, store := range stores {
921+ if err := contextErr(ctx); err != nil {
922+ return "", err
923+ }
924+ if err := collectManagedSessionMatches(ctx, store, id, &matches); err != nil {
925+ return "", err
926+ }
927+ }
928+ if err := contextErr(ctx); err != nil {
929+ return "", err
930+ }
931+ if len(matches) == 0 {
932+ return "", nil
933+ }
934+ if len(matches) > 1 {
935+ return "", fmt.Errorf("find managed session: UUID %q is ambiguous", id)
936+ }
937+ return matches[0], nil
938+}
939+
940+func collectManagedSessionMatches(ctx context.Context, root, id string, matches *[]string) error {
941+ if err := contextErr(ctx); err != nil {
942+ return err
943+ }
944+ dir := filepath.Join(root, "sessions")
945+ entries, err := os.ReadDir(dir)
946+ if errors.Is(err, os.ErrNotExist) {
947+ return nil
948+ }
949+ if err != nil {
950+ return fmt.Errorf("find managed session: read %q: %w", dir, err)
951+ }
952+ for _, bucket := range entries {
953+ if err := contextErr(ctx); err != nil {
954+ return err
955+ }
956+ if !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) {
957+ continue
958+ }
959+ bucketPath := filepath.Join(dir, bucket.Name())
960+ files, err := os.ReadDir(bucketPath)
961+ if err != nil {
962+ return fmt.Errorf("find managed session: read %q: %w", bucketPath, err)
963+ }
964+ for _, file := range files {
965+ if err := contextErr(ctx); err != nil {
966+ return err
967+ }
968+ if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") {
969+ continue
970+ }
971+ path := filepath.Join(bucketPath, file.Name())
972+ meta, err := scanSessionMetaContext(ctx, path)
973+ if err != nil {
974+ return fmt.Errorf("find managed session: read metadata %q: %w", path, err)
975+ }
976+ if meta.ID == id {
977+ *matches = append(*matches, path)
978+ }
979+ }
980+ }
981+ return nil
982+}
983+
984+func isSessionBucketName(name string) bool {
985+ if len(name) != 16 {
986+ return false
987+ }
988+ for _, r := range name {
989+ if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
990+ return false
991+ }
992+ }
993+ return true
994+}
995+
885996 // firstUserPrompt scans forward from the current source position
886997 // looking for the first user-role message and returns its text.
887998 // Used to build a humane export filename. Uses Reader instead of
@@ -3,6 +3,7 @@ package core
3 import (3 import (
4 "bufio"4 "bufio"
5 "bytes"5 "bytes"
6+ "context"
6 "encoding/json"7 "encoding/json"
7 "errors"8 "errors"
8 "fmt"9 "fmt"
@@ -803,6 +804,13 @@ func linkSessionTreeNodes(nodes map[string]*TreeNode, order []string) []*TreeNod
803 // small projection; full snapshot validation remains the responsibility of804 // small projection; full snapshot validation remains the responsibility of
804 // resume, export, branching, and tree preflight callers.805 // resume, export, branching, and tree preflight callers.
805 func scanSessionMeta(path string) (meta SessionMeta, err error) {806 func scanSessionMeta(path string) (meta SessionMeta, err error) {
807+ return scanSessionMetaContext(context.Background(), path)
808+}
809+
810+func scanSessionMetaContext(ctx context.Context, path string) (meta SessionMeta, err error) {
811+ if err := contextErr(ctx); err != nil {
812+ return SessionMeta{}, err
813+ }
806 f, err := os.Open(path)814 f, err := os.Open(path)
807 if err != nil {815 if err != nil {
808 return SessionMeta{}, err816 return SessionMeta{}, err
@@ -810,7 +818,7 @@ func scanSessionMeta(path string) (meta SessionMeta, err error) {
810 defer f.Close()818 defer f.Close()
811 819
812 var sawMeta bool820 var sawMeta bool
813- err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {821+ err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error {
814 var head sessionLineHead822 var head sessionLineHead
815 if err := json.Unmarshal(line, &head); err != nil {823 if err := json.Unmarshal(line, &head); err != nil {
816 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)824 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
@@ -882,6 +890,109 @@ func FindSessionByID(root, cwd, id string) string {
882 return ""890 return ""
883 }891 }
884 892
893+// FindManagedSessionByID looks up a session UUID across zut's managed
894+// session stores, independent of cwd. A nil error and empty path mean no
895+// matching session was found. Read, storage, metadata, and duplicate-ID
896+// failures are returned to the caller instead of being reported as a miss.
897+func FindManagedSessionByID(ctx context.Context, root, id string) (string, error) {
898+ if strings.TrimSpace(root) == "" {
899+ return "", errors.New("find managed session: root is empty")
900+ }
901+ if strings.TrimSpace(id) == "" {
902+ return "", errors.New("find managed session: id is empty")
903+ }
904+
905+ stores := []string{root}
906+ agentsDir := filepath.Join(root, "sessions", "agents")
907+ agents, err := os.ReadDir(agentsDir)
908+ if err != nil && !errors.Is(err, os.ErrNotExist) {
909+ return "", fmt.Errorf("find managed session: read agent stores: %w", err)
910+ }
911+ if err == nil {
912+ for _, entry := range agents {
913+ if entry.IsDir() {
914+ stores = append(stores, filepath.Join(agentsDir, entry.Name()))
915+ }
916+ }
917+ }
918+
919+ var matches []string
920+ for _, store := range stores {
921+ if err := contextErr(ctx); err != nil {
922+ return "", err
923+ }
924+ if err := collectManagedSessionMatches(ctx, store, id, &matches); err != nil {
925+ return "", err
926+ }
927+ }
928+ if err := contextErr(ctx); err != nil {
929+ return "", err
930+ }
931+ if len(matches) == 0 {
932+ return "", nil
933+ }
934+ if len(matches) > 1 {
935+ return "", fmt.Errorf("find managed session: UUID %q is ambiguous", id)
936+ }
937+ return matches[0], nil
938+}
939+
940+func collectManagedSessionMatches(ctx context.Context, root, id string, matches *[]string) error {
941+ if err := contextErr(ctx); err != nil {
942+ return err
943+ }
944+ dir := filepath.Join(root, "sessions")
945+ entries, err := os.ReadDir(dir)
946+ if errors.Is(err, os.ErrNotExist) {
947+ return nil
948+ }
949+ if err != nil {
950+ return fmt.Errorf("find managed session: read %q: %w", dir, err)
951+ }
952+ for _, bucket := range entries {
953+ if err := contextErr(ctx); err != nil {
954+ return err
955+ }
956+ if !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) {
957+ continue
958+ }
959+ bucketPath := filepath.Join(dir, bucket.Name())
960+ files, err := os.ReadDir(bucketPath)
961+ if err != nil {
962+ return fmt.Errorf("find managed session: read %q: %w", bucketPath, err)
963+ }
964+ for _, file := range files {
965+ if err := contextErr(ctx); err != nil {
966+ return err
967+ }
968+ if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") {
969+ continue
970+ }
971+ path := filepath.Join(bucketPath, file.Name())
972+ meta, err := scanSessionMetaContext(ctx, path)
973+ if err != nil {
974+ return fmt.Errorf("find managed session: read metadata %q: %w", path, err)
975+ }
976+ if meta.ID == id {
977+ *matches = append(*matches, path)
978+ }
979+ }
980+ }
981+ return nil
982+}
983+
984+func isSessionBucketName(name string) bool {
985+ if len(name) != 16 {
986+ return false
987+ }
988+ for _, r := range name {
989+ if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
990+ return false
991+ }
992+ }
993+ return true
994+}
995+
885 // firstUserPrompt scans forward from the current source position996 // firstUserPrompt scans forward from the current source position
886 // looking for the first user-role message and returns its text.997 // looking for the first user-role message and returns its text.
887 // Used to build a humane export filename. Uses Reader instead of998 // Used to build a humane export filename. Uses Reader instead of
M packages/core/session_portable_test.go +47 -0
@@ -1,7 +1,9 @@
11 package core
22
33 import (
4+ "context"
45 "encoding/json"
6+ "errors"
57 "os"
68 "path/filepath"
79 "strings"
@@ -10,6 +12,51 @@ import (
1012 "github.com/bnema/zut/packages/provider"
1113 )
1214
15+func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(t *testing.T) {
16+ root := t.TempDir()
17+ cwd := t.TempDir()
18+ session, err := NewSession(root, cwd, "provider", "model", "test")
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ if err := session.AppendMessage(provider.Message{
23+ Role: provider.RoleUser,
24+ Content: []provider.Content{provider.TextBlock{Text: "managed"}},
25+ }); err != nil {
26+ t.Fatal(err)
27+ }
28+ if err := session.Close(); err != nil {
29+ t.Fatal(err)
30+ }
31+
32+ got, err := FindManagedSessionByID(context.Background(), root, session.ID)
33+ if err != nil {
34+ t.Fatalf("FindManagedSessionByID: %v", err)
35+ }
36+ if got != session.Path {
37+ t.Fatalf("managed session path = %q, want %q", got, session.Path)
38+ }
39+
40+ badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl")
41+ if err := os.WriteFile(badPath, []byte("not json\n"), 0o644); err != nil {
42+ t.Fatal(err)
43+ }
44+ if _, err := FindManagedSessionByID(context.Background(), root, "missing"); err == nil {
45+ t.Fatal("malformed managed metadata was reported as not found")
46+ } else if !strings.Contains(err.Error(), "metadata") {
47+ t.Fatalf("malformed metadata error = %v, want metadata context", err)
48+ }
49+}
50+
51+func TestFindManagedSessionByIDHonorsCancellation(t *testing.T) {
52+ ctx, cancel := context.WithCancel(context.Background())
53+ cancel()
54+
55+ if _, err := FindManagedSessionByID(ctx, t.TempDir(), "session-id"); !errors.Is(err, context.Canceled) {
56+ t.Fatalf("FindManagedSessionByID error = %v, want context.Canceled", err)
57+ }
58+}
59+
1360 // TestSessionExportImportRoundTrip writes a few messages to a live
1461 // session, exports it, imports the export under a different cwd,
1562 // and verifies OpenSession on the imported file yields the same
@@ -1,7 +1,9 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "context"
4 "encoding/json"5 "encoding/json"
6+ "errors"
5 "os"7 "os"
6 "path/filepath"8 "path/filepath"
7 "strings"9 "strings"
@@ -10,6 +12,51 @@ import (
10 "github.com/bnema/zut/packages/provider"12 "github.com/bnema/zut/packages/provider"
11 )13 )
12 14
15+func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(t *testing.T) {
16+ root := t.TempDir()
17+ cwd := t.TempDir()
18+ session, err := NewSession(root, cwd, "provider", "model", "test")
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ if err := session.AppendMessage(provider.Message{
23+ Role: provider.RoleUser,
24+ Content: []provider.Content{provider.TextBlock{Text: "managed"}},
25+ }); err != nil {
26+ t.Fatal(err)
27+ }
28+ if err := session.Close(); err != nil {
29+ t.Fatal(err)
30+ }
31+
32+ got, err := FindManagedSessionByID(context.Background(), root, session.ID)
33+ if err != nil {
34+ t.Fatalf("FindManagedSessionByID: %v", err)
35+ }
36+ if got != session.Path {
37+ t.Fatalf("managed session path = %q, want %q", got, session.Path)
38+ }
39+
40+ badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl")
41+ if err := os.WriteFile(badPath, []byte("not json\n"), 0o644); err != nil {
42+ t.Fatal(err)
43+ }
44+ if _, err := FindManagedSessionByID(context.Background(), root, "missing"); err == nil {
45+ t.Fatal("malformed managed metadata was reported as not found")
46+ } else if !strings.Contains(err.Error(), "metadata") {
47+ t.Fatalf("malformed metadata error = %v, want metadata context", err)
48+ }
49+}
50+
51+func TestFindManagedSessionByIDHonorsCancellation(t *testing.T) {
52+ ctx, cancel := context.WithCancel(context.Background())
53+ cancel()
54+
55+ if _, err := FindManagedSessionByID(ctx, t.TempDir(), "session-id"); !errors.Is(err, context.Canceled) {
56+ t.Fatalf("FindManagedSessionByID error = %v, want context.Canceled", err)
57+ }
58+}
59+
13 // TestSessionExportImportRoundTrip writes a few messages to a live60 // TestSessionExportImportRoundTrip writes a few messages to a live
14 // session, exports it, imports the export under a different cwd,61 // session, exports it, imports the export under a different cwd,
15 // and verifies OpenSession on the imported file yields the same62 // and verifies OpenSession on the imported file yields the same

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close