detached 443b176

feat(session): resume sessions by UUID

commit
443b17656526c2be9e3f589493b23f45b243586d
author
brice · · 2026-08-08 06:11 UTC
parent
ab30a57
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +429 -9
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
@@ -195,6 +197,12 @@ func ParseArgs(in []string) (Args, error) {
195197 a.Continue = true
196198 case "-r", "--resume":
197199 a.Resume = true
200+ if i+1 < len(in) {
201+ if id, err := uuid.Parse(in[i+1]); err == nil {
202+ a.ResumeSessionID = id.String()
203+ i++
204+ }
205+ }
198206 case "--no-session":
199207 a.NoSess = true
200208 case "--no-tools":
@@ -510,7 +518,7 @@ func PrintHelp(version string) {
510518 row{"--system-prompt TEXT", "replace the default system prompt"},
511519 row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"},
512520 row{"-c, --continue", "continue the most recent session for this cwd"},
513- row{"-r, --resume", "pick a session to resume"},
521+ row{"-r, --resume [UUID]", "pick a session, or resume the persisted UUID"},
514522 row{"--session PATH", "resume a specific session file"},
515523 row{"--no-session", "do not read or write a session file"},
516524 )
@@ -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
@@ -195,6 +197,12 @@ func ParseArgs(in []string) (Args, error) {
195 a.Continue = true197 a.Continue = true
196 case "-r", "--resume":198 case "-r", "--resume":
197 a.Resume = true199 a.Resume = true
200+ if i+1 < len(in) {
201+ if id, err := uuid.Parse(in[i+1]); err == nil {
202+ a.ResumeSessionID = id.String()
203+ i++
204+ }
205+ }
198 case "--no-session":206 case "--no-session":
199 a.NoSess = true207 a.NoSess = true
200 case "--no-tools":208 case "--no-tools":
@@ -510,7 +518,7 @@ func PrintHelp(version string) {
510 row{"--system-prompt TEXT", "replace the default system prompt"},518 row{"--system-prompt TEXT", "replace the default system prompt"},
511 row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"},519 row{"--append-system-prompt TEXT", "append to the system prompt (repeatable)"},
512 row{"-c, --continue", "continue the most recent session for this cwd"},520 row{"-c, --continue", "continue the most recent session for this cwd"},
513- row{"-r, --resume", "pick a session to resume"},521+ row{"-r, --resume [UUID]", "pick a session, or resume the persisted UUID"},
514 row{"--session PATH", "resume a specific session file"},522 row{"--session PATH", "resume a specific session file"},
515 row{"--no-session", "do not read or write a session file"},523 row{"--no-session", "do not read or write a session file"},
516 )524 )
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 +55 -3
@@ -1744,6 +1744,20 @@ func runInteractive(ctx context.Context, args Args, version string) error {
17441744 }
17451745 }
17461746 announceSession(extMgr, sess)
1747+ // Print the hint after the session close defer below and all terminal/TUI
1748+ // cleanup defers. Close removes a fresh empty session, so only a session
1749+ // that remains discoverable through the hinted command gets a hint.
1750+ defer func() {
1751+ if !iv.ExitedViaCtrlC() {
1752+ return
1753+ }
1754+ persistMu.Lock()
1755+ current := sess
1756+ persistMu.Unlock()
1757+ if resumeID := resumableSessionID(ZutHome(), current); resumeID != "" {
1758+ fmt.Print(resumeSessionHint(resumeID))
1759+ }
1760+ }()
17471761 defer func() {
17481762 persistMu.Lock()
17491763 defer persistMu.Unlock()
@@ -2581,6 +2595,30 @@ func runInteractive(ctx context.Context, args Args, version string) error {
25812595 return runErr
25822596 }
25832597
2598+func resumableSessionID(root string, sess *core.Session) string {
2599+ if sess == nil || sess.ID == "" || sess.Path == "" {
2600+ return ""
2601+ }
2602+ path, err := core.FindManagedSessionByID(root, sess.ID)
2603+ if err != nil || !sameSessionPath(path, sess.Path) {
2604+ return ""
2605+ }
2606+ return sess.ID
2607+}
2608+
2609+func sameSessionPath(left, right string) bool {
2610+ leftAbs, leftErr := filepath.Abs(left)
2611+ rightAbs, rightErr := filepath.Abs(right)
2612+ if leftErr == nil && rightErr == nil {
2613+ return filepath.Clean(leftAbs) == filepath.Clean(rightAbs)
2614+ }
2615+ return filepath.Clean(left) == filepath.Clean(right)
2616+}
2617+
2618+func resumeSessionHint(id string) string {
2619+ return fmt.Sprintf("Resume this session with: zut --resume %s\n", id)
2620+}
2621+
25842622 func agentSessionsRoot(root string, args Args) string {
25852623 if args.AgentName == "" {
25862624 return root
@@ -2595,10 +2633,13 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
25952633 return nil, nil
25962634 }
25972635 // Sweep meta-only files left over from older zut versions (and from
2598- // any session that crashed before its first AppendMessage). Cheap;
2599- // reads the first few bytes of each file in the cwd's session dir.
2636+ // any session that crashed before its first AppendMessage) for the
2637+ // cwd-scoped picker paths. Explicit UUID lookup stays strict so it
2638+ // can report metadata failures instead of turning them into misses.
26002639 sessionsRoot := agentSessionsRoot(ZutHome(), args)
2601- core.PruneEmptySessions(sessionsRoot, args.CWD)
2640+ if args.ResumeSessionID == "" {
2641+ core.PruneEmptySessions(sessionsRoot, args.CWD)
2642+ }
26022643 var (
26032644 s *core.Session
26042645 msgs []provider.Message
@@ -2625,6 +2666,17 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
26252666 s, msgs, err = core.OpenSession(latest)
26262667 }
26272668 case args.Resume:
2669+ if args.ResumeSessionID != "" {
2670+ picked, lookupErr := core.FindManagedSessionByID(ZutHome(), args.ResumeSessionID)
2671+ if lookupErr != nil {
2672+ return nil, lookupErr
2673+ }
2674+ if picked == "" {
2675+ return nil, fmt.Errorf("session %q not found", args.ResumeSessionID)
2676+ }
2677+ s, msgs, err = core.OpenSession(picked)
2678+ break
2679+ }
26282680 picked, perr := pickSession(sessionsRoot, args.CWD)
26292681 if perr != nil {
26302682 return nil, perr
@@ -1744,6 +1744,20 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1744 }1744 }
1745 }1745 }
1746 announceSession(extMgr, sess)1746 announceSession(extMgr, sess)
1747+ // Print the hint after the session close defer below and all terminal/TUI
1748+ // cleanup defers. Close removes a fresh empty session, so only a session
1749+ // that remains discoverable through the hinted command gets a hint.
1750+ defer func() {
1751+ if !iv.ExitedViaCtrlC() {
1752+ return
1753+ }
1754+ persistMu.Lock()
1755+ current := sess
1756+ persistMu.Unlock()
1757+ if resumeID := resumableSessionID(ZutHome(), current); resumeID != "" {
1758+ fmt.Print(resumeSessionHint(resumeID))
1759+ }
1760+ }()
1747 defer func() {1761 defer func() {
1748 persistMu.Lock()1762 persistMu.Lock()
1749 defer persistMu.Unlock()1763 defer persistMu.Unlock()
@@ -2581,6 +2595,30 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2581 return runErr2595 return runErr
2582 }2596 }
2583 2597
2598+func resumableSessionID(root string, sess *core.Session) string {
2599+ if sess == nil || sess.ID == "" || sess.Path == "" {
2600+ return ""
2601+ }
2602+ path, err := core.FindManagedSessionByID(root, sess.ID)
2603+ if err != nil || !sameSessionPath(path, sess.Path) {
2604+ return ""
2605+ }
2606+ return sess.ID
2607+}
2608+
2609+func sameSessionPath(left, right string) bool {
2610+ leftAbs, leftErr := filepath.Abs(left)
2611+ rightAbs, rightErr := filepath.Abs(right)
2612+ if leftErr == nil && rightErr == nil {
2613+ return filepath.Clean(leftAbs) == filepath.Clean(rightAbs)
2614+ }
2615+ return filepath.Clean(left) == filepath.Clean(right)
2616+}
2617+
2618+func resumeSessionHint(id string) string {
2619+ return fmt.Sprintf("Resume this session with: zut --resume %s\n", id)
2620+}
2621+
2584 func agentSessionsRoot(root string, args Args) string {2622 func agentSessionsRoot(root string, args Args) string {
2585 if args.AgentName == "" {2623 if args.AgentName == "" {
2586 return root2624 return root
@@ -2595,10 +2633,13 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
2595 return nil, nil2633 return nil, nil
2596 }2634 }
2597 // Sweep meta-only files left over from older zut versions (and from2635 // Sweep meta-only files left over from older zut versions (and from
2598- // any session that crashed before its first AppendMessage). Cheap;2636+ // any session that crashed before its first AppendMessage) for the
2599- // reads the first few bytes of each file in the cwd's session dir.2637+ // cwd-scoped picker paths. Explicit UUID lookup stays strict so it
2638+ // can report metadata failures instead of turning them into misses.
2600 sessionsRoot := agentSessionsRoot(ZutHome(), args)2639 sessionsRoot := agentSessionsRoot(ZutHome(), args)
2601- core.PruneEmptySessions(sessionsRoot, args.CWD)2640+ if args.ResumeSessionID == "" {
2641+ core.PruneEmptySessions(sessionsRoot, args.CWD)
2642+ }
2602 var (2643 var (
2603 s *core.Session2644 s *core.Session
2604 msgs []provider.Message2645 msgs []provider.Message
@@ -2625,6 +2666,17 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
2625 s, msgs, err = core.OpenSession(latest)2666 s, msgs, err = core.OpenSession(latest)
2626 }2667 }
2627 case args.Resume:2668 case args.Resume:
2669+ if args.ResumeSessionID != "" {
2670+ picked, lookupErr := core.FindManagedSessionByID(ZutHome(), args.ResumeSessionID)
2671+ if lookupErr != nil {
2672+ return nil, lookupErr
2673+ }
2674+ if picked == "" {
2675+ return nil, fmt.Errorf("session %q not found", args.ResumeSessionID)
2676+ }
2677+ s, msgs, err = core.OpenSession(picked)
2678+ break
2679+ }
2628 picked, perr := pickSession(sessionsRoot, args.CWD)2680 picked, perr := pickSession(sessionsRoot, args.CWD)
2629 if perr != nil {2681 if perr != nil {
2630 return nil, perr2682 return nil, perr
M packages/agent/cli_session_test.go +170 -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,176 @@ 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(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(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 TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) {
135+ root := t.TempDir()
136+ cwd := t.TempDir()
137+ t.Setenv("ZUT_HOME", root)
138+ id := uuid.NewString()
139+
140+ _, err := openOrCreateSession(Args{
141+ CWD: cwd,
142+ Resume: true,
143+ ResumeSessionID: id,
144+ }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
145+ if err == nil {
146+ t.Fatal("unknown UUID resume unexpectedly succeeded")
147+ }
148+ if !strings.Contains(err.Error(), id) {
149+ t.Fatalf("unknown UUID error = %v, want session ID", err)
150+ }
151+ if sessions := core.ListSessions(root, cwd); len(sessions) != 0 {
152+ t.Fatalf("unknown UUID created sessions: %v", sessions)
153+ }
154+}
155+
156+func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) {
157+ root := t.TempDir()
158+ cwd := t.TempDir()
159+ t.Setenv("ZUT_HOME", root)
160+ if err := os.MkdirAll(core.SessionsDir(root, cwd), 0o755); err != nil {
161+ t.Fatal(err)
162+ }
163+ if err := os.WriteFile(filepath.Join(core.SessionsDir(root, cwd), "bad.jsonl"), []byte("not json\n"), 0o644); err != nil {
164+ t.Fatal(err)
165+ }
166+
167+ _, err := openOrCreateSession(Args{
168+ CWD: cwd,
169+ Resume: true,
170+ ResumeSessionID: uuid.NewString(),
171+ }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
172+ if err == nil {
173+ t.Fatal("malformed UUID lookup unexpectedly succeeded")
174+ }
175+ if !strings.Contains(err.Error(), "metadata") || strings.Contains(err.Error(), "not found") {
176+ t.Fatalf("malformed UUID lookup error = %v, want strict metadata error", err)
177+ }
178+}
179+
180+func TestResumeSessionHint(t *testing.T) {
181+ id := uuid.NewString()
182+ want := "Resume this session with: zut --resume " + id + "\n"
183+ if got := resumeSessionHint(id); got != want {
184+ t.Fatalf("resume hint = %q, want %q", got, want)
185+ }
186+}
187+
18188 func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {
19189 msgs := make([]provider.Message, 0, 101)
20190 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,176 @@ 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(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(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 TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) {
135+ root := t.TempDir()
136+ cwd := t.TempDir()
137+ t.Setenv("ZUT_HOME", root)
138+ id := uuid.NewString()
139+
140+ _, err := openOrCreateSession(Args{
141+ CWD: cwd,
142+ Resume: true,
143+ ResumeSessionID: id,
144+ }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
145+ if err == nil {
146+ t.Fatal("unknown UUID resume unexpectedly succeeded")
147+ }
148+ if !strings.Contains(err.Error(), id) {
149+ t.Fatalf("unknown UUID error = %v, want session ID", err)
150+ }
151+ if sessions := core.ListSessions(root, cwd); len(sessions) != 0 {
152+ t.Fatalf("unknown UUID created sessions: %v", sessions)
153+ }
154+}
155+
156+func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) {
157+ root := t.TempDir()
158+ cwd := t.TempDir()
159+ t.Setenv("ZUT_HOME", root)
160+ if err := os.MkdirAll(core.SessionsDir(root, cwd), 0o755); err != nil {
161+ t.Fatal(err)
162+ }
163+ if err := os.WriteFile(filepath.Join(core.SessionsDir(root, cwd), "bad.jsonl"), []byte("not json\n"), 0o644); err != nil {
164+ t.Fatal(err)
165+ }
166+
167+ _, err := openOrCreateSession(Args{
168+ CWD: cwd,
169+ Resume: true,
170+ ResumeSessionID: uuid.NewString(),
171+ }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
172+ if err == nil {
173+ t.Fatal("malformed UUID lookup unexpectedly succeeded")
174+ }
175+ if !strings.Contains(err.Error(), "metadata") || strings.Contains(err.Error(), "not found") {
176+ t.Fatalf("malformed UUID lookup error = %v, want strict metadata error", err)
177+ }
178+}
179+
180+func TestResumeSessionHint(t *testing.T) {
181+ id := uuid.NewString()
182+ want := "Resume this session with: zut --resume " + id + "\n"
183+ if got := resumeSessionHint(id); got != want {
184+ t.Fatalf("resume hint = %q, want %q", got, want)
185+ }
186+}
187+
18 func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {188 func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {
19 msgs := make([]provider.Message, 0, 101)189 msgs := make([]provider.Message, 0, 101)
20 msgs = append(msgs, provider.Message{190 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
@@ -664,6 +664,7 @@ type Interactive struct {
664664 cumUsage provider.Usage
665665 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
666666 busy bool
667+ ctrlCExit bool
667668 activity agentActivity
668669 subagentActivityActive bool
669670 pendingIdleWork []func()
@@ -1009,6 +1010,21 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
10091010 return i
10101011 }
10111012
1013+// ExitedViaCtrlC reports whether the last Run returned because the user
1014+// deliberately exited with Ctrl+C.
1015+func (i *Interactive) ExitedViaCtrlC() bool {
1016+ i.mu.Lock()
1017+ defer i.mu.Unlock()
1018+ return i.ctrlCExit
1019+}
1020+
1021+// markCtrlCExit records that the current Run is ending via Ctrl+C.
1022+func (i *Interactive) markCtrlCExit() {
1023+ i.mu.Lock()
1024+ i.ctrlCExit = true
1025+ i.mu.Unlock()
1026+}
1027+
10121028 // Run blocks until the user quits.
10131029 func (i *Interactive) Run(ctx context.Context) error {
10141030 i.runCtx = ctx
@@ -3183,6 +3199,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
31833199 loadingSession := i.sessionLoading
31843200 i.mu.Unlock()
31853201 if loadingSession {
3202+ i.markCtrlCExit()
31863203 return true
31873204 }
31883205 // While busy: do NOT cancel the turn. ctrl+c during a
@@ -3195,6 +3212,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
31953212 // ctrlCExitWindow quits.
31963213 if i.busy {
31973214 if i.ctrlCExitArmed() {
3215+ i.markCtrlCExit()
31983216 return true
31993217 }
32003218 i.mu.Lock()
@@ -3230,6 +3248,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
32303248 return false
32313249 }
32323250 if i.ctrlCExitArmed() {
3251+ i.markCtrlCExit()
32333252 return true
32343253 }
32353254 i.mu.Lock()
@@ -664,6 +664,7 @@ type Interactive struct {
664 cumUsage provider.Usage664 cumUsage provider.Usage
665 lastCtxInput int // input_tokens of the most recent turn — approximates current context size665 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
666 busy bool666 busy bool
667+ ctrlCExit bool
667 activity agentActivity668 activity agentActivity
668 subagentActivityActive bool669 subagentActivityActive bool
669 pendingIdleWork []func()670 pendingIdleWork []func()
@@ -1009,6 +1010,21 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
1009 return i1010 return i
1010 }1011 }
1011 1012
1013+// ExitedViaCtrlC reports whether the last Run returned because the user
1014+// deliberately exited with Ctrl+C.
1015+func (i *Interactive) ExitedViaCtrlC() bool {
1016+ i.mu.Lock()
1017+ defer i.mu.Unlock()
1018+ return i.ctrlCExit
1019+}
1020+
1021+// markCtrlCExit records that the current Run is ending via Ctrl+C.
1022+func (i *Interactive) markCtrlCExit() {
1023+ i.mu.Lock()
1024+ i.ctrlCExit = true
1025+ i.mu.Unlock()
1026+}
1027+
1012 // Run blocks until the user quits.1028 // Run blocks until the user quits.
1013 func (i *Interactive) Run(ctx context.Context) error {1029 func (i *Interactive) Run(ctx context.Context) error {
1014 i.runCtx = ctx1030 i.runCtx = ctx
@@ -3183,6 +3199,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3183 loadingSession := i.sessionLoading3199 loadingSession := i.sessionLoading
3184 i.mu.Unlock()3200 i.mu.Unlock()
3185 if loadingSession {3201 if loadingSession {
3202+ i.markCtrlCExit()
3186 return true3203 return true
3187 }3204 }
3188 // While busy: do NOT cancel the turn. ctrl+c during a3205 // While busy: do NOT cancel the turn. ctrl+c during a
@@ -3195,6 +3212,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3195 // ctrlCExitWindow quits.3212 // ctrlCExitWindow quits.
3196 if i.busy {3213 if i.busy {
3197 if i.ctrlCExitArmed() {3214 if i.ctrlCExitArmed() {
3215+ i.markCtrlCExit()
3198 return true3216 return true
3199 }3217 }
3200 i.mu.Lock()3218 i.mu.Lock()
@@ -3230,6 +3248,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3230 return false3248 return false
3231 }3249 }
3232 if i.ctrlCExitArmed() {3250 if i.ctrlCExitArmed() {
3251+ i.markCtrlCExit()
3233 return true3252 return true
3234 }3253 }
3235 i.mu.Lock()3254 i.mu.Lock()
M packages/core/session_portable.go +88 -0
@@ -882,6 +882,94 @@ func FindSessionByID(root, cwd, id string) string {
882882 return ""
883883 }
884884
885+// FindManagedSessionByID looks up a session UUID across zut's managed
886+// session stores, independent of cwd. A nil error and empty path mean no
887+// matching session was found. Read, storage, metadata, and duplicate-ID
888+// failures are returned to the caller instead of being reported as a miss.
889+func FindManagedSessionByID(root, id string) (string, error) {
890+ if strings.TrimSpace(root) == "" {
891+ return "", errors.New("find managed session: root is empty")
892+ }
893+ if strings.TrimSpace(id) == "" {
894+ return "", errors.New("find managed session: id is empty")
895+ }
896+
897+ stores := []string{root}
898+ agentsDir := filepath.Join(root, "sessions", "agents")
899+ agents, err := os.ReadDir(agentsDir)
900+ if err != nil && !errors.Is(err, os.ErrNotExist) {
901+ return "", fmt.Errorf("find managed session: read agent stores: %w", err)
902+ }
903+ if err == nil {
904+ for _, entry := range agents {
905+ if entry.IsDir() {
906+ stores = append(stores, filepath.Join(agentsDir, entry.Name()))
907+ }
908+ }
909+ }
910+
911+ var matches []string
912+ for _, store := range stores {
913+ if err := collectManagedSessionMatches(store, id, &matches); err != nil {
914+ return "", err
915+ }
916+ }
917+ if len(matches) == 0 {
918+ return "", nil
919+ }
920+ if len(matches) > 1 {
921+ return "", fmt.Errorf("find managed session: UUID %q is ambiguous", id)
922+ }
923+ return matches[0], nil
924+}
925+
926+func collectManagedSessionMatches(root, id string, matches *[]string) error {
927+ dir := filepath.Join(root, "sessions")
928+ entries, err := os.ReadDir(dir)
929+ if errors.Is(err, os.ErrNotExist) {
930+ return nil
931+ }
932+ if err != nil {
933+ return fmt.Errorf("find managed session: read %q: %w", dir, err)
934+ }
935+ for _, bucket := range entries {
936+ if !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) {
937+ continue
938+ }
939+ bucketPath := filepath.Join(dir, bucket.Name())
940+ files, err := os.ReadDir(bucketPath)
941+ if err != nil {
942+ return fmt.Errorf("find managed session: read %q: %w", bucketPath, err)
943+ }
944+ for _, file := range files {
945+ if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") {
946+ continue
947+ }
948+ path := filepath.Join(bucketPath, file.Name())
949+ meta, err := scanSessionMeta(path)
950+ if err != nil {
951+ return fmt.Errorf("find managed session: read metadata %q: %w", path, err)
952+ }
953+ if meta.ID == id {
954+ *matches = append(*matches, path)
955+ }
956+ }
957+ }
958+ return nil
959+}
960+
961+func isSessionBucketName(name string) bool {
962+ if len(name) != 16 {
963+ return false
964+ }
965+ for _, r := range name {
966+ if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
967+ return false
968+ }
969+ }
970+ return true
971+}
972+
885973 // firstUserPrompt scans forward from the current source position
886974 // looking for the first user-role message and returns its text.
887975 // Used to build a humane export filename. Uses Reader instead of
@@ -882,6 +882,94 @@ func FindSessionByID(root, cwd, id string) string {
882 return ""882 return ""
883 }883 }
884 884
885+// FindManagedSessionByID looks up a session UUID across zut's managed
886+// session stores, independent of cwd. A nil error and empty path mean no
887+// matching session was found. Read, storage, metadata, and duplicate-ID
888+// failures are returned to the caller instead of being reported as a miss.
889+func FindManagedSessionByID(root, id string) (string, error) {
890+ if strings.TrimSpace(root) == "" {
891+ return "", errors.New("find managed session: root is empty")
892+ }
893+ if strings.TrimSpace(id) == "" {
894+ return "", errors.New("find managed session: id is empty")
895+ }
896+
897+ stores := []string{root}
898+ agentsDir := filepath.Join(root, "sessions", "agents")
899+ agents, err := os.ReadDir(agentsDir)
900+ if err != nil && !errors.Is(err, os.ErrNotExist) {
901+ return "", fmt.Errorf("find managed session: read agent stores: %w", err)
902+ }
903+ if err == nil {
904+ for _, entry := range agents {
905+ if entry.IsDir() {
906+ stores = append(stores, filepath.Join(agentsDir, entry.Name()))
907+ }
908+ }
909+ }
910+
911+ var matches []string
912+ for _, store := range stores {
913+ if err := collectManagedSessionMatches(store, id, &matches); err != nil {
914+ return "", err
915+ }
916+ }
917+ if len(matches) == 0 {
918+ return "", nil
919+ }
920+ if len(matches) > 1 {
921+ return "", fmt.Errorf("find managed session: UUID %q is ambiguous", id)
922+ }
923+ return matches[0], nil
924+}
925+
926+func collectManagedSessionMatches(root, id string, matches *[]string) error {
927+ dir := filepath.Join(root, "sessions")
928+ entries, err := os.ReadDir(dir)
929+ if errors.Is(err, os.ErrNotExist) {
930+ return nil
931+ }
932+ if err != nil {
933+ return fmt.Errorf("find managed session: read %q: %w", dir, err)
934+ }
935+ for _, bucket := range entries {
936+ if !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) {
937+ continue
938+ }
939+ bucketPath := filepath.Join(dir, bucket.Name())
940+ files, err := os.ReadDir(bucketPath)
941+ if err != nil {
942+ return fmt.Errorf("find managed session: read %q: %w", bucketPath, err)
943+ }
944+ for _, file := range files {
945+ if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") {
946+ continue
947+ }
948+ path := filepath.Join(bucketPath, file.Name())
949+ meta, err := scanSessionMeta(path)
950+ if err != nil {
951+ return fmt.Errorf("find managed session: read metadata %q: %w", path, err)
952+ }
953+ if meta.ID == id {
954+ *matches = append(*matches, path)
955+ }
956+ }
957+ }
958+ return nil
959+}
960+
961+func isSessionBucketName(name string) bool {
962+ if len(name) != 16 {
963+ return false
964+ }
965+ for _, r := range name {
966+ if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
967+ return false
968+ }
969+ }
970+ return true
971+}
972+
885 // firstUserPrompt scans forward from the current source position973 // firstUserPrompt scans forward from the current source position
886 // looking for the first user-role message and returns its text.974 // looking for the first user-role message and returns its text.
887 // Used to build a humane export filename. Uses Reader instead of975 // Used to build a humane export filename. Uses Reader instead of
M packages/core/session_portable_test.go +36 -0
@@ -10,6 +10,42 @@ import (
1010 "github.com/bnema/zut/packages/provider"
1111 )
1212
13+func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(t *testing.T) {
14+ root := t.TempDir()
15+ cwd := t.TempDir()
16+ session, err := NewSession(root, cwd, "provider", "model", "test")
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := session.AppendMessage(provider.Message{
21+ Role: provider.RoleUser,
22+ Content: []provider.Content{provider.TextBlock{Text: "managed"}},
23+ }); err != nil {
24+ t.Fatal(err)
25+ }
26+ if err := session.Close(); err != nil {
27+ t.Fatal(err)
28+ }
29+
30+ got, err := FindManagedSessionByID(root, session.ID)
31+ if err != nil {
32+ t.Fatalf("FindManagedSessionByID: %v", err)
33+ }
34+ if got != session.Path {
35+ t.Fatalf("managed session path = %q, want %q", got, session.Path)
36+ }
37+
38+ badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl")
39+ if err := os.WriteFile(badPath, []byte("not json\n"), 0o644); err != nil {
40+ t.Fatal(err)
41+ }
42+ if _, err := FindManagedSessionByID(root, "missing"); err == nil {
43+ t.Fatal("malformed managed metadata was reported as not found")
44+ } else if !strings.Contains(err.Error(), "metadata") {
45+ t.Fatalf("malformed metadata error = %v, want metadata context", err)
46+ }
47+}
48+
1349 // TestSessionExportImportRoundTrip writes a few messages to a live
1450 // session, exports it, imports the export under a different cwd,
1551 // and verifies OpenSession on the imported file yields the same
@@ -10,6 +10,42 @@ import (
10 "github.com/bnema/zut/packages/provider"10 "github.com/bnema/zut/packages/provider"
11 )11 )
12 12
13+func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(t *testing.T) {
14+ root := t.TempDir()
15+ cwd := t.TempDir()
16+ session, err := NewSession(root, cwd, "provider", "model", "test")
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := session.AppendMessage(provider.Message{
21+ Role: provider.RoleUser,
22+ Content: []provider.Content{provider.TextBlock{Text: "managed"}},
23+ }); err != nil {
24+ t.Fatal(err)
25+ }
26+ if err := session.Close(); err != nil {
27+ t.Fatal(err)
28+ }
29+
30+ got, err := FindManagedSessionByID(root, session.ID)
31+ if err != nil {
32+ t.Fatalf("FindManagedSessionByID: %v", err)
33+ }
34+ if got != session.Path {
35+ t.Fatalf("managed session path = %q, want %q", got, session.Path)
36+ }
37+
38+ badPath := filepath.Join(SessionsDir(root, cwd), "bad.jsonl")
39+ if err := os.WriteFile(badPath, []byte("not json\n"), 0o644); err != nil {
40+ t.Fatal(err)
41+ }
42+ if _, err := FindManagedSessionByID(root, "missing"); err == nil {
43+ t.Fatal("malformed managed metadata was reported as not found")
44+ } else if !strings.Contains(err.Error(), "metadata") {
45+ t.Fatalf("malformed metadata error = %v, want metadata context", err)
46+ }
47+}
48+
13 // TestSessionExportImportRoundTrip writes a few messages to a live49 // TestSessionExportImportRoundTrip writes a few messages to a live
14 // session, exports it, imports the export under a different cwd,50 // session, exports it, imports the export under a different cwd,
15 // and verifies OpenSession on the imported file yields the same51 // 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