detached 66d3695

merge(main): integrate latest main

commit
66d3695d381c7b02f0f21f5ad4714123b54c1055
author
brice · · 2026-08-06 20:04 UTC
merge of
f886a9c + 069bdb5 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
37 files · +2560 -178
M .github/workflows/ci.yml +4 -12
@@ -47,22 +47,14 @@ jobs:
4747 - name: go test
4848 run: go test -race ./...
4949
50- lint:
51- name: lint
52- runs-on: ubuntu-latest
53- steps:
54- - uses: actions/checkout@v4
55-
50+ # Keep Linux-only quality checks in the Ubuntu test job so CI needs
51+ # only the three platform runners.
5652 - name: release-version policy
53+ if: matrix.os == 'ubuntu-latest'
5754 run: bash .github/scripts/next-release-tag_test.sh
5855
59- - name: set up go
60- uses: actions/setup-go@v7
61- with:
62- go-version: "1.26"
63- cache: true
64-
6556 - name: golangci-lint
57+ if: matrix.os == 'ubuntu-latest'
6658 uses: golangci/golangci-lint-action@v9.3.0
6759 with:
6860 version: v2.12.2
@@ -47,22 +47,14 @@ jobs:
47 - name: go test47 - name: go test
48 run: go test -race ./...48 run: go test -race ./...
49 49
50- lint:50+ # Keep Linux-only quality checks in the Ubuntu test job so CI needs
51- name: lint51+ # only the three platform runners.
52- runs-on: ubuntu-latest
53- steps:
54- - uses: actions/checkout@v4
55-
56 - name: release-version policy52 - name: release-version policy
53+ if: matrix.os == 'ubuntu-latest'
57 run: bash .github/scripts/next-release-tag_test.sh54 run: bash .github/scripts/next-release-tag_test.sh
58 55
59- - name: set up go
60- uses: actions/setup-go@v7
61- with:
62- go-version: "1.26"
63- cache: true
64-
65 - name: golangci-lint56 - name: golangci-lint
57+ if: matrix.os == 'ubuntu-latest'
66 uses: golangci/golangci-lint-action@v9.3.058 uses: golangci/golangci-lint-action@v9.3.0
67 with:59 with:
68 version: v2.12.260 version: v2.12.2
M .gitignore +1 -0
@@ -76,3 +76,4 @@ tmp/
7676 .codereview/
7777 docs/superpowers/
7878 npm/
79+/.worktrees/
@@ -76,3 +76,4 @@ tmp/
76 .codereview/76 .codereview/
77 docs/superpowers/77 docs/superpowers/
78 npm/78 npm/
79+/.worktrees/
M README.md +13 -11
@@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
99
1010 - one static binary.
1111 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.
12-- six core built-in tools (read, write, edit, bash, lsp, and web_search); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries.
12+- seven core built-in tools (read, write, edit, bash, create_worktree, lsp, and web_search); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries.
1313 - three run modes (interactive tui, print, json).
1414 - built-in telegram bot.
1515 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).
@@ -233,10 +233,11 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
233233 - `write`: create or overwrite files, making parent directories as needed.
234234 - `edit`: one or more exact-match replacements in an existing file.
235235 - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. 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.
236+- `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.
236237 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).
237238 - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
238239
239-When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd. Jail does not sandbox web-search network egress.
240+When the sandbox is on (see `/jail`), filesystem tools 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. Jail does not sandbox web-search network egress.
240241
241242 ## Modes
242243
@@ -279,6 +280,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
279280 | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zut again. |
280281 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |
281282 | `/reasoning` | Set the reasoning level for subsequent model calls. |
283+| `/fast` | Toggle fast mode for subsequent model calls. |
282284 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |
283285 | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. |
284286 | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. |
@@ -293,7 +295,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
293295 | `/unjail` | Allow tools to touch paths outside again. |
294296 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
295297 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
296-| `/settings` | Change persistent settings, including web search, inline images, terminal alerts, AI terminal titles, auto-subagents, Ponytail coding mode, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZUT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. |
298+| `/settings` | Change persistent settings, including web search, inline images, terminal alerts, AI terminal titles, auto-subagents, Ponytail coding mode, fast mode, main/sub-agent LSP access, and the auto-compact threshold. `/fast` is a shortcut for the fast-mode toggle. Saved to `$ZUT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. |
297299 | `/clear` | Clear the chat transcript. |
298300 | `/exit` | Exit zut. |
299301
@@ -380,7 +382,7 @@ Background subagents that run alongside your main session. Each one is a separat
380382
381383 **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back.
382384
383-**Persistence across zut restarts** — every spawn writes a durable manifest, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch they show up as **detached**; use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.
385+**Persistence across zut restarts** — every spawn writes a durable manifest, lifecycle summary, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch the compact summary restores each agent as **detached** without replaying its full history. Open its transcript to hydrate the durable event log, then use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.
384386
385387 **Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.
386388
@@ -402,10 +404,10 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
402404 - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zut: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.
403405 - **auto-subagents** — let the main agent spawn background sub-agents in parallel via `subagent_spawn` and query live state via `subagent_status`. Off by default. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion updates still arrive through the auto-subagents watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.
404406 - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZUT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zutfile agents.
405-- **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account.
407+- **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.
406408 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
407409 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.
408-- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. After a successful threshold compaction, zut automatically resumes an unfinished most-recent intent; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
410+- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. After a successful threshold compaction, zut automatically resumes an unfinished most-recent intent, including truncated output; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
409411 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
410412 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
411413 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
@@ -422,11 +424,11 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
422424
423425 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%.
424426
425-zut also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own and, once it succeeds, resumes an unfinished most-recent intent automatically. Completed text answers settle normally, while a prompt queued during compaction runs instead of the automatic continuation. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.
427+zut also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own and, once it succeeds, resumes an unfinished most-recent intent automatically, including output that stopped at the model's token limit. Completed text answers settle normally, while a prompt queued during compaction runs instead of the automatic continuation. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.
426428
427429 ### `/jail`
428430
429-Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session.
431+Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `create_worktree` also requires its repository root, Git metadata, optional `.gitignore`, and configured worktree destination to be inside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session.
430432
431433 This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox.
432434
@@ -794,7 +796,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st
794796
795797 ## Tool rendering
796798
797-By default each tool call (bash, read, write, edit) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame.
799+By default each tool call (bash, read, write, edit, create_worktree, lsp) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame.
798800
799801 Set the `tool_render` key in `$ZUT_HOME/config.json`:
800802
@@ -883,7 +885,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
883885 | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. |
884886 | `ctrl+l` | Redraw the screen. |
885887 | `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. |
886-| `ctrl+o` | Expand or collapse long tool results (outputs over ~12 lines, including `web_search`). |
888+| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, lsp, and web_search outputs over ~12 lines). |
887889 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |
888890 | `@` | Open the file picker. Browse files and directories in the working directory. |
889891
@@ -1066,7 +1068,7 @@ packages/agent/extproto/ extension wire-format types
10661068 packages/agent/modes/ interactive tui, print, json, dialogs
10671069 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
10681070 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1069-packages/agent/tools/ read, write, edit, bash, lsp, web search, sandbox
1071+packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, sandbox
10701072 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
10711073 packages/agent/subagents/ named profiles, supervisor, and background runtime
10721074 packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk)
@@ -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-- six core built-in tools (read, write, edit, bash, lsp, and web_search); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries.12+- seven core built-in tools (read, write, edit, bash, create_worktree, lsp, and web_search); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search 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).
@@ -233,10 +233,11 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
233 - `write`: create or overwrite files, making parent directories as needed.233 - `write`: create or overwrite files, making parent directories as needed.
234 - `edit`: one or more exact-match replacements in an existing file.234 - `edit`: one or more exact-match replacements in an existing file.
235 - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. 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.235 - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. 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.
236+- `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.
236 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).237 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).
237 - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).238 - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
238 239
239-When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd. Jail does not sandbox web-search network egress.240+When the sandbox is on (see `/jail`), filesystem tools 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. Jail does not sandbox web-search network egress.
240 241
241 ## Modes242 ## Modes
242 243
@@ -279,6 +280,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
279 | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zut again. |280 | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zut again. |
280 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |281 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |
281 | `/reasoning` | Set the reasoning level for subsequent model calls. |282 | `/reasoning` | Set the reasoning level for subsequent model calls. |
283+| `/fast` | Toggle fast mode for subsequent model calls. |
282 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |284 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |
283 | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. |285 | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. |
284 | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. |286 | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. |
@@ -293,7 +295,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
293 | `/unjail` | Allow tools to touch paths outside again. |295 | `/unjail` | Allow tools to touch paths outside again. |
294 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |296 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
295 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |297 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
296-| `/settings` | Change persistent settings, including web search, inline images, terminal alerts, AI terminal titles, auto-subagents, Ponytail coding mode, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZUT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. |298+| `/settings` | Change persistent settings, including web search, inline images, terminal alerts, AI terminal titles, auto-subagents, Ponytail coding mode, fast mode, main/sub-agent LSP access, and the auto-compact threshold. `/fast` is a shortcut for the fast-mode toggle. Saved to `$ZUT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. |
297 | `/clear` | Clear the chat transcript. |299 | `/clear` | Clear the chat transcript. |
298 | `/exit` | Exit zut. |300 | `/exit` | Exit zut. |
299 301
@@ -380,7 +382,7 @@ Background subagents that run alongside your main session. Each one is a separat
380 382
381 **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back.383 **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back.
382 384
383-**Persistence across zut restarts** — every spawn writes a durable manifest, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch they show up as **detached**; use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.385+**Persistence across zut restarts** — every spawn writes a durable manifest, lifecycle summary, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch the compact summary restores each agent as **detached** without replaying its full history. Open its transcript to hydrate the durable event log, then use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.
384 386
385 **Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.387 **Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.
386 388
@@ -402,10 +404,10 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
402 - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zut: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.404 - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zut: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.
403 - **auto-subagents** — let the main agent spawn background sub-agents in parallel via `subagent_spawn` and query live state via `subagent_status`. Off by default. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion updates still arrive through the auto-subagents watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.405 - **auto-subagents** — let the main agent spawn background sub-agents in parallel via `subagent_spawn` and query live state via `subagent_status`. Off by default. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion updates still arrive through the auto-subagents watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.
404 - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZUT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zutfile agents.406 - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZUT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zutfile agents.
405-- **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account.407+- **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.
406 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.408 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
407 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.409 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.
408-- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. After a successful threshold compaction, zut automatically resumes an unfinished most-recent intent; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.410+- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. After a successful threshold compaction, zut automatically resumes an unfinished most-recent intent, including truncated output; a prompt queued during compaction takes priority. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses.
409 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.411 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
410 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.412 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
411 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.413 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
@@ -422,11 +424,11 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
422 424
423 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%.425 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%.
424 426
425-zut also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own and, once it succeeds, resumes an unfinished most-recent intent automatically. Completed text answers settle normally, while a prompt queued during compaction runs instead of the automatic continuation. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.427+zut also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own and, once it succeeds, resumes an unfinished most-recent intent automatically, including output that stopped at the model's token limit. Completed text answers settle normally, while a prompt queued during compaction runs instead of the automatic continuation. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.
426 428
427 ### `/jail`429 ### `/jail`
428 430
429-Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session.431+Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `create_worktree` also requires its repository root, Git metadata, optional `.gitignore`, and configured worktree destination to be inside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session.
430 432
431 This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox.433 This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox.
432 434
@@ -794,7 +796,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st
794 796
795 ## Tool rendering797 ## Tool rendering
796 798
797-By default each tool call (bash, read, write, edit) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame.799+By default each tool call (bash, read, write, edit, create_worktree, lsp) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame.
798 800
799 Set the `tool_render` key in `$ZUT_HOME/config.json`:801 Set the `tool_render` key in `$ZUT_HOME/config.json`:
800 802
@@ -883,7 +885,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
883 | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. |885 | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. |
884 | `ctrl+l` | Redraw the screen. |886 | `ctrl+l` | Redraw the screen. |
885 | `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. |887 | `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. |
886-| `ctrl+o` | Expand or collapse long tool results (outputs over ~12 lines, including `web_search`). |888+| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, lsp, and web_search outputs over ~12 lines). |
887 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |889 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |
888 | `@` | Open the file picker. Browse files and directories in the working directory. |890 | `@` | Open the file picker. Browse files and directories in the working directory. |
889 891
@@ -1066,7 +1068,7 @@ packages/agent/extproto/ extension wire-format types
1066 packages/agent/modes/ interactive tui, print, json, dialogs1068 packages/agent/modes/ interactive tui, print, json, dialogs
1067 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)1069 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
1068 packages/agent/modes/telegram/ telegram adapter, api client, daemon1070 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1069-packages/agent/tools/ read, write, edit, bash, lsp, web search, sandbox1071+packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, sandbox
1070 packages/agent/skills/ skill discovery, frontmatter parser, skill tool1072 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
1071 packages/agent/subagents/ named profiles, supervisor, and background runtime1073 packages/agent/subagents/ named profiles, supervisor, and background runtime
1072 packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk)1074 packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk)
M docs/extensions.md +4 -3
@@ -225,9 +225,10 @@ describing the tool's args (the same shape Anthropic and OpenAI accept).
225225 ```
226226
227227 Tool names live in the same namespace as built-in tools (`read`,
228-`write`, `edit`, `bash`, `lsp`, `web_search`, `skill`). Conflicts with active
229-built-ins are ignored. The native `web_search` name is reserved even when the
230-setting disables that built-in, so an extension cannot claim or replace it.
228+`write`, `edit`, `bash`, `create_worktree`, `lsp`, `web_search`, `skill`).
229+Conflicts with active built-ins are silently shadowed by the built-in. The
230+native `web_search` name is reserved even when the setting disables that
231+built-in, so an extension cannot claim or replace it.
231232
232233 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:
233234
@@ -225,9 +225,10 @@ describing the tool's args (the same shape Anthropic and OpenAI accept).
225 ```225 ```
226 226
227 Tool names live in the same namespace as built-in tools (`read`,227 Tool names live in the same namespace as built-in tools (`read`,
228-`write`, `edit`, `bash`, `lsp`, `web_search`, `skill`). Conflicts with active228+`write`, `edit`, `bash`, `create_worktree`, `lsp`, `web_search`, `skill`).
229-built-ins are ignored. The native `web_search` name is reserved even when the229+Conflicts with active built-ins are silently shadowed by the built-in. The
230-setting disables that built-in, so an extension cannot claim or replace it.230+native `web_search` name is reserved even when the setting disables that
231+built-in, so an extension cannot claim or replace it.
231 232
232 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:233 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:
233 234
M docs/providers.md +12 -10
@@ -115,16 +115,6 @@ show instructions and should be configured with environment variables.
115115 | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` |
116116 | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |
117117
118-### OpenAI Fast mode
119-
120-OpenAI, OpenAI Responses, and OpenAI Codex requests can opt into OpenAI's
121-Fast service tier from `/settings` by enabling **fast mode**. The setting is
122-off by default and is stored as `fast_mode` in `$ZUT_HOME/config.json`.
123-
124-Fast mode is currently limited to those OpenAI provider IDs. Enabling it while
125-using another provider returns an error instead of silently changing that
126-provider's request. Subagent children inherit the setting from their parent.
127-
128118 Example:
129119
130120 ```bash
@@ -132,6 +122,18 @@ export OPENROUTER_API_KEY=...
132122 zut --provider openrouter
133123 ```
134124
125+## Fast mode
126+
127+Use `/fast` or `/settings` to enable **fast mode** for subsequent model calls.
128+The setting is off by default and is stored as `fast_mode` in
129+`$ZUT_HOME/config.json`.
130+
131+Fast mode currently requests OpenAI's Fast service tier for OpenAI, OpenAI
132+Responses, and OpenAI Codex requests. Enabling it while using another provider
133+currently returns an error instead of silently changing that provider's request.
134+Subagent children inherit the setting from their parent unless their profile
135+sets `fastMode` explicitly.
136+
135137 ## Local llama.cpp router
136138
137139 The `llama.cpp` provider connects to a multi-model router and is separate from the `ollama` provider. Ollama normally uses `http://localhost:11434`; entering that URL for llama.cpp management produces a 404 because Ollama does not implement the router endpoints.
@@ -115,16 +115,6 @@ show instructions and should be configured with environment variables.
115 | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` |115 | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` |
116 | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |116 | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |
117 117
118-### OpenAI Fast mode
119-
120-OpenAI, OpenAI Responses, and OpenAI Codex requests can opt into OpenAI's
121-Fast service tier from `/settings` by enabling **fast mode**. The setting is
122-off by default and is stored as `fast_mode` in `$ZUT_HOME/config.json`.
123-
124-Fast mode is currently limited to those OpenAI provider IDs. Enabling it while
125-using another provider returns an error instead of silently changing that
126-provider's request. Subagent children inherit the setting from their parent.
127-
128 Example:118 Example:
129 119
130 ```bash120 ```bash
@@ -132,6 +122,18 @@ export OPENROUTER_API_KEY=...
132 zut --provider openrouter122 zut --provider openrouter
133 ```123 ```
134 124
125+## Fast mode
126+
127+Use `/fast` or `/settings` to enable **fast mode** for subsequent model calls.
128+The setting is off by default and is stored as `fast_mode` in
129+`$ZUT_HOME/config.json`.
130+
131+Fast mode currently requests OpenAI's Fast service tier for OpenAI, OpenAI
132+Responses, and OpenAI Codex requests. Enabling it while using another provider
133+currently returns an error instead of silently changing that provider's request.
134+Subagent children inherit the setting from their parent unless their profile
135+sets `fastMode` explicitly.
136+
135 ## Local llama.cpp router137 ## Local llama.cpp router
136 138
137 The `llama.cpp` provider connects to a multi-model router and is separate from the `ollama` provider. Ollama normally uses `http://localhost:11434`; entering that URL for llama.cpp management produces a 404 because Ollama does not implement the router endpoints.139 The `llama.cpp` provider connects to a multi-model router and is separate from the `ollama` provider. Ollama normally uses `http://localhost:11434`; entering that URL for llama.cpp management produces a 404 because Ollama does not implement the router endpoints.
M docs/subagents.md +3 -3
@@ -41,14 +41,14 @@ Supported metadata:
4141 |---|---|
4242 | `name` | Name passed to `subagent_spawn`'s `agent` field. Falls back to the filename. |
4343 | `description` | Short description shown to the main agent. |
44-| `tools` | Comma-separated or list-form tool names. zut enforces its built-in `read`, `write`, `edit`, `bash`, `lsp`, and `web_search` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. |
44+| `tools` | Comma-separated or list-form tool names. zut enforces its built-in `read`, `write`, `edit`, `bash`, `create_worktree`, `lsp`, and `web_search` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. |
4545 | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. |
4646 | `provider` | Optional separate provider ID for a model without a provider prefix. |
4747 | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. |
4848 | `systemPromptMode` | `append` (default) or `replace`. |
4949 | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. |
5050 | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. |
51-| `fastMode` | Optional fast-mode restriction. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` only permits fast mode when the host setting is enabled. |
51+| `fastMode` | Optional fast-mode override. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` enables it for this profile even when the host setting is off. |
5252
5353 Other frontmatter from another agent host is ignored when zut does not have an equivalent setting. Recursive child spawning is disabled in v1; a worker cannot invoke `subagent_spawn` to create descendants.
5454
@@ -98,7 +98,7 @@ The interactive command also supports the same selection explicitly:
9898 /subagents new --agent implementer --reasoning high Implement the parser change
9999 ```
100100
101-Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; global append addenda, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting.
101+Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; globally appended instructions, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` enables fast mode even when the host setting is off; the `subagent_spawn` result warns the parent session when this override occurs. Child providers that do not yet support fast mode return an unsupported-provider error instead of silently ignoring an enabled setting.
102102
103103 ## Lifecycle, results, and recovery
104104
@@ -41,14 +41,14 @@ Supported metadata:
41 |---|---|41 |---|---|
42 | `name` | Name passed to `subagent_spawn`'s `agent` field. Falls back to the filename. |42 | `name` | Name passed to `subagent_spawn`'s `agent` field. Falls back to the filename. |
43 | `description` | Short description shown to the main agent. |43 | `description` | Short description shown to the main agent. |
44-| `tools` | Comma-separated or list-form tool names. zut enforces its built-in `read`, `write`, `edit`, `bash`, `lsp`, and `web_search` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. |44+| `tools` | Comma-separated or list-form tool names. zut enforces its built-in `read`, `write`, `edit`, `bash`, `create_worktree`, `lsp`, and `web_search` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. |
45 | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. |45 | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. |
46 | `provider` | Optional separate provider ID for a model without a provider prefix. |46 | `provider` | Optional separate provider ID for a model without a provider prefix. |
47 | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. |47 | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. |
48 | `systemPromptMode` | `append` (default) or `replace`. |48 | `systemPromptMode` | `append` (default) or `replace`. |
49 | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. |49 | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. |
50 | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. |50 | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. |
51-| `fastMode` | Optional fast-mode restriction. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` only permits fast mode when the host setting is enabled. |51+| `fastMode` | Optional fast-mode override. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` enables it for this profile even when the host setting is off. |
52 52
53 Other frontmatter from another agent host is ignored when zut does not have an equivalent setting. Recursive child spawning is disabled in v1; a worker cannot invoke `subagent_spawn` to create descendants.53 Other frontmatter from another agent host is ignored when zut does not have an equivalent setting. Recursive child spawning is disabled in v1; a worker cannot invoke `subagent_spawn` to create descendants.
54 54
@@ -98,7 +98,7 @@ The interactive command also supports the same selection explicitly:
98 /subagents new --agent implementer --reasoning high Implement the parser change98 /subagents new --agent implementer --reasoning high Implement the parser change
99 ```99 ```
100 100
101-Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; global append addenda, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting.101+Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zut creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; globally appended instructions, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` enables fast mode even when the host setting is off; the `subagent_spawn` result warns the parent session when this override occurs. Child providers that do not yet support fast mode return an unsupported-provider error instead of silently ignoring an enabled setting.
102 102
103 ## Lifecycle, results, and recovery103 ## Lifecycle, results, and recovery
104 104
M docs/zutfiles.md +19 -1
@@ -303,7 +303,7 @@ In `--print` / `--stream` / `--json` modes, `pre` still runs first: shell escape
303303
304304 ## Filesystem permissions
305305
306-Filesystem permissions are enforced by the built-in `read`, `write`, and `edit` tools. Empty or omitted scopes deny the operation.
306+Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, and `create_worktree` tools. Empty or omitted scopes deny the operation.
307307
308308 Two variables are available in filesystem scopes:
309309
@@ -373,6 +373,24 @@ Data-only agent:
373373
374374 Filesystem checks canonicalize existing paths, or the nearest existing parent for a new path, so symlinks cannot be used to escape a declared scope.
375375
376+`create_worktree` bootstraps a repository without an existing worktree root in two calls. Its first branch-only call returns no-write guidance for the agent to ask the user whether to use `.worktrees` or an external directory. The reply is sent as `bootstrap_root`: `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the repository root's `.gitignore`; an absolute external path creates `<external-root>/<branch>` without changing repository files. The selected root is stored privately as `zut.worktrees.path` in local Git config, so later calls only need `branch`.
377+
378+The tool needs read and write scopes covering the repository root, Git metadata, and the selected worktree root. It also requires Bash permission for `git`, even though it invokes Git directly without a shell:
379+
380+```json
381+{
382+ "permissions": {
383+ "fs": {
384+ "read": ["${workspace}"],
385+ "write": ["${workspace}"]
386+ },
387+ "bash": { "mode": "allowlist", "allow": ["git"] }
388+ }
389+}
390+```
391+
392+The tool disables Git hooks, but a repository checkout can still use configured Git filters. Treat `git` permission for `create_worktree` as permission to run repository-controlled checkout behavior; jail mode remains an accident-prevention guardrail, not a security sandbox.
393+
376394 If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory.
377395
378396 ## Bash permissions
@@ -303,7 +303,7 @@ In `--print` / `--stream` / `--json` modes, `pre` still runs first: shell escape
303 303
304 ## Filesystem permissions304 ## Filesystem permissions
305 305
306-Filesystem permissions are enforced by the built-in `read`, `write`, and `edit` tools. Empty or omitted scopes deny the operation.306+Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, and `create_worktree` tools. Empty or omitted scopes deny the operation.
307 307
308 Two variables are available in filesystem scopes:308 Two variables are available in filesystem scopes:
309 309
@@ -373,6 +373,24 @@ Data-only agent:
373 373
374 Filesystem checks canonicalize existing paths, or the nearest existing parent for a new path, so symlinks cannot be used to escape a declared scope.374 Filesystem checks canonicalize existing paths, or the nearest existing parent for a new path, so symlinks cannot be used to escape a declared scope.
375 375
376+`create_worktree` bootstraps a repository without an existing worktree root in two calls. Its first branch-only call returns no-write guidance for the agent to ask the user whether to use `.worktrees` or an external directory. The reply is sent as `bootstrap_root`: `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the repository root's `.gitignore`; an absolute external path creates `<external-root>/<branch>` without changing repository files. The selected root is stored privately as `zut.worktrees.path` in local Git config, so later calls only need `branch`.
377+
378+The tool needs read and write scopes covering the repository root, Git metadata, and the selected worktree root. It also requires Bash permission for `git`, even though it invokes Git directly without a shell:
379+
380+```json
381+{
382+ "permissions": {
383+ "fs": {
384+ "read": ["${workspace}"],
385+ "write": ["${workspace}"]
386+ },
387+ "bash": { "mode": "allowlist", "allow": ["git"] }
388+ }
389+}
390+```
391+
392+The tool disables Git hooks, but a repository checkout can still use configured Git filters. Treat `git` permission for `create_worktree` as permission to run repository-controlled checkout behavior; jail mode remains an accident-prevention guardrail, not a security sandbox.
393+
376 If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory.394 If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory.
377 395
378 ## Bash permissions396 ## Bash permissions
M packages/agent/build.go +12 -10
@@ -890,12 +890,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
890890 if args.FastModeSet {
891891 fastMode = args.FastMode
892892 }
893- // A profile can opt out of the effective host/child fast-mode setting,
894- // but it does not enable fast mode by itself. Keep this as a
895- // restriction on the effective value rather than an override so an
896- // explicit child --fast-mode flag cannot bypass fastMode: false.
893+ // A profile's explicit fastMode setting overrides the global setting
894+ // for this child. This preserves fastMode: false as an opt-out while
895+ // allowing fastMode: true to request the fast tier for a focused worker.
897896 if selectedProfile != nil && selectedProfile.FastMode != nil {
898- fastMode = fastMode && *selectedProfile.FastMode
897+ fastMode = *selectedProfile.FastMode
899898 }
900899
901900 max := args.MaxSteps // 0 = unlimited
@@ -1225,6 +1224,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
12251224 v.Sandbox = s
12261225 case *tools.BashTool:
12271226 v.Sandbox = s
1227+ case *tools.CreateWorktreeTool:
1228+ v.Sandbox = s
12281229 case *tools.LSPTool:
12291230 v.Sandbox = s
12301231 }
@@ -1256,10 +1257,11 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
12561257 manager = lsp.NewManagerWithOptions(options)
12571258 }
12581259 all := map[string]core.Tool{
1259- "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1260- "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1261- "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1262- "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1260+ "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1261+ "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1262+ "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1263+ "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1264+ "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
12631265 }
12641266 if webSearchAllowedForRegistry(args) {
12651267 all["web_search"] = tools.NewWebSearchTool()
@@ -1332,7 +1334,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
13321334 return false
13331335 }
13341336
1335-var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "lsp", "web_search"}
1337+var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "create_worktree", "lsp", "web_search"}
13361338
13371339 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
13381340 var out []ToolSummary
@@ -890,12 +890,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
890 if args.FastModeSet {890 if args.FastModeSet {
891 fastMode = args.FastMode891 fastMode = args.FastMode
892 }892 }
893- // A profile can opt out of the effective host/child fast-mode setting,893+ // A profile's explicit fastMode setting overrides the global setting
894- // but it does not enable fast mode by itself. Keep this as a894+ // for this child. This preserves fastMode: false as an opt-out while
895- // restriction on the effective value rather than an override so an895+ // allowing fastMode: true to request the fast tier for a focused worker.
896- // explicit child --fast-mode flag cannot bypass fastMode: false.
897 if selectedProfile != nil && selectedProfile.FastMode != nil {896 if selectedProfile != nil && selectedProfile.FastMode != nil {
898- fastMode = fastMode && *selectedProfile.FastMode897+ fastMode = *selectedProfile.FastMode
899 }898 }
900 899
901 max := args.MaxSteps // 0 = unlimited900 max := args.MaxSteps // 0 = unlimited
@@ -1225,6 +1224,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1225 v.Sandbox = s1224 v.Sandbox = s
1226 case *tools.BashTool:1225 case *tools.BashTool:
1227 v.Sandbox = s1226 v.Sandbox = s
1227+ case *tools.CreateWorktreeTool:
1228+ v.Sandbox = s
1228 case *tools.LSPTool:1229 case *tools.LSPTool:
1229 v.Sandbox = s1230 v.Sandbox = s
1230 }1231 }
@@ -1256,10 +1257,11 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
1256 manager = lsp.NewManagerWithOptions(options)1257 manager = lsp.NewManagerWithOptions(options)
1257 }1258 }
1258 all := map[string]core.Tool{1259 all := map[string]core.Tool{
1259- "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},1260+ "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1260- "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},1261+ "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1261- "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},1262+ "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1262- "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},1263+ "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1264+ "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1263 }1265 }
1264 if webSearchAllowedForRegistry(args) {1266 if webSearchAllowedForRegistry(args) {
1265 all["web_search"] = tools.NewWebSearchTool()1267 all["web_search"] = tools.NewWebSearchTool()
@@ -1332,7 +1334,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1332 return false1334 return false
1333 }1335 }
1334 1336
1335-var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "lsp", "web_search"}1337+var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "create_worktree", "lsp", "web_search"}
1336 1338
1337 func toolSummaries(reg core.Registry, args Args) []ToolSummary {1339 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1338 var out []ToolSummary1340 var out []ToolSummary
M packages/agent/build_test.go +51 -6
@@ -103,7 +103,7 @@ func TestMergeExtensionToolsKeepsDeterministicNativeSummaryOrder(t *testing.T) {
103103 for _, summary := range r.ToolSummary {
104104 names = append(names, summary.Name)
105105 }
106- want := "read,write,edit,bash,web_search,alpha,zeta"
106+ want := "read,write,edit,bash,create_worktree,web_search,alpha,zeta"
107107 if got := strings.Join(names, ","); got != want {
108108 t.Fatalf("merged tool summary order = %q, want %q", got, want)
109109 }
@@ -136,7 +136,8 @@ func TestMergeExtensionToolsRetainsPonytailPromptAddendum(t *testing.T) {
136136
137137 func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
138138 root := t.TempDir()
139- registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false)
139+ sandbox := tools.NewSandbox(root)
140+ registry := buildToolRegistry(Args{}, root, sandbox, true, true, false)
140141 lspTool, ok := registry["lsp"].(*tools.LSPTool)
141142 if !ok || lspTool.Manager == nil {
142143 t.Fatalf("lsp tool = %#v", registry["lsp"])
@@ -145,6 +146,10 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
145146 if _, ok := registry["lsp"]; !ok {
146147 t.Fatal("default registry does not include lsp")
147148 }
149+ worktree, ok := registry["create_worktree"].(*tools.CreateWorktreeTool)
150+ if !ok || worktree.CWD != root || worktree.Sandbox != sandbox {
151+ t.Fatalf("default registry worktree tool = %#v", registry["create_worktree"])
152+ }
148153 if _, ok := registry["web_search"].(*tools.WebSearchTool); !ok {
149154 t.Fatalf("default registry web_search = %#v", registry["web_search"])
150155 }
@@ -156,8 +161,8 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
156161 if !ok || edit.LSP == nil || edit.LSPDiagnostics {
157162 t.Fatalf("edit tool LSP wiring = %#v", edit)
158163 }
159- if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 5 {
160- t.Fatalf("LSP-disabled registry has %d tools, want 5", len(disabled))
164+ if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 6 {
165+ t.Fatalf("LSP-disabled registry has %d tools, want 6", len(disabled))
161166 }
162167 readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true)
163168 if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil {
@@ -182,6 +187,46 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
182187 if _, ok := workerCapped["web_search"]; ok {
183188 t.Fatalf("worker tool list ceiling was bypassed: %#v", workerCapped)
184189 }
190+ worktreeOnly := buildToolRegistry(Args{Tools: []string{"create_worktree"}}, root, tools.NewSandbox(root), true, true, true)
191+ if worktree, ok := worktreeOnly["create_worktree"].(*tools.CreateWorktreeTool); !ok || worktree == nil || len(worktreeOnly) != 1 {
192+ t.Fatalf("worktree-only registry = %#v", worktreeOnly)
193+ }
194+}
195+
196+func TestToolSummariesIncludeCreateWorktreeAndWebSearch(t *testing.T) {
197+ root := t.TempDir()
198+ registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, false, false)
199+
200+ summaries := toolSummaries(registry, Args{})
201+ want := []string{"read", "write", "edit", "bash", "create_worktree", "web_search"}
202+ if len(summaries) != len(want) {
203+ t.Fatalf("tool summaries = %#v, want %v", summaries, want)
204+ }
205+ for index, summary := range summaries {
206+ if summary.Name != want[index] {
207+ t.Fatalf("tool summaries[%d] = %q, want %q", index, summary.Name, want[index])
208+ }
209+ }
210+}
211+
212+func TestResolvedUseSandboxUpdatesCreateWorktreeTool(t *testing.T) {
213+ root := t.TempDir()
214+ previous := tools.NewSandbox(root)
215+ shared := tools.NewSandbox(root)
216+ worktree := &tools.CreateWorktreeTool{CWD: root, Sandbox: previous}
217+ resolved := &Resolved{
218+ Sandbox: previous,
219+ ToolRegistry: core.NewRegistry(worktree),
220+ }
221+
222+ resolved.UseSandbox(shared)
223+
224+ if resolved.Sandbox != shared {
225+ t.Fatal("resolved sandbox was not replaced")
226+ }
227+ if worktree.Sandbox != shared {
228+ t.Fatal("worktree tool sandbox was not replaced")
229+ }
185230 }
186231
187232 func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) {
@@ -378,7 +423,7 @@ You are a read-only reviewer.
378423 }
379424 }
380425
381-func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) {
426+func TestResolveSubagentFastModeProfileOverridesHostSetting(t *testing.T) {
382427 cases := []struct {
383428 name string
384429 hostFastMode bool
@@ -390,7 +435,7 @@ func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) {
390435 {name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true},
391436 {name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false},
392437 {name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false},
393- {name: "true cannot enable disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: false},
438+ {name: "true enables disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: true},
394439 {name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false},
395440 {name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false},
396441 }
@@ -103,7 +103,7 @@ func TestMergeExtensionToolsKeepsDeterministicNativeSummaryOrder(t *testing.T) {
103 for _, summary := range r.ToolSummary {103 for _, summary := range r.ToolSummary {
104 names = append(names, summary.Name)104 names = append(names, summary.Name)
105 }105 }
106- want := "read,write,edit,bash,web_search,alpha,zeta"106+ want := "read,write,edit,bash,create_worktree,web_search,alpha,zeta"
107 if got := strings.Join(names, ","); got != want {107 if got := strings.Join(names, ","); got != want {
108 t.Fatalf("merged tool summary order = %q, want %q", got, want)108 t.Fatalf("merged tool summary order = %q, want %q", got, want)
109 }109 }
@@ -136,7 +136,8 @@ func TestMergeExtensionToolsRetainsPonytailPromptAddendum(t *testing.T) {
136 136
137 func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {137 func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
138 root := t.TempDir()138 root := t.TempDir()
139- registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false)139+ sandbox := tools.NewSandbox(root)
140+ registry := buildToolRegistry(Args{}, root, sandbox, true, true, false)
140 lspTool, ok := registry["lsp"].(*tools.LSPTool)141 lspTool, ok := registry["lsp"].(*tools.LSPTool)
141 if !ok || lspTool.Manager == nil {142 if !ok || lspTool.Manager == nil {
142 t.Fatalf("lsp tool = %#v", registry["lsp"])143 t.Fatalf("lsp tool = %#v", registry["lsp"])
@@ -145,6 +146,10 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
145 if _, ok := registry["lsp"]; !ok {146 if _, ok := registry["lsp"]; !ok {
146 t.Fatal("default registry does not include lsp")147 t.Fatal("default registry does not include lsp")
147 }148 }
149+ worktree, ok := registry["create_worktree"].(*tools.CreateWorktreeTool)
150+ if !ok || worktree.CWD != root || worktree.Sandbox != sandbox {
151+ t.Fatalf("default registry worktree tool = %#v", registry["create_worktree"])
152+ }
148 if _, ok := registry["web_search"].(*tools.WebSearchTool); !ok {153 if _, ok := registry["web_search"].(*tools.WebSearchTool); !ok {
149 t.Fatalf("default registry web_search = %#v", registry["web_search"])154 t.Fatalf("default registry web_search = %#v", registry["web_search"])
150 }155 }
@@ -156,8 +161,8 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
156 if !ok || edit.LSP == nil || edit.LSPDiagnostics {161 if !ok || edit.LSP == nil || edit.LSPDiagnostics {
157 t.Fatalf("edit tool LSP wiring = %#v", edit)162 t.Fatalf("edit tool LSP wiring = %#v", edit)
158 }163 }
159- if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 5 {164+ if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 6 {
160- t.Fatalf("LSP-disabled registry has %d tools, want 5", len(disabled))165+ t.Fatalf("LSP-disabled registry has %d tools, want 6", len(disabled))
161 }166 }
162 readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true)167 readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true)
163 if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil {168 if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil {
@@ -182,6 +187,46 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
182 if _, ok := workerCapped["web_search"]; ok {187 if _, ok := workerCapped["web_search"]; ok {
183 t.Fatalf("worker tool list ceiling was bypassed: %#v", workerCapped)188 t.Fatalf("worker tool list ceiling was bypassed: %#v", workerCapped)
184 }189 }
190+ worktreeOnly := buildToolRegistry(Args{Tools: []string{"create_worktree"}}, root, tools.NewSandbox(root), true, true, true)
191+ if worktree, ok := worktreeOnly["create_worktree"].(*tools.CreateWorktreeTool); !ok || worktree == nil || len(worktreeOnly) != 1 {
192+ t.Fatalf("worktree-only registry = %#v", worktreeOnly)
193+ }
194+}
195+
196+func TestToolSummariesIncludeCreateWorktreeAndWebSearch(t *testing.T) {
197+ root := t.TempDir()
198+ registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, false, false)
199+
200+ summaries := toolSummaries(registry, Args{})
201+ want := []string{"read", "write", "edit", "bash", "create_worktree", "web_search"}
202+ if len(summaries) != len(want) {
203+ t.Fatalf("tool summaries = %#v, want %v", summaries, want)
204+ }
205+ for index, summary := range summaries {
206+ if summary.Name != want[index] {
207+ t.Fatalf("tool summaries[%d] = %q, want %q", index, summary.Name, want[index])
208+ }
209+ }
210+}
211+
212+func TestResolvedUseSandboxUpdatesCreateWorktreeTool(t *testing.T) {
213+ root := t.TempDir()
214+ previous := tools.NewSandbox(root)
215+ shared := tools.NewSandbox(root)
216+ worktree := &tools.CreateWorktreeTool{CWD: root, Sandbox: previous}
217+ resolved := &Resolved{
218+ Sandbox: previous,
219+ ToolRegistry: core.NewRegistry(worktree),
220+ }
221+
222+ resolved.UseSandbox(shared)
223+
224+ if resolved.Sandbox != shared {
225+ t.Fatal("resolved sandbox was not replaced")
226+ }
227+ if worktree.Sandbox != shared {
228+ t.Fatal("worktree tool sandbox was not replaced")
229+ }
185 }230 }
186 231
187 func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) {232 func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) {
@@ -378,7 +423,7 @@ You are a read-only reviewer.
378 }423 }
379 }424 }
380 425
381-func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) {426+func TestResolveSubagentFastModeProfileOverridesHostSetting(t *testing.T) {
382 cases := []struct {427 cases := []struct {
383 name string428 name string
384 hostFastMode bool429 hostFastMode bool
@@ -390,7 +435,7 @@ func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) {
390 {name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true},435 {name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true},
391 {name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false},436 {name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false},
392 {name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false},437 {name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false},
393- {name: "true cannot enable disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: false},438+ {name: "true enables disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: true},
394 {name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false},439 {name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false},
395 {name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false},440 {name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false},
396 }441 }
M packages/agent/cli.go +6 -3
@@ -1407,15 +1407,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14071407 return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil
14081408 },
14091409 })
1410- // Pull any previously-spawned agents off disk so the dashboard
1411- // shows them as detached and the user can resume / remove them.
1412- _, _ = subagentsMgr.Reload()
14131410 if args.PermissionSet != nil {
14141411 // Packaged zutfile agents have an explicit capability ceiling. A
14151412 // child process would otherwise rebuild a fresh unrestricted sandbox,
14161413 // so do not expose either slash or tool-based delegation from this
14171414 // restricted host until worker capability propagation exists.
14181415 subagentsMgr = nil
1416+ } else {
1417+ // Replaying every historical event log can take seconds for users with
1418+ // many completed subagents. The manager is safe for concurrent snapshots,
1419+ // so populate the detached-agent dashboard in the background instead of
1420+ // blocking the first interactive paint.
1421+ go func() { _, _ = subagentsMgr.Reload() }()
14191422 }
14201423
14211424 // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool
@@ -1407,15 +1407,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1407 return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil1407 return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil
1408 },1408 },
1409 })1409 })
1410- // Pull any previously-spawned agents off disk so the dashboard
1411- // shows them as detached and the user can resume / remove them.
1412- _, _ = subagentsMgr.Reload()
1413 if args.PermissionSet != nil {1410 if args.PermissionSet != nil {
1414 // Packaged zutfile agents have an explicit capability ceiling. A1411 // Packaged zutfile agents have an explicit capability ceiling. A
1415 // child process would otherwise rebuild a fresh unrestricted sandbox,1412 // child process would otherwise rebuild a fresh unrestricted sandbox,
1416 // so do not expose either slash or tool-based delegation from this1413 // so do not expose either slash or tool-based delegation from this
1417 // restricted host until worker capability propagation exists.1414 // restricted host until worker capability propagation exists.
1418 subagentsMgr = nil1415 subagentsMgr = nil
1416+ } else {
1417+ // Replaying every historical event log can take seconds for users with
1418+ // many completed subagents. The manager is safe for concurrent snapshots,
1419+ // so populate the detached-agent dashboard in the background instead of
1420+ // blocking the first interactive paint.
1421+ go func() { _, _ = subagentsMgr.Reload() }()
1419 }1422 }
1420 1423
1421 // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool1424 // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool
M packages/agent/ext/ext.go +4 -3
@@ -420,9 +420,10 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
420420 // OpenAI accept). Call this BEFORE Run(); zut folds extension tools
421421 // into the agent's registry once the extension's ready frame fires.
422422 //
423-// Conflicts with active built-in tool names are ignored. The native
424-// web_search name is reserved even when that built-in is disabled, so an
425-// extension cannot claim or replace it.
423+// Conflicts with active built-in tool names (read, write, edit, bash,
424+// create_worktree, lsp, web_search, and skill) are silently shadowed by the
425+// built-in. The native web_search name is reserved even when that built-in is
426+// disabled, so an extension cannot claim or replace it.
426427 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
427428 e.registerTool(name, description, schema, false, fn)
428429 }
@@ -420,9 +420,10 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
420 // OpenAI accept). Call this BEFORE Run(); zut folds extension tools420 // OpenAI accept). Call this BEFORE Run(); zut folds extension tools
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 are ignored. The native423+// Conflicts with active built-in tool names (read, write, edit, bash,
424-// web_search name is reserved even when that built-in is disabled, so an424+// create_worktree, lsp, web_search, and skill) are silently shadowed by the
425-// extension cannot claim or replace it.425+// built-in. The native web_search name is reserved even when that built-in is
426+// disabled, so an extension cannot claim or replace it.
426 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {427 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
427 e.registerTool(name, description, schema, false, fn)428 e.registerTool(name, description, schema, false, fn)
428 }429 }
M packages/agent/modes/compact_queue_test.go +133 -5
@@ -153,7 +153,7 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
153153 interactive := NewInteractive(InteractiveConfig{Agent: agent})
154154 interactive.runCtx = context.Background()
155155
156- interactive.runCompact(context.Background(), false)
156+ interactive.runCompact(context.Background(), false, false)
157157 select {
158158 case <-client.compactionStarted:
159159 case <-time.After(2 * time.Second):
@@ -178,9 +178,12 @@ type thresholdAutoCompactClient struct {
178178 compactionStarted chan struct{}
179179 releaseCompaction chan struct{}
180180 followUpRequest chan provider.Request
181+ firstStop provider.StopReason
182+ firstContent []provider.Content
181183
182- mu sync.Mutex
183- calls int
184+ mu sync.Mutex
185+ calls int
186+ followUps int
184187 }
185188
186189 func (c *thresholdAutoCompactClient) Name() string { return "threshold-auto-compact-test" }
@@ -196,12 +199,20 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re
196199 defer close(out)
197200 switch call {
198201 case 1:
202+ stop := c.firstStop
203+ if stop == "" {
204+ stop = provider.StopEnd
205+ }
206+ content := c.firstContent
207+ if len(content) == 0 {
208+ content = []provider.Content{provider.ReasoningBlock{Summary: "still working"}}
209+ }
199210 out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}}
200211 out <- provider.EventDone{
201- Stop: provider.StopEnd,
212+ Stop: stop,
202213 Message: provider.Message{
203214 Role: provider.RoleAssistant,
204- Content: []provider.Content{provider.ReasoningBlock{Summary: "still working"}},
215+ Content: content,
205216 },
206217 }
207218 case 2:
@@ -214,6 +225,9 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re
214225 out <- provider.EventDone{Stop: provider.StopEnd}
215226 }
216227 default:
228+ c.mu.Lock()
229+ c.followUps++
230+ c.mu.Unlock()
217231 c.followUpRequest <- req
218232 out <- provider.EventDone{
219233 Stop: provider.StopEnd,
@@ -261,6 +275,120 @@ func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
261275 }
262276 }
263277
278+func TestThresholdAutoCompactionContinuesTruncatedText(t *testing.T) {
279+ client := &thresholdAutoCompactClient{
280+ compactionStarted: make(chan struct{}),
281+ releaseCompaction: make(chan struct{}),
282+ followUpRequest: make(chan provider.Request, 1),
283+ firstStop: provider.StopLength,
284+ firstContent: []provider.Content{provider.TextBlock{Text: "partial answer"}},
285+ }
286+ agent := core.NewAgent(client, "test-model", "", nil)
287+ threshold := 70
288+ interactive := NewInteractive(InteractiveConfig{
289+ Agent: agent,
290+ Provider: "anthropic",
291+ Model: "claude-sonnet-4-5-20250929",
292+ AutoCompactThreshold: &threshold,
293+ })
294+ interactive.runCtx = context.Background()
295+
296+ interactive.startTurn(context.Background(), "initial request")
297+ select {
298+ case <-client.compactionStarted:
299+ case <-time.After(2 * time.Second):
300+ t.Fatal("threshold auto-compaction did not start after truncated text")
301+ }
302+ close(client.releaseCompaction)
303+
304+ select {
305+ case req := <-client.followUpRequest:
306+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
307+ t.Fatalf("truncated continuation request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
308+ }
309+ case <-time.After(2 * time.Second):
310+ t.Fatal("threshold auto-compaction did not continue truncated text")
311+ }
312+}
313+
314+func TestThresholdAutoCompactionForcedContinuationPrecedesQueuedPrompt(t *testing.T) {
315+ client := &thresholdAutoCompactClient{
316+ compactionStarted: make(chan struct{}),
317+ releaseCompaction: make(chan struct{}),
318+ followUpRequest: make(chan provider.Request, 2),
319+ firstStop: provider.StopLength,
320+ firstContent: []provider.Content{provider.TextBlock{Text: "partial answer"}},
321+ }
322+ agent := core.NewAgent(client, "test-model", "", nil)
323+ threshold := 70
324+ interactive := NewInteractive(InteractiveConfig{
325+ Agent: agent,
326+ Provider: "anthropic",
327+ Model: "claude-sonnet-4-5-20250929",
328+ AutoCompactThreshold: &threshold,
329+ })
330+ interactive.runCtx = context.Background()
331+
332+ interactive.startTurn(context.Background(), "initial request")
333+ select {
334+ case <-client.compactionStarted:
335+ case <-time.After(2 * time.Second):
336+ t.Fatal("threshold auto-compaction did not start after truncated text")
337+ }
338+ interactive.SubmitOrQueue("queued follow-up", nil)
339+ close(client.releaseCompaction)
340+
341+ select {
342+ case req := <-client.followUpRequest:
343+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
344+ t.Fatalf("first request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
345+ }
346+ if got := requestUserTextCount(req, "queued follow-up"); got != 0 {
347+ t.Fatalf("forced continuation request included queued prompt %d times, want 0: %#v", got, req.Messages)
348+ }
349+ case <-time.After(2 * time.Second):
350+ t.Fatal("threshold auto-compaction did not continue truncated text")
351+ }
352+
353+ select {
354+ case req := <-client.followUpRequest:
355+ if got := requestUserTextCount(req, "queued follow-up"); got != 1 {
356+ t.Fatalf("second request contains queued prompt %d times, want 1: %#v", got, req.Messages)
357+ }
358+ case <-time.After(2 * time.Second):
359+ t.Fatal("queued prompt did not run after forced continuation")
360+ }
361+
362+ deadline := time.NewTimer(2 * time.Second)
363+ defer deadline.Stop()
364+ poll := time.NewTicker(time.Millisecond)
365+ defer poll.Stop()
366+ for {
367+ interactive.mu.Lock()
368+ idle := !interactive.busy
369+ interactive.mu.Unlock()
370+ if idle {
371+ break
372+ }
373+ select {
374+ case <-deadline.C:
375+ t.Fatal("interactive did not settle after forced continuation and queued prompt")
376+ case <-poll.C:
377+ }
378+ }
379+
380+ client.mu.Lock()
381+ calls := client.calls
382+ followUps := client.followUps
383+ client.mu.Unlock()
384+ if calls != 4 {
385+ t.Fatalf("provider called %d times, want initial request, compaction, forced continuation, and queued prompt", calls)
386+ }
387+ if followUps != 2 {
388+ t.Fatalf("follow-up requests = %d, want forced continuation and queued prompt", followUps)
389+ }
390+}
391+
264392 func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {
265393 client := &compactQueueClient{
266394 compactionStarted: make(chan struct{}),
@@ -153,7 +153,7 @@ func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
153 interactive := NewInteractive(InteractiveConfig{Agent: agent})153 interactive := NewInteractive(InteractiveConfig{Agent: agent})
154 interactive.runCtx = context.Background()154 interactive.runCtx = context.Background()
155 155
156- interactive.runCompact(context.Background(), false)156+ interactive.runCompact(context.Background(), false, false)
157 select {157 select {
158 case <-client.compactionStarted:158 case <-client.compactionStarted:
159 case <-time.After(2 * time.Second):159 case <-time.After(2 * time.Second):
@@ -178,9 +178,12 @@ type thresholdAutoCompactClient struct {
178 compactionStarted chan struct{}178 compactionStarted chan struct{}
179 releaseCompaction chan struct{}179 releaseCompaction chan struct{}
180 followUpRequest chan provider.Request180 followUpRequest chan provider.Request
181+ firstStop provider.StopReason
182+ firstContent []provider.Content
181 183
182- mu sync.Mutex184+ mu sync.Mutex
183- calls int185+ calls int
186+ followUps int
184 }187 }
185 188
186 func (c *thresholdAutoCompactClient) Name() string { return "threshold-auto-compact-test" }189 func (c *thresholdAutoCompactClient) Name() string { return "threshold-auto-compact-test" }
@@ -196,12 +199,20 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re
196 defer close(out)199 defer close(out)
197 switch call {200 switch call {
198 case 1:201 case 1:
202+ stop := c.firstStop
203+ if stop == "" {
204+ stop = provider.StopEnd
205+ }
206+ content := c.firstContent
207+ if len(content) == 0 {
208+ content = []provider.Content{provider.ReasoningBlock{Summary: "still working"}}
209+ }
199 out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}}210 out <- provider.EventUsage{Usage: provider.Usage{InputTokens: 150000}}
200 out <- provider.EventDone{211 out <- provider.EventDone{
201- Stop: provider.StopEnd,212+ Stop: stop,
202 Message: provider.Message{213 Message: provider.Message{
203 Role: provider.RoleAssistant,214 Role: provider.RoleAssistant,
204- Content: []provider.Content{provider.ReasoningBlock{Summary: "still working"}},215+ Content: content,
205 },216 },
206 }217 }
207 case 2:218 case 2:
@@ -214,6 +225,9 @@ func (c *thresholdAutoCompactClient) Stream(ctx context.Context, req provider.Re
214 out <- provider.EventDone{Stop: provider.StopEnd}225 out <- provider.EventDone{Stop: provider.StopEnd}
215 }226 }
216 default:227 default:
228+ c.mu.Lock()
229+ c.followUps++
230+ c.mu.Unlock()
217 c.followUpRequest <- req231 c.followUpRequest <- req
218 out <- provider.EventDone{232 out <- provider.EventDone{
219 Stop: provider.StopEnd,233 Stop: provider.StopEnd,
@@ -261,6 +275,120 @@ func TestThresholdAutoCompactionContinuesMostRecentIntent(t *testing.T) {
261 }275 }
262 }276 }
263 277
278+func TestThresholdAutoCompactionContinuesTruncatedText(t *testing.T) {
279+ client := &thresholdAutoCompactClient{
280+ compactionStarted: make(chan struct{}),
281+ releaseCompaction: make(chan struct{}),
282+ followUpRequest: make(chan provider.Request, 1),
283+ firstStop: provider.StopLength,
284+ firstContent: []provider.Content{provider.TextBlock{Text: "partial answer"}},
285+ }
286+ agent := core.NewAgent(client, "test-model", "", nil)
287+ threshold := 70
288+ interactive := NewInteractive(InteractiveConfig{
289+ Agent: agent,
290+ Provider: "anthropic",
291+ Model: "claude-sonnet-4-5-20250929",
292+ AutoCompactThreshold: &threshold,
293+ })
294+ interactive.runCtx = context.Background()
295+
296+ interactive.startTurn(context.Background(), "initial request")
297+ select {
298+ case <-client.compactionStarted:
299+ case <-time.After(2 * time.Second):
300+ t.Fatal("threshold auto-compaction did not start after truncated text")
301+ }
302+ close(client.releaseCompaction)
303+
304+ select {
305+ case req := <-client.followUpRequest:
306+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
307+ t.Fatalf("truncated continuation request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
308+ }
309+ case <-time.After(2 * time.Second):
310+ t.Fatal("threshold auto-compaction did not continue truncated text")
311+ }
312+}
313+
314+func TestThresholdAutoCompactionForcedContinuationPrecedesQueuedPrompt(t *testing.T) {
315+ client := &thresholdAutoCompactClient{
316+ compactionStarted: make(chan struct{}),
317+ releaseCompaction: make(chan struct{}),
318+ followUpRequest: make(chan provider.Request, 2),
319+ firstStop: provider.StopLength,
320+ firstContent: []provider.Content{provider.TextBlock{Text: "partial answer"}},
321+ }
322+ agent := core.NewAgent(client, "test-model", "", nil)
323+ threshold := 70
324+ interactive := NewInteractive(InteractiveConfig{
325+ Agent: agent,
326+ Provider: "anthropic",
327+ Model: "claude-sonnet-4-5-20250929",
328+ AutoCompactThreshold: &threshold,
329+ })
330+ interactive.runCtx = context.Background()
331+
332+ interactive.startTurn(context.Background(), "initial request")
333+ select {
334+ case <-client.compactionStarted:
335+ case <-time.After(2 * time.Second):
336+ t.Fatal("threshold auto-compaction did not start after truncated text")
337+ }
338+ interactive.SubmitOrQueue("queued follow-up", nil)
339+ close(client.releaseCompaction)
340+
341+ select {
342+ case req := <-client.followUpRequest:
343+ if got := requestUserTextCount(req, autoCompactContinuationPrompt); got != 1 {
344+ t.Fatalf("first request contains auto-continue prompt %d times, want 1: %#v", got, req.Messages)
345+ }
346+ if got := requestUserTextCount(req, "queued follow-up"); got != 0 {
347+ t.Fatalf("forced continuation request included queued prompt %d times, want 0: %#v", got, req.Messages)
348+ }
349+ case <-time.After(2 * time.Second):
350+ t.Fatal("threshold auto-compaction did not continue truncated text")
351+ }
352+
353+ select {
354+ case req := <-client.followUpRequest:
355+ if got := requestUserTextCount(req, "queued follow-up"); got != 1 {
356+ t.Fatalf("second request contains queued prompt %d times, want 1: %#v", got, req.Messages)
357+ }
358+ case <-time.After(2 * time.Second):
359+ t.Fatal("queued prompt did not run after forced continuation")
360+ }
361+
362+ deadline := time.NewTimer(2 * time.Second)
363+ defer deadline.Stop()
364+ poll := time.NewTicker(time.Millisecond)
365+ defer poll.Stop()
366+ for {
367+ interactive.mu.Lock()
368+ idle := !interactive.busy
369+ interactive.mu.Unlock()
370+ if idle {
371+ break
372+ }
373+ select {
374+ case <-deadline.C:
375+ t.Fatal("interactive did not settle after forced continuation and queued prompt")
376+ case <-poll.C:
377+ }
378+ }
379+
380+ client.mu.Lock()
381+ calls := client.calls
382+ followUps := client.followUps
383+ client.mu.Unlock()
384+ if calls != 4 {
385+ t.Fatalf("provider called %d times, want initial request, compaction, forced continuation, and queued prompt", calls)
386+ }
387+ if followUps != 2 {
388+ t.Fatalf("follow-up requests = %d, want forced continuation and queued prompt", followUps)
389+ }
390+}
391+
264 func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {392 func TestPreTurnCompactionPreservesPromptImages(t *testing.T) {
265 client := &compactQueueClient{393 client := &compactQueueClient{
266 compactionStarted: make(chan struct{}),394 compactionStarted: make(chan struct{}),
M packages/agent/modes/fast_mode_test.go +33 -0
@@ -6,6 +6,7 @@ import (
66 "strings"
77 "testing"
88
9+ "github.com/bnema/zut/packages/agent/subagents"
910 "github.com/bnema/zut/packages/core"
1011 "github.com/bnema/zut/packages/provider"
1112 )
@@ -76,6 +77,38 @@ func TestApplyFastModeUpdatesAgentAndStore(t *testing.T) {
7677 }
7778 }
7879
80+func TestFastSlashTogglesFastMode(t *testing.T) {
81+ store := &recordingFastModeSettingsStore{}
82+ agent := core.NewAgent(fastModeProviderClient{}, "gpt-5", "", nil)
83+ supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
84+ interactive := NewInteractive(InteractiveConfig{
85+ Provider: "openai",
86+ Model: "gpt-5",
87+ SettingsStore: store,
88+ Agent: agent,
89+ Supervisor: supervisor,
90+ })
91+ interactive.rend = nil
92+
93+ if interactive.runSlash(context.Background(), "/FAST") {
94+ t.Fatal("/fast requested exit")
95+ }
96+ if interactive.cfg.FastMode == nil || !*interactive.cfg.FastMode || !agent.FastMode || !supervisor.FastMode() {
97+ t.Fatalf("/fast did not enable every live fast-mode target: cfg=%v agent=%v supervisor=%v", interactive.cfg.FastMode, agent.FastMode, supervisor.FastMode())
98+ }
99+ if store.setCalls != 1 || !store.last || interactive.statusOK != "fast mode enabled" {
100+ t.Fatalf("enable persistence/status = calls=%d last=%v status=%q", store.setCalls, store.last, interactive.statusOK)
101+ }
102+
103+ interactive.runSlash(context.Background(), "/fast")
104+ if interactive.cfg.FastMode == nil || *interactive.cfg.FastMode || agent.FastMode || supervisor.FastMode() {
105+ t.Fatalf("/fast did not disable every live fast-mode target: cfg=%v agent=%v supervisor=%v", interactive.cfg.FastMode, agent.FastMode, supervisor.FastMode())
106+ }
107+ if store.setCalls != 2 || store.last || interactive.statusOK != "fast mode disabled" {
108+ t.Fatalf("disable persistence/status = calls=%d last=%v status=%q", store.setCalls, store.last, interactive.statusOK)
109+ }
110+}
111+
79112 func TestApplyFastModeRejectsUnsupportedProvider(t *testing.T) {
80113 store := &recordingFastModeSettingsStore{}
81114 interactive := NewInteractive(InteractiveConfig{
@@ -6,6 +6,7 @@ import (
6 "strings"6 "strings"
7 "testing"7 "testing"
8 8
9+ "github.com/bnema/zut/packages/agent/subagents"
9 "github.com/bnema/zut/packages/core"10 "github.com/bnema/zut/packages/core"
10 "github.com/bnema/zut/packages/provider"11 "github.com/bnema/zut/packages/provider"
11 )12 )
@@ -76,6 +77,38 @@ func TestApplyFastModeUpdatesAgentAndStore(t *testing.T) {
76 }77 }
77 }78 }
78 79
80+func TestFastSlashTogglesFastMode(t *testing.T) {
81+ store := &recordingFastModeSettingsStore{}
82+ agent := core.NewAgent(fastModeProviderClient{}, "gpt-5", "", nil)
83+ supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
84+ interactive := NewInteractive(InteractiveConfig{
85+ Provider: "openai",
86+ Model: "gpt-5",
87+ SettingsStore: store,
88+ Agent: agent,
89+ Supervisor: supervisor,
90+ })
91+ interactive.rend = nil
92+
93+ if interactive.runSlash(context.Background(), "/FAST") {
94+ t.Fatal("/fast requested exit")
95+ }
96+ if interactive.cfg.FastMode == nil || !*interactive.cfg.FastMode || !agent.FastMode || !supervisor.FastMode() {
97+ t.Fatalf("/fast did not enable every live fast-mode target: cfg=%v agent=%v supervisor=%v", interactive.cfg.FastMode, agent.FastMode, supervisor.FastMode())
98+ }
99+ if store.setCalls != 1 || !store.last || interactive.statusOK != "fast mode enabled" {
100+ t.Fatalf("enable persistence/status = calls=%d last=%v status=%q", store.setCalls, store.last, interactive.statusOK)
101+ }
102+
103+ interactive.runSlash(context.Background(), "/fast")
104+ if interactive.cfg.FastMode == nil || *interactive.cfg.FastMode || agent.FastMode || supervisor.FastMode() {
105+ t.Fatalf("/fast did not disable every live fast-mode target: cfg=%v agent=%v supervisor=%v", interactive.cfg.FastMode, agent.FastMode, supervisor.FastMode())
106+ }
107+ if store.setCalls != 2 || store.last || interactive.statusOK != "fast mode disabled" {
108+ t.Fatalf("disable persistence/status = calls=%d last=%v status=%q", store.setCalls, store.last, interactive.statusOK)
109+ }
110+}
111+
79 func TestApplyFastModeRejectsUnsupportedProvider(t *testing.T) {112 func TestApplyFastModeRejectsUnsupportedProvider(t *testing.T) {
80 store := &recordingFastModeSettingsStore{}113 store := &recordingFastModeSettingsStore{}
81 interactive := NewInteractive(InteractiveConfig{114 interactive := NewInteractive(InteractiveConfig{
M packages/agent/modes/interactive.go +18 -11
@@ -4362,7 +4362,7 @@ func (i *Interactive) openSettingsDialog() {
43624362 {
43634363 key: "fast_mode",
43644364 label: "fast mode",
4365- desc: "request OpenAI's fast service tier; unsupported providers return an error",
4365+ desc: "request the provider's fast tier where supported; unsupported providers return an error",
43664366 value: fastMode,
43674367 hint: fastModeHint,
43684368 },
@@ -5713,6 +5713,9 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
57135713 }
57145714 case "/reasoning":
57155715 i.openReasoningDialog()
5716+ case "/fast":
5717+ enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode
5718+ i.applySettingToggle("fast_mode", enabled)
57165719 case "/settings":
57175720 i.openSettingsDialog()
57185721 case "/sessions":
@@ -5728,7 +5731,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
57285731 case "/skills":
57295732 i.openSkillsDialog()
57305733 case "/compact":
5731- i.runCompact(ctx, false)
5734+ i.runCompact(ctx, false, false)
57325735 case "/study":
57335736 // Canned prompt that tells the agent to read every file
57345737 // in some target so its later turns have the whole thing
@@ -6674,7 +6677,7 @@ func (i *Interactive) clearPendingCompactTurnLocked() {
66746677 i.pendingPostCompactNote = ""
66756678 }
66766679
6677-const autoCompactContinuationPrompt = `Resume work on the user's most recent intent. Re-read the kept recent messages after the summary to confirm what the user asked for last. If their latest request supersedes earlier plans recorded in the summary, follow the latest request. If there is nothing left to do, say so briefly instead of inventing further work.`
6680+const autoCompactContinuationPrompt = `Context was compacted while work was in progress. Continue the user's most recent active request now; do not wait for them to type "continue". Re-read the context summary and kept recent messages, treating active constraints and preferences there as still in force, including delegation/subagent instructions. If a newer request supersedes earlier plans in the summary, follow the newer request. If there is truly nothing left to do, say so briefly instead of inventing work.`
66786681
66796682 // startAutoCompactContinuation adds an internal user turn that tells the
66806683 // model to resume the task after threshold compaction. Provider requests
@@ -6709,7 +6712,9 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
67096712 // When auto is true the spinner message is pinned to "condensing
67106713 // history" and the status bar surfaces "(auto)" next to the context
67116714 // percentage so it's obvious the system triggered this, not the user.
6712-func (i *Interactive) runCompact(parent context.Context, auto bool) {
6715+// forceAutoContinue tells the post-compact hand-off to resume even if the
6716+// transcript tail has visible assistant text, for example after StopLength.
6717+func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoContinue bool) {
67136718 if i.agent == nil {
67146719 i.mu.Lock()
67156720 i.statusErr = "not logged in. type /login first."
@@ -6743,7 +6748,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
67436748 // Sink discards deltas — we don't stream the summary to the UI.
67446749 sink := func(delta string) {}
67456750 msgsBefore := i.agent.Messages()
6746- autoContinue := auto && shouldAutoContinueAfterCompaction(msgsBefore)
6751+ autoContinue := auto && (forceAutoContinue || shouldAutoContinueAfterCompaction(msgsBefore))
67476752 // Keep the usual recent tail when possible, but never let it cover
67486753 // the whole transcript. A short session can still be at 70–90% of
67496754 // a model's window because one prompt or tool result is large; the
@@ -6829,14 +6834,16 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
68296834 i.pendingCompactImages = nil
68306835 i.hasPendingCompactPrompt = false
68316836 hasNext = true
6837+ case forceAutoContinue && autoContinue:
6838+ continueAutomatically = true
68326839 case len(i.queued) > 0:
68336840 next, i.queued = i.queued[0], i.queued[1:]
68346841 hasNext = true
68356842 case autoContinue:
68366843 // A successful threshold compaction must hand control back to
6837- // an unfinished model turn. Completed text answers should settle
6838- // without an invented extra request; the user can still prompt
6839- // again normally.
6844+ // unfinished work. Completed text answers still settle normally
6845+ // unless the caller knows the text was truncated and forces this
6846+ // continuation.
68406847 continueAutomatically = true
68416848 }
68426849 }
@@ -7047,7 +7054,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
70477054 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
70487055 i.mu.Unlock()
70497056 i.invalidate()
7050- i.runCompact(parent, true)
7057+ i.runCompact(parent, true, false)
70517058 return
70527059 }
70537060 i.mu.Unlock()
@@ -7242,9 +7249,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
72427249 case offer:
72437250 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
72447251 case recoverContextOverflow:
7245- i.runCompact(parent, true)
7252+ i.runCompact(parent, true, false)
72467253 case shouldAutoCompact:
7247- i.runCompact(parent, true)
7254+ i.runCompact(parent, true, lastStop == provider.StopLength)
72487255 }
72497256 }()
72507257 }
@@ -4362,7 +4362,7 @@ func (i *Interactive) openSettingsDialog() {
4362 {4362 {
4363 key: "fast_mode",4363 key: "fast_mode",
4364 label: "fast mode",4364 label: "fast mode",
4365- desc: "request OpenAI's fast service tier; unsupported providers return an error",4365+ desc: "request the provider's fast tier where supported; unsupported providers return an error",
4366 value: fastMode,4366 value: fastMode,
4367 hint: fastModeHint,4367 hint: fastModeHint,
4368 },4368 },
@@ -5713,6 +5713,9 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5713 }5713 }
5714 case "/reasoning":5714 case "/reasoning":
5715 i.openReasoningDialog()5715 i.openReasoningDialog()
5716+ case "/fast":
5717+ enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode
5718+ i.applySettingToggle("fast_mode", enabled)
5716 case "/settings":5719 case "/settings":
5717 i.openSettingsDialog()5720 i.openSettingsDialog()
5718 case "/sessions":5721 case "/sessions":
@@ -5728,7 +5731,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5728 case "/skills":5731 case "/skills":
5729 i.openSkillsDialog()5732 i.openSkillsDialog()
5730 case "/compact":5733 case "/compact":
5731- i.runCompact(ctx, false)5734+ i.runCompact(ctx, false, false)
5732 case "/study":5735 case "/study":
5733 // Canned prompt that tells the agent to read every file5736 // Canned prompt that tells the agent to read every file
5734 // in some target so its later turns have the whole thing5737 // in some target so its later turns have the whole thing
@@ -6674,7 +6677,7 @@ func (i *Interactive) clearPendingCompactTurnLocked() {
6674 i.pendingPostCompactNote = ""6677 i.pendingPostCompactNote = ""
6675 }6678 }
6676 6679
6677-const autoCompactContinuationPrompt = `Resume work on the user's most recent intent. Re-read the kept recent messages after the summary to confirm what the user asked for last. If their latest request supersedes earlier plans recorded in the summary, follow the latest request. If there is nothing left to do, say so briefly instead of inventing further work.`6680+const autoCompactContinuationPrompt = `Context was compacted while work was in progress. Continue the user's most recent active request now; do not wait for them to type "continue". Re-read the context summary and kept recent messages, treating active constraints and preferences there as still in force, including delegation/subagent instructions. If a newer request supersedes earlier plans in the summary, follow the newer request. If there is truly nothing left to do, say so briefly instead of inventing work.`
6678 6681
6679 // startAutoCompactContinuation adds an internal user turn that tells the6682 // startAutoCompactContinuation adds an internal user turn that tells the
6680 // model to resume the task after threshold compaction. Provider requests6683 // model to resume the task after threshold compaction. Provider requests
@@ -6709,7 +6712,9 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
6709 // When auto is true the spinner message is pinned to "condensing6712 // When auto is true the spinner message is pinned to "condensing
6710 // history" and the status bar surfaces "(auto)" next to the context6713 // history" and the status bar surfaces "(auto)" next to the context
6711 // percentage so it's obvious the system triggered this, not the user.6714 // percentage so it's obvious the system triggered this, not the user.
6712-func (i *Interactive) runCompact(parent context.Context, auto bool) {6715+// forceAutoContinue tells the post-compact hand-off to resume even if the
6716+// transcript tail has visible assistant text, for example after StopLength.
6717+func (i *Interactive) runCompact(parent context.Context, auto bool, forceAutoContinue bool) {
6713 if i.agent == nil {6718 if i.agent == nil {
6714 i.mu.Lock()6719 i.mu.Lock()
6715 i.statusErr = "not logged in. type /login first."6720 i.statusErr = "not logged in. type /login first."
@@ -6743,7 +6748,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
6743 // Sink discards deltas — we don't stream the summary to the UI.6748 // Sink discards deltas — we don't stream the summary to the UI.
6744 sink := func(delta string) {}6749 sink := func(delta string) {}
6745 msgsBefore := i.agent.Messages()6750 msgsBefore := i.agent.Messages()
6746- autoContinue := auto && shouldAutoContinueAfterCompaction(msgsBefore)6751+ autoContinue := auto && (forceAutoContinue || shouldAutoContinueAfterCompaction(msgsBefore))
6747 // Keep the usual recent tail when possible, but never let it cover6752 // Keep the usual recent tail when possible, but never let it cover
6748 // the whole transcript. A short session can still be at 70–90% of6753 // the whole transcript. A short session can still be at 70–90% of
6749 // a model's window because one prompt or tool result is large; the6754 // a model's window because one prompt or tool result is large; the
@@ -6829,14 +6834,16 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
6829 i.pendingCompactImages = nil6834 i.pendingCompactImages = nil
6830 i.hasPendingCompactPrompt = false6835 i.hasPendingCompactPrompt = false
6831 hasNext = true6836 hasNext = true
6837+ case forceAutoContinue && autoContinue:
6838+ continueAutomatically = true
6832 case len(i.queued) > 0:6839 case len(i.queued) > 0:
6833 next, i.queued = i.queued[0], i.queued[1:]6840 next, i.queued = i.queued[0], i.queued[1:]
6834 hasNext = true6841 hasNext = true
6835 case autoContinue:6842 case autoContinue:
6836 // A successful threshold compaction must hand control back to6843 // A successful threshold compaction must hand control back to
6837- // an unfinished model turn. Completed text answers should settle6844+ // unfinished work. Completed text answers still settle normally
6838- // without an invented extra request; the user can still prompt6845+ // unless the caller knows the text was truncated and forces this
6839- // again normally.6846+ // continuation.
6840 continueAutomatically = true6847 continueAutomatically = true
6841 }6848 }
6842 }6849 }
@@ -7047,7 +7054,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7047 i.pendingPostCompactNote = "context auto-compacted; sending your last message"7054 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
7048 i.mu.Unlock()7055 i.mu.Unlock()
7049 i.invalidate()7056 i.invalidate()
7050- i.runCompact(parent, true)7057+ i.runCompact(parent, true, false)
7051 return7058 return
7052 }7059 }
7053 i.mu.Unlock()7060 i.mu.Unlock()
@@ -7242,9 +7249,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7242 case offer:7249 case offer:
7243 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)7250 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
7244 case recoverContextOverflow:7251 case recoverContextOverflow:
7245- i.runCompact(parent, true)7252+ i.runCompact(parent, true, false)
7246 case shouldAutoCompact:7253 case shouldAutoCompact:
7247- i.runCompact(parent, true)7254+ i.runCompact(parent, true, lastStop == provider.StopLength)
7248 }7255 }
7249 }()7256 }()
7250 }7257 }
M packages/agent/modes/slash_suggest.go +1 -0
@@ -51,6 +51,7 @@ var slashCatalog = []slashCommand{
5151 {Name: "/logout", Desc: "clear a provider's credentials"},
5252 {Name: "/model", Desc: "pick a model (or /model <id>)"},
5353 {Name: "/reasoning", Desc: "set the reasoning level"},
54+ {Name: "/fast", Desc: "toggle fast mode"},
5455 {Name: "/llama", Desc: "manage llama.cpp router models"},
5556 {Name: "/sessions", Desc: "resume a previous session for this directory"},
5657 {Name: "/fork", Desc: "pick a user message and fork the current session"},
@@ -51,6 +51,7 @@ var slashCatalog = []slashCommand{
51 {Name: "/logout", Desc: "clear a provider's credentials"},51 {Name: "/logout", Desc: "clear a provider's credentials"},
52 {Name: "/model", Desc: "pick a model (or /model <id>)"},52 {Name: "/model", Desc: "pick a model (or /model <id>)"},
53 {Name: "/reasoning", Desc: "set the reasoning level"},53 {Name: "/reasoning", Desc: "set the reasoning level"},
54+ {Name: "/fast", Desc: "toggle fast mode"},
54 {Name: "/llama", Desc: "manage llama.cpp router models"},55 {Name: "/llama", Desc: "manage llama.cpp router models"},
55 {Name: "/sessions", Desc: "resume a previous session for this directory"},56 {Name: "/sessions", Desc: "resume a previous session for this directory"},
56 {Name: "/fork", Desc: "pick a user message and fork the current session"},57 {Name: "/fork", Desc: "pick a user message and fork the current session"},
M packages/agent/modes/slash_suggest_test.go +10 -0
@@ -44,6 +44,16 @@ func TestSlashSuggesterHasSupervisor(t *testing.T) {
4444 }
4545 }
4646
47+func TestSlashSuggesterHasFastModeToggle(t *testing.T) {
48+ s := newSlashSuggester()
49+ if got := commandNames(s.matches("/fa")); !contains(got, "/fast") {
50+ t.Fatalf("/fast missing from suggestions, got %v", got)
51+ }
52+ if !isKnownSlashCommand("/FAST") {
53+ t.Fatal("/FAST was not recognized as a built-in command")
54+ }
55+}
56+
4757 func TestSlashCommandsAreCaseInsensitive(t *testing.T) {
4858 s := newSlashSuggester()
4959 if got := commandNames(s.matches("/EX")); !contains(got, "/exit") {
@@ -44,6 +44,16 @@ func TestSlashSuggesterHasSupervisor(t *testing.T) {
44 }44 }
45 }45 }
46 46
47+func TestSlashSuggesterHasFastModeToggle(t *testing.T) {
48+ s := newSlashSuggester()
49+ if got := commandNames(s.matches("/fa")); !contains(got, "/fast") {
50+ t.Fatalf("/fast missing from suggestions, got %v", got)
51+ }
52+ if !isKnownSlashCommand("/FAST") {
53+ t.Fatal("/FAST was not recognized as a built-in command")
54+ }
55+}
56+
47 func TestSlashCommandsAreCaseInsensitive(t *testing.T) {57 func TestSlashCommandsAreCaseInsensitive(t *testing.T) {
48 s := newSlashSuggester()58 s := newSlashSuggester()
49 if got := commandNames(s.matches("/EX")); !contains(got, "/exit") {59 if got := commandNames(s.matches("/EX")); !contains(got, "/exit") {
M packages/agent/modes/subagents_dialog.go +16 -0
@@ -49,6 +49,9 @@ type subagentsDialog struct {
4949 // session. Wired by Open(); when nil the inline 'R' shortcut is
5050 // disabled.
5151 resume func(id string) error
52+ // loadTranscript hydrates a detached agent's durable event history when
53+ // its transcript view is opened. Nil preserves snapshot-only behavior.
54+ loadTranscript func(id string) error
5255
5356 rows []subagents.AgentSnapshot
5457 cursor int
@@ -123,6 +126,11 @@ func (d *subagentsDialog) SetLineInput(enabled bool) {
123126 d.lineInput = enabled
124127 }
125128
129+// SetLoadTranscript supplies the optional on-demand durable transcript loader.
130+func (d *subagentsDialog) SetLoadTranscript(fn func(id string) error) {
131+ d.loadTranscript = fn
132+}
133+
126134 func (d *subagentsDialog) editorPrompt(th tui.Theme) string {
127135 if d.lineInput {
128136 return ""
@@ -362,6 +370,9 @@ func (d *subagentsDialog) OpenViewing(
362370 rows := d.snapshot()
363371 for i, r := range rows {
364372 if r.ID == agentID || strings.HasPrefix(r.ID, agentID) {
373+ if d.loadTranscript != nil {
374+ _ = d.loadTranscript(r.ID)
375+ }
365376 d.cursor = i
366377 d.viewing = true
367378 // Auto-open the inline editor for running agents so
@@ -514,6 +525,11 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string)
514525 }
515526 case tui.KeyEnter:
516527 if len(d.rows) > 0 {
528+ if a := d.selected(); a != nil && d.loadTranscript != nil {
529+ if err := d.loadTranscript(a.ID); err != nil {
530+ return false, "", "load transcript: " + err.Error()
531+ }
532+ }
517533 d.viewing = true
518534 // Auto-open the inline editor (à la /btw) so the user
519535 // can start typing immediately. Skipped for non-running
@@ -49,6 +49,9 @@ type subagentsDialog struct {
49 // session. Wired by Open(); when nil the inline 'R' shortcut is49 // session. Wired by Open(); when nil the inline 'R' shortcut is
50 // disabled.50 // disabled.
51 resume func(id string) error51 resume func(id string) error
52+ // loadTranscript hydrates a detached agent's durable event history when
53+ // its transcript view is opened. Nil preserves snapshot-only behavior.
54+ loadTranscript func(id string) error
52 55
53 rows []subagents.AgentSnapshot56 rows []subagents.AgentSnapshot
54 cursor int57 cursor int
@@ -123,6 +126,11 @@ func (d *subagentsDialog) SetLineInput(enabled bool) {
123 d.lineInput = enabled126 d.lineInput = enabled
124 }127 }
125 128
129+// SetLoadTranscript supplies the optional on-demand durable transcript loader.
130+func (d *subagentsDialog) SetLoadTranscript(fn func(id string) error) {
131+ d.loadTranscript = fn
132+}
133+
126 func (d *subagentsDialog) editorPrompt(th tui.Theme) string {134 func (d *subagentsDialog) editorPrompt(th tui.Theme) string {
127 if d.lineInput {135 if d.lineInput {
128 return ""136 return ""
@@ -362,6 +370,9 @@ func (d *subagentsDialog) OpenViewing(
362 rows := d.snapshot()370 rows := d.snapshot()
363 for i, r := range rows {371 for i, r := range rows {
364 if r.ID == agentID || strings.HasPrefix(r.ID, agentID) {372 if r.ID == agentID || strings.HasPrefix(r.ID, agentID) {
373+ if d.loadTranscript != nil {
374+ _ = d.loadTranscript(r.ID)
375+ }
365 d.cursor = i376 d.cursor = i
366 d.viewing = true377 d.viewing = true
367 // Auto-open the inline editor for running agents so378 // Auto-open the inline editor for running agents so
@@ -514,6 +525,11 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string)
514 }525 }
515 case tui.KeyEnter:526 case tui.KeyEnter:
516 if len(d.rows) > 0 {527 if len(d.rows) > 0 {
528+ if a := d.selected(); a != nil && d.loadTranscript != nil {
529+ if err := d.loadTranscript(a.ID); err != nil {
530+ return false, "", "load transcript: " + err.Error()
531+ }
532+ }
517 d.viewing = true533 d.viewing = true
518 // Auto-open the inline editor (à la /btw) so the user534 // Auto-open the inline editor (à la /btw) so the user
519 // can start typing immediately. Skipped for non-running535 // can start typing immediately. Skipped for non-running
M packages/agent/modes/subagents_dialog_test.go +31 -0
@@ -113,6 +113,37 @@ func TestSubagentsDialogEnterShowsTranscript(t *testing.T) {
113113 }
114114 }
115115
116+func TestSubagentsDialogEnterHydratesTranscript(t *testing.T) {
117+ now := time.Now()
118+ hydrated := false
119+ loads := 0
120+ snapshot := func() []subagents.AgentSnapshot {
121+ row := subagents.AgentSnapshot{ID: "alpha-1", Task: "do stuff", Status: subagents.StatusDone, Started: now}
122+ if hydrated {
123+ row.Lines = []string{"loaded history"}
124+ }
125+ return []subagents.AgentSnapshot{row}
126+ }
127+ d := newSubagentsDialog()
128+ d.SetLoadTranscript(func(id string) error {
129+ if id != "alpha-1" {
130+ t.Fatalf("load id = %q", id)
131+ }
132+ loads++
133+ hydrated = true
134+ return nil
135+ })
136+ d.Open(snapshot, nil, nil, nil, nil, nil, "")
137+ _ = d.Render(tui.Theme{}, 80)
138+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
139+ if loads != 1 {
140+ t.Fatalf("transcript loads = %d, want 1", loads)
141+ }
142+ if out := strings.Join(d.Render(tui.Theme{}, 80), "\n"); !strings.Contains(out, "loaded history") {
143+ t.Fatalf("hydrated transcript missing from view:\n%s", out)
144+ }
145+}
146+
116147 func TestSubagentsDialogEscClosesList(t *testing.T) {
117148 d := newSubagentsDialog()
118149 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
@@ -113,6 +113,37 @@ func TestSubagentsDialogEnterShowsTranscript(t *testing.T) {
113 }113 }
114 }114 }
115 115
116+func TestSubagentsDialogEnterHydratesTranscript(t *testing.T) {
117+ now := time.Now()
118+ hydrated := false
119+ loads := 0
120+ snapshot := func() []subagents.AgentSnapshot {
121+ row := subagents.AgentSnapshot{ID: "alpha-1", Task: "do stuff", Status: subagents.StatusDone, Started: now}
122+ if hydrated {
123+ row.Lines = []string{"loaded history"}
124+ }
125+ return []subagents.AgentSnapshot{row}
126+ }
127+ d := newSubagentsDialog()
128+ d.SetLoadTranscript(func(id string) error {
129+ if id != "alpha-1" {
130+ t.Fatalf("load id = %q", id)
131+ }
132+ loads++
133+ hydrated = true
134+ return nil
135+ })
136+ d.Open(snapshot, nil, nil, nil, nil, nil, "")
137+ _ = d.Render(tui.Theme{}, 80)
138+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
139+ if loads != 1 {
140+ t.Fatalf("transcript loads = %d, want 1", loads)
141+ }
142+ if out := strings.Join(d.Render(tui.Theme{}, 80), "\n"); !strings.Contains(out, "loaded history") {
143+ t.Fatalf("hydrated transcript missing from view:\n%s", out)
144+ }
145+}
146+
116 func TestSubagentsDialogEscClosesList(t *testing.T) {147 func TestSubagentsDialogEscClosesList(t *testing.T) {
117 d := newSubagentsDialog()148 d := newSubagentsDialog()
118 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")149 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
M packages/agent/modes/subagents_slash.go +5 -0
@@ -77,6 +77,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
7777 // editor, by typing /model on its own line to pop the picker.
7878 i.subagentsDialog.SetCompactMode(i.compactModeEnabled())
7979 i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
80+ i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript)
8081 i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
8182 if i.cfg.LoggedInProviders != nil {
8283 i.subagentsDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
@@ -205,6 +206,10 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
205206 i.subagentsStatus("", "/subagents logs <id>: missing id")
206207 return
207208 }
209+ if err := i.cfg.Supervisor.LoadTranscript(rest); err != nil {
210+ i.subagentsStatus("", "logs: "+err.Error())
211+ return
212+ }
208213 ok := i.subagentsDialog.OpenViewing(
209214 rest,
210215 i.cfg.Supervisor.SnapshotAll,
@@ -77,6 +77,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
77 // editor, by typing /model on its own line to pop the picker.77 // editor, by typing /model on its own line to pop the picker.
78 i.subagentsDialog.SetCompactMode(i.compactModeEnabled())78 i.subagentsDialog.SetCompactMode(i.compactModeEnabled())
79 i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)79 i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
80+ i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript)
80 i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)81 i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
81 if i.cfg.LoggedInProviders != nil {82 if i.cfg.LoggedInProviders != nil {
82 i.subagentsDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())83 i.subagentsDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
@@ -205,6 +206,10 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
205 i.subagentsStatus("", "/subagents logs <id>: missing id")206 i.subagentsStatus("", "/subagents logs <id>: missing id")
206 return207 return
207 }208 }
209+ if err := i.cfg.Supervisor.LoadTranscript(rest); err != nil {
210+ i.subagentsStatus("", "logs: "+err.Error())
211+ return
212+ }
208 ok := i.subagentsDialog.OpenViewing(213 ok := i.subagentsDialog.OpenViewing(
209 rest,214 rest,
210 i.cfg.Supervisor.SnapshotAll,215 i.cfg.Supervisor.SnapshotAll,
M packages/agent/sdk/sdk.go +3 -2
@@ -77,8 +77,9 @@ type Config struct {
7777 BaseURL string
7878
7979 // Tools is the list of tools to enable. Nil/empty = all ordinary built-in
80- // tools (read, write, edit, bash, lsp). web_search is excluded unless this
81- // list explicitly contains "web_search". Pass an empty-but-non-nil slice
80+ // tools (read, write, edit, bash, create_worktree, lsp). web_search is
81+ // excluded unless this list explicitly contains "web_search". Pass an
82+ // empty-but-non-nil slice
8283 // (e.g. []string{}) plus NoTools=true to disable everything.
8384 Tools []string
8485
@@ -77,8 +77,9 @@ type Config struct {
77 BaseURL string77 BaseURL string
78 78
79 // Tools is the list of tools to enable. Nil/empty = all ordinary built-in79 // Tools is the list of tools to enable. Nil/empty = all ordinary built-in
80- // tools (read, write, edit, bash, lsp). web_search is excluded unless this80+ // tools (read, write, edit, bash, create_worktree, lsp). web_search is
81- // list explicitly contains "web_search". Pass an empty-but-non-nil slice81+ // excluded unless this list explicitly contains "web_search". Pass an
82+ // empty-but-non-nil slice
82 // (e.g. []string{}) plus NoTools=true to disable everything.83 // (e.g. []string{}) plus NoTools=true to disable everything.
83 Tools []string84 Tools []string
84 85
M packages/agent/skills/builtin/write-zut-extension/SKILL.md +1 -1
@@ -29,7 +29,7 @@ Three things an extension can do (any combination):
2929 2. **Tools** — register tools the LLM itself calls. Schema is
3030 JSON Schema; zut routes the model's `tool_call` to the
3131 extension's `tool_result`. Same lifecycle as built-in tools
32- (read/write/edit/bash/lsp/web_search/skill).
32+ (read/write/edit/bash/create_worktree/lsp/web_search/skill).
3333
3434 3. **Lifecycle hooks** — subscribe to events
3535 (session_start, turn_start, tool_call, turn_end,
@@ -29,7 +29,7 @@ Three things an extension can do (any combination):
29 2. **Tools** — register tools the LLM itself calls. Schema is29 2. **Tools** — register tools the LLM itself calls. Schema is
30 JSON Schema; zut routes the model's `tool_call` to the30 JSON Schema; zut routes the model's `tool_call` to the
31 extension's `tool_result`. Same lifecycle as built-in tools31 extension's `tool_result`. Same lifecycle as built-in tools
32- (read/write/edit/bash/lsp/web_search/skill).32+ (read/write/edit/bash/create_worktree/lsp/web_search/skill).
33 33
34 3. **Lifecycle hooks** — subscribe to events34 3. **Lifecycle hooks** — subscribe to events
35 (session_start, turn_start, tool_call, turn_end,35 (session_start, turn_start, tool_call, turn_end,
M packages/agent/subagents/agent.go +25 -8
@@ -58,6 +58,12 @@ type Agent struct {
5858 FastMode bool
5959 Subagent string
6060
61+ // fastModeOverridesHost records the spawn-time case where an explicit
62+ // profile fastMode: true enabled the child while the host was off. It is
63+ // intentionally transient: the parent only needs it to report the initial
64+ // spawn warning, while the effective FastMode remains durable state.
65+ fastModeOverridesHost bool
66+
6167 // SessionID, when non-empty, scopes the agent to a particular
6268 // host zut session: the dashboard only surfaces agents whose
6369 // SessionID matches the active session. Empty means "unscoped" and
@@ -105,13 +111,16 @@ type Agent struct {
105111 // Supervisor.Spawn so callers do not need to manage the socket directly.
106112 inbox *Inbox
107113
108- mu sync.Mutex
109- status Status
110- activity string
111- transcript []string
112- lastAssistant string
113- finished time.Time
114- lastErr error
114+ mu sync.Mutex
115+ status Status
116+ activity string
117+ transcript []string
118+ lastAssistant string
119+ finished time.Time
120+ lastErr error
121+ transcriptLoaded bool
122+ legacyEventState bool
123+ transcriptMu sync.Mutex
115124
116125 lifecycleMu sync.Mutex
117126 processState ProcessState
@@ -168,6 +177,12 @@ type Agent struct {
168177 // doesn't speak the daemon protocol).
169178 func (a *Agent) Inbox() *Inbox { return a.inbox }
170179
180+// FastModeOverridesHost reports whether this child was created with an
181+// explicit fast-mode override while the host setting was disabled.
182+func (a *Agent) FastModeOverridesHost() bool {
183+ return a != nil && a.fastModeOverridesHost
184+}
185+
171186 // Status returns the current high-level status. Cheap; safe from any goroutine.
172187 func (a *Agent) Status() Status {
173188 a.mu.Lock()
@@ -299,8 +314,10 @@ func (a *Agent) setResult(result *TurnResult) {
299314 a.lifecycleMu.Unlock()
300315 }
301316
302-// Transcript returns a copy of the running transcript.
317+// Transcript returns a copy of the running transcript. Detached agents
318+// hydrate their durable event history the first time it is requested.
303319 func (a *Agent) Transcript() []string {
320+ _ = a.loadTranscript()
304321 a.mu.Lock()
305322 defer a.mu.Unlock()
306323 out := make([]string, len(a.transcript))
@@ -58,6 +58,12 @@ type Agent struct {
58 FastMode bool58 FastMode bool
59 Subagent string59 Subagent string
60 60
61+ // fastModeOverridesHost records the spawn-time case where an explicit
62+ // profile fastMode: true enabled the child while the host was off. It is
63+ // intentionally transient: the parent only needs it to report the initial
64+ // spawn warning, while the effective FastMode remains durable state.
65+ fastModeOverridesHost bool
66+
61 // SessionID, when non-empty, scopes the agent to a particular67 // SessionID, when non-empty, scopes the agent to a particular
62 // host zut session: the dashboard only surfaces agents whose68 // host zut session: the dashboard only surfaces agents whose
63 // SessionID matches the active session. Empty means "unscoped" and69 // SessionID matches the active session. Empty means "unscoped" and
@@ -105,13 +111,16 @@ type Agent struct {
105 // Supervisor.Spawn so callers do not need to manage the socket directly.111 // Supervisor.Spawn so callers do not need to manage the socket directly.
106 inbox *Inbox112 inbox *Inbox
107 113
108- mu sync.Mutex114+ mu sync.Mutex
109- status Status115+ status Status
110- activity string116+ activity string
111- transcript []string117+ transcript []string
112- lastAssistant string118+ lastAssistant string
113- finished time.Time119+ finished time.Time
114- lastErr error120+ lastErr error
121+ transcriptLoaded bool
122+ legacyEventState bool
123+ transcriptMu sync.Mutex
115 124
116 lifecycleMu sync.Mutex125 lifecycleMu sync.Mutex
117 processState ProcessState126 processState ProcessState
@@ -168,6 +177,12 @@ type Agent struct {
168 // doesn't speak the daemon protocol).177 // doesn't speak the daemon protocol).
169 func (a *Agent) Inbox() *Inbox { return a.inbox }178 func (a *Agent) Inbox() *Inbox { return a.inbox }
170 179
180+// FastModeOverridesHost reports whether this child was created with an
181+// explicit fast-mode override while the host setting was disabled.
182+func (a *Agent) FastModeOverridesHost() bool {
183+ return a != nil && a.fastModeOverridesHost
184+}
185+
171 // Status returns the current high-level status. Cheap; safe from any goroutine.186 // Status returns the current high-level status. Cheap; safe from any goroutine.
172 func (a *Agent) Status() Status {187 func (a *Agent) Status() Status {
173 a.mu.Lock()188 a.mu.Lock()
@@ -299,8 +314,10 @@ func (a *Agent) setResult(result *TurnResult) {
299 a.lifecycleMu.Unlock()314 a.lifecycleMu.Unlock()
300 }315 }
301 316
302-// Transcript returns a copy of the running transcript.317+// Transcript returns a copy of the running transcript. Detached agents
318+// hydrate their durable event history the first time it is requested.
303 func (a *Agent) Transcript() []string {319 func (a *Agent) Transcript() []string {
320+ _ = a.loadTranscript()
304 a.mu.Lock()321 a.mu.Lock()
305 defer a.mu.Unlock()322 defer a.mu.Unlock()
306 out := make([]string, len(a.transcript))323 out := make([]string, len(a.transcript))
M packages/agent/subagents/event.go +5 -9
@@ -8,6 +8,7 @@ import (
88 "io"
99 "os"
1010 "path/filepath"
11+ "reflect"
1112 "sync"
1213 "time"
1314 )
@@ -343,16 +344,11 @@ func isLikelyDoubleWrite(ev, previous Event) bool {
343344 return sameEventData(previous.Data, ev.Data)
344345 }
345346
346-// sameEventData deep-compares two event payloads. Cheap because the
347-// payloads are small map[string]any trees built from JSON, and only
348-// called for adjacent same-type pairs.
347+// sameEventData deep-compares two event payloads without re-encoding them.
348+// Reload invokes this for adjacent same-type events across every persisted
349+// subagent log, so serializing large streamed payloads here delays startup.
349350 func sameEventData(a, b map[string]any) bool {
350- if len(a) != len(b) {
351- return false
352- }
353- ab, _ := json.Marshal(a)
354- bb, _ := json.Marshal(b)
355- return string(ab) == string(bb)
351+ return len(a) == len(b) && reflect.DeepEqual(a, b)
356352 }
357353
358354 // EventFollower polls an events.jsonl file and emits new events as
@@ -8,6 +8,7 @@ import (
8 "io"8 "io"
9 "os"9 "os"
10 "path/filepath"10 "path/filepath"
11+ "reflect"
11 "sync"12 "sync"
12 "time"13 "time"
13 )14 )
@@ -343,16 +344,11 @@ func isLikelyDoubleWrite(ev, previous Event) bool {
343 return sameEventData(previous.Data, ev.Data)344 return sameEventData(previous.Data, ev.Data)
344 }345 }
345 346
346-// sameEventData deep-compares two event payloads. Cheap because the347+// sameEventData deep-compares two event payloads without re-encoding them.
347-// payloads are small map[string]any trees built from JSON, and only348+// Reload invokes this for adjacent same-type events across every persisted
348-// called for adjacent same-type pairs.349+// subagent log, so serializing large streamed payloads here delays startup.
349 func sameEventData(a, b map[string]any) bool {350 func sameEventData(a, b map[string]any) bool {
350- if len(a) != len(b) {351+ return len(a) == len(b) && reflect.DeepEqual(a, b)
351- return false
352- }
353- ab, _ := json.Marshal(a)
354- bb, _ := json.Marshal(b)
355- return string(ab) == string(bb)
356 }352 }
357 353
358 // EventFollower polls an events.jsonl file and emits new events as354 // EventFollower polls an events.jsonl file and emits new events as
M packages/agent/subagents/event_test.go +8 -0
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "encoding/json"
66 "fmt"
7+ "math"
78 "os"
89 "path/filepath"
910 "strings"
@@ -247,6 +248,13 @@ func TestReadEventLogDeduplicatesDoubleWrites(t *testing.T) {
247248 // by more than the dedup window (250ms) must both survive. The
248249 // agent legitimately can emit identical adjacent events (think: a
249250 // retry running the same tool seconds apart).
251+func TestSameEventDataDoesNotConflateFailedEncodings(t *testing.T) {
252+ payload := math.NaN()
253+ if sameEventData(map[string]any{"value": payload}, map[string]any{"value": payload}) {
254+ t.Fatal("payloads with non-equal values must not be treated as equal when encoding fails")
255+ }
256+}
257+
250258 func TestReadEventLogKeepsLegitimateAdjacentEvents(t *testing.T) {
251259 path := filepath.Join(t.TempDir(), "events.jsonl")
252260 log, err := OpenEventLog(path)
@@ -4,6 +4,7 @@ import (
4 "bytes"4 "bytes"
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7+ "math"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
9 "strings"10 "strings"
@@ -247,6 +248,13 @@ func TestReadEventLogDeduplicatesDoubleWrites(t *testing.T) {
247 // by more than the dedup window (250ms) must both survive. The248 // by more than the dedup window (250ms) must both survive. The
248 // agent legitimately can emit identical adjacent events (think: a249 // agent legitimately can emit identical adjacent events (think: a
249 // retry running the same tool seconds apart).250 // retry running the same tool seconds apart).
251+func TestSameEventDataDoesNotConflateFailedEncodings(t *testing.T) {
252+ payload := math.NaN()
253+ if sameEventData(map[string]any{"value": payload}, map[string]any{"value": payload}) {
254+ t.Fatal("payloads with non-equal values must not be treated as equal when encoding fails")
255+ }
256+}
257+
250 func TestReadEventLogKeepsLegitimateAdjacentEvents(t *testing.T) {258 func TestReadEventLogKeepsLegitimateAdjacentEvents(t *testing.T) {
251 path := filepath.Join(t.TempDir(), "events.jsonl")259 path := filepath.Join(t.TempDir(), "events.jsonl")
252 log, err := OpenEventLog(path)260 log, err := OpenEventLog(path)
M packages/agent/subagents/fast_mode_test.go +10 -6
@@ -58,18 +58,19 @@ func TestSupervisorFastModePropagatesToChild(t *testing.T) {
5858 }
5959 }
6060
61-func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) {
61+func TestSpawnRequestFastModeProfileOverridesHostSetting(t *testing.T) {
6262 falseValue, trueValue := false, true
6363 cases := []struct {
64- name string
65- hostFast bool
66- profileFast *bool
67- want bool
64+ name string
65+ hostFast bool
66+ profileFast *bool
67+ want bool
68+ wantHostOverride bool
6869 }{
6970 {name: "unset inherits enabled host", hostFast: true, want: true},
7071 {name: "unset inherits disabled host", hostFast: false, want: false},
7172 {name: "false disables enabled host", hostFast: true, profileFast: &falseValue, want: false},
72- {name: "true cannot enable disabled host", hostFast: false, profileFast: &trueValue, want: false},
73+ {name: "true enables disabled host", hostFast: false, profileFast: &trueValue, want: true, wantHostOverride: true},
7374 {name: "true preserves enabled host", hostFast: true, profileFast: &trueValue, want: true},
7475 }
7576 for _, tc := range cases {
@@ -91,6 +92,9 @@ func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) {
9192 if a.FastMode != tc.want {
9293 t.Fatalf("FastMode = %v, want %v", a.FastMode, tc.want)
9394 }
95+ if a.FastModeOverridesHost() != tc.wantHostOverride {
96+ t.Fatalf("FastModeOverridesHost() = %v, want %v", a.FastModeOverridesHost(), tc.wantHostOverride)
97+ }
9498 args := defaultChildArgs("/zut", a, "/session", "/inbox")
9599 wantFlag := "--no-fast-mode"
96100 if tc.want {
@@ -58,18 +58,19 @@ func TestSupervisorFastModePropagatesToChild(t *testing.T) {
58 }58 }
59 }59 }
60 60
61-func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) {61+func TestSpawnRequestFastModeProfileOverridesHostSetting(t *testing.T) {
62 falseValue, trueValue := false, true62 falseValue, trueValue := false, true
63 cases := []struct {63 cases := []struct {
64- name string64+ name string
65- hostFast bool65+ hostFast bool
66- profileFast *bool66+ profileFast *bool
67- want bool67+ want bool
68+ wantHostOverride bool
68 }{69 }{
69 {name: "unset inherits enabled host", hostFast: true, want: true},70 {name: "unset inherits enabled host", hostFast: true, want: true},
70 {name: "unset inherits disabled host", hostFast: false, want: false},71 {name: "unset inherits disabled host", hostFast: false, want: false},
71 {name: "false disables enabled host", hostFast: true, profileFast: &falseValue, want: false},72 {name: "false disables enabled host", hostFast: true, profileFast: &falseValue, want: false},
72- {name: "true cannot enable disabled host", hostFast: false, profileFast: &trueValue, want: false},73+ {name: "true enables disabled host", hostFast: false, profileFast: &trueValue, want: true, wantHostOverride: true},
73 {name: "true preserves enabled host", hostFast: true, profileFast: &trueValue, want: true},74 {name: "true preserves enabled host", hostFast: true, profileFast: &trueValue, want: true},
74 }75 }
75 for _, tc := range cases {76 for _, tc := range cases {
@@ -91,6 +92,9 @@ func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) {
91 if a.FastMode != tc.want {92 if a.FastMode != tc.want {
92 t.Fatalf("FastMode = %v, want %v", a.FastMode, tc.want)93 t.Fatalf("FastMode = %v, want %v", a.FastMode, tc.want)
93 }94 }
95+ if a.FastModeOverridesHost() != tc.wantHostOverride {
96+ t.Fatalf("FastModeOverridesHost() = %v, want %v", a.FastModeOverridesHost(), tc.wantHostOverride)
97+ }
94 args := defaultChildArgs("/zut", a, "/session", "/inbox")98 args := defaultChildArgs("/zut", a, "/session", "/inbox")
95 wantFlag := "--no-fast-mode"99 wantFlag := "--no-fast-mode"
96 if tc.want {100 if tc.want {
M packages/agent/subagents/persist.go +186 -59
@@ -3,19 +3,14 @@ package subagents
33 // On-disk persistence for subagent agents.
44 //
55 // Every Spawn writes a meta.json next to the agent's events.jsonl and
6-// session.json. The file captures the immutable identity bits (id,
7-// task, branch, dir) plus the paths the runner needs to resume the
8-// agent later. On a fresh zut launch, Supervisor.Reload() walks
9-// <root>/agents/*/meta.json and re-registers every agent it finds in
10-// StatusDetached so the user can see, view, resume, or remove them
11-// from the dashboard.
6+// session.json. The file captures identity, resumable lifecycle state, and
7+// compact dashboard state. On a fresh zut launch, Supervisor.Reload() walks
8+// <root>/agents/*/meta.json and re-registers every agent without replaying
9+// its full history. The event log remains the durable transcript source and
10+// is hydrated only when a caller requests that agent's transcript.
1211 //
13-// We don't try to keep meta.json in sync with mutable state (status,
14-// activity, transcript). Those live in the events log (durable) and
15-// in-memory Agent fields (rebuilt by Reload from the log tail).
16-// Keeping meta.json immutable means we never have to worry about
17-// concurrent writers stomping on each other and the file matters
18-// only on the spawn/reload boundary.
12+// Metadata writes are atomic (tmp + rename). The supervisor is the sole
13+// writer, so its lifecycle snapshots cannot race with another host process.
1914
2015 import (
2116 "context"
@@ -62,6 +57,10 @@ type agentMeta struct {
6257 Timeout time.Duration `json:"timeout,omitempty"`
6358 Tools []string `json:"tools,omitempty"`
6459 WebSearchPolicy WebSearchPolicy `json:"web_search_policy,omitempty"`
60+ Status Status `json:"status,omitempty"`
61+ Activity string `json:"activity,omitempty"`
62+ Finished time.Time `json:"finished,omitempty"`
63+ Error string `json:"error,omitempty"`
6564 ProcessState ProcessState `json:"process_state,omitempty"`
6665 TurnState TurnState `json:"turn_state,omitempty"`
6766 CurrentTurnID string `json:"current_turn_id,omitempty"`
@@ -94,6 +93,15 @@ func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.jso
9493 func writeAgentMeta(stateDir string, a *Agent) error {
9594 fastMode := a.FastMode
9695 webSearchPolicy := childWebSearchPolicy(a.WebSearchPolicy, a.Subagent, a.Tools)
96+ a.mu.Lock()
97+ status := a.status
98+ activity := a.activity
99+ finished := a.finished
100+ errText := ""
101+ if a.lastErr != nil {
102+ errText = a.lastErr.Error()
103+ }
104+ a.mu.Unlock()
97105 a.lifecycleMu.Lock()
98106 processState := a.processState
99107 turnState := a.turnState
@@ -131,6 +139,10 @@ func writeAgentMeta(stateDir string, a *Agent) error {
131139 Timeout: a.Timeout,
132140 Tools: append([]string(nil), a.Tools...),
133141 WebSearchPolicy: webSearchPolicy,
142+ Status: status,
143+ Activity: activity,
144+ Finished: finished,
145+ Error: errText,
134146 ProcessState: processState,
135147 TurnState: turnState,
136148 CurrentTurnID: currentTurnID,
@@ -323,13 +335,12 @@ func (f *Supervisor) reloadRoot(root string) (loaded int, errs []error) {
323335 return loaded, errs
324336 }
325337
326-// buildDetachedAgent constructs an Agent from a meta.json with no
327-// running Runner. The agent's transcript is populated from the tail
328-// of its event log so the dashboard immediately shows recent output;
329-// activity is inferred from the last lifecycle event.
338+// buildDetachedAgent constructs an Agent from compact metadata with no running
339+// Runner. Its transcript stays unloaded until a caller requests it, while the
340+// persisted lifecycle summary keeps the dashboard immediately useful.
330341 //
331-// The returned Agent has a closed `done` channel because Wait should
332-// return instantly: there is nothing to wait for.
342+// The returned Agent has a closed `done` channel because Wait should return
343+// instantly: there is nothing to wait for.
333344 func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
334345 // Metadata without a repository identity uses the current RepoRoot;
335346 // records with one retain their persisted repository so a restart from
@@ -355,12 +366,42 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
355366 if lastActivity.IsZero() {
356367 lastActivity = m.Started
357368 }
358- processState := ProcessDetached
359- activity := "detached"
369+ persistedStatus := m.Status
370+ status := StatusDetached
371+ activity := strings.TrimSpace(m.Activity)
372+ processState := m.ProcessState
373+ legacyEventState := persistedStatus == ""
374+ if legacyEventState {
375+ activity = "detached"
376+ processState = ProcessDetached
377+ }
378+ if processState == "" {
379+ processState = ProcessDetached
380+ }
381+ if activity == "" {
382+ switch persistedStatus {
383+ case StatusDone:
384+ activity = "done"
385+ case StatusFailed:
386+ activity = "failed"
387+ case StatusKilled:
388+ activity = "cancelled"
389+ default:
390+ activity = "detached"
391+ }
392+ }
393+ if persistedStatus == StatusRunning || persistedStatus == StatusPending {
394+ activity = "detached (resume to continue)"
395+ processState = ProcessDetached
396+ }
360397 if inboxLive(m.InboxPath) {
361398 processState = ProcessAlive
362399 activity = "live (detached)"
363400 }
401+ var lastErr error
402+ if m.Error != "" {
403+ lastErr = errors.New(m.Error)
404+ }
364405 agentStateDir := f.agentStateDir(m.ID)
365406 if m.EventLogPath != "" {
366407 // EventLogPath is <state-dir>/events.jsonl; keep the reloaded
@@ -401,8 +442,11 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
401442 SessionPath: m.SessionPath,
402443 SessionID: m.SessionID,
403444 inbox: NewInbox(m.InboxPath),
404- status: StatusDetached,
445+ status: status,
405446 activity: activity,
447+ finished: m.Finished,
448+ lastErr: lastErr,
449+ legacyEventState: legacyEventState,
406450 processState: processState,
407451 turnState: turnState,
408452 currentTurnID: m.CurrentTurnID,
@@ -422,14 +466,6 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
422466 }
423467 a.closeDone()
424468
425- // Recover transcript + activity hints from the event log. Best
426- // effort: a missing or unreadable log just leaves the agent
427- // detached with an empty transcript.
428- if a.EventLogPath != "" {
429- if evs, err := ReadEventLog(a.EventLogPath); err == nil {
430- replayEventsIntoAgent(a, evs)
431- }
432- }
433469 // The append-only log may contain a terminal event from an earlier
434470 // attempt. A live socket is stronger evidence for the current process;
435471 // keep the reloaded entry detached/live so Stop and Remove cannot treat a
@@ -451,6 +487,80 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
451487 return a
452488 }
453489
490+// LoadTranscript hydrates one reloaded agent's durable event history. Reload
491+// deliberately skips this work so startup stays bounded by metadata size;
492+// callers request it only when opening the transcript or otherwise reading it.
493+func (f *Supervisor) LoadTranscript(id string) error {
494+ a := f.Get(id)
495+ if a == nil {
496+ return fmt.Errorf("subagent: no such agent %q", id)
497+ }
498+ return a.loadTranscript()
499+}
500+
501+func (a *Agent) loadTranscript() error {
502+ if a == nil {
503+ return nil
504+ }
505+ a.transcriptMu.Lock()
506+ defer a.transcriptMu.Unlock()
507+
508+ a.mu.Lock()
509+ if a.transcriptLoaded {
510+ a.mu.Unlock()
511+ return nil
512+ }
513+ path := a.EventLogPath
514+ legacyEventState := a.legacyEventState
515+ a.mu.Unlock()
516+ var summaryUpdatedAt, summaryLastActivity time.Time
517+ if !legacyEventState {
518+ a.lifecycleMu.Lock()
519+ summaryUpdatedAt = a.updatedAt
520+ summaryLastActivity = a.lastActivity
521+ a.lifecycleMu.Unlock()
522+ }
523+ if path == "" {
524+ a.mu.Lock()
525+ a.transcriptLoaded = true
526+ a.mu.Unlock()
527+ return nil
528+ }
529+
530+ events, err := ReadEventLog(path)
531+ if err != nil {
532+ return fmt.Errorf("read subagent transcript: %w", err)
533+ }
534+ a.mu.Lock()
535+ if a.transcriptLoaded {
536+ a.mu.Unlock()
537+ return nil
538+ }
539+ a.transcript = nil
540+ a.lastAssistant = ""
541+ a.outputBytes = 0
542+ a.outputLines = 0
543+ a.outputTruncated = false
544+ a.mu.Unlock()
545+
546+ if legacyEventState {
547+ // Metadata written before the dashboard summary existed has no
548+ // terminal status to trust. Preserve its historical event-derived
549+ // behavior once the user explicitly opens its transcript.
550+ replayEventsIntoAgent(a, events)
551+ } else {
552+ replayTranscriptIntoAgent(a, events)
553+ a.lifecycleMu.Lock()
554+ a.updatedAt = summaryUpdatedAt
555+ a.lastActivity = summaryLastActivity
556+ a.lifecycleMu.Unlock()
557+ }
558+ a.mu.Lock()
559+ a.transcriptLoaded = true
560+ a.mu.Unlock()
561+ return nil
562+}
563+
454564 // replayEventsIntoAgent re-derives an agent's transcript and last
455565 // known status hint from its event log. Mirrors applyEventToSink in
456566 // runner.go but writes directly to the Agent fields (no Sink because
@@ -491,36 +601,8 @@ func replayEventsIntoAgent(a *Agent, evs []Event) {
491601 a.status = StatusDone
492602 a.activity = "done (offline)"
493603 a.mu.Unlock()
494- case "assistant_message", "user_message":
495- var text []string
496- if c, ok := ev.Data["content"].([]any); ok {
497- for _, blk := range c {
498- m, _ := blk.(map[string]any)
499- if t, _ := m["type"].(string); t == "text" {
500- if txt, _ := m["text"].(string); txt != "" {
501- text = append(text, txt)
502- }
503- }
504- }
505- }
506- message := strings.Join(text, "\n")
507- if ev.Type == "assistant_message" {
508- a.appendAssistantMessage(message)
509- } else {
510- a.appendUserMessage(message)
511- }
512- case "stdout":
513- if txt, _ := ev.Data["text"].(string); txt != "" {
514- a.appendTranscript(txt)
515- }
516- case "stderr":
517- if txt, _ := ev.Data["text"].(string); txt != "" {
518- a.appendTranscript("stderr: " + txt)
519- }
520- case "error":
521- if msg, _ := ev.Data["message"].(string); msg != "" {
522- a.appendTranscript("error: " + msg)
523- }
604+ case "assistant_message", "user_message", "stdout", "stderr", "error":
605+ replayEventTranscript(a, ev)
524606 case "agent_stopped":
525607 terminal = true
526608 reason, _ := ev.Data["reason"].(string)
@@ -559,6 +641,50 @@ func replayEventsIntoAgent(a *Agent, evs []Event) {
559641 }
560642 }
561643
644+// replayTranscriptIntoAgent rebuilds only the visible transcript. Newer
645+// metadata already contains the authoritative dashboard lifecycle summary,
646+// so opening history must not overwrite it with an earlier event attempt.
647+func replayTranscriptIntoAgent(a *Agent, evs []Event) {
648+ for _, ev := range evs {
649+ replayEventTranscript(a, ev)
650+ }
651+}
652+
653+func replayEventTranscript(a *Agent, ev Event) {
654+ switch ev.Type {
655+ case "assistant_message", "user_message":
656+ var text []string
657+ if c, ok := ev.Data["content"].([]any); ok {
658+ for _, blk := range c {
659+ m, _ := blk.(map[string]any)
660+ if t, _ := m["type"].(string); t == "text" {
661+ if txt, _ := m["text"].(string); txt != "" {
662+ text = append(text, txt)
663+ }
664+ }
665+ }
666+ }
667+ message := strings.Join(text, "\n")
668+ if ev.Type == "assistant_message" {
669+ a.appendAssistantMessage(message)
670+ } else {
671+ a.appendUserMessage(message)
672+ }
673+ case "stdout":
674+ if txt, _ := ev.Data["text"].(string); txt != "" {
675+ a.appendTranscript(txt)
676+ }
677+ case "stderr":
678+ if txt, _ := ev.Data["text"].(string); txt != "" {
679+ a.appendTranscript("stderr: " + txt)
680+ }
681+ case "error":
682+ if msg, _ := ev.Data["message"].(string); msg != "" {
683+ a.appendTranscript("error: " + msg)
684+ }
685+ }
686+}
687+
562688 // Resume re-attaches a Runner to a previously-spawned agent. Durable
563689 // session and event files are kept, while the transient inbox path is
564690 // recalculated for the current runtime environment. Use this to
@@ -745,6 +871,7 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age
745871 inbox: NewInbox(m.InboxPath),
746872 status: StatusPending,
747873 activity: "resuming",
874+ transcriptLoaded: true,
748875 processState: ProcessPending,
749876 turnState: TurnQueued,
750877 updatedAt: now,
@@ -3,19 +3,14 @@ package subagents
3 // On-disk persistence for subagent agents.3 // On-disk persistence for subagent agents.
4 //4 //
5 // Every Spawn writes a meta.json next to the agent's events.jsonl and5 // Every Spawn writes a meta.json next to the agent's events.jsonl and
6-// session.json. The file captures the immutable identity bits (id,6+// session.json. The file captures identity, resumable lifecycle state, and
7-// task, branch, dir) plus the paths the runner needs to resume the7+// compact dashboard state. On a fresh zut launch, Supervisor.Reload() walks
8-// agent later. On a fresh zut launch, Supervisor.Reload() walks8+// <root>/agents/*/meta.json and re-registers every agent without replaying
9-// <root>/agents/*/meta.json and re-registers every agent it finds in9+// its full history. The event log remains the durable transcript source and
10-// StatusDetached so the user can see, view, resume, or remove them10+// is hydrated only when a caller requests that agent's transcript.
11-// from the dashboard.
12 //11 //
13-// We don't try to keep meta.json in sync with mutable state (status,12+// Metadata writes are atomic (tmp + rename). The supervisor is the sole
14-// activity, transcript). Those live in the events log (durable) and13+// writer, so its lifecycle snapshots cannot race with another host process.
15-// in-memory Agent fields (rebuilt by Reload from the log tail).
16-// Keeping meta.json immutable means we never have to worry about
17-// concurrent writers stomping on each other and the file matters
18-// only on the spawn/reload boundary.
19 14
20 import (15 import (
21 "context"16 "context"
@@ -62,6 +57,10 @@ type agentMeta struct {
62 Timeout time.Duration `json:"timeout,omitempty"`57 Timeout time.Duration `json:"timeout,omitempty"`
63 Tools []string `json:"tools,omitempty"`58 Tools []string `json:"tools,omitempty"`
64 WebSearchPolicy WebSearchPolicy `json:"web_search_policy,omitempty"`59 WebSearchPolicy WebSearchPolicy `json:"web_search_policy,omitempty"`
60+ Status Status `json:"status,omitempty"`
61+ Activity string `json:"activity,omitempty"`
62+ Finished time.Time `json:"finished,omitempty"`
63+ Error string `json:"error,omitempty"`
65 ProcessState ProcessState `json:"process_state,omitempty"`64 ProcessState ProcessState `json:"process_state,omitempty"`
66 TurnState TurnState `json:"turn_state,omitempty"`65 TurnState TurnState `json:"turn_state,omitempty"`
67 CurrentTurnID string `json:"current_turn_id,omitempty"`66 CurrentTurnID string `json:"current_turn_id,omitempty"`
@@ -94,6 +93,15 @@ func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.jso
94 func writeAgentMeta(stateDir string, a *Agent) error {93 func writeAgentMeta(stateDir string, a *Agent) error {
95 fastMode := a.FastMode94 fastMode := a.FastMode
96 webSearchPolicy := childWebSearchPolicy(a.WebSearchPolicy, a.Subagent, a.Tools)95 webSearchPolicy := childWebSearchPolicy(a.WebSearchPolicy, a.Subagent, a.Tools)
96+ a.mu.Lock()
97+ status := a.status
98+ activity := a.activity
99+ finished := a.finished
100+ errText := ""
101+ if a.lastErr != nil {
102+ errText = a.lastErr.Error()
103+ }
104+ a.mu.Unlock()
97 a.lifecycleMu.Lock()105 a.lifecycleMu.Lock()
98 processState := a.processState106 processState := a.processState
99 turnState := a.turnState107 turnState := a.turnState
@@ -131,6 +139,10 @@ func writeAgentMeta(stateDir string, a *Agent) error {
131 Timeout: a.Timeout,139 Timeout: a.Timeout,
132 Tools: append([]string(nil), a.Tools...),140 Tools: append([]string(nil), a.Tools...),
133 WebSearchPolicy: webSearchPolicy,141 WebSearchPolicy: webSearchPolicy,
142+ Status: status,
143+ Activity: activity,
144+ Finished: finished,
145+ Error: errText,
134 ProcessState: processState,146 ProcessState: processState,
135 TurnState: turnState,147 TurnState: turnState,
136 CurrentTurnID: currentTurnID,148 CurrentTurnID: currentTurnID,
@@ -323,13 +335,12 @@ func (f *Supervisor) reloadRoot(root string) (loaded int, errs []error) {
323 return loaded, errs335 return loaded, errs
324 }336 }
325 337
326-// buildDetachedAgent constructs an Agent from a meta.json with no338+// buildDetachedAgent constructs an Agent from compact metadata with no running
327-// running Runner. The agent's transcript is populated from the tail339+// Runner. Its transcript stays unloaded until a caller requests it, while the
328-// of its event log so the dashboard immediately shows recent output;340+// persisted lifecycle summary keeps the dashboard immediately useful.
329-// activity is inferred from the last lifecycle event.
330 //341 //
331-// The returned Agent has a closed `done` channel because Wait should342+// The returned Agent has a closed `done` channel because Wait should return
332-// return instantly: there is nothing to wait for.343+// instantly: there is nothing to wait for.
333 func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {344 func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
334 // Metadata without a repository identity uses the current RepoRoot;345 // Metadata without a repository identity uses the current RepoRoot;
335 // records with one retain their persisted repository so a restart from346 // records with one retain their persisted repository so a restart from
@@ -355,12 +366,42 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
355 if lastActivity.IsZero() {366 if lastActivity.IsZero() {
356 lastActivity = m.Started367 lastActivity = m.Started
357 }368 }
358- processState := ProcessDetached369+ persistedStatus := m.Status
359- activity := "detached"370+ status := StatusDetached
371+ activity := strings.TrimSpace(m.Activity)
372+ processState := m.ProcessState
373+ legacyEventState := persistedStatus == ""
374+ if legacyEventState {
375+ activity = "detached"
376+ processState = ProcessDetached
377+ }
378+ if processState == "" {
379+ processState = ProcessDetached
380+ }
381+ if activity == "" {
382+ switch persistedStatus {
383+ case StatusDone:
384+ activity = "done"
385+ case StatusFailed:
386+ activity = "failed"
387+ case StatusKilled:
388+ activity = "cancelled"
389+ default:
390+ activity = "detached"
391+ }
392+ }
393+ if persistedStatus == StatusRunning || persistedStatus == StatusPending {
394+ activity = "detached (resume to continue)"
395+ processState = ProcessDetached
396+ }
360 if inboxLive(m.InboxPath) {397 if inboxLive(m.InboxPath) {
361 processState = ProcessAlive398 processState = ProcessAlive
362 activity = "live (detached)"399 activity = "live (detached)"
363 }400 }
401+ var lastErr error
402+ if m.Error != "" {
403+ lastErr = errors.New(m.Error)
404+ }
364 agentStateDir := f.agentStateDir(m.ID)405 agentStateDir := f.agentStateDir(m.ID)
365 if m.EventLogPath != "" {406 if m.EventLogPath != "" {
366 // EventLogPath is <state-dir>/events.jsonl; keep the reloaded407 // EventLogPath is <state-dir>/events.jsonl; keep the reloaded
@@ -401,8 +442,11 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
401 SessionPath: m.SessionPath,442 SessionPath: m.SessionPath,
402 SessionID: m.SessionID,443 SessionID: m.SessionID,
403 inbox: NewInbox(m.InboxPath),444 inbox: NewInbox(m.InboxPath),
404- status: StatusDetached,445+ status: status,
405 activity: activity,446 activity: activity,
447+ finished: m.Finished,
448+ lastErr: lastErr,
449+ legacyEventState: legacyEventState,
406 processState: processState,450 processState: processState,
407 turnState: turnState,451 turnState: turnState,
408 currentTurnID: m.CurrentTurnID,452 currentTurnID: m.CurrentTurnID,
@@ -422,14 +466,6 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
422 }466 }
423 a.closeDone()467 a.closeDone()
424 468
425- // Recover transcript + activity hints from the event log. Best
426- // effort: a missing or unreadable log just leaves the agent
427- // detached with an empty transcript.
428- if a.EventLogPath != "" {
429- if evs, err := ReadEventLog(a.EventLogPath); err == nil {
430- replayEventsIntoAgent(a, evs)
431- }
432- }
433 // The append-only log may contain a terminal event from an earlier469 // The append-only log may contain a terminal event from an earlier
434 // attempt. A live socket is stronger evidence for the current process;470 // attempt. A live socket is stronger evidence for the current process;
435 // keep the reloaded entry detached/live so Stop and Remove cannot treat a471 // keep the reloaded entry detached/live so Stop and Remove cannot treat a
@@ -451,6 +487,80 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
451 return a487 return a
452 }488 }
453 489
490+// LoadTranscript hydrates one reloaded agent's durable event history. Reload
491+// deliberately skips this work so startup stays bounded by metadata size;
492+// callers request it only when opening the transcript or otherwise reading it.
493+func (f *Supervisor) LoadTranscript(id string) error {
494+ a := f.Get(id)
495+ if a == nil {
496+ return fmt.Errorf("subagent: no such agent %q", id)
497+ }
498+ return a.loadTranscript()
499+}
500+
501+func (a *Agent) loadTranscript() error {
502+ if a == nil {
503+ return nil
504+ }
505+ a.transcriptMu.Lock()
506+ defer a.transcriptMu.Unlock()
507+
508+ a.mu.Lock()
509+ if a.transcriptLoaded {
510+ a.mu.Unlock()
511+ return nil
512+ }
513+ path := a.EventLogPath
514+ legacyEventState := a.legacyEventState
515+ a.mu.Unlock()
516+ var summaryUpdatedAt, summaryLastActivity time.Time
517+ if !legacyEventState {
518+ a.lifecycleMu.Lock()
519+ summaryUpdatedAt = a.updatedAt
520+ summaryLastActivity = a.lastActivity
521+ a.lifecycleMu.Unlock()
522+ }
523+ if path == "" {
524+ a.mu.Lock()
525+ a.transcriptLoaded = true
526+ a.mu.Unlock()
527+ return nil
528+ }
529+
530+ events, err := ReadEventLog(path)
531+ if err != nil {
532+ return fmt.Errorf("read subagent transcript: %w", err)
533+ }
534+ a.mu.Lock()
535+ if a.transcriptLoaded {
536+ a.mu.Unlock()
537+ return nil
538+ }
539+ a.transcript = nil
540+ a.lastAssistant = ""
541+ a.outputBytes = 0
542+ a.outputLines = 0
543+ a.outputTruncated = false
544+ a.mu.Unlock()
545+
546+ if legacyEventState {
547+ // Metadata written before the dashboard summary existed has no
548+ // terminal status to trust. Preserve its historical event-derived
549+ // behavior once the user explicitly opens its transcript.
550+ replayEventsIntoAgent(a, events)
551+ } else {
552+ replayTranscriptIntoAgent(a, events)
553+ a.lifecycleMu.Lock()
554+ a.updatedAt = summaryUpdatedAt
555+ a.lastActivity = summaryLastActivity
556+ a.lifecycleMu.Unlock()
557+ }
558+ a.mu.Lock()
559+ a.transcriptLoaded = true
560+ a.mu.Unlock()
561+ return nil
562+}
563+
454 // replayEventsIntoAgent re-derives an agent's transcript and last564 // replayEventsIntoAgent re-derives an agent's transcript and last
455 // known status hint from its event log. Mirrors applyEventToSink in565 // known status hint from its event log. Mirrors applyEventToSink in
456 // runner.go but writes directly to the Agent fields (no Sink because566 // runner.go but writes directly to the Agent fields (no Sink because
@@ -491,36 +601,8 @@ func replayEventsIntoAgent(a *Agent, evs []Event) {
491 a.status = StatusDone601 a.status = StatusDone
492 a.activity = "done (offline)"602 a.activity = "done (offline)"
493 a.mu.Unlock()603 a.mu.Unlock()
494- case "assistant_message", "user_message":604+ case "assistant_message", "user_message", "stdout", "stderr", "error":
495- var text []string605+ replayEventTranscript(a, ev)
496- if c, ok := ev.Data["content"].([]any); ok {
497- for _, blk := range c {
498- m, _ := blk.(map[string]any)
499- if t, _ := m["type"].(string); t == "text" {
500- if txt, _ := m["text"].(string); txt != "" {
501- text = append(text, txt)
502- }
503- }
504- }
505- }
506- message := strings.Join(text, "\n")
507- if ev.Type == "assistant_message" {
508- a.appendAssistantMessage(message)
509- } else {
510- a.appendUserMessage(message)
511- }
512- case "stdout":
513- if txt, _ := ev.Data["text"].(string); txt != "" {
514- a.appendTranscript(txt)
515- }
516- case "stderr":
517- if txt, _ := ev.Data["text"].(string); txt != "" {
518- a.appendTranscript("stderr: " + txt)
519- }
520- case "error":
521- if msg, _ := ev.Data["message"].(string); msg != "" {
522- a.appendTranscript("error: " + msg)
523- }
524 case "agent_stopped":606 case "agent_stopped":
525 terminal = true607 terminal = true
526 reason, _ := ev.Data["reason"].(string)608 reason, _ := ev.Data["reason"].(string)
@@ -559,6 +641,50 @@ func replayEventsIntoAgent(a *Agent, evs []Event) {
559 }641 }
560 }642 }
561 643
644+// replayTranscriptIntoAgent rebuilds only the visible transcript. Newer
645+// metadata already contains the authoritative dashboard lifecycle summary,
646+// so opening history must not overwrite it with an earlier event attempt.
647+func replayTranscriptIntoAgent(a *Agent, evs []Event) {
648+ for _, ev := range evs {
649+ replayEventTranscript(a, ev)
650+ }
651+}
652+
653+func replayEventTranscript(a *Agent, ev Event) {
654+ switch ev.Type {
655+ case "assistant_message", "user_message":
656+ var text []string
657+ if c, ok := ev.Data["content"].([]any); ok {
658+ for _, blk := range c {
659+ m, _ := blk.(map[string]any)
660+ if t, _ := m["type"].(string); t == "text" {
661+ if txt, _ := m["text"].(string); txt != "" {
662+ text = append(text, txt)
663+ }
664+ }
665+ }
666+ }
667+ message := strings.Join(text, "\n")
668+ if ev.Type == "assistant_message" {
669+ a.appendAssistantMessage(message)
670+ } else {
671+ a.appendUserMessage(message)
672+ }
673+ case "stdout":
674+ if txt, _ := ev.Data["text"].(string); txt != "" {
675+ a.appendTranscript(txt)
676+ }
677+ case "stderr":
678+ if txt, _ := ev.Data["text"].(string); txt != "" {
679+ a.appendTranscript("stderr: " + txt)
680+ }
681+ case "error":
682+ if msg, _ := ev.Data["message"].(string); msg != "" {
683+ a.appendTranscript("error: " + msg)
684+ }
685+ }
686+}
687+
562 // Resume re-attaches a Runner to a previously-spawned agent. Durable688 // Resume re-attaches a Runner to a previously-spawned agent. Durable
563 // session and event files are kept, while the transient inbox path is689 // session and event files are kept, while the transient inbox path is
564 // recalculated for the current runtime environment. Use this to690 // recalculated for the current runtime environment. Use this to
@@ -745,6 +871,7 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age
745 inbox: NewInbox(m.InboxPath),871 inbox: NewInbox(m.InboxPath),
746 status: StatusPending,872 status: StatusPending,
747 activity: "resuming",873 activity: "resuming",
874+ transcriptLoaded: true,
748 processState: ProcessPending,875 processState: ProcessPending,
749 turnState: TurnQueued,876 turnState: TurnQueued,
750 updatedAt: now,877 updatedAt: now,
M packages/agent/subagents/persist_test.go +81 -3
@@ -292,11 +292,15 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) {
292292 if a == nil {
293293 t.Fatal("reloaded agent not found")
294294 }
295- // Status should reflect the lifecycle terminator we wrote.
296- if a.Status() != StatusDone {
297- t.Errorf("status = %q; want done (offline)", a.Status())
295+ // Legacy metadata has no persisted dashboard status, so startup keeps the
296+ // agent detached until the user asks to inspect its durable history.
297+ if a.Status() != StatusDetached {
298+ t.Errorf("startup status = %q; want detached", a.Status())
298299 }
299300 got := a.Transcript()
301+ if a.Status() != StatusDone {
302+ t.Errorf("hydrated status = %q; want done (offline)", a.Status())
303+ }
300304 found := false
301305 for _, line := range got {
302306 if line == "hello from the past" {
@@ -308,6 +312,80 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) {
308312 }
309313 }
310314
315+// TestReloadUsesPersistedSummaryBeforeTranscriptHydration ensures startup
316+// rebuilds the dashboard from compact metadata rather than replaying the
317+// event log. Opening the transcript remains the explicit point that loads
318+// durable history.
319+func TestReloadUsesPersistedSummaryBeforeTranscriptHydration(t *testing.T) {
320+ root := t.TempDir()
321+ id := "summary-1"
322+ stateDir := filepath.Join(root, "agents", id)
323+ agent := &Agent{
324+ ID: id,
325+ Task: "persist summary",
326+ OriginalTask: "persist summary",
327+ Dir: root,
328+ Started: time.Now().Add(-time.Hour),
329+ EventLogPath: filepath.Join(stateDir, "events.jsonl"),
330+ SessionPath: filepath.Join(stateDir, "session.json"),
331+ status: StatusDone,
332+ activity: "done",
333+ finished: time.Now().Add(-time.Minute),
334+ processState: ProcessExited,
335+ turnState: TurnSucceeded,
336+ currentTurnID: "turn-1",
337+ done: make(chan struct{}),
338+ }
339+ if err := writeAgentMeta(stateDir, agent); err != nil {
340+ t.Fatal(err)
341+ }
342+
343+ log, err := OpenEventLog(agent.EventLogPath)
344+ if err != nil {
345+ t.Fatal(err)
346+ }
347+ if err := log.Append(NewEvent("assistant_message", map[string]any{
348+ "content": []any{map[string]any{"type": "text", "text": "historical reply"}},
349+ })); err != nil {
350+ t.Fatal(err)
351+ }
352+ if err := log.Append(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"})); err != nil {
353+ t.Fatal(err)
354+ }
355+ if err := log.Close(); err != nil {
356+ t.Fatal(err)
357+ }
358+
359+ supervisor := New(Config{Root: root, RepoRoot: root})
360+ if loaded, errs := supervisor.Reload(); loaded != 1 || len(errs) != 0 {
361+ t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
362+ }
363+ reloaded := supervisor.Get(id)
364+ if reloaded == nil {
365+ t.Fatal("reloaded agent missing")
366+ }
367+ before := reloaded.Snapshot()
368+ if before.Status != StatusDetached || before.Activity != "done" || before.ProcessState != ProcessExited || before.TurnState != TurnSucceeded {
369+ t.Fatalf("startup summary = (%q, %q, %q, %q), want (detached, done, exited, succeeded)", before.Status, before.Activity, before.ProcessState, before.TurnState)
370+ }
371+ if len(before.Lines) != 0 {
372+ t.Fatalf("startup loaded transcript lines: %v", before.Lines)
373+ }
374+ lastActivity := before.LastActivity
375+
376+ lines := reloaded.Transcript()
377+ if len(lines) != 1 || lines[0] != "historical reply" {
378+ t.Fatalf("hydrated transcript = %v", lines)
379+ }
380+ after := reloaded.Snapshot()
381+ if after.Status != StatusDetached || after.Activity != "done" {
382+ t.Fatalf("hydration overwrote persisted summary: (%q, %q)", after.Status, after.Activity)
383+ }
384+ if !after.LastActivity.Equal(lastActivity) {
385+ t.Fatalf("hydration changed last activity from %s to %s", lastActivity, after.LastActivity)
386+ }
387+}
388+
311389 // TestReloadSkipsBareDirsAndCorruptMeta ensures one bad meta.json
312390 // doesn't blow up the whole reload. Directories with no meta.json
313391 // at all are silently ignored (Spawn that failed mid-way leaves
@@ -292,11 +292,15 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) {
292 if a == nil {292 if a == nil {
293 t.Fatal("reloaded agent not found")293 t.Fatal("reloaded agent not found")
294 }294 }
295- // Status should reflect the lifecycle terminator we wrote.295+ // Legacy metadata has no persisted dashboard status, so startup keeps the
296- if a.Status() != StatusDone {296+ // agent detached until the user asks to inspect its durable history.
297- t.Errorf("status = %q; want done (offline)", a.Status())297+ if a.Status() != StatusDetached {
298+ t.Errorf("startup status = %q; want detached", a.Status())
298 }299 }
299 got := a.Transcript()300 got := a.Transcript()
301+ if a.Status() != StatusDone {
302+ t.Errorf("hydrated status = %q; want done (offline)", a.Status())
303+ }
300 found := false304 found := false
301 for _, line := range got {305 for _, line := range got {
302 if line == "hello from the past" {306 if line == "hello from the past" {
@@ -308,6 +312,80 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) {
308 }312 }
309 }313 }
310 314
315+// TestReloadUsesPersistedSummaryBeforeTranscriptHydration ensures startup
316+// rebuilds the dashboard from compact metadata rather than replaying the
317+// event log. Opening the transcript remains the explicit point that loads
318+// durable history.
319+func TestReloadUsesPersistedSummaryBeforeTranscriptHydration(t *testing.T) {
320+ root := t.TempDir()
321+ id := "summary-1"
322+ stateDir := filepath.Join(root, "agents", id)
323+ agent := &Agent{
324+ ID: id,
325+ Task: "persist summary",
326+ OriginalTask: "persist summary",
327+ Dir: root,
328+ Started: time.Now().Add(-time.Hour),
329+ EventLogPath: filepath.Join(stateDir, "events.jsonl"),
330+ SessionPath: filepath.Join(stateDir, "session.json"),
331+ status: StatusDone,
332+ activity: "done",
333+ finished: time.Now().Add(-time.Minute),
334+ processState: ProcessExited,
335+ turnState: TurnSucceeded,
336+ currentTurnID: "turn-1",
337+ done: make(chan struct{}),
338+ }
339+ if err := writeAgentMeta(stateDir, agent); err != nil {
340+ t.Fatal(err)
341+ }
342+
343+ log, err := OpenEventLog(agent.EventLogPath)
344+ if err != nil {
345+ t.Fatal(err)
346+ }
347+ if err := log.Append(NewEvent("assistant_message", map[string]any{
348+ "content": []any{map[string]any{"type": "text", "text": "historical reply"}},
349+ })); err != nil {
350+ t.Fatal(err)
351+ }
352+ if err := log.Append(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"})); err != nil {
353+ t.Fatal(err)
354+ }
355+ if err := log.Close(); err != nil {
356+ t.Fatal(err)
357+ }
358+
359+ supervisor := New(Config{Root: root, RepoRoot: root})
360+ if loaded, errs := supervisor.Reload(); loaded != 1 || len(errs) != 0 {
361+ t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
362+ }
363+ reloaded := supervisor.Get(id)
364+ if reloaded == nil {
365+ t.Fatal("reloaded agent missing")
366+ }
367+ before := reloaded.Snapshot()
368+ if before.Status != StatusDetached || before.Activity != "done" || before.ProcessState != ProcessExited || before.TurnState != TurnSucceeded {
369+ t.Fatalf("startup summary = (%q, %q, %q, %q), want (detached, done, exited, succeeded)", before.Status, before.Activity, before.ProcessState, before.TurnState)
370+ }
371+ if len(before.Lines) != 0 {
372+ t.Fatalf("startup loaded transcript lines: %v", before.Lines)
373+ }
374+ lastActivity := before.LastActivity
375+
376+ lines := reloaded.Transcript()
377+ if len(lines) != 1 || lines[0] != "historical reply" {
378+ t.Fatalf("hydrated transcript = %v", lines)
379+ }
380+ after := reloaded.Snapshot()
381+ if after.Status != StatusDetached || after.Activity != "done" {
382+ t.Fatalf("hydration overwrote persisted summary: (%q, %q)", after.Status, after.Activity)
383+ }
384+ if !after.LastActivity.Equal(lastActivity) {
385+ t.Fatalf("hydration changed last activity from %s to %s", lastActivity, after.LastActivity)
386+ }
387+}
388+
311 // TestReloadSkipsBareDirsAndCorruptMeta ensures one bad meta.json389 // TestReloadSkipsBareDirsAndCorruptMeta ensures one bad meta.json
312 // doesn't blow up the whole reload. Directories with no meta.json390 // doesn't blow up the whole reload. Directories with no meta.json
313 // at all are silently ignored (Spawn that failed mid-way leaves391 // at all are silently ignored (Spawn that failed mid-way leaves
M packages/agent/subagents/supervisor.go +8 -5
@@ -328,7 +328,7 @@ func (f *Supervisor) uniqueAgentIDLocked(base string) string {
328328 // chosen model regardless of the parent's current selection. A named
329329 // Subagent is passed to the child as --subagent, where it resolves the
330330 // markdown profile and applies its instructions. Reasoning is passed
331-// as --reasoning when set. FastMode restricts the host setting when a
331+// as --reasoning when set. FastMode overrides the host setting when a
332332 // selected profile declares a fastMode preference.
333333 type SpawnRequest struct {
334334 Task string
@@ -339,9 +339,8 @@ type SpawnRequest struct {
339339 // effective custom provider endpoint.
340340 InsecureTLS bool
341341 Reasoning string // optional reasoning/thinking level override
342- // FastMode is an optional profile restriction. A non-nil false value
343- // disables fast mode for this child; true still cannot enable fast mode
344- // when Config.FastMode is false. Nil inherits Config.FastMode.
342+ // FastMode is an optional profile override. A non-nil value enables or
343+ // disables fast mode for this child; nil inherits Config.FastMode.
345344 FastMode *bool
346345 Subagent string // optional named markdown profile
347346
@@ -413,8 +412,10 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
413412 configInsecureTLS := f.cfg.InsecureTLS
414413 sessionID := f.activeSession
415414 f.mu.Unlock()
415+ fastModeOverridesHost := false
416416 if req.FastMode != nil {
417- fastMode = fastMode && *req.FastMode
417+ fastModeOverridesHost = !fastMode && *req.FastMode
418+ fastMode = *req.FastMode
418419 }
419420 childProvider := strings.TrimSpace(req.Provider)
420421 inheritProviderSettings := childProvider == "" || configProvider == "" || strings.EqualFold(childProvider, strings.TrimSpace(configProvider))
@@ -527,6 +528,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
527528 inbox: NewInbox(inboxPath),
528529 status: StatusPending,
529530 activity: "queued",
531+ transcriptLoaded: true,
530532 processState: ProcessPending,
531533 turnState: TurnQueued,
532534 updatedAt: now,
@@ -538,6 +540,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
538540 done: make(chan struct{}),
539541 turnResults: make(chan *TurnResult, 16),
540542 }
543+ a.fastModeOverridesHost = fastModeOverridesHost
541544 if a.RootSessionID == "" {
542545 a.RootSessionID = sessionID
543546 }
@@ -328,7 +328,7 @@ func (f *Supervisor) uniqueAgentIDLocked(base string) string {
328 // chosen model regardless of the parent's current selection. A named328 // chosen model regardless of the parent's current selection. A named
329 // Subagent is passed to the child as --subagent, where it resolves the329 // Subagent is passed to the child as --subagent, where it resolves the
330 // markdown profile and applies its instructions. Reasoning is passed330 // markdown profile and applies its instructions. Reasoning is passed
331-// as --reasoning when set. FastMode restricts the host setting when a331+// as --reasoning when set. FastMode overrides the host setting when a
332 // selected profile declares a fastMode preference.332 // selected profile declares a fastMode preference.
333 type SpawnRequest struct {333 type SpawnRequest struct {
334 Task string334 Task string
@@ -339,9 +339,8 @@ type SpawnRequest struct {
339 // effective custom provider endpoint.339 // effective custom provider endpoint.
340 InsecureTLS bool340 InsecureTLS bool
341 Reasoning string // optional reasoning/thinking level override341 Reasoning string // optional reasoning/thinking level override
342- // FastMode is an optional profile restriction. A non-nil false value342+ // FastMode is an optional profile override. A non-nil value enables or
343- // disables fast mode for this child; true still cannot enable fast mode343+ // disables fast mode for this child; nil inherits Config.FastMode.
344- // when Config.FastMode is false. Nil inherits Config.FastMode.
345 FastMode *bool344 FastMode *bool
346 Subagent string // optional named markdown profile345 Subagent string // optional named markdown profile
347 346
@@ -413,8 +412,10 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
413 configInsecureTLS := f.cfg.InsecureTLS412 configInsecureTLS := f.cfg.InsecureTLS
414 sessionID := f.activeSession413 sessionID := f.activeSession
415 f.mu.Unlock()414 f.mu.Unlock()
415+ fastModeOverridesHost := false
416 if req.FastMode != nil {416 if req.FastMode != nil {
417- fastMode = fastMode && *req.FastMode417+ fastModeOverridesHost = !fastMode && *req.FastMode
418+ fastMode = *req.FastMode
418 }419 }
419 childProvider := strings.TrimSpace(req.Provider)420 childProvider := strings.TrimSpace(req.Provider)
420 inheritProviderSettings := childProvider == "" || configProvider == "" || strings.EqualFold(childProvider, strings.TrimSpace(configProvider))421 inheritProviderSettings := childProvider == "" || configProvider == "" || strings.EqualFold(childProvider, strings.TrimSpace(configProvider))
@@ -527,6 +528,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
527 inbox: NewInbox(inboxPath),528 inbox: NewInbox(inboxPath),
528 status: StatusPending,529 status: StatusPending,
529 activity: "queued",530 activity: "queued",
531+ transcriptLoaded: true,
530 processState: ProcessPending,532 processState: ProcessPending,
531 turnState: TurnQueued,533 turnState: TurnQueued,
532 updatedAt: now,534 updatedAt: now,
@@ -538,6 +540,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
538 done: make(chan struct{}),540 done: make(chan struct{}),
539 turnResults: make(chan *TurnResult, 16),541 turnResults: make(chan *TurnResult, 16),
540 }542 }
543+ a.fastModeOverridesHost = fastModeOverridesHost
541 if a.RootSessionID == "" {544 if a.RootSessionID == "" {
542 a.RootSessionID = sessionID545 a.RootSessionID = sessionID
543 }546 }
M packages/agent/systemprompt.go +5 -0
@@ -64,6 +64,9 @@ func BuildSystemPrompt(o SystemPromptOpts) string {
6464 sb.WriteString(defaultIdentity)
6565 }
6666
67+ sb.WriteString("\n\n")
68+ sb.WriteString(compactedSummaryHandoffInstruction)
69+
6770 if strings.TrimSpace(o.ZutDocsDir) != "" {
6871 sb.WriteString("\n\nZut's own docs are installed under ")
6972 sb.WriteString(o.ZutDocsDir)
@@ -87,3 +90,5 @@ const defaultIdentity = `You are an expert coding assistant operating inside zut
8790 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.
8891
8992 For focused changes to an existing file, inspect its current contents and use edit with verbatim oldText taken from that same file. Include only enough context to make each match unambiguous. Use write when creating a file or replacing it wholesale. Do not mutate files through bash redirections or commands such as cat, echo, sed, or tee, because those changes appear as opaque shell output instead of a readable edit diff.`
93+
94+const compactedSummaryHandoffInstruction = `When you see a "## Context Summary (compacted)" message, treat it as a handoff from earlier work. Keep its active constraints and preferences in force, continue the most recent unresolved user request without waiting for the user to type "continue", and follow a newer user request when it supersedes the summary.`
@@ -64,6 +64,9 @@ func BuildSystemPrompt(o SystemPromptOpts) string {
64 sb.WriteString(defaultIdentity)64 sb.WriteString(defaultIdentity)
65 }65 }
66 66
67+ sb.WriteString("\n\n")
68+ sb.WriteString(compactedSummaryHandoffInstruction)
69+
67 if strings.TrimSpace(o.ZutDocsDir) != "" {70 if strings.TrimSpace(o.ZutDocsDir) != "" {
68 sb.WriteString("\n\nZut's own docs are installed under ")71 sb.WriteString("\n\nZut's own docs are installed under ")
69 sb.WriteString(o.ZutDocsDir)72 sb.WriteString(o.ZutDocsDir)
@@ -87,3 +90,5 @@ const defaultIdentity = `You are an expert coding assistant operating inside zut
87 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.90 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.
88 91
89 For focused changes to an existing file, inspect its current contents and use edit with verbatim oldText taken from that same file. Include only enough context to make each match unambiguous. Use write when creating a file or replacing it wholesale. Do not mutate files through bash redirections or commands such as cat, echo, sed, or tee, because those changes appear as opaque shell output instead of a readable edit diff.`92 For focused changes to an existing file, inspect its current contents and use edit with verbatim oldText taken from that same file. Include only enough context to make each match unambiguous. Use write when creating a file or replacing it wholesale. Do not mutate files through bash redirections or commands such as cat, echo, sed, or tee, because those changes appear as opaque shell output instead of a readable edit diff.`
93+
94+const compactedSummaryHandoffInstruction = `When you see a "## Context Summary (compacted)" message, treat it as a handoff from earlier work. Keep its active constraints and preferences in force, continue the most recent unresolved user request without waiting for the user to type "continue", and follow a newer user request when it supersedes the summary.`
A packages/agent/systemprompt_test.go +19 -0
@@ -0,0 +1,19 @@
1+package agent
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) {
9+ prompt := BuildSystemPrompt(SystemPromptOpts{Custom: "custom identity"})
10+ if !strings.Contains(prompt, "custom identity") {
11+ t.Fatalf("custom prompt missing:\n%s", prompt)
12+ }
13+ if !strings.Contains(prompt, compactedSummaryHandoffInstruction) {
14+ t.Fatalf("custom prompt missing compaction handoff:\n%s", prompt)
15+ }
16+ if count := strings.Count(prompt, compactedSummaryHandoffInstruction); count != 1 {
17+ t.Fatalf("compaction handoff count = %d, want 1:\n%s", count, prompt)
18+ }
19+}
@@ -0,0 +1,19 @@
1+package agent
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) {
9+ prompt := BuildSystemPrompt(SystemPromptOpts{Custom: "custom identity"})
10+ if !strings.Contains(prompt, "custom identity") {
11+ t.Fatalf("custom prompt missing:\n%s", prompt)
12+ }
13+ if !strings.Contains(prompt, compactedSummaryHandoffInstruction) {
14+ t.Fatalf("custom prompt missing compaction handoff:\n%s", prompt)
15+ }
16+ if count := strings.Count(prompt, compactedSummaryHandoffInstruction); count != 1 {
17+ t.Fatalf("compaction handoff count = %d, want 1:\n%s", count, prompt)
18+ }
19+}
A packages/agent/tools/create_worktree.go +908 -0
@@ -0,0 +1,908 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "strings"
14+ "sync"
15+ "time"
16+
17+ "github.com/bnema/zut/packages/core"
18+ "github.com/bnema/zut/packages/provider"
19+)
20+
21+const (
22+ worktreesIgnoreEntry = "/.worktrees/"
23+ defaultWorktreeRoot = ".worktrees"
24+ worktreeConfigKey = "zut.worktrees.path"
25+ maxCreateWorktreeGitOutput = 64 * 1024
26+ maxCreateWorktreeIgnoreBytes = 1 << 20
27+)
28+
29+var createWorktreeOperationMu sync.Mutex
30+
31+// CreateWorktreeTool creates a persistent branch checkout. A repository that
32+// has neither a local worktree configuration nor an existing .worktrees
33+// directory returns a bootstrap request without changing any files.
34+type CreateWorktreeTool struct {
35+ CWD string
36+ Sandbox *Sandbox
37+
38+ previewMu sync.Mutex
39+ preview *createWorktreePreview
40+}
41+
42+type createWorktreeArgs struct {
43+ Branch string `json:"branch"`
44+ BootstrapRoot string `json:"bootstrap_root,omitempty"`
45+}
46+
47+const createWorktreeSchema = `{
48+ "type":"object",
49+ "properties":{
50+ "branch":{
51+ "type":"string",
52+ "description":"Name of the new Git branch."
53+ },
54+ "bootstrap_root":{
55+ "type":"string",
56+ "description":"Only for an unconfigured repository: use .worktrees to initialize the repository-root default, or provide an absolute external worktree root. The choice is stored in local Git config. Omit it on the first call to request user guidance instead of changing files."
57+ }
58+ },
59+ "required":["branch"]
60+}`
61+
62+func (t *CreateWorktreeTool) Name() string { return "create_worktree" }
63+func (t *CreateWorktreeTool) Description() string {
64+ return "Create a persistent Git branch worktree. An unconfigured repository returns bootstrap guidance; ask the user for .worktrees or an absolute external root, then call again with bootstrap_root."
65+}
66+func (t *CreateWorktreeTool) Schema() json.RawMessage { return json.RawMessage(createWorktreeSchema) }
67+
68+// Preview reports the exact bootstrap request or checkout that Execute will
69+// produce without modifying the repository.
70+func (t *CreateWorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
71+ plan, err := t.plan(ctx, raw)
72+ if err != nil {
73+ return core.ToolResult{}, err
74+ }
75+ t.previewMu.Lock()
76+ t.preview = &createWorktreePreview{raw: string(raw), plan: plan}
77+ t.previewMu.Unlock()
78+ return plan.preview, nil
79+}
80+
81+func (t *CreateWorktreeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
82+ createWorktreeOperationMu.Lock()
83+ defer createWorktreeOperationMu.Unlock()
84+
85+ plan, err := t.planForExecute(ctx, raw)
86+ if err != nil {
87+ return core.ToolResult{}, err
88+ }
89+ if plan.bootstrapRequired {
90+ return plan.preview, nil
91+ }
92+ return executeCreateWorktreePlan(ctx, plan, progress)
93+}
94+
95+func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, progress func(string)) (core.ToolResult, error) {
96+ configSaved := false
97+ var createdWorktreeParents []string
98+ if plan.setConfig {
99+ if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "config", "--local", "--replace-all", worktreeConfigKey, plan.configValue); err != nil {
100+ return core.ToolResult{}, fmt.Errorf("create_worktree: save worktree root: %w", err)
101+ }
102+ configSaved = true
103+ }
104+ rollback := func(cause error) error {
105+ cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
106+ defer cancel()
107+ if err := plan.cleanupFailedCheckout(cleanupCtx); err != nil {
108+ cause = fmt.Errorf("%w; cleanup worktree: %v", cause, err)
109+ }
110+ if err := removeCreatedWorktreeParents(createdWorktreeParents); err != nil {
111+ cause = fmt.Errorf("%w; remove created worktree directories: %v", cause, err)
112+ }
113+ if err := plan.ignore.restore(); err != nil {
114+ cause = fmt.Errorf("%w; restore .gitignore: %v", cause, err)
115+ }
116+ if configSaved {
117+ if err := plan.removeBootstrapConfig(cleanupCtx); err != nil {
118+ cause = fmt.Errorf("%w; remove local worktree configuration: %v", cause, err)
119+ }
120+ }
121+ return cause
122+ }
123+ if err := plan.ignore.apply(); err != nil {
124+ return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: update .gitignore: %w", err))
125+ }
126+
127+ createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath)
128+ if err != nil {
129+ return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: inspect worktree directories: %w", err))
130+ }
131+ if err := os.MkdirAll(filepath.Dir(plan.worktreePath), 0o755); err != nil {
132+ return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: make worktree directory: %w", err))
133+ }
134+ if progress != nil {
135+ progress("Creating Git worktree...\n")
136+ }
137+ if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "worktree", "add", "-b", plan.branch, plan.worktreePath, plan.base); err != nil {
138+ return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: create worktree: %w", err))
139+ }
140+
141+ result := plan.preview
142+ previewDetails, _ := plan.preview.Details.(map[string]any)
143+ details := make(map[string]any, len(previewDetails)+1)
144+ for key, value := range previewDetails {
145+ details[key] = value
146+ }
147+ details["state"] = "created"
148+ result.Details = details
149+ result.Content = []provider.Content{provider.TextBlock{Text: strings.Replace(plan.previewText, "create worktree", "created worktree", 1)}}
150+ return result, nil
151+}
152+
153+type createWorktreePreview struct {
154+ raw string
155+ plan createWorktreePlan
156+}
157+
158+func (t *CreateWorktreeTool) planForExecute(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {
159+ t.previewMu.Lock()
160+ cached := t.preview
161+ t.preview = nil
162+ t.previewMu.Unlock()
163+
164+ plan, err := t.plan(ctx, raw)
165+ if err != nil {
166+ return createWorktreePlan{}, err
167+ }
168+ if cached != nil && cached.raw == string(raw) && !sameCreateWorktreePlan(cached.plan, plan) {
169+ return createWorktreePlan{}, errors.New("create_worktree: repository state changed since preview; inspect and retry")
170+ }
171+ return plan, nil
172+}
173+
174+type createWorktreePlan struct {
175+ repoRoot string
176+ branch string
177+ base string
178+ worktreeRoot string
179+ worktreePath string
180+ rootSource string
181+ configValue string
182+ setConfig bool
183+ bootstrapRequired bool
184+ previewText string
185+ preview core.ToolResult
186+ ignore worktreesIgnoreUpdate
187+}
188+
189+func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {
190+ var args createWorktreeArgs
191+ if err := json.Unmarshal(raw, &args); err != nil {
192+ return createWorktreePlan{}, fmt.Errorf("invalid args: %w", err)
193+ }
194+ branchInput := strings.TrimSpace(args.Branch)
195+ if branchInput == "" {
196+ return createWorktreePlan{}, errors.New("branch is required")
197+ }
198+ if strings.HasPrefix(branchInput, "-") || strings.Contains(branchInput, "@{") {
199+ return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)
200+ }
201+ if t.Sandbox == nil {
202+ return createWorktreePlan{}, errors.New("create_worktree: sandbox is required")
203+ }
204+ if err := t.Sandbox.CheckBashPermission("git"); err != nil {
205+ return createWorktreePlan{}, fmt.Errorf("create_worktree: %w", err)
206+ }
207+
208+ cwd, err := t.accessibleCWD()
209+ if err != nil {
210+ return createWorktreePlan{}, err
211+ }
212+ candidateRoot, err := t.findGitRootCandidate(cwd)
213+ if err != nil {
214+ return createWorktreePlan{}, err
215+ }
216+ repoRoot, err := createWorktreeGitOutput(ctx, candidateRoot, "rev-parse", "--show-toplevel")
217+ if err != nil {
218+ return createWorktreePlan{}, fmt.Errorf("create_worktree: verify repository: %w", err)
219+ }
220+ repoRoot, err = canonical(repoRoot)
221+ if err != nil {
222+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve repository root: %w", err)
223+ }
224+ if err := t.checkReadAccess(repoRoot); err != nil {
225+ return createWorktreePlan{}, err
226+ }
227+ branch, err := createWorktreeGitOutput(ctx, repoRoot, "check-ref-format", "--branch", branchInput)
228+ if err != nil {
229+ return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q: %w", branchInput, err)
230+ }
231+ if branch != branchInput {
232+ return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)
233+ }
234+ base, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "HEAD^{commit}")
235+ if err != nil {
236+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve current commit: %w", err)
237+ }
238+ commonDir, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--git-common-dir")
239+ if err != nil {
240+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err)
241+ }
242+ if !filepath.IsAbs(commonDir) {
243+ commonDir = filepath.Join(repoRoot, commonDir)
244+ }
245+ commonDir, err = canonical(commonDir)
246+ if err != nil {
247+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err)
248+ }
249+ if err := t.checkReadAccess(commonDir); err != nil {
250+ return createWorktreePlan{}, err
251+ }
252+
253+ configuredRoot, configured, err := createWorktreeGitConfigValue(ctx, repoRoot, worktreeConfigKey)
254+ if err != nil {
255+ return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err)
256+ }
257+ bootstrapRoot := strings.TrimSpace(args.BootstrapRoot)
258+ if configured && bootstrapRoot != "" {
259+ return createWorktreePlan{}, errors.New("create_worktree: worktree root is already configured; change the local Git configuration explicitly before bootstrapping again")
260+ }
261+
262+ rootSource := "existing .worktrees directory"
263+ configValue := ""
264+ setConfig := false
265+ defaultRoot := false
266+ var worktreeRoot string
267+ if configured {
268+ worktreeRoot, defaultRoot, err = resolveConfiguredWorktreeRoot(repoRoot, configuredRoot)
269+ if err != nil {
270+ return createWorktreePlan{}, err
271+ }
272+ rootSource = "local Git config"
273+ } else {
274+ defaultPath := filepath.Join(repoRoot, defaultWorktreeRoot)
275+ exists, err := inspectWorktreeRoot(defaultPath)
276+ if err != nil {
277+ return createWorktreePlan{}, err
278+ }
279+ if !exists && bootstrapRoot == "" {
280+ return bootstrapRequiredPlan(branch, repoRoot), nil
281+ }
282+ if exists {
283+ if bootstrapRoot != "" {
284+ return createWorktreePlan{}, errors.New("create_worktree: worktree root is already established; omit bootstrap_root")
285+ }
286+ worktreeRoot = defaultPath
287+ defaultRoot = true
288+ } else {
289+ worktreeRoot, defaultRoot, configValue, err = resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot)
290+ if err != nil {
291+ return createWorktreePlan{}, err
292+ }
293+ setConfig = true
294+ rootSource = "bootstrap selection"
295+ }
296+ }
297+
298+ worktreeRoot, err = validateWorktreeRoot(worktreeRoot, defaultRoot)
299+ if err != nil {
300+ return createWorktreePlan{}, err
301+ }
302+ worktreePath := filepath.Join(worktreeRoot, filepath.FromSlash(branch))
303+ rootCanonical, err := canonicalOrParent(worktreeRoot)
304+ if err != nil {
305+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree root: %w", err)
306+ }
307+ if !defaultRoot && isUnder(repoRoot, rootCanonical) {
308+ return createWorktreePlan{}, errors.New("create_worktree: external worktree root must be outside the repository")
309+ }
310+ pathCanonical, err := canonicalOrParent(worktreePath)
311+ if err != nil {
312+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree path: %w", err)
313+ }
314+ if !isUnder(rootCanonical, pathCanonical) {
315+ return createWorktreePlan{}, errors.New("create_worktree: branch path escapes configured worktree root")
316+ }
317+
318+ ignorePath := ""
319+ accessPaths := []string{repoRoot, commonDir, worktreeRoot, worktreePath}
320+ if defaultRoot {
321+ ignorePath = filepath.Join(repoRoot, ".gitignore")
322+ accessPaths = append(accessPaths, ignorePath)
323+ }
324+ if err := t.checkReadWriteAccess(accessPaths...); err != nil {
325+ return createWorktreePlan{}, err
326+ }
327+ if _, err := os.Lstat(worktreePath); err == nil {
328+ return createWorktreePlan{}, fmt.Errorf("create_worktree: worktree path %q already exists", filepath.ToSlash(worktreePath))
329+ } else if !os.IsNotExist(err) {
330+ return createWorktreePlan{}, fmt.Errorf("create_worktree: inspect worktree path: %w", err)
331+ }
332+ exists, err := createWorktreeBranchExists(ctx, repoRoot, branch)
333+ if err != nil {
334+ return createWorktreePlan{}, fmt.Errorf("create_worktree: check branch: %w", err)
335+ }
336+ if exists {
337+ return createWorktreePlan{}, fmt.Errorf("create_worktree: branch %q already exists", branch)
338+ }
339+
340+ ignore := worktreesIgnoreUpdate{}
341+ ignoreAction := "not modified (external worktree root)"
342+ if defaultRoot {
343+ ignore, err = newWorktreesIgnoreUpdate(ignorePath)
344+ if err != nil {
345+ return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err)
346+ }
347+ ignoreAction = "already contains " + worktreesIgnoreEntry
348+ if ignore.changed {
349+ ignoreAction = "add " + worktreesIgnoreEntry
350+ }
351+ }
352+
353+ displayRoot := worktreeRoot
354+ if defaultRoot {
355+ displayRoot = defaultWorktreeRoot
356+ }
357+ displayPath := displayWorktreePath(repoRoot, worktreePath, defaultRoot)
358+ configAction := ""
359+ if setConfig {
360+ configAction = fmt.Sprintf("local config: set %s=%s\n", worktreeConfigKey, configValue)
361+ }
362+ previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n%s.gitignore: %s\n", branch, base, filepath.ToSlash(displayRoot), rootSource, displayPath, configAction, ignoreAction)
363+ return createWorktreePlan{
364+ repoRoot: repoRoot,
365+ branch: branch,
366+ base: base,
367+ worktreeRoot: worktreeRoot,
368+ worktreePath: worktreePath,
369+ rootSource: rootSource,
370+ configValue: configValue,
371+ setConfig: setConfig,
372+ previewText: previewText,
373+ preview: core.ToolResult{
374+ Content: []provider.Content{provider.TextBlock{Text: previewText}},
375+ Details: map[string]any{
376+ "state": "ready",
377+ "branch": branch,
378+ "base": base,
379+ "worktree_root": displayRoot,
380+ "root_source": rootSource,
381+ "path": displayPath,
382+ "bootstrap_saved": setConfig,
383+ },
384+ },
385+ ignore: ignore,
386+ }, nil
387+}
388+
389+func bootstrapRequiredPlan(branch, repoRoot string) createWorktreePlan {
390+ text := fmt.Sprintf("Worktree bootstrap is required.\nbranch: %s\nrepository: %s\nChoose a location with the user, then call create_worktree again with bootstrap_root set to .worktrees for the repository default or to an absolute external directory. This call made no changes.\n", branch, filepath.ToSlash(repoRoot))
391+ return createWorktreePlan{
392+ branch: branch,
393+ bootstrapRequired: true,
394+ previewText: text,
395+ preview: core.ToolResult{
396+ Content: []provider.Content{provider.TextBlock{Text: text}},
397+ Details: map[string]any{
398+ "state": "bootstrap_required",
399+ "branch": branch,
400+ "default_bootstrap_root": defaultWorktreeRoot,
401+ },
402+ },
403+ }
404+}
405+
406+func (t *CreateWorktreeTool) accessibleCWD() (string, error) {
407+ cwd := t.CWD
408+ if cwd == "" {
409+ var err error
410+ cwd, err = os.Getwd()
411+ if err != nil {
412+ return "", fmt.Errorf("create_worktree: working directory: %w", err)
413+ }
414+ }
415+ cwd, err := canonical(cwd)
416+ if err != nil {
417+ return "", fmt.Errorf("create_worktree: resolve working directory: %w", err)
418+ }
419+ if err := t.checkReadAccess(cwd); err != nil {
420+ return "", err
421+ }
422+ return cwd, nil
423+}
424+
425+func (t *CreateWorktreeTool) findGitRootCandidate(cwd string) (string, error) {
426+ for dir := cwd; ; dir = filepath.Dir(dir) {
427+ if err := t.checkReadAccess(dir); err != nil {
428+ return "", err
429+ }
430+ if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
431+ return dir, nil
432+ } else if !os.IsNotExist(err) {
433+ return "", fmt.Errorf("create_worktree: inspect Git metadata: %w", err)
434+ }
435+ parent := filepath.Dir(dir)
436+ if parent == dir {
437+ break
438+ }
439+ }
440+ return "", errors.New("create_worktree: working directory is not a Git repository")
441+}
442+
443+func (t *CreateWorktreeTool) checkReadAccess(paths ...string) error {
444+ for _, path := range paths {
445+ if err := t.Sandbox.CheckReadPath(path); err != nil {
446+ return fmt.Errorf("create_worktree: %w", err)
447+ }
448+ }
449+ return nil
450+}
451+
452+func (t *CreateWorktreeTool) checkReadWriteAccess(paths ...string) error {
453+ for _, path := range paths {
454+ if err := t.Sandbox.CheckReadPath(path); err != nil {
455+ return fmt.Errorf("create_worktree: %w", err)
456+ }
457+ if err := t.Sandbox.CheckWritePath(path); err != nil {
458+ return fmt.Errorf("create_worktree: %w", err)
459+ }
460+ }
461+ return nil
462+}
463+
464+func resolveConfiguredWorktreeRoot(repoRoot, value string) (string, bool, error) {
465+ value = strings.TrimSpace(value)
466+ if value == defaultWorktreeRoot {
467+ return filepath.Join(repoRoot, defaultWorktreeRoot), true, nil
468+ }
469+ if !filepath.IsAbs(value) {
470+ return "", false, errors.New("create_worktree: local worktree configuration must be .worktrees or an absolute external path")
471+ }
472+ return value, false, nil
473+}
474+
475+func resolveBootstrapWorktreeRoot(repoRoot, value string) (string, bool, string, error) {
476+ if value == defaultWorktreeRoot {
477+ return filepath.Join(repoRoot, defaultWorktreeRoot), true, defaultWorktreeRoot, nil
478+ }
479+ if !filepath.IsAbs(value) {
480+ return "", false, "", errors.New("create_worktree: bootstrap_root must be .worktrees or an absolute external path")
481+ }
482+ root, err := canonicalOrParent(value)
483+ if err != nil {
484+ return "", false, "", fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err)
485+ }
486+ if isUnder(repoRoot, root) {
487+ return "", false, "", errors.New("create_worktree: external bootstrap_root must be outside the repository")
488+ }
489+ return root, false, root, nil
490+}
491+
492+func validateWorktreeRoot(path string, defaultRoot bool) (string, error) {
493+ info, err := os.Lstat(path)
494+ if os.IsNotExist(err) {
495+ return path, nil
496+ }
497+ if err != nil {
498+ return "", fmt.Errorf("create_worktree: inspect worktree root: %w", err)
499+ }
500+ if info.Mode()&os.ModeSymlink != 0 {
501+ return "", errors.New("create_worktree: worktree root must not be a symbolic link")
502+ }
503+ if !info.IsDir() {
504+ return "", errors.New("create_worktree: worktree root exists but is not a directory")
505+ }
506+ if defaultRoot && filepath.Base(path) != defaultWorktreeRoot {
507+ return "", errors.New("create_worktree: invalid default worktree root")
508+ }
509+ return path, nil
510+}
511+
512+func inspectWorktreeRoot(path string) (bool, error) {
513+ info, err := os.Lstat(path)
514+ if os.IsNotExist(err) {
515+ return false, nil
516+ }
517+ if err != nil {
518+ return false, fmt.Errorf("create_worktree: inspect .worktrees: %w", err)
519+ }
520+ if info.Mode()&os.ModeSymlink != 0 {
521+ return false, errors.New("create_worktree: .worktrees must not be a symbolic link")
522+ }
523+ if !info.IsDir() {
524+ return false, errors.New("create_worktree: .worktrees exists but is not a directory")
525+ }
526+ return true, nil
527+}
528+
529+func displayWorktreePath(repoRoot, path string, defaultRoot bool) string {
530+ if defaultRoot {
531+ relative, err := filepath.Rel(repoRoot, path)
532+ if err == nil {
533+ return filepath.ToSlash(relative)
534+ }
535+ }
536+ return filepath.ToSlash(path)
537+}
538+
539+func sameCreateWorktreePlan(a, b createWorktreePlan) bool {
540+ return a.bootstrapRequired == b.bootstrapRequired &&
541+ a.repoRoot == b.repoRoot &&
542+ a.branch == b.branch &&
543+ a.base == b.base &&
544+ a.worktreeRoot == b.worktreeRoot &&
545+ a.worktreePath == b.worktreePath &&
546+ a.rootSource == b.rootSource &&
547+ a.configValue == b.configValue &&
548+ a.setConfig == b.setConfig &&
549+ a.ignore.path == b.ignore.path &&
550+ a.ignore.existed == b.ignore.existed &&
551+ a.ignore.changed == b.ignore.changed &&
552+ bytes.Equal(a.ignore.before, b.ignore.before)
553+}
554+
555+func missingWorktreeParents(worktreePath string) ([]string, error) {
556+ var missing []string
557+ for directory := filepath.Dir(worktreePath); ; directory = filepath.Dir(directory) {
558+ info, err := os.Lstat(directory)
559+ if err == nil {
560+ if !info.IsDir() {
561+ return nil, fmt.Errorf("%q is not a directory", directory)
562+ }
563+ break
564+ }
565+ if !os.IsNotExist(err) {
566+ return nil, err
567+ }
568+ missing = append(missing, directory)
569+ parent := filepath.Dir(directory)
570+ if parent == directory {
571+ return nil, errors.New("no existing parent directory")
572+ }
573+ }
574+ for left, right := 0, len(missing)-1; left < right; left, right = left+1, right-1 {
575+ missing[left], missing[right] = missing[right], missing[left]
576+ }
577+ return missing, nil
578+}
579+
580+func removeCreatedWorktreeParents(parents []string) error {
581+ for index := len(parents) - 1; index >= 0; index-- {
582+ if err := os.Remove(parents[index]); err != nil && !os.IsNotExist(err) {
583+ return err
584+ }
585+ }
586+ return nil
587+}
588+
589+func (p createWorktreePlan) removeBootstrapConfig(ctx context.Context) error {
590+ value, configured, err := createWorktreeGitConfigValue(ctx, p.repoRoot, worktreeConfigKey)
591+ if err != nil {
592+ return err
593+ }
594+ if !configured {
595+ return nil
596+ }
597+ if value != p.configValue {
598+ return errors.New("local worktree configuration changed during creation")
599+ }
600+ _, err = createWorktreeGitOutput(ctx, p.repoRoot, "config", "--local", "--unset-all", worktreeConfigKey)
601+ return err
602+}
603+
604+func (p createWorktreePlan) cleanupFailedCheckout(ctx context.Context) error {
605+ registered, err := createWorktreeRegistered(ctx, p.repoRoot, p.worktreePath)
606+ if err != nil {
607+ return err
608+ }
609+ var cleanupErr error
610+ if registered {
611+ if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "worktree", "remove", "--force", p.worktreePath); err != nil {
612+ cleanupErr = fmt.Errorf("remove registered worktree: %w", err)
613+ }
614+ }
615+ branchHead, exists, err := createWorktreeGitRef(ctx, p.repoRoot, "refs/heads/"+p.branch)
616+ if err == nil && exists && branchHead == p.base {
617+ if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "branch", "-D", p.branch); err != nil && cleanupErr == nil {
618+ cleanupErr = fmt.Errorf("remove branch: %w", err)
619+ }
620+ }
621+ if err != nil && cleanupErr == nil {
622+ cleanupErr = err
623+ }
624+ return cleanupErr
625+}
626+
627+func createWorktreeRegistered(ctx context.Context, repoRoot, worktreePath string) (bool, error) {
628+ output, err := createWorktreeGitOutput(ctx, repoRoot, "worktree", "list", "--porcelain")
629+ if err != nil {
630+ return false, err
631+ }
632+ want, err := canonicalOrParent(worktreePath)
633+ if err != nil {
634+ return false, err
635+ }
636+ for _, line := range strings.Split(output, "\n") {
637+ if !strings.HasPrefix(line, "worktree ") {
638+ continue
639+ }
640+ path, err := canonicalOrParent(strings.TrimPrefix(line, "worktree "))
641+ if err == nil && path == want {
642+ return true, nil
643+ }
644+ }
645+ return false, nil
646+}
647+
648+func createWorktreeBranchExists(ctx context.Context, repoRoot, branch string) (bool, error) {
649+ _, err := createWorktreeGitOutput(ctx, repoRoot, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
650+ if err == nil {
651+ return true, nil
652+ }
653+ if ctx.Err() != nil {
654+ return false, ctx.Err()
655+ }
656+ if createWorktreeGitExitCode(err) == 1 {
657+ return false, nil
658+ }
659+ return false, err
660+}
661+
662+func createWorktreeGitRef(ctx context.Context, repoRoot, ref string) (string, bool, error) {
663+ output, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "--quiet", ref+"^{commit}")
664+ if err == nil {
665+ return output, true, nil
666+ }
667+ if createWorktreeGitExitCode(err) == 1 {
668+ return "", false, nil
669+ }
670+ return "", false, err
671+}
672+
673+func createWorktreeGitConfigValue(ctx context.Context, repoRoot, key string) (string, bool, error) {
674+ output, err := createWorktreeGitOutput(ctx, repoRoot, "config", "--local", "--get", key)
675+ if err == nil {
676+ return output, true, nil
677+ }
678+ if createWorktreeGitExitCode(err) == 1 {
679+ return "", false, nil
680+ }
681+ return "", false, err
682+}
683+
684+type createWorktreeGitError struct {
685+ args []string
686+ output string
687+ err error
688+}
689+
690+func (e *createWorktreeGitError) Error() string {
691+ message := strings.TrimSpace(e.output)
692+ if message == "" {
693+ message = e.err.Error()
694+ }
695+ return fmt.Sprintf("git %s: %s", strings.Join(e.args, " "), message)
696+}
697+
698+func (e *createWorktreeGitError) Unwrap() error { return e.err }
699+
700+func createWorktreeGitExitCode(err error) int {
701+ var exitErr *exec.ExitError
702+ if errors.As(err, &exitErr) {
703+ return exitErr.ExitCode()
704+ }
705+ return -1
706+}
707+
708+func createWorktreeGitOutput(ctx context.Context, dir string, args ...string) (string, error) {
709+ cmdArgs := append([]string{"-c", "core.hooksPath=" + os.DevNull, "-C", dir}, args...)
710+ cmd := exec.CommandContext(ctx, "git", cmdArgs...)
711+ cmd.Env = createWorktreeGitEnv()
712+ setProcessGroup(cmd)
713+
714+ var stdout, output cappedGitOutput
715+ cmd.Stdout = io.MultiWriter(&stdout, &output)
716+ cmd.Stderr = &output
717+ if err := cmd.Start(); err != nil {
718+ return "", fmt.Errorf("start git %s: %w", strings.Join(args, " "), err)
719+ }
720+ watchDone := make(chan struct{})
721+ watchStopped := make(chan struct{})
722+ go func() {
723+ defer close(watchStopped)
724+ select {
725+ case <-ctx.Done():
726+ killProcessGroup(cmd)
727+ case <-watchDone:
728+ }
729+ }()
730+ waitErr := cmd.Wait()
731+ close(watchDone)
732+ <-watchStopped
733+ if waitErr != nil {
734+ if ctx.Err() != nil {
735+ return "", ctx.Err()
736+ }
737+ return "", &createWorktreeGitError{args: args, output: output.String(), err: waitErr}
738+ }
739+ return strings.TrimSpace(stdout.String()), nil
740+}
741+
742+type cappedGitOutput struct {
743+ mu sync.Mutex
744+ buffer bytes.Buffer
745+ truncated bool
746+}
747+
748+func (b *cappedGitOutput) Write(p []byte) (int, error) {
749+ b.mu.Lock()
750+ defer b.mu.Unlock()
751+ if room := maxCreateWorktreeGitOutput - b.buffer.Len(); room > 0 {
752+ if len(p) > room {
753+ _, _ = b.buffer.Write(p[:room])
754+ b.truncated = true
755+ } else {
756+ _, _ = b.buffer.Write(p)
757+ }
758+ } else {
759+ b.truncated = true
760+ }
761+ return len(p), nil
762+}
763+
764+func (b *cappedGitOutput) String() string {
765+ b.mu.Lock()
766+ defer b.mu.Unlock()
767+ output := b.buffer.String()
768+ if b.truncated {
769+ output += "\n... [git output truncated]"
770+ }
771+ return output
772+}
773+
774+func createWorktreeGitEnv() []string {
775+ env := make([]string, 0, len(os.Environ())+5)
776+ for _, entry := range os.Environ() {
777+ key, _, _ := strings.Cut(entry, "=")
778+ if strings.HasPrefix(key, "GIT_") || key == "SSH_ASKPASS" {
779+ continue
780+ }
781+ env = append(env, entry)
782+ }
783+ return append(env,
784+ "GIT_CONFIG_NOSYSTEM=1",
785+ "GIT_CONFIG_GLOBAL="+os.DevNull,
786+ "GIT_TERMINAL_PROMPT=0",
787+ "GIT_ASKPASS=",
788+ "SSH_ASKPASS=",
789+ )
790+}
791+
792+type worktreesIgnoreUpdate struct {
793+ path string
794+ before []byte
795+ after []byte
796+ existed bool
797+ mode os.FileMode
798+ changed bool
799+}
800+
801+func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) {
802+ contents, mode, existed, err := readWorktreesIgnore(path)
803+ if err != nil {
804+ return worktreesIgnoreUpdate{}, err
805+ }
806+ for _, line := range strings.Split(string(contents), "\n") {
807+ if strings.TrimSuffix(line, "\r") == worktreesIgnoreEntry {
808+ return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil
809+ }
810+ }
811+ lineEnding := "\n"
812+ if bytes.Contains(contents, []byte("\r\n")) {
813+ lineEnding = "\r\n"
814+ }
815+ after := append([]byte(nil), contents...)
816+ if len(after) > 0 && !strings.HasSuffix(string(after), "\n") {
817+ after = append(after, lineEnding...)
818+ }
819+ after = append(after, []byte(worktreesIgnoreEntry+lineEnding)...)
820+ return worktreesIgnoreUpdate{path: path, before: contents, after: after, existed: existed, mode: mode, changed: true}, nil
821+}
822+
823+func (u worktreesIgnoreUpdate) apply() error {
824+ if !u.changed {
825+ return nil
826+ }
827+ current, _, existed, err := readWorktreesIgnore(u.path)
828+ if err != nil {
829+ return err
830+ }
831+ if existed != u.existed || !bytes.Equal(current, u.before) {
832+ return errors.New(".gitignore changed since preview; inspect it and retry")
833+ }
834+ return writeWorktreesIgnore(u.path, u.after, u.mode)
835+}
836+
837+func (u worktreesIgnoreUpdate) restore() error {
838+ if !u.changed {
839+ return nil
840+ }
841+ current, _, existed, err := readWorktreesIgnore(u.path)
842+ if err != nil {
843+ return err
844+ }
845+ if !existed || !bytes.Equal(current, u.after) {
846+ return nil
847+ }
848+ if !u.existed {
849+ if err := os.Remove(u.path); err != nil && !os.IsNotExist(err) {
850+ return err
851+ }
852+ return nil
853+ }
854+ return writeWorktreesIgnore(u.path, u.before, u.mode)
855+}
856+
857+func readWorktreesIgnore(path string) ([]byte, os.FileMode, bool, error) {
858+ info, err := os.Lstat(path)
859+ if os.IsNotExist(err) {
860+ return nil, 0o644, false, nil
861+ }
862+ if err != nil {
863+ return nil, 0, false, err
864+ }
865+ if info.Mode()&os.ModeSymlink != 0 {
866+ return nil, 0, false, errors.New(".gitignore must not be a symbolic link")
867+ }
868+ if !info.Mode().IsRegular() {
869+ return nil, 0, false, errors.New(".gitignore must be a regular file")
870+ }
871+ if info.Size() > maxCreateWorktreeIgnoreBytes {
872+ return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes)
873+ }
874+ file, err := os.Open(path)
875+ if err != nil {
876+ return nil, 0, false, err
877+ }
878+ defer file.Close()
879+ contents, err := io.ReadAll(io.LimitReader(file, maxCreateWorktreeIgnoreBytes+1))
880+ if err != nil {
881+ return nil, 0, false, err
882+ }
883+ if len(contents) > maxCreateWorktreeIgnoreBytes {
884+ return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes)
885+ }
886+ return contents, info.Mode().Perm(), true, nil
887+}
888+
889+func writeWorktreesIgnore(path string, contents []byte, mode os.FileMode) error {
890+ file, err := os.CreateTemp(filepath.Dir(path), ".zut-gitignore-*")
891+ if err != nil {
892+ return err
893+ }
894+ tempPath := file.Name()
895+ defer os.Remove(tempPath)
896+ if err := file.Chmod(mode); err != nil {
897+ _ = file.Close()
898+ return err
899+ }
900+ if _, err := file.Write(contents); err != nil {
901+ _ = file.Close()
902+ return err
903+ }
904+ if err := file.Close(); err != nil {
905+ return err
906+ }
907+ return os.Rename(tempPath, path)
908+}
@@ -0,0 +1,908 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "strings"
14+ "sync"
15+ "time"
16+
17+ "github.com/bnema/zut/packages/core"
18+ "github.com/bnema/zut/packages/provider"
19+)
20+
21+const (
22+ worktreesIgnoreEntry = "/.worktrees/"
23+ defaultWorktreeRoot = ".worktrees"
24+ worktreeConfigKey = "zut.worktrees.path"
25+ maxCreateWorktreeGitOutput = 64 * 1024
26+ maxCreateWorktreeIgnoreBytes = 1 << 20
27+)
28+
29+var createWorktreeOperationMu sync.Mutex
30+
31+// CreateWorktreeTool creates a persistent branch checkout. A repository that
32+// has neither a local worktree configuration nor an existing .worktrees
33+// directory returns a bootstrap request without changing any files.
34+type CreateWorktreeTool struct {
35+ CWD string
36+ Sandbox *Sandbox
37+
38+ previewMu sync.Mutex
39+ preview *createWorktreePreview
40+}
41+
42+type createWorktreeArgs struct {
43+ Branch string `json:"branch"`
44+ BootstrapRoot string `json:"bootstrap_root,omitempty"`
45+}
46+
47+const createWorktreeSchema = `{
48+ "type":"object",
49+ "properties":{
50+ "branch":{
51+ "type":"string",
52+ "description":"Name of the new Git branch."
53+ },
54+ "bootstrap_root":{
55+ "type":"string",
56+ "description":"Only for an unconfigured repository: use .worktrees to initialize the repository-root default, or provide an absolute external worktree root. The choice is stored in local Git config. Omit it on the first call to request user guidance instead of changing files."
57+ }
58+ },
59+ "required":["branch"]
60+}`
61+
62+func (t *CreateWorktreeTool) Name() string { return "create_worktree" }
63+func (t *CreateWorktreeTool) Description() string {
64+ return "Create a persistent Git branch worktree. An unconfigured repository returns bootstrap guidance; ask the user for .worktrees or an absolute external root, then call again with bootstrap_root."
65+}
66+func (t *CreateWorktreeTool) Schema() json.RawMessage { return json.RawMessage(createWorktreeSchema) }
67+
68+// Preview reports the exact bootstrap request or checkout that Execute will
69+// produce without modifying the repository.
70+func (t *CreateWorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
71+ plan, err := t.plan(ctx, raw)
72+ if err != nil {
73+ return core.ToolResult{}, err
74+ }
75+ t.previewMu.Lock()
76+ t.preview = &createWorktreePreview{raw: string(raw), plan: plan}
77+ t.previewMu.Unlock()
78+ return plan.preview, nil
79+}
80+
81+func (t *CreateWorktreeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
82+ createWorktreeOperationMu.Lock()
83+ defer createWorktreeOperationMu.Unlock()
84+
85+ plan, err := t.planForExecute(ctx, raw)
86+ if err != nil {
87+ return core.ToolResult{}, err
88+ }
89+ if plan.bootstrapRequired {
90+ return plan.preview, nil
91+ }
92+ return executeCreateWorktreePlan(ctx, plan, progress)
93+}
94+
95+func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, progress func(string)) (core.ToolResult, error) {
96+ configSaved := false
97+ var createdWorktreeParents []string
98+ if plan.setConfig {
99+ if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "config", "--local", "--replace-all", worktreeConfigKey, plan.configValue); err != nil {
100+ return core.ToolResult{}, fmt.Errorf("create_worktree: save worktree root: %w", err)
101+ }
102+ configSaved = true
103+ }
104+ rollback := func(cause error) error {
105+ cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
106+ defer cancel()
107+ if err := plan.cleanupFailedCheckout(cleanupCtx); err != nil {
108+ cause = fmt.Errorf("%w; cleanup worktree: %v", cause, err)
109+ }
110+ if err := removeCreatedWorktreeParents(createdWorktreeParents); err != nil {
111+ cause = fmt.Errorf("%w; remove created worktree directories: %v", cause, err)
112+ }
113+ if err := plan.ignore.restore(); err != nil {
114+ cause = fmt.Errorf("%w; restore .gitignore: %v", cause, err)
115+ }
116+ if configSaved {
117+ if err := plan.removeBootstrapConfig(cleanupCtx); err != nil {
118+ cause = fmt.Errorf("%w; remove local worktree configuration: %v", cause, err)
119+ }
120+ }
121+ return cause
122+ }
123+ if err := plan.ignore.apply(); err != nil {
124+ return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: update .gitignore: %w", err))
125+ }
126+
127+ createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath)
128+ if err != nil {
129+ return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: inspect worktree directories: %w", err))
130+ }
131+ if err := os.MkdirAll(filepath.Dir(plan.worktreePath), 0o755); err != nil {
132+ return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: make worktree directory: %w", err))
133+ }
134+ if progress != nil {
135+ progress("Creating Git worktree...\n")
136+ }
137+ if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "worktree", "add", "-b", plan.branch, plan.worktreePath, plan.base); err != nil {
138+ return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: create worktree: %w", err))
139+ }
140+
141+ result := plan.preview
142+ previewDetails, _ := plan.preview.Details.(map[string]any)
143+ details := make(map[string]any, len(previewDetails)+1)
144+ for key, value := range previewDetails {
145+ details[key] = value
146+ }
147+ details["state"] = "created"
148+ result.Details = details
149+ result.Content = []provider.Content{provider.TextBlock{Text: strings.Replace(plan.previewText, "create worktree", "created worktree", 1)}}
150+ return result, nil
151+}
152+
153+type createWorktreePreview struct {
154+ raw string
155+ plan createWorktreePlan
156+}
157+
158+func (t *CreateWorktreeTool) planForExecute(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {
159+ t.previewMu.Lock()
160+ cached := t.preview
161+ t.preview = nil
162+ t.previewMu.Unlock()
163+
164+ plan, err := t.plan(ctx, raw)
165+ if err != nil {
166+ return createWorktreePlan{}, err
167+ }
168+ if cached != nil && cached.raw == string(raw) && !sameCreateWorktreePlan(cached.plan, plan) {
169+ return createWorktreePlan{}, errors.New("create_worktree: repository state changed since preview; inspect and retry")
170+ }
171+ return plan, nil
172+}
173+
174+type createWorktreePlan struct {
175+ repoRoot string
176+ branch string
177+ base string
178+ worktreeRoot string
179+ worktreePath string
180+ rootSource string
181+ configValue string
182+ setConfig bool
183+ bootstrapRequired bool
184+ previewText string
185+ preview core.ToolResult
186+ ignore worktreesIgnoreUpdate
187+}
188+
189+func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {
190+ var args createWorktreeArgs
191+ if err := json.Unmarshal(raw, &args); err != nil {
192+ return createWorktreePlan{}, fmt.Errorf("invalid args: %w", err)
193+ }
194+ branchInput := strings.TrimSpace(args.Branch)
195+ if branchInput == "" {
196+ return createWorktreePlan{}, errors.New("branch is required")
197+ }
198+ if strings.HasPrefix(branchInput, "-") || strings.Contains(branchInput, "@{") {
199+ return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)
200+ }
201+ if t.Sandbox == nil {
202+ return createWorktreePlan{}, errors.New("create_worktree: sandbox is required")
203+ }
204+ if err := t.Sandbox.CheckBashPermission("git"); err != nil {
205+ return createWorktreePlan{}, fmt.Errorf("create_worktree: %w", err)
206+ }
207+
208+ cwd, err := t.accessibleCWD()
209+ if err != nil {
210+ return createWorktreePlan{}, err
211+ }
212+ candidateRoot, err := t.findGitRootCandidate(cwd)
213+ if err != nil {
214+ return createWorktreePlan{}, err
215+ }
216+ repoRoot, err := createWorktreeGitOutput(ctx, candidateRoot, "rev-parse", "--show-toplevel")
217+ if err != nil {
218+ return createWorktreePlan{}, fmt.Errorf("create_worktree: verify repository: %w", err)
219+ }
220+ repoRoot, err = canonical(repoRoot)
221+ if err != nil {
222+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve repository root: %w", err)
223+ }
224+ if err := t.checkReadAccess(repoRoot); err != nil {
225+ return createWorktreePlan{}, err
226+ }
227+ branch, err := createWorktreeGitOutput(ctx, repoRoot, "check-ref-format", "--branch", branchInput)
228+ if err != nil {
229+ return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q: %w", branchInput, err)
230+ }
231+ if branch != branchInput {
232+ return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)
233+ }
234+ base, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "HEAD^{commit}")
235+ if err != nil {
236+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve current commit: %w", err)
237+ }
238+ commonDir, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--git-common-dir")
239+ if err != nil {
240+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err)
241+ }
242+ if !filepath.IsAbs(commonDir) {
243+ commonDir = filepath.Join(repoRoot, commonDir)
244+ }
245+ commonDir, err = canonical(commonDir)
246+ if err != nil {
247+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err)
248+ }
249+ if err := t.checkReadAccess(commonDir); err != nil {
250+ return createWorktreePlan{}, err
251+ }
252+
253+ configuredRoot, configured, err := createWorktreeGitConfigValue(ctx, repoRoot, worktreeConfigKey)
254+ if err != nil {
255+ return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err)
256+ }
257+ bootstrapRoot := strings.TrimSpace(args.BootstrapRoot)
258+ if configured && bootstrapRoot != "" {
259+ return createWorktreePlan{}, errors.New("create_worktree: worktree root is already configured; change the local Git configuration explicitly before bootstrapping again")
260+ }
261+
262+ rootSource := "existing .worktrees directory"
263+ configValue := ""
264+ setConfig := false
265+ defaultRoot := false
266+ var worktreeRoot string
267+ if configured {
268+ worktreeRoot, defaultRoot, err = resolveConfiguredWorktreeRoot(repoRoot, configuredRoot)
269+ if err != nil {
270+ return createWorktreePlan{}, err
271+ }
272+ rootSource = "local Git config"
273+ } else {
274+ defaultPath := filepath.Join(repoRoot, defaultWorktreeRoot)
275+ exists, err := inspectWorktreeRoot(defaultPath)
276+ if err != nil {
277+ return createWorktreePlan{}, err
278+ }
279+ if !exists && bootstrapRoot == "" {
280+ return bootstrapRequiredPlan(branch, repoRoot), nil
281+ }
282+ if exists {
283+ if bootstrapRoot != "" {
284+ return createWorktreePlan{}, errors.New("create_worktree: worktree root is already established; omit bootstrap_root")
285+ }
286+ worktreeRoot = defaultPath
287+ defaultRoot = true
288+ } else {
289+ worktreeRoot, defaultRoot, configValue, err = resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot)
290+ if err != nil {
291+ return createWorktreePlan{}, err
292+ }
293+ setConfig = true
294+ rootSource = "bootstrap selection"
295+ }
296+ }
297+
298+ worktreeRoot, err = validateWorktreeRoot(worktreeRoot, defaultRoot)
299+ if err != nil {
300+ return createWorktreePlan{}, err
301+ }
302+ worktreePath := filepath.Join(worktreeRoot, filepath.FromSlash(branch))
303+ rootCanonical, err := canonicalOrParent(worktreeRoot)
304+ if err != nil {
305+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree root: %w", err)
306+ }
307+ if !defaultRoot && isUnder(repoRoot, rootCanonical) {
308+ return createWorktreePlan{}, errors.New("create_worktree: external worktree root must be outside the repository")
309+ }
310+ pathCanonical, err := canonicalOrParent(worktreePath)
311+ if err != nil {
312+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree path: %w", err)
313+ }
314+ if !isUnder(rootCanonical, pathCanonical) {
315+ return createWorktreePlan{}, errors.New("create_worktree: branch path escapes configured worktree root")
316+ }
317+
318+ ignorePath := ""
319+ accessPaths := []string{repoRoot, commonDir, worktreeRoot, worktreePath}
320+ if defaultRoot {
321+ ignorePath = filepath.Join(repoRoot, ".gitignore")
322+ accessPaths = append(accessPaths, ignorePath)
323+ }
324+ if err := t.checkReadWriteAccess(accessPaths...); err != nil {
325+ return createWorktreePlan{}, err
326+ }
327+ if _, err := os.Lstat(worktreePath); err == nil {
328+ return createWorktreePlan{}, fmt.Errorf("create_worktree: worktree path %q already exists", filepath.ToSlash(worktreePath))
329+ } else if !os.IsNotExist(err) {
330+ return createWorktreePlan{}, fmt.Errorf("create_worktree: inspect worktree path: %w", err)
331+ }
332+ exists, err := createWorktreeBranchExists(ctx, repoRoot, branch)
333+ if err != nil {
334+ return createWorktreePlan{}, fmt.Errorf("create_worktree: check branch: %w", err)
335+ }
336+ if exists {
337+ return createWorktreePlan{}, fmt.Errorf("create_worktree: branch %q already exists", branch)
338+ }
339+
340+ ignore := worktreesIgnoreUpdate{}
341+ ignoreAction := "not modified (external worktree root)"
342+ if defaultRoot {
343+ ignore, err = newWorktreesIgnoreUpdate(ignorePath)
344+ if err != nil {
345+ return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err)
346+ }
347+ ignoreAction = "already contains " + worktreesIgnoreEntry
348+ if ignore.changed {
349+ ignoreAction = "add " + worktreesIgnoreEntry
350+ }
351+ }
352+
353+ displayRoot := worktreeRoot
354+ if defaultRoot {
355+ displayRoot = defaultWorktreeRoot
356+ }
357+ displayPath := displayWorktreePath(repoRoot, worktreePath, defaultRoot)
358+ configAction := ""
359+ if setConfig {
360+ configAction = fmt.Sprintf("local config: set %s=%s\n", worktreeConfigKey, configValue)
361+ }
362+ previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n%s.gitignore: %s\n", branch, base, filepath.ToSlash(displayRoot), rootSource, displayPath, configAction, ignoreAction)
363+ return createWorktreePlan{
364+ repoRoot: repoRoot,
365+ branch: branch,
366+ base: base,
367+ worktreeRoot: worktreeRoot,
368+ worktreePath: worktreePath,
369+ rootSource: rootSource,
370+ configValue: configValue,
371+ setConfig: setConfig,
372+ previewText: previewText,
373+ preview: core.ToolResult{
374+ Content: []provider.Content{provider.TextBlock{Text: previewText}},
375+ Details: map[string]any{
376+ "state": "ready",
377+ "branch": branch,
378+ "base": base,
379+ "worktree_root": displayRoot,
380+ "root_source": rootSource,
381+ "path": displayPath,
382+ "bootstrap_saved": setConfig,
383+ },
384+ },
385+ ignore: ignore,
386+ }, nil
387+}
388+
389+func bootstrapRequiredPlan(branch, repoRoot string) createWorktreePlan {
390+ text := fmt.Sprintf("Worktree bootstrap is required.\nbranch: %s\nrepository: %s\nChoose a location with the user, then call create_worktree again with bootstrap_root set to .worktrees for the repository default or to an absolute external directory. This call made no changes.\n", branch, filepath.ToSlash(repoRoot))
391+ return createWorktreePlan{
392+ branch: branch,
393+ bootstrapRequired: true,
394+ previewText: text,
395+ preview: core.ToolResult{
396+ Content: []provider.Content{provider.TextBlock{Text: text}},
397+ Details: map[string]any{
398+ "state": "bootstrap_required",
399+ "branch": branch,
400+ "default_bootstrap_root": defaultWorktreeRoot,
401+ },
402+ },
403+ }
404+}
405+
406+func (t *CreateWorktreeTool) accessibleCWD() (string, error) {
407+ cwd := t.CWD
408+ if cwd == "" {
409+ var err error
410+ cwd, err = os.Getwd()
411+ if err != nil {
412+ return "", fmt.Errorf("create_worktree: working directory: %w", err)
413+ }
414+ }
415+ cwd, err := canonical(cwd)
416+ if err != nil {
417+ return "", fmt.Errorf("create_worktree: resolve working directory: %w", err)
418+ }
419+ if err := t.checkReadAccess(cwd); err != nil {
420+ return "", err
421+ }
422+ return cwd, nil
423+}
424+
425+func (t *CreateWorktreeTool) findGitRootCandidate(cwd string) (string, error) {
426+ for dir := cwd; ; dir = filepath.Dir(dir) {
427+ if err := t.checkReadAccess(dir); err != nil {
428+ return "", err
429+ }
430+ if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
431+ return dir, nil
432+ } else if !os.IsNotExist(err) {
433+ return "", fmt.Errorf("create_worktree: inspect Git metadata: %w", err)
434+ }
435+ parent := filepath.Dir(dir)
436+ if parent == dir {
437+ break
438+ }
439+ }
440+ return "", errors.New("create_worktree: working directory is not a Git repository")
441+}
442+
443+func (t *CreateWorktreeTool) checkReadAccess(paths ...string) error {
444+ for _, path := range paths {
445+ if err := t.Sandbox.CheckReadPath(path); err != nil {
446+ return fmt.Errorf("create_worktree: %w", err)
447+ }
448+ }
449+ return nil
450+}
451+
452+func (t *CreateWorktreeTool) checkReadWriteAccess(paths ...string) error {
453+ for _, path := range paths {
454+ if err := t.Sandbox.CheckReadPath(path); err != nil {
455+ return fmt.Errorf("create_worktree: %w", err)
456+ }
457+ if err := t.Sandbox.CheckWritePath(path); err != nil {
458+ return fmt.Errorf("create_worktree: %w", err)
459+ }
460+ }
461+ return nil
462+}
463+
464+func resolveConfiguredWorktreeRoot(repoRoot, value string) (string, bool, error) {
465+ value = strings.TrimSpace(value)
466+ if value == defaultWorktreeRoot {
467+ return filepath.Join(repoRoot, defaultWorktreeRoot), true, nil
468+ }
469+ if !filepath.IsAbs(value) {
470+ return "", false, errors.New("create_worktree: local worktree configuration must be .worktrees or an absolute external path")
471+ }
472+ return value, false, nil
473+}
474+
475+func resolveBootstrapWorktreeRoot(repoRoot, value string) (string, bool, string, error) {
476+ if value == defaultWorktreeRoot {
477+ return filepath.Join(repoRoot, defaultWorktreeRoot), true, defaultWorktreeRoot, nil
478+ }
479+ if !filepath.IsAbs(value) {
480+ return "", false, "", errors.New("create_worktree: bootstrap_root must be .worktrees or an absolute external path")
481+ }
482+ root, err := canonicalOrParent(value)
483+ if err != nil {
484+ return "", false, "", fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err)
485+ }
486+ if isUnder(repoRoot, root) {
487+ return "", false, "", errors.New("create_worktree: external bootstrap_root must be outside the repository")
488+ }
489+ return root, false, root, nil
490+}
491+
492+func validateWorktreeRoot(path string, defaultRoot bool) (string, error) {
493+ info, err := os.Lstat(path)
494+ if os.IsNotExist(err) {
495+ return path, nil
496+ }
497+ if err != nil {
498+ return "", fmt.Errorf("create_worktree: inspect worktree root: %w", err)
499+ }
500+ if info.Mode()&os.ModeSymlink != 0 {
501+ return "", errors.New("create_worktree: worktree root must not be a symbolic link")
502+ }
503+ if !info.IsDir() {
504+ return "", errors.New("create_worktree: worktree root exists but is not a directory")
505+ }
506+ if defaultRoot && filepath.Base(path) != defaultWorktreeRoot {
507+ return "", errors.New("create_worktree: invalid default worktree root")
508+ }
509+ return path, nil
510+}
511+
512+func inspectWorktreeRoot(path string) (bool, error) {
513+ info, err := os.Lstat(path)
514+ if os.IsNotExist(err) {
515+ return false, nil
516+ }
517+ if err != nil {
518+ return false, fmt.Errorf("create_worktree: inspect .worktrees: %w", err)
519+ }
520+ if info.Mode()&os.ModeSymlink != 0 {
521+ return false, errors.New("create_worktree: .worktrees must not be a symbolic link")
522+ }
523+ if !info.IsDir() {
524+ return false, errors.New("create_worktree: .worktrees exists but is not a directory")
525+ }
526+ return true, nil
527+}
528+
529+func displayWorktreePath(repoRoot, path string, defaultRoot bool) string {
530+ if defaultRoot {
531+ relative, err := filepath.Rel(repoRoot, path)
532+ if err == nil {
533+ return filepath.ToSlash(relative)
534+ }
535+ }
536+ return filepath.ToSlash(path)
537+}
538+
539+func sameCreateWorktreePlan(a, b createWorktreePlan) bool {
540+ return a.bootstrapRequired == b.bootstrapRequired &&
541+ a.repoRoot == b.repoRoot &&
542+ a.branch == b.branch &&
543+ a.base == b.base &&
544+ a.worktreeRoot == b.worktreeRoot &&
545+ a.worktreePath == b.worktreePath &&
546+ a.rootSource == b.rootSource &&
547+ a.configValue == b.configValue &&
548+ a.setConfig == b.setConfig &&
549+ a.ignore.path == b.ignore.path &&
550+ a.ignore.existed == b.ignore.existed &&
551+ a.ignore.changed == b.ignore.changed &&
552+ bytes.Equal(a.ignore.before, b.ignore.before)
553+}
554+
555+func missingWorktreeParents(worktreePath string) ([]string, error) {
556+ var missing []string
557+ for directory := filepath.Dir(worktreePath); ; directory = filepath.Dir(directory) {
558+ info, err := os.Lstat(directory)
559+ if err == nil {
560+ if !info.IsDir() {
561+ return nil, fmt.Errorf("%q is not a directory", directory)
562+ }
563+ break
564+ }
565+ if !os.IsNotExist(err) {
566+ return nil, err
567+ }
568+ missing = append(missing, directory)
569+ parent := filepath.Dir(directory)
570+ if parent == directory {
571+ return nil, errors.New("no existing parent directory")
572+ }
573+ }
574+ for left, right := 0, len(missing)-1; left < right; left, right = left+1, right-1 {
575+ missing[left], missing[right] = missing[right], missing[left]
576+ }
577+ return missing, nil
578+}
579+
580+func removeCreatedWorktreeParents(parents []string) error {
581+ for index := len(parents) - 1; index >= 0; index-- {
582+ if err := os.Remove(parents[index]); err != nil && !os.IsNotExist(err) {
583+ return err
584+ }
585+ }
586+ return nil
587+}
588+
589+func (p createWorktreePlan) removeBootstrapConfig(ctx context.Context) error {
590+ value, configured, err := createWorktreeGitConfigValue(ctx, p.repoRoot, worktreeConfigKey)
591+ if err != nil {
592+ return err
593+ }
594+ if !configured {
595+ return nil
596+ }
597+ if value != p.configValue {
598+ return errors.New("local worktree configuration changed during creation")
599+ }
600+ _, err = createWorktreeGitOutput(ctx, p.repoRoot, "config", "--local", "--unset-all", worktreeConfigKey)
601+ return err
602+}
603+
604+func (p createWorktreePlan) cleanupFailedCheckout(ctx context.Context) error {
605+ registered, err := createWorktreeRegistered(ctx, p.repoRoot, p.worktreePath)
606+ if err != nil {
607+ return err
608+ }
609+ var cleanupErr error
610+ if registered {
611+ if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "worktree", "remove", "--force", p.worktreePath); err != nil {
612+ cleanupErr = fmt.Errorf("remove registered worktree: %w", err)
613+ }
614+ }
615+ branchHead, exists, err := createWorktreeGitRef(ctx, p.repoRoot, "refs/heads/"+p.branch)
616+ if err == nil && exists && branchHead == p.base {
617+ if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "branch", "-D", p.branch); err != nil && cleanupErr == nil {
618+ cleanupErr = fmt.Errorf("remove branch: %w", err)
619+ }
620+ }
621+ if err != nil && cleanupErr == nil {
622+ cleanupErr = err
623+ }
624+ return cleanupErr
625+}
626+
627+func createWorktreeRegistered(ctx context.Context, repoRoot, worktreePath string) (bool, error) {
628+ output, err := createWorktreeGitOutput(ctx, repoRoot, "worktree", "list", "--porcelain")
629+ if err != nil {
630+ return false, err
631+ }
632+ want, err := canonicalOrParent(worktreePath)
633+ if err != nil {
634+ return false, err
635+ }
636+ for _, line := range strings.Split(output, "\n") {
637+ if !strings.HasPrefix(line, "worktree ") {
638+ continue
639+ }
640+ path, err := canonicalOrParent(strings.TrimPrefix(line, "worktree "))
641+ if err == nil && path == want {
642+ return true, nil
643+ }
644+ }
645+ return false, nil
646+}
647+
648+func createWorktreeBranchExists(ctx context.Context, repoRoot, branch string) (bool, error) {
649+ _, err := createWorktreeGitOutput(ctx, repoRoot, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
650+ if err == nil {
651+ return true, nil
652+ }
653+ if ctx.Err() != nil {
654+ return false, ctx.Err()
655+ }
656+ if createWorktreeGitExitCode(err) == 1 {
657+ return false, nil
658+ }
659+ return false, err
660+}
661+
662+func createWorktreeGitRef(ctx context.Context, repoRoot, ref string) (string, bool, error) {
663+ output, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "--quiet", ref+"^{commit}")
664+ if err == nil {
665+ return output, true, nil
666+ }
667+ if createWorktreeGitExitCode(err) == 1 {
668+ return "", false, nil
669+ }
670+ return "", false, err
671+}
672+
673+func createWorktreeGitConfigValue(ctx context.Context, repoRoot, key string) (string, bool, error) {
674+ output, err := createWorktreeGitOutput(ctx, repoRoot, "config", "--local", "--get", key)
675+ if err == nil {
676+ return output, true, nil
677+ }
678+ if createWorktreeGitExitCode(err) == 1 {
679+ return "", false, nil
680+ }
681+ return "", false, err
682+}
683+
684+type createWorktreeGitError struct {
685+ args []string
686+ output string
687+ err error
688+}
689+
690+func (e *createWorktreeGitError) Error() string {
691+ message := strings.TrimSpace(e.output)
692+ if message == "" {
693+ message = e.err.Error()
694+ }
695+ return fmt.Sprintf("git %s: %s", strings.Join(e.args, " "), message)
696+}
697+
698+func (e *createWorktreeGitError) Unwrap() error { return e.err }
699+
700+func createWorktreeGitExitCode(err error) int {
701+ var exitErr *exec.ExitError
702+ if errors.As(err, &exitErr) {
703+ return exitErr.ExitCode()
704+ }
705+ return -1
706+}
707+
708+func createWorktreeGitOutput(ctx context.Context, dir string, args ...string) (string, error) {
709+ cmdArgs := append([]string{"-c", "core.hooksPath=" + os.DevNull, "-C", dir}, args...)
710+ cmd := exec.CommandContext(ctx, "git", cmdArgs...)
711+ cmd.Env = createWorktreeGitEnv()
712+ setProcessGroup(cmd)
713+
714+ var stdout, output cappedGitOutput
715+ cmd.Stdout = io.MultiWriter(&stdout, &output)
716+ cmd.Stderr = &output
717+ if err := cmd.Start(); err != nil {
718+ return "", fmt.Errorf("start git %s: %w", strings.Join(args, " "), err)
719+ }
720+ watchDone := make(chan struct{})
721+ watchStopped := make(chan struct{})
722+ go func() {
723+ defer close(watchStopped)
724+ select {
725+ case <-ctx.Done():
726+ killProcessGroup(cmd)
727+ case <-watchDone:
728+ }
729+ }()
730+ waitErr := cmd.Wait()
731+ close(watchDone)
732+ <-watchStopped
733+ if waitErr != nil {
734+ if ctx.Err() != nil {
735+ return "", ctx.Err()
736+ }
737+ return "", &createWorktreeGitError{args: args, output: output.String(), err: waitErr}
738+ }
739+ return strings.TrimSpace(stdout.String()), nil
740+}
741+
742+type cappedGitOutput struct {
743+ mu sync.Mutex
744+ buffer bytes.Buffer
745+ truncated bool
746+}
747+
748+func (b *cappedGitOutput) Write(p []byte) (int, error) {
749+ b.mu.Lock()
750+ defer b.mu.Unlock()
751+ if room := maxCreateWorktreeGitOutput - b.buffer.Len(); room > 0 {
752+ if len(p) > room {
753+ _, _ = b.buffer.Write(p[:room])
754+ b.truncated = true
755+ } else {
756+ _, _ = b.buffer.Write(p)
757+ }
758+ } else {
759+ b.truncated = true
760+ }
761+ return len(p), nil
762+}
763+
764+func (b *cappedGitOutput) String() string {
765+ b.mu.Lock()
766+ defer b.mu.Unlock()
767+ output := b.buffer.String()
768+ if b.truncated {
769+ output += "\n... [git output truncated]"
770+ }
771+ return output
772+}
773+
774+func createWorktreeGitEnv() []string {
775+ env := make([]string, 0, len(os.Environ())+5)
776+ for _, entry := range os.Environ() {
777+ key, _, _ := strings.Cut(entry, "=")
778+ if strings.HasPrefix(key, "GIT_") || key == "SSH_ASKPASS" {
779+ continue
780+ }
781+ env = append(env, entry)
782+ }
783+ return append(env,
784+ "GIT_CONFIG_NOSYSTEM=1",
785+ "GIT_CONFIG_GLOBAL="+os.DevNull,
786+ "GIT_TERMINAL_PROMPT=0",
787+ "GIT_ASKPASS=",
788+ "SSH_ASKPASS=",
789+ )
790+}
791+
792+type worktreesIgnoreUpdate struct {
793+ path string
794+ before []byte
795+ after []byte
796+ existed bool
797+ mode os.FileMode
798+ changed bool
799+}
800+
801+func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) {
802+ contents, mode, existed, err := readWorktreesIgnore(path)
803+ if err != nil {
804+ return worktreesIgnoreUpdate{}, err
805+ }
806+ for _, line := range strings.Split(string(contents), "\n") {
807+ if strings.TrimSuffix(line, "\r") == worktreesIgnoreEntry {
808+ return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil
809+ }
810+ }
811+ lineEnding := "\n"
812+ if bytes.Contains(contents, []byte("\r\n")) {
813+ lineEnding = "\r\n"
814+ }
815+ after := append([]byte(nil), contents...)
816+ if len(after) > 0 && !strings.HasSuffix(string(after), "\n") {
817+ after = append(after, lineEnding...)
818+ }
819+ after = append(after, []byte(worktreesIgnoreEntry+lineEnding)...)
820+ return worktreesIgnoreUpdate{path: path, before: contents, after: after, existed: existed, mode: mode, changed: true}, nil
821+}
822+
823+func (u worktreesIgnoreUpdate) apply() error {
824+ if !u.changed {
825+ return nil
826+ }
827+ current, _, existed, err := readWorktreesIgnore(u.path)
828+ if err != nil {
829+ return err
830+ }
831+ if existed != u.existed || !bytes.Equal(current, u.before) {
832+ return errors.New(".gitignore changed since preview; inspect it and retry")
833+ }
834+ return writeWorktreesIgnore(u.path, u.after, u.mode)
835+}
836+
837+func (u worktreesIgnoreUpdate) restore() error {
838+ if !u.changed {
839+ return nil
840+ }
841+ current, _, existed, err := readWorktreesIgnore(u.path)
842+ if err != nil {
843+ return err
844+ }
845+ if !existed || !bytes.Equal(current, u.after) {
846+ return nil
847+ }
848+ if !u.existed {
849+ if err := os.Remove(u.path); err != nil && !os.IsNotExist(err) {
850+ return err
851+ }
852+ return nil
853+ }
854+ return writeWorktreesIgnore(u.path, u.before, u.mode)
855+}
856+
857+func readWorktreesIgnore(path string) ([]byte, os.FileMode, bool, error) {
858+ info, err := os.Lstat(path)
859+ if os.IsNotExist(err) {
860+ return nil, 0o644, false, nil
861+ }
862+ if err != nil {
863+ return nil, 0, false, err
864+ }
865+ if info.Mode()&os.ModeSymlink != 0 {
866+ return nil, 0, false, errors.New(".gitignore must not be a symbolic link")
867+ }
868+ if !info.Mode().IsRegular() {
869+ return nil, 0, false, errors.New(".gitignore must be a regular file")
870+ }
871+ if info.Size() > maxCreateWorktreeIgnoreBytes {
872+ return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes)
873+ }
874+ file, err := os.Open(path)
875+ if err != nil {
876+ return nil, 0, false, err
877+ }
878+ defer file.Close()
879+ contents, err := io.ReadAll(io.LimitReader(file, maxCreateWorktreeIgnoreBytes+1))
880+ if err != nil {
881+ return nil, 0, false, err
882+ }
883+ if len(contents) > maxCreateWorktreeIgnoreBytes {
884+ return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes)
885+ }
886+ return contents, info.Mode().Perm(), true, nil
887+}
888+
889+func writeWorktreesIgnore(path string, contents []byte, mode os.FileMode) error {
890+ file, err := os.CreateTemp(filepath.Dir(path), ".zut-gitignore-*")
891+ if err != nil {
892+ return err
893+ }
894+ tempPath := file.Name()
895+ defer os.Remove(tempPath)
896+ if err := file.Chmod(mode); err != nil {
897+ _ = file.Close()
898+ return err
899+ }
900+ if _, err := file.Write(contents); err != nil {
901+ _ = file.Close()
902+ return err
903+ }
904+ if err := file.Close(); err != nil {
905+ return err
906+ }
907+ return os.Rename(tempPath, path)
908+}
A packages/agent/tools/create_worktree_test.go +812 -0
@@ -0,0 +1,812 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "runtime"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/bnema/zut/packages/provider"
16+)
17+
18+func TestCreateWorktreeBootstrapsDefaultAtRepositoryRoot(t *testing.T) {
19+ if _, err := exec.LookPath("git"); err != nil {
20+ t.Skip("git is required")
21+ }
22+ repo := initWorktreeTestRepo(t)
23+ nested := filepath.Join(repo, "nested", "dir")
24+ if err := os.MkdirAll(nested, 0o755); err != nil {
25+ t.Fatal(err)
26+ }
27+ if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("*.local\n"), 0o644); err != nil {
28+ t.Fatal(err)
29+ }
30+ branchBefore := gitTestOutput(t, repo, "branch", "--show-current")
31+ sandbox := NewSandbox(repo)
32+ sandbox.Lock()
33+
34+ tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
35+ args, err := json.Marshal(map[string]string{"branch": "feature/new-checkout", "bootstrap_root": ".worktrees"})
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if _, err := tool.Execute(context.Background(), args, nil); err != nil {
40+ t.Fatal(err)
41+ }
42+
43+ worktree := filepath.Join(repo, ".worktrees", "feature", "new-checkout")
44+ if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
45+ t.Fatalf("worktree %q was not created: %v", worktree, err)
46+ }
47+ if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/new-checkout" {
48+ t.Fatalf("worktree branch = %q, want %q", got, "feature/new-checkout")
49+ }
50+ if got := gitTestOutput(t, repo, "branch", "--show-current"); got != branchBefore {
51+ t.Fatalf("source branch changed from %q to %q", branchBefore, got)
52+ }
53+ ignore, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ if count := strings.Count(string(ignore), "/.worktrees/\n"); count != 1 {
58+ t.Fatalf("/.worktrees/ ignore entries = %d, want 1:\n%s", count, ignore)
59+ }
60+ if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != defaultWorktreeRoot {
61+ t.Fatalf("saved worktree root = %q, want %q", got, defaultWorktreeRoot)
62+ }
63+}
64+
65+func TestCreateWorktreeRequiresBootstrapForUnconfiguredRepository(t *testing.T) {
66+ if _, err := exec.LookPath("git"); err != nil {
67+ t.Skip("git is required")
68+ }
69+ repo := initWorktreeTestRepo(t)
70+ tool := newCreateWorktreeTestTool(repo)
71+
72+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/needs-bootstrap"}`), nil)
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ text := result.Content[0].(provider.TextBlock).Text
77+ if !strings.Contains(text, "Worktree bootstrap is required") {
78+ t.Fatalf("bootstrap result = %q", text)
79+ }
80+ if state, _ := result.Details.(map[string]any)["state"].(string); state != "bootstrap_required" {
81+ t.Fatalf("bootstrap state = %q", state)
82+ }
83+ for _, path := range []string{".worktrees", ".gitignore"} {
84+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
85+ t.Fatalf("bootstrap created %s: %v", path, statErr)
86+ }
87+ }
88+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
89+ t.Fatalf("local worktree config exit = %d, want 1", got.exitCode)
90+ }
91+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/needs-bootstrap"); got.exitCode != 1 {
92+ t.Fatalf("bootstrap created branch: %#v", got)
93+ }
94+}
95+
96+func TestCreateWorktreeRejectsBootstrapRootWhenDefaultRootExists(t *testing.T) {
97+ if _, err := exec.LookPath("git"); err != nil {
98+ t.Skip("git is required")
99+ }
100+ repo := initWorktreeTestRepo(t)
101+ if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil {
102+ t.Fatal(err)
103+ }
104+ tool := newCreateWorktreeTestTool(repo)
105+
106+ _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{
107+ "branch": "feature/conflicting-bootstrap",
108+ "bootstrap_root": t.TempDir(),
109+ }), nil)
110+ if err == nil || !strings.Contains(err.Error(), "already established") {
111+ t.Fatalf("error = %v, want established-root error", err)
112+ }
113+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
114+ t.Fatalf("conflicting bootstrap saved config: %#v", got)
115+ }
116+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-bootstrap"); got.exitCode != 1 {
117+ t.Fatalf("conflicting bootstrap created branch: %#v", got)
118+ }
119+}
120+
121+func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing.T) {
122+ if _, err := exec.LookPath("git"); err != nil {
123+ t.Skip("git is required")
124+ }
125+ repo := initWorktreeTestRepo(t)
126+ externalRoot, err := canonicalOrParent(filepath.Join(t.TempDir(), "company-worktrees"))
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ tool := newCreateWorktreeTestTool(repo)
131+ args := mustJSON(t, map[string]string{"branch": "feature/external", "bootstrap_root": externalRoot})
132+
133+ result, err := tool.Execute(context.Background(), args, nil)
134+ if err != nil {
135+ t.Fatal(err)
136+ }
137+ worktree := filepath.Join(externalRoot, "feature", "external")
138+ if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
139+ t.Fatalf("external worktree %q was not created: %v", worktree, err)
140+ }
141+ if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/external" {
142+ t.Fatalf("external branch = %q", got)
143+ }
144+ if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != externalRoot {
145+ t.Fatalf("saved external root = %q, want %q", got, externalRoot)
146+ }
147+ if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) {
148+ t.Fatalf("external bootstrap changed .gitignore: %v", err)
149+ }
150+ if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "bootstrap selection" {
151+ t.Fatalf("root source = %q", source)
152+ }
153+ if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" {
154+ t.Fatalf("state = %q, want created", state)
155+ }
156+ followup, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/external-followup"}`), nil)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ if _, err := os.Stat(filepath.Join(externalRoot, "feature", "external-followup")); err != nil {
161+ t.Fatalf("configured external root was not reused: %v", err)
162+ }
163+ if source, _ := followup.Details.(map[string]any)["root_source"].(string); source != "local Git config" {
164+ t.Fatalf("follow-up root source = %q", source)
165+ }
166+}
167+
168+func TestCreateWorktreeRejectsInvalidBootstrapRoot(t *testing.T) {
169+ if _, err := exec.LookPath("git"); err != nil {
170+ t.Skip("git is required")
171+ }
172+ repo := initWorktreeTestRepo(t)
173+ tool := newCreateWorktreeTestTool(repo)
174+ for _, root := range []string{"relative-worktrees", filepath.Join(repo, "company-worktrees")} {
175+ _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": "feature/invalid", "bootstrap_root": root}), nil)
176+ if err == nil || !strings.Contains(err.Error(), "bootstrap_root") {
177+ t.Fatalf("bootstrap_root %q error = %v", root, err)
178+ }
179+ }
180+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
181+ t.Fatalf("invalid bootstrap saved config: %#v", got)
182+ }
183+}
184+
185+func TestCreateWorktreeRejectsBranchSyntaxThatGitCanInterpret(t *testing.T) {
186+ if _, err := exec.LookPath("git"); err != nil {
187+ t.Skip("git is required")
188+ }
189+ repo := initWorktreeTestRepo(t)
190+ tool := newCreateWorktreeTestTool(repo)
191+ for _, branch := range []string{"-option", "topic@{1}"} {
192+ _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": branch}), nil)
193+ if err == nil || !strings.Contains(err.Error(), "invalid branch") {
194+ t.Fatalf("branch %q error = %v", branch, err)
195+ }
196+ }
197+}
198+
199+func TestCreateWorktreeRejectsConfiguredExternalRootResolvingInsideRepository(t *testing.T) {
200+ if _, err := exec.LookPath("git"); err != nil {
201+ t.Skip("git is required")
202+ }
203+ repo := initWorktreeTestRepo(t)
204+ link := filepath.Join(t.TempDir(), "repository-link")
205+ if err := os.Symlink(repo, link); err != nil {
206+ t.Skipf("symbolic links are unavailable: %v", err)
207+ }
208+ root := filepath.Join(link, "external-worktrees")
209+ gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, root)
210+ tool := newCreateWorktreeTestTool(repo)
211+
212+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/inside-repository"}`), nil)
213+ if err == nil || !strings.Contains(err.Error(), "external worktree root must be outside the repository") {
214+ t.Fatalf("error = %v, want external-root error", err)
215+ }
216+}
217+
218+func TestCreateWorktreeDisablesPostCheckoutHook(t *testing.T) {
219+ if runtime.GOOS == "windows" {
220+ t.Skip("Git hook fixture uses a POSIX shell")
221+ }
222+ if _, err := exec.LookPath("git"); err != nil {
223+ t.Skip("git is required")
224+ }
225+ repo := initWorktreeTestRepo(t)
226+ if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil {
227+ t.Fatal(err)
228+ }
229+ sentinel := filepath.Join(repo, "hook-ran")
230+ hook := filepath.Join(repo, ".git", "hooks", "post-checkout")
231+ script := "#!/bin/sh\ntouch " + shellQuoteForTest(sentinel) + "\n"
232+ if err := os.WriteFile(hook, []byte(script), 0o755); err != nil {
233+ t.Fatal(err)
234+ }
235+ tool := newCreateWorktreeTestTool(repo)
236+
237+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-hook"}`), nil); err != nil {
238+ t.Fatal(err)
239+ }
240+ worktree := filepath.Join(repo, ".worktrees", "feature", "no-hook")
241+ if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
242+ t.Fatalf("worktree %q was not created: %v", worktree, err)
243+ }
244+ if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
245+ t.Fatalf("post-checkout hook ran: %v", err)
246+ }
247+}
248+
249+func TestCreateWorktreeCleanupRemovesPartialCheckoutAndBranch(t *testing.T) {
250+ if _, err := exec.LookPath("git"); err != nil {
251+ t.Skip("git is required")
252+ }
253+ repo := initWorktreeTestRepo(t)
254+ worktreePath := filepath.Join(repo, ".worktrees", "partial")
255+ if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {
256+ t.Fatal(err)
257+ }
258+ base := gitTestOutput(t, repo, "rev-parse", "HEAD")
259+ gitTestOutput(t, repo, "worktree", "add", "-b", "feature/partial", worktreePath, base)
260+ plan := createWorktreePlan{
261+ repoRoot: repo,
262+ branch: "feature/partial",
263+ base: base,
264+ worktreePath: worktreePath,
265+ }
266+
267+ if err := plan.cleanupFailedCheckout(context.Background()); err != nil {
268+ t.Fatal(err)
269+ }
270+ if _, err := os.Stat(worktreePath); !os.IsNotExist(err) {
271+ t.Fatalf("partial worktree remains: %v", err)
272+ }
273+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/partial"); got.exitCode != 1 {
274+ t.Fatalf("partial branch remains: %#v", got)
275+ }
276+}
277+
278+func TestExecuteCreateWorktreePlanRemovesCreatedParentsOnCheckoutFailure(t *testing.T) {
279+ if _, err := exec.LookPath("git"); err != nil {
280+ t.Skip("git is required")
281+ }
282+ repo := initWorktreeTestRepo(t)
283+ branch := "feature/checkout-failure"
284+ plan := createWorktreePlan{
285+ repoRoot: repo,
286+ branch: branch,
287+ base: "not-a-commit",
288+ worktreePath: filepath.Join(repo, defaultWorktreeRoot, "feature", "checkout-failure"),
289+ configValue: defaultWorktreeRoot,
290+ setConfig: true,
291+ }
292+
293+ _, err := executeCreateWorktreePlan(context.Background(), plan, nil)
294+ if err == nil || !strings.Contains(err.Error(), "create worktree") {
295+ t.Fatalf("error = %v, want worktree creation error", err)
296+ }
297+ if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot)); !os.IsNotExist(err) {
298+ t.Fatalf("failed checkout left worktree directories: %v", err)
299+ }
300+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
301+ t.Fatalf("failed checkout kept local config: %#v", got)
302+ }
303+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch); got.exitCode != 1 {
304+ t.Fatalf("failed checkout created branch: %#v", got)
305+ }
306+}
307+
308+func TestExecuteCreateWorktreePlanRollsBackBootstrapConfigOnIgnoreFailure(t *testing.T) {
309+ if _, err := exec.LookPath("git"); err != nil {
310+ t.Skip("git is required")
311+ }
312+ repo := initWorktreeTestRepo(t)
313+ ignorePath := filepath.Join(repo, ".gitignore")
314+ before := []byte("generated/\n")
315+ if err := os.WriteFile(ignorePath, before, 0o644); err != nil {
316+ t.Fatal(err)
317+ }
318+ if err := os.WriteFile(ignorePath, []byte("changed by another process\n"), 0o644); err != nil {
319+ t.Fatal(err)
320+ }
321+ base := gitTestOutput(t, repo, "rev-parse", "HEAD")
322+ plan := createWorktreePlan{
323+ repoRoot: repo,
324+ branch: "feature/ignore-failure",
325+ base: base,
326+ worktreePath: filepath.Join(repo, ".worktrees", "feature", "ignore-failure"),
327+ configValue: defaultWorktreeRoot,
328+ setConfig: true,
329+ ignore: worktreesIgnoreUpdate{
330+ path: ignorePath,
331+ before: before,
332+ after: append(append([]byte(nil), before...), []byte(worktreesIgnoreEntry+"\n")...),
333+ existed: true,
334+ mode: 0o644,
335+ changed: true,
336+ },
337+ }
338+
339+ _, err := executeCreateWorktreePlan(context.Background(), plan, nil)
340+ if err == nil || !strings.Contains(err.Error(), ".gitignore changed since preview") {
341+ t.Fatalf("error = %v, want stale .gitignore error", err)
342+ }
343+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
344+ t.Fatalf("failed bootstrap kept local config: %#v", got)
345+ }
346+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/ignore-failure"); got.exitCode != 1 {
347+ t.Fatalf("failed bootstrap created branch: %#v", got)
348+ }
349+ contents, err := os.ReadFile(ignorePath)
350+ if err != nil {
351+ t.Fatal(err)
352+ }
353+ if got, want := string(contents), "changed by another process\n"; got != want {
354+ t.Fatalf(".gitignore = %q, want %q", got, want)
355+ }
356+}
357+
358+func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) {
359+ if _, err := exec.LookPath("git"); err != nil {
360+ t.Skip("git is required")
361+ }
362+ repo := initWorktreeTestRepo(t)
363+ ignorePath := filepath.Join(repo, ".gitignore")
364+ if err := os.WriteFile(ignorePath, []byte("generated/\r\n"), 0o644); err != nil {
365+ t.Fatal(err)
366+ }
367+ tool := newCreateWorktreeTestTool(repo)
368+
369+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/crlf","bootstrap_root":".worktrees"}`), nil); err != nil {
370+ t.Fatal(err)
371+ }
372+ ignore, err := os.ReadFile(ignorePath)
373+ if err != nil {
374+ t.Fatal(err)
375+ }
376+ if got, want := string(ignore), "generated/\r\n/.worktrees/\r\n"; got != want {
377+ t.Fatalf(".gitignore = %q, want %q", got, want)
378+ }
379+}
380+
381+func TestCreateWorktreeRejectsGitignoreSymbolicLink(t *testing.T) {
382+ if _, err := exec.LookPath("git"); err != nil {
383+ t.Skip("git is required")
384+ }
385+ repo := initWorktreeTestRepo(t)
386+ target := filepath.Join(repo, "ignore-target")
387+ if err := os.WriteFile(target, []byte("generated/\n"), 0o644); err != nil {
388+ t.Fatal(err)
389+ }
390+ ignorePath := filepath.Join(repo, ".gitignore")
391+ if err := os.Symlink(target, ignorePath); err != nil {
392+ t.Skipf("symbolic links are unavailable: %v", err)
393+ }
394+ tool := newCreateWorktreeTestTool(repo)
395+
396+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/ignore-symlink","bootstrap_root":".worktrees"}`), nil)
397+ if err == nil || !strings.Contains(err.Error(), ".gitignore must not be a symbolic link") {
398+ t.Fatalf("error = %v, want symbolic-link error", err)
399+ }
400+ info, err := os.Lstat(ignorePath)
401+ if err != nil {
402+ t.Fatal(err)
403+ }
404+ if info.Mode()&os.ModeSymlink == 0 {
405+ t.Fatal("tool replaced .gitignore symbolic link")
406+ }
407+ if _, statErr := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(statErr) {
408+ t.Fatalf("symbolic-link rejection created .worktrees: %v", statErr)
409+ }
410+}
411+
412+func TestCreateWorktreeRejectsNonRegularGitignore(t *testing.T) {
413+ if _, err := exec.LookPath("git"); err != nil {
414+ t.Skip("git is required")
415+ }
416+ repo := initWorktreeTestRepo(t)
417+ if err := os.Mkdir(filepath.Join(repo, ".gitignore"), 0o755); err != nil {
418+ t.Fatal(err)
419+ }
420+ tool := newCreateWorktreeTestTool(repo)
421+
422+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/nonregular","bootstrap_root":".worktrees"}`), nil)
423+ if err == nil || !strings.Contains(err.Error(), ".gitignore must be a regular file") {
424+ t.Fatalf("error = %v, want regular-file error", err)
425+ }
426+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
427+ t.Fatalf("non-regular ignore saved config: %#v", got)
428+ }
429+}
430+
431+func TestCreateWorktreeGitOutputKeepsStderrOutOfSuccessfulOutput(t *testing.T) {
432+ if runtime.GOOS == "windows" {
433+ t.Skip("Git fixture uses a POSIX shell")
434+ }
435+ binDir := t.TempDir()
436+ gitPath := filepath.Join(binDir, "git")
437+ if err := os.WriteFile(gitPath, []byte("#!/bin/sh\nprintf 'value\\n'\nprintf 'warning\\n' >&2\n"), 0o755); err != nil {
438+ t.Fatal(err)
439+ }
440+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
441+
442+ output, err := createWorktreeGitOutput(context.Background(), t.TempDir(), "status")
443+ if err != nil {
444+ t.Fatal(err)
445+ }
446+ if output != "value" {
447+ t.Fatalf("output = %q, want value", output)
448+ }
449+}
450+
451+func TestCreateWorktreeGitCancelsDescendants(t *testing.T) {
452+ if runtime.GOOS == "windows" {
453+ t.Skip("process-group fixture uses a POSIX shell")
454+ }
455+ binDir := t.TempDir()
456+ started := filepath.Join(t.TempDir(), "git-started")
457+ childPID := filepath.Join(t.TempDir(), "child-pid")
458+ gitPath := filepath.Join(binDir, "git")
459+ script := "#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\" > " + shellQuoteForTest(childPID) + "\nprintf started > " + shellQuoteForTest(started) + "\nwait\n"
460+ if err := os.WriteFile(gitPath, []byte(script), 0o755); err != nil {
461+ t.Fatal(err)
462+ }
463+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
464+ ctx, cancel := context.WithCancel(context.Background())
465+ defer cancel()
466+ cwd := t.TempDir()
467+ done := make(chan error, 1)
468+ go func() {
469+ _, err := createWorktreeGitOutput(ctx, cwd, "status")
470+ done <- err
471+ }()
472+
473+ deadline := time.NewTimer(5 * time.Second)
474+ defer deadline.Stop()
475+ poll := time.NewTicker(10 * time.Millisecond)
476+ defer poll.Stop()
477+ for {
478+ if _, err := os.Stat(started); err == nil {
479+ break
480+ } else if !os.IsNotExist(err) {
481+ t.Fatal(err)
482+ }
483+ select {
484+ case <-deadline.C:
485+ t.Fatal("fake git did not start")
486+ case <-poll.C:
487+ }
488+ }
489+ cancel()
490+ select {
491+ case err := <-done:
492+ if !errors.Is(err, context.Canceled) {
493+ t.Fatalf("error = %v, want context canceled", err)
494+ }
495+ case <-time.After(5 * time.Second):
496+ t.Fatal("Git process group did not exit after cancellation")
497+ }
498+ pid, err := os.ReadFile(childPID)
499+ if err != nil {
500+ t.Fatal(err)
501+ }
502+ exitDeadline := time.NewTimer(5 * time.Second)
503+ defer exitDeadline.Stop()
504+ for {
505+ err := exec.Command("kill", "-0", strings.TrimSpace(string(pid))).Run()
506+ if err != nil {
507+ var exitErr *exec.ExitError
508+ if errors.As(err, &exitErr) {
509+ break
510+ }
511+ t.Fatalf("check child process: %v", err)
512+ }
513+ select {
514+ case <-exitDeadline.C:
515+ t.Fatalf("Git descendant %q remained after cancellation", pid)
516+ case <-poll.C:
517+ }
518+ }
519+}
520+
521+func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) {
522+ if _, err := exec.LookPath("git"); err != nil {
523+ t.Skip("git is required")
524+ }
525+ repo := initWorktreeTestRepo(t)
526+ tool := newCreateWorktreeTestTool(repo)
527+ args := json.RawMessage(`{"branch":"feature/preview"}`)
528+
529+ preview, err := tool.Preview(context.Background(), args)
530+ if err != nil {
531+ t.Fatal(err)
532+ }
533+ text := preview.Content[0].(provider.TextBlock).Text
534+ for _, want := range []string{"Worktree bootstrap is required", "feature/preview", "bootstrap_root"} {
535+ if !strings.Contains(text, want) {
536+ t.Fatalf("preview missing %q:\n%s", want, text)
537+ }
538+ }
539+ if _, err := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(err) {
540+ t.Fatalf("preview created .worktrees: %v", err)
541+ }
542+ if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) {
543+ t.Fatalf("preview created .gitignore: %v", err)
544+ }
545+ cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview")
546+ if err := cmd.Run(); err == nil {
547+ t.Fatal("preview created the branch")
548+ }
549+}
550+
551+func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) {
552+ if _, err := exec.LookPath("git"); err != nil {
553+ t.Skip("git is required")
554+ }
555+ repo := initWorktreeTestRepo(t)
556+ if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil {
557+ t.Fatal(err)
558+ }
559+ tool := newCreateWorktreeTestTool(repo)
560+ args := json.RawMessage(`{"branch":"feature/stale-preview"}`)
561+ if _, err := tool.Preview(context.Background(), args); err != nil {
562+ t.Fatal(err)
563+ }
564+ if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil {
565+ t.Fatal(err)
566+ }
567+ gitTestOutput(t, repo, "add", "after-preview.txt")
568+ gitTestOutput(t, repo, "commit", "-qm", "change after preview")
569+
570+ _, err := tool.Execute(context.Background(), args, nil)
571+ if err == nil || !strings.Contains(err.Error(), "state changed since preview") {
572+ t.Fatalf("error = %v, want stale-preview error", err)
573+ }
574+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 {
575+ t.Fatalf("stale preview created branch: %#v", got)
576+ }
577+}
578+
579+func TestCreateWorktreeRejectsNonRepository(t *testing.T) {
580+ dir := t.TempDir()
581+ tool := newCreateWorktreeTestTool(dir)
582+
583+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil)
584+ if err == nil || !strings.Contains(err.Error(), "not a Git repository") {
585+ t.Fatalf("error = %v, want repository error", err)
586+ }
587+ for _, path := range []string{".worktrees", ".gitignore"} {
588+ if _, statErr := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(statErr) {
589+ t.Fatalf("non-repository call created %s: %v", path, statErr)
590+ }
591+ }
592+}
593+
594+func TestCreateWorktreeRequiresSandbox(t *testing.T) {
595+ tool := &CreateWorktreeTool{CWD: t.TempDir()}
596+
597+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil)
598+ if err == nil || !strings.Contains(err.Error(), "sandbox is required") {
599+ t.Fatalf("error = %v, want sandbox error", err)
600+ }
601+}
602+
603+func TestCreateWorktreeRejectsExistingBranchWithoutChangingGitignore(t *testing.T) {
604+ if _, err := exec.LookPath("git"); err != nil {
605+ t.Skip("git is required")
606+ }
607+ repo := initWorktreeTestRepo(t)
608+ tool := newCreateWorktreeTestTool(repo)
609+ bootstrapArgs := json.RawMessage(`{"branch":"feature/existing","bootstrap_root":".worktrees"}`)
610+ if _, err := tool.Execute(context.Background(), bootstrapArgs, nil); err != nil {
611+ t.Fatal(err)
612+ }
613+ ignoreBefore, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
614+ if err != nil {
615+ t.Fatal(err)
616+ }
617+
618+ _, err = tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/existing"}`), nil)
619+ if err == nil || !strings.Contains(err.Error(), "already exists") {
620+ t.Fatalf("error = %v, want collision error", err)
621+ }
622+ ignoreAfter, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
623+ if err != nil {
624+ t.Fatal(err)
625+ }
626+ if string(ignoreAfter) != string(ignoreBefore) {
627+ t.Fatalf("collision changed .gitignore:\nbefore: %q\nafter: %q", ignoreBefore, ignoreAfter)
628+ }
629+}
630+
631+func TestCreateWorktreeRespectsJail(t *testing.T) {
632+ if _, err := exec.LookPath("git"); err != nil {
633+ t.Skip("git is required")
634+ }
635+ repo := initWorktreeTestRepo(t)
636+ nested := filepath.Join(repo, "nested")
637+ if err := os.Mkdir(nested, 0o755); err != nil {
638+ t.Fatal(err)
639+ }
640+ sandbox := NewSandbox(nested)
641+ sandbox.Lock()
642+ tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
643+
644+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil)
645+ if err == nil || !strings.Contains(err.Error(), "jailed:") {
646+ t.Fatalf("error = %v, want jailed error", err)
647+ }
648+ for _, path := range []string{".worktrees", ".gitignore"} {
649+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
650+ t.Fatalf("jailed call created %s: %v", path, statErr)
651+ }
652+ }
653+}
654+
655+func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) {
656+ if runtime.GOOS == "windows" {
657+ t.Skip("Git invocation fixture uses a POSIX shell")
658+ }
659+ if _, err := exec.LookPath("git"); err != nil {
660+ t.Skip("git is required")
661+ }
662+ repo := initWorktreeTestRepo(t)
663+ nested := filepath.Join(repo, "nested")
664+ if err := os.Mkdir(nested, 0o755); err != nil {
665+ t.Fatal(err)
666+ }
667+ sentinel := filepath.Join(repo, "git-was-invoked")
668+ binDir := t.TempDir()
669+ fakeGit := filepath.Join(binDir, "git")
670+ if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil {
671+ t.Fatal(err)
672+ }
673+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
674+ sandbox := NewSandbox(nested)
675+ sandbox.Lock()
676+ tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
677+
678+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil)
679+ if err == nil || !strings.Contains(err.Error(), "jailed:") {
680+ t.Fatalf("error = %v, want jailed error", err)
681+ }
682+ if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
683+ t.Fatalf("Git ran before jailed ancestor was rejected: %v", err)
684+ }
685+}
686+
687+func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) {
688+ if _, err := exec.LookPath("git"); err != nil {
689+ t.Skip("git is required")
690+ }
691+ repo := initWorktreeTestRepo(t)
692+ sandbox := NewSandbox(repo)
693+ permissions := &PermissionSet{}
694+ permissions.Bash.Mode = "allowlist"
695+ permissions.Bash.Allow = []string{"git"}
696+ sandbox.SetPermissions(permissions)
697+ tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox}
698+
699+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil)
700+ if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") {
701+ t.Fatalf("error = %v, want filesystem permission error", err)
702+ }
703+ for _, path := range []string{".worktrees", ".gitignore"} {
704+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
705+ t.Fatalf("restricted call created %s: %v", path, statErr)
706+ }
707+ }
708+}
709+
710+func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) {
711+ if _, err := exec.LookPath("git"); err != nil {
712+ t.Skip("git is required")
713+ }
714+ repo := initWorktreeTestRepo(t)
715+ sandbox := NewSandbox(repo)
716+ permissions := &PermissionSet{}
717+ permissions.FS.Read = []string{repo}
718+ permissions.FS.Write = []string{repo}
719+ sandbox.SetPermissions(permissions)
720+ tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox}
721+
722+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil)
723+ if err == nil || !strings.Contains(err.Error(), "no bash permission") {
724+ t.Fatalf("error = %v, want git permission error", err)
725+ }
726+ for _, path := range []string{".worktrees", ".gitignore"} {
727+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
728+ t.Fatalf("restricted call created %s: %v", path, statErr)
729+ }
730+ }
731+}
732+
733+func TestCreateWorktreeRejectsInvalidWorktreePathBeforeGitignoreUpdate(t *testing.T) {
734+ if _, err := exec.LookPath("git"); err != nil {
735+ t.Skip("git is required")
736+ }
737+ repo := initWorktreeTestRepo(t)
738+ worktrees := filepath.Join(repo, ".worktrees")
739+ if err := os.Mkdir(worktrees, 0o755); err != nil {
740+ t.Fatal(err)
741+ }
742+ if err := os.WriteFile(filepath.Join(worktrees, "blocked"), []byte("not a directory"), 0o644); err != nil {
743+ t.Fatal(err)
744+ }
745+ tool := newCreateWorktreeTestTool(repo)
746+
747+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil)
748+ if err == nil || !strings.Contains(err.Error(), "not a directory") {
749+ t.Fatalf("error = %v, want non-directory path error", err)
750+ }
751+ if _, statErr := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(statErr) {
752+ t.Fatalf("invalid path left .gitignore behind: %v", statErr)
753+ }
754+ cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new")
755+ if err := cmd.Run(); err == nil {
756+ t.Fatal("invalid path created the branch")
757+ }
758+}
759+
760+func newCreateWorktreeTestTool(cwd string) *CreateWorktreeTool {
761+ return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd)}
762+}
763+
764+func initWorktreeTestRepo(t *testing.T) string {
765+ t.Helper()
766+ t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
767+ t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull)
768+ t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
769+ repo := t.TempDir()
770+ gitTestOutput(t, repo, "init", "-q")
771+ gitTestOutput(t, repo, "config", "user.email", "test@example.invalid")
772+ gitTestOutput(t, repo, "config", "user.name", "Test User")
773+ if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o644); err != nil {
774+ t.Fatal(err)
775+ }
776+ gitTestOutput(t, repo, "add", "README.md")
777+ gitTestOutput(t, repo, "commit", "-qm", "initial")
778+ return repo
779+}
780+
781+func shellQuoteForTest(value string) string {
782+ return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
783+}
784+
785+type gitTestResult struct {
786+ output string
787+ exitCode int
788+}
789+
790+func gitTestOutputAllowExit(t *testing.T, dir string, args ...string) gitTestResult {
791+ t.Helper()
792+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
793+ output, err := cmd.CombinedOutput()
794+ if err == nil {
795+ return gitTestResult{output: strings.TrimSpace(string(output))}
796+ }
797+ if exitErr, ok := err.(*exec.ExitError); ok {
798+ return gitTestResult{output: strings.TrimSpace(string(output)), exitCode: exitErr.ExitCode()}
799+ }
800+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
801+ return gitTestResult{}
802+}
803+
804+func gitTestOutput(t *testing.T, dir string, args ...string) string {
805+ t.Helper()
806+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
807+ output, err := cmd.CombinedOutput()
808+ if err != nil {
809+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
810+ }
811+ return strings.TrimSpace(string(output))
812+}
@@ -0,0 +1,812 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "runtime"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/bnema/zut/packages/provider"
16+)
17+
18+func TestCreateWorktreeBootstrapsDefaultAtRepositoryRoot(t *testing.T) {
19+ if _, err := exec.LookPath("git"); err != nil {
20+ t.Skip("git is required")
21+ }
22+ repo := initWorktreeTestRepo(t)
23+ nested := filepath.Join(repo, "nested", "dir")
24+ if err := os.MkdirAll(nested, 0o755); err != nil {
25+ t.Fatal(err)
26+ }
27+ if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("*.local\n"), 0o644); err != nil {
28+ t.Fatal(err)
29+ }
30+ branchBefore := gitTestOutput(t, repo, "branch", "--show-current")
31+ sandbox := NewSandbox(repo)
32+ sandbox.Lock()
33+
34+ tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
35+ args, err := json.Marshal(map[string]string{"branch": "feature/new-checkout", "bootstrap_root": ".worktrees"})
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if _, err := tool.Execute(context.Background(), args, nil); err != nil {
40+ t.Fatal(err)
41+ }
42+
43+ worktree := filepath.Join(repo, ".worktrees", "feature", "new-checkout")
44+ if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
45+ t.Fatalf("worktree %q was not created: %v", worktree, err)
46+ }
47+ if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/new-checkout" {
48+ t.Fatalf("worktree branch = %q, want %q", got, "feature/new-checkout")
49+ }
50+ if got := gitTestOutput(t, repo, "branch", "--show-current"); got != branchBefore {
51+ t.Fatalf("source branch changed from %q to %q", branchBefore, got)
52+ }
53+ ignore, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ if count := strings.Count(string(ignore), "/.worktrees/\n"); count != 1 {
58+ t.Fatalf("/.worktrees/ ignore entries = %d, want 1:\n%s", count, ignore)
59+ }
60+ if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != defaultWorktreeRoot {
61+ t.Fatalf("saved worktree root = %q, want %q", got, defaultWorktreeRoot)
62+ }
63+}
64+
65+func TestCreateWorktreeRequiresBootstrapForUnconfiguredRepository(t *testing.T) {
66+ if _, err := exec.LookPath("git"); err != nil {
67+ t.Skip("git is required")
68+ }
69+ repo := initWorktreeTestRepo(t)
70+ tool := newCreateWorktreeTestTool(repo)
71+
72+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/needs-bootstrap"}`), nil)
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ text := result.Content[0].(provider.TextBlock).Text
77+ if !strings.Contains(text, "Worktree bootstrap is required") {
78+ t.Fatalf("bootstrap result = %q", text)
79+ }
80+ if state, _ := result.Details.(map[string]any)["state"].(string); state != "bootstrap_required" {
81+ t.Fatalf("bootstrap state = %q", state)
82+ }
83+ for _, path := range []string{".worktrees", ".gitignore"} {
84+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
85+ t.Fatalf("bootstrap created %s: %v", path, statErr)
86+ }
87+ }
88+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
89+ t.Fatalf("local worktree config exit = %d, want 1", got.exitCode)
90+ }
91+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/needs-bootstrap"); got.exitCode != 1 {
92+ t.Fatalf("bootstrap created branch: %#v", got)
93+ }
94+}
95+
96+func TestCreateWorktreeRejectsBootstrapRootWhenDefaultRootExists(t *testing.T) {
97+ if _, err := exec.LookPath("git"); err != nil {
98+ t.Skip("git is required")
99+ }
100+ repo := initWorktreeTestRepo(t)
101+ if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil {
102+ t.Fatal(err)
103+ }
104+ tool := newCreateWorktreeTestTool(repo)
105+
106+ _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{
107+ "branch": "feature/conflicting-bootstrap",
108+ "bootstrap_root": t.TempDir(),
109+ }), nil)
110+ if err == nil || !strings.Contains(err.Error(), "already established") {
111+ t.Fatalf("error = %v, want established-root error", err)
112+ }
113+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
114+ t.Fatalf("conflicting bootstrap saved config: %#v", got)
115+ }
116+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-bootstrap"); got.exitCode != 1 {
117+ t.Fatalf("conflicting bootstrap created branch: %#v", got)
118+ }
119+}
120+
121+func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing.T) {
122+ if _, err := exec.LookPath("git"); err != nil {
123+ t.Skip("git is required")
124+ }
125+ repo := initWorktreeTestRepo(t)
126+ externalRoot, err := canonicalOrParent(filepath.Join(t.TempDir(), "company-worktrees"))
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ tool := newCreateWorktreeTestTool(repo)
131+ args := mustJSON(t, map[string]string{"branch": "feature/external", "bootstrap_root": externalRoot})
132+
133+ result, err := tool.Execute(context.Background(), args, nil)
134+ if err != nil {
135+ t.Fatal(err)
136+ }
137+ worktree := filepath.Join(externalRoot, "feature", "external")
138+ if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
139+ t.Fatalf("external worktree %q was not created: %v", worktree, err)
140+ }
141+ if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/external" {
142+ t.Fatalf("external branch = %q", got)
143+ }
144+ if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != externalRoot {
145+ t.Fatalf("saved external root = %q, want %q", got, externalRoot)
146+ }
147+ if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) {
148+ t.Fatalf("external bootstrap changed .gitignore: %v", err)
149+ }
150+ if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "bootstrap selection" {
151+ t.Fatalf("root source = %q", source)
152+ }
153+ if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" {
154+ t.Fatalf("state = %q, want created", state)
155+ }
156+ followup, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/external-followup"}`), nil)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ if _, err := os.Stat(filepath.Join(externalRoot, "feature", "external-followup")); err != nil {
161+ t.Fatalf("configured external root was not reused: %v", err)
162+ }
163+ if source, _ := followup.Details.(map[string]any)["root_source"].(string); source != "local Git config" {
164+ t.Fatalf("follow-up root source = %q", source)
165+ }
166+}
167+
168+func TestCreateWorktreeRejectsInvalidBootstrapRoot(t *testing.T) {
169+ if _, err := exec.LookPath("git"); err != nil {
170+ t.Skip("git is required")
171+ }
172+ repo := initWorktreeTestRepo(t)
173+ tool := newCreateWorktreeTestTool(repo)
174+ for _, root := range []string{"relative-worktrees", filepath.Join(repo, "company-worktrees")} {
175+ _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": "feature/invalid", "bootstrap_root": root}), nil)
176+ if err == nil || !strings.Contains(err.Error(), "bootstrap_root") {
177+ t.Fatalf("bootstrap_root %q error = %v", root, err)
178+ }
179+ }
180+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
181+ t.Fatalf("invalid bootstrap saved config: %#v", got)
182+ }
183+}
184+
185+func TestCreateWorktreeRejectsBranchSyntaxThatGitCanInterpret(t *testing.T) {
186+ if _, err := exec.LookPath("git"); err != nil {
187+ t.Skip("git is required")
188+ }
189+ repo := initWorktreeTestRepo(t)
190+ tool := newCreateWorktreeTestTool(repo)
191+ for _, branch := range []string{"-option", "topic@{1}"} {
192+ _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": branch}), nil)
193+ if err == nil || !strings.Contains(err.Error(), "invalid branch") {
194+ t.Fatalf("branch %q error = %v", branch, err)
195+ }
196+ }
197+}
198+
199+func TestCreateWorktreeRejectsConfiguredExternalRootResolvingInsideRepository(t *testing.T) {
200+ if _, err := exec.LookPath("git"); err != nil {
201+ t.Skip("git is required")
202+ }
203+ repo := initWorktreeTestRepo(t)
204+ link := filepath.Join(t.TempDir(), "repository-link")
205+ if err := os.Symlink(repo, link); err != nil {
206+ t.Skipf("symbolic links are unavailable: %v", err)
207+ }
208+ root := filepath.Join(link, "external-worktrees")
209+ gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, root)
210+ tool := newCreateWorktreeTestTool(repo)
211+
212+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/inside-repository"}`), nil)
213+ if err == nil || !strings.Contains(err.Error(), "external worktree root must be outside the repository") {
214+ t.Fatalf("error = %v, want external-root error", err)
215+ }
216+}
217+
218+func TestCreateWorktreeDisablesPostCheckoutHook(t *testing.T) {
219+ if runtime.GOOS == "windows" {
220+ t.Skip("Git hook fixture uses a POSIX shell")
221+ }
222+ if _, err := exec.LookPath("git"); err != nil {
223+ t.Skip("git is required")
224+ }
225+ repo := initWorktreeTestRepo(t)
226+ if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil {
227+ t.Fatal(err)
228+ }
229+ sentinel := filepath.Join(repo, "hook-ran")
230+ hook := filepath.Join(repo, ".git", "hooks", "post-checkout")
231+ script := "#!/bin/sh\ntouch " + shellQuoteForTest(sentinel) + "\n"
232+ if err := os.WriteFile(hook, []byte(script), 0o755); err != nil {
233+ t.Fatal(err)
234+ }
235+ tool := newCreateWorktreeTestTool(repo)
236+
237+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-hook"}`), nil); err != nil {
238+ t.Fatal(err)
239+ }
240+ worktree := filepath.Join(repo, ".worktrees", "feature", "no-hook")
241+ if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
242+ t.Fatalf("worktree %q was not created: %v", worktree, err)
243+ }
244+ if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
245+ t.Fatalf("post-checkout hook ran: %v", err)
246+ }
247+}
248+
249+func TestCreateWorktreeCleanupRemovesPartialCheckoutAndBranch(t *testing.T) {
250+ if _, err := exec.LookPath("git"); err != nil {
251+ t.Skip("git is required")
252+ }
253+ repo := initWorktreeTestRepo(t)
254+ worktreePath := filepath.Join(repo, ".worktrees", "partial")
255+ if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {
256+ t.Fatal(err)
257+ }
258+ base := gitTestOutput(t, repo, "rev-parse", "HEAD")
259+ gitTestOutput(t, repo, "worktree", "add", "-b", "feature/partial", worktreePath, base)
260+ plan := createWorktreePlan{
261+ repoRoot: repo,
262+ branch: "feature/partial",
263+ base: base,
264+ worktreePath: worktreePath,
265+ }
266+
267+ if err := plan.cleanupFailedCheckout(context.Background()); err != nil {
268+ t.Fatal(err)
269+ }
270+ if _, err := os.Stat(worktreePath); !os.IsNotExist(err) {
271+ t.Fatalf("partial worktree remains: %v", err)
272+ }
273+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/partial"); got.exitCode != 1 {
274+ t.Fatalf("partial branch remains: %#v", got)
275+ }
276+}
277+
278+func TestExecuteCreateWorktreePlanRemovesCreatedParentsOnCheckoutFailure(t *testing.T) {
279+ if _, err := exec.LookPath("git"); err != nil {
280+ t.Skip("git is required")
281+ }
282+ repo := initWorktreeTestRepo(t)
283+ branch := "feature/checkout-failure"
284+ plan := createWorktreePlan{
285+ repoRoot: repo,
286+ branch: branch,
287+ base: "not-a-commit",
288+ worktreePath: filepath.Join(repo, defaultWorktreeRoot, "feature", "checkout-failure"),
289+ configValue: defaultWorktreeRoot,
290+ setConfig: true,
291+ }
292+
293+ _, err := executeCreateWorktreePlan(context.Background(), plan, nil)
294+ if err == nil || !strings.Contains(err.Error(), "create worktree") {
295+ t.Fatalf("error = %v, want worktree creation error", err)
296+ }
297+ if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot)); !os.IsNotExist(err) {
298+ t.Fatalf("failed checkout left worktree directories: %v", err)
299+ }
300+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
301+ t.Fatalf("failed checkout kept local config: %#v", got)
302+ }
303+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch); got.exitCode != 1 {
304+ t.Fatalf("failed checkout created branch: %#v", got)
305+ }
306+}
307+
308+func TestExecuteCreateWorktreePlanRollsBackBootstrapConfigOnIgnoreFailure(t *testing.T) {
309+ if _, err := exec.LookPath("git"); err != nil {
310+ t.Skip("git is required")
311+ }
312+ repo := initWorktreeTestRepo(t)
313+ ignorePath := filepath.Join(repo, ".gitignore")
314+ before := []byte("generated/\n")
315+ if err := os.WriteFile(ignorePath, before, 0o644); err != nil {
316+ t.Fatal(err)
317+ }
318+ if err := os.WriteFile(ignorePath, []byte("changed by another process\n"), 0o644); err != nil {
319+ t.Fatal(err)
320+ }
321+ base := gitTestOutput(t, repo, "rev-parse", "HEAD")
322+ plan := createWorktreePlan{
323+ repoRoot: repo,
324+ branch: "feature/ignore-failure",
325+ base: base,
326+ worktreePath: filepath.Join(repo, ".worktrees", "feature", "ignore-failure"),
327+ configValue: defaultWorktreeRoot,
328+ setConfig: true,
329+ ignore: worktreesIgnoreUpdate{
330+ path: ignorePath,
331+ before: before,
332+ after: append(append([]byte(nil), before...), []byte(worktreesIgnoreEntry+"\n")...),
333+ existed: true,
334+ mode: 0o644,
335+ changed: true,
336+ },
337+ }
338+
339+ _, err := executeCreateWorktreePlan(context.Background(), plan, nil)
340+ if err == nil || !strings.Contains(err.Error(), ".gitignore changed since preview") {
341+ t.Fatalf("error = %v, want stale .gitignore error", err)
342+ }
343+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
344+ t.Fatalf("failed bootstrap kept local config: %#v", got)
345+ }
346+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/ignore-failure"); got.exitCode != 1 {
347+ t.Fatalf("failed bootstrap created branch: %#v", got)
348+ }
349+ contents, err := os.ReadFile(ignorePath)
350+ if err != nil {
351+ t.Fatal(err)
352+ }
353+ if got, want := string(contents), "changed by another process\n"; got != want {
354+ t.Fatalf(".gitignore = %q, want %q", got, want)
355+ }
356+}
357+
358+func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) {
359+ if _, err := exec.LookPath("git"); err != nil {
360+ t.Skip("git is required")
361+ }
362+ repo := initWorktreeTestRepo(t)
363+ ignorePath := filepath.Join(repo, ".gitignore")
364+ if err := os.WriteFile(ignorePath, []byte("generated/\r\n"), 0o644); err != nil {
365+ t.Fatal(err)
366+ }
367+ tool := newCreateWorktreeTestTool(repo)
368+
369+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/crlf","bootstrap_root":".worktrees"}`), nil); err != nil {
370+ t.Fatal(err)
371+ }
372+ ignore, err := os.ReadFile(ignorePath)
373+ if err != nil {
374+ t.Fatal(err)
375+ }
376+ if got, want := string(ignore), "generated/\r\n/.worktrees/\r\n"; got != want {
377+ t.Fatalf(".gitignore = %q, want %q", got, want)
378+ }
379+}
380+
381+func TestCreateWorktreeRejectsGitignoreSymbolicLink(t *testing.T) {
382+ if _, err := exec.LookPath("git"); err != nil {
383+ t.Skip("git is required")
384+ }
385+ repo := initWorktreeTestRepo(t)
386+ target := filepath.Join(repo, "ignore-target")
387+ if err := os.WriteFile(target, []byte("generated/\n"), 0o644); err != nil {
388+ t.Fatal(err)
389+ }
390+ ignorePath := filepath.Join(repo, ".gitignore")
391+ if err := os.Symlink(target, ignorePath); err != nil {
392+ t.Skipf("symbolic links are unavailable: %v", err)
393+ }
394+ tool := newCreateWorktreeTestTool(repo)
395+
396+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/ignore-symlink","bootstrap_root":".worktrees"}`), nil)
397+ if err == nil || !strings.Contains(err.Error(), ".gitignore must not be a symbolic link") {
398+ t.Fatalf("error = %v, want symbolic-link error", err)
399+ }
400+ info, err := os.Lstat(ignorePath)
401+ if err != nil {
402+ t.Fatal(err)
403+ }
404+ if info.Mode()&os.ModeSymlink == 0 {
405+ t.Fatal("tool replaced .gitignore symbolic link")
406+ }
407+ if _, statErr := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(statErr) {
408+ t.Fatalf("symbolic-link rejection created .worktrees: %v", statErr)
409+ }
410+}
411+
412+func TestCreateWorktreeRejectsNonRegularGitignore(t *testing.T) {
413+ if _, err := exec.LookPath("git"); err != nil {
414+ t.Skip("git is required")
415+ }
416+ repo := initWorktreeTestRepo(t)
417+ if err := os.Mkdir(filepath.Join(repo, ".gitignore"), 0o755); err != nil {
418+ t.Fatal(err)
419+ }
420+ tool := newCreateWorktreeTestTool(repo)
421+
422+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/nonregular","bootstrap_root":".worktrees"}`), nil)
423+ if err == nil || !strings.Contains(err.Error(), ".gitignore must be a regular file") {
424+ t.Fatalf("error = %v, want regular-file error", err)
425+ }
426+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
427+ t.Fatalf("non-regular ignore saved config: %#v", got)
428+ }
429+}
430+
431+func TestCreateWorktreeGitOutputKeepsStderrOutOfSuccessfulOutput(t *testing.T) {
432+ if runtime.GOOS == "windows" {
433+ t.Skip("Git fixture uses a POSIX shell")
434+ }
435+ binDir := t.TempDir()
436+ gitPath := filepath.Join(binDir, "git")
437+ if err := os.WriteFile(gitPath, []byte("#!/bin/sh\nprintf 'value\\n'\nprintf 'warning\\n' >&2\n"), 0o755); err != nil {
438+ t.Fatal(err)
439+ }
440+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
441+
442+ output, err := createWorktreeGitOutput(context.Background(), t.TempDir(), "status")
443+ if err != nil {
444+ t.Fatal(err)
445+ }
446+ if output != "value" {
447+ t.Fatalf("output = %q, want value", output)
448+ }
449+}
450+
451+func TestCreateWorktreeGitCancelsDescendants(t *testing.T) {
452+ if runtime.GOOS == "windows" {
453+ t.Skip("process-group fixture uses a POSIX shell")
454+ }
455+ binDir := t.TempDir()
456+ started := filepath.Join(t.TempDir(), "git-started")
457+ childPID := filepath.Join(t.TempDir(), "child-pid")
458+ gitPath := filepath.Join(binDir, "git")
459+ script := "#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\" > " + shellQuoteForTest(childPID) + "\nprintf started > " + shellQuoteForTest(started) + "\nwait\n"
460+ if err := os.WriteFile(gitPath, []byte(script), 0o755); err != nil {
461+ t.Fatal(err)
462+ }
463+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
464+ ctx, cancel := context.WithCancel(context.Background())
465+ defer cancel()
466+ cwd := t.TempDir()
467+ done := make(chan error, 1)
468+ go func() {
469+ _, err := createWorktreeGitOutput(ctx, cwd, "status")
470+ done <- err
471+ }()
472+
473+ deadline := time.NewTimer(5 * time.Second)
474+ defer deadline.Stop()
475+ poll := time.NewTicker(10 * time.Millisecond)
476+ defer poll.Stop()
477+ for {
478+ if _, err := os.Stat(started); err == nil {
479+ break
480+ } else if !os.IsNotExist(err) {
481+ t.Fatal(err)
482+ }
483+ select {
484+ case <-deadline.C:
485+ t.Fatal("fake git did not start")
486+ case <-poll.C:
487+ }
488+ }
489+ cancel()
490+ select {
491+ case err := <-done:
492+ if !errors.Is(err, context.Canceled) {
493+ t.Fatalf("error = %v, want context canceled", err)
494+ }
495+ case <-time.After(5 * time.Second):
496+ t.Fatal("Git process group did not exit after cancellation")
497+ }
498+ pid, err := os.ReadFile(childPID)
499+ if err != nil {
500+ t.Fatal(err)
501+ }
502+ exitDeadline := time.NewTimer(5 * time.Second)
503+ defer exitDeadline.Stop()
504+ for {
505+ err := exec.Command("kill", "-0", strings.TrimSpace(string(pid))).Run()
506+ if err != nil {
507+ var exitErr *exec.ExitError
508+ if errors.As(err, &exitErr) {
509+ break
510+ }
511+ t.Fatalf("check child process: %v", err)
512+ }
513+ select {
514+ case <-exitDeadline.C:
515+ t.Fatalf("Git descendant %q remained after cancellation", pid)
516+ case <-poll.C:
517+ }
518+ }
519+}
520+
521+func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) {
522+ if _, err := exec.LookPath("git"); err != nil {
523+ t.Skip("git is required")
524+ }
525+ repo := initWorktreeTestRepo(t)
526+ tool := newCreateWorktreeTestTool(repo)
527+ args := json.RawMessage(`{"branch":"feature/preview"}`)
528+
529+ preview, err := tool.Preview(context.Background(), args)
530+ if err != nil {
531+ t.Fatal(err)
532+ }
533+ text := preview.Content[0].(provider.TextBlock).Text
534+ for _, want := range []string{"Worktree bootstrap is required", "feature/preview", "bootstrap_root"} {
535+ if !strings.Contains(text, want) {
536+ t.Fatalf("preview missing %q:\n%s", want, text)
537+ }
538+ }
539+ if _, err := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(err) {
540+ t.Fatalf("preview created .worktrees: %v", err)
541+ }
542+ if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) {
543+ t.Fatalf("preview created .gitignore: %v", err)
544+ }
545+ cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview")
546+ if err := cmd.Run(); err == nil {
547+ t.Fatal("preview created the branch")
548+ }
549+}
550+
551+func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) {
552+ if _, err := exec.LookPath("git"); err != nil {
553+ t.Skip("git is required")
554+ }
555+ repo := initWorktreeTestRepo(t)
556+ if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil {
557+ t.Fatal(err)
558+ }
559+ tool := newCreateWorktreeTestTool(repo)
560+ args := json.RawMessage(`{"branch":"feature/stale-preview"}`)
561+ if _, err := tool.Preview(context.Background(), args); err != nil {
562+ t.Fatal(err)
563+ }
564+ if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil {
565+ t.Fatal(err)
566+ }
567+ gitTestOutput(t, repo, "add", "after-preview.txt")
568+ gitTestOutput(t, repo, "commit", "-qm", "change after preview")
569+
570+ _, err := tool.Execute(context.Background(), args, nil)
571+ if err == nil || !strings.Contains(err.Error(), "state changed since preview") {
572+ t.Fatalf("error = %v, want stale-preview error", err)
573+ }
574+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 {
575+ t.Fatalf("stale preview created branch: %#v", got)
576+ }
577+}
578+
579+func TestCreateWorktreeRejectsNonRepository(t *testing.T) {
580+ dir := t.TempDir()
581+ tool := newCreateWorktreeTestTool(dir)
582+
583+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil)
584+ if err == nil || !strings.Contains(err.Error(), "not a Git repository") {
585+ t.Fatalf("error = %v, want repository error", err)
586+ }
587+ for _, path := range []string{".worktrees", ".gitignore"} {
588+ if _, statErr := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(statErr) {
589+ t.Fatalf("non-repository call created %s: %v", path, statErr)
590+ }
591+ }
592+}
593+
594+func TestCreateWorktreeRequiresSandbox(t *testing.T) {
595+ tool := &CreateWorktreeTool{CWD: t.TempDir()}
596+
597+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil)
598+ if err == nil || !strings.Contains(err.Error(), "sandbox is required") {
599+ t.Fatalf("error = %v, want sandbox error", err)
600+ }
601+}
602+
603+func TestCreateWorktreeRejectsExistingBranchWithoutChangingGitignore(t *testing.T) {
604+ if _, err := exec.LookPath("git"); err != nil {
605+ t.Skip("git is required")
606+ }
607+ repo := initWorktreeTestRepo(t)
608+ tool := newCreateWorktreeTestTool(repo)
609+ bootstrapArgs := json.RawMessage(`{"branch":"feature/existing","bootstrap_root":".worktrees"}`)
610+ if _, err := tool.Execute(context.Background(), bootstrapArgs, nil); err != nil {
611+ t.Fatal(err)
612+ }
613+ ignoreBefore, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
614+ if err != nil {
615+ t.Fatal(err)
616+ }
617+
618+ _, err = tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/existing"}`), nil)
619+ if err == nil || !strings.Contains(err.Error(), "already exists") {
620+ t.Fatalf("error = %v, want collision error", err)
621+ }
622+ ignoreAfter, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
623+ if err != nil {
624+ t.Fatal(err)
625+ }
626+ if string(ignoreAfter) != string(ignoreBefore) {
627+ t.Fatalf("collision changed .gitignore:\nbefore: %q\nafter: %q", ignoreBefore, ignoreAfter)
628+ }
629+}
630+
631+func TestCreateWorktreeRespectsJail(t *testing.T) {
632+ if _, err := exec.LookPath("git"); err != nil {
633+ t.Skip("git is required")
634+ }
635+ repo := initWorktreeTestRepo(t)
636+ nested := filepath.Join(repo, "nested")
637+ if err := os.Mkdir(nested, 0o755); err != nil {
638+ t.Fatal(err)
639+ }
640+ sandbox := NewSandbox(nested)
641+ sandbox.Lock()
642+ tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
643+
644+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil)
645+ if err == nil || !strings.Contains(err.Error(), "jailed:") {
646+ t.Fatalf("error = %v, want jailed error", err)
647+ }
648+ for _, path := range []string{".worktrees", ".gitignore"} {
649+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
650+ t.Fatalf("jailed call created %s: %v", path, statErr)
651+ }
652+ }
653+}
654+
655+func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) {
656+ if runtime.GOOS == "windows" {
657+ t.Skip("Git invocation fixture uses a POSIX shell")
658+ }
659+ if _, err := exec.LookPath("git"); err != nil {
660+ t.Skip("git is required")
661+ }
662+ repo := initWorktreeTestRepo(t)
663+ nested := filepath.Join(repo, "nested")
664+ if err := os.Mkdir(nested, 0o755); err != nil {
665+ t.Fatal(err)
666+ }
667+ sentinel := filepath.Join(repo, "git-was-invoked")
668+ binDir := t.TempDir()
669+ fakeGit := filepath.Join(binDir, "git")
670+ if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil {
671+ t.Fatal(err)
672+ }
673+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
674+ sandbox := NewSandbox(nested)
675+ sandbox.Lock()
676+ tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
677+
678+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil)
679+ if err == nil || !strings.Contains(err.Error(), "jailed:") {
680+ t.Fatalf("error = %v, want jailed error", err)
681+ }
682+ if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
683+ t.Fatalf("Git ran before jailed ancestor was rejected: %v", err)
684+ }
685+}
686+
687+func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) {
688+ if _, err := exec.LookPath("git"); err != nil {
689+ t.Skip("git is required")
690+ }
691+ repo := initWorktreeTestRepo(t)
692+ sandbox := NewSandbox(repo)
693+ permissions := &PermissionSet{}
694+ permissions.Bash.Mode = "allowlist"
695+ permissions.Bash.Allow = []string{"git"}
696+ sandbox.SetPermissions(permissions)
697+ tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox}
698+
699+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil)
700+ if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") {
701+ t.Fatalf("error = %v, want filesystem permission error", err)
702+ }
703+ for _, path := range []string{".worktrees", ".gitignore"} {
704+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
705+ t.Fatalf("restricted call created %s: %v", path, statErr)
706+ }
707+ }
708+}
709+
710+func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) {
711+ if _, err := exec.LookPath("git"); err != nil {
712+ t.Skip("git is required")
713+ }
714+ repo := initWorktreeTestRepo(t)
715+ sandbox := NewSandbox(repo)
716+ permissions := &PermissionSet{}
717+ permissions.FS.Read = []string{repo}
718+ permissions.FS.Write = []string{repo}
719+ sandbox.SetPermissions(permissions)
720+ tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox}
721+
722+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil)
723+ if err == nil || !strings.Contains(err.Error(), "no bash permission") {
724+ t.Fatalf("error = %v, want git permission error", err)
725+ }
726+ for _, path := range []string{".worktrees", ".gitignore"} {
727+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
728+ t.Fatalf("restricted call created %s: %v", path, statErr)
729+ }
730+ }
731+}
732+
733+func TestCreateWorktreeRejectsInvalidWorktreePathBeforeGitignoreUpdate(t *testing.T) {
734+ if _, err := exec.LookPath("git"); err != nil {
735+ t.Skip("git is required")
736+ }
737+ repo := initWorktreeTestRepo(t)
738+ worktrees := filepath.Join(repo, ".worktrees")
739+ if err := os.Mkdir(worktrees, 0o755); err != nil {
740+ t.Fatal(err)
741+ }
742+ if err := os.WriteFile(filepath.Join(worktrees, "blocked"), []byte("not a directory"), 0o644); err != nil {
743+ t.Fatal(err)
744+ }
745+ tool := newCreateWorktreeTestTool(repo)
746+
747+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil)
748+ if err == nil || !strings.Contains(err.Error(), "not a directory") {
749+ t.Fatalf("error = %v, want non-directory path error", err)
750+ }
751+ if _, statErr := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(statErr) {
752+ t.Fatalf("invalid path left .gitignore behind: %v", statErr)
753+ }
754+ cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new")
755+ if err := cmd.Run(); err == nil {
756+ t.Fatal("invalid path created the branch")
757+ }
758+}
759+
760+func newCreateWorktreeTestTool(cwd string) *CreateWorktreeTool {
761+ return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd)}
762+}
763+
764+func initWorktreeTestRepo(t *testing.T) string {
765+ t.Helper()
766+ t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
767+ t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull)
768+ t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
769+ repo := t.TempDir()
770+ gitTestOutput(t, repo, "init", "-q")
771+ gitTestOutput(t, repo, "config", "user.email", "test@example.invalid")
772+ gitTestOutput(t, repo, "config", "user.name", "Test User")
773+ if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o644); err != nil {
774+ t.Fatal(err)
775+ }
776+ gitTestOutput(t, repo, "add", "README.md")
777+ gitTestOutput(t, repo, "commit", "-qm", "initial")
778+ return repo
779+}
780+
781+func shellQuoteForTest(value string) string {
782+ return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
783+}
784+
785+type gitTestResult struct {
786+ output string
787+ exitCode int
788+}
789+
790+func gitTestOutputAllowExit(t *testing.T, dir string, args ...string) gitTestResult {
791+ t.Helper()
792+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
793+ output, err := cmd.CombinedOutput()
794+ if err == nil {
795+ return gitTestResult{output: strings.TrimSpace(string(output))}
796+ }
797+ if exitErr, ok := err.(*exec.ExitError); ok {
798+ return gitTestResult{output: strings.TrimSpace(string(output)), exitCode: exitErr.ExitCode()}
799+ }
800+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
801+ return gitTestResult{}
802+}
803+
804+func gitTestOutput(t *testing.T, dir string, args ...string) string {
805+ t.Helper()
806+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
807+ output, err := cmd.CombinedOutput()
808+ if err != nil {
809+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
810+ }
811+ return strings.TrimSpace(string(output))
812+}
M packages/agent/tools/read.go +1 -1
@@ -1,4 +1,4 @@
1-// Package tools implements zut's built-in tools: read, write, edit, bash, lsp, and web search.
1+// Package tools implements zut's built-in tools: read, write, edit, bash, create_worktree, lsp, and web search.
22 package tools
33
44 import (
@@ -1,4 +1,4 @@
1-// Package tools implements zut's built-in tools: read, write, edit, bash, lsp, and web search.1+// Package tools implements zut's built-in tools: read, write, edit, bash, create_worktree, lsp, and web search.
2 package tools2 package tools
3 3
4 import (4 import (
M packages/agent/tools/subagent_spawn.go +3 -0
@@ -274,6 +274,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
274274 if agent.FastMode {
275275 sb.WriteString("fast mode: enabled\n")
276276 }
277+ if fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() {
278+ sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n")
279+ }
277280 sb.WriteString("\nThe sub-agent is running in the background. Use /subagents in the TUI to monitor it. ")
278281 sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")
279282 return core.ToolResult{
@@ -274,6 +274,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
274 if agent.FastMode {274 if agent.FastMode {
275 sb.WriteString("fast mode: enabled\n")275 sb.WriteString("fast mode: enabled\n")
276 }276 }
277+ if fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() {
278+ sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n")
279+ }
277 sb.WriteString("\nThe sub-agent is running in the background. Use /subagents in the TUI to monitor it. ")280 sb.WriteString("\nThe sub-agent is running in the background. Use /subagents in the TUI to monitor it. ")
278 sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")281 sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")
279 return core.ToolResult{282 return core.ToolResult{
M packages/agent/tools/subagent_spawn_test.go +41 -0
@@ -299,6 +299,47 @@ func TestSubagentSpawnAppliesProfileFastModeRestriction(t *testing.T) {
299299 }
300300 }
301301
302+func TestSubagentSpawnProfileFastModeOverridesGlobalOffWithWarning(t *testing.T) {
303+ root := t.TempDir()
304+ profileFastMode := true
305+ f := subagents.New(subagents.Config{
306+ Root: filepath.Join(root, "subagents"),
307+ RepoRoot: root,
308+ NewRunner: func(*subagents.Agent) subagents.Runner {
309+ return noopSupervisorRunner{}
310+ },
311+ })
312+ t.Cleanup(f.StopAll)
313+ tool := &SubagentSpawnTool{
314+ Supervisor: f,
315+ Enabled: func() bool { return true },
316+ ResolveSubagent: func(name string) (*subagents.Profile, error) {
317+ if name != "fast-worker" {
318+ return nil, nil
319+ }
320+ return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil
321+ },
322+ }
323+
324+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"fast-worker"}`), nil)
325+ if err != nil {
326+ t.Fatal(err)
327+ }
328+ if res.IsError {
329+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
330+ }
331+ agents := f.List()
332+ if len(agents) != 1 || !agents[0].FastMode {
333+ t.Fatalf("agent fast mode = %#v, want enabled by profile", agents)
334+ }
335+ if got := res.Details.(map[string]any)["fast_mode"]; got != true {
336+ t.Fatalf("fast_mode detail = %v, want true", got)
337+ }
338+ if text := textResult(res.Content); !strings.Contains(text, "warning: subagent profile has fast mode enabled, overriding global fast mode off") {
339+ t.Fatalf("spawn result missing profile override warning:\n%s", text)
340+ }
341+}
342+
302343 func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {
303344 tool := &SubagentSpawnTool{
304345 Supervisor: newTestSupervisor(t),
@@ -299,6 +299,47 @@ func TestSubagentSpawnAppliesProfileFastModeRestriction(t *testing.T) {
299 }299 }
300 }300 }
301 301
302+func TestSubagentSpawnProfileFastModeOverridesGlobalOffWithWarning(t *testing.T) {
303+ root := t.TempDir()
304+ profileFastMode := true
305+ f := subagents.New(subagents.Config{
306+ Root: filepath.Join(root, "subagents"),
307+ RepoRoot: root,
308+ NewRunner: func(*subagents.Agent) subagents.Runner {
309+ return noopSupervisorRunner{}
310+ },
311+ })
312+ t.Cleanup(f.StopAll)
313+ tool := &SubagentSpawnTool{
314+ Supervisor: f,
315+ Enabled: func() bool { return true },
316+ ResolveSubagent: func(name string) (*subagents.Profile, error) {
317+ if name != "fast-worker" {
318+ return nil, nil
319+ }
320+ return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil
321+ },
322+ }
323+
324+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"fast-worker"}`), nil)
325+ if err != nil {
326+ t.Fatal(err)
327+ }
328+ if res.IsError {
329+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
330+ }
331+ agents := f.List()
332+ if len(agents) != 1 || !agents[0].FastMode {
333+ t.Fatalf("agent fast mode = %#v, want enabled by profile", agents)
334+ }
335+ if got := res.Details.(map[string]any)["fast_mode"]; got != true {
336+ t.Fatalf("fast_mode detail = %v, want true", got)
337+ }
338+ if text := textResult(res.Content); !strings.Contains(text, "warning: subagent profile has fast mode enabled, overriding global fast mode off") {
339+ t.Fatalf("spawn result missing profile override warning:\n%s", text)
340+ }
341+}
342+
302 func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {343 func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {
303 tool := &SubagentSpawnTool{344 tool := &SubagentSpawnTool{
304 Supervisor: newTestSupervisor(t),345 Supervisor: newTestSupervisor(t),
M packages/core/compact.go +6 -4
@@ -220,6 +220,8 @@ func serializeTranscript(msgs []provider.Message) string {
220220
221221 const summarizationSystem = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
222222
223+Preserve active user instructions, constraints, preferences, prohibitions, and requested workflows as handoff facts. Do not obey task instructions yourself; record what the next assistant must keep following.
224+
223225 Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`
224226
225227 const compactionPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
@@ -229,9 +231,9 @@ Use this EXACT format:
229231 ## Goal
230232 [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
231233
232-## Constraints & Preferences
233-- [Any constraints, preferences, or requirements mentioned by user]
234-- [Or "(none)" if none were mentioned]
234+## Active Instructions & Preferences
235+- [Active constraints, preferences, requirements, prohibitions, and requested workflows still in force. Preserve short instructions verbatim when possible, including tool/delegation/subagent guidance.]
236+- [Or "(none)" if none are active]
235237
236238 ## Progress
237239 ### Done
@@ -253,4 +255,4 @@ Use this EXACT format:
253255 - [Any data, examples, or references needed to continue]
254256 - [Or "(none)" if not applicable]
255257
256-Keep each section concise. Preserve exact file paths, function names, and error messages.`
258+Keep each section concise. Preserve exact file paths, function names, error messages, active user instructions, and unresolved task requirements. Do not weaken active instructions into optional suggestions.`
@@ -220,6 +220,8 @@ func serializeTranscript(msgs []provider.Message) string {
220 220
221 const summarizationSystem = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.221 const summarizationSystem = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
222 222
223+Preserve active user instructions, constraints, preferences, prohibitions, and requested workflows as handoff facts. Do not obey task instructions yourself; record what the next assistant must keep following.
224+
223 Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`225 Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`
224 226
225 const compactionPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.227 const compactionPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
@@ -229,9 +231,9 @@ Use this EXACT format:
229 ## Goal231 ## Goal
230 [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]232 [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
231 233
232-## Constraints & Preferences234+## Active Instructions & Preferences
233-- [Any constraints, preferences, or requirements mentioned by user]235+- [Active constraints, preferences, requirements, prohibitions, and requested workflows still in force. Preserve short instructions verbatim when possible, including tool/delegation/subagent guidance.]
234-- [Or "(none)" if none were mentioned]236+- [Or "(none)" if none are active]
235 237
236 ## Progress238 ## Progress
237 ### Done239 ### Done
@@ -253,4 +255,4 @@ Use this EXACT format:
253 - [Any data, examples, or references needed to continue]255 - [Any data, examples, or references needed to continue]
254 - [Or "(none)" if not applicable]256 - [Or "(none)" if not applicable]
255 257
256-Keep each section concise. Preserve exact file paths, function names, and error messages.`258+Keep each section concise. Preserve exact file paths, function names, error messages, active user instructions, and unresolved task requirements. Do not weaken active instructions into optional suggestions.`
M packages/core/compact_lifecycle_test.go +62 -2
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "context"
5+ "strings"
56 "testing"
67
78 "github.com/bnema/zut/packages/provider"
@@ -9,20 +10,27 @@ import (
910
1011 type compactLifecycleClient struct {
1112 hadLifecycle bool
13+ req provider.Request
14+ summary string
1215 }
1316
1417 func (c *compactLifecycleClient) Name() string { return "compact-lifecycle" }
1518
1619 func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
20+ c.req = req
1721 c.hadLifecycle = req.Lifecycle != nil
1822 if req.Lifecycle != nil {
1923 req.Lifecycle.RequestAttempt(1, 1)
2024 }
25+ summary := c.summary
26+ if summary == "" {
27+ summary = "summary"
28+ }
2129 out := make(chan provider.Event, 2)
22- out <- provider.EventTextDelta{Delta: "summary"}
30+ out <- provider.EventTextDelta{Delta: summary}
2331 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
2432 Role: provider.RoleAssistant,
25- Content: []provider.Content{provider.TextBlock{Text: "summary"}},
33+ Content: []provider.Content{provider.TextBlock{Text: summary}},
2634 }}
2735 close(out)
2836 return out, nil
@@ -68,3 +76,55 @@ func TestCompactWithEventsEmitsRequestLifecycle(t *testing.T) {
6876 t.Fatalf("fourth event = %#v, want summary text delta", events[3])
6977 }
7078 }
79+
80+func TestCompactPromptPreservesActiveInstructions(t *testing.T) {
81+ const activeInstruction = "delegate independent work to subagents"
82+ client := &compactLifecycleClient{summary: "Active instruction still in force: " + activeInstruction}
83+ agent := NewAgent(client, "compact-model", "system", Registry{})
84+ agent.SetMessages([]provider.Message{
85+ {
86+ Role: provider.RoleUser,
87+ Content: []provider.Content{provider.TextBlock{Text: "When coding, delegate independent work to subagents."}},
88+ },
89+ {
90+ Role: provider.RoleAssistant,
91+ Content: []provider.Content{provider.TextBlock{Text: "I will do that."}},
92+ },
93+ })
94+
95+ if _, err := agent.Compact(context.Background(), 0, nil); err != nil {
96+ t.Fatalf("Compact: %v", err)
97+ }
98+ messages := agent.Messages()
99+ if len(messages) != 1 {
100+ t.Fatalf("compacted messages = %d, want 1", len(messages))
101+ }
102+ if messages[0].Role != provider.RoleUser {
103+ t.Fatalf("compacted message role = %q, want user", messages[0].Role)
104+ }
105+ if messages[0].Meta["compaction"] != "true" {
106+ t.Fatalf("compacted message meta = %#v, want compaction=true", messages[0].Meta)
107+ }
108+ compacted, ok := messages[0].Content[0].(provider.TextBlock)
109+ if !ok {
110+ t.Fatalf("compacted message content = %#v, want TextBlock", messages[0].Content[0])
111+ }
112+ if !strings.Contains(compacted.Text, "## Context Summary (compacted)") || !strings.Contains(compacted.Text, activeInstruction) {
113+ t.Fatalf("compacted transcript did not preserve active instruction:\n%s", compacted.Text)
114+ }
115+ if !strings.Contains(client.req.System, "Preserve active user instructions") {
116+ t.Fatalf("compaction system prompt does not preserve active instructions: %q", client.req.System)
117+ }
118+ if len(client.req.Messages) != 1 {
119+ t.Fatalf("compaction request messages = %d, want 1", len(client.req.Messages))
120+ }
121+ text, ok := client.req.Messages[0].Content[0].(provider.TextBlock)
122+ if !ok {
123+ t.Fatalf("compaction request content = %#v, want TextBlock", client.req.Messages[0].Content[0])
124+ }
125+ for _, want := range []string{"## Active Instructions & Preferences", "delegation/subagent", activeInstruction} {
126+ if !strings.Contains(text.Text, want) {
127+ t.Fatalf("compaction prompt missing %q:\n%s", want, text.Text)
128+ }
129+ }
130+}
@@ -2,6 +2,7 @@ package core
2 2
3 import (3 import (
4 "context"4 "context"
5+ "strings"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/zut/packages/provider"8 "github.com/bnema/zut/packages/provider"
@@ -9,20 +10,27 @@ import (
9 10
10 type compactLifecycleClient struct {11 type compactLifecycleClient struct {
11 hadLifecycle bool12 hadLifecycle bool
13+ req provider.Request
14+ summary string
12 }15 }
13 16
14 func (c *compactLifecycleClient) Name() string { return "compact-lifecycle" }17 func (c *compactLifecycleClient) Name() string { return "compact-lifecycle" }
15 18
16 func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {19 func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
20+ c.req = req
17 c.hadLifecycle = req.Lifecycle != nil21 c.hadLifecycle = req.Lifecycle != nil
18 if req.Lifecycle != nil {22 if req.Lifecycle != nil {
19 req.Lifecycle.RequestAttempt(1, 1)23 req.Lifecycle.RequestAttempt(1, 1)
20 }24 }
25+ summary := c.summary
26+ if summary == "" {
27+ summary = "summary"
28+ }
21 out := make(chan provider.Event, 2)29 out := make(chan provider.Event, 2)
22- out <- provider.EventTextDelta{Delta: "summary"}30+ out <- provider.EventTextDelta{Delta: summary}
23 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{31 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
24 Role: provider.RoleAssistant,32 Role: provider.RoleAssistant,
25- Content: []provider.Content{provider.TextBlock{Text: "summary"}},33+ Content: []provider.Content{provider.TextBlock{Text: summary}},
26 }}34 }}
27 close(out)35 close(out)
28 return out, nil36 return out, nil
@@ -68,3 +76,55 @@ func TestCompactWithEventsEmitsRequestLifecycle(t *testing.T) {
68 t.Fatalf("fourth event = %#v, want summary text delta", events[3])76 t.Fatalf("fourth event = %#v, want summary text delta", events[3])
69 }77 }
70 }78 }
79+
80+func TestCompactPromptPreservesActiveInstructions(t *testing.T) {
81+ const activeInstruction = "delegate independent work to subagents"
82+ client := &compactLifecycleClient{summary: "Active instruction still in force: " + activeInstruction}
83+ agent := NewAgent(client, "compact-model", "system", Registry{})
84+ agent.SetMessages([]provider.Message{
85+ {
86+ Role: provider.RoleUser,
87+ Content: []provider.Content{provider.TextBlock{Text: "When coding, delegate independent work to subagents."}},
88+ },
89+ {
90+ Role: provider.RoleAssistant,
91+ Content: []provider.Content{provider.TextBlock{Text: "I will do that."}},
92+ },
93+ })
94+
95+ if _, err := agent.Compact(context.Background(), 0, nil); err != nil {
96+ t.Fatalf("Compact: %v", err)
97+ }
98+ messages := agent.Messages()
99+ if len(messages) != 1 {
100+ t.Fatalf("compacted messages = %d, want 1", len(messages))
101+ }
102+ if messages[0].Role != provider.RoleUser {
103+ t.Fatalf("compacted message role = %q, want user", messages[0].Role)
104+ }
105+ if messages[0].Meta["compaction"] != "true" {
106+ t.Fatalf("compacted message meta = %#v, want compaction=true", messages[0].Meta)
107+ }
108+ compacted, ok := messages[0].Content[0].(provider.TextBlock)
109+ if !ok {
110+ t.Fatalf("compacted message content = %#v, want TextBlock", messages[0].Content[0])
111+ }
112+ if !strings.Contains(compacted.Text, "## Context Summary (compacted)") || !strings.Contains(compacted.Text, activeInstruction) {
113+ t.Fatalf("compacted transcript did not preserve active instruction:\n%s", compacted.Text)
114+ }
115+ if !strings.Contains(client.req.System, "Preserve active user instructions") {
116+ t.Fatalf("compaction system prompt does not preserve active instructions: %q", client.req.System)
117+ }
118+ if len(client.req.Messages) != 1 {
119+ t.Fatalf("compaction request messages = %d, want 1", len(client.req.Messages))
120+ }
121+ text, ok := client.req.Messages[0].Content[0].(provider.TextBlock)
122+ if !ok {
123+ t.Fatalf("compaction request content = %#v, want TextBlock", client.req.Messages[0].Content[0])
124+ }
125+ for _, want := range []string{"## Active Instructions & Preferences", "delegation/subagent", activeInstruction} {
126+ if !strings.Contains(text.Text, want) {
127+ t.Fatalf("compaction prompt missing %q:\n%s", want, text.Text)
128+ }
129+ }
130+}

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