detached e363e10

Resync upstream runtime, rendering, session, and glob changes (#206)

* fix(provider): dedupe tool results, project tool images, gate deferred tools, retry codex cache fallback

RepairOrphanedToolResults keeps the first result per call ID and drops
duplicates and orphans. OpenAI-compatible tool messages are text-only;
images ride once in a following user message (DeepSeek drops them, the
persisted mirror is reused). The Moonshot deferred-tools handshake only
applies to Moonshot providers; others use top-level activated tools. A
400 with param=prompt_cache_retention retries once with the native
identity on the same session/request correlation.

* fix(extensions): preserve startup error context and render multiline diagnostics

Manifest and spawn failures identify the extension directory once, with
name, quoted executable, declared language, cause, and stderr log path.
Error wrapping keeps errors.Is(err, exec.ErrNotFound) working. renderErr
and renderReloadErrors split explicit newlines before wrapping so each
row is one bounded terminal line.

* fix(tui): force full repaint on inaccessible bottom-band shrink

A shorter bottom frame starting above the addressable viewport forces a
full repaint so stale dialog rows cannot remain. Removed-row counts at
or above the terminal height repaint fully without overwriting the
recomputed viewport state with stale incremental values.

* feat(compact): retain post-compaction context estimate

Successful compaction exposes a deterministic timeline-based estimate
for the status display. Immediate continuations bypass the pre-turn
compaction guard until fresh provider usage arrives.

* feat(tools): add bounded, confined glob file matching

GlobTool supports *, ?, **, and character classes with slash-normalized
matching, hidden opt-in, inherited ignore rules, jail/symlink checks, and
depth (24) and result (500) bounds with sorted output. Registered beside
grep in the default, research, orchestrator, and summary surfaces.

* feat(sessions): add picker deletion and standalone prune command

The session picker confirms deletion with y, refuses the active session,
and removes rows from every projection without racing async load/search
generations. zut sessions prune scans stored sessions by recorded cwd,
supports dry-run, age and cwd filters, rechecks before deleting, and
preserves symlinks and malformed entries.

* docs: describe glob, session deletion, and prune command

Present-state documentation for the glob tool, picker deletion keys, and
the sessions prune command with its filters and safety behavior.

* fix(review): close picker-delete race, unify DeepSeek text-only routing

Move sessionDialog.Remove under i.mu since the render scheduler renders
the dialog concurrently. Apply the DeepSeek text-only wire to DeepSeek
models routed through opencode-go, matching the developer-role branch.

* fix(review): harden session scanner against non-regular entries

Report FIFOs, devices, and other non-regular files instead of opening
them; report a symlinked sessions root; skip tree-navigation branches
hidden from the flat picker.

* fix(review): walk explicit ignored glob roots, polish diagnostics and docs

An explicitly requested search root is always walked with nested ignore
files still applying. Support [!...] negation, skip symlinked directory
targets, and keep the requested root name in results. Fix dry-run wording
without --all, indent ext doctor continuations, normalize CRLF in error
rows, and document prune/session limits.
commit
e363e1080a40d678c0fcea316f7ee6b94092e496
author
bnema · · 2026-09-12 16:51 UTC
committer
GitHub · · 2026-09-12 16:51 UTC
parent
f7eb314
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.37.0
38 files · +3690 -113
M README.md +20 -5
@@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
99
1010 - one static binary.
1111 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.
12-- fifteen core built-in tools (read, write, edit, bash, python, create_worktree, grep, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.
12+- seventeen core built-in tools (read, write, edit, bash, python, create_worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.
1313 - three run modes (interactive tui, print, json).
1414 - built-in telegram bot.
1515 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).
@@ -264,6 +264,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
264264 - `write`: create or overwrite files, making parent directories as needed.
265265 - `edit`: one or more exact-match replacements in an existing file.
266266 - `grep`: search a file or directory for a regular expression using ripgrep when available, with a portable grep fallback. Search output is bounded and follows the active filesystem read scope; it never invokes a shell. The fallback's regular-expression syntax and ignore/hidden-file behavior can differ from ripgrep.
267+- `glob`: find files matching a glob pattern (`**/*.go`, `*.json`, `src/**/*.ts`) within a directory tree. Supports `*`, `?`, `**`, and character classes; patterns with a slash match relative paths, others match basenames. Honors `.gitignore` rules including nested files, skips hidden entries unless asked, never follows nested directory symlinks, and bounds depth (24) and matches (500, sorted).
267268 - `ast`: search or rewrite source code structurally with an [ast-grep](https://ast-grep.github.io/) pattern. Prefer it when the operation depends on code syntax; use `grep`/`edit` for plain text. Zut starts an installed `ast-grep` or `sg` executable directly without a shell. Searches return compact `path:line` matches. Rewrites are planned in memory, shown as a diff for confirmation, checked against the active filesystem scope, applied with file permissions preserved, and followed by bounded diagnostics. Install ast-grep separately; the tool returns a clear error when neither executable is available.
268269 - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. On Unix, zut uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable.
269270 - `python`: execute inline Python 3 code with an existing interpreter, never installing one. Every call must include nonblank `code` and a positive integer `timeout` in seconds (shared by interpreter discovery and execution). Each call runs a fresh stateless subprocess directly (`-u -B -`, code on stdin) in the session cwd with merged stdout/stderr; there is no persistent interpreter state, no script file, and no bytecode cache. Interactive input is unsupported (stdin observes EOF). Output is bounded (50 KiB, 2,000 lines) with explicit truncation marks; truncated results persist the byte-capped buffer to a temp file referenced as `full output` with its path in `full_output_path`, mirroring `bash`. Discovery order: `python_interpreter` in `$ZUT_HOME/config.json` (one executable path; relative paths resolve against the session cwd), then the active `VIRTUAL_ENV`, then `./.venv`, then `python3`/`python` on `PATH`. On Windows, Store aliases, `py` launchers, and install-manager entrypoints are rejected; only positively identified installed interpreters run. Missing packages fail with the ordinary import traceback. Tool arguments and output are recorded in the session like other tools; files the code deliberately writes persist. Save reusable scripts with `write`/`edit` instead of using `python` as an opaque file-edit path. Unavailable to packaged agents, and rejected while jailed (see below).
@@ -276,7 +277,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
276277
277278 Tool results carry resource provenance in the durable session. Before each provider request, Zut replaces an older read only when an equivalent newer view supersedes it, and replaces a multi-file search only after every discovered file has been consumed or mutated. This provider-only projection removes known-stale context without rewriting session history. Because an older projected result changes, prompt caching restarts from that point; compact deterministic markers limit the cost.
278279
279-When the sandbox is on (see `/jail`), filesystem tools, `grep`, `ast`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress.
280+When the sandbox is on (see `/jail`), filesystem tools, `grep`, `glob`, `ast`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress.
280281
281282 ## Modes
282283
@@ -381,7 +382,7 @@ Type `!` followed by a command to run it directly without going through the mode
381382
382383 ### `/sessions`
383384
384-Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Press `Tab` to rotate between **this directory** and **all sessions** in the active normal or Zutfile-agent namespace; other agent namespaces never appear. Press `/` and type at least two characters to search contiguous text in an ephemeral in-memory corpus of persisted user and assistant messages. Tool calls, tool results, images, developer context, and compaction payloads are excluded. The picker displays each matching session's count and a highlighted first matching excerpt; changing a query reuses the corpus rather than reopening transcripts. Session entries and search corpus load in the background. Once an entry appears, `up`/`down` and `enter` can select it immediately; `esc` clears a search or cancels and closes the picker. Selecting a session from another working directory switches the live workspace to that session's recorded directory before the transcript resumes, so tools, instructions, sandbox state, and file suggestions do not retain the old directory. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if a staged switch fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. `/sessions` is unavailable with `--no-session`. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed.
385+Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Press `Tab` to rotate between **this directory** and **all sessions** in the active normal or Zutfile-agent namespace; other agent namespaces never appear. Press `/` and type at least two characters to search contiguous text in an ephemeral in-memory corpus of persisted user and assistant messages. Tool calls, tool results, images, developer context, and compaction payloads are excluded. The picker displays each matching session's count and a highlighted first matching excerpt; changing a query reuses the corpus rather than reopening transcripts. Session entries and search corpus load in the background. Once an entry appears, `up`/`down` and `enter` can select it immediately; `r` renames, `d` deletes after confirmation (the active session is protected, but sessions open in other zut processes cannot be detected and are deleted like any other file), and `esc` clears a search or cancels and closes the picker. Selecting a session from another working directory switches the live workspace to that session's recorded directory before the transcript resumes, so tools, instructions, sandbox state, and file suggestions do not retain the old directory. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if a staged switch fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. `/sessions` is unavailable with `--no-session`. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed.
385386
386387 ### `/session`
387388
@@ -526,6 +527,20 @@ This is a guardrail against accidents, not a hard security boundary. If you need
526527
527528 Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZUT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Session metadata stores the provider and model used by that session; resume honors that pair, rebuilding the active agent when it differs and refusing the switch without discarding the current session if rebuilding fails. Usage checkpoints may include sanitized `retry_lifecycle` metadata—attempts, backoff delays, terminal state, and an allowlisted failure category—but never raw provider errors or response bodies. This metadata is ignored when rebuilding provider context. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy.
528529
530+Use `zut sessions prune` outside the TUI to find sessions whose recorded working directories no longer exist. The command groups sessions by directory, shows the stored session count and size, lets you select groups, and requires confirmation before permanently deleting files. It preserves sessions when a directory check fails with an error other than "not found" and rechecks each selected directory immediately before deletion. A deleted directory and a path hidden by some unmounted filesystems are indistinguishable, and sessions open in another running zut process cannot be detected, so review the selection, ensure remote filesystems are mounted, and prune only what no live session is using before deleting missing-directory groups.
531+
532+Use `--older-than` to prune sessions by last activity instead, including sessions for directories that still exist. Supported age units are `m` (minutes), `h` (hours), `d` (24-hour days), `w` (7-day weeks), `mo` (calendar months), and `y` (calendar years). Add `--cwd PATH` to limit age pruning to one working directory. Each session file is checked again immediately before deletion and preserved if it has become active recently.
533+
534+```sh
535+zut sessions prune --dry-run # list missing-directory groups
536+zut sessions prune --older-than 4h --dry-run # inspect sessions inactive for four hours
537+zut sessions prune --older-than 1mo --cwd ~/project # select old sessions for one directory
538+zut sessions prune --older-than 1y --all # select every match, then confirm
539+zut sessions prune --older-than 30d --all --yes # delete every match without prompting
540+```
541+
542+`--all` selects every match and still asks for confirmation. Adding `--yes` makes deletion non-interactive and requires `--all`. Malformed, unreadable, and symlinked session entries are reported and preserved. The scan covers `$ZUT_HOME/sessions/` including named-agent namespaces; explicit session files stored elsewhere are not included.
543+
529544 ## Providers
530545
531546 zut's built-in provider catalog includes:
@@ -974,7 +989,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
974989 | `ctrl+d` | Exit on empty input. |
975990 | `ctrl+l` | Redraw the screen. |
976991 | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. |
977-| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, lsp, and web_search outputs over ~12 lines). |
992+| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, glob, lsp, and web_search outputs over ~12 lines). |
978993 | `ctrl+r` | Cycle through the reasoning levels supported by the active model. The selected level applies to subsequent model calls and persists to `config.json`. |
979994 | `ctrl+1` ... `ctrl+9` | Activate a persistent model + reasoning + fast-mode profile; empty slots open the model picker. French AZERTY Ctrl + top-row symbols also work when forwarded by the terminal. Rejected while a turn is running. See [Model profiles](#model-profiles); `/profile N` is the terminal-independent fallback. |
980995 | `@` | Open the file picker. Browse files and directories in the working directory. |
@@ -1158,7 +1173,7 @@ packages/agent/extproto/ extension wire-format types
11581173 packages/agent/modes/ interactive tui, print, json, dialogs
11591174 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
11601175 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1161-packages/agent/tools/ read, write, edit, bash, create_worktree, grep, lsp, web search, goals, sandbox
1176+packages/agent/tools/ read, write, edit, bash, create_worktree, grep, glob, lsp, web search, goals, sandbox
11621177 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
11631178 packages/agent/subagents/ named profiles and resident background runtime
11641179 packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk)
M docs/extensions.md +5 -1
@@ -776,7 +776,11 @@ error in red, including its message, and dismisses itself after five
776776 seconds. Extension failures from startup and `/reload-ext` remain in the
777777 scrolling chat until a successful reload or `/clear`, without being sent to
778778 the model or saved in the session transcript. Subprocess startup and
779-handshake errors include the exact stderr log path for the failed extension.
779+handshake errors identify the extension and its directory, configured executable,
780+and declared language (if present), along with the original error and stderr log
781+path when the log was opened. Manifest errors also identify the extension
782+directory. Multiline diagnostics preserve their line breaks in the `/reload-ext`
783+status and scrolling chat, and in `zut ext doctor` output.
780784 If a subprocess exits before sending `hello`, the error also reports its exit
781785 status or terminating signal.
782786
M packages/agent/args.go +5 -0
@@ -427,6 +427,11 @@ func PrintHelp(version string) {
427427 row{"zut --ext ./path/to/ext", "load an extension for this run only"},
428428 row{"zut ext help", "show all extension subcommands"},
429429 )
430+ section("session management",
431+ row{"zut sessions prune", "select and delete sessions for directories that no longer exist"},
432+ row{"zut sessions prune --dry-run", "list stale session groups without deleting them"},
433+ row{"zut sessions prune --older-than 30d", "select sessions inactive for 30 days"},
434+ )
430435 section("self-update",
431436 row{"zut update", "download and install the latest release"},
432437 row{"zut update --check", "show whether a new release is available"},
M packages/agent/build.go +6 -2
@@ -123,7 +123,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
123123 // Web capability and other native names remain reserved even when their
124124 // policy excludes the current session. An extension must not turn a
125125 // normal CLI opt-out into a differently implemented capability.
126- if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "ast" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.UpdatePlanToolName {
126+ if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "glob" || info.Name == "ast" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.UpdatePlanToolName {
127127 continue
128128 }
129129 if _, exists := r.ToolRegistry[info.Name]; exists {
@@ -1318,6 +1318,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
13181318 v.Sandbox = s
13191319 case *tools.GrepTool:
13201320 v.Sandbox = s
1321+ case *tools.GlobTool:
1322+ v.Sandbox = s
13211323 case *tools.ASTTool:
13221324 v.Sandbox = s
13231325 }
@@ -1356,6 +1358,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
13561358 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
13571359 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
13581360 "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1361+ "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
13591362 "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
13601363 "update_goal": &tools.UpdateGoalTool{},
13611364 "update_plan": &tools.UpdatePlanTool{},
@@ -1394,6 +1397,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
13941397 if orchestratorExplorationToolsAllowed(args) {
13951398 reg["read"] = all["read"]
13961399 reg["grep"] = all["grep"]
1400+ reg["glob"] = all["glob"]
13971401 if astTool, ok := all["ast"].(*tools.ASTTool); ok {
13981402 astTool.ReadOnly = true
13991403 reg["ast"] = astTool
@@ -1504,7 +1508,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
15041508 return false
15051509 }
15061510
1507-var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "ast", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}
1511+var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}
15081512
15091513 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
15101514 var out []ToolSummary
M packages/agent/build_grep_test.go +34 -0
@@ -46,6 +46,40 @@ func TestBuildToolRegistryIncludesUpdatePlanByDefaultAndExplicitSelection(t *tes
4646 }
4747 }
4848
49+func TestBuildToolRegistryGlobSelectionAndDefaults(t *testing.T) {
50+ cwd := t.TempDir()
51+
52+ defaultRegistry := buildToolRegistry(Args{}, cwd, nil, false, false, false)
53+ if _, ok := defaultRegistry["glob"].(*tools.GlobTool); !ok {
54+ t.Fatalf("default glob has type %T", defaultRegistry["glob"])
55+ }
56+ defaultSummaries := toolSummaries(defaultRegistry, Args{})
57+ globIndex := -1
58+ for i, summary := range defaultSummaries {
59+ if summary.Name == "glob" {
60+ globIndex = i
61+ break
62+ }
63+ }
64+ if globIndex < 0 {
65+ t.Fatal("default tool summaries do not contain glob")
66+ }
67+ if globIndex == 0 || defaultSummaries[globIndex-1].Name != "grep" {
68+ t.Fatalf("glob summary order has predecessor %q, want grep", defaultSummaries[globIndex-1].Name)
69+ }
70+
71+ selected := buildToolRegistry(Args{Tools: []string{"glob"}}, cwd, nil, false, false, false)
72+ if len(selected) != 1 {
73+ t.Fatalf("selected registry has %d tools, want one", len(selected))
74+ }
75+ if _, ok := selected["glob"].(*tools.GlobTool); !ok {
76+ t.Fatalf("selected glob has type %T", selected["glob"])
77+ }
78+ if summaries := toolSummaries(selected, Args{Tools: []string{"glob"}}); len(summaries) != 1 || summaries[0].Name != "glob" {
79+ t.Fatalf("selected summaries = %#v, want only glob", summaries)
80+ }
81+}
82+
4983 func TestBuildToolRegistryGrepSelectionAndDefaults(t *testing.T) {
5084 cwd := t.TempDir()
5185
M packages/agent/build_orchestrator_test.go +3 -0
@@ -126,6 +126,9 @@ func TestOrchestratorAlwaysReceivesResearchTools(t *testing.T) {
126126 if _, ok := registry["grep"].(*tools.GrepTool); ok != tc.wantResearch {
127127 t.Fatalf("grep = %T, want research tool present = %v", registry["grep"], tc.wantResearch)
128128 }
129+ if _, ok := registry["glob"].(*tools.GlobTool); ok != tc.wantResearch {
130+ t.Fatalf("glob = %T, want research tool present = %v", registry["glob"], tc.wantResearch)
131+ }
129132 for _, name := range tools.WebCapabilityNames {
130133 _, gotWebTool := registry[name]
131134 if gotWebTool != tc.wantWeb {
M packages/agent/cli.go +3 -0
@@ -867,6 +867,9 @@ func Run(rawArgs []string, version string) error {
867867 if handled, err := runUpdateCommand(rawArgs, version); handled {
868868 return err
869869 }
870+ if handled, err := runSessionsCommand(rawArgs); handled {
871+ return err
872+ }
870873 if handled, err := runZutfileCommand(rawArgs, version); handled {
871874 return err
872875 }
M packages/agent/ext/ext.go +4 -3
@@ -421,9 +421,10 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
421421 // into the agent's registry once the extension's ready frame fires.
422422 //
423423 // Conflicts with active built-in tool names (read, write, edit, bash,
424-// python, create_worktree, grep, ast, lsp, web_search, web_open, web_find,
425-// web_click, schedule, update_goal, update_plan, and skill) are silently
426-// shadowed by the built-in. Every native public-web name, plus grep, ast, python, schedule,
424+// python, create_worktree, grep, glob, ast, lsp, web_search, web_open,
425+// web_find, web_click, schedule, update_goal, update_plan, and skill) are
426+// silently shadowed by the built-in. Every native public-web name, plus
427+// grep, glob, ast, python, schedule,
427428 // update_goal, and update_plan, is reserved even when unavailable, so an
428429 // extension cannot claim or replace it.
429430 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
M packages/agent/extcmd.go +4 -1
@@ -195,7 +195,10 @@ func extDoctor(version string) error {
195195 if len(errs) > 0 {
196196 fmt.Fprintln(os.Stdout, "load errors:")
197197 for _, err := range errs {
198- fmt.Fprintf(os.Stdout, " ! %v\n", err)
198+ // Multiline diagnostics keep their line breaks; indent
199+ // continuations so they read as one entry.
200+ indented := strings.ReplaceAll(err.Error(), "\n", "\n ")
201+ fmt.Fprintf(os.Stdout, " ! %s\n", indented)
199202 }
200203 }
201204 return nil
M packages/agent/extensions/manager.go +16 -8
@@ -308,7 +308,7 @@ func (m *Manager) Discover(ctx context.Context) []error {
308308 go func(extDir string) {
309309 defer wg.Done()
310310 if err := m.loadOne(ctx, extDir); err != nil {
311- errCh <- fmt.Errorf("%s: %w", extDir, err)
311+ errCh <- err
312312 }
313313 }(j.dir)
314314 }
@@ -342,18 +342,18 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
342342 manifestPath := filepath.Join(dir, "extension.json")
343343 raw, err := os.ReadFile(manifestPath)
344344 if err != nil {
345- return fmt.Errorf("read manifest: %w", err)
345+ return fmt.Errorf("%s: read manifest: %w", dir, err)
346346 }
347347 var mf Manifest
348348 if err := json.Unmarshal(raw, &mf); err != nil {
349- return fmt.Errorf("parse manifest: %w", err)
349+ return fmt.Errorf("%s: parse manifest: %w", dir, err)
350350 }
351351 if mf.Name == "" {
352- return errors.New("manifest: name is required")
352+ return fmt.Errorf("%s: manifest: name is required", dir)
353353 }
354354 hasTheme := HasExtensionTheme(dir)
355355 if mf.Exec == "" && !hasTheme {
356- return errors.New("manifest: exec is required")
356+ return fmt.Errorf("%s: manifest: exec is required", dir)
357357 }
358358 if !mf.IsEnabled() {
359359 // Quietly skip disabled extensions; zut ext list will show them.
@@ -387,7 +387,15 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
387387 }
388388 if mf.Exec != "" {
389389 if err := m.spawn(ctx, ext); err != nil {
390- return err
390+ language := ""
391+ if mf.Language != "" {
392+ language = fmt.Sprintf(" (declared language: %q)", mf.Language)
393+ }
394+ logDetail := ""
395+ if ext.LogPath != "" {
396+ logDetail = "\n stderr log: " + ext.LogPath
397+ }
398+ return fmt.Errorf("Extension %s failed to start.\n\n exec: %q%s\n error: %w\n extension directory: %s%s", mf.Name, mf.Exec, language, err, dir, logDetail)
391399 }
392400 } else {
393401 ext.readyOnce.Do(func() { close(ext.readyCh) })
@@ -462,7 +470,7 @@ func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {
462470 go func(extDir string) {
463471 defer wg.Done()
464472 if err := m.loadOne(ctx, extDir); err != nil {
465- errCh <- fmt.Errorf("%s: %w", extDir, err)
473+ errCh <- err
466474 }
467475 }(abs)
468476 }
@@ -681,7 +689,7 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
681689 return fmt.Errorf("stdout pipe (stderr log: %s): %w", logPath, err)
682690 }
683691 if err := cmd.Start(); err != nil {
684- return fmt.Errorf("spawn (stderr log: %s): %w", logPath, err)
692+ return err
685693 }
686694 started = true
687695 ext.cmd = cmd
A packages/agent/extensions/manager_diagnostics_test.go +106 -0
@@ -0,0 +1,106 @@
1+package extensions
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+)
12+
13+func TestLoadErrorsIdentifyExtensionDirectory(t *testing.T) {
14+ for _, explicit := range []bool{false, true} {
15+ mode := "discover"
16+ if explicit {
17+ mode = "explicit"
18+ }
19+ for _, tc := range []struct {
20+ name string
21+ manifest string
22+ want string
23+ }{
24+ {"invalid-json", `{`, "parse manifest"},
25+ {"missing-name", `{"exec":"missing"}`, "name is required"},
26+ {"missing-exec", `{"name":"broken"}`, "exec is required"},
27+ {"open-log", `{"name":"broken","exec":"missing"}`, "open log"},
28+ {"missing-runtime", `{"name":"broken","exec":"zut-test-missing-runtime"}`, "failed to start"},
29+ } {
30+ t.Run(mode+"/"+tc.name, func(t *testing.T) {
31+ home := t.TempDir()
32+ dir := filepath.Join(home, "extensions", tc.name)
33+ if err := os.MkdirAll(dir, 0o755); err != nil {
34+ t.Fatal(err)
35+ }
36+ if err := os.WriteFile(filepath.Join(dir, "extension.json"), []byte(tc.manifest), 0o644); err != nil {
37+ t.Fatal(err)
38+ }
39+ if tc.name == "open-log" {
40+ if err := os.WriteFile(filepath.Join(home, "logs"), nil, 0o644); err != nil {
41+ t.Fatal(err)
42+ }
43+ }
44+ if tc.name == "missing-runtime" {
45+ t.Setenv("PATH", t.TempDir())
46+ }
47+ mgr := New(home, "", "test", "", "", nil)
48+ var errs []error
49+ if explicit {
50+ errs = mgr.LoadExplicit(context.Background(), []string{dir})
51+ } else {
52+ errs = mgr.Discover(context.Background())
53+ }
54+ if len(errs) != 1 {
55+ t.Fatalf("errors = %v, want one", errs)
56+ }
57+ if got := errs[0].Error(); !strings.Contains(got, dir) || !strings.Contains(got, tc.want) {
58+ t.Fatalf("error = %q, want directory %q and %q", got, dir, tc.want)
59+ }
60+ if tc.name == "missing-runtime" {
61+ if !errors.Is(errs[0], exec.ErrNotFound) {
62+ t.Fatalf("error does not wrap exec.ErrNotFound: %v", errs[0])
63+ }
64+ if strings.Count(errs[0].Error(), dir) != 1 {
65+ t.Fatalf("directory should appear once: %v", errs[0])
66+ }
67+ }
68+ })
69+ }
70+ }
71+}
72+
73+func TestDiscoverReportsMissingRuntimeWhenExtensionCannotStart(t *testing.T) {
74+ tmp := t.TempDir()
75+ extDir := filepath.Join(tmp, "extensions", "missing-runtime")
76+ if err := os.MkdirAll(extDir, 0o755); err != nil {
77+ t.Fatal(err)
78+ }
79+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"missing-runtime","exec":"python3","language":"python"}`), 0o644); err != nil {
80+ t.Fatal(err)
81+ }
82+
83+ // Isolate PATH so this tests a missing language runtime rather than
84+ // relying on a made-up executable name that could theoretically exist.
85+ t.Setenv("PATH", filepath.Join(tmp, "empty-bin"))
86+ mgr := New(tmp, "", "0.0.0-test", "", "", nil)
87+ errs := mgr.Discover(context.Background())
88+ if len(errs) != 1 {
89+ t.Fatalf("discover errors = %v, want one", errs)
90+ }
91+ got := errs[0].Error()
92+ for _, want := range []string{
93+ `Extension missing-runtime failed to start.`,
94+ `exec: "python3"`,
95+ `declared language: "python"`,
96+ "executable file not found",
97+ extDir,
98+ } {
99+ if !strings.Contains(got, want) {
100+ t.Fatalf("error = %q, want it to contain %q", got, want)
101+ }
102+ }
103+ if strings.Count(got, extDir) != 1 {
104+ t.Fatalf("directory should appear once: %q", got)
105+ }
106+}
M packages/agent/modes/compact_queue_test.go +141 -0
@@ -1575,3 +1575,144 @@ func requestImageCount(req provider.Request, want provider.ImageBlock) int {
15751575 }
15761576 return count
15771577 }
1578+
1579+func TestCompactionRetainsContextEstimate(t *testing.T) {
1580+ client := &compactQueueClient{
1581+ compactionStarted: make(chan struct{}),
1582+ releaseCompaction: make(chan struct{}),
1583+ followUpRequest: make(chan provider.Request, 1),
1584+ }
1585+ agent := core.NewAgent(client, "test-model", "", nil)
1586+ agent.SetMessages([]provider.Message{
1587+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
1588+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
1589+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
1590+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
1591+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
1592+ })
1593+ interactive := NewInteractive(InteractiveConfig{Agent: agent})
1594+
1595+ interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual})
1596+ select {
1597+ case <-client.compactionStarted:
1598+ case <-time.After(2 * time.Second):
1599+ t.Fatal("compaction did not start")
1600+ }
1601+ close(client.releaseCompaction)
1602+
1603+ deadline := time.NewTimer(5 * time.Second)
1604+ defer deadline.Stop()
1605+ poll := time.NewTicker(time.Millisecond)
1606+ defer poll.Stop()
1607+ for {
1608+ interactive.mu.Lock()
1609+ busy := interactive.busy
1610+ got := interactive.lastCtxInput
1611+ interactive.mu.Unlock()
1612+ if !busy {
1613+ want := estimateCompactedContextInput(agent.Messages())
1614+ if got != want || got == 0 {
1615+ t.Fatalf("context estimate after compaction = %d, want %d", got, want)
1616+ }
1617+ return
1618+ }
1619+ select {
1620+ case <-deadline.C:
1621+ t.Fatal("compaction did not finish")
1622+ case <-poll.C:
1623+ }
1624+ }
1625+}
1626+
1627+func TestCompactionEstimateBypassesImmediateContinuation(t *testing.T) {
1628+ client := &compactQueueClient{
1629+ compactionStarted: make(chan struct{}),
1630+ releaseCompaction: make(chan struct{}),
1631+ followUpRequest: make(chan provider.Request, 1),
1632+ }
1633+ agent := core.NewAgent(client, "test-model", "", nil)
1634+ agent.SetMessages([]provider.Message{
1635+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
1636+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
1637+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}},
1638+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}},
1639+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}},
1640+ })
1641+ interactive := NewInteractive(InteractiveConfig{Agent: agent})
1642+ interactive.runCtx = context.Background()
1643+
1644+ interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual})
1645+ select {
1646+ case <-client.compactionStarted:
1647+ case <-time.After(2 * time.Second):
1648+ t.Fatal("compaction did not start")
1649+ }
1650+
1651+ interactive.ed.SetValue("follow up")
1652+ interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
1653+ close(client.releaseCompaction)
1654+
1655+ select {
1656+ case req := <-client.followUpRequest:
1657+ if !requestContainsUserText(req, "follow up") {
1658+ t.Fatalf("follow-up request does not contain queued prompt: %#v", req.Messages)
1659+ }
1660+ case <-time.After(5 * time.Second):
1661+ t.Fatal("queued prompt did not start a turn after compaction")
1662+ }
1663+
1664+ // The immediate continuation runs against the just-compacted transcript,
1665+ // so the pre-turn guard must see a zero estimate until fresh provider
1666+ // usage arrives. The follow-up sends no usage, so the bypass must hold
1667+ // through the turn with no second compaction (exactly two provider calls).
1668+ deadline := time.NewTimer(5 * time.Second)
1669+ defer deadline.Stop()
1670+ poll := time.NewTicker(time.Millisecond)
1671+ defer poll.Stop()
1672+ for {
1673+ interactive.mu.Lock()
1674+ busy := interactive.busy
1675+ estimate := interactive.lastCtxInput
1676+ interactive.mu.Unlock()
1677+ client.mu.Lock()
1678+ calls := client.calls
1679+ client.mu.Unlock()
1680+ if !busy {
1681+ if estimate != 0 {
1682+ t.Fatalf("context estimate during immediate continuation = %d, want bypass 0", estimate)
1683+ }
1684+ if calls != 2 {
1685+ t.Fatalf("provider called %d times, want compaction plus one follow-up (no second compaction)", calls)
1686+ }
1687+ return
1688+ }
1689+ select {
1690+ case <-deadline.C:
1691+ t.Fatal("follow-up turn did not finish")
1692+ case <-poll.C:
1693+ }
1694+ }
1695+}
1696+
1697+func TestEstimateCompactedContextInput(t *testing.T) {
1698+ if got := estimateCompactedContextInput(nil); got != 0 {
1699+ t.Fatalf("empty transcript estimate = %d, want 0", got)
1700+ }
1701+ msgs := []provider.Message{
1702+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "1234"}}},
1703+ {Role: provider.RoleAssistant, Content: []provider.Content{
1704+ provider.TextBlock{Text: "5678"},
1705+ provider.ToolCallBlock{ID: "call-1", Name: "read", Arguments: json.RawMessage(`{}`)},
1706+ }},
1707+ {Role: provider.RoleTool, Content: []provider.Content{
1708+ provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{
1709+ provider.TextBlock{Text: "01234567"},
1710+ provider.ImageBlock{MimeType: "image/png", Data: []byte("ignored-bytes")},
1711+ }},
1712+ }},
1713+ }
1714+ // 4 + 4 + len("read")+2 + 8 = 22 chars → 5 tokens; image bytes excluded.
1715+ if got, want := estimateCompactedContextInput(msgs), 5; got != want {
1716+ t.Fatalf("estimate = %d, want %d", got, want)
1717+ }
1718+}
M packages/agent/modes/interactive_commands.go +36 -0
@@ -1007,6 +1007,42 @@ func (i *Interactive) canResumeSessionSelection() bool {
10071007 return !busy && !queued && ag != nil && ag.QueuedMessageCount() == 0
10081008 }
10091009
1010+// applySessionDeletion permanently removes a session selected in the
1011+// picker. The active session is protected because unlinking its open file
1012+// would lose subsequent transcript writes on Unix and behaves differently
1013+// on Windows.
1014+func (i *Interactive) applySessionDeletion(path string) {
1015+ if i.cfg.CurrentSessionPath == nil {
1016+ i.mu.Lock()
1017+ i.statusErr = "session deletion is not wired in this build"
1018+ i.statusOK = ""
1019+ i.mu.Unlock()
1020+ return
1021+ }
1022+ current := i.cfg.CurrentSessionPath()
1023+ if current != "" && filepath.Clean(current) == filepath.Clean(path) {
1024+ i.mu.Lock()
1025+ i.statusErr = "cannot delete the active session; resume another session first"
1026+ i.statusOK = ""
1027+ i.mu.Unlock()
1028+ return
1029+ }
1030+ if err := core.DeleteSession(path); err != nil {
1031+ i.mu.Lock()
1032+ i.statusErr = "delete session: " + err.Error()
1033+ i.statusOK = ""
1034+ i.mu.Unlock()
1035+ return
1036+ }
1037+ // Remove runs under the mutex: the render-scheduler goroutine assembles
1038+ // state and renders the dialog concurrently while it stays active.
1039+ i.mu.Lock()
1040+ i.sessionDialog.Remove(path)
1041+ i.statusOK = "deleted session: " + friendlyPath(path)
1042+ i.statusErr = ""
1043+ i.mu.Unlock()
1044+}
1045+
10101046 func (i *Interactive) applySessionSelection(path string) {
10111047 if i.cfg.LoadSession == nil {
10121048 i.mu.Lock()
M packages/agent/modes/interactive_compaction.go +37 -1
@@ -17,6 +17,35 @@ func (i *Interactive) clearPendingCompactTurnLocked() {
1717 i.continueAfterCompact = false
1818 i.pendingPostCompactNote = ""
1919 }
20+
21+// estimateCompactedContextInput approximates the input tokens of the
22+// just-compacted transcript for the status display. It uses the same rough
23+// 1-token-per-4-chars rule as compaction's own estimate, summed over the
24+// textual timeline content. Empty transcripts yield zero; image bytes are
25+// not counted. This is display-only: the next provider usage replaces it,
26+// and immediate continuations bypass the pre-turn guard until then.
27+func estimateCompactedContextInput(msgs []provider.Message) int {
28+ total := 0
29+ for _, msg := range msgs {
30+ for _, c := range msg.Content {
31+ switch v := c.(type) {
32+ case provider.TextBlock:
33+ total += len(v.Text)
34+ case provider.ToolCallBlock:
35+ total += len(v.Name) + len(v.Arguments)
36+ case provider.ToolResultBlock:
37+ for _, inner := range v.Content {
38+ if text, ok := inner.(provider.TextBlock); ok {
39+ total += len(text.Text)
40+ }
41+ }
42+ case provider.ReasoningBlock:
43+ total += len(v.Summary)
44+ }
45+ }
46+ }
47+ return total / 4
48+}
2049 func (i *Interactive) compactHandoffLocked() json.RawMessage {
2150 return encodeCompactHandoff(i.compactContinuation)
2251 }
@@ -322,7 +351,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
322351 }
323352 i.pendingPostCompactNote = ""
324353 i.extNotes = stripAutoCompactNotes(i.extNotes)
325- i.lastCtxInput = 0
354+ i.lastCtxInput = estimateCompactedContextInput(msgs)
326355 i.toolCalls = map[string]*tui.ToolCallView{}
327356 i.toolOrder = nil
328357 i.toolGate = map[string]int{}
@@ -380,6 +409,13 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
380409 // the mutex. A completion update arriving after the compaction result
381410 // but before this assignment is then queued for the selected follow-up
382411 // instead of starting a competing turn.
412+ if hasNext || continueExisting || continueAutomatically || continueGoal {
413+ // The estimate above keeps the status display honest, but the
414+ // follow-up turn must not trip the pre-turn compaction guard
415+ // before fresh provider usage arrives: it runs against the
416+ // just-compacted transcript by construction.
417+ i.lastCtxInput = 0
418+ }
383419 i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
384420 i.compacting = false
385421 i.autoCompacting = false
M packages/agent/modes/interactive_input_routing.go +3 -0
@@ -250,6 +250,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
250250 if act.Select {
251251 i.applySessionSelection(act.Path)
252252 }
253+ if act.Delete {
254+ i.applySessionDeletion(act.Path)
255+ }
253256 if act.Err != nil {
254257 if manualRenameCurrent {
255258 i.restoreFailedSessionTitle()
M packages/agent/modes/reload_ext.go +15 -9
@@ -55,16 +55,22 @@ func renderReloadErrors(theme tui.Theme, errors []string, width int) []string {
5555 }
5656 var out []string
5757 for _, msg := range errors {
58- wrapped := tui.WrapANSILine(msg, wrapWidth)
59- if len(wrapped) == 0 {
60- wrapped = []string{""}
61- }
62- for idx, line := range wrapped {
63- prefix := marker
64- if idx > 0 {
65- prefix = indent
58+ // Split explicit newlines before wrapping. Passing a multiline string
59+ // to WrapANSILine would leave the terminal to interpret the embedded
60+ // newline, which can misalign the continuation text. Normalize CRLF
61+ // first so no raw CR survives inside a row.
62+ for lineIndex, paragraph := range strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n") {
63+ wrapped := tui.WrapANSILine(paragraph, wrapWidth)
64+ if len(wrapped) == 0 {
65+ wrapped = []string{""}
66+ }
67+ for wrapIndex, line := range wrapped {
68+ prefix := indent
69+ if lineIndex == 0 && wrapIndex == 0 {
70+ prefix = marker
71+ }
72+ out = append(out, theme.FGColor(theme.Error, prefix+line))
6673 }
67- out = append(out, theme.FGColor(theme.Error, prefix+line))
6874 }
6975 }
7076 return out
M packages/agent/modes/reload_ext_test.go +15 -0
@@ -35,6 +35,21 @@ func TestFormatReloadStatusIncludesErrorDetails(t *testing.T) {
3535 }
3636 }
3737
38+func TestRenderReloadErrorsPreservesMultilineAlignment(t *testing.T) {
39+ lines := renderReloadErrors(tui.Theme{Error: tui.Color256(1)}, []string{"failed to start\nextension directory: /tmp/ext\nstderr log: /tmp/ext.log"}, 80)
40+ if len(lines) != 3 {
41+ t.Fatalf("rendered %d lines, want 3: %q", len(lines), lines)
42+ }
43+ if !strings.Contains(lines[0], "✖ failed to start") {
44+ t.Fatalf("first line = %q, want error marker", lines[0])
45+ }
46+ for _, line := range lines[1:] {
47+ if !strings.Contains(line, " ") {
48+ t.Fatalf("continuation line = %q, want indentation", line)
49+ }
50+ }
51+}
52+
3853 func TestFormatReloadStatusSuccess(t *testing.T) {
3954 msg, failed := formatReloadStatus(extensions.ReloadStats{Stopped: 2, Loaded: 3, Ready: 3})
4055 if failed {
M packages/agent/modes/session_dialog.go +83 -4
@@ -75,6 +75,11 @@ type sessionDialog struct {
7575 cursor int
7676 renaming bool
7777 rename string
78+ deleting bool
79+ // removedPaths remembers picker deletions for the dialog lifetime so
80+ // in-flight load/search generations cannot resurrect a deleted row
81+ // when they rebuild the visible list.
82+ removedPaths map[string]bool
7883
7984 allScope bool
8085 root string
@@ -132,6 +137,7 @@ type sessionDialog struct {
132137 // sessionDialogAction is returned by HandleKey.
133138 type sessionDialogAction struct {
134139 Select bool
140+ Delete bool
135141 Path string
136142 Close bool
137143 Renamed bool
@@ -174,6 +180,8 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope
174180 d.viewTop = 0
175181 d.renaming = false
176182 d.rename = ""
183+ d.deleting = false
184+ d.removedPaths = nil
177185 d.root = root
178186 d.cwd = cwd
179187 d.allScope = len(allScope) > 0 && allScope[0]
@@ -377,7 +385,7 @@ func (d *sessionDialog) appendLoadedSessions(start, end int) {
377385 return
378386 }
379387 for _, slot := range d.loadSlots[start:end] {
380- if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {
388+ if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 || d.removedPaths[slot.summary.Path] {
381389 continue
382390 }
383391 d.baseSessions = append(d.baseSessions, slot.summary)
@@ -393,7 +401,7 @@ func (d *sessionDialog) rebuildLoadedSessions(limit int) {
393401 }
394402 filtered := make([]core.SessionSummary, 0, limit)
395403 for _, slot := range d.loadSlots[:limit] {
396- if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 {
404+ if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 || d.removedPaths[slot.summary.Path] {
397405 continue
398406 }
399407 filtered = append(filtered, slot.summary)
@@ -423,7 +431,8 @@ func (d *sessionDialog) CursorPos() (row, col int) {
423431 return 3, 4 + len([]rune(d.rename))
424432 }
425433
426-// Close hides the dialog and cancels any in-flight session reads.
434+// Close hides the dialog, cancels any in-flight session reads, and abandons
435+// any pending edit or deletion.
427436 func (d *sessionDialog) Close() {
428437 if d.loadCancel != nil {
429438 d.loadCancel()
@@ -447,6 +456,7 @@ func (d *sessionDialog) Close() {
447456 d.searchEvents = nil
448457 d.searchSegments = nil
449458 d.searchMatches = nil
459+ d.deleting = false
450460 d.active = false
451461 }
452462
@@ -632,6 +642,17 @@ func (d *sessionDialog) applySearchFilter() {
632642 }
633643 d.sessions = filtered
634644 }
645+ // Belt and suspenders: a deletion confirmed while a search match or
646+ // load event is being applied must not reappear in the visible rows.
647+ if len(d.removedPaths) > 0 && len(d.sessions) > 0 {
648+ kept := d.sessions[:0]
649+ for _, summary := range d.sessions {
650+ if !d.removedPaths[summary.Path] {
651+ kept = append(kept, summary)
652+ }
653+ }
654+ d.sessions = kept
655+ }
635656 if d.cursor >= len(d.sessions) {
636657 d.cursor = max(0, len(d.sessions)-1)
637658 }
@@ -682,6 +703,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
682703 lines = append(lines, frameRule(th, width))
683704 return lines
684705 }
706+ if d.deleting && d.cursor >= 0 && d.cursor < len(d.sessions) {
707+ s := d.sessions[d.cursor]
708+ lines = append(lines, th.FGColor(th.Muted, "permanently delete this session?"))
709+ lines = append(lines, " "+formatSessionRowPlain(s, width-2))
710+ lines = append(lines, th.FGColor(th.Muted, "press y to delete, enter/esc to cancel"))
711+ lines = append(lines, frameRule(th, width))
712+ return lines
713+ }
685714 if d.renaming {
686715 lines = append(lines, th.FGColor(th.Muted, "rename session (enter save, esc cancel):"))
687716 text := d.rename
@@ -694,7 +723,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
694723 lines = append(lines, frameRule(th, width))
695724 return lines
696725 }
697- hint := "↑/↓ pick · enter resume · / search · tab scope · r rename · esc cancel"
726+ hint := "↑/↓ pick · enter resume · / search · tab scope · r rename · d delete · esc cancel"
698727 if d.searchRequested {
699728 hint = "search: " + d.query + " (esc clear · tab scope)"
700729 if !sessionSearchQuery(d.query) {
@@ -979,6 +1008,23 @@ func formatRelative(t time.Time) string {
9791008
9801009 // HandleKey advances the dialog and returns an action to apply, if any.
9811010 func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
1011+ // Deletion confirmation: y deletes, anything else (including enter and
1012+ // esc) cancels without closing the picker.
1013+ if d.deleting {
1014+ switch k.Kind {
1015+ case tui.KeyRune:
1016+ if (k.Rune == 'y' || k.Rune == 'Y') && d.cursor >= 0 && d.cursor < len(d.sessions) {
1017+ path := d.sessions[d.cursor].Path
1018+ d.deleting = false
1019+ return sessionDialogAction{Delete: true, Path: path}
1020+ }
1021+ d.deleting = false
1022+ case tui.KeyEnter, tui.KeyEsc:
1023+ d.deleting = false
1024+ }
1025+ return sessionDialogAction{}
1026+ }
1027+
9821028 // Rename mode: type the new name.
9831029 if d.renaming {
9841030 switch k.Kind {
@@ -1151,6 +1197,39 @@ navigate:
11511197 }
11521198 return sessionDialogAction{}
11531199 }
1200+ if k.Rune == 'd' && !d.loading && !d.searchRequested && len(d.sessions) > 0 {
1201+ d.deleting = true
1202+ }
11541203 }
11551204 return sessionDialogAction{}
11561205 }
1206+
1207+// Remove deletes path from every picker projection after the host has
1208+// removed the corresponding file. The next row stays selected when
1209+// possible and the dialog stays open. The path is remembered so an
1210+// in-flight load or search generation cannot resurrect the row.
1211+func (d *sessionDialog) Remove(path string) {
1212+ if d.removedPaths == nil {
1213+ d.removedPaths = map[string]bool{}
1214+ }
1215+ d.removedPaths[path] = true
1216+ d.sessions = removeSessionSummary(d.sessions, path)
1217+ d.baseSessions = removeSessionSummary(d.baseSessions, path)
1218+ if d.cursor >= len(d.sessions) {
1219+ d.cursor = len(d.sessions) - 1
1220+ }
1221+ if d.cursor < 0 {
1222+ d.cursor = 0
1223+ }
1224+ d.viewTop = clampViewTop(d.viewTop, d.cursor, d.MaxRows, len(d.sessions))
1225+}
1226+
1227+func removeSessionSummary(summaries []core.SessionSummary, path string) []core.SessionSummary {
1228+ for idx := range summaries {
1229+ if summaries[idx].Path != path {
1230+ continue
1231+ }
1232+ return append(summaries[:idx], summaries[idx+1:]...)
1233+ }
1234+ return summaries
1235+}
M packages/agent/modes/session_dialog_test.go +211 -0
@@ -2,6 +2,8 @@ package modes
22
33 import (
44 "context"
5+ "os"
6+ "path/filepath"
57 "strings"
68 "sync"
79 "testing"
@@ -612,3 +614,212 @@ func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
612614 for range secondEvents {
613615 }
614616 }
617+
618+func TestSessionDialogDeleteRequiresConfirmation(t *testing.T) {
619+ d := newSessionDialog()
620+ d.active = true
621+ d.sessions = []core.SessionSummary{{Path: "/sessions/one.jsonl"}}
622+
623+ if act := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}); act.Delete {
624+ t.Fatal("delete hotkey immediately returned a delete action")
625+ }
626+ if !d.deleting {
627+ t.Fatal("delete hotkey did not open confirmation")
628+ }
629+ if act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); act.Delete {
630+ t.Fatal("enter confirmed deletion; want safe default cancellation")
631+ }
632+ if d.deleting {
633+ t.Fatal("enter did not close deletion confirmation")
634+ }
635+ if !d.Active() {
636+ t.Fatal("enter closed the picker; want the dialog to stay open")
637+ }
638+
639+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'})
640+ if act := d.HandleKey(tui.Key{Kind: tui.KeyEsc}); act.Delete || act.Close {
641+ t.Fatalf("esc action = %+v, want silent cancellation", act)
642+ }
643+ if d.deleting {
644+ t.Fatal("esc did not close deletion confirmation")
645+ }
646+ if !d.Active() {
647+ t.Fatal("esc closed the picker; want the dialog to stay open")
648+ }
649+
650+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'})
651+ act := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'y'})
652+ if !act.Delete || act.Path != "/sessions/one.jsonl" {
653+ t.Fatalf("confirmed action = %+v, want delete for selected path", act)
654+ }
655+ if len(d.sessions) != 1 {
656+ t.Fatal("dialog removed row before the host confirmed filesystem deletion")
657+ }
658+}
659+
660+func TestSessionDialogDeleteConfirmationRender(t *testing.T) {
661+ d := newSessionDialog()
662+ d.active = true
663+ d.sessions = []core.SessionSummary{{Path: "/sessions/one.jsonl", Title: "keep or delete"}}
664+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'})
665+
666+ plain := strings.Join(d.Render(tui.Theme{}, 100), "\n")
667+ for _, want := range []string{"permanently delete", "keep or delete", "press y to delete"} {
668+ if !strings.Contains(plain, want) {
669+ t.Fatalf("confirmation render missing %q:\n%s", want, plain)
670+ }
671+ }
672+ normal := &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: "/sessions/one.jsonl"}}}
673+ if got := strings.Join(normal.Render(tui.Theme{}, 100), "\n"); !strings.Contains(got, "d delete") {
674+ t.Fatalf("picker hint missing delete key:\n%s", got)
675+ }
676+}
677+
678+func TestSessionDialogRemoveKeepsCursorValid(t *testing.T) {
679+ tests := []struct {
680+ name string
681+ cursor int
682+ removePath string
683+ wantCursor int
684+ wantPath string
685+ }{
686+ {name: "first", cursor: 0, removePath: "a", wantCursor: 0, wantPath: "b"},
687+ {name: "middle", cursor: 1, removePath: "b", wantCursor: 1, wantPath: "c"},
688+ {name: "last", cursor: 2, removePath: "c", wantCursor: 1, wantPath: "b"},
689+ }
690+ for _, tt := range tests {
691+ t.Run(tt.name, func(t *testing.T) {
692+ d := &sessionDialog{
693+ sessions: []core.SessionSummary{{Path: "a"}, {Path: "b"}, {Path: "c"}},
694+ baseSessions: []core.SessionSummary{{Path: "a"}, {Path: "b"}, {Path: "c"}},
695+ cursor: tt.cursor,
696+ }
697+ d.Remove(tt.removePath)
698+ if d.cursor != tt.wantCursor {
699+ t.Fatalf("cursor = %d, want %d", d.cursor, tt.wantCursor)
700+ }
701+ if got := d.sessions[d.cursor].Path; got != tt.wantPath {
702+ t.Fatalf("selected path = %q, want %q", got, tt.wantPath)
703+ }
704+ for _, summary := range d.baseSessions {
705+ if summary.Path == tt.removePath {
706+ t.Fatalf("removed path %q still in base sessions", tt.removePath)
707+ }
708+ }
709+ })
710+ }
711+
712+ d := &sessionDialog{sessions: []core.SessionSummary{{Path: "only"}}}
713+ d.Remove("only")
714+ if len(d.sessions) != 0 || d.cursor != 0 {
715+ t.Fatalf("empty dialog has len %d and cursor %d, want 0 and 0", len(d.sessions), d.cursor)
716+ }
717+}
718+
719+func TestSessionDialogRemoveSurvivesLoadRebuild(t *testing.T) {
720+ d := &sessionDialog{
721+ active: true,
722+ sessions: []core.SessionSummary{{Path: "b"}},
723+ baseSessions: []core.SessionSummary{{Path: "a"}, {Path: "b"}},
724+ cursor: 0,
725+ loadSlots: []sessionLoadSlot{
726+ {loaded: true, summary: core.SessionSummary{Path: "a", MessageCount: 1}},
727+ {loaded: true, summary: core.SessionSummary{Path: "b", MessageCount: 1}},
728+ },
729+ }
730+ d.Remove("a")
731+ d.rebuildLoadedSessions(2)
732+ for _, summary := range d.sessions {
733+ if summary.Path == "a" {
734+ t.Fatalf("deleted path resurrected by load rebuild: %+v", d.sessions)
735+ }
736+ }
737+ d.applySearchFilter()
738+ for _, summary := range d.sessions {
739+ if summary.Path == "a" {
740+ t.Fatalf("deleted path resurrected by search filter: %+v", d.sessions)
741+ }
742+ }
743+}
744+
745+func TestApplySessionDeletionRemovesFileAndRow(t *testing.T) {
746+ path := filepath.Join(t.TempDir(), "session.jsonl")
747+ if err := os.WriteFile(path, []byte("session"), 0o600); err != nil {
748+ t.Fatal(err)
749+ }
750+ i := &Interactive{
751+ cfg: InteractiveConfig{CurrentSessionPath: func() string { return "" }},
752+ sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}},
753+ }
754+
755+ i.applySessionDeletion(path)
756+
757+ if _, err := os.Stat(path); !os.IsNotExist(err) {
758+ t.Fatalf("deleted session still exists: %v", err)
759+ }
760+ if len(i.sessionDialog.sessions) != 0 {
761+ t.Fatal("deleted session remains in picker")
762+ }
763+ if !strings.Contains(i.statusOK, "deleted session") || i.statusErr != "" {
764+ t.Fatalf("statusOK = %q, statusErr = %q", i.statusOK, i.statusErr)
765+ }
766+}
767+
768+func TestApplySessionDeletionProtectsActiveSession(t *testing.T) {
769+ path := filepath.Join(t.TempDir(), "session.jsonl")
770+ if err := os.WriteFile(path, []byte("session"), 0o600); err != nil {
771+ t.Fatal(err)
772+ }
773+ i := &Interactive{
774+ cfg: InteractiveConfig{CurrentSessionPath: func() string { return path }},
775+ sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}},
776+ }
777+
778+ i.applySessionDeletion(path)
779+
780+ if _, err := os.Stat(path); err != nil {
781+ t.Fatalf("active session was deleted: %v", err)
782+ }
783+ if len(i.sessionDialog.sessions) != 1 {
784+ t.Fatal("active session was removed from picker")
785+ }
786+ if !strings.Contains(i.statusErr, "active session") {
787+ t.Fatalf("statusErr = %q, want active-session explanation", i.statusErr)
788+ }
789+}
790+
791+func TestApplySessionDeletionRequiresCurrentSessionWiring(t *testing.T) {
792+ path := filepath.Join(t.TempDir(), "session.jsonl")
793+ if err := os.WriteFile(path, []byte("session"), 0o600); err != nil {
794+ t.Fatal(err)
795+ }
796+ i := &Interactive{
797+ sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}},
798+ }
799+
800+ i.applySessionDeletion(path)
801+
802+ if _, err := os.Stat(path); err != nil {
803+ t.Fatalf("session was deleted without active-session wiring: %v", err)
804+ }
805+ if !strings.Contains(i.statusErr, "not wired") {
806+ t.Fatalf("statusErr = %q, want wiring error", i.statusErr)
807+ }
808+}
809+
810+func TestApplySessionDeletionPreservesRowOnError(t *testing.T) {
811+ path := filepath.Join(t.TempDir(), "missing.jsonl")
812+ i := &Interactive{
813+ cfg: InteractiveConfig{CurrentSessionPath: func() string { return "" }},
814+ sessionDialog: &sessionDialog{active: true, sessions: []core.SessionSummary{{Path: path}}},
815+ }
816+
817+ i.applySessionDeletion(path)
818+
819+ if len(i.sessionDialog.sessions) != 1 {
820+ t.Fatal("failed deletion removed session from picker")
821+ }
822+ if !strings.Contains(i.statusErr, "delete session") {
823+ t.Fatalf("statusErr = %q, want deletion error", i.statusErr)
824+ }
825+}
M packages/agent/sdk/sdk.go +1 -1
@@ -78,7 +78,7 @@ type Config struct {
7878 BaseURL string
7979
8080 // Tools is the list of tools to enable. Nil/empty enables the ordinary
81- // built-ins: read, write, edit, bash, python, create_worktree, grep, lsp,
81+ // built-ins: read, write, edit, bash, python, create_worktree, grep, glob, ast, lsp,
8282 // update_goal, and update_plan. The public-web capability is excluded unless this list
8383 // explicitly contains "web_search"; that selector enables web_search,
8484 // web_open, web_find, and web_click. Pass NoTools=true to disable everything.
A packages/agent/sessionscmd.go +433 -0
@@ -0,0 +1,433 @@
1+package agent
2+
3+import (
4+ "bufio"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "io/fs"
9+ "os"
10+ "path/filepath"
11+ "sort"
12+ "strconv"
13+ "strings"
14+ "time"
15+
16+ "github.com/bnema/zut/packages/core"
17+)
18+
19+type sessionsPruneOptions struct {
20+ dryRun bool
21+ all bool
22+ yes bool
23+ olderThan *sessionAge
24+ cwd string
25+}
26+
27+type sessionAge struct {
28+ value int
29+ unit string
30+}
31+
32+type statPathFunc func(string) (fs.FileInfo, error)
33+
34+// runSessionsCommand dispatches standalone session management commands
35+// before normal agent startup, so they do not require a provider or
36+// credentials.
37+func runSessionsCommand(rawArgs []string) (handled bool, err error) {
38+ if len(rawArgs) == 0 || rawArgs[0] != "sessions" {
39+ return false, nil
40+ }
41+ if len(rawArgs) == 1 {
42+ printSessionsHelp(os.Stdout)
43+ return true, nil
44+ }
45+ switch rawArgs[1] {
46+ case "help", "-h", "--help":
47+ printSessionsHelp(os.Stdout)
48+ return true, nil
49+ case "prune":
50+ opts, err := parseSessionsPruneOptions(rawArgs[2:])
51+ if errors.Is(err, errSessionsPruneHelp) {
52+ printSessionsHelp(os.Stdout)
53+ return true, nil
54+ }
55+ if err != nil {
56+ printSessionsHelp(os.Stderr)
57+ return true, err
58+ }
59+ return true, runSessionsPrune(SessionsPath(), opts, os.Stdin, os.Stdout, os.Stderr, os.Stat)
60+ default:
61+ printSessionsHelp(os.Stderr)
62+ return true, fmt.Errorf("unknown sessions subcommand: %s", rawArgs[1])
63+ }
64+}
65+
66+func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) {
67+ var opts sessionsPruneOptions
68+ for idx := 0; idx < len(args); idx++ {
69+ arg := args[idx]
70+ switch arg {
71+ case "--dry-run":
72+ opts.dryRun = true
73+ case "--all":
74+ opts.all = true
75+ case "-y", "--yes":
76+ opts.yes = true
77+ case "--older-than":
78+ if idx+1 >= len(args) {
79+ return opts, fmt.Errorf("--older-than requires an age")
80+ }
81+ idx++
82+ age, err := parseSessionAge(args[idx])
83+ if err != nil {
84+ return opts, err
85+ }
86+ opts.olderThan = &age
87+ case "--cwd":
88+ if idx+1 >= len(args) {
89+ return opts, fmt.Errorf("--cwd requires a path")
90+ }
91+ idx++
92+ if strings.TrimSpace(args[idx]) == "" {
93+ return opts, fmt.Errorf("--cwd requires a non-empty path")
94+ }
95+ opts.cwd = args[idx]
96+ case "-h", "--help":
97+ return opts, errSessionsPruneHelp
98+ default:
99+ return opts, fmt.Errorf("unknown sessions prune flag: %s", arg)
100+ }
101+ }
102+ if opts.yes && !opts.all {
103+ return opts, fmt.Errorf("--yes requires --all")
104+ }
105+ if opts.yes && opts.dryRun {
106+ return opts, fmt.Errorf("--yes cannot be used with --dry-run")
107+ }
108+ if opts.cwd != "" && opts.olderThan == nil {
109+ return opts, fmt.Errorf("--cwd requires --older-than")
110+ }
111+ return opts, nil
112+}
113+
114+func parseSessionAge(value string) (sessionAge, error) {
115+ value = strings.TrimSpace(value)
116+ digitEnd := 0
117+ for digitEnd < len(value) && value[digitEnd] >= '0' && value[digitEnd] <= '9' {
118+ digitEnd++
119+ }
120+ if digitEnd == 0 || digitEnd == len(value) {
121+ return sessionAge{}, fmt.Errorf("invalid age %q (use m, h, d, w, mo, or y)", value)
122+ }
123+ amount, err := strconv.ParseInt(value[:digitEnd], 10, 32)
124+ if err != nil || amount <= 0 {
125+ return sessionAge{}, fmt.Errorf("invalid age %q (value must be a positive whole number)", value)
126+ }
127+ unit := value[digitEnd:]
128+ switch unit {
129+ case "m", "h", "d", "w", "mo", "y":
130+ default:
131+ return sessionAge{}, fmt.Errorf("invalid age unit %q (use m, h, d, w, mo, or y)", unit)
132+ }
133+ age := sessionAge{value: int(amount), unit: unit}
134+ if _, err := age.cutoff(time.Unix(0, 0)); err != nil {
135+ return sessionAge{}, fmt.Errorf("invalid age %q: %w", value, err)
136+ }
137+ return age, nil
138+}
139+
140+func (age sessionAge) cutoff(now time.Time) (time.Time, error) {
141+ var unit time.Duration
142+ switch age.unit {
143+ case "m":
144+ unit = time.Minute
145+ case "h":
146+ unit = time.Hour
147+ case "d":
148+ unit = 24 * time.Hour
149+ case "w":
150+ unit = 7 * 24 * time.Hour
151+ case "mo":
152+ return subtractCalendarAge(now, 0, age.value), nil
153+ case "y":
154+ return subtractCalendarAge(now, age.value, 0), nil
155+ default:
156+ return time.Time{}, fmt.Errorf("unsupported unit %q", age.unit)
157+ }
158+ if int64(age.value) > int64((time.Duration(1<<63-1))/unit) {
159+ return time.Time{}, fmt.Errorf("duration is too large")
160+ }
161+ return now.Add(-time.Duration(age.value) * unit), nil
162+}
163+
164+// subtractCalendarAge clamps the day to the target month's final day. This
165+// makes one month before March 31 the end of February rather than early March.
166+func subtractCalendarAge(now time.Time, years, months int) time.Time {
167+ first := time.Date(now.Year(), now.Month(), 1, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), now.Location())
168+ target := first.AddDate(-years, -months, 0)
169+ lastDay := target.AddDate(0, 1, -1).Day()
170+ day := now.Day()
171+ if day > lastDay {
172+ day = lastDay
173+ }
174+ return time.Date(target.Year(), target.Month(), day, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), now.Location())
175+}
176+
177+var errSessionsPruneHelp = errors.New("sessions prune help requested")
178+
179+func printSessionsHelp(out io.Writer) {
180+ fmt.Fprintln(out, `zut sessions - manage stored sessions
181+
182+usage:
183+ zut sessions prune select missing directory groups
184+ zut sessions prune --older-than 30d select sessions inactive for 30 days
185+ zut sessions prune --older-than 1mo --cwd PATH
186+ zut sessions prune --dry-run list matches without deleting
187+ zut sessions prune --all [--yes] select every match, optionally without prompting
188+
189+Age units are m (minutes), h (hours), d (24-hour days), w (7-day weeks),
190+mo (calendar months), and y (calendar years). Age uses last session activity.
191+Without --older-than, only sessions whose recorded directory is missing match.
192+A missing directory cannot be distinguished from some unmounted filesystems.
193+Unreadable and malformed entries are preserved.`)
194+}
195+
196+func runSessionsPrune(sessionsRoot string, opts sessionsPruneOptions, in io.Reader, out, errOut io.Writer, stat statPathFunc) error {
197+ groups, scanIssues := core.ScanStoredSessionGroups(sessionsRoot)
198+ for _, issue := range scanIssues {
199+ fmt.Fprintf(errOut, "warning: preserving %s: %v\n", issue.Path, issue.Err)
200+ }
201+
202+ candidates := make([]core.StoredSessionGroup, 0, len(groups))
203+ var cutoff time.Time
204+ if opts.olderThan != nil {
205+ var err error
206+ cutoff, err = opts.olderThan.cutoff(time.Now())
207+ if err != nil {
208+ return err
209+ }
210+ cwd := opts.cwd
211+ if cwd != "" {
212+ cwd, err = filepath.Abs(cwd)
213+ if err != nil {
214+ return fmt.Errorf("resolve --cwd: %w", err)
215+ }
216+ cwd = filepath.Clean(cwd)
217+ }
218+ for _, group := range groups {
219+ if cwd != "" && filepath.Clean(group.CWD) != cwd {
220+ continue
221+ }
222+ matching := core.StoredSessionGroup{CWD: group.CWD}
223+ for _, path := range group.Paths {
224+ info, err := stat(path)
225+ if err != nil {
226+ fmt.Fprintf(errOut, "warning: preserving %s: %v\n", path, err)
227+ continue
228+ }
229+ if info.ModTime().Before(cutoff) {
230+ matching.Paths = append(matching.Paths, path)
231+ matching.SizeBytes += info.Size()
232+ }
233+ }
234+ if len(matching.Paths) > 0 {
235+ candidates = append(candidates, matching)
236+ }
237+ }
238+ } else {
239+ for _, group := range groups {
240+ if !filepath.IsAbs(group.CWD) {
241+ fmt.Fprintf(errOut, "warning: preserving sessions with non-absolute cwd %q\n", group.CWD)
242+ continue
243+ }
244+ _, err := stat(group.CWD)
245+ switch {
246+ case err == nil:
247+ continue
248+ case errors.Is(err, fs.ErrNotExist):
249+ candidates = append(candidates, group)
250+ default:
251+ fmt.Fprintf(errOut, "warning: preserving sessions for %s: %v\n", group.CWD, err)
252+ }
253+ }
254+ }
255+ if len(candidates) == 0 {
256+ if opts.olderThan != nil {
257+ fmt.Fprintln(out, "no sessions older than the requested age found")
258+ } else {
259+ fmt.Fprintln(out, "no stale session directories found")
260+ }
261+ return nil
262+ }
263+
264+ if opts.olderThan != nil {
265+ fmt.Fprintln(out, "session directories with inactive sessions:")
266+ } else {
267+ fmt.Fprintln(out, "missing session directories:")
268+ }
269+ for idx, group := range candidates {
270+ fmt.Fprintf(out, " %d. %s (%s, %d bytes)\n", idx+1, group.CWD, sessionCount(len(group.Paths)), group.SizeBytes)
271+ }
272+ if opts.dryRun {
273+ if opts.all {
274+ fmt.Fprintf(out, "dry run: %s in %s would be deleted\n", sessionCount(totalSessions(candidates)), directoryCount(len(candidates)))
275+ } else {
276+ fmt.Fprintln(out, "dry run: nothing selected; add --all to preview deletion of every match")
277+ }
278+ return nil
279+ }
280+
281+ reader := bufio.NewReader(in)
282+ selected := make([]int, 0, len(candidates))
283+ if opts.all {
284+ for idx := range candidates {
285+ selected = append(selected, idx)
286+ }
287+ } else {
288+ fmt.Fprint(out, "select groups to delete (for example 1,3-5; all; none): ")
289+ line, err := reader.ReadString('\n')
290+ if err != nil && !errors.Is(err, io.EOF) {
291+ return fmt.Errorf("read selection: %w", err)
292+ }
293+ selected, err = parseSessionSelection(line, len(candidates))
294+ if err != nil {
295+ return err
296+ }
297+ if len(selected) == 0 {
298+ fmt.Fprintln(out, "no sessions deleted")
299+ return nil
300+ }
301+ }
302+
303+ selectedSessions := 0
304+ for _, idx := range selected {
305+ selectedSessions += len(candidates[idx].Paths)
306+ }
307+ if !opts.yes {
308+ fmt.Fprintf(out, "permanently delete %s in %s? [y/N]: ", sessionCount(selectedSessions), directoryCount(len(selected)))
309+ line, err := reader.ReadString('\n')
310+ if err != nil && !errors.Is(err, io.EOF) {
311+ return fmt.Errorf("read confirmation: %w", err)
312+ }
313+ answer := strings.ToLower(strings.TrimSpace(line))
314+ if answer != "y" && answer != "yes" {
315+ fmt.Fprintln(out, "no sessions deleted")
316+ return nil
317+ }
318+ }
319+
320+ deletedSessions := 0
321+ deletedGroups := 0
322+ var deleteErrors []error
323+ for _, idx := range selected {
324+ group := candidates[idx]
325+ if opts.olderThan == nil {
326+ // Recheck just before deleting: a directory that reappeared
327+ // (remount, restore) must not lose its sessions.
328+ _, err := stat(group.CWD)
329+ if err == nil {
330+ fmt.Fprintf(errOut, "warning: preserving sessions for %s because the directory now exists\n", group.CWD)
331+ continue
332+ }
333+ if !errors.Is(err, fs.ErrNotExist) {
334+ fmt.Fprintf(errOut, "warning: preserving sessions for %s after recheck: %v\n", group.CWD, err)
335+ continue
336+ }
337+ }
338+
339+ groupDeleted := 0
340+ for _, path := range group.Paths {
341+ if opts.olderThan != nil {
342+ info, err := stat(path)
343+ if err != nil {
344+ fmt.Fprintf(errOut, "warning: preserving %s after recheck: %v\n", path, err)
345+ continue
346+ }
347+ if !info.ModTime().Before(cutoff) {
348+ fmt.Fprintf(errOut, "warning: preserving %s because it now has recent activity\n", path)
349+ continue
350+ }
351+ }
352+ if err := core.DeleteSession(path); err != nil {
353+ deleteErrors = append(deleteErrors, fmt.Errorf("delete %s: %w", path, err))
354+ continue
355+ }
356+ groupDeleted++
357+ }
358+ deletedSessions += groupDeleted
359+ if groupDeleted == len(group.Paths) {
360+ deletedGroups++
361+ }
362+ }
363+ fmt.Fprintf(out, "deleted %s from %s\n", sessionCount(deletedSessions), directoryCount(deletedGroups))
364+ return errors.Join(deleteErrors...)
365+}
366+
367+func parseSessionSelection(input string, count int) ([]int, error) {
368+ input = strings.ToLower(strings.TrimSpace(input))
369+ switch input {
370+ case "", "none", "n":
371+ return nil, nil
372+ case "all", "a":
373+ selected := make([]int, count)
374+ for idx := range selected {
375+ selected[idx] = idx
376+ }
377+ return selected, nil
378+ }
379+
380+ seen := make(map[int]bool)
381+ for _, field := range strings.Split(input, ",") {
382+ field = strings.TrimSpace(field)
383+ if field == "" {
384+ return nil, fmt.Errorf("invalid empty selection")
385+ }
386+ startText, endText, isRange := strings.Cut(field, "-")
387+ start, err := strconv.Atoi(strings.TrimSpace(startText))
388+ if err != nil {
389+ return nil, fmt.Errorf("invalid selection %q", field)
390+ }
391+ end := start
392+ if isRange {
393+ end, err = strconv.Atoi(strings.TrimSpace(endText))
394+ if err != nil || end < start {
395+ return nil, fmt.Errorf("invalid selection range %q", field)
396+ }
397+ }
398+ if start < 1 || end > count {
399+ return nil, fmt.Errorf("selection %q is outside 1-%d", field, count)
400+ }
401+ for value := start; value <= end; value++ {
402+ seen[value-1] = true
403+ }
404+ }
405+ selected := make([]int, 0, len(seen))
406+ for idx := range seen {
407+ selected = append(selected, idx)
408+ }
409+ sort.Ints(selected)
410+ return selected, nil
411+}
412+
413+func totalSessions(groups []core.StoredSessionGroup) int {
414+ total := 0
415+ for _, group := range groups {
416+ total += len(group.Paths)
417+ }
418+ return total
419+}
420+
421+func sessionCount(count int) string {
422+ if count == 1 {
423+ return "1 session"
424+ }
425+ return fmt.Sprintf("%d sessions", count)
426+}
427+
428+func directoryCount(count int) string {
429+ if count == 1 {
430+ return "1 directory"
431+ }
432+ return fmt.Sprintf("%d directories", count)
433+}
A packages/agent/sessionscmd_test.go +372 -0
@@ -0,0 +1,372 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "io/fs"
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/zut/packages/core"
14+ "github.com/bnema/zut/packages/provider"
15+)
16+
17+func TestParseSessionSelection(t *testing.T) {
18+ tests := []struct {
19+ input string
20+ count int
21+ want []int
22+ wantErr bool
23+ }{
24+ {input: "", count: 3},
25+ {input: "none", count: 3},
26+ {input: "all", count: 3, want: []int{0, 1, 2}},
27+ {input: "1,3-4,3", count: 4, want: []int{0, 2, 3}},
28+ {input: "0", count: 3, wantErr: true},
29+ {input: "4", count: 3, wantErr: true},
30+ {input: "3-2", count: 3, wantErr: true},
31+ {input: "1,,2", count: 3, wantErr: true},
32+ }
33+ for _, tt := range tests {
34+ t.Run("select/"+tt.input, func(t *testing.T) {
35+ got, err := parseSessionSelection(tt.input, tt.count)
36+ if (err != nil) != tt.wantErr {
37+ t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
38+ }
39+ if !reflect.DeepEqual(got, tt.want) {
40+ t.Fatalf("selection = %v, want %v", got, tt.want)
41+ }
42+ })
43+ }
44+}
45+
46+func TestParseSessionAge(t *testing.T) {
47+ now := time.Date(2026, time.August, 22, 12, 0, 0, 0, time.UTC)
48+ tests := []struct {
49+ input string
50+ want time.Time
51+ wantErr bool
52+ }{
53+ {input: "30m", want: now.Add(-30 * time.Minute)},
54+ {input: "4h", want: now.Add(-4 * time.Hour)},
55+ {input: "30d", want: now.Add(-30 * 24 * time.Hour)},
56+ {input: "2w", want: now.Add(-14 * 24 * time.Hour)},
57+ {input: "1mo", want: now.AddDate(0, -1, 0)},
58+ {input: "1y", want: now.AddDate(-1, 0, 0)},
59+ {input: "", wantErr: true},
60+ {input: "0d", wantErr: true},
61+ {input: "-1d", wantErr: true},
62+ {input: "1.5h", wantErr: true},
63+ {input: "1s", wantErr: true},
64+ {input: "99999999999999999999d", wantErr: true},
65+ }
66+ for _, tt := range tests {
67+ t.Run("age/"+tt.input, func(t *testing.T) {
68+ age, err := parseSessionAge(tt.input)
69+ if (err != nil) != tt.wantErr {
70+ t.Fatalf("parseSessionAge(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
71+ }
72+ if tt.wantErr {
73+ return
74+ }
75+ got, err := age.cutoff(now)
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ if !got.Equal(tt.want) {
80+ t.Fatalf("cutoff = %v, want %v", got, tt.want)
81+ }
82+ })
83+ }
84+}
85+
86+func TestSessionAgeClampsCalendarBoundaries(t *testing.T) {
87+ month, err := parseSessionAge("1mo")
88+ if err != nil {
89+ t.Fatal(err)
90+ }
91+ got, err := month.cutoff(time.Date(2025, time.March, 31, 12, 0, 0, 0, time.UTC))
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ want := time.Date(2025, time.February, 28, 12, 0, 0, 0, time.UTC)
96+ if !got.Equal(want) {
97+ t.Fatalf("one month cutoff = %v, want %v", got, want)
98+ }
99+
100+ year, err := parseSessionAge("1y")
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+ got, err = year.cutoff(time.Date(2024, time.February, 29, 12, 0, 0, 0, time.UTC))
105+ if err != nil {
106+ t.Fatal(err)
107+ }
108+ want = time.Date(2023, time.February, 28, 12, 0, 0, 0, time.UTC)
109+ if !got.Equal(want) {
110+ t.Fatalf("one year cutoff = %v, want %v", got, want)
111+ }
112+}
113+
114+func TestParseSessionsPruneOptions(t *testing.T) {
115+ if _, err := parseSessionsPruneOptions([]string{"--yes"}); err == nil {
116+ t.Fatal("--yes without --all was accepted")
117+ }
118+ if _, err := parseSessionsPruneOptions([]string{"--cwd", "/tmp/project"}); err == nil {
119+ t.Fatal("--cwd without --older-than was accepted")
120+ }
121+ if _, err := parseSessionsPruneOptions([]string{"--all", "--yes", "--dry-run"}); err == nil {
122+ t.Fatal("--yes with --dry-run was accepted")
123+ }
124+ if _, err := parseSessionsPruneOptions([]string{"--bogus"}); err == nil {
125+ t.Fatal("unknown flag was accepted")
126+ }
127+ if _, err := parseSessionsPruneOptions([]string{"--older-than"}); err == nil {
128+ t.Fatal("missing --older-than value was accepted")
129+ }
130+ opts, err := parseSessionsPruneOptions([]string{"--older-than", "1mo", "--cwd", "/tmp/project", "--all", "--yes"})
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ if !opts.all || !opts.yes || opts.olderThan == nil || opts.olderThan.unit != "mo" || opts.cwd != "/tmp/project" {
135+ t.Fatalf("options = %#v", opts)
136+ }
137+}
138+
139+func TestSessionsPruneDryRunPreservesSessions(t *testing.T) {
140+ root := t.TempDir()
141+ missing := filepath.Join(root, "missing-project")
142+ existing := t.TempDir()
143+ missingPath := createPruneTestSession(t, root, missing)
144+ existingPath := createPruneTestSession(t, root, existing)
145+
146+ var out, errOut bytes.Buffer
147+ if err := runSessionsPrune(root, sessionsPruneOptions{dryRun: true, all: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
148+ t.Fatal(err)
149+ }
150+ if !strings.Contains(out.String(), missing) || strings.Contains(out.String(), existing) {
151+ t.Fatalf("output = %q, want only missing cwd", out.String())
152+ }
153+ if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") {
154+ t.Fatalf("output = %q, want dry-run summary", out.String())
155+ }
156+ for _, path := range []string{missingPath, existingPath} {
157+ if _, err := os.Stat(path); err != nil {
158+ t.Fatalf("dry run removed %s: %v", path, err)
159+ }
160+ }
161+}
162+
163+func TestSessionsPruneInteractiveDeletesSelectedGroup(t *testing.T) {
164+ root := t.TempDir()
165+ firstCWD := filepath.Join(root, "gone-a")
166+ secondCWD := filepath.Join(root, "gone-b")
167+ first := createPruneTestSession(t, root, firstCWD)
168+ second := createPruneTestSession(t, root, secondCWD)
169+
170+ var out, errOut bytes.Buffer
171+ if err := runSessionsPrune(root, sessionsPruneOptions{}, strings.NewReader("2\ny\n"), &out, &errOut, os.Stat); err != nil {
172+ t.Fatal(err)
173+ }
174+ if _, err := os.Stat(first); err != nil {
175+ t.Fatalf("unselected session was removed: %v", err)
176+ }
177+ if _, err := os.Stat(second); !os.IsNotExist(err) {
178+ t.Fatalf("selected session still exists or stat failed unexpectedly: %v", err)
179+ }
180+ if !strings.Contains(out.String(), "deleted 1 session from 1 directory") {
181+ t.Fatalf("output = %q", out.String())
182+ }
183+}
184+
185+func TestSessionsPruneDeclinedConfirmationDeletesNothing(t *testing.T) {
186+ root := t.TempDir()
187+ path := createPruneTestSession(t, root, filepath.Join(root, "gone"))
188+
189+ var out, errOut bytes.Buffer
190+ if err := runSessionsPrune(root, sessionsPruneOptions{all: true}, strings.NewReader("n\n"), &out, &errOut, os.Stat); err != nil {
191+ t.Fatal(err)
192+ }
193+ if _, err := os.Stat(path); err != nil {
194+ t.Fatalf("declined confirmation removed the session: %v", err)
195+ }
196+ if !strings.Contains(out.String(), "no sessions deleted") {
197+ t.Fatalf("output = %q, want declined message", out.String())
198+ }
199+}
200+
201+func TestSessionsPruneAllYesDeletesWithoutPrompting(t *testing.T) {
202+ root := t.TempDir()
203+ first := createPruneTestSession(t, root, filepath.Join(root, "gone-a"))
204+ second := createPruneTestSession(t, root, filepath.Join(root, "gone-b"))
205+
206+ var out, errOut bytes.Buffer
207+ if err := runSessionsPrune(root, sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
208+ t.Fatal(err)
209+ }
210+ for _, path := range []string{first, second} {
211+ if _, err := os.Stat(path); !os.IsNotExist(err) {
212+ t.Fatalf("session %s still exists: %v", path, err)
213+ }
214+ }
215+ if !strings.Contains(out.String(), "deleted 2 sessions from 2 directories") {
216+ t.Fatalf("output = %q", out.String())
217+ }
218+}
219+
220+func TestSessionsPrunePreservesGroupWhenDirectoryCheckIsInconclusive(t *testing.T) {
221+ root := t.TempDir()
222+ cwd := filepath.Join(root, "unavailable-mount")
223+ path := createPruneTestSession(t, root, cwd)
224+ stat := func(string) (fs.FileInfo, error) { return nil, fs.ErrPermission }
225+
226+ var out, errOut bytes.Buffer
227+ if err := runSessionsPrune(root, sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat); err != nil {
228+ t.Fatal(err)
229+ }
230+ if _, err := os.Stat(path); err != nil {
231+ t.Fatalf("session was removed after inconclusive stat: %v", err)
232+ }
233+ if !strings.Contains(errOut.String(), "permission denied") {
234+ t.Fatalf("stderr = %q, want stat warning", errOut.String())
235+ }
236+ if !strings.Contains(out.String(), "no stale session directories found") {
237+ t.Fatalf("output = %q", out.String())
238+ }
239+}
240+
241+func TestSessionsPruneRechecksDirectoryBeforeDeleting(t *testing.T) {
242+ root := t.TempDir()
243+ cwd := filepath.Join(root, "temporarily-missing")
244+ path := createPruneTestSession(t, root, cwd)
245+ calls := 0
246+ stat := func(string) (fs.FileInfo, error) {
247+ calls++
248+ if calls == 1 {
249+ return nil, fs.ErrNotExist
250+ }
251+ return nil, nil
252+ }
253+
254+ var out, errOut bytes.Buffer
255+ if err := runSessionsPrune(root, sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat); err != nil {
256+ t.Fatal(err)
257+ }
258+ if _, err := os.Stat(path); err != nil {
259+ t.Fatalf("session was removed after cwd reappeared: %v", err)
260+ }
261+ if !strings.Contains(errOut.String(), "directory now exists") {
262+ t.Fatalf("stderr = %q, want recheck warning", errOut.String())
263+ }
264+}
265+
266+func TestSessionsPruneOlderThanFiltersByLastActivity(t *testing.T) {
267+ root := t.TempDir()
268+ cwd := t.TempDir()
269+ oldPath := createPruneTestSession(t, root, cwd)
270+ newPath := createPruneTestSession(t, root, cwd)
271+ oldTime := time.Now().Add(-48 * time.Hour)
272+ if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
273+ t.Fatal(err)
274+ }
275+
276+ age, err := parseSessionAge("24h")
277+ if err != nil {
278+ t.Fatal(err)
279+ }
280+ var out, errOut bytes.Buffer
281+ opts := sessionsPruneOptions{dryRun: true, all: true, olderThan: &age}
282+ if err := runSessionsPrune(root, opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
283+ t.Fatal(err)
284+ }
285+ if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") {
286+ t.Fatalf("output = %q", out.String())
287+ }
288+ for _, path := range []string{oldPath, newPath} {
289+ if _, err := os.Stat(path); err != nil {
290+ t.Fatalf("dry run removed %s: %v", path, err)
291+ }
292+ }
293+}
294+
295+func TestSessionsPruneOlderThanLimitsDeletionToCWD(t *testing.T) {
296+ root := t.TempDir()
297+ firstCWD := t.TempDir()
298+ secondCWD := t.TempDir()
299+ first := createPruneTestSession(t, root, firstCWD)
300+ second := createPruneTestSession(t, root, secondCWD)
301+ oldTime := time.Now().Add(-48 * time.Hour)
302+ for _, path := range []string{first, second} {
303+ if err := os.Chtimes(path, oldTime, oldTime); err != nil {
304+ t.Fatal(err)
305+ }
306+ }
307+
308+ age, err := parseSessionAge("24h")
309+ if err != nil {
310+ t.Fatal(err)
311+ }
312+ var out, errOut bytes.Buffer
313+ opts := sessionsPruneOptions{all: true, yes: true, olderThan: &age, cwd: firstCWD}
314+ if err := runSessionsPrune(root, opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
315+ t.Fatal(err)
316+ }
317+ if _, err := os.Stat(first); !os.IsNotExist(err) {
318+ t.Fatalf("matching session still exists or stat failed unexpectedly: %v", err)
319+ }
320+ if _, err := os.Stat(second); err != nil {
321+ t.Fatalf("session from another cwd was removed: %v", err)
322+ }
323+}
324+
325+func TestSessionsPruneWarnsOnScanIssues(t *testing.T) {
326+ root := t.TempDir()
327+ kept := createPruneTestSession(t, root, filepath.Join(root, "gone"))
328+ if err := os.WriteFile(filepath.Join(root, "broken.jsonl"), []byte("{not json\n"), 0o600); err != nil {
329+ t.Fatal(err)
330+ }
331+
332+ var out, errOut bytes.Buffer
333+ if err := runSessionsPrune(root, sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
334+ t.Fatal(err)
335+ }
336+ if !strings.Contains(errOut.String(), "warning: preserving") {
337+ t.Fatalf("stderr = %q, want scan-issue warning", errOut.String())
338+ }
339+ if _, err := os.Stat(filepath.Join(root, "broken.jsonl")); err != nil {
340+ t.Fatalf("malformed entry was removed: %v", err)
341+ }
342+ if _, err := os.Stat(kept); !os.IsNotExist(err) {
343+ t.Fatalf("stale session still exists: %v", err)
344+ }
345+}
346+
347+func TestSessionsCommandDispatch(t *testing.T) {
348+ if handled, err := runSessionsCommand(nil); handled || err != nil {
349+ t.Fatalf("handled=%v err=%v, want passthrough", handled, err)
350+ }
351+ if handled, err := runSessionsCommand([]string{"other"}); handled || err != nil {
352+ t.Fatalf("handled=%v err=%v, want passthrough", handled, err)
353+ }
354+ if handled, err := runSessionsCommand([]string{"sessions", "bogus"}); !handled || err == nil {
355+ t.Fatalf("handled=%v err=%v, want handled error", handled, err)
356+ }
357+}
358+
359+func createPruneTestSession(t *testing.T, root, cwd string) string {
360+ t.Helper()
361+ session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
362+ if err != nil {
363+ t.Fatal(err)
364+ }
365+ if err := session.AppendMessage(provider.Message{
366+ Role: provider.RoleUser,
367+ Content: []provider.Content{provider.TextBlock{Text: "hello"}},
368+ }); err != nil {
369+ t.Fatal(err)
370+ }
371+ return session.Path
372+}
A packages/agent/tools/glob.go +402 -0
@@ -0,0 +1,402 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "regexp"
10+ "sort"
11+ "strings"
12+ "unicode/utf8"
13+
14+ "github.com/bnema/zut/packages/core"
15+ "github.com/bnema/zut/packages/ignore"
16+ "github.com/bnema/zut/packages/provider"
17+)
18+
19+const (
20+ maxGlobInputBytes = 4 * 1024
21+ maxGlobMatches = 500
22+ maxGlobDepth = 24
23+)
24+
25+// GlobTool finds files matching a pattern within a directory tree.
26+type GlobTool struct {
27+ CWD string
28+ Sandbox *Sandbox
29+}
30+
31+var _ core.Tool = (*GlobTool)(nil)
32+
33+type globArgs struct {
34+ Pattern string `json:"pattern"`
35+ Path string `json:"path,omitempty"`
36+ Hidden bool `json:"hidden,omitempty"`
37+}
38+
39+const globSchema = `{"type":"object","properties":{"pattern":{"type":"string","description":"Glob pattern to match files against (e.g. \"**/*.go\", \"*.json\", \"src/**/*.ts\")"},"path":{"type":"string","description":"Directory to search within, relative to CWD (defaults to \".\")"},"hidden":{"type":"boolean","description":"Whether to include hidden files and directories (default false)"}},"required":["pattern"],"additionalProperties":false}`
40+
41+func (t *GlobTool) Name() string { return "glob" }
42+
43+func (t *GlobTool) Description() string {
44+ return "Find files matching a glob pattern (e.g. \"**/*.go\", \"*.json\", \"src/**/*.ts\"). Honors .gitignore rules."
45+}
46+
47+func (t *GlobTool) Schema() json.RawMessage { return json.RawMessage(globSchema) }
48+
49+// Execute walks the requested directory and returns the files matching the
50+// pattern. The search root is resolved and checked before traversal so a
51+// denied path cannot be used to probe the filesystem.
52+func (t *GlobTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
53+ args, err := parseGlobArgs(raw)
54+ if err != nil {
55+ return core.ToolResult{}, err
56+ }
57+ if err := ctx.Err(); err != nil {
58+ return core.ToolResult{}, err
59+ }
60+
61+ re, hasSlash, err := compileGlob(args.Pattern)
62+ if err != nil {
63+ return core.ToolResult{}, err
64+ }
65+
66+ root := filepath.Clean(resolvePath(t.CWD, args.Path))
67+ if !filepath.IsAbs(root) {
68+ root, err = filepath.Abs(root)
69+ if err != nil {
70+ return core.ToolResult{}, err
71+ }
72+ }
73+ if err := t.Sandbox.CheckReadPath(root); err != nil {
74+ return core.ToolResult{}, err
75+ }
76+ info, err := os.Stat(root)
77+ if err != nil {
78+ return core.ToolResult{}, err
79+ }
80+ if !info.IsDir() {
81+ shown := t.Sandbox.DisplayPath(root, args.Path)
82+ return core.ToolResult{}, fmt.Errorf("glob: %s is not a directory", shown)
83+ }
84+
85+ // WalkDir does not follow a symlink used as its root. Resolve the
86+ // explicit search directory so a path that Stat identified as a
87+ // directory is actually traversed, then re-check the resolved target
88+ // before reading it.
89+ searchDir, err := filepath.EvalSymlinks(root)
90+ if err != nil {
91+ return core.ToolResult{}, err
92+ }
93+ if err := t.Sandbox.CheckReadPath(searchDir); err != nil {
94+ return core.ToolResult{}, err
95+ }
96+
97+ stack, ignoreRoot, searchIgnored := t.globIgnoreStack(searchDir)
98+ if searchIgnored {
99+ // An explicitly requested root is always walked, even when an
100+ // ignore rule matches it: like ripgrep with an explicit path, the
101+ // user's narrowing wins over inherited rules. Nested ignore files
102+ // still apply inside the walk.
103+ stack, ignoreRoot = ignore.NewStack(searchDir), searchDir
104+ }
105+ rootSep := strings.Count(searchDir, string(os.PathSeparator))
106+ var pushed []string
107+ var matches []string
108+ var discovered []string
109+ truncated := false
110+
111+ var walkErr error
112+ walkErr = filepath.WalkDir(searchDir, func(path string, d os.DirEntry, err error) error {
113+ if ctxErr := ctx.Err(); ctxErr != nil {
114+ return ctxErr
115+ }
116+ if err != nil {
117+ if d != nil && d.IsDir() {
118+ return filepath.SkipDir
119+ }
120+ return nil
121+ }
122+ if path == searchDir {
123+ return nil
124+ }
125+
126+ rel, relErr := filepath.Rel(searchDir, path)
127+ if relErr != nil {
128+ return nil
129+ }
130+ relSlash := filepath.ToSlash(rel)
131+
132+ // Hidden entries are skipped unless explicitly requested.
133+ // .git is always skipped. Nested symlinks are never followed: a
134+ // symlink to a directory is reported by WalkDir as a non-directory,
135+ // so resolve it explicitly and skip directory targets instead of
136+ // listing them as file matches.
137+ if d.Type()&os.ModeSymlink != 0 {
138+ if target, evalErr := filepath.EvalSymlinks(path); evalErr == nil {
139+ if targetInfo, statErr := os.Stat(target); statErr == nil && targetInfo.IsDir() {
140+ return nil
141+ }
142+ }
143+ }
144+ if !args.Hidden {
145+ if strings.HasPrefix(d.Name(), ".") {
146+ if d.IsDir() {
147+ return filepath.SkipDir
148+ }
149+ return nil
150+ }
151+ } else if d.IsDir() && d.Name() == ".git" {
152+ return filepath.SkipDir
153+ }
154+
155+ ignoreRel, ignoreRelErr := filepath.Rel(ignoreRoot, path)
156+ if ignoreRelErr != nil {
157+ return nil
158+ }
159+ ignoreRelSlash := filepath.ToSlash(ignoreRel)
160+
161+ // Ignore filtering: pop stack frames for directories no
162+ // longer in scope.
163+ dirSlash := relSlash
164+ if !d.IsDir() {
165+ if idx := strings.LastIndex(relSlash, "/"); idx >= 0 {
166+ dirSlash = relSlash[:idx]
167+ } else {
168+ dirSlash = ""
169+ }
170+ }
171+ for len(pushed) > 0 {
172+ top := pushed[len(pushed)-1]
173+ if top == dirSlash || strings.HasPrefix(dirSlash, top+"/") {
174+ break
175+ }
176+ pushed = pushed[:len(pushed)-1]
177+ stack.Pop()
178+ }
179+
180+ if stack.Match(ignoreRelSlash, d.IsDir()) {
181+ if d.IsDir() {
182+ return filepath.SkipDir
183+ }
184+ return nil
185+ }
186+
187+ if d.IsDir() {
188+ if strings.Count(path, string(os.PathSeparator))-rootSep >= maxGlobDepth {
189+ return filepath.SkipDir
190+ }
191+ stack.Push(path, ignoreRel)
192+ pushed = append(pushed, relSlash)
193+ return nil
194+ }
195+
196+ var matched bool
197+ if hasSlash {
198+ matched = re.MatchString(relSlash)
199+ } else {
200+ matched = re.MatchString(d.Name())
201+ }
202+ if matched {
203+ matches = append(matches, t.globDisplayPath(root, args.Path, relSlash))
204+ discovered = append(discovered, canonicalResourcePath(filepath.Join(searchDir, filepath.FromSlash(relSlash))))
205+ if len(matches) >= maxGlobMatches {
206+ truncated = true
207+ return filepath.SkipAll
208+ }
209+ }
210+ return nil
211+ })
212+
213+ if walkErr != nil && walkErr != filepath.SkipAll {
214+ return core.ToolResult{}, walkErr
215+ }
216+
217+ sort.Strings(matches)
218+
219+ var text string
220+ if len(matches) == 0 {
221+ text = "No files matched the pattern."
222+ } else {
223+ text = strings.Join(matches, "\n")
224+ if truncated {
225+ text += fmt.Sprintf("\n\n(Truncated: showing first %d matches)", maxGlobMatches)
226+ }
227+ }
228+
229+ return core.ToolResult{
230+ Content: []provider.Content{provider.TextBlock{Text: text}},
231+ Context: provider.ToolContext{Discovers: discovered},
232+ Details: map[string]any{
233+ "matches": len(matches),
234+ "truncated": truncated,
235+ "pattern": args.Pattern,
236+ },
237+ }, nil
238+}
239+
240+func parseGlobArgs(raw json.RawMessage) (globArgs, error) {
241+ if len(raw) == 0 || len(raw) > maxGlobInputBytes {
242+ return globArgs{}, fmt.Errorf("glob: invalid arguments")
243+ }
244+ var fields map[string]json.RawMessage
245+ if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
246+ return globArgs{}, fmt.Errorf("glob: invalid arguments")
247+ }
248+ for key := range fields {
249+ if key != "pattern" && key != "path" && key != "hidden" {
250+ return globArgs{}, fmt.Errorf("glob: unknown argument %q", key)
251+ }
252+ }
253+ var args globArgs
254+ if err := json.Unmarshal(raw, &args); err != nil {
255+ return globArgs{}, fmt.Errorf("glob: invalid arguments: %w", err)
256+ }
257+ if _, ok := fields["pattern"]; !ok || strings.TrimSpace(args.Pattern) == "" {
258+ return globArgs{}, fmt.Errorf("glob: pattern is required")
259+ }
260+ if !utf8.ValidString(args.Pattern) || !utf8.ValidString(args.Path) ||
261+ strings.IndexByte(args.Pattern, 0) >= 0 || strings.IndexByte(args.Path, 0) >= 0 {
262+ return globArgs{}, fmt.Errorf("glob: arguments contain invalid characters")
263+ }
264+ return args, nil
265+}
266+
267+// globIgnoreStack builds an ignore stack rooted at CWD when the search path
268+// is below it, preloading the ignore files between CWD and the search root.
269+// This preserves inherited rules when the caller narrows a search with path.
270+func (t *GlobTool) globIgnoreStack(searchDir string) (*ignore.Stack, string, bool) {
271+ cwd := resolvePath(t.CWD, ".")
272+ cwd, err := filepath.EvalSymlinks(cwd)
273+ if err != nil || t.Sandbox.CheckReadPath(cwd) != nil {
274+ return ignore.NewStack(searchDir), searchDir, false
275+ }
276+
277+ rel, err := filepath.Rel(cwd, searchDir)
278+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
279+ return ignore.NewStack(searchDir), searchDir, false
280+ }
281+
282+ stack := ignore.NewStack(cwd)
283+ if rel == "." {
284+ return stack, cwd, false
285+ }
286+
287+ current := cwd
288+ var relParts []string
289+ for _, part := range strings.Split(rel, string(os.PathSeparator)) {
290+ current = filepath.Join(current, part)
291+ relParts = append(relParts, part)
292+ currentRel := filepath.Join(relParts...)
293+ if stack.Match(filepath.ToSlash(currentRel), true) {
294+ return stack, cwd, true
295+ }
296+ stack.Push(current, currentRel)
297+ }
298+ return stack, cwd, false
299+}
300+
301+func (t *GlobTool) globDisplayPath(searchDir, given, relSlash string) string {
302+ if given == "" || given == "." {
303+ return relSlash
304+ }
305+ // Prefer the path exactly as requested so a symlinked search root
306+ // keeps its name in results instead of the resolved target.
307+ if !filepath.IsAbs(given) {
308+ cleaned := filepath.ToSlash(filepath.Clean(given))
309+ if cleaned != "" && cleaned != "." {
310+ return strings.TrimSuffix(cleaned, "/") + "/" + relSlash
311+ }
312+ return relSlash
313+ }
314+ prefix := filepath.ToSlash(t.Sandbox.DisplayPath(searchDir, given))
315+ if prefix == "" || prefix == "." {
316+ return relSlash
317+ }
318+ return strings.TrimSuffix(prefix, "/") + "/" + relSlash
319+}
320+
321+// compileGlob converts a slash-normalized glob pattern into a regular
322+// expression. It supports '*', '**', '?', and character classes '[...]'.
323+// Patterns containing a slash match against the path relative to the search
324+// root; patterns without one match against the file basename.
325+func compileGlob(pattern string) (*regexp.Regexp, bool, error) {
326+ pattern = filepath.ToSlash(strings.TrimSpace(pattern))
327+ pattern = strings.TrimPrefix(pattern, "./")
328+ if pattern == "" {
329+ return nil, false, fmt.Errorf("glob: empty pattern")
330+ }
331+
332+ hasSlash := strings.Contains(pattern, "/")
333+
334+ var sb strings.Builder
335+ sb.WriteString("^")
336+ i := 0
337+ for i < len(pattern) {
338+ switch c := pattern[i]; c {
339+ case '*':
340+ if i+1 < len(pattern) && pattern[i+1] == '*' {
341+ i += 2
342+ if i < len(pattern) && pattern[i] == '/' {
343+ i++
344+ sb.WriteString("(?:.*/)?")
345+ } else {
346+ sb.WriteString(".*")
347+ }
348+ } else {
349+ i++
350+ if hasSlash {
351+ sb.WriteString("[^/]*")
352+ } else {
353+ sb.WriteString(".*")
354+ }
355+ }
356+ case '?':
357+ i++
358+ if hasSlash {
359+ sb.WriteString("[^/]")
360+ } else {
361+ sb.WriteString(".")
362+ }
363+ case '[':
364+ j := i + 1
365+ negated := false
366+ if j < len(pattern) && (pattern[j] == '^' || pattern[j] == '!') {
367+ negated = pattern[j] == '!'
368+ j++
369+ }
370+ if j < len(pattern) && pattern[j] == ']' {
371+ j++
372+ }
373+ for j < len(pattern) && pattern[j] != ']' {
374+ j++
375+ }
376+ if j < len(pattern) {
377+ class := pattern[i+1 : j+1]
378+ if negated {
379+ class = "^" + class[1:]
380+ }
381+ sb.WriteString("[" + class)
382+ i = j + 1
383+ } else {
384+ return nil, false, fmt.Errorf("glob: invalid pattern %q: unterminated character class", pattern)
385+ }
386+ case '.', '+', '(', ')', '^', '$', '|', '\\', '{', '}', ',':
387+ sb.WriteByte('\\')
388+ sb.WriteByte(c)
389+ i++
390+ default:
391+ sb.WriteByte(c)
392+ i++
393+ }
394+ }
395+ sb.WriteString("$")
396+
397+ re, err := regexp.Compile(sb.String())
398+ if err != nil {
399+ return nil, false, fmt.Errorf("glob: invalid pattern %q: %w", pattern, err)
400+ }
401+ return re, hasSlash, nil
402+}
A packages/agent/tools/glob_test.go +541 -0
@@ -0,0 +1,541 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "runtime"
9+ "strings"
10+ "testing"
11+
12+ "github.com/bnema/zut/packages/core"
13+ "github.com/bnema/zut/packages/provider"
14+)
15+
16+func globArgsJSON(pattern, path string, hidden bool) json.RawMessage {
17+ args := map[string]any{"pattern": pattern}
18+ if path != "" {
19+ args["path"] = path
20+ }
21+ if hidden {
22+ args["hidden"] = true
23+ }
24+ raw, _ := json.Marshal(args)
25+ return raw
26+}
27+
28+func writeGlobFile(t *testing.T, path, content string) {
29+ t.Helper()
30+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
31+ t.Fatal(err)
32+ }
33+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
34+ t.Fatal(err)
35+ }
36+}
37+
38+func globDetails(result core.ToolResult) map[string]any {
39+ return result.Details.(map[string]any)
40+}
41+
42+func globResultText(t *testing.T, result core.ToolResult) string {
43+ t.Helper()
44+ if len(result.Content) != 1 {
45+ t.Fatalf("content blocks = %d, want 1", len(result.Content))
46+ }
47+ text, ok := result.Content[0].(provider.TextBlock)
48+ if !ok {
49+ t.Fatalf("content type = %T, want TextBlock", result.Content[0])
50+ }
51+ return text.Text
52+}
53+
54+func TestGlobStarMatchesBasenames(t *testing.T) {
55+ root := t.TempDir()
56+ writeGlobFile(t, filepath.Join(root, "a.go"), "")
57+ writeGlobFile(t, filepath.Join(root, "b.txt"), "")
58+ writeGlobFile(t, filepath.Join(root, "sub", "c.go"), "")
59+
60+ tool := &GlobTool{CWD: root}
61+ result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil)
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ text := globResultText(t, result)
66+ if !strings.Contains(text, "a.go") || !strings.Contains(text, "sub/c.go") {
67+ t.Fatalf("output missing matches: %q", text)
68+ }
69+ if strings.Contains(text, "b.txt") {
70+ t.Fatalf("output contains non-match: %q", text)
71+ }
72+ if globDetails(result)["matches"] != 2 {
73+ t.Fatalf("matches = %v, want 2", globDetails(result)["matches"])
74+ }
75+}
76+
77+func TestGlobGlobstarMatchesNestedPaths(t *testing.T) {
78+ root := t.TempDir()
79+ writeGlobFile(t, filepath.Join(root, "a.go"), "")
80+ writeGlobFile(t, filepath.Join(root, "sub", "deep", "b.go"), "")
81+
82+ tool := &GlobTool{CWD: root}
83+ result, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil)
84+ if err != nil {
85+ t.Fatal(err)
86+ }
87+ text := globResultText(t, result)
88+ if !strings.Contains(text, "a.go") || !strings.Contains(text, "sub/deep/b.go") {
89+ t.Fatalf("globstar missed nested matches: %q", text)
90+ }
91+}
92+
93+func TestGlobQuestionMarkAndCharacterClass(t *testing.T) {
94+ root := t.TempDir()
95+ writeGlobFile(t, filepath.Join(root, "a1.go"), "")
96+ writeGlobFile(t, filepath.Join(root, "a2.go"), "")
97+ writeGlobFile(t, filepath.Join(root, "abc.go"), "")
98+
99+ tool := &GlobTool{CWD: root}
100+ result, err := tool.Execute(context.Background(), globArgsJSON("a?.go", "", false), nil)
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+ if got := globDetails(result)["matches"]; got != 2 {
105+ t.Fatalf("? matches = %v, want 2", got)
106+ }
107+
108+ result, err = tool.Execute(context.Background(), globArgsJSON("a[12].go", "", false), nil)
109+ if err != nil {
110+ t.Fatal(err)
111+ }
112+ if got := globDetails(result)["matches"]; got != 2 {
113+ t.Fatalf("class matches = %v, want 2", got)
114+ }
115+}
116+
117+func TestGlobSlashPatternMatchesRelativePath(t *testing.T) {
118+ root := t.TempDir()
119+ writeGlobFile(t, filepath.Join(root, "src", "a.go"), "")
120+ writeGlobFile(t, filepath.Join(root, "other", "a.go"), "")
121+
122+ tool := &GlobTool{CWD: root}
123+ result, err := tool.Execute(context.Background(), globArgsJSON("src/*.go", "", false), nil)
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ text := globResultText(t, result)
128+ if !strings.Contains(text, "src/a.go") || strings.Contains(text, "other/a.go") {
129+ t.Fatalf("path pattern matched wrong set: %q", text)
130+ }
131+}
132+
133+func TestGlobInvalidPatternIsRejected(t *testing.T) {
134+ tool := &GlobTool{CWD: t.TempDir()}
135+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":""}`), nil); err == nil {
136+ t.Fatal("empty pattern accepted")
137+ }
138+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"a["}`), nil); err == nil {
139+ t.Fatal("unterminated class accepted")
140+ }
141+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":1}`), nil); err == nil {
142+ t.Fatal("non-string pattern accepted")
143+ }
144+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"*.go","bogus":1}`), nil); err == nil {
145+ t.Fatal("unknown argument accepted")
146+ }
147+}
148+
149+func TestGlobHiddenToggle(t *testing.T) {
150+ root := t.TempDir()
151+ writeGlobFile(t, filepath.Join(root, ".hidden.go"), "")
152+ writeGlobFile(t, filepath.Join(root, ".hdir", "inside.go"), "")
153+ writeGlobFile(t, filepath.Join(root, "visible.go"), "")
154+ writeGlobFile(t, filepath.Join(root, ".git", "objects", "x"), "")
155+
156+ tool := &GlobTool{CWD: root}
157+ result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil)
158+ if err != nil {
159+ t.Fatal(err)
160+ }
161+ text := globResultText(t, result)
162+ if strings.Contains(text, ".hidden") || strings.Contains(text, ".hdir") {
163+ t.Fatalf("hidden entries leaked: %q", text)
164+ }
165+
166+ result, err = tool.Execute(context.Background(), globArgsJSON("*.go", "", true), nil)
167+ if err != nil {
168+ t.Fatal(err)
169+ }
170+ text = globResultText(t, result)
171+ if !strings.Contains(text, ".hidden.go") || !strings.Contains(text, ".hdir/inside.go") {
172+ t.Fatalf("hidden opt-in missed entries: %q", text)
173+ }
174+ if strings.Contains(text, ".git") {
175+ t.Fatalf(".git leaked with hidden opt-in: %q", text)
176+ }
177+}
178+
179+func TestGlobHonorsRootAndNestedIgnores(t *testing.T) {
180+ root := t.TempDir()
181+ writeGlobFile(t, filepath.Join(root, ".gitignore"), "ignored.txt\nbuild/\n")
182+ writeGlobFile(t, filepath.Join(root, "ignored.txt"), "")
183+ writeGlobFile(t, filepath.Join(root, "kept.txt"), "")
184+ writeGlobFile(t, filepath.Join(root, "build", "out.txt"), "")
185+ writeGlobFile(t, filepath.Join(root, "sub", ".gitignore"), "local.txt\n")
186+ writeGlobFile(t, filepath.Join(root, "sub", "local.txt"), "")
187+ writeGlobFile(t, filepath.Join(root, "sub", "keep.txt"), "")
188+
189+ tool := &GlobTool{CWD: root}
190+ result, err := tool.Execute(context.Background(), globArgsJSON("*.txt", "", false), nil)
191+ if err != nil {
192+ t.Fatal(err)
193+ }
194+ text := globResultText(t, result)
195+ for _, leaked := range []string{"ignored.txt", "build/out.txt", "local.txt"} {
196+ if strings.Contains(text, leaked) {
197+ t.Fatalf("ignored file leaked: %q in %q", leaked, text)
198+ }
199+ }
200+ if !strings.Contains(text, "kept.txt") || !strings.Contains(text, "sub/keep.txt") {
201+ t.Fatalf("kept files missing: %q", text)
202+ }
203+
204+ // Narrowing with path preserves the inherited root rules.
205+ result, err = tool.Execute(context.Background(), globArgsJSON("*.txt", "sub", false), nil)
206+ if err != nil {
207+ t.Fatal(err)
208+ }
209+ text = globResultText(t, result)
210+ if strings.Contains(text, "local.txt") {
211+ t.Fatalf("nested ignore lost with path narrowing: %q", text)
212+ }
213+ if !strings.Contains(text, "keep.txt") {
214+ t.Fatalf("narrowed search missed keep.txt: %q", text)
215+ }
216+}
217+
218+func TestGlobNegationUnignores(t *testing.T) {
219+ root := t.TempDir()
220+ writeGlobFile(t, filepath.Join(root, ".gitignore"), "*.log\n!important.log\n")
221+ writeGlobFile(t, filepath.Join(root, "a.log"), "")
222+ writeGlobFile(t, filepath.Join(root, "important.log"), "")
223+
224+ tool := &GlobTool{CWD: root}
225+ result, err := tool.Execute(context.Background(), globArgsJSON("*.log", "", false), nil)
226+ if err != nil {
227+ t.Fatal(err)
228+ }
229+ text := globResultText(t, result)
230+ if strings.Contains(text, "a.log") || !strings.Contains(text, "important.log") {
231+ t.Fatalf("negation mishandled: %q", text)
232+ }
233+}
234+
235+func TestGlobWalksExplicitlyRequestedIgnoredRoot(t *testing.T) {
236+ root := t.TempDir()
237+ writeGlobFile(t, filepath.Join(root, ".gitignore"), "build/\n")
238+ writeGlobFile(t, filepath.Join(root, "build", "out.txt"), "")
239+
240+ tool := &GlobTool{CWD: root}
241+ result, err := tool.Execute(context.Background(), globArgsJSON("*.txt", "build", false), nil)
242+ if err != nil {
243+ t.Fatal(err)
244+ }
245+ if !strings.Contains(globResultText(t, result), "build/out.txt") {
246+ t.Fatalf("explicit ignored root not walked: %q", globResultText(t, result))
247+ }
248+}
249+
250+func TestGlobFileRootIsRejected(t *testing.T) {
251+ root := t.TempDir()
252+ writeGlobFile(t, filepath.Join(root, "a.go"), "")
253+
254+ tool := &GlobTool{CWD: root}
255+ if _, err := tool.Execute(context.Background(), globArgsJSON("*.go", "a.go", false), nil); err == nil {
256+ t.Fatal("file search root accepted")
257+ }
258+}
259+
260+func TestGlobEmptyResults(t *testing.T) {
261+ tool := &GlobTool{CWD: t.TempDir()}
262+ result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil)
263+ if err != nil {
264+ t.Fatal(err)
265+ }
266+ if got := globResultText(t, result); got != "No files matched the pattern." {
267+ t.Fatalf("empty output = %q", got)
268+ }
269+ if globDetails(result)["matches"] != 0 || globDetails(result)["truncated"] != false {
270+ t.Fatalf("empty details = %v", result.Details)
271+ }
272+}
273+
274+func TestGlobSortsDeterministically(t *testing.T) {
275+ root := t.TempDir()
276+ for _, name := range []string{"z.go", "m.go", "a.go", "sub/z.go", "sub/a.go"} {
277+ writeGlobFile(t, filepath.Join(root, name), "")
278+ }
279+ tool := &GlobTool{CWD: root}
280+ first, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil)
281+ if err != nil {
282+ t.Fatal(err)
283+ }
284+ second, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil)
285+ if err != nil {
286+ t.Fatal(err)
287+ }
288+ a, b := globResultText(t, first), globResultText(t, second)
289+ if a != b {
290+ t.Fatalf("nondeterministic order:\n%s\n%s", a, b)
291+ }
292+ lines := strings.Split(a, "\n")
293+ for i := 1; i < len(lines); i++ {
294+ if lines[i-1] > lines[i] {
295+ t.Fatalf("unsorted output: %q", a)
296+ }
297+ }
298+}
299+
300+func TestGlobRespectsSandbox(t *testing.T) {
301+ base := t.TempDir()
302+ root := filepath.Join(base, "workspace")
303+ if err := os.Mkdir(root, 0o755); err != nil {
304+ t.Fatal(err)
305+ }
306+ outside := filepath.Join(base, "outside")
307+ if err := os.Mkdir(outside, 0o755); err != nil {
308+ t.Fatal(err)
309+ }
310+ writeGlobFile(t, filepath.Join(outside, "secret.go"), "")
311+
312+ sandbox := NewSandbox(root)
313+ sandbox.Lock()
314+ tool := &GlobTool{CWD: root, Sandbox: sandbox}
315+ if _, err := tool.Execute(context.Background(), globArgsJSON("*.go", outside, false), nil); err == nil {
316+ t.Fatal("search outside jail accepted")
317+ } else if !strings.Contains(err.Error(), "jailed") {
318+ t.Fatalf("error = %v, want jail denial", err)
319+ }
320+}
321+
322+func TestGlobRootSymlinkInsideAndOutsideJail(t *testing.T) {
323+ if os.Getenv("OS") == "Windows_NT" {
324+ t.Skip("symlink tests need privileges on windows")
325+ }
326+ base := t.TempDir()
327+ root := filepath.Join(base, "workspace")
328+ inner := filepath.Join(root, "inner")
329+ if err := os.MkdirAll(inner, 0o755); err != nil {
330+ t.Fatal(err)
331+ }
332+ writeGlobFile(t, filepath.Join(inner, "a.go"), "")
333+ outside := filepath.Join(base, "outside")
334+ if err := os.MkdirAll(outside, 0o755); err != nil {
335+ t.Fatal(err)
336+ }
337+ writeGlobFile(t, filepath.Join(outside, "secret.go"), "")
338+
339+ sandbox := NewSandbox(root)
340+ sandbox.Lock()
341+
342+ inside := filepath.Join(root, "link-inside")
343+ if err := os.Symlink(inner, inside); err != nil {
344+ t.Skipf("symlinks unsupported: %v", err)
345+ }
346+ tool := &GlobTool{CWD: root, Sandbox: sandbox}
347+ result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "link-inside", false), nil)
348+ if err != nil {
349+ t.Fatal(err)
350+ }
351+ if !strings.Contains(globResultText(t, result), "a.go") {
352+ t.Fatalf("symlinked root not traversed: %v", result)
353+ }
354+
355+ escape := filepath.Join(root, "link-outside")
356+ if err := os.Symlink(outside, escape); err != nil {
357+ t.Skipf("symlinks unsupported: %v", err)
358+ }
359+ if _, err := tool.Execute(context.Background(), globArgsJSON("*.go", "link-outside", false), nil); err == nil {
360+ t.Fatal("symlinked root escaping the jail accepted")
361+ } else if !strings.Contains(err.Error(), "jailed") {
362+ t.Fatalf("error = %v, want jail denial", err)
363+ }
364+}
365+
366+func TestGlobCancellationPropagates(t *testing.T) {
367+ root := t.TempDir()
368+ writeGlobFile(t, filepath.Join(root, "a.go"), "")
369+ tool := &GlobTool{CWD: root}
370+ ctx, cancel := context.WithCancel(context.Background())
371+ cancel()
372+ if _, err := tool.Execute(ctx, globArgsJSON("*.go", "", false), nil); err == nil {
373+ t.Fatal("cancelled context accepted")
374+ }
375+}
376+
377+func TestGlobReportsMetadata(t *testing.T) {
378+ root := t.TempDir()
379+ writeGlobFile(t, filepath.Join(root, "a.go"), "")
380+ tool := &GlobTool{CWD: root}
381+ result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil)
382+ if err != nil {
383+ t.Fatal(err)
384+ }
385+ if globDetails(result)["pattern"] != "*.go" {
386+ t.Fatalf("pattern detail = %v", result.Details)
387+ }
388+ if globDetails(result)["truncated"] != false || globDetails(result)["matches"] != 1 {
389+ t.Fatalf("details = %v", result.Details)
390+ }
391+}
392+
393+func TestGlobBoundsDepth(t *testing.T) {
394+ root := t.TempDir()
395+ deep := root
396+ for i := 0; i < maxGlobDepth+5; i++ {
397+ deep = filepath.Join(deep, "d")
398+ }
399+ writeGlobFile(t, filepath.Join(deep, "deep.go"), "")
400+ writeGlobFile(t, filepath.Join(root, "top.go"), "")
401+
402+ tool := &GlobTool{CWD: root}
403+ result, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil)
404+ if err != nil {
405+ t.Fatal(err)
406+ }
407+ text := globResultText(t, result)
408+ if !strings.Contains(text, "top.go") {
409+ t.Fatalf("shallow match missing: %q", text)
410+ }
411+ if strings.Contains(text, "deep.go") {
412+ t.Fatalf("depth bound not enforced: %q", text)
413+ }
414+}
415+
416+func TestGlobBoundsResults(t *testing.T) {
417+ root := t.TempDir()
418+ for i := 0; i < maxGlobMatches+50; i++ {
419+ writeGlobFile(t, filepath.Join(root, strings.Repeat("f", 4)+string(rune('a'+i/26))+string(rune('a'+i%26))+".go"), "")
420+ }
421+
422+ tool := &GlobTool{CWD: root}
423+ result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil)
424+ if err != nil {
425+ t.Fatal(err)
426+ }
427+ if got := globDetails(result)["matches"]; got != maxGlobMatches {
428+ t.Fatalf("matches = %v, want %d", got, maxGlobMatches)
429+ }
430+ if globDetails(result)["truncated"] != true {
431+ t.Fatalf("truncated = %v, want true", globDetails(result))
432+ }
433+ if !strings.Contains(globResultText(t, result), "Truncated") {
434+ t.Fatalf("truncation note missing: %q", globResultText(t, result))
435+ }
436+}
437+
438+func TestGlobUnreadableEntriesAreSkipped(t *testing.T) {
439+ root := t.TempDir()
440+ writeGlobFile(t, filepath.Join(root, "a.go"), "")
441+ denied := filepath.Join(root, "denied")
442+ if err := os.MkdirAll(denied, 0o755); err != nil {
443+ t.Fatal(err)
444+ }
445+ writeGlobFile(t, filepath.Join(denied, "b.go"), "")
446+ if err := os.Chmod(denied, 0o000); err != nil {
447+ t.Skipf("chmod unsupported: %v", err)
448+ }
449+ defer func() { _ = os.Chmod(denied, 0o755) }()
450+
451+ tool := &GlobTool{CWD: root}
452+ result, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil)
453+ if err != nil {
454+ t.Fatal(err)
455+ }
456+ if !strings.Contains(globResultText(t, result), "a.go") {
457+ t.Fatalf("readable match missing: %q", globResultText(t, result))
458+ }
459+}
460+
461+func TestGlobNegatedCharacterClass(t *testing.T) {
462+ root := t.TempDir()
463+ writeGlobFile(t, filepath.Join(root, "a.go"), "")
464+ writeGlobFile(t, filepath.Join(root, "b.go"), "")
465+ writeGlobFile(t, filepath.Join(root, "c.go"), "")
466+
467+ tool := &GlobTool{CWD: root}
468+ result, err := tool.Execute(context.Background(), globArgsJSON("[!ab].go", "", false), nil)
469+ if err != nil {
470+ t.Fatal(err)
471+ }
472+ text := globResultText(t, result)
473+ if !strings.Contains(text, "c.go") || strings.Contains(text, "a.go") || strings.Contains(text, "b.go") {
474+ t.Fatalf("negated class matched wrong set: %q", text)
475+ }
476+}
477+
478+func TestGlobSkipsSymlinkedDirectoryTargets(t *testing.T) {
479+ if runtime.GOOS == "windows" {
480+ t.Skip("symlink tests need privileges on windows")
481+ }
482+ root := t.TempDir()
483+ inner := filepath.Join(root, "inner")
484+ if err := os.MkdirAll(inner, 0o755); err != nil {
485+ t.Fatal(err)
486+ }
487+ writeGlobFile(t, filepath.Join(inner, "a.go"), "")
488+ link := filepath.Join(root, "dirlink")
489+ if err := os.Symlink(inner, link); err != nil {
490+ t.Skipf("symlinks unsupported: %v", err)
491+ }
492+
493+ tool := &GlobTool{CWD: root}
494+ result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "", false), nil)
495+ if err != nil {
496+ t.Fatal(err)
497+ }
498+ if strings.Contains(globResultText(t, result), "dirlink") {
499+ t.Fatalf("symlinked directory listed as a file: %q", globResultText(t, result))
500+ }
501+}
502+
503+func TestGlobSymlinkedRootKeepsRequestedName(t *testing.T) {
504+ if runtime.GOOS == "windows" {
505+ t.Skip("symlink tests need privileges on windows")
506+ }
507+ base := t.TempDir()
508+ inner := filepath.Join(base, "inner")
509+ if err := os.MkdirAll(inner, 0o755); err != nil {
510+ t.Fatal(err)
511+ }
512+ writeGlobFile(t, filepath.Join(inner, "a.go"), "")
513+ link := filepath.Join(base, "link-inside")
514+ if err := os.Symlink(inner, link); err != nil {
515+ t.Skipf("symlinks unsupported: %v", err)
516+ }
517+
518+ tool := &GlobTool{CWD: base}
519+ result, err := tool.Execute(context.Background(), globArgsJSON("*.go", "link-inside", false), nil)
520+ if err != nil {
521+ t.Fatal(err)
522+ }
523+ if got := globResultText(t, result); got != "link-inside/a.go" {
524+ t.Fatalf("output = %q, want link-inside/a.go", got)
525+ }
526+}
527+
528+func TestGlobReportsDiscoveredPaths(t *testing.T) {
529+ root := t.TempDir()
530+ writeGlobFile(t, filepath.Join(root, "sub", "a.go"), "")
531+ tool := &GlobTool{CWD: root}
532+ result, err := tool.Execute(context.Background(), globArgsJSON("**/*.go", "", false), nil)
533+ if err != nil {
534+ t.Fatal(err)
535+ }
536+ discovers := result.Context
537+ want := canonicalResourcePath(filepath.Join(root, "sub", "a.go"))
538+ if len(discovers.Discovers) != 1 || discovers.Discovers[0] != want {
539+ t.Fatalf("discovers = %v, want [%s]", discovers.Discovers, want)
540+ }
541+}
A packages/core/session_prune.go +108 -0
@@ -0,0 +1,108 @@
1+package core
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "io/fs"
7+ "os"
8+ "path/filepath"
9+ "sort"
10+ "strings"
11+)
12+
13+// StoredSessionGroup describes sessions whose metadata names the same working
14+// directory. Paths can span the default and named-agent session namespaces.
15+type StoredSessionGroup struct {
16+ CWD string
17+ Paths []string
18+ SizeBytes int64
19+}
20+
21+// SessionScanIssue describes a stored entry that could not be safely
22+// classified. Callers should report these entries and leave them untouched.
23+type SessionScanIssue struct {
24+ Path string
25+ Err error
26+}
27+
28+// ScanStoredSessionGroups finds JSONL sessions below sessionsRoot and groups
29+// valid entries by the cwd recorded in their metadata. Symlinks and malformed
30+// sessions are reported as issues rather than followed or deleted.
31+func ScanStoredSessionGroups(sessionsRoot string) ([]StoredSessionGroup, []SessionScanIssue) {
32+ byCWD := make(map[string]*StoredSessionGroup)
33+ var issues []SessionScanIssue
34+
35+ // A symlinked sessions root would otherwise scan as an empty store
36+ // with no hint; report it instead of silently finding nothing.
37+ if info, err := os.Lstat(sessionsRoot); err == nil && info.Mode()&os.ModeSymlink != 0 {
38+ return nil, []SessionScanIssue{{Path: sessionsRoot, Err: fmt.Errorf("sessions root is a symbolic link")}}
39+ }
40+ err := filepath.WalkDir(sessionsRoot, func(path string, entry fs.DirEntry, walkErr error) error {
41+ if walkErr != nil {
42+ if path == sessionsRoot && errors.Is(walkErr, fs.ErrNotExist) {
43+ return nil
44+ }
45+ issues = append(issues, SessionScanIssue{Path: path, Err: walkErr})
46+ if entry != nil && entry.IsDir() {
47+ return fs.SkipDir
48+ }
49+ return nil
50+ }
51+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
52+ return nil
53+ }
54+ if entry.Type()&os.ModeSymlink != 0 {
55+ issues = append(issues, SessionScanIssue{Path: path, Err: fmt.Errorf("symbolic link is not a stored session")})
56+ return nil
57+ }
58+ // Never block on or stream non-regular entries: a FIFO named
59+ // *.jsonl would hang open(2) forever and a device node would
60+ // return data without end. Report and preserve them instead.
61+ if !entry.Type().IsRegular() {
62+ issues = append(issues, SessionScanIssue{Path: path, Err: fmt.Errorf("not a regular file")})
63+ return nil
64+ }
65+
66+ meta, err := readSessionMeta(path)
67+ if err != nil {
68+ issues = append(issues, SessionScanIssue{Path: path, Err: err})
69+ return nil
70+ }
71+ cwd := strings.TrimSpace(meta.CWD)
72+ if cwd == "" {
73+ issues = append(issues, SessionScanIssue{Path: path, Err: fmt.Errorf("session metadata has an empty cwd")})
74+ return nil
75+ }
76+ // Tree-navigation branches stay visible through /session tree;
77+ // pruning them here would silently remove sessions the picker
78+ // deliberately hides from the flat list.
79+ if meta.HideFromSessions {
80+ return nil
81+ }
82+ info, err := entry.Info()
83+ if err != nil {
84+ issues = append(issues, SessionScanIssue{Path: path, Err: err})
85+ return nil
86+ }
87+ group := byCWD[cwd]
88+ if group == nil {
89+ group = &StoredSessionGroup{CWD: cwd}
90+ byCWD[cwd] = group
91+ }
92+ group.Paths = append(group.Paths, path)
93+ group.SizeBytes += info.Size()
94+ return nil
95+ })
96+ if err != nil && !os.IsNotExist(err) {
97+ issues = append(issues, SessionScanIssue{Path: sessionsRoot, Err: err})
98+ }
99+
100+ groups := make([]StoredSessionGroup, 0, len(byCWD))
101+ for _, group := range byCWD {
102+ sort.Strings(group.Paths)
103+ groups = append(groups, *group)
104+ }
105+ sort.Slice(groups, func(i, j int) bool { return groups[i].CWD < groups[j].CWD })
106+ sort.Slice(issues, func(i, j int) bool { return issues[i].Path < issues[j].Path })
107+ return groups, issues
108+}
A packages/core/session_prune_test.go +177 -0
@@ -0,0 +1,177 @@
1+package core
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "runtime"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "golang.org/x/sys/unix"
12+)
13+
14+func writePruneTestSession(t *testing.T, path, cwd string) {
15+ t.Helper()
16+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
17+ t.Fatal(err)
18+ }
19+ line := `{"type":"meta","meta":{"id":"test-id","cwd":` + strconvQuote(cwd) + `,"model":"m","provider":"p"}}` + "\n"
20+ if err := os.WriteFile(path, []byte(line), 0o600); err != nil {
21+ t.Fatal(err)
22+ }
23+}
24+
25+func markHiddenFromSessions(t *testing.T, path string) {
26+ t.Helper()
27+ raw, err := os.ReadFile(path)
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ // The flag lives inside the meta object, not on the row.
32+ line := strings.TrimSuffix(strings.TrimSuffix(string(raw), "\n"), "}")
33+ line = strings.TrimSuffix(line, "}") + `,"hide_from_sessions":true}}` + "\n"
34+ if err := os.WriteFile(path, []byte(line), 0o600); err != nil {
35+ t.Fatal(err)
36+ }
37+}
38+
39+func makeFifo(path string) error {
40+ return unix.Mkfifo(path, 0o600)
41+}
42+
43+func strconvQuote(s string) string {
44+ quoted := strings.ReplaceAll(s, `\`, `\\`)
45+ quoted = strings.ReplaceAll(quoted, `"`, `\"`)
46+ return `"` + quoted + `"`
47+}
48+
49+func TestScanStoredSessionGroupsAbsentRoot(t *testing.T) {
50+ groups, issues := ScanStoredSessionGroups(filepath.Join(t.TempDir(), "no-such-root"))
51+ if len(groups) != 0 || len(issues) != 0 {
52+ t.Fatalf("groups=%v issues=%v, want empty", groups, issues)
53+ }
54+}
55+
56+func TestScanStoredSessionGroupsGroupsByCWD(t *testing.T) {
57+ root := t.TempDir()
58+ writePruneTestSession(t, filepath.Join(root, "one.jsonl"), "/work/a")
59+ writePruneTestSession(t, filepath.Join(root, "agents", "named", "two.jsonl"), "/work/a")
60+ writePruneTestSession(t, filepath.Join(root, "three.jsonl"), "/work/b")
61+
62+ groups, issues := ScanStoredSessionGroups(root)
63+ if len(issues) != 0 {
64+ t.Fatalf("issues=%v, want none", issues)
65+ }
66+ if len(groups) != 2 {
67+ t.Fatalf("groups=%v, want 2", groups)
68+ }
69+ if groups[0].CWD != "/work/a" || len(groups[0].Paths) != 2 {
70+ t.Fatalf("first group=%+v, want /work/a with 2 paths", groups[0])
71+ }
72+ if groups[1].CWD != "/work/b" || len(groups[1].Paths) != 1 {
73+ t.Fatalf("second group=%+v, want /work/b with 1 path", groups[1])
74+ }
75+ if groups[0].SizeBytes <= 0 {
76+ t.Fatalf("group size=%d, want positive", groups[0].SizeBytes)
77+ }
78+}
79+
80+func TestScanStoredSessionGroupsPreservesProblemEntries(t *testing.T) {
81+ root := t.TempDir()
82+ writePruneTestSession(t, filepath.Join(root, "ok.jsonl"), "/work/a")
83+ writePruneTestSession(t, filepath.Join(root, "bad.jsonl"), "")
84+ if err := os.WriteFile(filepath.Join(root, "broken.jsonl"), []byte("{not json\n"), 0o600); err != nil {
85+ t.Fatal(err)
86+ }
87+ link := filepath.Join(root, "link.jsonl")
88+ if err := os.Symlink(filepath.Join(root, "ok.jsonl"), link); err != nil {
89+ t.Skipf("symlinks unsupported: %v", err)
90+ }
91+
92+ groups, issues := ScanStoredSessionGroups(root)
93+ if len(groups) != 1 || len(groups[0].Paths) != 1 {
94+ t.Fatalf("groups=%v, want only the valid session", groups)
95+ }
96+ if len(issues) != 3 {
97+ t.Fatalf("issues=%v, want empty-cwd, malformed, and symlink", issues)
98+ }
99+ for i := 1; i < len(issues); i++ {
100+ if issues[i-1].Path > issues[i].Path {
101+ t.Fatalf("issues unsorted: %v", issues)
102+ }
103+ }
104+}
105+
106+func TestScanStoredSessionGroupsSkipsNonRegularEntries(t *testing.T) {
107+ if runtime.GOOS == "windows" {
108+ t.Skip("fifo tests need unix privileges")
109+ }
110+ root := t.TempDir()
111+ writePruneTestSession(t, filepath.Join(root, "ok.jsonl"), "/work/a")
112+ fifo := filepath.Join(root, "pipe.jsonl")
113+ if err := makeFifo(fifo); err != nil {
114+ t.Skipf("fifos unsupported: %v", err)
115+ }
116+
117+ done := make(chan struct{})
118+ go func() {
119+ defer close(done)
120+ groups, issues := ScanStoredSessionGroups(root)
121+ if len(groups) != 1 || len(groups[0].Paths) != 1 {
122+ t.Errorf("groups=%v, want only the valid session", groups)
123+ return
124+ }
125+ found := false
126+ for _, issue := range issues {
127+ if issue.Path == fifo {
128+ found = true
129+ }
130+ }
131+ if !found {
132+ t.Errorf("issues=%v, want the fifo reported", issues)
133+ }
134+ }()
135+ select {
136+ case <-done:
137+ case <-time.After(10 * time.Second):
138+ t.Fatal("scanner blocked on a fifo entry")
139+ }
140+}
141+
142+func TestScanStoredSessionGroupsSkipsHiddenBranches(t *testing.T) {
143+ root := t.TempDir()
144+ writePruneTestSession(t, filepath.Join(root, "visible.jsonl"), "/work/a")
145+ hidden := filepath.Join(root, "branch.jsonl")
146+ writePruneTestSession(t, hidden, "/work/a")
147+ markHiddenFromSessions(t, hidden)
148+
149+ groups, _ := ScanStoredSessionGroups(root)
150+ if len(groups) != 1 || len(groups[0].Paths) != 1 {
151+ t.Fatalf("groups=%v, want only the visible session", groups)
152+ }
153+}
154+
155+func TestScanStoredSessionGroupsReportsSymlinkedRoot(t *testing.T) {
156+ if runtime.GOOS == "windows" {
157+ t.Skip("symlink tests need privileges on windows")
158+ }
159+ base := t.TempDir()
160+ real := filepath.Join(base, "real")
161+ if err := os.MkdirAll(real, 0o755); err != nil {
162+ t.Fatal(err)
163+ }
164+ writePruneTestSession(t, filepath.Join(real, "one.jsonl"), "/work/a")
165+ link := filepath.Join(base, "link")
166+ if err := os.Symlink(real, link); err != nil {
167+ t.Skipf("symlinks unsupported: %v", err)
168+ }
169+
170+ groups, issues := ScanStoredSessionGroups(link)
171+ if len(groups) != 0 {
172+ t.Fatalf("groups=%v, want none for a symlinked root", groups)
173+ }
174+ if len(issues) != 1 {
175+ t.Fatalf("issues=%v, want the symlinked root reported", issues)
176+ }
177+}
M packages/provider/deferred_tools_test.go +104 -0
@@ -37,6 +37,78 @@ func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
3737 }
3838 }
3939
40+// The deferred-tools handshake is a Moonshot API extension. Another
41+// provider serving the same model id must get the standard wire instead:
42+// no tools-bearing system message (it carries no content, which strict
43+// schemas reject), and the activated tool in the top-level definitions
44+// where every provider reads it.
45+func TestKimiK3DeferredToolsSkippedForNonMoonshotProvider(t *testing.T) {
46+ client := NewOpenAICompat("custom", "token", "https://example.test/v1", "").(*openaiClient)
47+ tools := []Tool{
48+ {Name: "search_tools", Schema: json.RawMessage(`{"type":"object"}`)},
49+ {Name: "lookup_weather", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
50+ }
51+ wire, err := client.buildRequest(Request{
52+ Model: "kimi-k3",
53+ Tools: tools,
54+ Messages: []Message{
55+ {Role: RoleUser, Content: []Content{TextBlock{Text: "weather"}}},
56+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "call-1", Name: "search_tools", Arguments: json.RawMessage(`{}`)}}},
57+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "found"}}}}, AddedToolNames: []string{"lookup_weather"}},
58+ },
59+ })
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ for _, message := range wire.Messages {
64+ if len(message.Tools) > 0 {
65+ t.Fatalf("message %q carries tools; deferred injection must not reach a non-Moonshot provider", message.Role)
66+ }
67+ if message.Content == nil && len(message.ToolCalls) == 0 {
68+ t.Fatalf("message %q has neither content nor tool calls", message.Role)
69+ }
70+ }
71+ var names []string
72+ for _, tool := range wire.Tools {
73+ names = append(names, tool.Function.Name)
74+ }
75+ if len(names) != 2 || names[0] != "search_tools" || names[1] != "lookup_weather" {
76+ t.Fatalf("top-level tools = %v, want [search_tools lookup_weather]", names)
77+ }
78+}
79+
80+func TestMoonshotCNKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
81+ client := NewMoonshotCN("token", "").(*openaiClient)
82+ tools := []Tool{
83+ {Name: "search_tools", Schema: json.RawMessage(`{"type":"object"}`)},
84+ {Name: "lookup_weather", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
85+ }
86+ wire, err := client.buildRequest(Request{
87+ Model: "kimi-k3",
88+ Tools: tools,
89+ Messages: []Message{
90+ {Role: RoleUser, Content: []Content{TextBlock{Text: "weather"}}},
91+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "call-1", Name: "search_tools", Arguments: json.RawMessage(`{}`)}}},
92+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "found"}}}}, AddedToolNames: []string{"lookup_weather"}},
93+ },
94+ })
95+ if err != nil {
96+ t.Fatal(err)
97+ }
98+ if len(wire.Tools) != 1 || wire.Tools[0].Function.Name != "search_tools" {
99+ t.Fatalf("top-level tools = %+v, want only search_tools", wire.Tools)
100+ }
101+ var loaded []oaiTool
102+ for _, message := range wire.Messages {
103+ if message.Role == "system" && len(message.Tools) > 0 {
104+ loaded = message.Tools
105+ }
106+ }
107+ if len(loaded) != 1 || loaded[0].Function.Name != "lookup_weather" {
108+ t.Fatalf("loaded tools = %+v, want lookup_weather", loaded)
109+ }
110+}
111+
40112 func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
41113 tools := []Tool{
42114 {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},
@@ -161,3 +233,35 @@ func TestKimiK3AndGrok45CatalogMetadata(t *testing.T) {
161233 t.Fatalf("xai/grok-4.5 metadata = %+v", m)
162234 }
163235 }
236+
237+func TestMoonshotDeferredMarkerSurvivesDedupOnRetainedMessage(t *testing.T) {
238+ client := NewMoonshot("token", "").(*openaiClient)
239+ tools := []Tool{
240+ {Name: "search_tools", Schema: json.RawMessage(`{"type":"object"}`)},
241+ {Name: "lookup_weather", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
242+ }
243+ wire, err := client.buildRequest(Request{
244+ Model: "kimi-k3",
245+ Tools: tools,
246+ Messages: []Message{
247+ {Role: RoleUser, Content: []Content{TextBlock{Text: "weather"}}},
248+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "call-1", Name: "search_tools", Arguments: json.RawMessage(`{}`)}}},
249+ // Duplicate results: repair keeps the first and its marker, so
250+ // the deferred tool still loads on the handshake message.
251+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "found"}}}}, AddedToolNames: []string{"lookup_weather"}},
252+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "stale duplicate"}}}}, AddedToolNames: []string{"lookup_weather"}},
253+ },
254+ })
255+ if err != nil {
256+ t.Fatal(err)
257+ }
258+ var loaded []oaiTool
259+ for _, message := range wire.Messages {
260+ if message.Role == "system" && len(message.Tools) > 0 {
261+ loaded = message.Tools
262+ }
263+ }
264+ if len(loaded) != 1 || loaded[0].Function.Name != "lookup_weather" {
265+ t.Fatalf("loaded tools = %+v, want lookup_weather from the retained marker", loaded)
266+ }
267+}
M packages/provider/openai.go +93 -32
@@ -310,7 +310,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
310310 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: system})
311311 }
312312
313- deferredMode := isKimiDeferredModel(req.Model)
313+ deferredMode := supportsDeferredTools(c.Name()) && isKimiDeferredModel(req.Model)
314314 toolByName := make(map[string]Tool, len(req.Tools))
315315 for _, t := range req.Tools {
316316 toolByName[t.Name] = t
@@ -321,10 +321,13 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
321321 // schema (parts arrays containing image_url). Force every user/tool
322322 // message to a plain string and silently drop image blocks for
323323 // this provider so historical sessions with screenshots still replay.
324- textOnly := c.name == "deepseek"
324+ // The same quirk applies to DeepSeek models routed through opencode-go.
325+ textOnly := c.name == ProviderDeepSeek || (c.name == ProviderOpenCodeGo && isDeepSeekModel(req.Model))
325326
326327 req.Messages = RepairOrphanedToolResults(req.Messages)
327- for _, msg := range req.Messages {
328+ for msgIndex := 0; msgIndex < len(req.Messages); msgIndex++ {
329+ msg := req.Messages[msgIndex]
330+ addedToolNames := append([]string(nil), msg.AddedToolNames...)
328331 switch msg.Role {
329332 case RoleDeveloper:
330333 content := buildOAIUserContent(msg.Content, textOnly)
@@ -388,23 +391,53 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
388391 }
389392 out.Messages = append(out.Messages, am)
390393 case RoleTool:
391- // Each ToolResultBlock becomes its own tool message. Preserve
392- // image blocks for vision-capable OpenAI models instead of
393- // flattening the tool output to plain text.
394- for _, b := range msg.Content {
395- if tr, ok := b.(ToolResultBlock); ok {
396- content := buildOAIToolContent(tr.Content, tr.IsError, textOnly)
397- out.Messages = append(out.Messages, oaiMessage{
398- Role: "tool",
399- ToolCallID: tr.CallID,
400- Content: content,
401- })
394+ // Chat Completions tool messages only carry text. Keep every
395+ // tool result paired with its call, then deliver any images in
396+ // one user message after the complete batch of tool results.
397+ // Strict OpenAI-compatible APIs reject image_url blocks on a
398+ // role=tool message.
399+ var images []Content
400+ groupEnd := msgIndex
401+ for groupEnd < len(req.Messages) && req.Messages[groupEnd].Role == RoleTool {
402+ toolMessage := req.Messages[groupEnd]
403+ if groupEnd > msgIndex {
404+ addedToolNames = append(addedToolNames, toolMessage.AddedToolNames...)
405+ }
406+ for _, b := range toolMessage.Content {
407+ if tr, ok := b.(ToolResultBlock); ok {
408+ content := buildOAIToolContent(tr.Content, tr.IsError, textOnly)
409+ out.Messages = append(out.Messages, oaiMessage{
410+ Role: "tool",
411+ ToolCallID: tr.CallID,
412+ Content: content,
413+ })
414+ if !textOnly {
415+ for _, inner := range tr.Content {
416+ if image, ok := inner.(ImageBlock); ok {
417+ images = append(images, image)
418+ }
419+ }
420+ }
421+ }
402422 }
423+ groupEnd++
424+ }
425+ // The agent loop persists an image mirror as the next user
426+ // message so Responses-routed models can consume it. Reuse
427+ // that message instead of sending the image twice.
428+ hasPersistedMirror := groupEnd < len(req.Messages) && isOpenAIToolImageMirror(req.Messages[groupEnd])
429+ if len(images) > 0 && !hasPersistedMirror {
430+ content := append([]Content{TextBlock{Text: "Tool output included the following image content:"}}, images...)
431+ out.Messages = append(out.Messages, oaiMessage{
432+ Role: "user",
433+ Content: buildOAIContentBlocks(content, false),
434+ })
403435 }
436+ msgIndex = groupEnd - 1
404437 }
405438 if deferredMode {
406439 var loaded []oaiTool
407- for _, name := range msg.AddedToolNames {
440+ for _, name := range addedToolNames {
408441 if t, ok := toolByName[name]; ok && t.Deferred {
409442 loaded = append(loaded, makeOAITool(t))
410443 }
@@ -433,6 +466,17 @@ func isKimiDeferredModel(model string) bool {
433466 return model == "kimi-k3" || strings.HasSuffix(model, "/kimi-k3")
434467 }
435468
469+// supportsDeferredTools reports whether a provider speaks Moonshot's
470+// deferred-tools handshake, where a tool that becomes available
471+// mid-conversation is delivered on a system message carrying a `tools`
472+// array. That message is a Moonshot API extension, not OpenAI wire format:
473+// it has no content, which a strict chat-completions schema rejects
474+// outright. Other providers take the standard path instead, where an
475+// activated deferred tool joins the top-level tools array.
476+func supportsDeferredTools(provider string) bool {
477+ return provider == "moonshotai" || provider == "moonshotai-cn"
478+}
479+
436480 func isDeepSeekModel(model string) bool {
437481 model = strings.ToLower(strings.TrimSpace(model))
438482 return strings.HasPrefix(model, "deepseek-") || strings.Contains(model, "/deepseek-")
@@ -477,30 +521,47 @@ func buildOAIUserContent(blocks []Content, textOnly bool) interface{} {
477521 return buildOAIContentBlocks(blocks, false)
478522 }
479523
480-func buildOAIToolContent(blocks []Content, isError, textOnly bool) interface{} {
524+func buildOAIToolContent(blocks []Content, isError, textOnly bool) string {
525+ var sb strings.Builder
481526 hasImage := false
482527 for _, b := range blocks {
483- if _, ok := b.(ImageBlock); ok {
528+ switch v := b.(type) {
529+ case TextBlock:
530+ if sb.Len() > 0 {
531+ sb.WriteString("\n")
532+ }
533+ sb.WriteString(v.Text)
534+ case ImageBlock:
484535 hasImage = true
485- break
486536 }
487537 }
488- if textOnly || !hasImage {
489- var sb strings.Builder
490- for _, b := range blocks {
491- if tb, ok := b.(TextBlock); ok {
492- if sb.Len() > 0 {
493- sb.WriteString("\n")
494- }
495- sb.WriteString(tb.Text)
496- }
497- }
498- if isError && sb.Len() > 0 {
499- sb.WriteString(" [error]")
538+ if sb.Len() == 0 && hasImage && !textOnly {
539+ sb.WriteString("(see attached image)")
540+ }
541+ if isError && sb.Len() > 0 {
542+ sb.WriteString(" [error]")
543+ }
544+ return sb.String()
545+}
546+
547+// isOpenAIToolImageMirror reports whether msg is the synthetic user message
548+// the agent loop persists after a tool result carrying images (see
549+// mirrorToolImagesAsUser). The image bytes are already available once
550+// through that message, so buildRequest must not emit a second copy.
551+func isOpenAIToolImageMirror(msg Message) bool {
552+ if msg.Role != RoleUser || len(msg.Content) < 2 {
553+ return false
554+ }
555+ text, ok := msg.Content[0].(TextBlock)
556+ if !ok || text.Text != "Tool output included the following image content:" {
557+ return false
558+ }
559+ for _, content := range msg.Content[1:] {
560+ if _, ok := content.(ImageBlock); ok {
561+ return true
500562 }
501- return sb.String()
502563 }
503- return buildOAIContentBlocks(blocks, isError)
564+ return false
504565 }
505566
506567 func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} {
M packages/provider/openai_codex.go +74 -14
@@ -565,6 +565,28 @@ func estimatedOpenAIPromptTokens(wire *codexRequest) int {
565565 return len(encoded) / 4
566566 }
567567
568+// unsupportedPromptCacheRetention reports whether a 400 response is the
569+// backend rejecting the prompt_cache_key retention the Codex CLI identity
570+// selects. Only that structured error retries with the native identity;
571+// every other status, param, or code is returned as-is.
572+func unsupportedPromptCacheRetention(status int, body []byte) bool {
573+ if status != http.StatusBadRequest {
574+ return false
575+ }
576+ var payload struct {
577+ Error struct {
578+ Message string `json:"message"`
579+ Param string `json:"param"`
580+ Code string `json:"code"`
581+ } `json:"error"`
582+ }
583+ if json.Unmarshal(body, &payload) != nil || payload.Error.Param != "prompt_cache_retention" {
584+ return false
585+ }
586+ message := strings.ToLower(payload.Error.Message)
587+ return payload.Error.Code == "invalid_parameter" || strings.Contains(message, "not supported")
588+}
589+
568590 func usesCodexCLIRouting(model string) bool {
569591 switch model {
570592 case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro":
@@ -596,18 +618,32 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
596618 return nil, err
597619 }
598620
599- newReq := func() (*http.Request, error) {
600- httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
601- if err != nil {
602- return nil, err
621+ newReq := func(useCodexIdentity bool) func() (*http.Request, error) {
622+ return func() (*http.Request, error) {
623+ httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
624+ if err != nil {
625+ return nil, err
626+ }
627+ httpReq.Header = c.responsesHeaders(req, useCodexIdentity, false)
628+ if !useCodexIdentity {
629+ // The native identity shape carries no session/request
630+ // correlation headers; keep the values selected under the
631+ // CLI identity so the retry stays on the same session and
632+ // request correlation.
633+ cliHeaders := c.responsesHeaders(req, true, false)
634+ for _, key := range []string{"session-id", "thread-id", "x-client-request-id"} {
635+ if value := cliHeaders.Get(key); value != "" {
636+ httpReq.Header.Set(key, value)
637+ }
638+ }
639+ }
640+ httpReq.Header.Set("content-type", "application/json")
641+ httpReq.Header.Set("accept", "text/event-stream")
642+ return httpReq, nil
603643 }
604- httpReq.Header = c.responsesHeaders(req, useCodexCLIRouting, false)
605- httpReq.Header.Set("content-type", "application/json")
606- httpReq.Header.Set("accept", "text/event-stream")
607- return httpReq, nil
608644 }
609645
610- resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
646+ resp, err := doStreamWithRetry(ctx, c.http, newReq(useCodexCLIRouting), req.Lifecycle)
611647 if err != nil {
612648 label := c.providerName
613649 if label == "" {
@@ -616,13 +652,37 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
616652 return nil, fmt.Errorf("%s: %w", label, err)
617653 }
618654 if resp.StatusCode != http.StatusOK {
619- b, _ := io.ReadAll(resp.Body)
655+ status := resp.StatusCode
656+ responseBody, _ := io.ReadAll(resp.Body)
620657 resp.Body.Close()
621- label := c.providerName
622- if label == "" {
623- label = ProviderOpenAICodex
658+ if !useCodexCLIRouting || !unsupportedPromptCacheRetention(status, responseBody) {
659+ label := c.providerName
660+ if label == "" {
661+ label = ProviderOpenAICodex
662+ }
663+ return nil, fmt.Errorf("%s: http %d: %s", label, status, strings.TrimSpace(string(responseBody)))
664+ }
665+ // The Codex CLI identity selects cached-prompt retention the model
666+ // does not support. Retry once with the native identity on the same
667+ // session/request correlation and body; anything else is returned.
668+ resp, err = doStreamWithRetry(ctx, c.http, newReq(false), req.Lifecycle)
669+ if err != nil {
670+ label := c.providerName
671+ if label == "" {
672+ label = ProviderOpenAICodex
673+ }
674+ return nil, fmt.Errorf("%s: cache compatibility retry: %w", label, err)
675+ }
676+ if resp.StatusCode != http.StatusOK {
677+ status = resp.StatusCode
678+ responseBody, _ = io.ReadAll(resp.Body)
679+ resp.Body.Close()
680+ label := c.providerName
681+ if label == "" {
682+ label = ProviderOpenAICodex
683+ }
684+ return nil, fmt.Errorf("%s: http %d: %s", label, status, strings.TrimSpace(string(responseBody)))
624685 }
625- return nil, fmt.Errorf("%s: http %d: %s", label, resp.StatusCode, strings.TrimSpace(string(b)))
626686 }
627687
628688 out := make(chan Event, 16)
M packages/provider/openai_codex_test.go +154 -0
@@ -393,3 +393,157 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
393393 t.Fatalf("request missing prompt_cache_key: %s", body.String())
394394 }
395395 }
396+
397+func TestCodexRetriesUnsupportedPromptCacheRetentionWithNativeIdentity(t *testing.T) {
398+ c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
399+ var headers []http.Header
400+ var bodies []string
401+ c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
402+ headers = append(headers, r.Header.Clone())
403+ body, err := io.ReadAll(r.Body)
404+ if err != nil {
405+ t.Fatal(err)
406+ }
407+ bodies = append(bodies, string(body))
408+ if len(headers) == 1 {
409+ return &http.Response{
410+ StatusCode: http.StatusBadRequest,
411+ Header: make(http.Header),
412+ Body: io.NopCloser(strings.NewReader(`{
413+ "error": {
414+ "message": "prompt_cache_retention is not supported on this model",
415+ "type": "invalid_request_error",
416+ "param": "prompt_cache_retention",
417+ "code": "invalid_parameter"
418+ }
419+ }`)),
420+ }, nil
421+ }
422+ return &http.Response{
423+ StatusCode: http.StatusOK,
424+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
425+ Body: io.NopCloser(strings.NewReader("")),
426+ }, nil
427+ })
428+
429+ events, err := c.Stream(context.Background(), Request{
430+ Model: "gpt-5.6-sol",
431+ Context: RequestContext{CacheSessionID: "cache-root-3", ThreadID: "thread-3", TurnID: "turn-3"},
432+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
433+ })
434+ if err != nil {
435+ t.Fatal(err)
436+ }
437+ for event := range events {
438+ if done, ok := event.(EventDone); ok && done.Err != nil {
439+ t.Fatalf("retry stream failed: %v", done.Err)
440+ }
441+ }
442+
443+ if len(headers) != 2 {
444+ t.Fatalf("requests = %d, want exactly one retry", len(headers))
445+ }
446+ if got := headers[0].Get("originator"); got != "codex_cli_rs" {
447+ t.Fatalf("initial originator = %q, want codex_cli_rs", got)
448+ }
449+ // The retry drops the Codex CLI identity and uses the native one on the
450+ // same session/request correlation and body.
451+ if got := headers[1].Get("originator"); got != "zot" {
452+ t.Fatalf("retry originator = %q, want zot", got)
453+ }
454+ if headers[1].Get("session-id") != "cache-root-3" || headers[1].Get("thread-id") != "thread-3" || headers[1].Get("x-client-request-id") != "thread-3" {
455+ t.Fatalf("retry correlation headers = session=%q thread=%q request=%q",
456+ headers[1].Get("session-id"), headers[1].Get("thread-id"), headers[1].Get("x-client-request-id"))
457+ }
458+ if len(bodies) != 2 || !strings.Contains(bodies[1], `"prompt_cache_key":"cache-root-3"`) {
459+ t.Fatalf("retry body did not preserve prompt cache key: %v", bodies)
460+ }
461+}
462+
463+func TestCodexDoesNotRetryUnrelatedBadRequest(t *testing.T) {
464+ c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
465+ requests := 0
466+ c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
467+ requests++
468+ return &http.Response{
469+ StatusCode: http.StatusBadRequest,
470+ Header: make(http.Header),
471+ Body: io.NopCloser(strings.NewReader(`{
472+ "error": {"message": "bad input", "param": "input", "code": "invalid_parameter"}
473+ }`)),
474+ }, nil
475+ })
476+
477+ _, err := c.Stream(context.Background(), Request{
478+ Model: "gpt-5.6-sol",
479+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
480+ })
481+ if err == nil {
482+ t.Fatal("expected error, got nil")
483+ }
484+ if requests != 1 {
485+ t.Fatalf("requests = %d, want 1 (no retry)", requests)
486+ }
487+ if !strings.Contains(err.Error(), "http 400") {
488+ t.Fatalf("error = %v, want http 400", err)
489+ }
490+}
491+
492+func TestCodexUnsupportedRetentionSecondFailureReturns(t *testing.T) {
493+ c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
494+ requests := 0
495+ c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
496+ requests++
497+ return &http.Response{
498+ StatusCode: http.StatusBadRequest,
499+ Header: make(http.Header),
500+ Body: io.NopCloser(strings.NewReader(`{
501+ "error": {
502+ "message": "prompt_cache_retention is not supported",
503+ "param": "prompt_cache_retention",
504+ "code": "unsupported_parameter"
505+ }
506+ }`)),
507+ }, nil
508+ })
509+
510+ _, err := c.Stream(context.Background(), Request{
511+ Model: "gpt-5.6-sol",
512+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
513+ })
514+ if err == nil {
515+ t.Fatal("expected error, got nil")
516+ }
517+ if requests != 2 {
518+ t.Fatalf("requests = %d, want exactly one retry", requests)
519+ }
520+ if !strings.Contains(err.Error(), "http 400") {
521+ t.Fatalf("error = %v, want second http 400", err)
522+ }
523+}
524+
525+func TestUnsupportedPromptCacheRetentionPredicate(t *testing.T) {
526+ retention := func(code string) string {
527+ return `{"error":{"message":"` + code + `","param":"prompt_cache_retention","code":"invalid_parameter"}}`
528+ }
529+ cases := []struct {
530+ name string
531+ status int
532+ body string
533+ want bool
534+ }{
535+ {"code match", http.StatusBadRequest, retention("anything"), true},
536+ {"message match", http.StatusBadRequest, `{"error":{"message":"retention not supported here","param":"prompt_cache_retention","code":"other"}}`, true},
537+ {"wrong status", http.StatusForbidden, retention("anything"), false},
538+ {"wrong param", http.StatusBadRequest, `{"error":{"message":"x","param":"input","code":"invalid_parameter"}}`, false},
539+ {"no signal", http.StatusBadRequest, `{"error":{"message":"x","param":"prompt_cache_retention","code":"other"}}`, false},
540+ {"malformed", http.StatusBadRequest, "not json", false},
541+ }
542+ for _, tc := range cases {
543+ t.Run(tc.name, func(t *testing.T) {
544+ if got := unsupportedPromptCacheRetention(tc.status, []byte(tc.body)); got != tc.want {
545+ t.Fatalf("predicate = %v, want %v", got, tc.want)
546+ }
547+ })
548+ }
549+}
A packages/provider/openai_tool_images_test.go +210 -0
@@ -0,0 +1,210 @@
1+package provider
2+
3+import "testing"
4+
5+func TestOpenAICompatToolResultImagesFollowTextToolMessages(t *testing.T) {
6+ c := NewOpenAICompat("commandcode", "token", "https://example.test/v1", "").(*openaiClient)
7+
8+ wire, err := c.buildRequest(Request{
9+ Model: "vision-model",
10+ Messages: []Message{
11+ {Role: RoleUser, Content: []Content{TextBlock{Text: "read both images"}}},
12+ {Role: RoleAssistant, Content: []Content{
13+ ToolCallBlock{ID: "call-1", Name: "read"},
14+ ToolCallBlock{ID: "call-2", Name: "read"},
15+ }},
16+ {Role: RoleTool, Content: []Content{
17+ ToolResultBlock{CallID: "call-1", Content: []Content{
18+ ImageBlock{MimeType: "image/png", Data: []byte("first")},
19+ }},
20+ }},
21+ {Role: RoleTool, Content: []Content{
22+ ToolResultBlock{CallID: "call-2", Content: []Content{
23+ TextBlock{Text: "second image"},
24+ ImageBlock{MimeType: "image/jpeg", Data: []byte("second")},
25+ }},
26+ }},
27+ },
28+ })
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+
33+ wantRoles := []string{"user", "assistant", "tool", "tool", "user"}
34+ if len(wire.Messages) != len(wantRoles) {
35+ t.Fatalf("message count = %d, want %d: %+v", len(wire.Messages), len(wantRoles), wire.Messages)
36+ }
37+ for i, want := range wantRoles {
38+ if got := wire.Messages[i].Role; got != want {
39+ t.Fatalf("message %d role = %q, want %q", i, got, want)
40+ }
41+ }
42+ if got, ok := wire.Messages[2].Content.(string); !ok || got != "(see attached image)" {
43+ t.Fatalf("first tool content = %#v, want text placeholder", wire.Messages[2].Content)
44+ }
45+ if got, ok := wire.Messages[3].Content.(string); !ok || got != "second image" {
46+ t.Fatalf("second tool content = %#v, want text", wire.Messages[3].Content)
47+ }
48+
49+ parts, ok := wire.Messages[4].Content.([]interface{})
50+ if !ok {
51+ t.Fatalf("image message content type = %T, want []interface{}", wire.Messages[4].Content)
52+ }
53+ if len(parts) != 3 {
54+ t.Fatalf("image message parts = %d, want prefix and two images", len(parts))
55+ }
56+ if text, ok := parts[0].(oaiContentText); !ok || text.Text != "Tool output included the following image content:" {
57+ t.Fatalf("image message prefix = %#v", parts[0])
58+ }
59+ for i, part := range parts[1:] {
60+ image, ok := part.(oaiContentImage)
61+ if !ok || image.Type != "image_url" {
62+ t.Fatalf("image part %d = %#v", i, part)
63+ }
64+ }
65+}
66+
67+func TestOpenAICompatDoesNotDuplicatePersistedToolImageMirror(t *testing.T) {
68+ c := NewOpenAICompat("custom", "token", "https://example.test/v1", "").(*openaiClient)
69+ image := ImageBlock{MimeType: "image/png", Data: []byte("image")}
70+
71+ wire, err := c.buildRequest(Request{
72+ Model: "vision-model",
73+ Messages: []Message{
74+ {Role: RoleAssistant, Content: []Content{
75+ ToolCallBlock{ID: "call-1", Name: "read"},
76+ }},
77+ {Role: RoleTool, Content: []Content{
78+ ToolResultBlock{CallID: "call-1", Content: []Content{image}},
79+ }},
80+ {Role: RoleUser, Content: []Content{
81+ TextBlock{Text: "Tool output included the following image content:"},
82+ image,
83+ }},
84+ },
85+ })
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+
90+ wantRoles := []string{"assistant", "tool", "user"}
91+ if len(wire.Messages) != len(wantRoles) {
92+ t.Fatalf("message count = %d, want %d: %+v", len(wire.Messages), len(wantRoles), wire.Messages)
93+ }
94+ for i, want := range wantRoles {
95+ if got := wire.Messages[i].Role; got != want {
96+ t.Fatalf("message %d role = %q, want %q", i, got, want)
97+ }
98+ }
99+}
100+
101+func TestOpenAICompatToolResultErrorText(t *testing.T) {
102+ c := NewOpenAICompat("custom", "token", "https://example.test/v1", "").(*openaiClient)
103+
104+ wire, err := c.buildRequest(Request{
105+ Model: "vision-model",
106+ Messages: []Message{
107+ {Role: RoleAssistant, Content: []Content{
108+ ToolCallBlock{ID: "call-1", Name: "read"},
109+ }},
110+ {Role: RoleTool, Content: []Content{
111+ ToolResultBlock{CallID: "call-1", IsError: true, Content: []Content{
112+ TextBlock{Text: "boom"},
113+ }},
114+ }},
115+ },
116+ })
117+ if err != nil {
118+ t.Fatal(err)
119+ }
120+ if len(wire.Messages) != 2 {
121+ t.Fatalf("message count = %d, want 2: %+v", len(wire.Messages), len(wire.Messages))
122+ }
123+ if got, ok := wire.Messages[1].Content.(string); !ok || got != "boom [error]" {
124+ t.Fatalf("tool content = %#v, want error text", wire.Messages[1].Content)
125+ }
126+}
127+
128+func TestOpenAICompatDeepSeekDropsToolImages(t *testing.T) {
129+ c := NewOpenAICompat("deepseek", "token", "https://example.test/v1", "").(*openaiClient)
130+
131+ wire, err := c.buildRequest(Request{
132+ Model: "deepseek-chat",
133+ Messages: []Message{
134+ {Role: RoleAssistant, Content: []Content{
135+ ToolCallBlock{ID: "call-1", Name: "read"},
136+ }},
137+ {Role: RoleTool, Content: []Content{
138+ ToolResultBlock{CallID: "call-1", Content: []Content{
139+ TextBlock{Text: "caption"},
140+ ImageBlock{MimeType: "image/png", Data: []byte("image")},
141+ }},
142+ }},
143+ },
144+ })
145+ if err != nil {
146+ t.Fatal(err)
147+ }
148+ if len(wire.Messages) != 2 {
149+ t.Fatalf("message count = %d, want 2 (no image mirror): %+v", len(wire.Messages), len(wire.Messages))
150+ }
151+ if got, ok := wire.Messages[1].Content.(string); !ok || got != "caption" {
152+ t.Fatalf("tool content = %#v, want text only", wire.Messages[1].Content)
153+ }
154+}
155+
156+func TestOpenAICompatOpenCodeGoDeepSeekDropsToolImages(t *testing.T) {
157+ c := NewOpenAICompat(ProviderOpenCodeGo, "token", "https://example.test/v1", "").(*openaiClient)
158+
159+ wire, err := c.buildRequest(Request{
160+ Model: "deepseek/deepseek-chat",
161+ Messages: []Message{
162+ {Role: RoleAssistant, Content: []Content{
163+ ToolCallBlock{ID: "call-1", Name: "read"},
164+ }},
165+ {Role: RoleTool, Content: []Content{
166+ ToolResultBlock{CallID: "call-1", Content: []Content{
167+ TextBlock{Text: "caption"},
168+ ImageBlock{MimeType: "image/png", Data: []byte("image")},
169+ }},
170+ }},
171+ },
172+ })
173+ if err != nil {
174+ t.Fatal(err)
175+ }
176+ if len(wire.Messages) != 2 {
177+ t.Fatalf("message count = %d, want 2 (no image mirror): %+v", len(wire.Messages), len(wire.Messages))
178+ }
179+ if got, ok := wire.Messages[1].Content.(string); !ok || got != "caption" {
180+ t.Fatalf("tool content = %#v, want text only", wire.Messages[1].Content)
181+ }
182+}
183+
184+func TestOpenAICompatUserImagesPassThrough(t *testing.T) {
185+ c := NewOpenAICompat("custom", "token", "https://example.test/v1", "").(*openaiClient)
186+ image := ImageBlock{MimeType: "image/png", Data: []byte("image")}
187+
188+ wire, err := c.buildRequest(Request{
189+ Model: "vision-model",
190+ Messages: []Message{
191+ {Role: RoleUser, Content: []Content{
192+ TextBlock{Text: "look"},
193+ image,
194+ }},
195+ },
196+ })
197+ if err != nil {
198+ t.Fatal(err)
199+ }
200+ if len(wire.Messages) != 1 {
201+ t.Fatalf("message count = %d, want 1: %+v", len(wire.Messages), len(wire.Messages))
202+ }
203+ parts, ok := wire.Messages[0].Content.([]interface{})
204+ if !ok || len(parts) != 2 {
205+ t.Fatalf("user content = %#v, want text+image", wire.Messages[0].Content)
206+ }
207+ if _, ok := parts[1].(oaiContentImage); !ok {
208+ t.Fatalf("second part = %#v, want image", parts[1])
209+ }
210+}
M packages/provider/provider.go +9 -7
@@ -131,12 +131,12 @@ type ReasoningBlock struct {
131131
132132 func (ReasoningBlock) isContent() {}
133133
134-// RepairOrphanedToolResults removes tool_result content blocks (and
135-// entire messages that become empty) when the matching tool_use ID
136-// does not appear anywhere in the given messages. Resume tails,
137-// compaction repair, and provider request builders all need this so
138-// the upstream API never sees a tool_call_id with no corresponding
139-// assistant tool_call earlier in the same request.
134+// RepairOrphanedToolResults removes invalid tool_result content blocks
135+// and entire messages that become empty. A result is invalid when its
136+// matching tool_use ID does not appear anywhere in the messages or when
137+// an earlier result already covers the same ID. Resume tails, compaction
138+// repair, and provider request builders all need this so the upstream API
139+// sees exactly one result for each referenced tool call.
140140 func RepairOrphanedToolResults(msgs []Message) []Message {
141141 useIDs := map[string]bool{}
142142 for _, m := range msgs {
@@ -146,14 +146,16 @@ func RepairOrphanedToolResults(msgs []Message) []Message {
146146 }
147147 }
148148 }
149+ resultIDs := map[string]bool{}
149150 out := make([]Message, 0, len(msgs))
150151 for _, m := range msgs {
151152 var filtered []Content
152153 for _, c := range m.Content {
153154 if tr, ok := c.(ToolResultBlock); ok {
154- if !useIDs[tr.CallID] {
155+ if !useIDs[tr.CallID] || resultIDs[tr.CallID] {
155156 continue
156157 }
158+ resultIDs[tr.CallID] = true
157159 }
158160 filtered = append(filtered, c)
159161 }
M packages/provider/provider_test.go +39 -0
@@ -472,6 +472,45 @@ func TestAnthropicBuildRequestStripsAssistantImages(t *testing.T) {
472472 }
473473 }
474474
475+func TestRepairOrphanedToolResultsDeduplicates(t *testing.T) {
476+ text := func(s string) TextBlock { return TextBlock{Text: s} }
477+ result := func(id, s string) ToolResultBlock {
478+ return ToolResultBlock{CallID: id, Content: []Content{text(s)}}
479+ }
480+ msgs := []Message{
481+ {Role: RoleAssistant, Content: []Content{
482+ ToolCallBlock{ID: "tool-1", Name: "read"},
483+ ToolCallBlock{ID: "tool-2", Name: "read"},
484+ }},
485+ {Role: RoleTool, Content: []Content{result("tool-1", "first"), text("note"), result("tool-1", "dup-same-msg")}},
486+ {Role: RoleTool, Content: []Content{result("tool-1", "dup-across-msg")}},
487+ {Role: RoleTool, Content: []Content{result("tool-2", "second")}},
488+ {Role: RoleTool, Content: []Content{result("orphan", "orphan")}},
489+ }
490+ got := RepairOrphanedToolResults(msgs)
491+ if len(got) != 3 {
492+ t.Fatalf("messages=%d, want 3 after dropping duplicates and orphans", len(got))
493+ }
494+ first := got[1].Content
495+ if len(first) != 2 {
496+ t.Fatalf("first result message blocks=%d, want result+note", len(first))
497+ }
498+ tr, ok := first[0].(ToolResultBlock)
499+ if !ok || tr.CallID != "tool-1" {
500+ t.Fatalf("first block=%T %+v, want tool-1 result", first[0], first[0])
501+ }
502+ if txt, ok := tr.Content[0].(TextBlock); !ok || txt.Text != "first" {
503+ t.Fatalf("first result content=%+v, want first", tr.Content)
504+ }
505+ if txt, ok := first[1].(TextBlock); !ok || txt.Text != "note" {
506+ t.Fatalf("mixed content=%+v, want preserved note", first[1])
507+ }
508+ last, ok := got[2].Content[0].(ToolResultBlock)
509+ if !ok || last.CallID != "tool-2" {
510+ t.Fatalf("last block=%T %+v, want tool-2 result", got[2].Content[0], got[2].Content[0])
511+ }
512+}
513+
475514 func TestAnthropicStreamHappyPath(t *testing.T) {
476515 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
477516 w.Header().Set("content-type", "text/event-stream")
M packages/tui/render.go +32 -9
@@ -763,7 +763,23 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int
763763 // real previous bg-colored row.
764764
765765 wasInitialized := r.logInit
766- full := !wasInitialized || len(r.logLines) == 0
766+ bottomShrunkAboveViewport := false
767+ if len(bottomFrame) < len(r.logBottom) {
768+ newViewportTop := len(lines) - r.rows
769+ if newViewportTop < 0 {
770+ newViewportTop = 0
771+ }
772+ // If the shorter bottom frame starts above the currently addressable
773+ // viewport, relative cursor movement cannot repaint its prefix. This
774+ // happens when returning from a moderately long dialog to a short
775+ // one, and more generally on any bottom-band shrink while the
776+ // transcript is taller than the viewport (queue chips draining,
777+ // extension chrome lines, activity pages). The full repaint rewrites
778+ // the whole frame without purging scrollback, matching the
779+ // recovery path below.
780+ bottomShrunkAboveViewport = newViewportTop < r.logViewportTop
781+ }
782+ full := !wasInitialized || len(r.logLines) == 0 || bottomShrunkAboveViewport
767783 if full {
768784 // A cache invalidation may require a full viewport repaint after flow
769785 // has started, but only the initial paint may discard old scrollback.
@@ -989,10 +1005,12 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int
9891005 w.WriteString(lines[idx])
9901006 }
9911007 finalRow := renderEnd
1008+ fullRepaint := false
9921009 if len(r.logLines) > len(lines) {
9931010 extra := len(r.logLines) - len(lines)
994- if extra > r.rows {
1011+ if extra >= r.rows {
9951012 writeFull(true, false)
1013+ fullRepaint = true
9961014 } else {
9971015 for e := 0; e < extra; e++ {
9981016 w.WriteString("\x1b[1B")
@@ -1007,13 +1025,18 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int
10071025 }
10081026 }
10091027 }
1010- r.logHardwareRow = finalRow
1011- r.logViewportTop = viewportTop
1012- if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
1013- r.logViewportTop = minTop
1014- }
1015- if r.logViewportTop < 0 {
1016- r.logViewportTop = 0
1028+ // When writeFull ran above it already recomputed logHardwareRow
1029+ // and logViewportTop; overwriting them with stale incremental
1030+ // values here would desync the next relative move.
1031+ if !fullRepaint {
1032+ r.logHardwareRow = finalRow
1033+ r.logViewportTop = viewportTop
1034+ if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
1035+ r.logViewportTop = minTop
1036+ }
1037+ if r.logViewportTop < 0 {
1038+ r.logViewportTop = 0
1039+ }
10171040 }
10181041 }
10191042 }
M packages/tui/render_test.go +127 -0
@@ -292,3 +292,130 @@ func TestDrawLogInvalidationPreservesScrollbackSelection(t *testing.T) {
292292 t.Fatalf("invalidation repaint erased scrollback and native selection: %q", got)
293293 }
294294 }
295+
296+// TestDrawLogPartialBottomShrinkRepaintsInaccessibleRows covers returning
297+// from a moderately long dialog to a short one: the shorter bottom frame
298+// starts above the currently addressable viewport, so relative cursor
299+// movement cannot repaint its prefix and a full repaint is required.
300+// Without the forced repaint, stale dialog rows stay on screen.
301+func TestDrawLogPartialBottomShrinkRepaintsInaccessibleRows(t *testing.T) {
302+ t.Setenv("TERM_PROGRAM", "ghostty")
303+ var buf bytes.Buffer
304+ r := NewRenderer(&buf)
305+ r.Resize(80, 4)
306+
307+ r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5"}, -1, 0)
308+ buf.Reset()
309+
310+ r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0)
311+ got := buf.String()
312+ if !strings.Contains(got, SeqClearScreenNoHome) {
313+ t.Fatalf("partial bottom shrink did not repaint the screen: %q", got)
314+ }
315+ for _, want := range []string{"dashboard", "selected agent 1"} {
316+ if !strings.Contains(got, want) {
317+ t.Fatalf("partial bottom repaint missing %q: %q", want, got)
318+ }
319+ }
320+}
321+
322+// TestDrawLogChatShrinkDoesNotForceBottomRepaint pins the scope of the
323+// forced repaint: chat reflow has its own coordinate-rebasing path which
324+// preserves native terminal selections. Only a shrinking bottom frame
325+// forces the full repaint used when returning to a short dialog.
326+func TestDrawLogChatShrinkDoesNotForceBottomRepaint(t *testing.T) {
327+ t.Setenv("TERM_PROGRAM", "ghostty")
328+ var buf bytes.Buffer
329+ r := NewRenderer(&buf)
330+ r.Resize(80, 4)
331+
332+ r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5", "chat 6"}, []string{"input"}, 0, 0)
333+ buf.Reset()
334+
335+ r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5"}, []string{"input"}, 0, 0)
336+ if got := buf.String(); strings.Contains(got, SeqClearScreenNoHome) {
337+ t.Fatalf("chat shrink unnecessarily repainted the screen: %q", got)
338+ }
339+}
340+
341+// TestDrawLogFullHeightShrinkRepaintsAndTracksViewport covers a dialog
342+// shrink that removes a full viewport of logical rows: the incremental
343+// clear-below path cannot address them, so the draw must repaint fully
344+// and the recomputed viewport state must survive for the next draw.
345+// A cursor move right after must update the visible selection instead of
346+// being discarded as inaccessible.
347+func TestDrawLogFullHeightShrinkRepaintsAndTracksViewport(t *testing.T) {
348+ t.Setenv("TERM_PROGRAM", "ghostty")
349+ var buf bytes.Buffer
350+ r := NewRenderer(&buf)
351+ r.Resize(80, 4)
352+
353+ r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5", "transcript 6"}, -1, 0)
354+ buf.Reset()
355+
356+ r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0)
357+ if got := buf.String(); !strings.Contains(got, SeqClearScreenNoHome) {
358+ t.Fatalf("full-height shrink did not repaint the screen: %q", got)
359+ }
360+ buf.Reset()
361+
362+ r.DrawLog(nil, []string{"dashboard", "selected agent 2"}, -1, 0)
363+ if got := buf.String(); !strings.Contains(got, "selected agent 2") {
364+ t.Fatalf("visible update after full-height shrink was suppressed: %q", got)
365+ }
366+}
367+
368+// TestDrawLogShrinkRecoveryMatrix pins the Zut-specific renderer state
369+// across a forced full repaint: themed backgrounds still tint the frame,
370+// keepScrollback terminals never purge scrollback, Kitty images are
371+// cleaned, and a narrow follow-up draw stays coherent with no stale rows.
372+func TestDrawLogShrinkRecoveryMatrix(t *testing.T) {
373+ t.Setenv("TERM_PROGRAM", "vscode")
374+ var buf bytes.Buffer
375+ r := NewRenderer(&buf)
376+ background := Color256(237)
377+ th := Dark
378+ th.Background = &background
379+ r.SetTheme(th)
380+ r.Resize(80, 4)
381+ if !r.keepScrollback {
382+ t.Fatal("vscode TERM_PROGRAM did not enable keepScrollback")
383+ }
384+
385+ image := "\x1b_Ga=T;payload\x1b\\"
386+ r.DrawLog(nil, []string{image, "transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5"}, -1, 0)
387+ buf.Reset()
388+
389+ r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0)
390+ got := buf.String()
391+ if !strings.Contains(got, SeqClearToEnd) {
392+ t.Fatalf("themed shrink recovery did not repaint in place: %q", got)
393+ }
394+ if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
395+ t.Fatalf("keepScrollback repaint purged scrollback: %q", got)
396+ }
397+ if !strings.Contains(got, SeqDeleteKittyImages) {
398+ t.Fatalf("shrink recovery did not clean Kitty images: %q", got)
399+ }
400+ if !strings.Contains(got, th.BackgroundStyle()) {
401+ t.Fatalf("themed repaint lost the background tint: %q", got)
402+ }
403+ buf.Reset()
404+
405+ // A narrow follow-up draw must stay coherent: no stale rows, no extra
406+ // full repaint, and the hardware cursor tracks the new viewport.
407+ r.Resize(20, 4)
408+ r.DrawLog(nil, []string{"dashboard", "selected agent 2"}, -1, 0)
409+ got = buf.String()
410+ if !strings.Contains(got, "selected agent 2") {
411+ t.Fatalf("narrow follow-up draw lost the selection: %q", got)
412+ }
413+ if strings.Contains(got, "transcript") {
414+ t.Fatalf("narrow follow-up draw left stale rows: %q", got)
415+ }
416+ for _, row := range strings.Split(got, "\r\n") {
417+ if strings.ContainsAny(row, "\r\n") {
418+ t.Fatalf("row embeds a newline: %q", row)
419+ }
420+ }
421+}
M packages/tui/view.go +26 -16
@@ -465,17 +465,17 @@ func (v *View) renderErr(width int) []string {
465465 if wrapWidth < 8 {
466466 wrapWidth = 8
467467 }
468- wrapped := wrapLine(v.Err, wrapWidth, "")
469- if len(wrapped) == 0 {
470- wrapped = []string{""}
471- }
472- out := make([]string, 0, len(wrapped))
473- for idx, line := range wrapped {
474- prefix := marker
475- if idx > 0 {
476- prefix = indent
468+ var out []string
469+ // Each returned row must represent one terminal line, including when
470+ // the error contains explicit newlines or blank separator lines.
471+ for _, paragraph := range strings.Split(v.Err, "\n") {
472+ for _, line := range wrapLine(paragraph, wrapWidth, "") {
473+ prefix := indent
474+ if len(out) == 0 {
475+ prefix = marker
476+ }
477+ out = append(out, v.Theme.FGColor(v.Theme.Error, prefix+line))
477478 }
478- out = append(out, v.Theme.FGColor(v.Theme.Error, prefix+line))
479479 }
480480 return out
481481 }
@@ -2859,9 +2859,9 @@ func toolArgWidth() int {
28592859 // suffix for the "tool name <args>" header. tool is the tool
28602860 // name so we can add shape-specific decorations: for read we
28612861 // append the requested line range (e.g. "path:1-200") pulled
2862-// from the offset/limit args, which is useful context at a
2863-// glance without expanding the result body. Other tools keep
2864-// the legacy "path or command, truncated" shape.
2862+// from the offset/limit args, and for glob we prefer the pattern
2863+// with an " in <dir>" suffix. Other tools keep the legacy
2864+// "path, file_path, or command, truncated" shape.
28652865 //
28662866 // The truncation width defaults to 60 cells but can be tuned via
28672867 // the ZUT_TOOL_ARG_WIDTH environment variable (see toolArgWidth).
@@ -2886,7 +2886,14 @@ func ShortArgs(tool string, raw json.RawMessage) string {
28862886 return s
28872887 }
28882888 var primary string
2889- for _, k := range []string{"path", "file_path", "command"} {
2889+ // Glob leads with its pattern; every other tool keeps the legacy
2890+ // path-first shape so tools carrying both (e.g. ast's pattern+path)
2891+ // still label with the file path.
2892+ keys := []string{"path", "file_path", "command"}
2893+ if strings.EqualFold(tool, "glob") {
2894+ keys = []string{"pattern", "path", "file_path", "command"}
2895+ }
2896+ for _, k := range keys {
28902897 if s, ok := x[k].(string); ok {
28912898 primary = s
28922899 break
@@ -2902,8 +2909,7 @@ func ShortArgs(tool string, raw json.RawMessage) string {
29022909 }
29032910 primary = oneLineToolLabel(primary)
29042911
2905- // Tool-specific decoration. Only the read tool gets a range
2906- // suffix for now; other tools just truncate the primary arg.
2912+ // Tool-specific decoration.
29072913 suffix := ""
29082914 switch strings.ToLower(tool) {
29092915 case "read":
@@ -2917,6 +2923,10 @@ func ShortArgs(tool string, raw json.RawMessage) string {
29172923 } else if start > 1 {
29182924 suffix = fmt.Sprintf(":%d-", start)
29192925 }
2926+ case "glob":
2927+ if dir, ok := x["path"].(string); ok && dir != "" && dir != "." {
2928+ suffix = " in " + dir
2929+ }
29202930 }
29212931
29222932 // Truncate the primary arg leaving room for the suffix so the
A packages/tui/view_error_test.go +28 -0
@@ -0,0 +1,28 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestRenderErrSplitsExplicitNewlines(t *testing.T) {
9+ v := &View{Theme: Theme{Error: Color256(1)}, Err: "failed to start\n\n exec: python3\nstderr log: /tmp/ext.log"}
10+ want := []string{"✖ failed to start", " ", " exec: python3", " stderr log: /tmp/ext.log"}
11+ rows := v.renderErr(80)
12+ if len(rows) != len(want) {
13+ t.Fatalf("rows = %q, want %d rows", rows, len(want))
14+ }
15+ for n, row := range rows {
16+ if expected := v.Theme.FGColor(v.Theme.Error, want[n]); row != expected {
17+ t.Errorf("row %d = %q, want %q", n, row, expected)
18+ }
19+ }
20+ for _, row := range v.renderErr(20) {
21+ if strings.ContainsAny(row, "\r\n") {
22+ t.Errorf("row contains a newline: %q", row)
23+ }
24+ if width := visibleWidth(row); width > 20 {
25+ t.Errorf("row width = %d, want <= 20: %q", width, row)
26+ }
27+ }
28+}
M packages/tui/view_tool_arg_width_test.go +13 -0
@@ -61,3 +61,16 @@ func TestShortArgsNoTruncationWhenShort(t *testing.T) {
6161 t.Fatalf("ShortArgs = %q, want %q", got, "short query")
6262 }
6363 }
64+
65+func TestShortArgsGlobShowsPatternAndDir(t *testing.T) {
66+ t.Setenv("ZUT_TOOL_ARG_WIDTH", "")
67+ if got := ShortArgs("glob", json.RawMessage(`{"pattern":"**/*.go"}`)); got != "**/*.go" {
68+ t.Fatalf("ShortArgs glob = %q, want pattern", got)
69+ }
70+ if got := ShortArgs("glob", json.RawMessage(`{"pattern":"*.json","path":"configs"}`)); got != "*.json in configs" {
71+ t.Fatalf("ShortArgs glob with dir = %q, want pattern plus dir", got)
72+ }
73+ if got := ShortArgs("glob", json.RawMessage(`{"pattern":"*.json","path":"."}`)); got != "*.json" {
74+ t.Fatalf("ShortArgs glob with dot dir = %q, want bare pattern", got)
75+ }
76+}

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