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.
9 9
10 - one static binary.10 - one static binary.
11 - 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.11 - 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.
13 - three run modes (interactive tui, print, json).13 - three run modes (interactive tui, print, json).
14 - built-in telegram bot.14 - built-in telegram bot.
15 - 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).15 - 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
264 - `write`: create or overwrite files, making parent directories as needed.264 - `write`: create or overwrite files, making parent directories as needed.
265 - `edit`: one or more exact-match replacements in an existing file.265 - `edit`: one or more exact-match replacements in an existing file.
266 - `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.266 - `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).
267 - `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.268 - `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.
268 - `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.269 - `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.
269 - `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).270 - `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
276 277
277 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.278 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.
278 279
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.
280 281
281 ## Modes282 ## Modes
282 283
@@ -381,7 +382,7 @@ Type `!` followed by a command to run it directly without going through the mode
381 382
382 ### `/sessions`383 ### `/sessions`
383 384
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.
385 386
386 ### `/session`387 ### `/session`
387 388
@@ -526,6 +527,20 @@ This is a guardrail against accidents, not a hard security boundary. If you need
526 527
527 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.528 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.
528 529
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+
529 ## Providers544 ## Providers
530 545
531 zut's built-in provider catalog includes:546 zut's built-in provider catalog includes:
@@ -974,7 +989,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
974 | `ctrl+d` | Exit on empty input. |989 | `ctrl+d` | Exit on empty input. |
975 | `ctrl+l` | Redraw the screen. |990 | `ctrl+l` | Redraw the screen. |
976 | `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. |991 | `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). |
978 | `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`. |993 | `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`. |
979 | `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. |994 | `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. |
980 | `@` | Open the file picker. Browse files and directories in the working directory. |995 | `@` | Open the file picker. Browse files and directories in the working directory. |
@@ -1158,7 +1173,7 @@ packages/agent/extproto/ extension wire-format types
1158 packages/agent/modes/ interactive tui, print, json, dialogs1173 packages/agent/modes/ interactive tui, print, json, dialogs
1159 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)1174 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
1160 packages/agent/modes/telegram/ telegram adapter, api client, daemon1175 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1161-packages/agent/tools/ read, write, edit, bash, create_worktree, grep, lsp, web search, goals, sandbox1176+packages/agent/tools/ read, write, edit, bash, create_worktree, grep, glob, lsp, web search, goals, sandbox
1162 packages/agent/skills/ skill discovery, frontmatter parser, skill tool1177 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
1163 packages/agent/subagents/ named profiles and resident background runtime1178 packages/agent/subagents/ named profiles and resident background runtime
1164 packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk)1179 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
776 seconds. Extension failures from startup and `/reload-ext` remain in the776 seconds. Extension failures from startup and `/reload-ext` remain in the
777 scrolling chat until a successful reload or `/clear`, without being sent to777 scrolling chat until a successful reload or `/clear`, without being sent to
778 the model or saved in the session transcript. Subprocess startup and778 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.
780 If a subprocess exits before sending `hello`, the error also reports its exit784 If a subprocess exits before sending `hello`, the error also reports its exit
781 status or terminating signal.785 status or terminating signal.
782 786
M packages/agent/args.go +5 -0
@@ -427,6 +427,11 @@ func PrintHelp(version string) {
427 row{"zut --ext ./path/to/ext", "load an extension for this run only"},427 row{"zut --ext ./path/to/ext", "load an extension for this run only"},
428 row{"zut ext help", "show all extension subcommands"},428 row{"zut ext help", "show all extension subcommands"},
429 )429 )
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+ )
430 section("self-update",435 section("self-update",
431 row{"zut update", "download and install the latest release"},436 row{"zut update", "download and install the latest release"},
432 row{"zut update --check", "show whether a new release is available"},437 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) {
123 // Web capability and other native names remain reserved even when their123 // Web capability and other native names remain reserved even when their
124 // policy excludes the current session. An extension must not turn a124 // policy excludes the current session. An extension must not turn a
125 // normal CLI opt-out into a differently implemented capability.125 // 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 {
127 continue127 continue
128 }128 }
129 if _, exists := r.ToolRegistry[info.Name]; exists {129 if _, exists := r.ToolRegistry[info.Name]; exists {
@@ -1318,6 +1318,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1318 v.Sandbox = s1318 v.Sandbox = s
1319 case *tools.GrepTool:1319 case *tools.GrepTool:
1320 v.Sandbox = s1320 v.Sandbox = s
1321+ case *tools.GlobTool:
1322+ v.Sandbox = s
1321 case *tools.ASTTool:1323 case *tools.ASTTool:
1322 v.Sandbox = s1324 v.Sandbox = s
1323 }1325 }
@@ -1356,6 +1358,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
1356 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},1358 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1357 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},1359 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1358 "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},1360 "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1361+ "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1359 "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},1362 "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1360 "update_goal": &tools.UpdateGoalTool{},1363 "update_goal": &tools.UpdateGoalTool{},
1361 "update_plan": &tools.UpdatePlanTool{},1364 "update_plan": &tools.UpdatePlanTool{},
@@ -1394,6 +1397,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
1394 if orchestratorExplorationToolsAllowed(args) {1397 if orchestratorExplorationToolsAllowed(args) {
1395 reg["read"] = all["read"]1398 reg["read"] = all["read"]
1396 reg["grep"] = all["grep"]1399 reg["grep"] = all["grep"]
1400+ reg["glob"] = all["glob"]
1397 if astTool, ok := all["ast"].(*tools.ASTTool); ok {1401 if astTool, ok := all["ast"].(*tools.ASTTool); ok {
1398 astTool.ReadOnly = true1402 astTool.ReadOnly = true
1399 reg["ast"] = astTool1403 reg["ast"] = astTool
@@ -1504,7 +1508,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1504 return false1508 return false
1505 }1509 }
1506 1510
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"}
1508 1512
1509 func toolSummaries(reg core.Registry, args Args) []ToolSummary {1513 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1510 var out []ToolSummary1514 var out []ToolSummary
M packages/agent/build_grep_test.go +34 -0
@@ -46,6 +46,40 @@ func TestBuildToolRegistryIncludesUpdatePlanByDefaultAndExplicitSelection(t *tes
46 }46 }
47 }47 }
48 48
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+
49 func TestBuildToolRegistryGrepSelectionAndDefaults(t *testing.T) {83 func TestBuildToolRegistryGrepSelectionAndDefaults(t *testing.T) {
50 cwd := t.TempDir()84 cwd := t.TempDir()
51 85
M packages/agent/build_orchestrator_test.go +3 -0
@@ -126,6 +126,9 @@ func TestOrchestratorAlwaysReceivesResearchTools(t *testing.T) {
126 if _, ok := registry["grep"].(*tools.GrepTool); ok != tc.wantResearch {126 if _, ok := registry["grep"].(*tools.GrepTool); ok != tc.wantResearch {
127 t.Fatalf("grep = %T, want research tool present = %v", registry["grep"], tc.wantResearch)127 t.Fatalf("grep = %T, want research tool present = %v", registry["grep"], tc.wantResearch)
128 }128 }
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+ }
129 for _, name := range tools.WebCapabilityNames {132 for _, name := range tools.WebCapabilityNames {
130 _, gotWebTool := registry[name]133 _, gotWebTool := registry[name]
131 if gotWebTool != tc.wantWeb {134 if gotWebTool != tc.wantWeb {
M packages/agent/cli.go +3 -0
@@ -867,6 +867,9 @@ func Run(rawArgs []string, version string) error {
867 if handled, err := runUpdateCommand(rawArgs, version); handled {867 if handled, err := runUpdateCommand(rawArgs, version); handled {
868 return err868 return err
869 }869 }
870+ if handled, err := runSessionsCommand(rawArgs); handled {
871+ return err
872+ }
870 if handled, err := runZutfileCommand(rawArgs, version); handled {873 if handled, err := runZutfileCommand(rawArgs, version); handled {
871 return err874 return err
872 }875 }
M packages/agent/ext/ext.go +4 -3
@@ -421,9 +421,10 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
421 // into the agent's registry once the extension's ready frame fires.421 // into the agent's registry once the extension's ready frame fires.
422 //422 //
423 // Conflicts with active built-in tool names (read, write, edit, bash,423 // Conflicts with active built-in tool names (read, write, edit, bash,
424-// python, create_worktree, grep, ast, lsp, web_search, web_open, web_find,424+// python, create_worktree, grep, glob, ast, lsp, web_search, web_open,
425-// web_click, schedule, update_goal, update_plan, and skill) are silently425+// web_find, web_click, schedule, update_goal, update_plan, and skill) are
426-// shadowed by the built-in. Every native public-web name, plus grep, ast, python, schedule,426+// silently shadowed by the built-in. Every native public-web name, plus
427+// grep, glob, ast, python, schedule,
427 // update_goal, and update_plan, is reserved even when unavailable, so an428 // update_goal, and update_plan, is reserved even when unavailable, so an
428 // extension cannot claim or replace it.429 // extension cannot claim or replace it.
429 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {430 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 {
195 if len(errs) > 0 {195 if len(errs) > 0 {
196 fmt.Fprintln(os.Stdout, "load errors:")196 fmt.Fprintln(os.Stdout, "load errors:")
197 for _, err := range errs {197 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)
199 }202 }
200 }203 }
201 return nil204 return nil
M packages/agent/extensions/manager.go +16 -8
@@ -308,7 +308,7 @@ func (m *Manager) Discover(ctx context.Context) []error {
308 go func(extDir string) {308 go func(extDir string) {
309 defer wg.Done()309 defer wg.Done()
310 if err := m.loadOne(ctx, extDir); err != nil {310 if err := m.loadOne(ctx, extDir); err != nil {
311- errCh <- fmt.Errorf("%s: %w", extDir, err)311+ errCh <- err
312 }312 }
313 }(j.dir)313 }(j.dir)
314 }314 }
@@ -342,18 +342,18 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
342 manifestPath := filepath.Join(dir, "extension.json")342 manifestPath := filepath.Join(dir, "extension.json")
343 raw, err := os.ReadFile(manifestPath)343 raw, err := os.ReadFile(manifestPath)
344 if err != nil {344 if err != nil {
345- return fmt.Errorf("read manifest: %w", err)345+ return fmt.Errorf("%s: read manifest: %w", dir, err)
346 }346 }
347 var mf Manifest347 var mf Manifest
348 if err := json.Unmarshal(raw, &mf); err != nil {348 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)
350 }350 }
351 if mf.Name == "" {351 if mf.Name == "" {
352- return errors.New("manifest: name is required")352+ return fmt.Errorf("%s: manifest: name is required", dir)
353 }353 }
354 hasTheme := HasExtensionTheme(dir)354 hasTheme := HasExtensionTheme(dir)
355 if mf.Exec == "" && !hasTheme {355 if mf.Exec == "" && !hasTheme {
356- return errors.New("manifest: exec is required")356+ return fmt.Errorf("%s: manifest: exec is required", dir)
357 }357 }
358 if !mf.IsEnabled() {358 if !mf.IsEnabled() {
359 // Quietly skip disabled extensions; zut ext list will show them.359 // Quietly skip disabled extensions; zut ext list will show them.
@@ -387,7 +387,15 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
387 }387 }
388 if mf.Exec != "" {388 if mf.Exec != "" {
389 if err := m.spawn(ctx, ext); err != nil {389 if err := m.spawn(ctx, ext); err != nil {
390- return err390+ 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)
391 }399 }
392 } else {400 } else {
393 ext.readyOnce.Do(func() { close(ext.readyCh) })401 ext.readyOnce.Do(func() { close(ext.readyCh) })
@@ -462,7 +470,7 @@ func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {
462 go func(extDir string) {470 go func(extDir string) {
463 defer wg.Done()471 defer wg.Done()
464 if err := m.loadOne(ctx, extDir); err != nil {472 if err := m.loadOne(ctx, extDir); err != nil {
465- errCh <- fmt.Errorf("%s: %w", extDir, err)473+ errCh <- err
466 }474 }
467 }(abs)475 }(abs)
468 }476 }
@@ -681,7 +689,7 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
681 return fmt.Errorf("stdout pipe (stderr log: %s): %w", logPath, err)689 return fmt.Errorf("stdout pipe (stderr log: %s): %w", logPath, err)
682 }690 }
683 if err := cmd.Start(); err != nil {691 if err := cmd.Start(); err != nil {
684- return fmt.Errorf("spawn (stderr log: %s): %w", logPath, err)692+ return err
685 }693 }
686 started = true694 started = true
687 ext.cmd = cmd695 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 {
1575 }1575 }
1576 return count1576 return count
1577 }1577 }
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 {
1007 return !busy && !queued && ag != nil && ag.QueuedMessageCount() == 01007 return !busy && !queued && ag != nil && ag.QueuedMessageCount() == 0
1008 }1008 }
1009 1009
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+
1010 func (i *Interactive) applySessionSelection(path string) {1046 func (i *Interactive) applySessionSelection(path string) {
1011 if i.cfg.LoadSession == nil {1047 if i.cfg.LoadSession == nil {
1012 i.mu.Lock()1048 i.mu.Lock()
M packages/agent/modes/interactive_compaction.go +37 -1
@@ -17,6 +17,35 @@ func (i *Interactive) clearPendingCompactTurnLocked() {
17 i.continueAfterCompact = false17 i.continueAfterCompact = false
18 i.pendingPostCompactNote = ""18 i.pendingPostCompactNote = ""
19 }19 }
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+}
20 func (i *Interactive) compactHandoffLocked() json.RawMessage {49 func (i *Interactive) compactHandoffLocked() json.RawMessage {
21 return encodeCompactHandoff(i.compactContinuation)50 return encodeCompactHandoff(i.compactContinuation)
22 }51 }
@@ -322,7 +351,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
322 }351 }
323 i.pendingPostCompactNote = ""352 i.pendingPostCompactNote = ""
324 i.extNotes = stripAutoCompactNotes(i.extNotes)353 i.extNotes = stripAutoCompactNotes(i.extNotes)
325- i.lastCtxInput = 0354+ i.lastCtxInput = estimateCompactedContextInput(msgs)
326 i.toolCalls = map[string]*tui.ToolCallView{}355 i.toolCalls = map[string]*tui.ToolCallView{}
327 i.toolOrder = nil356 i.toolOrder = nil
328 i.toolGate = map[string]int{}357 i.toolGate = map[string]int{}
@@ -380,6 +409,13 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
380 // the mutex. A completion update arriving after the compaction result409 // the mutex. A completion update arriving after the compaction result
381 // but before this assignment is then queued for the selected follow-up410 // but before this assignment is then queued for the selected follow-up
382 // instead of starting a competing turn.411 // 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+ }
383 i.busy = hasNext || continueExisting || continueAutomatically || continueGoal419 i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
384 i.compacting = false420 i.compacting = false
385 i.autoCompacting = false421 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) {
250 if act.Select {250 if act.Select {
251 i.applySessionSelection(act.Path)251 i.applySessionSelection(act.Path)
252 }252 }
253+ if act.Delete {
254+ i.applySessionDeletion(act.Path)
255+ }
253 if act.Err != nil {256 if act.Err != nil {
254 if manualRenameCurrent {257 if manualRenameCurrent {
255 i.restoreFailedSessionTitle()258 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 {
55 }55 }
56 var out []string56 var out []string
57 for _, msg := range errors {57 for _, msg := range errors {
58- wrapped := tui.WrapANSILine(msg, wrapWidth)58+ // Split explicit newlines before wrapping. Passing a multiline string
59- if len(wrapped) == 0 {59+ // to WrapANSILine would leave the terminal to interpret the embedded
60- wrapped = []string{""}60+ // newline, which can misalign the continuation text. Normalize CRLF
61- }61+ // first so no raw CR survives inside a row.
62- for idx, line := range wrapped {62+ for lineIndex, paragraph := range strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n") {
63- prefix := marker63+ wrapped := tui.WrapANSILine(paragraph, wrapWidth)
64- if idx > 0 {64+ if len(wrapped) == 0 {
65- prefix = indent65+ 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))
66 }73 }
67- out = append(out, theme.FGColor(theme.Error, prefix+line))
68 }74 }
69 }75 }
70 return out76 return out
M packages/agent/modes/reload_ext_test.go +15 -0
@@ -35,6 +35,21 @@ func TestFormatReloadStatusIncludesErrorDetails(t *testing.T) {
35 }35 }
36 }36 }
37 37
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+
38 func TestFormatReloadStatusSuccess(t *testing.T) {53 func TestFormatReloadStatusSuccess(t *testing.T) {
39 msg, failed := formatReloadStatus(extensions.ReloadStats{Stopped: 2, Loaded: 3, Ready: 3})54 msg, failed := formatReloadStatus(extensions.ReloadStats{Stopped: 2, Loaded: 3, Ready: 3})
40 if failed {55 if failed {
M packages/agent/modes/session_dialog.go +83 -4
@@ -75,6 +75,11 @@ type sessionDialog struct {
75 cursor int75 cursor int
76 renaming bool76 renaming bool
77 rename string77 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
78 83
79 allScope bool84 allScope bool
80 root string85 root string
@@ -132,6 +137,7 @@ type sessionDialog struct {
132 // sessionDialogAction is returned by HandleKey.137 // sessionDialogAction is returned by HandleKey.
133 type sessionDialogAction struct {138 type sessionDialogAction struct {
134 Select bool139 Select bool
140+ Delete bool
135 Path string141 Path string
136 Close bool142 Close bool
137 Renamed bool143 Renamed bool
@@ -174,6 +180,8 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope
174 d.viewTop = 0180 d.viewTop = 0
175 d.renaming = false181 d.renaming = false
176 d.rename = ""182 d.rename = ""
183+ d.deleting = false
184+ d.removedPaths = nil
177 d.root = root185 d.root = root
178 d.cwd = cwd186 d.cwd = cwd
179 d.allScope = len(allScope) > 0 && allScope[0]187 d.allScope = len(allScope) > 0 && allScope[0]
@@ -377,7 +385,7 @@ func (d *sessionDialog) appendLoadedSessions(start, end int) {
377 return385 return
378 }386 }
379 for _, slot := range d.loadSlots[start:end] {387 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] {
381 continue389 continue
382 }390 }
383 d.baseSessions = append(d.baseSessions, slot.summary)391 d.baseSessions = append(d.baseSessions, slot.summary)
@@ -393,7 +401,7 @@ func (d *sessionDialog) rebuildLoadedSessions(limit int) {
393 }401 }
394 filtered := make([]core.SessionSummary, 0, limit)402 filtered := make([]core.SessionSummary, 0, limit)
395 for _, slot := range d.loadSlots[:limit] {403 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] {
397 continue405 continue
398 }406 }
399 filtered = append(filtered, slot.summary)407 filtered = append(filtered, slot.summary)
@@ -423,7 +431,8 @@ func (d *sessionDialog) CursorPos() (row, col int) {
423 return 3, 4 + len([]rune(d.rename))431 return 3, 4 + len([]rune(d.rename))
424 }432 }
425 433
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.
427 func (d *sessionDialog) Close() {436 func (d *sessionDialog) Close() {
428 if d.loadCancel != nil {437 if d.loadCancel != nil {
429 d.loadCancel()438 d.loadCancel()
@@ -447,6 +456,7 @@ func (d *sessionDialog) Close() {
447 d.searchEvents = nil456 d.searchEvents = nil
448 d.searchSegments = nil457 d.searchSegments = nil
449 d.searchMatches = nil458 d.searchMatches = nil
459+ d.deleting = false
450 d.active = false460 d.active = false
451 }461 }
452 462
@@ -632,6 +642,17 @@ func (d *sessionDialog) applySearchFilter() {
632 }642 }
633 d.sessions = filtered643 d.sessions = filtered
634 }644 }
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+ }
635 if d.cursor >= len(d.sessions) {656 if d.cursor >= len(d.sessions) {
636 d.cursor = max(0, len(d.sessions)-1)657 d.cursor = max(0, len(d.sessions)-1)
637 }658 }
@@ -682,6 +703,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
682 lines = append(lines, frameRule(th, width))703 lines = append(lines, frameRule(th, width))
683 return lines704 return lines
684 }705 }
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+ }
685 if d.renaming {714 if d.renaming {
686 lines = append(lines, th.FGColor(th.Muted, "rename session (enter save, esc cancel):"))715 lines = append(lines, th.FGColor(th.Muted, "rename session (enter save, esc cancel):"))
687 text := d.rename716 text := d.rename
@@ -694,7 +723,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
694 lines = append(lines, frameRule(th, width))723 lines = append(lines, frameRule(th, width))
695 return lines724 return lines
696 }725 }
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"
698 if d.searchRequested {727 if d.searchRequested {
699 hint = "search: " + d.query + " (esc clear · tab scope)"728 hint = "search: " + d.query + " (esc clear · tab scope)"
700 if !sessionSearchQuery(d.query) {729 if !sessionSearchQuery(d.query) {
@@ -979,6 +1008,23 @@ func formatRelative(t time.Time) string {
979 1008
980 // HandleKey advances the dialog and returns an action to apply, if any.1009 // HandleKey advances the dialog and returns an action to apply, if any.
981 func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {1010 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+
982 // Rename mode: type the new name.1028 // Rename mode: type the new name.
983 if d.renaming {1029 if d.renaming {
984 switch k.Kind {1030 switch k.Kind {
@@ -1151,6 +1197,39 @@ navigate:
1151 }1197 }
1152 return sessionDialogAction{}1198 return sessionDialogAction{}
1153 }1199 }
1200+ if k.Rune == 'd' && !d.loading && !d.searchRequested && len(d.sessions) > 0 {
1201+ d.deleting = true
1202+ }
1154 }1203 }
1155 return sessionDialogAction{}1204 return sessionDialogAction{}
1156 }1205 }
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
2 2
3 import (3 import (
4 "context"4 "context"
5+ "os"
6+ "path/filepath"
5 "strings"7 "strings"
6 "sync"8 "sync"
7 "testing"9 "testing"
@@ -612,3 +614,212 @@ func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
612 for range secondEvents {614 for range secondEvents {
613 }615 }
614 }616 }
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 {
78 BaseURL string78 BaseURL string
79 79
80 // Tools is the list of tools to enable. Nil/empty enables the ordinary80 // 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,
82 // update_goal, and update_plan. The public-web capability is excluded unless this list82 // update_goal, and update_plan. The public-web capability is excluded unless this list
83 // explicitly contains "web_search"; that selector enables web_search,83 // explicitly contains "web_search"; that selector enables web_search,
84 // web_open, web_find, and web_click. Pass NoTools=true to disable everything.84 // 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) {
37 }37 }
38 }38 }
39 39
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+
40 func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {112 func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
41 tools := []Tool{113 tools := []Tool{
42 {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},114 {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},
@@ -161,3 +233,35 @@ func TestKimiK3AndGrok45CatalogMetadata(t *testing.T) {
161 t.Fatalf("xai/grok-4.5 metadata = %+v", m)233 t.Fatalf("xai/grok-4.5 metadata = %+v", m)
162 }234 }
163 }235 }
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) {
310 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: system})310 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: system})
311 }311 }
312 312
313- deferredMode := isKimiDeferredModel(req.Model)313+ deferredMode := supportsDeferredTools(c.Name()) && isKimiDeferredModel(req.Model)
314 toolByName := make(map[string]Tool, len(req.Tools))314 toolByName := make(map[string]Tool, len(req.Tools))
315 for _, t := range req.Tools {315 for _, t := range req.Tools {
316 toolByName[t.Name] = t316 toolByName[t.Name] = t
@@ -321,10 +321,13 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
321 // schema (parts arrays containing image_url). Force every user/tool321 // schema (parts arrays containing image_url). Force every user/tool
322 // message to a plain string and silently drop image blocks for322 // message to a plain string and silently drop image blocks for
323 // this provider so historical sessions with screenshots still replay.323 // 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))
325 326
326 req.Messages = RepairOrphanedToolResults(req.Messages)327 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...)
328 switch msg.Role {331 switch msg.Role {
329 case RoleDeveloper:332 case RoleDeveloper:
330 content := buildOAIUserContent(msg.Content, textOnly)333 content := buildOAIUserContent(msg.Content, textOnly)
@@ -388,23 +391,53 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
388 }391 }
389 out.Messages = append(out.Messages, am)392 out.Messages = append(out.Messages, am)
390 case RoleTool:393 case RoleTool:
391- // Each ToolResultBlock becomes its own tool message. Preserve394+ // Chat Completions tool messages only carry text. Keep every
392- // image blocks for vision-capable OpenAI models instead of395+ // tool result paired with its call, then deliver any images in
393- // flattening the tool output to plain text.396+ // one user message after the complete batch of tool results.
394- for _, b := range msg.Content {397+ // Strict OpenAI-compatible APIs reject image_url blocks on a
395- if tr, ok := b.(ToolResultBlock); ok {398+ // role=tool message.
396- content := buildOAIToolContent(tr.Content, tr.IsError, textOnly)399+ var images []Content
397- out.Messages = append(out.Messages, oaiMessage{400+ groupEnd := msgIndex
398- Role: "tool",401+ for groupEnd < len(req.Messages) && req.Messages[groupEnd].Role == RoleTool {
399- ToolCallID: tr.CallID,402+ toolMessage := req.Messages[groupEnd]
400- Content: content,403+ if groupEnd > msgIndex {
401- })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+ }
402 }422 }
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+ })
403 }435 }
436+ msgIndex = groupEnd - 1
404 }437 }
405 if deferredMode {438 if deferredMode {
406 var loaded []oaiTool439 var loaded []oaiTool
407- for _, name := range msg.AddedToolNames {440+ for _, name := range addedToolNames {
408 if t, ok := toolByName[name]; ok && t.Deferred {441 if t, ok := toolByName[name]; ok && t.Deferred {
409 loaded = append(loaded, makeOAITool(t))442 loaded = append(loaded, makeOAITool(t))
410 }443 }
@@ -433,6 +466,17 @@ func isKimiDeferredModel(model string) bool {
433 return model == "kimi-k3" || strings.HasSuffix(model, "/kimi-k3")466 return model == "kimi-k3" || strings.HasSuffix(model, "/kimi-k3")
434 }467 }
435 468
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+
436 func isDeepSeekModel(model string) bool {480 func isDeepSeekModel(model string) bool {
437 model = strings.ToLower(strings.TrimSpace(model))481 model = strings.ToLower(strings.TrimSpace(model))
438 return strings.HasPrefix(model, "deepseek-") || strings.Contains(model, "/deepseek-")482 return strings.HasPrefix(model, "deepseek-") || strings.Contains(model, "/deepseek-")
@@ -477,30 +521,47 @@ func buildOAIUserContent(blocks []Content, textOnly bool) interface{} {
477 return buildOAIContentBlocks(blocks, false)521 return buildOAIContentBlocks(blocks, false)
478 }522 }
479 523
480-func buildOAIToolContent(blocks []Content, isError, textOnly bool) interface{} {524+func buildOAIToolContent(blocks []Content, isError, textOnly bool) string {
525+ var sb strings.Builder
481 hasImage := false526 hasImage := false
482 for _, b := range blocks {527 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:
484 hasImage = true535 hasImage = true
485- break
486 }536 }
487 }537 }
488- if textOnly || !hasImage {538+ if sb.Len() == 0 && hasImage && !textOnly {
489- var sb strings.Builder539+ sb.WriteString("(see attached image)")
490- for _, b := range blocks {540+ }
491- if tb, ok := b.(TextBlock); ok {541+ if isError && sb.Len() > 0 {
492- if sb.Len() > 0 {542+ sb.WriteString(" [error]")
493- sb.WriteString("\n")543+ }
494- }544+ return sb.String()
495- sb.WriteString(tb.Text)545+}
496- }546+
497- }547+// isOpenAIToolImageMirror reports whether msg is the synthetic user message
498- if isError && sb.Len() > 0 {548+// the agent loop persists after a tool result carrying images (see
499- sb.WriteString(" [error]")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
500 }562 }
501- return sb.String()
502 }563 }
503- return buildOAIContentBlocks(blocks, isError)564+ return false
504 }565 }
505 566
506 func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} {567 func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} {
M packages/provider/openai_codex.go +74 -14
@@ -565,6 +565,28 @@ func estimatedOpenAIPromptTokens(wire *codexRequest) int {
565 return len(encoded) / 4565 return len(encoded) / 4
566 }566 }
567 567
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+
568 func usesCodexCLIRouting(model string) bool {590 func usesCodexCLIRouting(model string) bool {
569 switch model {591 switch model {
570 case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro":592 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
596 return nil, err618 return nil, err
597 }619 }
598 620
599- newReq := func() (*http.Request, error) {621+ newReq := func(useCodexIdentity bool) func() (*http.Request, error) {
600- httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))622+ return func() (*http.Request, error) {
601- if err != nil {623+ httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
602- return nil, err624+ 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
603 }643 }
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
608 }644 }
609 645
610- resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)646+ resp, err := doStreamWithRetry(ctx, c.http, newReq(useCodexCLIRouting), req.Lifecycle)
611 if err != nil {647 if err != nil {
612 label := c.providerName648 label := c.providerName
613 if label == "" {649 if label == "" {
@@ -616,13 +652,37 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
616 return nil, fmt.Errorf("%s: %w", label, err)652 return nil, fmt.Errorf("%s: %w", label, err)
617 }653 }
618 if resp.StatusCode != http.StatusOK {654 if resp.StatusCode != http.StatusOK {
619- b, _ := io.ReadAll(resp.Body)655+ status := resp.StatusCode
656+ responseBody, _ := io.ReadAll(resp.Body)
620 resp.Body.Close()657 resp.Body.Close()
621- label := c.providerName658+ if !useCodexCLIRouting || !unsupportedPromptCacheRetention(status, responseBody) {
622- if label == "" {659+ label := c.providerName
623- label = ProviderOpenAICodex660+ 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)))
624 }685 }
625- return nil, fmt.Errorf("%s: http %d: %s", label, resp.StatusCode, strings.TrimSpace(string(b)))
626 }686 }
627 687
628 out := make(chan Event, 16)688 out := make(chan Event, 16)
M packages/provider/openai_codex_test.go +154 -0
@@ -393,3 +393,157 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
393 t.Fatalf("request missing prompt_cache_key: %s", body.String())393 t.Fatalf("request missing prompt_cache_key: %s", body.String())
394 }394 }
395 }395 }
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 {
131 131
132 func (ReasoningBlock) isContent() {}132 func (ReasoningBlock) isContent() {}
133 133
134-// RepairOrphanedToolResults removes tool_result content blocks (and134+// RepairOrphanedToolResults removes invalid tool_result content blocks
135-// entire messages that become empty) when the matching tool_use ID135+// and entire messages that become empty. A result is invalid when its
136-// does not appear anywhere in the given messages. Resume tails,136+// matching tool_use ID does not appear anywhere in the messages or when
137-// compaction repair, and provider request builders all need this so137+// an earlier result already covers the same ID. Resume tails, compaction
138-// the upstream API never sees a tool_call_id with no corresponding138+// repair, and provider request builders all need this so the upstream API
139-// assistant tool_call earlier in the same request.139+// sees exactly one result for each referenced tool call.
140 func RepairOrphanedToolResults(msgs []Message) []Message {140 func RepairOrphanedToolResults(msgs []Message) []Message {
141 useIDs := map[string]bool{}141 useIDs := map[string]bool{}
142 for _, m := range msgs {142 for _, m := range msgs {
@@ -146,14 +146,16 @@ func RepairOrphanedToolResults(msgs []Message) []Message {
146 }146 }
147 }147 }
148 }148 }
149+ resultIDs := map[string]bool{}
149 out := make([]Message, 0, len(msgs))150 out := make([]Message, 0, len(msgs))
150 for _, m := range msgs {151 for _, m := range msgs {
151 var filtered []Content152 var filtered []Content
152 for _, c := range m.Content {153 for _, c := range m.Content {
153 if tr, ok := c.(ToolResultBlock); ok {154 if tr, ok := c.(ToolResultBlock); ok {
154- if !useIDs[tr.CallID] {155+ if !useIDs[tr.CallID] || resultIDs[tr.CallID] {
155 continue156 continue
156 }157 }
158+ resultIDs[tr.CallID] = true
157 }159 }
158 filtered = append(filtered, c)160 filtered = append(filtered, c)
159 }161 }
M packages/provider/provider_test.go +39 -0
@@ -472,6 +472,45 @@ func TestAnthropicBuildRequestStripsAssistantImages(t *testing.T) {
472 }472 }
473 }473 }
474 474
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+
475 func TestAnthropicStreamHappyPath(t *testing.T) {514 func TestAnthropicStreamHappyPath(t *testing.T) {
476 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {515 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
477 w.Header().Set("content-type", "text/event-stream")516 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
763 // real previous bg-colored row.763 // real previous bg-colored row.
764 764
765 wasInitialized := r.logInit765 wasInitialized := r.logInit
766- full := !wasInitialized || len(r.logLines) == 0766+ 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
767 if full {783 if full {
768 // A cache invalidation may require a full viewport repaint after flow784 // A cache invalidation may require a full viewport repaint after flow
769 // has started, but only the initial paint may discard old scrollback.785 // 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
989 w.WriteString(lines[idx])1005 w.WriteString(lines[idx])
990 }1006 }
991 finalRow := renderEnd1007 finalRow := renderEnd
1008+ fullRepaint := false
992 if len(r.logLines) > len(lines) {1009 if len(r.logLines) > len(lines) {
993 extra := len(r.logLines) - len(lines)1010 extra := len(r.logLines) - len(lines)
994- if extra > r.rows {1011+ if extra >= r.rows {
995 writeFull(true, false)1012 writeFull(true, false)
1013+ fullRepaint = true
996 } else {1014 } else {
997 for e := 0; e < extra; e++ {1015 for e := 0; e < extra; e++ {
998 w.WriteString("\x1b[1B")1016 w.WriteString("\x1b[1B")
@@ -1007,13 +1025,18 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int
1007 }1025 }
1008 }1026 }
1009 }1027 }
1010- r.logHardwareRow = finalRow1028+ // When writeFull ran above it already recomputed logHardwareRow
1011- r.logViewportTop = viewportTop1029+ // and logViewportTop; overwriting them with stale incremental
1012- if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {1030+ // values here would desync the next relative move.
1013- r.logViewportTop = minTop1031+ if !fullRepaint {
1014- }1032+ r.logHardwareRow = finalRow
1015- if r.logViewportTop < 0 {1033+ r.logViewportTop = viewportTop
1016- r.logViewportTop = 01034+ 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+ }
1017 }1040 }
1018 }1041 }
1019 }1042 }
M packages/tui/render_test.go +127 -0
@@ -292,3 +292,130 @@ func TestDrawLogInvalidationPreservesScrollbackSelection(t *testing.T) {
292 t.Fatalf("invalidation repaint erased scrollback and native selection: %q", got)292 t.Fatalf("invalidation repaint erased scrollback and native selection: %q", got)
293 }293 }
294 }294 }
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 {
465 if wrapWidth < 8 {465 if wrapWidth < 8 {
466 wrapWidth = 8466 wrapWidth = 8
467 }467 }
468- wrapped := wrapLine(v.Err, wrapWidth, "")468+ var out []string
469- if len(wrapped) == 0 {469+ // Each returned row must represent one terminal line, including when
470- wrapped = []string{""}470+ // the error contains explicit newlines or blank separator lines.
471- }471+ for _, paragraph := range strings.Split(v.Err, "\n") {
472- out := make([]string, 0, len(wrapped))472+ for _, line := range wrapLine(paragraph, wrapWidth, "") {
473- for idx, line := range wrapped {473+ prefix := indent
474- prefix := marker474+ if len(out) == 0 {
475- if idx > 0 {475+ prefix = marker
476- prefix = indent476+ }
477+ out = append(out, v.Theme.FGColor(v.Theme.Error, prefix+line))
477 }478 }
478- out = append(out, v.Theme.FGColor(v.Theme.Error, prefix+line))
479 }479 }
480 return out480 return out
481 }481 }
@@ -2859,9 +2859,9 @@ func toolArgWidth() int {
2859 // suffix for the "tool name <args>" header. tool is the tool2859 // suffix for the "tool name <args>" header. tool is the tool
2860 // name so we can add shape-specific decorations: for read we2860 // name so we can add shape-specific decorations: for read we
2861 // append the requested line range (e.g. "path:1-200") pulled2861 // append the requested line range (e.g. "path:1-200") pulled
2862-// from the offset/limit args, which is useful context at a2862+// from the offset/limit args, and for glob we prefer the pattern
2863-// glance without expanding the result body. Other tools keep2863+// with an " in <dir>" suffix. Other tools keep the legacy
2864-// the legacy "path or command, truncated" shape.2864+// "path, file_path, or command, truncated" shape.
2865 //2865 //
2866 // The truncation width defaults to 60 cells but can be tuned via2866 // The truncation width defaults to 60 cells but can be tuned via
2867 // the ZUT_TOOL_ARG_WIDTH environment variable (see toolArgWidth).2867 // the ZUT_TOOL_ARG_WIDTH environment variable (see toolArgWidth).
@@ -2886,7 +2886,14 @@ func ShortArgs(tool string, raw json.RawMessage) string {
2886 return s2886 return s
2887 }2887 }
2888 var primary string2888 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 {
2890 if s, ok := x[k].(string); ok {2897 if s, ok := x[k].(string); ok {
2891 primary = s2898 primary = s
2892 break2899 break
@@ -2902,8 +2909,7 @@ func ShortArgs(tool string, raw json.RawMessage) string {
2902 }2909 }
2903 primary = oneLineToolLabel(primary)2910 primary = oneLineToolLabel(primary)
2904 2911
2905- // Tool-specific decoration. Only the read tool gets a range2912+ // Tool-specific decoration.
2906- // suffix for now; other tools just truncate the primary arg.
2907 suffix := ""2913 suffix := ""
2908 switch strings.ToLower(tool) {2914 switch strings.ToLower(tool) {
2909 case "read":2915 case "read":
@@ -2917,6 +2923,10 @@ func ShortArgs(tool string, raw json.RawMessage) string {
2917 } else if start > 1 {2923 } else if start > 1 {
2918 suffix = fmt.Sprintf(":%d-", start)2924 suffix = fmt.Sprintf(":%d-", start)
2919 }2925 }
2926+ case "glob":
2927+ if dir, ok := x["path"].(string); ok && dir != "" && dir != "." {
2928+ suffix = " in " + dir
2929+ }
2920 }2930 }
2921 2931
2922 // Truncate the primary arg leaving room for the suffix so the2932 // 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) {
61 t.Fatalf("ShortArgs = %q, want %q", got, "short query")61 t.Fatalf("ShortArgs = %q, want %q", got, "short query")
62 }62 }
63 }63 }
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 477

↑↓ moveenter openesc close