detached ad3af57

feat(agent): integrate selected upstream improvements (#237)

* feat(agent): integrate selected upstream improvements

* test(modes): make resident completion batch setup deterministic

* test(skills): use test volume root for Windows ancestor link
commit
ad3af576af6a785d5120bd6cedfdbb7372ba8150
author
bnema · · 2026-09-30 17:36 UTC
committer
GitHub · · 2026-09-30 17:36 UTC
parent
4c37e30
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.48.0
92 files · +9955 -437
M README.md +12 -6
@@ -12,7 +12,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
1212 - seventeen core built-in tools (read, write, edit, bash, python, worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.
1313 - three run modes (interactive tui, print, json).
1414 - built-in telegram bot.
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`. Interactive tools can wait for user input without a reply deadline and support cooperative cancellation; headless hosts reject them. See [docs/extensions.md](docs/extensions.md).
1616 - user and extension themes via JSON; see [docs/themes.md](docs/themes.md).
1717 - standing instructions via `AGENTS.md` files (global and per-project); see [Persistent instructions](#persistent-instructions-agentsmd).
1818 - reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md).
@@ -113,11 +113,12 @@ Run `zut` and type `/login`. Pick one of two methods:
113113 - **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you pick a provider from the full API-key provider list, paste the key, and zut saves it to `auth.json` if accepted. Providers with a lightweight model-list endpoint are probed before saving; provider backends that need extra project/account env vars are saved directly.
114114 - **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, SuperGrok/X Premium, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow.
115115 - Anthropic and OpenAI pin the browser callback to fixed provider-specific ports (`localhost:53692` for Anthropic, `localhost:1455` for OpenAI) because those are the only ports their auth servers will redirect to.
116- - Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.
116+ - Anthropic uses the Claude Code OAuth flow, with separate browser callback and copy-code choices. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.
117+ - Browser login for Anthropic and OpenAI Codex accepts a pasted callback URL or code from the same transaction; a supplied state must match. `esc` cancels the pending flow. OpenAI Codex has no standalone manual-code login.
117118 - OpenAI uses the Codex CLI OAuth flow. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.
118119 - Kimi uses the Kimi Code device-code OAuth flow. zut opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers.
119120 - xAI uses a device-code OAuth flow. zut opens a prefilled authorization URL, polls for approval, and uses the resulting token with the xAI API.
120- - GitHub Copilot uses GitHub's device-code login flow. zut stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand.
121+ - GitHub Copilot uses GitHub's device-code login flow. zut stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand. Claude uses Messages, models tagged for Responses use that API, and the remaining models use Chat Completions. Account-specific model discovery filters picker availability without enabling policies; see [GitHub Copilot](docs/providers.md#github-copilot).
121122
122123 > **Note on subscription login.** The OAuth client IDs used are the ones published in Anthropic's Claude Code CLI, OpenAI's Codex CLI, Kimi Code CLI, xAI's device flow, and GitHub Copilot's device-code flow. Reusing them from a third-party tool may be against their terms of service and may be revoked at any time. Use it at your own risk; the API-key flow is the safe default.
123124
@@ -387,8 +388,9 @@ Shows previous sessions for the current working directory, newest first, with ti
387388
388389 ### `/session`
389390
390-Four ops on the current session. `/session` alone opens a picker; each is also runnable directly.
391+Inspect the current session with `/session info` or `/session timeline`, or export, import, fork, and browse its family tree. `/session` alone opens a picker; each operation is also runnable directly.
391392
393+- **`/session timeline`**. Opens a read-only floating inspector for the current system prompt, visible messages, reasoning summaries, and tool calls. Select an event with `up`/`down` or `pgup`/`pgdn`; `tab`/`shift+tab` switches between summary, payload, result, schema, and transcript-derived timing. Use `left`/`right` or `home`/`end` to scroll details, `/` to search, and `esc` to return. `ctrl+e` exports JSON to the default export directory with `0600` permissions where supported. Images are represented by MIME type and size; provider-private signatures and encrypted reasoning are omitted. The export still contains conversation and tool text: review it before sharing. Context composition is a byte-based estimate, not tokenizer-exact.
392394 - **`/session export [path]`**. Writes the running transcript to a portable `.zutsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zutsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zutsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout.
393395
394396 **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/subagents` child journals are not included.** They remain machine-local managed state; a `.zutsession` is only a main-chat transcript. If you want the child conversation, copy it from the child-session view manually.
@@ -510,7 +512,9 @@ Use `{}` for an empty position. If `active_model_profile` is missing, `0`, or ou
510512
511513 ### `/skills`
512514
513-Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately.
515+Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately. User skill discovery follows directory symlinks, skips cycles and dangling links, and preserves nested path aliases. Extension skills remain confined to the extension directory.
516+
517+Press `p` or `g` in the list to toggle a project or global pin. Pinned skill bodies join the first user prompt of a fresh supported conversation; a read-only notice shows the pending selection without changing the editor. Resuming does not reload pins. Pins save repeated invocation, not tokens. See [pinning skills](docs/skills.md#pinning-skills) for storage, lifecycle, and mode support.
514518
515519 ### `/compact`
516520
@@ -626,7 +630,9 @@ A top-level provider key that is not a built-in id defines a custom provider. Gi
626630 }
627631 ```
628632
629-Custom providers are first-class: they appear in `--list-models`, `/model`, and `/login`. `models.json` never stores secrets. Supply the key through `/login`, `--api-key`, or a derived environment variable in upper snake case (so `my-company` reads `MY_COMPANY_API_KEY`). Because many self-hosted gateways do not expose a model-list endpoint, custom provider keys are accepted and stored without a verification probe; an invalid key surfaces on the first model call.
633+Custom providers are first-class: they appear in `--list-models`, `/model`, and `/login`. `models.json` never stores secrets. For authenticated endpoints, supply the key through `/login`, `--api-key`, or a derived environment variable in upper snake case (so `my-company` reads `MY_COMPANY_API_KEY`). Custom endpoints with a configured URL can also run without a key. Set `"auth": "none"` on a custom provider to ignore inherited, CLI, environment, and stored credentials for inference and discovery; command-backed keys are not executed. Use this only with trusted keyless endpoints. Custom keys are saved without a verification probe; an invalid key surfaces on the first model call.
634+
635+Set `"discover": true` at the provider level for opt-in model discovery from its OpenAI-style `/models` endpoint. A provider-level `baseUrl` is required. Discovered models use conservative 32K context and 4K output defaults; explicit model entries override their metadata. Failed refreshes keep the previous in-memory list, and background discovery never executes command-backed credentials. See [custom model discovery](docs/providers.md#custom-model-discovery) for the schema and limits.
630636
631637 To retrieve this custom provider's key from a password manager, add a matching entry to `$ZUT_HOME/auth.json`:
632638
M docs/extensions.md +102 -1
@@ -200,6 +200,12 @@ or any other stdout frame before `hello`.
200200 "capabilities":["commands","tools","alerts","panels"]}
201201 ```
202202
203+Include `"tool_cancel"` in `capabilities` to opt in to host cancellation
204+notifications for abandoned tool calls (see `tool_cancel` below). The Go
205+SDK advertises it automatically. The host only sends `tool_cancel` to
206+extensions that advertised that exact capability; extensions that omit it
207+never receive the frame and otherwise behave as before.
208+
203209 #### `register_command`
204210
205211 ```json
@@ -242,6 +248,66 @@ Set `"deferred": true` to register a tool without advertising its definition ini
242248
243249 On Kimi K3's OpenAI-compatible routes, zut places newly activated schemas at the tool-result position using Kimi's native deferred-tool format. Other models receive the complete active tool list on the next request. Unknown names are ignored. The Go extension SDK exposes `DeferredTool` and `ToolResult.ActivateTools` for the same protocol.
244250
251+##### Interactive tools
252+
253+Set `"interactive": true` on `register_tool` for a tool that intentionally
254+waits for a person, for example one that opens a panel and returns once the
255+user answers. Omitted or `false` keeps the normal 60-second reply timeout.
256+
257+```json
258+{"type":"register_tool","name":"ask_user",
259+ "description":"Ask the user a question","schema":{"type":"object"},
260+ "interactive":true}
261+```
262+
263+An interactive tool has no host-imposed reply deadline. The call still ends
264+when the agent context is cancelled (user abort, turn deadline), when the
265+extension disconnects, or when zut shuts down; each of those surfaces a tool
266+error to the model. Only the interactive terminal host can run interactive
267+tools. Print, JSON, RPC, and bot hosts reject the call with a tool error instead
268+of waiting on an invisible panel. Scheduled background sessions exclude
269+interactive tools from their catalog, even when the shared extension manager is
270+attached to the foreground TUI. Resident subagents also exclude them and reject
271+a declared interactive tool during assembly. Ordinary extension tools retain
272+their existing mode-specific availability.
273+
274+Interactive tools also run from `/btw` and an interactive conversation mirrored
275+to Telegram, but their panels appear only in the local TUI, not in Telegram.
276+A Telegram-only user must use `/stop` to cancel a waiting main-turn call;
277+`/btw` calls are cancelled with Escape in the local TUI. Panels use one shared
278+slot: opening another panel replaces the visible one without notifying
279+the previous owner. Extension handlers must honor cancellation and must not
280+rely on simultaneous panels.
281+
282+The Go SDK provides a context-aware handler:
283+
284+```go
285+e.InteractiveTool("ask_user", "Ask the user a question", schema,
286+ func(ctx context.Context, args json.RawMessage) ext.ToolResult {
287+ // Open your panel and arrange for its callbacks to deliver an answer.
288+ // Use invocation-local state, including a unique panel ID.
289+ defer closeQuestionPanel()
290+ select {
291+ case answer := <-answers:
292+ return ext.TextResult(answer)
293+ case <-ctx.Done():
294+ return ext.TextErrorResult("question cancelled")
295+ }
296+ })
297+```
298+
299+`schema`, `answers`, and `closeQuestionPanel` are extension-owned values.
300+Panel dismissal must also resolve the handler's wait. Cancellation is
301+cooperative: the SDK cancels the handler's `ctx` on `tool_cancel`, on
302+`shutdown`, and when the host connection ends, and it discards a result the
303+handler returns after cancellation, but it cannot stop a handler that ignores
304+`ctx`. Existing `Tool` and `DeferredTool` handlers are unchanged and remain
305+source-compatible.
306+
307+The `interactive` field is additive and does not change the protocol
308+version. Older hosts ignore it and apply their normal timeout. Both host and
309+extension must support the feature for a cancellable, unlimited wait.
310+
245311 #### `ready`
246312
247313 Sentinel telling zut "all initial registrations are flushed". Send it
@@ -541,7 +607,42 @@ responsible for validating/coercing it.
541607
542608 Reply with `tool_result` within the host's tool timeout (default 60s).
543609 Missing the timeout surfaces an error to the model and the call is
544-marked as failed.
610+marked as failed. Tools registered with `"interactive": true` have no reply
611+timeout (see *Interactive tools* above).
612+
613+The request write itself is always bounded, independently of the reply
614+deadline: host frames are queued in order, synchronous writes have a
615+five-second timeout, and a write timeout disconnects the extension. The queue is
616+bounded to 256 frames and 16 MiB; overflowing it disconnects the extension
617+rather than dropping frames silently. If the agent cancels a call
618+while its `tool_call` frame is still queued, the frame is withdrawn and the
619+transport stays healthy. If writing has begun, the host allows up to 250 ms
620+for that frame to finish, then disconnects if it is still blocked: a torn frame
621+cannot be withdrawn, and other pending calls on that extension fail too.
622+
623+Every `tool_call` carries a correlation `id` that is unique within the host
624+process. A `tool_result` for an id the host no longer waits on (timed out,
625+cancelled, or already answered) is ignored.
626+
627+#### `tool_cancel`
628+
629+Sent best-effort when a delivered `tool_call` is abandoned because the agent
630+context was cancelled or the reply timeout elapsed, and only to extensions
631+that advertised `"tool_cancel"` in `hello.capabilities`. Normal completion
632+never sends it. `id` is the `tool_call` correlation id, not a transcript
633+tool id.
634+
635+```json
636+{"type":"tool_cancel","id":"..."}
637+```
638+
639+Stop the associated work, close any panel it opened, and discard its pending
640+state. Ignore unknown or already-completed ids. `tool_cancel` may arrive
641+before your handler goroutine has started, so register cancellation state
642+before dispatching work. Delivery is not guaranteed after a transport
643+disconnect; extensions must also clean up on `shutdown` and on stdin EOF.
644+The host ignores late results for abandoned calls, including races with
645+cancellation.
545646
546647 #### `event`
547648
M docs/providers.md +92 -5
@@ -90,6 +90,38 @@ These providers support subscription login:
9090 OAuth tokens are stored in `$ZUT_HOME/auth.json` and refreshed when refresh is
9191 available.
9292
93+Anthropic offers browser callback and copy-code login as separate choices.
94+Browser login for Anthropic and OpenAI Codex also accepts a pasted callback URL
95+or code using the same authorization transaction; it does not start a second
96+login. A supplied state must match the pending transaction. Escape cancels the
97+pending flow. OpenAI Codex requires the browser callback transaction and has no
98+standalone manual-code login.
99+
100+### GitHub Copilot
101+
102+Copilot uses the account's short-lived inference token. Claude models use the
103+Anthropic Messages API with Copilot Bearer authentication, without Claude Code
104+identity headers. Newer GPT, Grok, and MAI models use Responses; older GPT-4 and
105+Grok Code models remain on Chat Completions. Gemini and Kimi use Chat Completions
106+without `reasoning_effort`. Requests carry user/agent initiator metadata and an
107+image request header when appropriate.
108+
109+Authenticated model discovery filters the runtime catalog by picker visibility,
110+policy state, and tool-call support. The individual-account endpoint may fall
111+back to explicitly enabled policies when no eligible picker entries exist;
112+other endpoints cannot. Zut never enables policies automatically. Availability
113+is a selection aid, not an authorization guarantee, and remains in memory rather
114+than the shared model cache. Failed discovery retains the previous snapshot.
115+Background discovery does not run command-backed credentials. Until discovery
116+succeeds, the static catalog is visible. Existing sessions and explicit model
117+selection still resolve catalog metadata for models hidden from the picker.
118+The default Copilot model is `claude-sonnet-5`. User model overrides retain
119+Copilot protocol defaults unless an explicit API or `reasoningLevelMap` overrides
120+them. For Copilot rows, the `anthropic` and `openai` API aliases select Messages
121+and Chat Completions respectively. Claude 4.5/4.6 rows use explicit thinking
122+budgets. The retired
123+`mai-code-1-flash-picker` is not in the static catalog; `mai-code-1.1-flash` is.
124+
93125 ## API-key providers
94126
95127 These providers can use environment variables. Simple API-key providers can
@@ -478,11 +510,18 @@ unknown `api` value falls back to `openai` with a warning.
478510 ```
479511
480512 Custom providers are first-class: they appear in `--list-models`, `/model`, and
481-`/login`. `models.json` never stores secrets. Supply the key through `/login`,
482-`--api-key`, or a derived environment variable in upper snake case, so
483-`my-company` reads `MY_COMPANY_API_KEY`. Because many self-hosted gateways do
484-not expose a model-list endpoint, custom provider keys are accepted and stored
485-without a verification probe; an invalid key surfaces on the first model call.
513+`/login`. `models.json` never stores secrets. For authenticated endpoints, supply
514+the key through `/login`, `--api-key`, or a derived environment variable in upper
515+snake case, so `my-company` reads `MY_COMPANY_API_KEY`. Custom endpoints with a
516+configured base URL can also run without a key. Set `"auth": "none"` on a custom
517+provider to ignore inherited, CLI, environment, and stored credentials, including
518+command-backed keys, for inference and discovery. Use this only with trusted
519+keyless endpoints. Built-in providers retain their own authentication rules;
520+setting `auth: "none"` on a built-in provider is ignored with a warning.
521+
522+Custom provider keys are accepted and stored without a verification probe,
523+because many self-hosted gateways do not expose a model-list endpoint; an invalid
524+key surfaces on the first model call.
486525
487526 To retrieve this custom provider's key from a password manager, add a matching
488527 entry to `$ZUT_HOME/auth.json`:
@@ -508,6 +547,54 @@ custom model directly:
508547 zut --provider my-company --model company-llm-v2
509548 ```
510549
550+### Custom model discovery
551+
552+Set `"discover": true` at the custom provider level to list models from its
553+OpenAI-style `/models` endpoint. Discovery is off by default and requires a
554+provider-level `baseUrl`; static model entries override discovered metadata.
555+For a local keyless server:
556+
557+```json
558+{
559+ "providers": {
560+ "local-gateway": {
561+ "baseUrl": "http://127.0.0.1:48000/v1",
562+ "api": "openai",
563+ "auth": "none",
564+ "discover": true,
565+ "models": [
566+ { "id": "local-model", "contextWindow": 65536, "maxTokens": 8192 }
567+ ]
568+ }
569+ }
570+}
571+```
572+
573+Zut requests `<baseUrl>/models`, or `<baseUrl>/v1/models` when the base has no
574+version suffix, with a three-second timeout and an eight-MiB response limit.
575+The response must contain `{"data": [{"id": "model-id"}]}`. Discovery trusts IDs
576+only: default metadata is 32,768 context tokens, 4,096 output tokens, zero pricing,
577+and no reasoning controls. IDs containing `embed` are skipped. Configure explicit
578+model metadata to raise limits or enable reasoning. Discovery always verifies
579+TLS certificates; `--insecure` and the saved insecure setting apply to inference,
580+not discovery. For self-signed servers, install a trusted certificate chain.
581+
582+Discovery runs in the background at startup and when `/model` opens. Launching
583+with an unknown model from a discovery-enabled provider probes only that
584+selected provider, without touching other providers' endpoints or key commands.
585+`/model` refreshes configured llama.cpp and custom catalogs with independent three-second
586+timeouts and still opens the picker if a refresh fails, showing the error.
587+Lists stay in memory, separate from the cloud model cache. Failed refreshes
588+retain the previous provider snapshot. Background discovery never executes an
589+`api_key_command` and skips a provider whose only key is command-backed; an
590+explicit refresh may execute that command. The refresh budget also bounds the
591+key command; a slow or interactive password-manager prompt can time out.
592+Environment or stored keys are used when available unless `auth` is `none`; no
593+key is required for a keyless server. If an explicitly selected custom model is
594+unknown and discovery fails, inference can still attempt it with the existing
595+128,000-context / 16,384-output fallback. Configure static model limits to avoid
596+relying on either fallback.
597+
511598 ## Credential resolution
512599
513600 For each request, zut checks credentials in this order:
M docs/skills.md +100 -1
@@ -79,7 +79,26 @@ install` and loaded automatically. User/project skills take precedence over
7979 bundled skills; bundled skills take precedence over embedded built-ins. When
8080 two extensions provide the same skill name, the first declaration wins. The
8181 host rejects manifest paths that escape the extension directory, including
82-symlinked skill files that resolve outside it.
82+symlinked skill files that resolve outside it. Skill directories inside a
83+bundled directory may be symlinks, but only while they resolve inside the
84+extension directory; a link that leaves it is reported and skipped. Real
85+directories load before links, a directory reached through both a real path and
86+a link loads once, and only direct children of the declared directory are
87+considered.
88+
89+User and project skill roots may themselves be symlinks, and so may the
90+directories inside them, so a checkout can live in one place and be linked into
91+a skills directory. Real directories are scanned before links, so a link cannot
92+take a name or slash alias ahead of the real tree. A directory reached twice
93+through links is scanned once, link cycles are ignored, and a link that points
94+at nothing is skipped silently. A link to the skills directory itself or to any
95+of its parents (for example `~` or `/`) is ignored, so it cannot pull the
96+surrounding filesystem into discovery; links to unrelated shared checkouts work
97+normally. Each skills directory is scanned to at most 64 levels and 10,000
98+directories; beyond that zut reports a warning, keeps the skills already found,
99+and skips the rest. Other failures, such as a permission error on a
100+link target, are reported. A skill found through a link reports the installed
101+(link) path, and its slash alias follows the path it was reached by.
83102
84103 When `XDG_STATE_HOME` is set on any platform, `$ZUT_HOME` defaults to
85104 `$XDG_STATE_HOME/zut`. Otherwise it defaults to `~/Library/Application Support/zut/`
@@ -91,6 +110,86 @@ In zut, run `/skills`. A picker lists every discovered skill with its
91110 description and source path. Press enter on a row to view the full
92111 body inline. Press esc to go back.
93112
113+## Pinning skills
114+
115+In the `/skills` list, press `p` to toggle a project pin or `g` to toggle a
116+global pin. Rows show `[p-]`, `[-g]`, or `[pg]` for the selected scopes. Global
117+and project pins are combined in alphabetical order of canonical skill name,
118+with each skill loaded only once even when pinned under both its name and a
119+nested slash alias. Unpinning one scope leaves the other intact. When the host
120+provides no pin preference callbacks, the picker is read-only: no markers are
121+shown and `p`/`g` do nothing. A failed save reports an error without changing
122+preferences.
123+
124+On a fresh interactive session, a read-only `[Pinned skills]` section above the
125+input lists the skills that will accompany your next message, each with its
126+scope: `(project)`, `(global)`, or `(project + global)`. Opening zut does not
127+start a model turn. The complete skill bodies and their directories are
128+included in your first genuine user message and persisted with it, without
129+changing the editor text. `/clear` prepares pins again, as does `/cd` for the
130+new directory. Resuming, importing, or forking an existing session does not
131+inject pins, even when its transcript is empty. Changing models and compacting
132+context do not reload pins.
133+
134+Changing pins before the first message updates the pending selection; after it,
135+changes apply on the next fresh conversation or `/clear`. Unpinning does not
136+remove instructions already in the transcript. A queued user prompt carries the
137+preload once. Recalling or discarding it before delivery restores the raw draft
138+and leaves pins pending for the next user submission; injected bodies are not
139+inserted into the editor. A failed or cancelled pre-turn compaction also leaves
140+the undelivered selection pending for the next user submission. Recall restores
141+the selection already attached to that draft; pin changes made after its
142+submission still apply on the next fresh conversation or `/clear`.
143+
144+Pins are persisted as part of the first user message. Session previews and
145+export filename slugs derived from that message can therefore show the pin
146+preamble rather than the user's request. Input history and queued-draft recall
147+show the original request.
148+
149+Preferences live in `$ZUT_HOME/skill-pins.json`, never in the repository:
150+
151+```json
152+{
153+ "global": ["code-review"],
154+ "projects": {
155+ "/workspace/project": ["test-fix"]
156+ }
157+}
158+```
159+
160+Project keys are the absolute starting directory with symlinks resolved when
161+possible, not the Git root, so subdirectories have separate project pins and a
162+symlinked alias shares the pins of its target. Pins store discovered skill
163+names, not copies of skill files, so normal discovery precedence applies.
164+Missing or disabled skills produce a warning and are skipped. A malformed
165+preferences file is reported and is never overwritten by a toggle; fix or
166+remove it. The file must contain a JSON object, so `null`,
167+arrays, and scalars count as malformed. Only the `global` and `projects` fields
168+are supported: any other top-level field is reported as an unsupported field and
169+the file is left untouched, so unknown data is never silently dropped by a save.
170+If `skill-pins.json` is a symbolic link, zut reads pins through it but refuses
171+to save (the link and its target are never replaced or modified), so pinning
172+from `/skills` reports an error; make the file a regular file to change pins
173+from zut. Saves replace the file through a temporary file
174+in the same directory. Concurrent edits from separate zut processes are not
175+merged, and an open session refreshes preferences on `/skills` and `/clear`,
176+not continuously.
177+
178+`--no-skill` disables pin preloading. Pinning a skill that sets
179+`disable-model-invocation: true` is allowed. Print, stream, and JSON modes also
180+include pins with the first prompt of a fresh session, including runs with
181+session persistence disabled; zutfile startup `pre` commands run first, and
182+warnings go to stderr. Resumed sessions get nothing. Explicit `--orchestrate`
183+runs, RPC, the SDK, standalone bot modes, and resident background workers do not
184+load pin preferences. The
185+interactive Telegram bridge uses the interactive conversation's pins.
186+
187+**Token cost:** pinning saves repeated invocation, not tokens. Every pinned
188+skill's complete body is added once to the first message and stays in the
189+context window (and in every later request) until compaction summarizes it,
190+unlike the on-demand `skill` tool where only the short manifest is always
191+present. Keep pins small, especially with local models.
192+
94193 ## Invoking skills
95194
96195 For normal skills, the system prompt tells the model the skill names and
M packages/agent/botcmd.go +11 -1
@@ -280,7 +280,7 @@ func botRun(spec *botSpec, rawTail []string, version string) (runErr error) {
280280 }
281281 defer closeResolvedLSP(next)
282282 agent.Client = next.NewClient()
283- agent.Model = next.Model
283+ applyResolvedModel(agent, next)
284284 runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD)
285285 return nil
286286 },
@@ -532,3 +532,13 @@ func telegramBotReset() error {
532532 fmt.Println("removed", p)
533533 return nil
534534 }
535+
536+// applyResolvedModel adopts a reloaded model's ID and limits together so a
537+// model change does not keep the previous model's output budget.
538+func applyResolvedModel(agent *core.Agent, next Resolved) {
539+ agent.Model = next.Model
540+ agent.MaxTokens = next.MaxOutput
541+ if next.ContextWindow > 0 {
542+ agent.ContextWindow = next.ContextWindow
543+ }
544+}
A packages/agent/botcmd_limits_test.go +25 -0
@@ -0,0 +1,25 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zut/packages/core"
7+)
8+
9+func TestApplyResolvedModelAdoptsLimits(t *testing.T) {
10+ ag := core.NewAgent(nil, "old", "", nil)
11+ ag.MaxTokens = 4096
12+ ag.ContextWindow = 8000
13+
14+ applyResolvedModel(ag, Resolved{Model: "new", MaxOutput: 64000, ContextWindow: 200000})
15+ if ag.Model != "new" || ag.MaxTokens != 64000 || ag.ContextWindow != 200000 {
16+ t.Fatalf("agent = model %q max %d ctx %d", ag.Model, ag.MaxTokens, ag.ContextWindow)
17+ }
18+
19+ // Missing output metadata must not keep the previous model's cap;
20+ // a missing context window keeps the existing one.
21+ applyResolvedModel(ag, Resolved{Model: "other"})
22+ if ag.Model != "other" || ag.MaxTokens != 0 || ag.ContextWindow != 200000 {
23+ t.Fatalf("agent = model %q max %d ctx %d", ag.Model, ag.MaxTokens, ag.ContextWindow)
24+ }
25+}
M packages/agent/build.go +56 -9
@@ -9,6 +9,7 @@ import (
99 "sort"
1010 "strings"
1111 "sync"
12+ "time"
1213
1314 zutdocs "github.com/bnema/zut"
1415 "github.com/bnema/zut/packages/agent/lsp"
@@ -69,6 +70,11 @@ type Resolved struct {
6970 // client construction does not reread a mutable global registry.
7071 customProviderConfig *provider.CustomProviderConfig
7172
73+ // credentialOptional marks a configured custom endpoint that may run
74+ // without any API credential (keyless). Credential stays empty then, so
75+ // clients send no Authorization header.
76+ credentialOptional bool
77+
7278 // SkillTool is the on-demand skill loader registered with the
7379 // agent's tool registry, or nil if no SKILL.md files were
7480 // discovered. Exposed so the tui can list / preview skills.
@@ -93,8 +99,11 @@ type Resolved struct {
9399 toolDescriptions map[string]string
94100 }
95101
96-// HasCredential reports whether a credential was resolved.
97-func (r Resolved) HasCredential() bool { return r.Credential != "" }
102+// HasCredential reports whether the resolved configuration can create a client.
103+// Configured custom endpoints may intentionally omit an API credential.
104+func (r Resolved) HasCredential() bool {
105+ return r.Credential != "" || r.credentialOptional
106+}
98107
99108 // ModelCatalogSnapshot returns the runtime-owned model metadata captured while
100109 // resolving. It is used by SDK clients whose provider catalog is scoped to a
@@ -235,6 +244,7 @@ type ExtensionToolInfo struct {
235244 Description string
236245 Schema []byte
237246 Deferred bool
247+ Interactive bool
238248 }
239249
240250 // toolSummariesFromRegistry rebuilds the system-prompt tool list
@@ -343,7 +353,7 @@ func defaultModelForProvider(prov string) string {
343353 case provider.ProviderAzureOpenAIResponses:
344354 return "gpt-5"
345355 case provider.ProviderGitHubCopilot:
346- return "claude-sonnet-4.5"
356+ return "claude-sonnet-5"
347357 default:
348358 // Custom providers: pick the first model from the catalog for
349359 // that provider, or fall back to the global default.
@@ -590,6 +600,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
590600 repairConfig = true
591601 }
592602
603+ // An explicit auth:"none" custom provider ignores CLI, env, and stored
604+ // credentials entirely and never runs a stored api-key command.
605+ customCfg, customProvider := provider.CustomProviders()[provName]
606+ keylessCustom := customProvider && !isBuiltinProvider(provName) && customCfg.NoAuth
607+
593608 var (
594609 cred string
595610 method string
@@ -601,7 +616,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
601616 // "ollama" credential never triggers the cloud default.
602617 hasRealOllamaCred bool
603618 )
604- if provName == "ollama" {
619+ if keylessCustom {
620+ // Explicit keyless configuration takes precedence over CLI, env, and
621+ // stored credentials. Never execute a stored API key command.
622+ cred, method, accountID, credErr = "", "", "", nil
623+ } else if provName == "ollama" {
605624 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
606625 if credErr != nil {
607626 // Local ollama needs no key: fall back to the dummy
@@ -616,7 +635,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
616635
617636 // Persist --api-key for custom providers so subsequent runs don't
618637 // need to pass it again.
619- if !isBuiltinProvider(provName) && args.APIKey != "" {
638+ if !isBuiltinProvider(provName) && !keylessCustom && args.APIKey != "" {
620639 if store := AuthStoreFor(); store != nil {
621640 _ = store.SetAPIKey(provName, args.APIKey)
622641 }
@@ -707,6 +726,17 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
707726 model = defaultModelForProvider(provName)
708727 }
709728 }
729+ if isDiscoverableCustomProvider(provName) {
730+ if _, ferr := provider.FindModel(provName, model); ferr != nil {
731+ // Restore discovery-enabled custom models after restart. Skipped
732+ // when the model is already known to avoid a probe per launch.
733+ // Scoped to the selected provider: other providers' endpoints and
734+ // api-key commands must not run for this launch.
735+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
736+ _ = refreshCustomProviderModel(ctx, provName, apiKeyCommandExecute)
737+ cancel()
738+ }
739+ }
710740 resolvedModel, err := findModelForResolve(provName, model, args.modelCatalog)
711741 if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) {
712742 // Local providers are intentionally open-catalogue: any model id the
@@ -820,7 +850,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
820850 _ = SaveConfig(cfg)
821851 }
822852
823- explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
853+ // Discovered custom-provider models inherit the user's models.json
854+ // endpoint, so they count as explicit for --insecure scoping too.
855+ discoveredCustom := resolvedModel.Source == "live" && isDiscoverableCustomProvider(provName)
856+ explicitBaseURL := args.BaseURL != "" || ((resolvedModel.Source == "user" || discoveredCustom) && resolvedModel.BaseURL != "")
824857
825858 // If the model defines a base URL (e.g. local ollama) and the
826859 // user didn't pass --base-url, use the model's URL. For ollama,
@@ -859,8 +892,21 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
859892
860893 // If the model has a base URL, credentials are optional (local
861894 // models like ollama don't need real API keys).
862- if resolvedModel.BaseURL != "" && credErr != nil {
863- cred = "ollama"
895+ credentialOptional := false
896+ if keylessCustom {
897+ if args.BaseURL == "" {
898+ return Resolved{}, fmt.Errorf("custom provider %q has auth none but no base URL", provName)
899+ }
900+ credentialOptional = true
901+ requireCred = false
902+ } else if resolvedModel.BaseURL != "" && credErr != nil {
903+ if customProviderConfig != nil && !isBuiltinProvider(provName) {
904+ // A custom endpoint may be configured for keyless access. Keep the
905+ // credential empty so no placeholder token is ever sent.
906+ credentialOptional = true
907+ } else {
908+ cred = "ollama"
909+ }
864910 credErr = nil
865911 requireCred = false
866912 }
@@ -1013,6 +1059,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
10131059 modelCatalog: modelCatalog,
10141060 modelCatalogAuthoritative: args.modelCatalogAuthoritative,
10151061 customProviderConfig: customProviderConfig,
1062+ credentialOptional: credentialOptional,
10161063 Sandbox: sandbox,
10171064 SkillTool: skillTool,
10181065 skillsEnabled: skillsEnabled,
@@ -1133,7 +1180,7 @@ func descMapFromSummaries(summaries []ToolSummary) map[string]string {
11331180 // must check HasCredential() first.
11341181 func (r Resolved) NewClient() provider.Client {
11351182 if !r.HasCredential() {
1136- panic("NewClient called without credential; check HasCredential first")
1183+ panic("NewClient called without credential or keyless endpoint; check HasCredential first")
11371184 }
11381185 wrap := r.withHTTPClient
11391186 switch r.Provider {
M packages/agent/cli.go +48 -145
@@ -73,6 +73,8 @@ func (h *interactiveExtHooks) iv() *modes.Interactive {
7373 return h.interactive
7474 }
7575
76+func (h *interactiveExtHooks) SupportsInteractiveTools() bool { return h.iv() != nil }
77+
7678 func (h *interactiveExtHooks) attachInteractive(iv *modes.Interactive) {
7779 if h == nil || iv == nil {
7880 return
@@ -272,6 +274,7 @@ func (a *extToolAdapter) Tools() []ExtensionToolInfo {
272274 Description: t.Description,
273275 Schema: t.Schema,
274276 Deferred: t.Deferred,
277+ Interactive: t.Interactive,
275278 }
276279 }
277280 return out
@@ -284,6 +287,7 @@ func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
284287 Description: info.Description,
285288 Schema: info.Schema,
286289 Deferred: info.Deferred,
290+ Interactive: info.Interactive,
287291 })
288292 }
289293
@@ -1146,7 +1150,7 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error)
11461150 }
11471151 }()
11481152 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1149- sess, err := openOrCreateSession(ctx, args, r, ag, version)
1153+ sess, freshSession, err := openOrCreateSessionState(ctx, args, r, ag, version)
11501154 if err != nil {
11511155 return err
11521156 }
@@ -1184,6 +1188,7 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error)
11841188 persistCompaction = sess.AppendCompaction
11851189 }
11861190 started := time.Now()
1191+ prompt = preloadSkillPins(args, freshSession, prompt, extMgr.Skills(), os.Stderr)
11871192 usage, recovery, err := modes.RunPrintWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction)
11881193 elapsed := time.Since(started)
11891194 transcriptStart := start
@@ -1228,7 +1233,7 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error
12281233 }
12291234 }()
12301235 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1231- sess, err := openOrCreateSession(ctx, args, r, ag, version)
1236+ sess, freshSession, err := openOrCreateSessionState(ctx, args, r, ag, version)
12321237 if err != nil {
12331238 return err
12341239 }
@@ -1268,6 +1273,7 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error
12681273 if sess != nil {
12691274 persistCompaction = sess.AppendCompaction
12701275 }
1276+ prompt = preloadSkillPins(args, freshSession, prompt, extMgr.Skills(), os.Stderr)
12711277 recovery, err := modes.RunStreamWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, os.Stderr, persistCompaction)
12721278 transcriptStart := start
12731279 if recovery.Compacted {
@@ -1349,7 +1355,7 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error)
13491355 }
13501356 }()
13511357 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1352- sess, err := openOrCreateSession(ctx, args, r, ag, version)
1358+ sess, freshSession, err := openOrCreateSessionState(ctx, args, r, ag, version)
13531359 if err != nil {
13541360 return err
13551361 }
@@ -1392,6 +1398,7 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error)
13921398 if sess != nil {
13931399 persistCompaction = sess.AppendCompaction
13941400 }
1401+ prompt = preloadSkillPins(args, freshSession, prompt, extMgr.Skills(), os.Stderr)
13951402 recovery, err := modes.RunJSONWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction)
13961403 transcriptStart := start
13971404 if recovery.Compacted {
@@ -1934,6 +1941,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
19341941 }
19351942 })
19361943
1944+ freshSession := true
19371945 var sessBaselineMsgs int // messages already on disk when current session opened
19381946 var sessionTitlePending bool
19391947 // initialPlanSnapshots is reconstructed from the untrimmed resumed
@@ -1942,7 +1950,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
19421950 var initialPlanSnapshots map[string]core.PlanUpdate
19431951 if !args.NoSess && ag != nil {
19441952 var sessErr error
1945- sess, sessErr = openOrCreateSession(ctx, args, r, ag, version)
1953+ sess, freshSession, sessErr = openOrCreateSessionState(ctx, args, r, ag, version)
19461954 if sessErr != nil {
19471955 return sessErr
19481956 }
@@ -2386,6 +2394,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
23862394 committed = true
23872395 persistMu.Unlock()
23882396 if iv != nil {
2397+ iv.SetPinnedSkillsPending(false)
23892398 iv.RefreshGoal()
23902399 iv.RefreshPlan(candidate.planSnapshots)
23912400 }
@@ -2810,49 +2819,30 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
28102819 setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow)
28112820 return nil
28122821 },
2813- AuthManager: mgr,
2814- LlamaCPPConfig: ResolveLlamaCPPConfig,
2815- RefreshLlamaCPPModels: RefreshLlamaCPPModels,
2816- BuildAgent: buildAgent,
2817- SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
2818- BuildAgentFor: buildAgentFor,
2819- BuildAgentForRescue: buildAgentForRescue,
2820- LoggedInProviders: func() []string {
2821- var out []string
2822- seen := map[string]bool{}
2823- for _, p := range knownProviders {
2824- if CredentialAvailable(p) && !seen[p] {
2825- out = append(out, p)
2826- seen[p] = true
2827- }
2828- }
2829- // Include custom providers that have credentials stored.
2830- for p := range provider.CustomProviders() {
2831- if CredentialAvailable(p) && !seen[p] {
2832- out = append(out, p)
2833- seen[p] = true
2834- }
2835- }
2836- // Ollama models are always available (no auth needed).
2837- if !seen["ollama"] {
2838- out = append(out, "ollama")
2839- }
2840- return out
2841- },
2842- LoadSession: loadSession,
2843- LoadSessionContext: loadSessionContext,
2844- ChangeCWD: changeCWD,
2845- PersistCompactHandoff: persistCompactHandoff,
2846- CurrentCompactHandoff: currentCompactHandoff,
2847- PersistGoal: persistGoal,
2848- PersistGoalRuntime: persistGoalRuntime,
2849- CurrentGoal: currentGoal,
2850- CurrentGoalHistory: currentGoalHistory,
2851- CurrentPlan: currentPlan,
2852- PersistPlan: persistPlan,
2853- PersistedPlan: persistedPlan,
2854- InitialPlanSnapshots: initialPlanSnapshots,
2855- EnsureMission: ensureMission,
2822+ AuthManager: mgr,
2823+ LlamaCPPConfig: ResolveLlamaCPPConfig,
2824+ RefreshLlamaCPPModels: RefreshLlamaCPPModels,
2825+ RefreshCustomProviderModels: RefreshCustomProviderModels,
2826+ CustomDiscoveryConfigured: CustomDiscoveryConfigured,
2827+ BuildAgent: buildAgent,
2828+ SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
2829+ BuildAgentFor: buildAgentFor,
2830+ BuildAgentForRescue: buildAgentForRescue,
2831+ LoggedInProviders: modelPickerProviders,
2832+ LoadSession: loadSession,
2833+ LoadSessionContext: loadSessionContext,
2834+ ChangeCWD: changeCWD,
2835+ PersistCompactHandoff: persistCompactHandoff,
2836+ CurrentCompactHandoff: currentCompactHandoff,
2837+ PersistGoal: persistGoal,
2838+ PersistGoalRuntime: persistGoalRuntime,
2839+ CurrentGoal: currentGoal,
2840+ CurrentGoalHistory: currentGoalHistory,
2841+ CurrentPlan: currentPlan,
2842+ PersistPlan: persistPlan,
2843+ PersistedPlan: persistedPlan,
2844+ InitialPlanSnapshots: initialPlanSnapshots,
2845+ EnsureMission: ensureMission,
28562846 CurrentSessionPath: func() string {
28572847 persistMu.Lock()
28582848 defer persistMu.Unlock()
@@ -2931,23 +2921,18 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
29312921 _ = MarkChangelogShown(v)
29322922 },
29332923 SkillSnapshot: func() []*skills.Skill {
2924+ return userSkillSnapshot(args, r.CWD, extMgr.Skills())
2925+ },
2926+ LoadSkillPins: func(cwd string) (skills.Pins, error) {
29342927 if args.NoSkill {
2935- // --no-skill: nothing for the picker to show.
2936- return nil
2928+ return skills.Pins{}, nil
29372929 }
2938- // Re-discover so the picker reflects edits made during
2939- // the session. Cheap; SKILL.md files are small. Filter
2940- // out built-in skills — they're hidden from user-facing
2941- // surfaces because they're implementation detail; the
2942- // model still sees them through the system-prompt
2943- // manifest and the skill tool.
2944- userHome, _ := os.UserHomeDir()
2945- list, _ := skills.Discover(ZutHome(), r.CWD, userHome, args.WithSkills)
2946- list = mergeExtensionSkills(skills.NewTool(list), extMgr.Skills())
2947- return skills.VisibleSkills(list)
2930+ return loadSkillPins(cwd)
29482931 },
2949- NoYolo: args.NoYolo,
2950- ConfirmGate: confirmGate,
2932+ ToggleSkillPin: toggleSkillPin,
2933+ PreloadPinnedSkills: freshSession && !args.NoSkill,
2934+ NoYolo: args.NoYolo,
2935+ ConfirmGate: confirmGate,
29512936 PersistModel: newPersistModelCallback(&persistMu, &sess, &activeProvider, &activeModel, func(message string) {
29522937 if iv != nil {
29532938 iv.Notify("session", "error", message)
@@ -3157,90 +3142,8 @@ func agentSessionsRoot(root string, args Args) string {
31573142 // openOrCreateSession returns a session for the run. sess may be nil
31583143 // with a nil error if session persistence is disabled.
31593144 func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
3160- if args.NoSess {
3161- // Allocate the one logical identity for this non-persisted run during
3162- // runtime assembly instead of allowing a provider adapter to invent it.
3163- ag.SessionID()
3164- return nil, nil
3165- }
3166- // Sweep meta-only files left over from older zut versions (and from
3167- // any session that crashed before its first AppendMessage) for the
3168- // cwd-scoped picker paths. Explicit UUID lookup identifies candidates
3169- // from their headers so unrelated transcript corruption does not block
3170- // a valid resume.
3171- sessionsRoot := agentSessionsRoot(ZutHome(), args)
3172- if args.ResumeSessionID == "" {
3173- core.PruneEmptySessions(sessionsRoot, args.CWD)
3174- }
3175- var (
3176- s *core.Session
3177- msgs []provider.Message
3178- err error
3179- )
3180- switch {
3181- case args.Session != "":
3182- s, msgs, err = core.OpenSession(args.Session)
3183- // An explicit session path may be created on first use; session pickers
3184- // and resume paths still select existing files only.
3185- if err != nil && errors.Is(err, os.ErrNotExist) {
3186- s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version)
3187- msgs = nil
3188- }
3189- case args.Continue:
3190- latest := core.LatestSession(sessionsRoot, args.CWD)
3191- if latest != "" {
3192- s, msgs, err = core.OpenSession(latest)
3193- }
3194- case args.Resume:
3195- if args.ResumeSessionID != "" {
3196- picked, lookupErr := core.FindManagedSessionByID(ctx, ZutHome(), args.ResumeSessionID)
3197- if lookupErr != nil {
3198- return nil, lookupErr
3199- }
3200- if picked == "" {
3201- return nil, fmt.Errorf("session %q not found", args.ResumeSessionID)
3202- }
3203- s, msgs, err = core.OpenSession(picked)
3204- if err == nil && s.ID != args.ResumeSessionID {
3205- closeErr := s.Close()
3206- return nil, errors.Join(
3207- fmt.Errorf("session %q changed during resume", args.ResumeSessionID),
3208- closeErr,
3209- )
3210- }
3211- break
3212- }
3213- picked, perr := pickSession(sessionsRoot, args.CWD)
3214- if perr != nil {
3215- return nil, perr
3216- }
3217- if picked != "" {
3218- s, msgs, err = core.OpenSession(picked)
3219- }
3220- }
3221- if err != nil {
3222- return nil, err
3223- }
3224- if s != nil {
3225- if err := ag.BindSessionID(s.Meta.ID); err != nil {
3226- return nil, err
3227- }
3228- ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
3229- ag.SetMessages(msgs)
3230- if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {
3231- ag.SeedCost(cum)
3232- ag.SeedLastTurnUsage(last)
3233- }
3234- return s, nil
3235- }
3236- s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
3237- if err == nil {
3238- if bindErr := ag.BindSessionID(s.Meta.ID); bindErr != nil {
3239- return nil, bindErr
3240- }
3241- ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
3242- }
3243- return s, err
3145+ sess, _, err := openOrCreateSessionState(ctx, args, r, ag, version)
3146+ return sess, err
32443147 }
32453148
32463149 func pickSession(root, cwd string) (string, error) {
A packages/agent/copilot_availability_test.go +29 -0
@@ -0,0 +1,29 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zut/packages/provider"
7+)
8+
9+func TestRefreshCopilotAvailabilityWithoutCredentials(t *testing.T) {
10+ preserveProviderCatalog(t)
11+ t.Setenv("ZUT_HOME", t.TempDir())
12+ t.Setenv("COPILOT_GITHUB_TOKEN", "")
13+ t.Setenv("GITHUB_COPILOT_TOKEN", "")
14+ provider.SetModelAvailability("github-copilot", []string{})
15+ refreshCopilotModelAvailability()
16+ if len(provider.ModelsForProvider("github-copilot")) == 0 {
17+ t.Fatal("account restriction was retained after credentials disappeared")
18+ }
19+}
20+
21+func TestCopilotDefaultUsesSonnet5(t *testing.T) {
22+ id := defaultModelForProvider("github-copilot")
23+ if id != "claude-sonnet-5" {
24+ t.Fatalf("Copilot default = %q, want claude-sonnet-5", id)
25+ }
26+ if _, err := provider.FindModel("github-copilot", id); err != nil {
27+ t.Fatalf("Copilot default missing from catalog: %v", err)
28+ }
29+}
A packages/agent/customdiscovery.go +133 -0
@@ -0,0 +1,133 @@
1+package agent
2+
3+import (
4+ "context"
5+ "errors"
6+ "net/http"
7+ "os"
8+ "sort"
9+ "strings"
10+ "time"
11+
12+ "github.com/bnema/zut/packages/provider"
13+)
14+
15+// discoverableCustomProviders returns the models.json providers that opted
16+// into live discovery, sorted for deterministic refresh order.
17+func discoverableCustomProviders() []string {
18+ var out []string
19+ for name := range provider.CustomProviders() {
20+ if isDiscoverableCustomProvider(name) {
21+ out = append(out, name)
22+ }
23+ }
24+ sort.Strings(out)
25+ return out
26+}
27+
28+// isDiscoverableCustomProvider reports whether name is a custom provider
29+// with live discovery enabled. Its catalog entries are transient, so config
30+// repair must not treat a missing model as stale.
31+func isDiscoverableCustomProvider(name string) bool {
32+ cfg, ok := provider.CustomProviders()[name]
33+ return ok && cfg.Discover && strings.TrimSpace(cfg.BaseURL) != "" && !isBuiltinProvider(name)
34+}
35+
36+// CustomDiscoveryConfigured reports whether any custom provider has live
37+// discovery enabled. It reads only loaded configuration, never the network.
38+func CustomDiscoveryConfigured() bool {
39+ return len(discoverableCustomProviders()) > 0
40+}
41+
42+// customProviderKey resolves the optional bearer key for a custom provider.
43+// A missing key is not an error because local servers commonly accept
44+// unauthenticated requests. skip is true when the only key is command-backed
45+// and mode forbids running the command. A provider configured with
46+// auth "none" never consults the environment, auth.json, or key commands.
47+func customProviderKey(ctx context.Context, name string, mode apiKeyCommandMode) (key string, skip bool, err error) {
48+ if cfg, ok := provider.CustomProviders()[name]; ok && cfg.NoAuth && !isBuiltinProvider(name) {
49+ return "", false, nil
50+ }
51+ if v := os.Getenv(normalizeCustomProviderEnvVar(name) + "_API_KEY"); v != "" {
52+ return v, false, nil
53+ }
54+ creds, err := AuthStoreFor().Load()
55+ if err != nil {
56+ return "", false, err
57+ }
58+ stored, ok := creds.AdditionalAPIKeyCreds[name]
59+ if !ok {
60+ return "", false, nil
61+ }
62+ if stored.APIKey == "" && stored.APIKeyCommand != nil && mode == apiKeyCommandSkip {
63+ return "", true, nil
64+ }
65+ key, _, err = resolveStoredAPIKey(ctx, name, stored, mode)
66+ return key, false, err
67+}
68+
69+// customDiscoveryHTTPClient keeps normal TLS verification for discovery,
70+// independent of the inference-only insecure setting, and honors proxy envs.
71+func customDiscoveryHTTPClient() *http.Client {
72+ return provider.NewHTTPClient(false)
73+}
74+
75+// RefreshCustomProviderModels lists models from every discovery-enabled
76+// custom provider and merges them into the transient managed catalog.
77+// A failing provider keeps its previous snapshot; errors are joined after
78+// all providers were attempted. It may run a stored api-key command, so
79+// only call it for an explicit user action or model launch.
80+func RefreshCustomProviderModels(ctx context.Context) error {
81+ ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
82+ defer cancel()
83+ return refreshCustomProviderModels(ctx, apiKeyCommandExecute)
84+}
85+
86+// refreshCustomProvidersBackground is the startup variant: it never runs an
87+// api-key command and skips providers whose only key is command-backed.
88+func refreshCustomProvidersBackground() {
89+ if !CustomDiscoveryConfigured() {
90+ return
91+ }
92+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
93+ defer cancel()
94+ _ = refreshCustomProviderModels(ctx, apiKeyCommandSkip)
95+}
96+
97+func refreshCustomProviderModels(ctx context.Context, mode apiKeyCommandMode) error {
98+ var errs []error
99+ var client *http.Client
100+ for _, name := range discoverableCustomProviders() {
101+ if client == nil {
102+ client = customDiscoveryHTTPClient()
103+ }
104+ if err := refreshCustomProviderModelWithClient(ctx, client, name, mode); err != nil {
105+ errs = append(errs, err)
106+ }
107+ }
108+ return errors.Join(errs...)
109+}
110+
111+// refreshCustomProviderModel refreshes exactly one discovery-enabled provider.
112+// Other providers' endpoints and key commands are never touched. A provider
113+// that did not opt in is a no-op.
114+func refreshCustomProviderModel(ctx context.Context, name string, mode apiKeyCommandMode) error {
115+ if !isDiscoverableCustomProvider(name) {
116+ return nil
117+ }
118+ return refreshCustomProviderModelWithClient(ctx, customDiscoveryHTTPClient(), name, mode)
119+}
120+
121+func refreshCustomProviderModelWithClient(ctx context.Context, client *http.Client, name string, mode apiKeyCommandMode) error {
122+ cfg := provider.CustomProviders()[name]
123+ key, skip, err := customProviderKey(ctx, name, mode)
124+ if err != nil || skip {
125+ return err
126+ }
127+ models, err := provider.DiscoverCustomProviderWithClient(ctx, client, name, cfg.BaseURL, key)
128+ if err != nil {
129+ return err // keep the previous snapshot
130+ }
131+ provider.SetManagedModelsForProvider(name, models)
132+ return nil
133+}
A packages/agent/customdiscovery_test.go +486 -0
@@ -0,0 +1,486 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "os"
10+ "path/filepath"
11+ "slices"
12+ "strings"
13+ "sync/atomic"
14+ "testing"
15+
16+ "github.com/bnema/zut/packages/provider"
17+ "github.com/bnema/zut/packages/provider/auth"
18+)
19+
20+// loadTestModelsJSON installs a models.json under ZUT_HOME and restores the
21+// provider catalog and custom-provider registry when the test ends.
22+func loadTestModelsJSON(t *testing.T, body string) {
23+ t.Helper()
24+ preserveProviderCatalog(t)
25+ if err := os.WriteFile(UserModelsPath(), []byte(body), 0o644); err != nil {
26+ t.Fatal(err)
27+ }
28+ provider.SetManagedModelsForProvider("m4", nil)
29+ provider.SetManagedModelsForProvider("m5", nil)
30+ LoadUserModels()
31+ t.Cleanup(func() {
32+ _ = os.WriteFile(UserModelsPath(), []byte(`{"providers":{}}`), 0o644)
33+ LoadUserModels()
34+ })
35+}
36+
37+func newModelsServer(t *testing.T, requests *atomic.Int32, wantAuth string, ids ...string) *httptest.Server {
38+ t.Helper()
39+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40+ requests.Add(1)
41+ if r.URL.Path != "/v1/models" {
42+ t.Errorf("unexpected path %s", r.URL.Path)
43+ }
44+ if got := r.Header.Get("Authorization"); got != wantAuth {
45+ t.Errorf("Authorization = %q, want %q", got, wantAuth)
46+ }
47+ var data []map[string]string
48+ for _, id := range ids {
49+ data = append(data, map[string]string{"id": id})
50+ }
51+ _ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data})
52+ }))
53+ t.Cleanup(server.Close)
54+ return server
55+}
56+
57+func TestCustomDiscoveryKeepsTLSVerificationWithInsecureInference(t *testing.T) {
58+ t.Setenv("ZUT_HOME", t.TempDir())
59+ if err := SaveConfig(Config{Insecure: true}); err != nil {
60+ t.Fatal(err)
61+ }
62+ client := customDiscoveryHTTPClient()
63+ if transport, ok := client.Transport.(*http.Transport); ok && transport.TLSClientConfig != nil && transport.TLSClientConfig.InsecureSkipVerify {
64+ t.Fatal("discovery inherited insecure inference TLS")
65+ }
66+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
67+ _, _ = w.Write([]byte(`{"data":[{"id":"test-model"}]}`))
68+ }))
69+ defer server.Close()
70+ if _, err := provider.DiscoverCustomProviderWithClient(context.Background(), client, "test-provider", server.URL, ""); err == nil {
71+ t.Fatal("discovery trusted a self-signed server")
72+ }
73+}
74+
75+func TestRefreshCustomProviderModelsIsOptIn(t *testing.T) {
76+ t.Setenv("ZUT_HOME", t.TempDir())
77+ t.Setenv("M4_API_KEY", "")
78+ var requests atomic.Int32
79+ server := newModelsServer(t, &requests, "", "qwen3-coder")
80+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {
81+ "m4": {"baseUrl": %q, "discover": true},
82+ "m5": {"baseUrl": %q, "models": [{"id": "pinned"}]}
83+ }}`, server.URL+"/v1", server.URL+"/v1"))
84+
85+ if !CustomDiscoveryConfigured() {
86+ t.Fatal("discovery not reported as configured")
87+ }
88+ if got := discoverableCustomProviders(); len(got) != 1 || got[0] != "m4" {
89+ t.Fatalf("discoverable = %v", got)
90+ }
91+ if err := RefreshCustomProviderModels(context.Background()); err != nil {
92+ t.Fatal(err)
93+ }
94+ if requests.Load() != 1 {
95+ t.Fatalf("requests = %d, want 1 (only m4)", requests.Load())
96+ }
97+ m, err := provider.FindModel("m4", "qwen3-coder")
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ if m.Source != "live" || m.BaseURL != server.URL+"/v1" || m.ContextWindow <= 0 || m.MaxOutput <= 0 {
102+ t.Fatalf("model = %+v", m)
103+ }
104+ if _, err := provider.FindModel("m5", "qwen3-coder"); err == nil {
105+ t.Fatal("m5 gained discovered models without opting in")
106+ }
107+ if _, err := provider.FindModel("m5", "pinned"); err != nil {
108+ t.Fatal("m5 user model lost")
109+ }
110+}
111+
112+func TestDiscoveryDefaultsOffNoRequestsAtStartup(t *testing.T) {
113+ t.Setenv("ZUT_HOME", t.TempDir())
114+ var requests atomic.Int32
115+ server := newModelsServer(t, &requests, "")
116+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers":{"m5":{"baseUrl":%q,"models":[{"id":"pinned"}]}}}`, server.URL+"/v1"))
117+ refreshCustomProvidersBackground()
118+ if requests.Load() != 0 || CustomDiscoveryConfigured() {
119+ t.Fatalf("discovery ran without opt-in (requests=%d)", requests.Load())
120+ }
121+}
122+
123+func TestRefreshCustomProviderModelsUsesStoredKeyAndKeepsUserOverrides(t *testing.T) {
124+ t.Setenv("ZUT_HOME", t.TempDir())
125+ t.Setenv("M4_API_KEY", "")
126+ var requests atomic.Int32
127+ server := newModelsServer(t, &requests, "Bearer dummy", "qwen3-coder", "big-model")
128+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {
129+ "m4": {"baseUrl": %q, "discover": true, "models": [{"id": "big-model", "contextWindow": 200000, "maxTokens": 16000}]}
130+ }}`, server.URL+"/v1"))
131+ if err := AuthStoreFor().SetAPIKey("m4", "dummy"); err != nil {
132+ t.Fatal(err)
133+ }
134+ if err := RefreshCustomProviderModels(context.Background()); err != nil {
135+ t.Fatal(err)
136+ }
137+ if requests.Load() != 1 {
138+ t.Fatalf("requests = %d", requests.Load())
139+ }
140+ big, err := provider.FindModel("m4", "big-model")
141+ if err != nil {
142+ t.Fatal(err)
143+ }
144+ if big.Source != "user" || big.ContextWindow != 200000 || big.MaxOutput != 16000 {
145+ t.Fatalf("models.json override lost: %+v", big)
146+ }
147+ if _, err := provider.FindModel("m4", "qwen3-coder"); err != nil {
148+ t.Fatal("discovered model missing")
149+ }
150+}
151+
152+func TestRefreshCustomProviderModelsExplicitKeylessIgnoresStoredKey(t *testing.T) {
153+ t.Setenv("ZUT_HOME", t.TempDir())
154+ t.Setenv("M4_API_KEY", "env-key")
155+ var requests atomic.Int32
156+ server := newModelsServer(t, &requests, "", "qwen3-coder")
157+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers":{"m4":{"baseUrl":%q,"discover":true,"auth":"none"}}}`, server.URL+"/v1"))
158+ if err := AuthStoreFor().SetAPIKey("m4", "stored-key"); err != nil {
159+ t.Fatal(err)
160+ }
161+ if err := RefreshCustomProviderModels(context.Background()); err != nil {
162+ t.Fatal(err)
163+ }
164+ if requests.Load() != 1 {
165+ t.Fatalf("requests = %d, want 1", requests.Load())
166+ }
167+}
168+
169+func TestRefreshCustomProviderModelsBackgroundSkipsKeyCommand(t *testing.T) {
170+ home := t.TempDir()
171+ t.Setenv("ZUT_HOME", home)
172+ t.Setenv("M4_API_KEY", "")
173+ var requests atomic.Int32
174+ server := newModelsServer(t, &requests, "", "m")
175+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {"m4": {"baseUrl": %q, "discover": true}}}`, server.URL))
176+ creds := auth.Credentials{AdditionalAPIKeyCreds: map[string]auth.ProviderCreds{
177+ "m4": {APIKeyCommand: &auth.APIKeyCommand{Program: filepath.Join(home, "does-not-exist")}},
178+ }}
179+ encoded, err := json.Marshal(creds)
180+ if err != nil {
181+ t.Fatal(err)
182+ }
183+ if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil {
184+ t.Fatal(err)
185+ }
186+ if err := refreshCustomProviderModels(context.Background(), apiKeyCommandSkip); err != nil {
187+ t.Fatal(err)
188+ }
189+ if requests.Load() != 0 {
190+ t.Fatal("background refresh probed a provider whose key needs a command")
191+ }
192+ err = RefreshCustomProviderModels(context.Background())
193+ if err == nil || !strings.Contains(err.Error(), "m4") {
194+ t.Fatalf("command failure not reported: %v", err)
195+ }
196+ if requests.Load() != 0 {
197+ t.Fatal("refresh probed after key command failed")
198+ }
199+}
200+
201+func TestRefreshCustomProviderModelsKeepsSnapshotOnFailure(t *testing.T) {
202+ t.Setenv("ZUT_HOME", t.TempDir())
203+ t.Setenv("M4_API_KEY", "")
204+ t.Setenv("M5_API_KEY", "")
205+ var requests atomic.Int32
206+ good := newModelsServer(t, &requests, "", "alive")
207+ failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
208+ http.Error(w, "nope", http.StatusServiceUnavailable)
209+ }))
210+ defer failing.Close()
211+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {
212+ "m4": {"baseUrl": %q, "discover": true},
213+ "m5": {"baseUrl": %q, "discover": true}
214+ }}`, good.URL, failing.URL))
215+ provider.SetManagedModelsForProvider("m5", []provider.Model{{Provider: "m5", ID: "previous", Source: "live"}})
216+
217+ err := RefreshCustomProviderModels(context.Background())
218+ if err == nil || !strings.Contains(err.Error(), "m5 discovery HTTP 503") {
219+ t.Fatalf("err = %v", err)
220+ }
221+ if _, err := provider.FindModel("m4", "alive"); err != nil {
222+ t.Fatal("healthy provider not refreshed when a sibling failed")
223+ }
224+ if _, err := provider.FindModel("m5", "previous"); err != nil {
225+ t.Fatal("failed refresh dropped the previous snapshot")
226+ }
227+}
228+
229+func TestValidateAndRepairConfig_PreservesDiscoverableCustomModel(t *testing.T) {
230+ home := t.TempDir()
231+ t.Setenv("ZUT_HOME", home)
232+ loadTestModelsJSON(t, `{"providers": {
233+ "m4": {"baseUrl": "http://127.0.0.1:1/v1", "discover": true},
234+ "m5": {"baseUrl": "http://127.0.0.1:1/v1", "models": [{"id": "pinned"}]}
235+ }}`)
236+ write := func(c Config) {
237+ t.Helper()
238+ b, _ := json.Marshal(c)
239+ if err := os.WriteFile(filepath.Join(home, "config.json"), b, 0o644); err != nil {
240+ t.Fatal(err)
241+ }
242+ }
243+ write(Config{Provider: "m4", Model: "qwen3-coder"})
244+ ValidateAndRepairConfig()
245+ out, _ := LoadConfig()
246+ if out.Provider != "m4" || out.Model != "qwen3-coder" {
247+ t.Fatalf("discoverable custom model repaired away: %+v", out)
248+ }
249+ write(Config{Provider: "m5", Model: "gone"})
250+ ValidateAndRepairConfig()
251+ out, _ = LoadConfig()
252+ if out.Provider != "m5" || out.Model != "pinned" {
253+ t.Fatalf("non-discoverable custom model not repaired: %+v", out)
254+ }
255+}
256+
257+func TestResolveRestoresDiscoveredCustomModelAfterRestart(t *testing.T) {
258+ t.Setenv("ZUT_HOME", t.TempDir())
259+ t.Setenv("M4_API_KEY", "")
260+ var requests atomic.Int32
261+ server := newModelsServer(t, &requests, "", "qwen3-coder")
262+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {"m4": {"baseUrl": %q, "discover": true}}}`, server.URL+"/v1"))
263+
264+ r, err := Resolve(Args{Provider: "m4", Model: "qwen3-coder", NoSkill: true}, true)
265+ if err != nil {
266+ t.Fatal(err)
267+ }
268+ if requests.Load() != 1 {
269+ t.Fatalf("requests = %d, want a restore probe", requests.Load())
270+ }
271+ if r.Provider != "m4" || r.Model != "qwen3-coder" || r.BaseURL != server.URL+"/v1" || r.MaxOutput <= 0 {
272+ t.Fatalf("resolved = %+v", r)
273+ }
274+ if !r.HasCredential() || r.Credential != "" {
275+ t.Fatalf("keyless discovered provider should be usable without a placeholder: %q", r.Credential)
276+ }
277+ if _, err := Resolve(Args{Provider: "m4", Model: "qwen3-coder", NoSkill: true}, true); err != nil {
278+ t.Fatal(err)
279+ }
280+ if requests.Load() != 1 {
281+ t.Fatalf("requests = %d, want no extra probe", requests.Load())
282+ }
283+}
284+
285+func streamOnce(t *testing.T, r Resolved, model string) {
286+ t.Helper()
287+ events, err := r.NewClient().Stream(context.Background(), provider.Request{
288+ Model: model, Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
289+ })
290+ if err != nil {
291+ t.Fatal(err)
292+ }
293+ for range events {
294+ }
295+}
296+
297+func TestCustomProviderKeylessOmitsAuthorization(t *testing.T) {
298+ for _, api := range []string{"openai", "openai-responses", "anthropic"} {
299+ t.Run(api, func(t *testing.T) {
300+ t.Setenv("ZUT_HOME", t.TempDir())
301+ t.Setenv("UNSLOTH_API_KEY", "")
302+ got := make(chan http.Header, 1)
303+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
304+ got <- r.Header.Clone()
305+ w.Header().Set("Content-Type", "text/event-stream")
306+ }))
307+ t.Cleanup(srv.Close)
308+ loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"`+api+`","models":[{"id":"qista"}]}}}`)
309+ r, err := Resolve(Args{Provider: "unsloth", Model: "qista", NoSkill: true}, true)
310+ if err != nil {
311+ t.Fatal(err)
312+ }
313+ if r.Credential != "" || !r.HasCredential() {
314+ t.Fatalf("credential = %q usable=%v", r.Credential, r.HasCredential())
315+ }
316+ streamOnce(t, r, "qista")
317+ h := <-got
318+ if _, ok := h["Authorization"]; ok {
319+ t.Fatal("Authorization sent")
320+ }
321+ if _, ok := h["X-Api-Key"]; ok {
322+ t.Fatal("X-Api-Key sent")
323+ }
324+ })
325+ }
326+}
327+
328+func TestCustomProviderExplicitKeylessIgnoresInheritedKeys(t *testing.T) {
329+ for _, api := range []string{"openai", "openai-responses", "anthropic"} {
330+ t.Run(api, func(t *testing.T) {
331+ home := t.TempDir()
332+ t.Setenv("ZUT_HOME", home)
333+ t.Setenv("UNSLOTH_API_KEY", "env-key")
334+ got := make(chan http.Header, 1)
335+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
336+ got <- r.Header.Clone()
337+ w.Header().Set("Content-Type", "text/event-stream")
338+ }))
339+ t.Cleanup(srv.Close)
340+ loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"`+api+`","auth":"none","models":[{"id":"qista"}]}}}`)
341+ // A stored command-backed key must never run for a keyless provider.
342+ marker := filepath.Join(home, "ran")
343+ creds := auth.Credentials{AdditionalAPIKeyCreds: map[string]auth.ProviderCreds{
344+ "unsloth": {APIKeyCommand: &auth.APIKeyCommand{Program: "/bin/sh", Args: []string{"-c", "touch " + marker + "; echo secret"}}},
345+ }}
346+ encoded, _ := json.Marshal(creds)
347+ if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil {
348+ t.Fatal(err)
349+ }
350+ r, err := Resolve(Args{Provider: "unsloth", Model: "qista", APIKey: "cli-key", NoSkill: true}, true)
351+ if err != nil {
352+ t.Fatal(err)
353+ }
354+ if r.Credential != "" || !r.HasCredential() {
355+ t.Fatalf("keyless resolved credential = %q, usable = %v", r.Credential, r.HasCredential())
356+ }
357+ streamOnce(t, r, "qista")
358+ h := <-got
359+ if _, ok := h["Authorization"]; ok {
360+ t.Fatal("Authorization sent")
361+ }
362+ if _, ok := h["X-Api-Key"]; ok {
363+ t.Fatal("X-Api-Key sent")
364+ }
365+ if _, err := os.Stat(marker); err == nil {
366+ t.Fatal("stored key command executed for explicit keyless provider")
367+ }
368+ stored, err := AuthStoreFor().Load()
369+ if err != nil || stored.AdditionalAPIKeyCreds["unsloth"].APIKeyCommand == nil {
370+ t.Fatalf("stored credential changed: %+v, %v", stored.AdditionalAPIKeyCreds["unsloth"], err)
371+ }
372+ })
373+ }
374+}
375+
376+func TestCustomProviderWithKeyStillSendsAuthorization(t *testing.T) {
377+ t.Setenv("ZUT_HOME", t.TempDir())
378+ t.Setenv("UNSLOTH_API_KEY", "")
379+ got := make(chan string, 1)
380+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
381+ got <- r.Header.Get("Authorization")
382+ w.Header().Set("Content-Type", "text/event-stream")
383+ }))
384+ t.Cleanup(srv.Close)
385+ loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"`+srv.URL+`/v1","api":"openai","models":[{"id":"qista"}]}}}`)
386+ if err := AuthStoreFor().SetAPIKey("unsloth", "stored-key"); err != nil {
387+ t.Fatal(err)
388+ }
389+ r, err := Resolve(Args{Provider: "unsloth", Model: "qista", NoSkill: true}, true)
390+ if err != nil {
391+ t.Fatal(err)
392+ }
393+ streamOnce(t, r, "qista")
394+ if a := <-got; a != "Bearer stored-key" {
395+ t.Fatalf("Authorization = %q", a)
396+ }
397+}
398+
399+func TestBuiltinProviderStillRequiresCredentialAndKeepsPlaceholderRules(t *testing.T) {
400+ t.Setenv("ZUT_HOME", t.TempDir())
401+ t.Setenv("ANTHROPIC_API_KEY", "")
402+ t.Setenv("ANTHROPIC_OAUTH_TOKEN", "")
403+ // A custom provider named like a built-in must not become keyless.
404+ loadTestModelsJSON(t, `{"providers":{"anthropic":{"baseUrl":"http://127.0.0.1:9/v1","auth":"none","models":[{"id":"claude-sonnet-5"}]}}}`)
405+ r, err := Resolve(Args{Provider: "anthropic", Model: "claude-sonnet-5", NoSkill: true}, false)
406+ if err != nil {
407+ t.Fatal(err)
408+ }
409+ if r.credentialOptional {
410+ t.Fatal("built-in provider treated as keyless custom endpoint")
411+ }
412+}
413+
414+func TestModelPickerProvidersIncludeKeylessCustomEndpoint(t *testing.T) {
415+ t.Setenv("ZUT_HOME", t.TempDir())
416+ t.Setenv("UNSLOTH_API_KEY", "")
417+ loadTestModelsJSON(t, `{"providers":{"unsloth":{"baseUrl":"http://127.0.0.1:8888/v1","api":"openai","models":[{"id":"qista"}]}}}`)
418+ if CredentialAvailable("unsloth") {
419+ t.Fatal("test requires no credential")
420+ }
421+ if !slices.Contains(modelPickerProviders(), "unsloth") {
422+ t.Fatal("keyless custom endpoint missing from model picker")
423+ }
424+ loadTestModelsJSON(t, `{"providers":{"unsloth":{"models":[{"id":"qista","baseUrl":"http://127.0.0.1:8888/v1"}]}}}`)
425+ if !slices.Contains(modelPickerProviders(), "unsloth") {
426+ t.Fatal("model-level keyless endpoint missing from model picker")
427+ }
428+}
429+
430+func TestResolveUnknownModelRefreshesOnlySelectedCustomProvider(t *testing.T) {
431+ home := t.TempDir()
432+ t.Setenv("ZUT_HOME", home)
433+ t.Setenv("M4_API_KEY", "")
434+ t.Setenv("M5_API_KEY", "")
435+ var m4Requests, m5Requests atomic.Int32
436+ m4 := newModelsServer(t, &m4Requests, "", "qwen3-coder")
437+ m5 := newModelsServer(t, &m5Requests, "", "other")
438+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers": {
439+ "m4": {"baseUrl": %q, "discover": true},
440+ "m5": {"baseUrl": %q, "discover": true}
441+ }}`, m4.URL+"/v1", m5.URL+"/v1"))
442+ // m5 has a command-backed key; resolving m4 must never run it.
443+ marker := filepath.Join(home, "m5-command-ran")
444+ creds := auth.Credentials{AdditionalAPIKeyCreds: map[string]auth.ProviderCreds{
445+ "m5": {APIKeyCommand: &auth.APIKeyCommand{Program: "/bin/sh", Args: []string{"-c", "touch " + marker + "; echo secret"}}},
446+ }}
447+ encoded, _ := json.Marshal(creds)
448+ if err := os.WriteFile(AuthPath(), encoded, 0o600); err != nil {
449+ t.Fatal(err)
450+ }
451+
452+ if _, err := Resolve(Args{Provider: "m4", Model: "qwen3-coder", NoSkill: true}, true); err != nil {
453+ t.Fatal(err)
454+ }
455+ if m4Requests.Load() != 1 {
456+ t.Fatalf("selected provider requests = %d, want 1", m4Requests.Load())
457+ }
458+ if m5Requests.Load() != 0 {
459+ t.Fatal("unrelated provider endpoint was contacted")
460+ }
461+ if _, err := os.Stat(marker); err == nil {
462+ t.Fatal("unrelated provider api-key command executed")
463+ }
464+}
465+
466+func TestRefreshSingleCustomProviderKeepsSnapshotOnFailure(t *testing.T) {
467+ t.Setenv("ZUT_HOME", t.TempDir())
468+ t.Setenv("M4_API_KEY", "")
469+ failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
470+ http.Error(w, "nope", http.StatusServiceUnavailable)
471+ }))
472+ defer failing.Close()
473+ loadTestModelsJSON(t, fmt.Sprintf(`{"providers":{"m4":{"baseUrl":%q,"discover":true},"m6":{"baseUrl":%q}}}`, failing.URL, failing.URL))
474+ provider.SetManagedModelsForProvider("m4", []provider.Model{{Provider: "m4", ID: "previous", Source: "live"}})
475+
476+ if err := refreshCustomProviderModel(context.Background(), "m4", apiKeyCommandExecute); err == nil {
477+ t.Fatal("expected refresh failure")
478+ }
479+ if _, err := provider.FindModel("m4", "previous"); err != nil {
480+ t.Fatal("failed scoped refresh dropped the previous snapshot")
481+ }
482+ // A provider that did not opt in is never contacted or changed.
483+ if err := refreshCustomProviderModel(context.Background(), "m6", apiKeyCommandExecute); err != nil {
484+ t.Fatalf("non-discoverable provider refreshed: %v", err)
485+ }
486+}
M packages/agent/ext/ext.go +88 -9
@@ -33,6 +33,7 @@ package ext
3333
3434 import (
3535 "bufio"
36+ "context"
3637 "encoding/base64"
3738 "encoding/json"
3839 "fmt"
@@ -57,6 +58,13 @@ type CommandHandler func(args string) Response
5758 // ToolResult describing what zut should send back to the model.
5859 type ToolHandler func(args json.RawMessage) ToolResult
5960
61+// InteractiveToolHandler is invoked for tools registered with
62+// InteractiveTool. ctx is cancelled when the host abandons the call
63+// (tool_cancel), on shutdown, or when the host connection ends. The
64+// handler must release panels and other resources when ctx ends. The
65+// SDK ignores a result returned after cancellation.
66+type InteractiveToolHandler func(ctx context.Context, args json.RawMessage) ToolResult
67+
6068 // EventHandler is called for each lifecycle event the extension
6169 // subscribed to via Subscribe. The handler is invoked synchronously
6270 // on the read goroutine; keep it quick or hand off to your own
@@ -283,8 +291,9 @@ type Extension struct {
283291 mu sync.Mutex
284292 commands map[string]CommandHandler
285293 descriptions []descTuple // ordered so register frames arrive in registration order
286- tools map[string]ToolHandler
287- toolDefs []toolDef // ordered so register frames arrive in registration order
294+ tools map[string]InteractiveToolHandler
295+ toolCancels map[string]*toolCall // in-flight tool_call id -> cancel handle
296+ toolDefs []toolDef // ordered so register frames arrive in registration order
288297 eventHandlers map[string]EventHandler
289298 eventNames []string // declared subscription order
290299
@@ -313,6 +322,12 @@ type toolDef struct {
313322 description string
314323 schema json.RawMessage
315324 deferred bool
325+ interactive bool
326+}
327+
328+// toolCall is the cancel handle of one in-flight tool invocation.
329+type toolCall struct {
330+ cancel context.CancelFunc
316331 }
317332
318333 // HostInfo is what the host (zut) tells us in HelloAck. Useful for
@@ -338,11 +353,12 @@ func New(name, version string) *Extension {
338353 out: os.Stdout,
339354 stderr: os.Stderr,
340355 commands: map[string]CommandHandler{},
341- tools: map[string]ToolHandler{},
356+ tools: map[string]InteractiveToolHandler{},
357+ toolCancels: map[string]*toolCall{},
342358 eventHandlers: map[string]EventHandler{},
343359 panelKeys: map[string]func(key, text string){},
344360 panelCloses: map[string]func(){},
345- caps: []string{"commands", "tools", "events", "alerts", "panels", "session_state", "status", "widgets"},
361+ caps: []string{"commands", "tools", "events", "alerts", "panels", "session_state", "status", "widgets", "tool_cancel"},
346362 }
347363 }
348364
@@ -437,13 +453,39 @@ func (e *Extension) DeferredTool(name, description string, schema json.RawMessag
437453 e.registerTool(name, description, schema, true, fn)
438454 }
439455
440-func (e *Extension) registerTool(name, description string, schema json.RawMessage, deferred bool, fn ToolHandler) {
456+// InteractiveTool registers a tool that intentionally waits for user input,
457+// for example by opening a panel and returning once a key arrives. An
458+// interactive host imposes no reply deadline on it; headless hosts (print,
459+// JSON, RPC, bot) reject the call with a tool error instead. The handler
460+// must honor ctx: it is cancelled when the host abandons the call, on
461+// shutdown, and when the host connection ends. Close any panel the handler
462+// opened before returning on either path.
463+func (e *Extension) InteractiveTool(name, description string, schema json.RawMessage, fn InteractiveToolHandler) {
441464 e.mu.Lock()
442465 e.tools[name] = fn
466+ e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema, interactive: true})
467+ e.mu.Unlock()
468+}
469+
470+func (e *Extension) registerTool(name, description string, schema json.RawMessage, deferred bool, fn ToolHandler) {
471+ e.mu.Lock()
472+ e.tools[name] = func(_ context.Context, args json.RawMessage) ToolResult { return fn(args) }
443473 e.toolDefs = append(e.toolDefs, toolDef{name: name, description: description, schema: schema, deferred: deferred})
444474 e.mu.Unlock()
445475 }
446476
477+// cancelAllTools cancels every in-flight tool handler context. Used on
478+// shutdown and host EOF so blocked interactive handlers can clean up.
479+func (e *Extension) cancelAllTools() {
480+ e.mu.Lock()
481+ calls := e.toolCancels
482+ e.toolCancels = map[string]*toolCall{}
483+ e.mu.Unlock()
484+ for _, call := range calls {
485+ call.cancel()
486+ }
487+}
488+
447489 // On subscribes to a lifecycle event. fn is called for each
448490 // notification; the same name can only have one handler (later
449491 // registrations replace earlier ones). Recognised names include:
@@ -558,6 +600,7 @@ func (e *Extension) Alert(alert AlertRequest) {
558600 // Run starts the protocol loop. Blocks until stdin closes (zut has
559601 // shut us down). Returns the first fatal error, or nil on clean exit.
560602 func (e *Extension) Run() error {
603+ defer e.cancelAllTools()
561604 scanner := bufio.NewScanner(e.in)
562605 scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
563606
@@ -628,6 +671,7 @@ func (e *Extension) Run() error {
628671 Description: td.description,
629672 Schema: td.schema,
630673 Deferred: td.deferred,
674+ Interactive: td.interactive,
631675 })
632676 }
633677 var intercepts []string
@@ -698,15 +742,49 @@ func (e *Extension) Run() error {
698742 e.respondTool(tc.ID, TextErrorResult(fmt.Sprintf("no handler for tool %q", tc.Name)))
699743 continue
700744 }
701- go func(id string, fn ToolHandler, args json.RawMessage) {
745+ // Register the cancel before dispatching so a tool_cancel that
746+ // arrives before the goroutine starts still lands.
747+ ctx, cancel := context.WithCancel(context.Background())
748+ call := &toolCall{cancel: cancel}
749+ e.mu.Lock()
750+ if prev := e.toolCancels[tc.ID]; prev != nil {
751+ prev.cancel() // duplicate id from the host; abandon the older call
752+ }
753+ e.toolCancels[tc.ID] = call
754+ e.mu.Unlock()
755+ go func(id string, fn InteractiveToolHandler, args json.RawMessage) {
702756 defer func() {
703- if r := recover(); r != nil {
757+ cancel()
758+ e.mu.Lock()
759+ if e.toolCancels[id] == call {
760+ delete(e.toolCancels, id)
761+ }
762+ e.mu.Unlock()
763+ }()
764+ defer func() {
765+ if r := recover(); r != nil && ctx.Err() == nil {
704766 e.respondTool(id, TextErrorResult(fmt.Sprintf("panic: %v", r)))
705767 }
706768 }()
707- res := fn(args)
708- e.respondTool(id, res)
769+ res := fn(ctx, args)
770+ // A result produced after cancellation is stale: the host
771+ // already dropped the pending slot and would ignore it.
772+ if ctx.Err() == nil {
773+ e.respondTool(id, res)
774+ }
709775 }(tc.ID, fn, tc.Args)
776+ case "tool_cancel":
777+ var tc extproto.ToolCancelFromHost
778+ if err := json.Unmarshal(line, &tc); err != nil {
779+ continue
780+ }
781+ e.mu.Lock()
782+ call := e.toolCancels[tc.ID]
783+ delete(e.toolCancels, tc.ID)
784+ e.mu.Unlock()
785+ if call != nil {
786+ call.cancel()
787+ }
710788 case "event":
711789 var ef extproto.EventFromHost
712790 if err := json.Unmarshal(line, &ef); err != nil {
@@ -759,6 +837,7 @@ func (e *Extension) Run() error {
759837 go h()
760838 }
761839 case "shutdown":
840+ e.cancelAllTools()
762841 _ = e.send(extproto.ShutdownAckFromExt{Type: "shutdown_ack"})
763842 return nil
764843 default:
A packages/agent/ext/interactive_tool_test.go +241 -0
@@ -0,0 +1,241 @@
1+package ext
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/zut/packages/agent/extproto"
10+)
11+
12+// interactiveHarness registers one interactive tool whose handler blocks on
13+// ctx or on an explicit answer, and reports when it started and ended.
14+type interactiveHarness struct {
15+ *extHarness
16+ started chan string // tool_call id
17+ ended chan error // ctx.Err() at handler exit
18+ answers chan string
19+}
20+
21+func newInteractiveHarness(t *testing.T) *interactiveHarness {
22+ t.Helper()
23+ h := &interactiveHarness{
24+ extHarness: newHarness("interactive-ext"),
25+ started: make(chan string, 8),
26+ ended: make(chan error, 8),
27+ answers: make(chan string, 8),
28+ }
29+ h.ext.InteractiveTool("ask", "ask the user", json.RawMessage(`{"type":"object"}`),
30+ func(ctx context.Context, args json.RawMessage) ToolResult {
31+ var in struct {
32+ ID string `json:"id"`
33+ }
34+ _ = json.Unmarshal(args, &in)
35+ h.started <- in.ID
36+ select {
37+ case a := <-h.answers:
38+ h.ended <- ctx.Err()
39+ return TextResult(a)
40+ case <-ctx.Done():
41+ h.ended <- ctx.Err()
42+ return TextErrorResult("cancelled")
43+ }
44+ })
45+ h.ext.Tool("plain", "plain", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult {
46+ return TextResult("plain-ok")
47+ })
48+ return h
49+}
50+
51+func (h *interactiveHarness) call(t *testing.T, id string) {
52+ t.Helper()
53+ h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: id, Name: "ask", Args: json.RawMessage(`{"id":"` + id + `"}`)})
54+ select {
55+ case got := <-h.started:
56+ if got != id {
57+ t.Fatalf("handler started for %q, want %q", got, id)
58+ }
59+ case <-time.After(2 * time.Second):
60+ t.Fatalf("handler for %s did not start", id)
61+ }
62+}
63+
64+func (h *interactiveHarness) awaitEnd(t *testing.T) error {
65+ t.Helper()
66+ select {
67+ case err := <-h.ended:
68+ return err
69+ case <-time.After(2 * time.Second):
70+ t.Fatal("handler did not end")
71+ return nil
72+ }
73+}
74+
75+// expectNoToolResult drains frames for a short window and fails on any
76+// tool_result. Used after a cancel to prove late results are suppressed.
77+func (h *interactiveHarness) expectNoToolResult(t *testing.T) {
78+ t.Helper()
79+ // Force a round trip: an event frame is processed on the read loop after
80+ // any tool_result the handler goroutine could have queued, so once we
81+ // observe the ack for it, an earlier tool_result would already be here.
82+ h.ext.OnPanelKey("probe", func(string, string) { h.ext.Notify("info", "probe") }, nil)
83+ h.sendToExt(t, extproto.PanelKeyFromHost{Type: "panel_key", PanelID: "probe", Key: "rune", Text: "x"})
84+ for {
85+ f := h.next(t)
86+ switch f.hdr.Type {
87+ case "tool_result":
88+ t.Fatalf("late tool_result emitted after cancellation: %s", f.raw)
89+ case "notify":
90+ return
91+ }
92+ }
93+}
94+
95+func TestInteractiveToolRegistersFlagAndCapability(t *testing.T) {
96+ h := newInteractiveHarness(t)
97+ h.startRun(t)
98+ f := h.next(t)
99+ var hello extproto.HelloFromExt
100+ if err := json.Unmarshal(f.raw, &hello); err != nil {
101+ t.Fatal(err)
102+ }
103+ hasCancel := false
104+ for _, c := range hello.Capabilities {
105+ if c == "tool_cancel" {
106+ hasCancel = true
107+ }
108+ }
109+ if !hasCancel {
110+ t.Fatalf("hello capabilities = %v, want tool_cancel", hello.Capabilities)
111+ }
112+ h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", ProtocolVersion: extproto.ProtocolVersion, ZutVersion: "0.0.0-test"})
113+ seen := map[string]bool{}
114+ for {
115+ f := h.next(t)
116+ if f.hdr.Type == "ready" {
117+ break
118+ }
119+ if f.hdr.Type != "register_tool" {
120+ continue
121+ }
122+ var rt extproto.RegisterToolFromExt
123+ if err := json.Unmarshal(f.raw, &rt); err != nil {
124+ t.Fatal(err)
125+ }
126+ seen[rt.Name] = rt.Interactive
127+ }
128+ if !seen["ask"] || seen["plain"] {
129+ t.Fatalf("interactive flags = %v", seen)
130+ }
131+}
132+
133+func TestInteractiveToolCompletes(t *testing.T) {
134+ h := newInteractiveHarness(t)
135+ h.startRun(t)
136+ h.handshake(t)
137+ h.call(t, "c1")
138+ h.answers <- "yes"
139+ if err := h.awaitEnd(t); err != nil {
140+ t.Fatalf("handler ctx err = %v, want nil", err)
141+ }
142+ f := h.drainUntil(t, "tool_result")
143+ var tr extproto.ToolResultFromExt
144+ if err := json.Unmarshal(f.raw, &tr); err != nil {
145+ t.Fatal(err)
146+ }
147+ if tr.ID != "c1" || tr.IsError || len(tr.Content) != 1 || tr.Content[0].Text != "yes" {
148+ t.Fatalf("tool_result = %+v", tr)
149+ }
150+ // The goroutine's deferred cleanup runs after respondTool; poll briefly.
151+ deadline := time.Now().Add(2 * time.Second)
152+ for {
153+ h.ext.mu.Lock()
154+ inflight := len(h.ext.toolCancels)
155+ h.ext.mu.Unlock()
156+ if inflight == 0 {
157+ break
158+ }
159+ if time.Now().After(deadline) {
160+ t.Fatalf("in-flight cancel handles leaked: %d", inflight)
161+ }
162+ time.Sleep(time.Millisecond)
163+ }
164+}
165+
166+func TestToolCancelCancelsHandlerAndSuppressesResult(t *testing.T) {
167+ h := newInteractiveHarness(t)
168+ h.startRun(t)
169+ h.handshake(t)
170+ h.call(t, "c1")
171+ h.call(t, "c2")
172+ h.sendToExt(t, extproto.ToolCancelFromHost{Type: "tool_cancel", ID: "c1"})
173+ if err := h.awaitEnd(t); err != context.Canceled {
174+ t.Fatalf("cancelled handler ctx err = %v", err)
175+ }
176+ h.expectNoToolResult(t)
177+ // The other in-flight call is unaffected and still completes.
178+ h.answers <- "two"
179+ if err := h.awaitEnd(t); err != nil {
180+ t.Fatalf("second handler ctx err = %v", err)
181+ }
182+ f := h.drainUntil(t, "tool_result")
183+ var tr extproto.ToolResultFromExt
184+ _ = json.Unmarshal(f.raw, &tr)
185+ if tr.ID != "c2" || tr.Content[0].Text != "two" {
186+ t.Fatalf("tool_result = %+v", tr)
187+ }
188+ // Unknown / already-finished ids are ignored without side effects.
189+ h.sendToExt(t, extproto.ToolCancelFromHost{Type: "tool_cancel", ID: "c1"})
190+ h.sendToExt(t, extproto.ToolCancelFromHost{Type: "tool_cancel", ID: "nope"})
191+ h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "c3", Name: "plain", Args: json.RawMessage(`{}`)})
192+ f = h.drainUntil(t, "tool_result")
193+ _ = json.Unmarshal(f.raw, &tr)
194+ if tr.ID != "c3" || tr.Content[0].Text != "plain-ok" {
195+ t.Fatalf("plain tool after cancels = %+v", tr)
196+ }
197+}
198+
199+func TestShutdownCancelsInteractiveHandlers(t *testing.T) {
200+ h := newInteractiveHarness(t)
201+ h.startRun(t)
202+ h.handshake(t)
203+ h.call(t, "c1")
204+ h.sendToExt(t, extproto.ShutdownFromHost{Type: "shutdown"})
205+ if err := h.awaitEnd(t); err != context.Canceled {
206+ t.Fatalf("handler ctx err on shutdown = %v", err)
207+ }
208+ h.drainUntil(t, "shutdown_ack")
209+}
210+
211+func TestHostEOFCancelsInteractiveHandlers(t *testing.T) {
212+ h := newInteractiveHarness(t)
213+ runDone := make(chan error, 1)
214+ go func() { runDone <- h.ext.Run() }()
215+ h.handshake(t)
216+ h.call(t, "c1")
217+ if err := h.hostW.Close(); err != nil {
218+ t.Fatal(err)
219+ }
220+ if err := h.awaitEnd(t); err != context.Canceled {
221+ t.Fatalf("handler ctx err on EOF = %v", err)
222+ }
223+ select {
224+ case <-runDone:
225+ case <-time.After(2 * time.Second):
226+ t.Fatal("Run did not return after EOF")
227+ }
228+}
229+
230+func TestPlainToolHandlersRemainSourceCompatible(t *testing.T) {
231+ h := newInteractiveHarness(t)
232+ h.startRun(t)
233+ h.handshake(t)
234+ h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "p1", Name: "plain", Args: json.RawMessage(`{}`)})
235+ f := h.drainUntil(t, "tool_result")
236+ var tr extproto.ToolResultFromExt
237+ _ = json.Unmarshal(f.raw, &tr)
238+ if tr.ID != "p1" || tr.IsError || tr.Content[0].Text != "plain-ok" {
239+ t.Fatalf("tool_result = %+v", tr)
240+ }
241+}
A packages/agent/extensions/interactive_tool_test.go +624 -0
@@ -0,0 +1,624 @@
1+package extensions
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "io"
9+ "os"
10+ "strings"
11+ "sync"
12+ "testing"
13+ "time"
14+
15+ "github.com/bnema/zut/packages/agent/extproto"
16+ "github.com/bnema/zut/packages/core"
17+ "github.com/bnema/zut/packages/provider"
18+)
19+
20+// interactiveHooks marks the host as able to show interactive tool prompts.
21+type interactiveHooks struct{ stubHooks }
22+
23+func (*interactiveHooks) SupportsInteractiveTools() bool { return true }
24+
25+// toolPeer wires a Manager to an in-process fake extension over io.Pipe
26+// pairs. The test plays the extension: it reads host frames from `frames`
27+// and replies through `replies`. The ordered transport is real, so write
28+// bounds, disconnects, and cancel frames exercise production code.
29+type toolPeer struct {
30+ m *Manager
31+ ext *Extension
32+ frames chan []byte // host -> extension frames, one per element
33+ replies *io.PipeWriter // extension -> host (feeds readLoop)
34+ readDone chan struct{}
35+}
36+
37+func newToolPeer(t *testing.T, hooks HostHooks, toolCancel bool) *toolPeer {
38+ return newToolPeerOpts(t, hooks, toolCancel, true)
39+}
40+
41+// newToolPeerOpts builds the peer; drain=false leaves the host's stdin pipe
42+// unread so every host write blocks in the transport writer.
43+func newToolPeerOpts(t *testing.T, hooks HostHooks, toolCancel, drain bool) *toolPeer {
44+ t.Helper()
45+ m := New(t.TempDir(), "", "0.0.0-test", "", "", hooks)
46+ log, err := os.CreateTemp(t.TempDir(), "log")
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ // Host stdin side: manager writes, test reads.
51+ stdinR, stdinW := io.Pipe()
52+ // Host stdout side: test writes, manager readLoop reads.
53+ stdoutR, stdoutW := io.Pipe()
54+ ext := &Extension{
55+ Manifest: Manifest{Name: "peer"},
56+ readyCh: make(chan struct{}),
57+ pending: map[string]chan extproto.CommandResponseFromExt{},
58+ pendingTool: map[string]chan extproto.ToolResultFromExt{},
59+ pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{},
60+ eventSubs: map[string]struct{}{},
61+ interceptSubs: map[string]struct{}{},
62+ lifecycleFrames: make(chan []byte, 64),
63+ lifecycleStop: make(chan struct{}),
64+ logFile: log,
65+ helloAck: true,
66+ toolCancel: toolCancel,
67+ }
68+ ext.stdin = newOrderedPipe(stdinW)
69+ m.mu.Lock()
70+ m.ext[ext.Manifest.Name] = ext
71+ m.mu.Unlock()
72+
73+ p := &toolPeer{m: m, ext: ext, frames: make(chan []byte, 64), replies: stdoutW, readDone: make(chan struct{})}
74+ go func() {
75+ m.readLoop(ext, bufio.NewScanner(stdoutR))
76+ close(p.readDone)
77+ }()
78+ if drain {
79+ go func() {
80+ sc := bufio.NewScanner(stdinR)
81+ sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
82+ for sc.Scan() {
83+ p.frames <- append([]byte(nil), sc.Bytes()...)
84+ }
85+ close(p.frames)
86+ }()
87+ }
88+ t.Cleanup(func() {
89+ _ = stdoutW.Close()
90+ <-p.readDone
91+ _ = stdinR.Close()
92+ _ = log.Close()
93+ })
94+ return p
95+}
96+
97+func (p *toolPeer) send(t *testing.T, frame any) {
98+ t.Helper()
99+ b, err := extproto.Encode(frame)
100+ if err != nil {
101+ t.Fatal(err)
102+ }
103+ if _, err := p.replies.Write(b); err != nil {
104+ t.Fatal(err)
105+ }
106+}
107+
108+func (p *toolPeer) register(t *testing.T, name string, interactive bool) {
109+ t.Helper()
110+ p.send(t, extproto.RegisterToolFromExt{Type: "register_tool", Name: name, Schema: json.RawMessage(`{"type":"object"}`), Interactive: interactive})
111+ p.send(t, extproto.ReadyFromExt{Type: "ready"})
112+ p.m.WaitForReady(2 * time.Second)
113+}
114+
115+func (p *toolPeer) next(t *testing.T) extproto.Frame {
116+ t.Helper()
117+ select {
118+ case raw, ok := <-p.frames:
119+ if !ok {
120+ t.Fatal("host transport closed before expected frame")
121+ }
122+ var f extproto.Frame
123+ if err := json.Unmarshal(raw, &f); err != nil {
124+ t.Fatal(err)
125+ }
126+ return f
127+ case <-time.After(2 * time.Second):
128+ t.Fatal("no frame from host")
129+ return extproto.Frame{}
130+ }
131+}
132+
133+func (p *toolPeer) expectToolCall(t *testing.T) extproto.Frame {
134+ t.Helper()
135+ f := p.next(t)
136+ if f.Type != "tool_call" {
137+ t.Fatalf("expected tool_call, got %+v", f)
138+ }
139+ return f
140+}
141+
142+// expectClosedWithout drains until the host transport closes and fails if a
143+// frame of the given type was seen. Used to prove legacy peers never see
144+// tool_cancel and headless hosts never dispatch interactive calls.
145+func (p *toolPeer) expectClosedWithout(t *testing.T, frameType string) {
146+ t.Helper()
147+ for {
148+ select {
149+ case raw, ok := <-p.frames:
150+ if !ok {
151+ return
152+ }
153+ var f extproto.Frame
154+ _ = json.Unmarshal(raw, &f)
155+ if f.Type == frameType {
156+ t.Fatalf("host sent %s: %s", frameType, raw)
157+ }
158+ case <-time.After(2 * time.Second):
159+ t.Fatal("host transport did not close")
160+ }
161+ }
162+}
163+
164+func execTool(tool core.Tool, ctx context.Context) <-chan struct {
165+ res core.ToolResult
166+ err error
167+} {
168+ done := make(chan struct {
169+ res core.ToolResult
170+ err error
171+ }, 1)
172+ go func() {
173+ res, err := tool.Execute(ctx, nil, nil)
174+ done <- struct {
175+ res core.ToolResult
176+ err error
177+ }{res, err}
178+ }()
179+ return done
180+}
181+
182+func resultText(res core.ToolResult) string {
183+ var b strings.Builder
184+ for _, c := range res.Content {
185+ if tb, ok := c.(provider.TextBlock); ok {
186+ b.WriteString(tb.Text)
187+ }
188+ }
189+ return b.String()
190+}
191+
192+func TestInteractiveToolRegistrationAndTimeoutPolicy(t *testing.T) {
193+ p := newToolPeer(t, &interactiveHooks{}, true)
194+ p.send(t, extproto.RegisterToolFromExt{Type: "register_tool", Name: "ask", Schema: json.RawMessage(`{}`), Interactive: true})
195+ p.send(t, extproto.RegisterToolFromExt{Type: "register_tool", Name: "plain", Schema: json.RawMessage(`{}`)})
196+ p.send(t, extproto.ReadyFromExt{Type: "ready"})
197+ p.m.WaitForReady(2 * time.Second)
198+
199+ infos := p.m.Tools()
200+ if len(infos) != 2 {
201+ t.Fatalf("tools = %+v", infos)
202+ }
203+ for _, info := range infos {
204+ tool := NewTool(p.m, info).(*extensionTool)
205+ switch info.Name {
206+ case "ask":
207+ if !info.Interactive || tool.timeout != 0 || !tool.Interactive() {
208+ t.Fatalf("interactive tool policy wrong: info=%+v timeout=%v", info, tool.timeout)
209+ }
210+ case "plain":
211+ if info.Interactive || tool.timeout != 60*time.Second || tool.Interactive() {
212+ t.Fatalf("normal tool policy wrong: info=%+v timeout=%v", info, tool.timeout)
213+ }
214+ }
215+ }
216+}
217+
218+func TestInteractiveToolCompletesWithoutDeadline(t *testing.T) {
219+ p := newToolPeer(t, &interactiveHooks{}, true)
220+ p.register(t, "ask", true)
221+ tool := NewTool(p.m, p.m.Tools()[0])
222+
223+ done := execTool(tool, context.Background())
224+ call := p.expectToolCall(t)
225+ p.send(t, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID, Content: []extproto.ContentBlock{{Type: "text", Text: "answer"}}})
226+ out := <-done
227+ if out.err != nil || out.res.IsError || resultText(out.res) != "answer" {
228+ t.Fatalf("unexpected result: %+v err=%v", out.res, out.err)
229+ }
230+ p.ext.mu.Lock()
231+ pending := len(p.ext.pendingTool)
232+ p.ext.mu.Unlock()
233+ if pending != 0 {
234+ t.Fatalf("pending slots leaked: %d", pending)
235+ }
236+}
237+
238+func TestInteractiveToolResultSurvivesImmediateDisconnect(t *testing.T) {
239+ for range 30 {
240+ p := newToolPeer(t, &interactiveHooks{}, true)
241+ p.register(t, "ask", true)
242+ done := execTool(NewTool(p.m, p.m.Tools()[0]), context.Background())
243+ call := p.expectToolCall(t)
244+ p.send(t, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID, Content: []extproto.ContentBlock{{Type: "text", Text: "answer"}}})
245+ if err := p.replies.Close(); err != nil {
246+ t.Fatal(err)
247+ }
248+ <-p.readDone
249+ select {
250+ case out := <-done:
251+ if out.err != nil || out.res.IsError || resultText(out.res) != "answer" {
252+ t.Fatalf("completed reply lost to disconnect: result=%+v err=%v", out.res, out.err)
253+ }
254+ case <-time.After(2 * time.Second):
255+ t.Fatal("tool call remained blocked after disconnect")
256+ }
257+ }
258+}
259+
260+func TestInteractiveToolContextCancelSendsToolCancelAndIgnoresLateResult(t *testing.T) {
261+ p := newToolPeer(t, &interactiveHooks{}, true)
262+ p.register(t, "ask", true)
263+ tool := NewTool(p.m, p.m.Tools()[0])
264+
265+ ctx, cancel := context.WithCancel(context.Background())
266+ done := execTool(tool, ctx)
267+ call := p.expectToolCall(t)
268+ cancel()
269+ out := <-done
270+ if out.err != nil || !out.res.IsError || !strings.Contains(resultText(out.res), "cancelled") {
271+ t.Fatalf("expected cancelled tool error, got %+v err=%v", out.res, out.err)
272+ }
273+ f := p.next(t)
274+ if f.Type != "tool_cancel" || f.ID != call.ID {
275+ t.Fatalf("expected tool_cancel for %s, got %+v", call.ID, f)
276+ }
277+ // A late reply for the abandoned id must be ignored and the pending map
278+ // must stay empty.
279+ p.send(t, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID})
280+ // Prove the read loop is still healthy by completing a fresh call.
281+ done = execTool(tool, context.Background())
282+ call2 := p.expectToolCall(t)
283+ if call2.ID == call.ID {
284+ t.Fatal("correlation ids repeated")
285+ }
286+ p.send(t, extproto.ToolResultFromExt{Type: "tool_result", ID: call2.ID, Content: []extproto.ContentBlock{{Type: "text", Text: "ok"}}})
287+ if out := <-done; out.err != nil || out.res.IsError {
288+ t.Fatalf("follow-up call failed: %+v err=%v", out.res, out.err)
289+ }
290+ p.ext.mu.Lock()
291+ pending := len(p.ext.pendingTool)
292+ p.ext.mu.Unlock()
293+ if pending != 0 {
294+ t.Fatalf("pending slots leaked: %d", pending)
295+ }
296+}
297+
298+func TestInteractiveToolContextDeadlineEndsCall(t *testing.T) {
299+ p := newToolPeer(t, &interactiveHooks{}, true)
300+ p.register(t, "ask", true)
301+ tool := NewTool(p.m, p.m.Tools()[0])
302+
303+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
304+ defer cancel()
305+ done := execTool(tool, ctx)
306+ call := p.expectToolCall(t)
307+ out := <-done
308+ if out.err != nil || !out.res.IsError || !strings.Contains(resultText(out.res), "deadline") {
309+ t.Fatalf("expected deadline tool error, got %+v err=%v", out.res, out.err)
310+ }
311+ if f := p.next(t); f.Type != "tool_cancel" || f.ID != call.ID {
312+ t.Fatalf("expected tool_cancel, got %+v", f)
313+ }
314+}
315+
316+func TestNormalToolReplyTimeoutSendsToolCancel(t *testing.T) {
317+ p := newToolPeer(t, &interactiveHooks{}, true)
318+ p.register(t, "plain", false)
319+
320+ done := make(chan error, 1)
321+ go func() {
322+ _, err := p.m.InvokeTool(context.Background(), "plain", json.RawMessage(`{}`), 20*time.Millisecond)
323+ done <- err
324+ }()
325+ call := p.expectToolCall(t)
326+ err := <-done
327+ if !errors.Is(err, context.DeadlineExceeded) || !strings.Contains(err.Error(), "timeout waiting for peer/plain") {
328+ t.Fatalf("expected reply timeout, got %v", err)
329+ }
330+ if f := p.next(t); f.Type != "tool_cancel" || f.ID != call.ID {
331+ t.Fatalf("expected tool_cancel, got %+v", f)
332+ }
333+ // The wrapper reports a plain timeout as an ordinary tool error.
334+ tool := NewTool(p.m, p.m.Tools()[0]).(*extensionTool)
335+ tool.timeout = 20 * time.Millisecond
336+ res, execErr := tool.Execute(context.Background(), nil, nil)
337+ p.expectToolCall(t)
338+ if execErr != nil || !res.IsError || !strings.Contains(resultText(res), "failed: timeout") {
339+ t.Fatalf("expected timeout tool error, got %+v err=%v", res, execErr)
340+ }
341+}
342+
343+func TestLegacyPeerNeverReceivesToolCancel(t *testing.T) {
344+ p := newToolPeer(t, &interactiveHooks{}, false)
345+ p.register(t, "ask", true)
346+ tool := NewTool(p.m, p.m.Tools()[0])
347+
348+ ctx, cancel := context.WithCancel(context.Background())
349+ done := execTool(tool, ctx)
350+ p.expectToolCall(t)
351+ cancel()
352+ if out := <-done; out.err != nil || !out.res.IsError {
353+ t.Fatalf("expected cancelled tool error, got %+v err=%v", out.res, out.err)
354+ }
355+ // Legacy registration without the interactive field keeps the old
356+ // timeout, and cancellation never produces a tool_cancel frame.
357+ p.m.Stop(time.Second)
358+ p.expectClosedWithout(t, "tool_cancel")
359+}
360+
361+func TestInteractiveToolDisconnectEndsCall(t *testing.T) {
362+ p := newToolPeer(t, &interactiveHooks{}, true)
363+ p.register(t, "ask", true)
364+ tool := NewTool(p.m, p.m.Tools()[0])
365+
366+ done := execTool(tool, context.Background())
367+ p.expectToolCall(t)
368+ // Extension exits: its stdout closes, readLoop ends and tears the
369+ // transport down.
370+ _ = p.replies.Close()
371+ out := <-done
372+ if out.err != nil || !out.res.IsError || !strings.Contains(resultText(out.res), "disconnected") {
373+ t.Fatalf("expected disconnect tool error, got %+v err=%v", out.res, out.err)
374+ }
375+ if p.m.HasTool("ask") {
376+ t.Fatal("tool still registered after disconnect")
377+ }
378+}
379+
380+func TestInteractiveToolShutdownEndsCall(t *testing.T) {
381+ p := newToolPeer(t, &interactiveHooks{}, true)
382+ p.register(t, "ask", true)
383+ tool := NewTool(p.m, p.m.Tools()[0])
384+
385+ done := execTool(tool, context.Background())
386+ p.expectToolCall(t)
387+ stopped := make(chan struct{})
388+ go func() {
389+ p.m.Stop(200 * time.Millisecond)
390+ close(stopped)
391+ }()
392+ out := <-done
393+ if out.err != nil || !out.res.IsError {
394+ t.Fatalf("expected tool error on shutdown, got %+v err=%v", out.res, out.err)
395+ }
396+ select {
397+ case <-stopped:
398+ case <-time.After(3 * time.Second):
399+ t.Fatal("Stop blocked on the pending interactive call")
400+ }
401+}
402+
403+func TestInteractiveToolBlockedWriteIsBoundedAndCancellable(t *testing.T) {
404+ // The extension never reads stdin, so the tool_call write blocks in the
405+ // transport writer. Context cancellation must return promptly instead of
406+ // waiting on the (deadline-free) reply, and must not pin goroutines.
407+ t.Run("cancel", func(t *testing.T) {
408+ p := newToolPeerOpts(t, &interactiveHooks{}, true, false)
409+ p.register(t, "ask", true)
410+ tool := NewTool(p.m, p.m.Tools()[0])
411+ ctx, cancel := context.WithCancel(context.Background())
412+ done := execTool(tool, ctx)
413+ // Wait for the frame to reach the writer goroutine (blocked on the
414+ // unread pipe), then cancel.
415+ pipe := p.ext.stdin.(*orderedPipe)
416+ for {
417+ pipe.mu.Lock()
418+ queued := len(pipe.queue)
419+ pipe.mu.Unlock()
420+ if queued == 0 {
421+ break
422+ }
423+ time.Sleep(time.Millisecond)
424+ }
425+ cancel()
426+ select {
427+ case out := <-done:
428+ if out.err != nil || !out.res.IsError {
429+ t.Fatalf("expected tool error, got %+v err=%v", out.res, out.err)
430+ }
431+ case <-time.After(3 * time.Second):
432+ t.Fatal("cancelled call stayed blocked on the transport write")
433+ }
434+ })
435+ // Without cancellation the write itself is still bounded, independent of
436+ // the absent reply deadline: the transport times out and disconnects.
437+ t.Run("timeout", func(t *testing.T) {
438+ p := newToolPeerOpts(t, &interactiveHooks{}, true, false)
439+ p.register(t, "ask", true)
440+ pipe := p.ext.stdin.(*orderedPipe)
441+ done := make(chan error, 1)
442+ go func() {
443+ _, err := pipe.writeContext(context.Background(), []byte("{}\n"), 30*time.Millisecond)
444+ done <- err
445+ }()
446+ select {
447+ case err := <-done:
448+ if !errors.Is(err, context.DeadlineExceeded) {
449+ t.Fatalf("expected transport timeout, got %v", err)
450+ }
451+ case <-time.After(3 * time.Second):
452+ t.Fatal("blocked write not bounded")
453+ }
454+ // A subsequent tool call fails immediately on the dead transport.
455+ tool := NewTool(p.m, p.m.Tools()[0])
456+ res, err := tool.Execute(context.Background(), nil, nil)
457+ if err != nil || !res.IsError || !strings.Contains(resultText(res), "write:") {
458+ t.Fatalf("expected write failure, got %+v err=%v", res, err)
459+ }
460+ })
461+}
462+
463+func TestOrderedPipeWriteTimeoutDisconnects(t *testing.T) {
464+ r, w := io.Pipe()
465+ pipe := newOrderedPipe(w)
466+ t.Cleanup(func() { _ = r.Close() })
467+ // Nobody reads r, so the first write blocks in the writer goroutine and
468+ // the second waits in the queue.
469+ first := make(chan error, 1)
470+ go func() {
471+ _, err := pipe.writeContext(context.Background(), []byte("a\n"), 30*time.Millisecond)
472+ first <- err
473+ }()
474+ select {
475+ case err := <-first:
476+ if !errors.Is(err, context.DeadlineExceeded) {
477+ t.Fatalf("expected timeout, got %v", err)
478+ }
479+ case <-time.After(3 * time.Second):
480+ t.Fatal("bounded write did not return")
481+ }
482+ select {
483+ case <-pipe.Done():
484+ case <-time.After(time.Second):
485+ t.Fatal("transport not disconnected after write timeout")
486+ }
487+ if _, err := pipe.Write([]byte("b\n")); !errors.Is(err, io.ErrClosedPipe) {
488+ t.Fatalf("write after disconnect = %v", err)
489+ }
490+}
491+
492+func TestOrderedPipeCancelWithdrawsQueuedFrameWithoutDisconnect(t *testing.T) {
493+ r, w := io.Pipe()
494+ pipe := newOrderedPipe(w)
495+ t.Cleanup(func() { pipe.Close(); _ = r.Close() })
496+
497+ // Head frame blocks in the writer; the second frame stays queued.
498+ headAck := make(chan error, 1)
499+ if _, err := pipe.enqueue([]byte("head\n"), headAck); err != nil {
500+ t.Fatal(err)
501+ }
502+ ctx, cancel := context.WithCancel(context.Background())
503+ second := make(chan error, 1)
504+ go func() {
505+ _, err := pipe.writeContext(ctx, []byte("second\n"), time.Minute)
506+ second <- err
507+ }()
508+ cancel()
509+ select {
510+ case err := <-second:
511+ if !errors.Is(err, context.Canceled) {
512+ t.Fatalf("expected cancel, got %v", err)
513+ }
514+ case <-time.After(3 * time.Second):
515+ t.Fatal("queued write did not return on cancel")
516+ }
517+ select {
518+ case <-pipe.Done():
519+ t.Fatal("cancel of a queued frame must not disconnect the transport")
520+ default:
521+ }
522+ // Reader drains: only the head frame is delivered, the withdrawn one is
523+ // not, and the transport keeps working.
524+ go func() {
525+ _, _ = pipe.Write([]byte("third\n"))
526+ }()
527+ sc := bufio.NewScanner(r)
528+ var got []string
529+ for len(got) < 2 && sc.Scan() {
530+ got = append(got, sc.Text())
531+ }
532+ if strings.Join(got, ",") != "head,third" {
533+ t.Fatalf("delivered frames = %v", got)
534+ }
535+}
536+
537+func TestOrderedPipeOverflowDisconnects(t *testing.T) {
538+ r, w := io.Pipe()
539+ pipe := newOrderedPipe(w)
540+ t.Cleanup(func() { _ = r.Close() })
541+ var err error
542+ for i := 0; i <= outboundFrames+1; i++ {
543+ if _, err = pipe.enqueue([]byte("x\n"), nil); err != nil {
544+ break
545+ }
546+ }
547+ if !errors.Is(err, errOutboundOverflow) {
548+ t.Fatalf("expected overflow, got %v", err)
549+ }
550+ select {
551+ case <-pipe.Done():
552+ case <-time.After(time.Second):
553+ t.Fatal("overflow did not disconnect")
554+ }
555+}
556+
557+func TestCorrelationIDsAreUniqueUnderConcurrency(t *testing.T) {
558+ const n = 2000
559+ var mu sync.Mutex
560+ seen := make(map[string]struct{}, n)
561+ var wg sync.WaitGroup
562+ for i := 0; i < n; i++ {
563+ wg.Add(1)
564+ go func() {
565+ defer wg.Done()
566+ id := newCorrelationID()
567+ mu.Lock()
568+ seen[id] = struct{}{}
569+ mu.Unlock()
570+ }()
571+ }
572+ wg.Wait()
573+ if len(seen) != n {
574+ t.Fatalf("got %d unique ids for %d calls", len(seen), n)
575+ }
576+}
577+
578+func TestHeadlessHostsRejectInteractiveTools(t *testing.T) {
579+ cases := map[string]HostHooks{
580+ "missing_method": &stubHooks{},
581+ "returns_false": &interactiveOptOut{},
582+ }
583+ for name, hooks := range cases {
584+ t.Run(name, func(t *testing.T) {
585+ p := newToolPeer(t, hooks, true)
586+ p.register(t, "ask", true)
587+ tool := NewTool(p.m, p.m.Tools()[0])
588+ res, err := tool.Execute(context.Background(), nil, nil)
589+ if err != nil || !res.IsError || !strings.Contains(resultText(res), "requires an interactive host") {
590+ t.Fatalf("expected fail-closed rejection, got %+v err=%v", res, err)
591+ }
592+ // Nothing was sent to the extension.
593+ p.m.Stop(time.Second)
594+ p.expectClosedWithout(t, "tool_call")
595+ })
596+ }
597+ // A non-interactive tool still works on a headless host.
598+ p := newToolPeer(t, &stubHooks{}, true)
599+ p.register(t, "plain", false)
600+ tool := NewTool(p.m, p.m.Tools()[0])
601+ done := execTool(tool, context.Background())
602+ call := p.expectToolCall(t)
603+ p.send(t, extproto.ToolResultFromExt{Type: "tool_result", ID: call.ID, Content: []extproto.ContentBlock{{Type: "text", Text: "ok"}}})
604+ if out := <-done; out.err != nil || out.res.IsError {
605+ t.Fatalf("normal tool on headless host failed: %+v err=%v", out.res, out.err)
606+ }
607+}
608+
609+type interactiveOptOut struct{ stubHooks }
610+
611+func (*interactiveOptOut) SupportsInteractiveTools() bool { return false }
612+
613+func TestIsInteractiveToolClassifiesExtensionTools(t *testing.T) {
614+ m := New(t.TempDir(), "", "0.0.0-test", "", "", &stubHooks{})
615+ if !IsInteractiveTool(NewTool(m, ToolInfo{Name: "ask", Interactive: true})) {
616+ t.Fatal("interactive tool not classified")
617+ }
618+ if IsInteractiveTool(NewTool(m, ToolInfo{Name: "plain"})) {
619+ t.Fatal("plain tool classified as interactive")
620+ }
621+ if IsInteractiveTool(nil) {
622+ t.Fatal("nil classified as interactive")
623+ }
624+}
M packages/agent/extensions/manager.go +148 -26
@@ -22,9 +22,11 @@ import (
2222 "os"
2323 "os/exec"
2424 "path/filepath"
25+ "slices"
2526 "sort"
2627 "strings"
2728 "sync"
29+ "sync/atomic"
2830 "syscall"
2931 "time"
3032
@@ -108,11 +110,18 @@ type Extension struct {
108110 stdout io.ReadCloser
109111 logFile *os.File
110112 helloAck bool
111- commands []extproto.RegisterCommandFromExt
112- tools []extproto.RegisterToolFromExt
113- skills []*skills.Skill
114-
115- writeMu sync.Mutex // serializes concurrent host→extension frames
113+ // toolCancel records whether the extension advertised the "tool_cancel"
114+ // capability in hello. Set during the handshake, before publication.
115+ toolCancel bool
116+ commands []extproto.RegisterCommandFromExt
117+ tools []extproto.RegisterToolFromExt
118+ skills []*skills.Skill
119+
120+ // writeMu serializes concurrent host→extension frames on plain writers.
121+ // Spawned extensions use an orderedPipe, which serializes internally and
122+ // bounds every synchronous write; writeMu is then only used by the
123+ // lifecycle writer's lock probe.
124+ writeMu sync.Mutex
116125
117126 // lifecycleFrames decouples session-event delivery from the host's
118127 // synchronous control paths. The queue preserves lifecycle ordering while
@@ -192,6 +201,24 @@ type HostHooks interface {
192201 ClearWidget(extName, id string)
193202 }
194203
204+// InteractiveToolHostHooks is an optional extension to HostHooks. A host
205+// that can actually show extension panels and wait for a person returns true
206+// from SupportsInteractiveTools. Hosts that do not implement it, or return
207+// false, are headless: interactive tool calls fail closed with a tool error
208+// instead of waiting forever on an invisible prompt.
209+type InteractiveToolHostHooks interface {
210+ SupportsInteractiveTools() bool
211+}
212+
213+// supportsInteractiveTools reports whether the current host opted in.
214+func (m *Manager) supportsInteractiveTools() bool {
215+ if m == nil {
216+ return false
217+ }
218+ h, ok := m.hooks.(InteractiveToolHostHooks)
219+ return ok && h.SupportsInteractiveTools()
220+}
221+
195222 // AlertHostHooks is an optional extension to HostHooks. Hosts that support
196223 // structured alerts implement it without breaking older embedders that only
197224 // implement the original HostHooks surface.
@@ -724,6 +751,7 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
724751 }
725752 // Trust the manifest's name; ignore mismatch from the hello.
726753 ext.helloAck = true
754+ ext.toolCancel = slices.Contains(hello.Capabilities, "tool_cancel")
727755
728756 m.mu.RLock()
729757 var activeSession *extproto.SessionContext
@@ -746,6 +774,11 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
746774 return fmt.Errorf("send hello_ack (stderr log: %s): %w", logPath, err)
747775 }
748776
777+ // Every frame after the handshake goes through the bounded, ordered
778+ // transport so a subprocess that stops reading stdin cannot pin host
779+ // goroutines indefinitely.
780+ ext.stdin = newOrderedPipe(stdin)
781+
749782 cleanup = false
750783
751784 // Spin up the lifecycle writer and read loop now that the handshake is done.
@@ -828,6 +861,13 @@ func (m *Manager) assumeReadyAfterIdle(ext *Extension) {
828861 // Returns when stdout closes.
829862 func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
830863 defer func() {
864+ // The peer is gone: disconnect the transport so every pending
865+ // synchronous call (including deadline-free interactive tools)
866+ // observes the disconnect instead of waiting for a reply that
867+ // can never arrive.
868+ if ext.stdin != nil {
869+ _ = ext.stdin.Close()
870+ }
831871 // On close, drop every command + tool this extension owned so
832872 // future invocations don't dangle. The subprocess is gone; we
833873 // won't hear back about its commands or tool calls anymore.
@@ -1053,18 +1093,35 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
10531093
10541094 const lifecycleWriteGrace = 250 * time.Millisecond
10551095
1096+// writeFrame sends one synchronous host frame. On the ordered transport the
1097+// write is serialized and bounded by transportWriteTimeout; on a plain
1098+// writer (tests, legacy embedders) writeMu provides the serialization.
10561099 func (ext *Extension) writeFrame(frame []byte) (int, error) {
10571100 if ext == nil || ext.stdin == nil {
10581101 return 0, fmt.Errorf("extension stdin is closed")
10591102 }
1103+ if pipe, ok := ext.stdin.(*orderedPipe); ok {
1104+ return pipe.Write(frame)
1105+ }
10601106 ext.writeMu.Lock()
10611107 defer ext.writeMu.Unlock()
10621108 return ext.stdin.Write(frame)
10631109 }
10641110
1065-// tryWriteLifecycleFrame bounds only a lifecycle writer's wait for the shared
1066-// writer lock. Synchronous host frames retain their existing write behavior;
1067-// a stalled lifecycle snapshot is dropped rather than delaying them.
1111+// orderedStdin returns the bounded transport when the extension was spawned
1112+// by this manager. The bool is false for plain writers.
1113+func (ext *Extension) orderedStdin() (*orderedPipe, bool) {
1114+ if ext == nil || ext.stdin == nil {
1115+ return nil, false
1116+ }
1117+ pipe, ok := ext.stdin.(*orderedPipe)
1118+ return pipe, ok
1119+}
1120+
1121+// tryWriteLifecycleFrame bounds a lifecycle writer's wait for writeMu; a
1122+// snapshot that cannot acquire it is dropped. Frames are serialized and bounded
1123+// by the ordered transport: a blocked pipe write still triggers its timeout
1124+// and disconnects the extension.
10681125 func (ext *Extension) tryWriteLifecycleFrame(frame []byte) error {
10691126 if ext == nil || ext.stdin == nil {
10701127 return fmt.Errorf("extension stdin is closed")
@@ -1283,6 +1340,8 @@ type ToolInfo struct {
12831340 Description string
12841341 Schema json.RawMessage
12851342 Deferred bool
1343+ // Interactive tools wait for user input and carry no reply deadline.
1344+ Interactive bool
12861345 }
12871346
12881347 // Tools returns a snapshot of every (extension, tool) pair currently
@@ -1300,6 +1359,7 @@ func (m *Manager) Tools() []ToolInfo {
13001359 Description: t.Description,
13011360 Schema: t.Schema,
13021361 Deferred: t.Deferred,
1362+ Interactive: t.Interactive,
13031363 })
13041364 }
13051365 }
@@ -1335,7 +1395,18 @@ func (m *Manager) HasTool(name string) bool {
13351395 // InvokeTool sends a tool_call to the owning extension and waits for
13361396 // the matching tool_result. Used by the core.Tool wrapper that the
13371397 // agent registers per extension-defined tool.
1398+//
1399+// timeout bounds the wait for the reply; timeout <= 0 means no reply
1400+// deadline (interactive tools). Regardless of timeout, the request write is
1401+// bounded by the transport, and context cancellation, context deadlines, and
1402+// an extension disconnect always end the call. When the call is abandoned
1403+// after the request was delivered, a best-effort tool_cancel is sent to
1404+// extensions that advertised the "tool_cancel" capability. A late
1405+// tool_result for an abandoned id is ignored.
13381406 func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMessage, timeout time.Duration) (extproto.ToolResultFromExt, error) {
1407+ if err := ctx.Err(); err != nil {
1408+ return extproto.ToolResultFromExt{}, err
1409+ }
13391410 m.mu.RLock()
13401411 ext, ok := m.toolIndex[name]
13411412 m.mu.RUnlock()
@@ -1348,6 +1419,11 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess
13481419 ext.mu.Lock()
13491420 ext.pendingTool[id] = ch
13501421 ext.mu.Unlock()
1422+ defer func() {
1423+ ext.mu.Lock()
1424+ delete(ext.pendingTool, id)
1425+ ext.mu.Unlock()
1426+ }()
13511427
13521428 frame, _ := extproto.Encode(extproto.ToolCallFromHost{
13531429 Type: "tool_call",
@@ -1355,26 +1431,57 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess
13551431 Name: name,
13561432 Args: args,
13571433 })
1358- if _, err := ext.writeFrame(frame); err != nil {
1359- ext.mu.Lock()
1360- delete(ext.pendingTool, id)
1361- ext.mu.Unlock()
1434+ var disconnected <-chan struct{}
1435+ var err error
1436+ pipe, ordered := ext.orderedStdin()
1437+ if ordered {
1438+ disconnected = pipe.Done()
1439+ _, err = pipe.writeContext(ctx, frame, transportWriteTimeout)
1440+ } else {
1441+ _, err = ext.writeFrame(frame)
1442+ }
1443+ if err != nil {
13621444 return extproto.ToolResultFromExt{}, fmt.Errorf("write: %w", err)
13631445 }
13641446
1447+ // cancelRemote tells a capable extension to abandon the delivered
1448+ // invocation. Enqueue only: the caller is already leaving and must not
1449+ // block on a stalled pipe.
1450+ cancelRemote := func() {
1451+ if !ordered || !ext.toolCancel {
1452+ return
1453+ }
1454+ cancelFrame, encErr := extproto.Encode(extproto.ToolCancelFromHost{Type: "tool_cancel", ID: id})
1455+ if encErr != nil {
1456+ return
1457+ }
1458+ _, _ = pipe.enqueue(cancelFrame, nil)
1459+ }
1460+
1461+ var deadline <-chan time.Time
1462+ if timeout > 0 {
1463+ timer := time.NewTimer(timeout)
1464+ defer timer.Stop()
1465+ deadline = timer.C
1466+ }
13651467 select {
13661468 case resp := <-ch:
13671469 return resp, nil
1368- case <-time.After(timeout):
1369- ext.mu.Lock()
1370- delete(ext.pendingTool, id)
1371- ext.mu.Unlock()
1372- return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s", ext.Manifest.Name, name)
1470+ case <-deadline:
1471+ cancelRemote()
1472+ return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s: %w", ext.Manifest.Name, name, context.DeadlineExceeded)
13731473 case <-ctx.Done():
1374- ext.mu.Lock()
1375- delete(ext.pendingTool, id)
1376- ext.mu.Unlock()
1474+ cancelRemote()
13771475 return extproto.ToolResultFromExt{}, ctx.Err()
1476+ case <-disconnected:
1477+ // readLoop may have delivered a result immediately before EOF. Prefer
1478+ // that completed reply over the simultaneously ready disconnect.
1479+ select {
1480+ case resp := <-ch:
1481+ return resp, nil
1482+ default:
1483+ }
1484+ return extproto.ToolResultFromExt{}, fmt.Errorf("extension %s disconnected", ext.Manifest.Name)
13781485 }
13791486 }
13801487
@@ -1482,17 +1589,30 @@ func (m *Manager) Stop(gracePeriod time.Duration) {
14821589 }
14831590
14841591 func stopExtensions(exts []*Extension, gracePeriod time.Duration) {
1592+ deadline := time.Now().Add(gracePeriod)
14851593 for _, ext := range exts {
14861594 if ext.stdin == nil {
14871595 continue
14881596 }
14891597 if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {
1490- _, _ = ext.writeFrame(frame)
1598+ if pipe, ok := ext.orderedStdin(); ok {
1599+ // Never wait longer than the shutdown grace for a peer that
1600+ // stopped reading; the process is killed below anyway.
1601+ wait := time.Until(deadline)
1602+ if wait > transportWriteTimeout {
1603+ wait = transportWriteTimeout
1604+ }
1605+ if wait < 50*time.Millisecond {
1606+ wait = 50 * time.Millisecond
1607+ }
1608+ _, _ = pipe.writeContext(context.Background(), frame, wait)
1609+ } else {
1610+ _, _ = ext.writeFrame(frame)
1611+ }
14911612 }
14921613 _ = ext.stdin.Close()
14931614 }
14941615
1495- deadline := time.Now().Add(gracePeriod)
14961616 for _, ext := range exts {
14971617 if ext.cmd == nil {
14981618 if ext.logFile != nil {
@@ -1535,9 +1655,11 @@ func (m *Manager) All() []*Extension {
15351655 return out
15361656 }
15371657
1538-// newCorrelationID returns a short non-cryptographic id. We don't
1539-// need uniqueness across processes, just within the lifetime of one
1540-// extension's pending map.
1658+var correlationSequence atomic.Uint64
1659+
1660+// newCorrelationID returns an id that is unique within this host process.
1661+// Two invocations issued in the same microsecond must never share a pending
1662+// slot, so a monotonic counter replaces the former clock-derived id.
15411663 func newCorrelationID() string {
1542- return strings.ReplaceAll(time.Now().Format("150405.000000"), ".", "")
1664+ return fmt.Sprintf("c%d", correlationSequence.Add(1))
15431665 }
A packages/agent/extensions/outbound.go +237 -0
@@ -0,0 +1,237 @@
1+package extensions
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "sync"
9+ "time"
10+)
11+
12+// orderedPipe serializes every host→extension frame through one writer
13+// goroutine so a blocked subprocess stdin never pins a host goroutine for an
14+// unbounded time. Frames are delivered in enqueue order. Synchronous callers
15+// wait for their frame's write with a bounded timeout or context; fire-and-
16+// forget callers only pay for the enqueue.
17+//
18+// The queue is bounded in frames and bytes. Overflow disconnects the
19+// extension transport instead of growing without limit or silently dropping
20+// a frame in the middle of an ordered stream.
21+type orderedPipe struct {
22+ pipe io.WriteCloser
23+ mu sync.Mutex
24+ queue []*outboundFrame
25+ bytes int
26+ closed bool
27+ wake chan struct{}
28+ done chan struct{}
29+ once sync.Once
30+}
31+
32+type outboundFrame struct {
33+ data []byte
34+ ack chan error
35+ // writing is set once the writer goroutine has taken ownership of the
36+ // frame. Until then the frame may still be withdrawn safely.
37+ writing bool
38+}
39+
40+const (
41+ outboundBytes = 16 * 1024 * 1024
42+ outboundFrames = 256
43+
44+ // transportWriteTimeout bounds how long a synchronous host frame may wait
45+ // for the extension to drain its stdin. It is independent of any reply
46+ // deadline: an interactive tool may wait forever for a reply, but the
47+ // request itself must be accepted by the subprocess promptly.
48+ transportWriteTimeout = 5 * time.Second
49+
50+ // abandonGrace is how long a cancelled caller waits for a frame that is
51+ // already mid-write to finish before the transport is disconnected. A
52+ // torn frame followed by unrelated bytes would corrupt the stream, so
53+ // closing is the only safe fallback.
54+ abandonGrace = 250 * time.Millisecond
55+)
56+
57+var errOutboundOverflow = errors.New("extension outbound queue exceeded its limit")
58+
59+func newOrderedPipe(pipe io.WriteCloser) *orderedPipe {
60+ p := &orderedPipe{pipe: pipe, wake: make(chan struct{}, 1), done: make(chan struct{})}
61+ go p.run()
62+ return p
63+}
64+
65+// enqueue appends a frame and returns a handle for withdrawal. ack, when not
66+// nil, receives exactly one write outcome unless the frame is withdrawn.
67+func (p *orderedPipe) enqueue(data []byte, ack chan error) (*outboundFrame, error) {
68+ p.mu.Lock()
69+ if p.closed {
70+ p.mu.Unlock()
71+ return nil, io.ErrClosedPipe
72+ }
73+ if len(p.queue) >= outboundFrames || p.bytes+len(data) > outboundBytes {
74+ p.mu.Unlock()
75+ p.Close()
76+ return nil, errOutboundOverflow
77+ }
78+ frame := &outboundFrame{data: append([]byte(nil), data...), ack: ack}
79+ p.queue = append(p.queue, frame)
80+ p.bytes += len(data)
81+ p.mu.Unlock()
82+ select {
83+ case p.wake <- struct{}{}:
84+ default:
85+ }
86+ return frame, nil
87+}
88+
89+// withdraw removes a frame that has not yet started writing. It reports
90+// false when the writer already owns the frame; in that case the bytes may be
91+// partially on the wire and cannot be taken back.
92+func (p *orderedPipe) withdraw(frame *outboundFrame) bool {
93+ p.mu.Lock()
94+ defer p.mu.Unlock()
95+ if frame.writing {
96+ return false
97+ }
98+ for i, queued := range p.queue {
99+ if queued == frame {
100+ p.queue = append(p.queue[:i], p.queue[i+1:]...)
101+ p.bytes -= len(frame.data)
102+ return true
103+ }
104+ }
105+ // Not queued and not writing: already written or the pipe was closed.
106+ return true
107+}
108+
109+// Write enqueues and waits for the write with the standard transport bound.
110+func (p *orderedPipe) Write(data []byte) (int, error) {
111+ return p.writeContext(context.Background(), data, transportWriteTimeout)
112+}
113+
114+// writeContext enqueues data and waits until it is written, the timeout
115+// elapses, ctx ends, or the pipe closes. A frame still waiting in the queue
116+// is withdrawn on cancellation without disturbing the transport; a frame
117+// already being written must finish within the cancellation grace or the
118+// transport is closed to avoid a torn frame. A write timeout disconnects the
119+// transport whether the frame is queued or in progress.
120+func (p *orderedPipe) writeContext(ctx context.Context, data []byte, timeout time.Duration) (int, error) {
121+ if err := ctx.Err(); err != nil {
122+ return 0, err
123+ }
124+ if timeout <= 0 {
125+ return 0, fmt.Errorf("extension outbound write timed out: %w", context.DeadlineExceeded)
126+ }
127+ ack := make(chan error, 1)
128+ frame, err := p.enqueue(data, ack)
129+ if err != nil {
130+ return 0, err
131+ }
132+ timer := time.NewTimer(timeout)
133+ defer timer.Stop()
134+ abandon := func(reason error) (int, error) {
135+ if p.withdraw(frame) {
136+ return 0, reason
137+ }
138+ // Mid-write: wait briefly for the writer to finish this frame so a
139+ // complete frame lands or the pipe error surfaces; otherwise close.
140+ select {
141+ case err := <-ack:
142+ if err != nil {
143+ return 0, err
144+ }
145+ return len(data), nil
146+ case <-p.done:
147+ return 0, io.ErrClosedPipe
148+ case <-time.After(abandonGrace):
149+ p.Close()
150+ return 0, reason
151+ }
152+ }
153+ select {
154+ case err := <-ack:
155+ if err != nil {
156+ return 0, err
157+ }
158+ return len(data), nil
159+ case <-p.done:
160+ // The frame may have completed just before the disconnect. If the
161+ // writer owns it, its outcome arrives promptly because the closed
162+ // pipe fails any in-progress write; report that outcome accurately so
163+ // callers distinguish "request delivered, peer gone" from "never sent".
164+ if !p.withdraw(frame) {
165+ if err := <-ack; err != nil {
166+ return 0, err
167+ }
168+ return len(data), nil
169+ }
170+ select {
171+ case err := <-ack:
172+ if err == nil {
173+ return len(data), nil
174+ }
175+ return 0, err
176+ default:
177+ }
178+ return 0, io.ErrClosedPipe
179+ case <-ctx.Done():
180+ return abandon(ctx.Err())
181+ case <-timer.C:
182+ p.Close()
183+ return 0, fmt.Errorf("extension outbound write timed out: %w", context.DeadlineExceeded)
184+ }
185+}
186+
187+// Close disconnects the transport. Queued frames are discarded and every
188+// waiter observes done. Safe to call more than once.
189+func (p *orderedPipe) Close() error {
190+ p.once.Do(func() {
191+ p.mu.Lock()
192+ p.closed = true
193+ p.queue = nil
194+ p.bytes = 0
195+ p.mu.Unlock()
196+ close(p.done)
197+ _ = p.pipe.Close()
198+ })
199+ return nil
200+}
201+
202+// Done is closed when the transport has been disconnected.
203+func (p *orderedPipe) Done() <-chan struct{} { return p.done }
204+
205+func (p *orderedPipe) run() {
206+ for {
207+ select {
208+ case <-p.done:
209+ return
210+ case <-p.wake:
211+ }
212+ for {
213+ p.mu.Lock()
214+ if p.closed || len(p.queue) == 0 {
215+ p.mu.Unlock()
216+ break
217+ }
218+ frame := p.queue[0]
219+ frame.writing = true
220+ p.queue[0] = nil
221+ p.queue = p.queue[1:]
222+ p.bytes -= len(frame.data)
223+ p.mu.Unlock()
224+ n, err := p.pipe.Write(frame.data)
225+ if err == nil && n != len(frame.data) {
226+ err = io.ErrShortWrite
227+ }
228+ if frame.ack != nil {
229+ frame.ack <- err
230+ }
231+ if err != nil {
232+ p.Close()
233+ return
234+ }
235+ }
236+ }
237+}
M packages/agent/extensions/tool.go +44 -3
@@ -3,6 +3,7 @@ package extensions
33 import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "fmt"
78 "time"
89
@@ -35,20 +36,45 @@ type extensionTool struct {
3536 manager *Manager
3637 timeout time.Duration
3738 deferred bool
39+ interactive bool
40+}
41+
42+// defaultToolTimeout is the reply deadline for ordinary extension tools.
43+const defaultToolTimeout = 60 * time.Second
44+
45+// InteractiveTool is implemented by extension tools that wait for user
46+// input. Hosts that build child or headless registries from a parent's
47+// catalogue can use IsInteractiveTool to exclude them explicitly instead of
48+// relying on the runtime rejection in Execute.
49+type InteractiveTool interface {
50+ Interactive() bool
51+}
52+
53+// IsInteractiveTool reports whether t is an interactive extension tool.
54+func IsInteractiveTool(t core.Tool) bool {
55+ it, ok := t.(InteractiveTool)
56+ return ok && it.Interactive()
3857 }
3958
4059 // NewTool returns a core.Tool that round-trips invocations through
41-// mgr to the extension that registered (name, schema). The default
42-// per-call timeout is 60 seconds; callers can override.
60+// mgr to the extension that registered (name, schema). Ordinary tools
61+// must reply within 60 seconds. Interactive tools have no host reply
62+// deadline; the agent context, its deadline, and extension disconnects
63+// still end the call.
4364 func NewTool(mgr *Manager, info ToolInfo) core.Tool {
65+ timeout := defaultToolTimeout
66+ if info.Interactive {
67+ timeout = 0
68+ }
4469 return &extensionTool{
4570 name: info.Name,
4671 description: info.Description,
4772 schema: info.Schema,
4873 extension: info.Extension,
4974 manager: mgr,
50- timeout: 60 * time.Second,
75+ timeout: timeout,
5176 deferred: info.Deferred,
77+ interactive: info.Interactive,
5278 }
5379 }
5480
@@ -57,6 +83,7 @@ func (t *extensionTool) Description() string { return t.description }
5783 func (t *extensionTool) Schema() json.RawMessage { return t.schema }
5884 func (t *extensionTool) Extension() string { return t.extension }
5985 func (t *extensionTool) Deferred() bool { return t.deferred }
86+func (t *extensionTool) Interactive() bool { return t.interactive }
6087
6188 // Execute is what the agent calls when the LLM invokes the tool. It
6289 // hands args to the owning extension, waits up to t.timeout for the
@@ -65,8 +92,22 @@ func (t *extensionTool) Execute(ctx context.Context, args json.RawMessage, _ fun
6592 if len(args) == 0 {
6693 args = json.RawMessage(`{}`)
6794 }
95+ if t.interactive && !t.manager.supportsInteractiveTools() {
96+ // Fail closed: a headless host has nowhere to show the prompt, so
97+ // waiting without a deadline would hang the run.
98+ return core.ToolResult{
99+ IsError: true,
100+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s is interactive and requires an interactive host", t.extension, t.name)}},
101+ }, nil
102+ }
68103 resp, err := t.manager.InvokeTool(ctx, t.name, args, t.timeout)
69104 if err != nil {
105+ if errors.Is(err, context.Canceled) {
106+ return core.ToolResult{
107+ IsError: true,
108+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s cancelled: %v", t.extension, t.name, err)}},
109+ }, nil
110+ }
70111 return core.ToolResult{
71112 IsError: true,
72113 Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("extension %s/%s failed: %v", t.extension, t.name, err)}},
M packages/agent/extproto/extproto.go +13 -0
@@ -48,6 +48,11 @@ type RegisterToolFromExt struct {
4848 Description string `json:"description,omitempty"`
4949 Schema json.RawMessage `json:"schema"`
5050 Deferred bool `json:"deferred,omitempty"`
51+ // Interactive marks a tool that intentionally waits for user input. The
52+ // host applies no reply deadline; context cancellation, deadlines, and
53+ // disconnects still end the call. Headless hosts reject such tools.
54+ // Older hosts ignore the field and keep their normal timeout.
55+ Interactive bool `json:"interactive,omitempty"`
5156 }
5257
5358 type ReadyFromExt struct {
@@ -247,6 +252,14 @@ type CommandInvokedFromHost struct {
247252 Args string `json:"args,omitempty"`
248253 }
249254
255+// ToolCancelFromHost asks the extension to stop an abandoned tool
256+// invocation. Sent best-effort, and only to extensions that advertised the
257+// "tool_cancel" capability in hello. ID is the tool_call correlation id.
258+type ToolCancelFromHost struct {
259+ Type string `json:"type"`
260+ ID string `json:"id"`
261+}
262+
250263 type ToolCallFromHost struct {
251264 Type string `json:"type"`
252265 ID string `json:"id"`
A packages/agent/interactive_extension_policy_test.go +57 -0
@@ -0,0 +1,57 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/zut/packages/agent/extensions"
10+ "github.com/bnema/zut/packages/agent/modes"
11+ "github.com/bnema/zut/packages/core"
12+)
13+
14+func TestExtensionAdapterPreservesInteractivePolicy(t *testing.T) {
15+ manager := extensions.New(t.TempDir(), t.TempDir(), "test", "provider", "model", nonInteractiveExtHooks{})
16+ adapter := &extToolAdapter{mgr: manager}
17+ tool := adapter.NewExtensionTool(ExtensionToolInfo{
18+ Name: "ask_user", Interactive: true, Schema: json.RawMessage(`{"type":"object"}`),
19+ })
20+ if !extensions.IsInteractiveTool(tool) {
21+ t.Fatal("adapter lost interactive registration")
22+ }
23+ result, err := tool.Execute(context.Background(), json.RawMessage(`{}`), nil)
24+ if err != nil || !result.IsError {
25+ t.Fatalf("headless execution result=%+v err=%v", result, err)
26+ }
27+}
28+
29+func TestInteractiveExtensionSupportRequiresAttachedHost(t *testing.T) {
30+ hooks := &interactiveExtHooks{}
31+ if hooks.SupportsInteractiveTools() {
32+ t.Fatal("unattached host permits interactive tools")
33+ }
34+ hooks.attachInteractive(&modes.Interactive{})
35+ if !hooks.SupportsInteractiveTools() {
36+ t.Fatal("attached TUI rejects interactive tools")
37+ }
38+}
39+
40+func TestScheduledSessionRegistryExcludesInteractiveExtensionTools(t *testing.T) {
41+ interactive := extensions.NewTool(nil, extensions.ToolInfo{Name: "ask_user", Interactive: true, Schema: json.RawMessage(`{"type":"object"}`)})
42+ ordinary := extensions.NewTool(nil, extensions.ToolInfo{Name: "lookup", Schema: json.RawMessage(`{"type":"object"}`)})
43+ registry := scheduledSessionRegistry(core.Registry{"ask_user": interactive, "lookup": ordinary})
44+ if _, ok := registry["ask_user"]; ok {
45+ t.Fatal("scheduled background session retained an interactive tool")
46+ }
47+ if registry["lookup"] != ordinary {
48+ t.Fatal("scheduled background session lost an ordinary extension tool")
49+ }
50+}
51+
52+func TestResidentRegistryRejectsInteractiveExtensionTools(t *testing.T) {
53+ tool := extensions.NewTool(nil, extensions.ToolInfo{Name: "ask_user", Interactive: true, Schema: json.RawMessage(`{"type":"object"}`)})
54+ if _, err := residentChildRegistry(core.Registry{"ask_user": tool}, []string{"ask_user"}); err == nil || !strings.Contains(err.Error(), "interactive") {
55+ t.Fatalf("resident registry accepted interactive tool: %v", err)
56+ }
57+}
A packages/agent/modelpicker_providers.go +55 -0
@@ -0,0 +1,55 @@
1+package agent
2+
3+import (
4+ "sort"
5+
6+ "github.com/bnema/zut/packages/provider"
7+)
8+
9+// modelPickerProviders returns providers whose models can be selected without
10+// prompting for credentials. Any custom provider with a configured endpoint
11+// (provider-level or per-model URL) is offered, even without a stored key,
12+// because such endpoints may be keyless; auth:"none" is not required.
13+func modelPickerProviders() []string {
14+ var out []string
15+ seen := map[string]bool{}
16+ for _, p := range knownProviders {
17+ if CredentialAvailable(p) && !seen[p] {
18+ out = append(out, p)
19+ seen[p] = true
20+ }
21+ }
22+ custom := provider.CustomProviders()
23+ names := make([]string, 0, len(custom))
24+ for p := range custom {
25+ names = append(names, p)
26+ }
27+ sort.Strings(names)
28+ for _, p := range names {
29+ if seen[p] {
30+ continue
31+ }
32+ cfg := custom[p]
33+ // Custom endpoints with a configured URL may run keyless, so picker
34+ // availability must not require stored credentials.
35+ keyless := !isBuiltinProvider(p) && (cfg.BaseURL != "" || customProviderHasModelURL(p))
36+ if keyless || CredentialAvailable(p) {
37+ out = append(out, p)
38+ seen[p] = true
39+ }
40+ }
41+ // Ollama models are always available (no auth needed).
42+ if !seen["ollama"] {
43+ out = append(out, "ollama")
44+ }
45+ return out
46+}
47+
48+func customProviderHasModelURL(name string) bool {
49+ for _, m := range provider.ModelsForProvider(name) {
50+ if m.Source == "user" && m.BaseURL != "" {
51+ return true
52+ }
53+ }
54+ return false
55+}
M packages/agent/modelsync.go +26 -1
@@ -304,6 +304,9 @@ func ValidateAndRepairConfig() {
304304 // baked-in entries, so an uncached id must survive startup repair.
305305 if provider.AcceptsUnlistedModels(cfg.Provider) {
306306 // The live refresh will validate the id when it is available.
307+ } else if isDiscoverableCustomProvider(cfg.Provider) {
308+ // Discovery is transient and has not run yet at repair time; keep
309+ // the selection even if another provider has the same model id.
307310 } else if isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(modelID) {
308311 // Provider is a router and the id is route-qualified; preserve it.
309312 } else if m, err := provider.FindModel("", modelID); err == nil {
@@ -344,6 +347,8 @@ func RefreshModelsAsync(explicitProvider, explicitAPIKey string, explicitBaseURL
344347 baseURL = explicitBaseURL[0]
345348 }
346349 go refreshModels(explicitProvider, explicitAPIKey, baseURL, "")
350+ go refreshCopilotModelAvailability()
351+ go refreshCustomProvidersBackground()
347352 go func() {
348353 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
349354 defer cancel()
@@ -351,6 +356,26 @@ func RefreshModelsAsync(explicitProvider, explicitAPIKey string, explicitBaseURL
351356 }()
352357 }
353358
359+// refreshCopilotModelAvailability filters the Copilot catalog to the models the
360+// authenticated account can select. Availability is account-specific, so it
361+// lives only in memory and never in the shared disk cache. It never runs an
362+// api-key command. Without a credential the restriction is cleared; a failed
363+// refresh keeps the previous snapshot (or the static catalog on first load).
364+func refreshCopilotModelAvailability() {
365+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
366+ defer cancel()
367+ cred, _, err := resolveCredentialForBackground(ctx, provider.ProviderGitHubCopilot)
368+ if err != nil || cred == "" {
369+ provider.SetModelAvailability(provider.ProviderGitHubCopilot, nil)
370+ return
371+ }
372+ ids, err := provider.DiscoverCopilotAvailableModels(ctx, cred)
373+ if err != nil {
374+ return
375+ }
376+ provider.SetModelAvailability(provider.ProviderGitHubCopilot, ids)
377+}
378+
354379 func refreshModelsAsyncForProvider(ctx context.Context, explicitProvider, explicitAPIKey, explicitBaseURL, onlyProvider string) {
355380 if ctx == nil {
356381 ctx = context.Background()
@@ -381,7 +406,7 @@ func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) e
381406 if err != nil {
382407 return err
383408 }
384- provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL))
409+ provider.SetManagedModelsForProvider(provider.LlamaCPPProviderID, provider.LlamaCPPModels(models, client.ServerURL))
385410 return nil
386411 }
387412
M packages/agent/modelsync_test.go +7 -2
@@ -191,8 +191,10 @@ func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {
191191 if err := AuthStoreFor().SetEndpointCredential(provider.LlamaCPPProviderID, server.URL, ""); err != nil {
192192 t.Fatal(err)
193193 }
194- provider.SetManagedModels(nil)
195- t.Cleanup(func() { provider.SetManagedModels(nil) })
194+ preserveProviderCatalog(t)
195+ provider.SetManagedModelsForProvider(provider.LlamaCPPProviderID, nil)
196+ // A custom-discovery snapshot must survive the llama.cpp refresh.
197+ provider.SetManagedModelsForProvider("m5", []provider.Model{{Provider: "m5", ID: "kept", Source: "live"}})
196198
197199 if err := RefreshLlamaCPPModels(context.Background()); err != nil {
198200 t.Fatal(err)
@@ -207,6 +209,9 @@ func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) {
207209 if _, err := provider.FindModel(provider.LlamaCPPProviderID, "offline"); err == nil {
208210 t.Fatal("unloaded model must not be selectable")
209211 }
212+ if _, err := provider.FindModel("m5", "kept"); err != nil {
213+ t.Fatal("llama.cpp refresh dropped another provider's managed models")
214+ }
210215 }
211216
212217 // TestValidateAndRepairConfig_MismatchedPair simulates the bug from a
M packages/agent/modes/clipboard_images.go +4 -1
@@ -34,7 +34,10 @@ func (i *Interactive) restoreQueuedMessageToEditor(message core.QueuedMessage) b
3434 if message.Text == "" && len(message.Images) == 0 {
3535 return false
3636 }
37- text := message.Text
37+ i.mu.Lock()
38+ text := i.skillPins.rawPrompt(message.Text)
39+ i.skillPins.recall(message.Text)
40+ i.mu.Unlock()
3841 attachments := make([]clipboardImageAttachment, 0, len(message.Images))
3942 for index, image := range message.Images {
4043 markerIndex := index + 1
M packages/agent/modes/double_escape.go +1 -0
@@ -90,6 +90,7 @@ func (i *Interactive) sessionTreeEscapeBlocked() bool {
9090 i.settingsDialog != nil && i.settingsDialog.Active() ||
9191 i.sessionOpsDialog != nil && i.sessionOpsDialog.Active() ||
9292 i.sessionTreeDialog != nil && i.sessionTreeDialog.Active() ||
93+ i.timeline != nil && i.timeline.Active() ||
9394 i.extPanel != nil && i.extPanel.Active() ||
9495 i.confirmDialog != nil && i.confirmDialog.Active() {
9596 return true
M packages/agent/modes/interactive.go +30 -0
@@ -253,6 +253,12 @@ type InteractiveConfig struct {
253253 // the catalog before /model opens.
254254 RefreshLlamaCPPModels func(context.Context) error
255255
256+ // RefreshCustomProviderModels lists models from models.json providers
257+ // that opted into discovery. CustomDiscoveryConfigured gates it so /model
258+ // opens immediately when nothing opted in. Both are optional.
259+ RefreshCustomProviderModels func(context.Context) error
260+ CustomDiscoveryConfigured func() bool
261+
256262 // BuildAgent is called after a successful login to (re)construct the
257263 // agent with the fresh credential. It returns the new agent and
258264 // the concrete provider/model in use.
@@ -439,6 +445,20 @@ type InteractiveConfig struct {
439445 // so the picker reflects edits made during the session.
440446 SkillSnapshot func() []*skills.Skill
441447
448+ // LoadSkillPins returns the pinned-skill preferences for cwd. With
449+ // PreloadPinnedSkills it feeds the first-prompt preload; either way it
450+ // drives the /skills pin markers. Nil disables pinning.
451+ LoadSkillPins func(cwd string) (skills.Pins, error)
452+
453+ // ToggleSkillPin flips one skill's pin in the project (global=false) or
454+ // global scope. Nil makes the /skills picker read-only.
455+ ToggleSkillPin func(cwd, name string, global bool) error
456+
457+ // PreloadPinnedSkills arms the pinned-skill preload for a fresh
458+ // conversation. Hosts that resume a session must leave it false or call
459+ // SetPinnedSkillsPending(false) once the session is loaded.
460+ PreloadPinnedSkills bool
461+
442462 // ChangelogChan, if non-nil, delivers release-notes for the
443463 // current binary version once at startup. Interactive opens a
444464 // dismissible overlay when the channel produces a non-empty
@@ -850,9 +870,14 @@ type Interactive struct {
850870 telegramBridge *telegram.Bridge
851871 sessionOpsDialog *sessionOpsDialog
852872 sessionTreeDialog *sessionTreeDialog
873+ timeline *timelineView
853874 extPanel *extPanelDialog
854875 llamaConfigured bool
855876
877+ // skillPins preload state is guarded by mu; its fixed callbacks run
878+ // outside mu, serialized by skillPins.ioMu.
879+ skillPins skillPinState
880+
856881 // completionTracker collects resident child terminal turns for delivery to
857882 // the parent orchestration wave.
858883 // turnCoordinator seals worker waves at manager-turn boundaries and decides
@@ -1098,6 +1123,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
10981123 settingsDialog: newSettingsDialog(),
10991124 sessionOpsDialog: newSessionOpsDialog(),
11001125 sessionTreeDialog: newSessionTreeDialog(),
1126+ timeline: newTimelineView(),
11011127 extPanel: newExtPanelDialog(),
11021128 extStatuses: map[string]map[string]extensionStatus{},
11031129 extWidgets: map[string]map[string]extensionWidget{},
@@ -1110,6 +1136,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
11101136 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
11111137 compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff),
11121138 }
1139+ i.skillPins = newSkillPinState(cfg.LoadSkillPins, cfg.ToggleSkillPin, cfg.SkillSnapshot)
11131140 i.btwDialog.setCloseHook(func() {
11141141 i.confirmDialog.CancelChildConfirmations("side chat closed")
11151142 })
@@ -1161,6 +1188,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
11611188 }
11621189 i.applyAutoSubagentsTool()
11631190 i.recoverGoalRun()
1191+ if cfg.PreloadPinnedSkills {
1192+ i.SetPinnedSkillsPending(true)
1193+ }
11641194 return i
11651195 }
11661196
M packages/agent/modes/interactive_commands.go +79 -25
@@ -3,6 +3,7 @@ package modes
33 import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "fmt"
78 "os"
89 "path/filepath"
@@ -224,7 +225,9 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
224225 // lock. It is part of the clear decision so a divergence still clears the
225226 // file.
226227 persistedPlan := i.cfg.PersistedPlan != nil && i.cfg.PersistedPlan() != nil
227- i.mu.Lock()
228+ // Pinned-skill discovery and preference loads are file I/O as well:
229+ // prefetch them beside the plan read, publish under the lock below.
230+ pinFetch, pinDone := i.lockWithSkillPinFetch()
228231 // Read i.agent under i.mu, the same field the locked Agent accessor
229232 // returns; the lock-free read this replaced could race a session swap.
230233 clearPlan := i.agent != nil && len(i.agent.CurrentPlan()) > 0
@@ -242,6 +245,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
242245 i.sessionInfoBlocks = nil
243246 i.parkedTurn = 0
244247 i.parkedTotal = 0
248+ i.lastCtxInput = 0
245249 i.scrollOffset = 0
246250 i.extNotes = nil
247251 i.reloadErrors = nil
@@ -251,7 +255,10 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
251255 i.planRevision++
252256 handoff, persistHandoff := i.resetCompactContinuationLocked()
253257 i.view.InvalidateRenderCache()
258+ // A cleared conversation gets its pinned skills again.
259+ i.armSkillPinsLocked(pinFetch)
254260 i.mu.Unlock()
261+ pinDone()
255262 if persistHandoff {
256263 i.persistCompactHandoff(handoff)
257264 }
@@ -290,16 +297,18 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
290297 case "/model":
291298 if len(parts) >= 2 {
292299 i.applyModelSelection("", parts[1])
293- } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
300+ } else if i.modelRefreshNeeded() {
294301 i.mu.Lock()
295302 i.modelRefreshing = true
296303 i.statusOK = "Refreshing models"
297304 i.statusErr = ""
298305 i.mu.Unlock()
299306 go func() {
300- refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
301- defer cancel()
302- i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)}
307+ err := i.refreshModelCatalogs(ctx)
308+ select {
309+ case i.modelRefresh <- modelRefreshResult{err: err}:
310+ case <-ctx.Done():
311+ }
303312 }()
304313 } else {
305314 i.openModelPickerAfterRefresh(nil)
@@ -513,6 +522,41 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
513522 }
514523 return false
515524 }
525+
526+// modelRefreshNeeded reports whether /model must synchronize an optional
527+// transient catalog (llama.cpp router or opted-in custom discovery) first.
528+func (i *Interactive) modelRefreshNeeded() bool {
529+ if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
530+ return true
531+ }
532+ return i.customDiscoveryEnabled()
533+}
534+
535+func (i *Interactive) customDiscoveryEnabled() bool {
536+ return i.cfg.RefreshCustomProviderModels != nil && i.cfg.CustomDiscoveryConfigured != nil && i.cfg.CustomDiscoveryConfigured()
537+}
538+
539+// refreshModelCatalogs runs each applicable refresh under its own 3 second
540+// deadline, so one slow source cannot starve the other. Failures are joined;
541+// the picker still opens with whatever catalog is available.
542+func (i *Interactive) refreshModelCatalogs(ctx context.Context) error {
543+ var errs []error
544+ run := func(refresh func(context.Context) error) {
545+ refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
546+ defer cancel()
547+ if err := refresh(refreshCtx); err != nil {
548+ errs = append(errs, err)
549+ }
550+ }
551+ if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
552+ run(i.cfg.RefreshLlamaCPPModels)
553+ }
554+ if i.customDiscoveryEnabled() {
555+ run(i.cfg.RefreshCustomProviderModels)
556+ }
557+ return errors.Join(errs...)
558+}
559+
516560 func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
517561 var loggedIn []string
518562 if i.cfg.LoggedInProviders != nil {
@@ -521,7 +565,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
521565 i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
522566 i.mu.Lock()
523567 if refreshErr != nil {
524- i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
568+ i.statusErr = "model refresh: " + refreshErr.Error()
525569 i.statusOK = ""
526570 } else if i.statusOK == "Refreshing models" {
527571 i.statusOK = ""
@@ -805,20 +849,14 @@ func (i *Interactive) startOAuthFlow(provider string) {
805849 i.dialog.ShowWaiting(loginURL)
806850 return
807851 }
808- // Always run the manual/copy-code flow in parallel with the local
809- // callback server so headless environments (docker, SSH) can paste
810- // the authorization code directly without first pressing 'p'.
811- _, err := i.cfg.AuthManager.StartOAuth(provider)
852+ // One browser transaction: the callback server and the pasted-code path
853+ // share the same PKCE verifier and state inside the auth manager.
854+ loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
812855 if err != nil {
813856 i.dialog.ShowResult(false, err.Error())
814857 return
815858 }
816- manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
817- if mErr == nil {
818- i.dialog.ShowWaiting(manualURL)
819- } else {
820- i.dialog.ShowResult(false, mErr.Error())
821- }
859+ i.dialog.ShowWaiting(loginURL)
822860 }
823861 func (i *Interactive) startManualOAuthFlow(provider string) {
824862 if i.cfg.AuthManager == nil {
@@ -830,7 +868,7 @@ func (i *Interactive) startManualOAuthFlow(provider string) {
830868 i.dialog.ShowResult(false, err.Error())
831869 return
832870 }
833- i.dialog.url = url
871+ i.dialog.ShowPasteCode(url)
834872 i.invalidate()
835873 }
836874 func (i *Interactive) submitManualOAuthCode(code string) {
@@ -838,10 +876,14 @@ func (i *Interactive) submitManualOAuthCode(code string) {
838876 return
839877 }
840878 go func() {
841- if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
842- i.dialog.ShowResult(false, err.Error())
843- i.invalidate()
879+ err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code)
880+ if err == nil || errors.Is(err, auth.ErrOAuthCanceled) {
881+ // A canceled or replaced login must not overwrite the dialog of
882+ // whatever login is current now.
883+ return
844884 }
885+ i.dialog.ShowResult(false, err.Error())
886+ i.invalidate()
845887 }()
846888 }
847889 func (i *Interactive) cancelAndWaitForIdle() {
@@ -910,6 +952,8 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
910952 i.invalidate()
911953 return
912954 }
955+ // A typed /skill:name command is a genuine user prompt.
956+ prompt = i.skillPins.consume(prompt)
913957 if i.busy {
914958 // Keep the mutex held while enqueueing so the turn-completion
915959 // goroutine cannot publish idle and inspect an empty queue between
@@ -934,11 +978,7 @@ func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
934978 i.startTurn(ctx, prompt)
935979 }
936980 func (i *Interactive) openSkillsDialog() {
937- var list []*skills.Skill
938- if i.cfg.SkillSnapshot != nil {
939- list = i.cfg.SkillSnapshot()
940- }
941- i.skillsDialog.Open(list)
981+ i.openSkillsDialogWithPins()
942982 i.invalidate()
943983 }
944984 func (i *Interactive) openJumpDialog(args []string) {
@@ -1204,6 +1244,16 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
12041244 i.mu.Lock()
12051245 i.cfg.Model = m.ID
12061246 i.agent.Model = m.ID
1247+ // Output budget and context capacity are per model; keeping the
1248+ // previous model's values can truncate responses or mis-size
1249+ // compaction. Router clients also retain the selected metadata.
1250+ if setter, ok := i.agent.Client.(provider.ModelMetadataSetter); ok {
1251+ setter.SetModelMetadata(m)
1252+ }
1253+ i.agent.MaxTokens = m.MaxOutput
1254+ if m.ContextWindow > 0 {
1255+ i.agent.ContextWindow = m.ContextWindow
1256+ }
12071257 i.statusOK = "model: " + m.ID
12081258 i.statusErr = ""
12091259 i.mu.Unlock()
@@ -1288,6 +1338,10 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
12881338 func (i *Interactive) handleAuthEvent(ev auth.Event) {
12891339 switch ev.Kind {
12901340 case "started":
1341+ // The manual flow already shows its own URL in the paste-code step.
1342+ if i.dialog.step == loginStepPasteCode && i.dialog.url == ev.URL {
1343+ return
1344+ }
12911345 i.dialog.ShowWaiting(ev.URL)
12921346 case "browser_open":
12931347 // no-op
M packages/agent/modes/interactive_compaction.go +2 -0
@@ -325,12 +325,14 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
325325 i.statusOK = "compaction cancelled"
326326 }
327327 i.discardQueuedMessagesLocked(false)
328+ i.skillPins.recall(i.pendingCompactPrompt)
328329 i.clearPendingCompactTurnLocked()
329330 handoff, persistHandoff = i.resetCompactContinuationLocked()
330331 case err != nil:
331332 i.statusErr = "compaction failed: " + err.Error()
332333 i.statusOK = ""
333334 i.discardQueuedMessagesLocked(true)
335+ i.skillPins.recall(i.pendingCompactPrompt)
334336 i.clearPendingCompactTurnLocked()
335337 handoff, persistHandoff = i.resetCompactContinuationLocked()
336338 default:
M packages/agent/modes/interactive_host.go +19 -0
@@ -103,6 +103,8 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid
103103 i.prepareReplacementAgentLocked(ag)
104104 i.compactContinuation = decodeCompactHandoff(compactHandoff)
105105 i.agent = ag
106+ // Resumed, imported and forked conversations already have context.
107+ i.skillPins.disarm()
106108 i.cfg.Provider = providerName
107109 i.cfg.Model = model
108110 i.cfg.detachMismatchedModelProfile()
@@ -169,6 +171,12 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
169171 profiles, _ := subagents.Discover(cwd, home)
170172 subagentsAddendum := subagents.SystemPromptAddendum(profiles)
171173
174+ // The new cwd starts a fresh conversation: discover its skills and load its
175+ // pins before taking any lock. The host has already switched its own cwd.
176+ i.skillPins.ioMu.Lock()
177+ defer i.skillPins.ioMu.Unlock()
178+ pinFetch := i.skillPins.fetch(cwd, true)
179+
172180 i.agentMu.Lock()
173181 defer i.agentMu.Unlock()
174182 i.mu.Lock()
@@ -180,6 +188,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
180188 i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked())
181189 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
182190 i.view.StartupContextPaths = nil
191+ i.armSkillPinsLocked(pinFetch)
183192 i.view.InvalidateRenderCache()
184193 if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
185194 i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
@@ -306,6 +315,11 @@ func (i *Interactive) submitOrQueueMessage(message core.QueuedMessage, userInput
306315 return
307316 }
308317 i.maybeStartSessionTitle(i.runCtx, text)
318+ if userInput {
319+ // Only genuine user input carries pinned skills. Host evidence and
320+ // scheduler/extension prompts (userInput=false) never consume them.
321+ message.Text = i.consumePinnedSkills(message.Text)
322+ }
309323 i.mu.Lock()
310324 if i.busy {
311325 // Keep the interactive mutex held through Agent.QueueMessage so turn teardown
@@ -365,6 +379,11 @@ func (i *Interactive) takeQueuedMessagesLocked() []core.QueuedMessage {
365379
366380 func (i *Interactive) discardQueuedMessagesLocked(preserveHostEvents bool) {
367381 pending := i.takeQueuedMessagesLocked()
382+ for _, message := range pending {
383+ if !message.HostEvent {
384+ i.skillPins.recall(message.Text)
385+ }
386+ }
368387 if !preserveHostEvents {
369388 return
370389 }
M packages/agent/modes/interactive_input_routing.go +26 -6
@@ -73,6 +73,7 @@ func (i *Interactive) confirmChildActiveLocked() bool {
7373 i.settingsDialog.Active() ||
7474 i.sessionOpsDialog.Active() ||
7575 i.sessionTreeDialog.Active() ||
76+ i.timeline.Active() ||
7677 i.extPanel.Active()
7778 }
7879 func (i *Interactive) restoreConfirmFocus() {
@@ -135,6 +136,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
135136 return false
136137 }
137138 act := i.dialog.HandleKey(k)
139+ if act.Close && i.cfg.AuthManager != nil {
140+ i.cfg.AuthManager.CancelOAuth()
141+ }
138142 if act.StartAPIKey {
139143 i.startAPIKeyFlow(act.Provider)
140144 }
@@ -377,6 +381,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
377381 i.invalidate()
378382 return false
379383 }
384+ if i.timeline.Active() {
385+ i.handleTimelineKey(k)
386+ return false
387+ }
380388 if i.sessionOpsDialog.Active() {
381389 if k.Kind == tui.KeyCtrlC {
382390 i.sessionOpsDialog.Close()
@@ -464,6 +472,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
464472 i.invalidate()
465473 return false
466474 }
475+ if i.handleSkillPinKey(k) {
476+ i.invalidate()
477+ return false
478+ }
467479 i.skillsDialog.HandleKey(k)
468480 i.invalidate()
469481 return false
@@ -558,11 +570,8 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
558570 i.ed.Clear()
559571 i.clipboardImages = nil
560572 i.suggest.Reset()
561- if ag != nil {
562- ag.DrainQueuedMessages()
563- }
564573 i.mu.Lock()
565- i.queued = nil
574+ i.discardQueuedMessagesLocked(false)
566575 i.statusOK = "input cleared"
567576 i.statusErr = ""
568577 i.mu.Unlock()
@@ -922,6 +931,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
922931 go i.telegramBridge.OnUserTyped(text)
923932 }
924933 i.maybeStartSessionTitle(ctx, text)
934+ // This is the explicit user boundary for pinned skills: the first
935+ // genuine prompt carries them whether it starts a turn or is queued
936+ // behind a startup command, shell escape, or running turn.
937+ text = i.consumePinnedSkills(text)
925938 // If a turn is already in flight, queue this prompt inside the
926939 // agent loop so it is delivered at the next safe model-call
927940 // boundary instead of waiting for the whole run to finish.
@@ -1034,16 +1047,23 @@ func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
10341047 return true
10351048 }
10361049 func (i *Interactive) inputHistory() []string {
1037- if i.agent == nil {
1050+ i.mu.Lock()
1051+ agent := i.agent
1052+ expanded, original := i.skillPins.expanded, i.skillPins.original
1053+ i.mu.Unlock()
1054+ if agent == nil {
10381055 return nil
10391056 }
1040- msgs := i.agent.Messages()
1057+ msgs := agent.Messages()
10411058 hist := make([]string, 0, len(msgs))
10421059 for _, m := range msgs {
10431060 if !isForkableUserMessage(m) {
10441061 continue
10451062 }
10461063 text := userMessageText(m)
1064+ if expanded != "" && text == expanded {
1065+ text = original
1066+ }
10471067 if strings.TrimSpace(text) == "" {
10481068 continue
10491069 }
A packages/agent/modes/interactive_model_refresh_test.go +136 -0
@@ -0,0 +1,136 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+ "time"
9+)
10+
11+func awaitModelRefresh(t *testing.T, i *Interactive) modelRefreshResult {
12+ t.Helper()
13+ select {
14+ case result := <-i.modelRefresh:
15+ return result
16+ case <-time.After(5 * time.Second):
17+ t.Fatal("refresh result never delivered")
18+ }
19+ return modelRefreshResult{}
20+}
21+
22+func TestModelPickerRefreshesCustomDiscoveryWithoutLlama(t *testing.T) {
23+ calls := make(chan struct{}, 2)
24+ i := &Interactive{
25+ cfg: InteractiveConfig{
26+ CustomDiscoveryConfigured: func() bool { return true },
27+ RefreshCustomProviderModels: func(ctx context.Context) error {
28+ if _, ok := ctx.Deadline(); !ok {
29+ t.Error("custom refresh has no deadline")
30+ }
31+ calls <- struct{}{}
32+ return nil
33+ },
34+ RefreshLlamaCPPModels: func(context.Context) error { t.Error("llama refreshed while unconfigured"); return nil },
35+ },
36+ modelRefresh: make(chan modelRefreshResult, 1),
37+ modelDialog: newModelDialog(),
38+ }
39+
40+ i.runSlash(context.Background(), "/model")
41+ if i.modelDialog.Active() || i.statusOK != "Refreshing models" || !i.modelRefreshing {
42+ t.Fatalf("picker opened early or status wrong: active=%v status=%q", i.modelDialog.Active(), i.statusOK)
43+ }
44+ result := awaitModelRefresh(t, i)
45+ <-calls
46+ i.openModelPickerAfterRefresh(result.err)
47+ if !i.modelDialog.Active() {
48+ t.Fatal("picker did not open after refresh")
49+ }
50+}
51+
52+func TestModelPickerCustomDiscoveryOptOutOpensImmediately(t *testing.T) {
53+ i := &Interactive{
54+ cfg: InteractiveConfig{
55+ CustomDiscoveryConfigured: func() bool { return false },
56+ RefreshCustomProviderModels: func(context.Context) error { t.Error("unexpected refresh"); return nil },
57+ },
58+ modelRefresh: make(chan modelRefreshResult, 1),
59+ modelDialog: newModelDialog(),
60+ }
61+ i.runSlash(context.Background(), "/model")
62+ if !i.modelDialog.Active() || i.modelRefreshing {
63+ t.Fatal("picker did not open immediately without opt-in")
64+ }
65+}
66+
67+func TestModelPickerRunsLlamaAndCustomIndependentlyAndJoinsErrors(t *testing.T) {
68+ var order []string
69+ llamaErr, customErr := errors.New("llama down"), errors.New("custom down")
70+ i := &Interactive{
71+ llamaConfigured: true,
72+ cfg: InteractiveConfig{
73+ RefreshLlamaCPPModels: func(ctx context.Context) error {
74+ order = append(order, "llama")
75+ return llamaErr
76+ },
77+ CustomDiscoveryConfigured: func() bool { return true },
78+ RefreshCustomProviderModels: func(ctx context.Context) error {
79+ // A failed sibling must not consume this refresh's own context.
80+ if ctx.Err() != nil {
81+ t.Errorf("custom refresh context already done: %v", ctx.Err())
82+ }
83+ order = append(order, "custom")
84+ return customErr
85+ },
86+ },
87+ modelRefresh: make(chan modelRefreshResult, 1),
88+ modelDialog: newModelDialog(),
89+ }
90+ i.runSlash(context.Background(), "/model")
91+ result := awaitModelRefresh(t, i)
92+ if strings.Join(order, ",") != "llama,custom" {
93+ t.Fatalf("refresh order = %v", order)
94+ }
95+ if !errors.Is(result.err, llamaErr) || !errors.Is(result.err, customErr) {
96+ t.Fatalf("errors not joined: %v", result.err)
97+ }
98+ i.openModelPickerAfterRefresh(result.err)
99+ if !i.modelDialog.Active() {
100+ t.Fatal("picker unusable after refresh errors")
101+ }
102+ if !strings.HasPrefix(i.statusErr, "model refresh: ") || !strings.Contains(i.statusErr, "llama down") || !strings.Contains(i.statusErr, "custom down") {
103+ t.Fatalf("status = %q", i.statusErr)
104+ }
105+}
106+
107+func TestModelPickerRefreshDoesNotLeakWhenContextCancelled(t *testing.T) {
108+ ctx, cancel := context.WithCancel(context.Background())
109+ done := make(chan struct{})
110+ i := &Interactive{
111+ cfg: InteractiveConfig{
112+ CustomDiscoveryConfigured: func() bool { return true },
113+ RefreshCustomProviderModels: func(ctx context.Context) error {
114+ cancel()
115+ close(done)
116+ return nil
117+ },
118+ },
119+ // Unbuffered and never read: delivery must give up on ctx.Done.
120+ modelRefresh: make(chan modelRefreshResult),
121+ modelDialog: newModelDialog(),
122+ }
123+ i.runSlash(ctx, "/model")
124+ select {
125+ case <-done:
126+ case <-time.After(5 * time.Second):
127+ t.Fatal("refresh not invoked")
128+ }
129+ // The goroutine must exit without a receiver; a leaked sender would keep
130+ // this send from ever being received by a fresh reader.
131+ select {
132+ case r := <-i.modelRefresh:
133+ t.Fatalf("result delivered after cancellation: %+v", r)
134+ case <-time.After(100 * time.Millisecond):
135+ }
136+}
A packages/agent/modes/interactive_model_swap_test.go +54 -0
@@ -0,0 +1,54 @@
1+package modes
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zut/packages/core"
7+ "github.com/bnema/zut/packages/provider"
8+)
9+
10+type metadataRecordingClient struct {
11+ provider.Client
12+ got []provider.Model
13+}
14+
15+func (c *metadataRecordingClient) SetModelMetadata(m provider.Model) { c.got = append(c.got, m) }
16+
17+// A same-provider /model switch reuses the agent, so it must also adopt the
18+// new model's output budget and context window. Keeping the previous model's
19+// MaxTokens truncated responses (e.g. a 4096 budget carried onto a 128k model).
20+func TestSameProviderModelSwapUpdatesModelLimits(t *testing.T) {
21+ const from, to = "claude-3-5-haiku-20241022", "claude-opus-4-1-20250805"
22+ fromModel, err := provider.FindModel("anthropic", from)
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ toModel, err := provider.FindModel("anthropic", to)
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ if fromModel.MaxOutput == toModel.MaxOutput || toModel.MaxOutput <= 0 {
31+ t.Fatalf("test needs distinct positive limits, got %d and %d", fromModel.MaxOutput, toModel.MaxOutput)
32+ }
33+
34+ client := &metadataRecordingClient{}
35+ ag := core.NewAgent(client, from, "", nil)
36+ ag.MaxTokens = fromModel.MaxOutput
37+ ag.ContextWindow = fromModel.ContextWindow
38+ i := &Interactive{agent: ag, cfg: InteractiveConfig{Provider: "anthropic", Model: from}}
39+
40+ i.swapModel("anthropic", to, nil, false)
41+
42+ if ag.Model != to {
43+ t.Fatalf("agent model = %q, want %q", ag.Model, to)
44+ }
45+ if ag.MaxTokens != toModel.MaxOutput {
46+ t.Fatalf("agent MaxTokens = %d, want %d", ag.MaxTokens, toModel.MaxOutput)
47+ }
48+ if ag.ContextWindow != toModel.ContextWindow {
49+ t.Fatalf("agent ContextWindow = %d, want %d", ag.ContextWindow, toModel.ContextWindow)
50+ }
51+ if len(client.got) != 1 || client.got[0].ID != to || client.got[0].MaxOutput != toModel.MaxOutput {
52+ t.Fatalf("client metadata = %+v, want %s", client.got, to)
53+ }
54+}
M packages/agent/modes/interactive_rendering.go +9 -1
@@ -530,6 +530,9 @@ func (i *Interactive) redraw() {
530530 dialogID = "session-tree"
531531 i.sessionTreeDialog.MaxRows = max(3, paneMax.ContentHeight()-5)
532532 dialog = i.sessionTreeDialog.Render(i.cfg.Theme, dialogWidth)
533+ case i.timeline.Active():
534+ dialogID = "timeline"
535+ dialog = i.timeline.Render(i.cfg.Theme, dialogWidth, paneMax.ContentHeight(), i.timelineDataLocked())
533536 }
534537 dialogTitle := floatingOverlayTitle(dialogID)
535538 dialogRemovedTopRows := 0
@@ -742,10 +745,15 @@ func (i *Interactive) redraw() {
742745 // content. The status block and editor get their own dedicated
743746 // blanks so spacing stays consistent whether or not a dialog or
744747 // popup is showing.
748+ var pinnedSkillLines []string
749+ if len(dialog) == 0 && len(suggest) == 0 {
750+ pinnedSkillLines = i.skillPins.notice(i.cfg.Theme, mainCols)
751+ }
745752 composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) {
746- bottom = make([]string, 0, len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9)
753+ bottom = make([]string, 0, len(suggest)+len(queue)+len(pinnedSkillLines)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9)
747754 bottom = append(bottom, suggest...)
748755 bottom = append(bottom, queue...)
756+ bottom = append(bottom, pinnedSkillLines...)
749757 lineInput := inputStyle == tui.InputStyleLines
750758 statusBelow := statusPosition == tui.StatusPositionBelowInput
751759 workingBelow := workingPosition == tui.WorkingPositionBelowInput
M packages/agent/modes/interactive_resident_completion_test.go +14 -19
@@ -18,7 +18,11 @@ func TestResidentCompletionBatchDoesNotReportTerminalSiblingAsRunning(t *testing
1818 ctx, cancel := context.WithCancel(context.Background())
1919 defer cancel()
2020 ag := core.NewAgent(nil, "model", "", nil)
21- i := &Interactive{agent: ag, busy: true, runCtx: ctx}
21+ // Own the delivery loop during setup: TrackResidentSubagent requests
22+ // delivery immediately, and a manager-wave hold does not stop reports
23+ // sliding into a busy parent. Both outcomes must be ready before draining
24+ // to exercise the same-batch sibling filter, not two valid separate batches.
25+ i := &Interactive{agent: ag, busy: true, runCtx: ctx, completionDeliveryRunning: true}
2226 release := i.beginCompletionDeliveryHold()
2327 defer release()
2428 i.TrackResidentSubagent("first", "turn-1")
@@ -27,24 +31,15 @@ func TestResidentCompletionBatchDoesNotReportTerminalSiblingAsRunning(t *testing
2731 tracker := i.ensureCompletionTracker()
2832 tracker.Report(subagents.Completion{AgentID: "first", TurnID: "turn-1", Status: "completed"})
2933 tracker.Report(subagents.Completion{AgentID: "second", TurnID: "turn-2", Status: "completed"})
30- i.requestCompletionDelivery()
31-
32- deadline := time.NewTimer(2 * time.Second)
33- defer deadline.Stop()
34- for {
35- queued := ag.PendingQueuedMessages()
36- if len(queued) == 2 {
37- for _, message := range queued {
38- if strings.Contains(message.Text, "Still running:") {
39- t.Fatalf("terminal sibling reported as running: %q", message.Text)
40- }
41- }
42- return
43- }
44- select {
45- case <-deadline.C:
46- t.Fatalf("queued completions = %#v, want 2", queued)
47- case <-time.After(time.Millisecond):
34+ i.deliverCompletionUpdates()
35+
36+ queued := ag.PendingQueuedMessages()
37+ if len(queued) != 2 {
38+ t.Fatalf("queued completions = %#v, want 2", queued)
39+ }
40+ for _, message := range queued {
41+ if strings.Contains(message.Text, "Still running:") {
42+ t.Fatalf("terminal sibling reported as running: %q", message.Text)
4843 }
4944 }
5045 }
M packages/agent/modes/interactive_resident_subagents.go +4 -1
@@ -689,6 +689,7 @@ func (h *telegramHost) Notify(level, message string) {
689689 }
690690 func (i *Interactive) openSessionOpsDialog() {
691691 items := []sessionOpsItem{
692+ {label: "timeline", action: "timeline", hint: "inspect context, messages, and tool calls"},
692693 {label: "export", action: "export", hint: "write the current session to a .zutsession file"},
693694 {label: "import", action: "import", hint: "load a .zutsession file into this directory"},
694695 {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
@@ -699,6 +700,8 @@ func (i *Interactive) openSessionOpsDialog() {
699700 }
700701 func (i *Interactive) doSessionOp(action, arg string) {
701702 switch action {
703+ case "timeline":
704+ i.openTimeline()
702705 case "export":
703706 i.doSessionExport(arg)
704707 case "import":
@@ -709,7 +712,7 @@ func (i *Interactive) doSessionOp(action, arg string) {
709712 i.doSessionTree()
710713 default:
711714 i.mu.Lock()
712- i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
715+ i.statusErr = "unknown /session action: " + action + " (use timeline, export, import, fork, or tree)"
713716 i.mu.Unlock()
714717 i.invalidate()
715718 }
M packages/agent/modes/interactive_submit.go +6 -0
@@ -17,6 +17,9 @@ func (i *Interactive) Submit(text string) {
1717 i.mu.Unlock()
1818 if !awaitingStartupPre {
1919 i.maybeStartSessionTitle(parent, text)
20+ // Initial and deferred input are user prompts; the startup command
21+ // itself is not and leaves the pins pending.
22+ text = i.consumePinnedSkills(text)
2023 }
2124 i.startTurn(parent, text)
2225 }
@@ -48,6 +51,9 @@ func (i *Interactive) completeStartupPre() {
4851 // single-threaded. Input entered while resources were reloading wins over the
4952 // deferred prefill rather than being overwritten.
5053 func (i *Interactive) applyStartupPreResult(result startupPreResult) {
54+ // entry.pre may have added or removed skills; re-resolve the pending pins
55+ // without holding the interactive mutex.
56+ i.refreshPendingSkillPins()
5157 if result.deferred == "" {
5258 return
5359 }
M packages/agent/modes/llama_dialog.go +1 -1
@@ -130,7 +130,7 @@ func (d *llamaDialog) refresh(next llamaDialogStep, message string) {
130130 }
131131 return models[i].ID < models[j].ID
132132 })
133- provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL))
133+ provider.SetManagedModelsForProvider(provider.LlamaCPPProviderID, provider.LlamaCPPModels(models, client.ServerURL))
134134 d.mu.Lock()
135135 if d.step != llamaClosed {
136136 d.models = models
M packages/agent/modes/login_dialog.go +60 -9
@@ -18,15 +18,16 @@ type loginStep int
1818 // dialog must default to closed so nothing shows up until Open() is
1919 // explicitly called.
2020 const (
21- loginStepClosed loginStep = iota
22- loginStepMethod // pick apikey vs subscription
23- loginStepProvider // pick anthropic vs openai vs kimi
24- loginStepLlamaURL // enter and validate llama.cpp router URL
25- loginStepLlamaKey // enter optional llama.cpp API key
26- loginStepWaiting // browser open, waiting for callback
27- loginStepPasteCode // user pastes the auth code here
28- loginStepInfo // informational setup guidance
29- loginStepDone // success or error, waiting for key to dismiss
21+ loginStepClosed loginStep = iota
22+ loginStepMethod // pick apikey vs subscription
23+ loginStepProvider // pick anthropic vs openai vs kimi
24+ loginStepOAuthMethod // pick browser or copy-code for Anthropic
25+ loginStepLlamaURL // enter and validate llama.cpp router URL
26+ loginStepLlamaKey // enter optional llama.cpp API key
27+ loginStepWaiting // browser open, waiting for callback
28+ loginStepPasteCode // user pastes the auth code here
29+ loginStepInfo // informational setup guidance
30+ loginStepDone // success or error, waiting for key to dismiss
3031 )
3132
3233 const loginProviderPageSize = 8
@@ -79,6 +80,7 @@ func (d *loginDialog) Open(zutHome string) {
7980 d.url = ""
8081 d.cursor = 0
8182 d.providerQuery = ""
83+ d.codeEd = nil
8284 d.llamaEd = nil
8385 d.llamaURL = ""
8486 d.infoTitle = ""
@@ -179,6 +181,18 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
179181 lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))
180182 }
181183 lines = append(lines, frameRule(th, width))
184+ case loginStepOAuthMethod:
185+ lines = append(lines, frameHeader(th, "login - subscription - "+providerLabel(d.provider), width))
186+ lines = append(lines, th.FGColor(th.Muted, "choose sign-in flow (↑/↓, enter, esc to go back):"))
187+ for idx, label := range []string{"browser callback", "copy code (headless / SSH)"} {
188+ plain := " " + label
189+ if idx == d.cursor {
190+ lines = append(lines, th.PadHighlight(plain, width))
191+ } else {
192+ lines = append(lines, th.FGColor(th.Muted, plain))
193+ }
194+ }
195+ lines = append(lines, frameRule(th, width))
182196 case loginStepLlamaURL:
183197 lines = append(lines, frameHeader(th, "login - api key - llama.cpp", width))
184198 lines = append(lines, th.FGColor(th.Muted, "llama.cpp router URL:"))
@@ -438,6 +452,8 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
438452 return d.handleMethodKey(k)
439453 case loginStepProvider:
440454 return d.handleProviderKey(k)
455+ case loginStepOAuthMethod:
456+ return d.handleOAuthMethodKey(k)
441457 case loginStepLlamaURL, loginStepLlamaKey:
442458 return d.handleLlamaKey(k)
443459 case loginStepWaiting:
@@ -534,6 +550,11 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
534550 d.message = ""
535551 return loginDialogAction{}
536552 }
553+ if d.method == "oauth" && d.provider == "anthropic" {
554+ d.step = loginStepOAuthMethod
555+ d.cursor = 0
556+ return loginDialogAction{}
557+ }
537558 d.step = loginStepWaiting
538559 if d.method == "apikey" {
539560 return loginDialogAction{StartAPIKey: true, Provider: d.provider}
@@ -543,6 +564,26 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
543564 return loginDialogAction{}
544565 }
545566
567+func (d *loginDialog) handleOAuthMethodKey(k tui.Key) loginDialogAction {
568+ switch k.Kind {
569+ case tui.KeyUp:
570+ d.cursor = 0
571+ case tui.KeyDown:
572+ d.cursor = 1
573+ case tui.KeyEsc:
574+ d.step = loginStepProvider
575+ d.cursor = 0
576+ case tui.KeyEnter:
577+ if d.cursor == 1 {
578+ d.step = loginStepPasteCode
579+ return loginDialogAction{StartManual: true, Provider: d.provider}
580+ }
581+ d.step = loginStepWaiting
582+ return loginDialogAction{StartOAuth: true, Provider: d.provider}
583+ }
584+ return loginDialogAction{}
585+}
586+
546587 func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction {
547588 if k.Kind == tui.KeyEsc {
548589 d.Close()
@@ -584,6 +625,16 @@ func (d *loginDialog) ShowWaiting(url string) {
584625 d.url = url
585626 }
586627
628+// ShowPasteCode displays the URL for a manual OAuth transaction.
629+// No-op if the user has already dismissed the dialog.
630+func (d *loginDialog) ShowPasteCode(url string) {
631+ if d.step == loginStepClosed {
632+ return
633+ }
634+ d.step = loginStepPasteCode
635+ d.url = url
636+}
637+
587638 // ShowInfo transitions to an informational setup dialog.
588639 // No-op if the user has already dismissed the dialog.
589640 func (d *loginDialog) ShowInfo(title string, lines []string) {
M packages/agent/modes/login_dialog_test.go +104 -0
@@ -2,11 +2,13 @@ package modes
22
33 import (
44 "context"
5+ "net/url"
56 "os"
67 "path/filepath"
78 "strings"
89 "testing"
910
11+ "github.com/bnema/zut/packages/provider/auth"
1012 "github.com/bnema/zut/packages/tui"
1113 )
1214
@@ -168,3 +170,105 @@ func TestInteractiveAltCUsesTerminalClipboardBeforeSystemClipboard(t *testing.T)
168170 t.Fatalf("system clipboard helper ran: %v", err)
169171 }
170172 }
173+
174+func TestLoginDialogOpenAIOAuthAcceptsCodeFromSameTransaction(t *testing.T) {
175+ d := newLoginDialog()
176+ d.Open(t.TempDir())
177+ d.method = "oauth"
178+ d.provider = "openai-codex"
179+ d.ShowWaiting("http://localhost:1455/auth/callback")
180+
181+ text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n"))
182+ if !strings.Contains(text, "paste the authorization code") {
183+ t.Fatal("OpenAI OAuth dialog does not offer pasted code entry")
184+ }
185+ d.codeEd.SetValue("code#state")
186+ if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.SubmitCode != "code#state" {
187+ t.Fatalf("code submission = %+v", action)
188+ }
189+}
190+
191+func TestLoginDialogAnthropicOffersSeparateOAuthFlows(t *testing.T) {
192+ for _, tc := range []struct {
193+ name string
194+ choice int
195+ manual bool
196+ step loginStep
197+ }{
198+ {name: "browser", choice: 0, step: loginStepWaiting},
199+ {name: "copy code", choice: 1, manual: true, step: loginStepPasteCode},
200+ } {
201+ t.Run(tc.name, func(t *testing.T) {
202+ d := newLoginDialog()
203+ d.Open(t.TempDir())
204+ d.method = "oauth"
205+ d.step = loginStepProvider
206+ for idx, p := range d.providerOptions() {
207+ if p == "anthropic" {
208+ d.cursor = idx
209+ break
210+ }
211+ }
212+ if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.StartOAuth || action.StartManual || d.step != loginStepOAuthMethod {
213+ t.Fatalf("provider selection started OAuth before choosing a flow: action=%+v step=%v", action, d.step)
214+ }
215+ for i := 0; i < tc.choice; i++ {
216+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
217+ }
218+ action := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
219+ if action.StartManual != tc.manual || action.StartOAuth == tc.manual || d.step != tc.step {
220+ t.Fatalf("flow action=%+v step=%v", action, d.step)
221+ }
222+ })
223+ }
224+}
225+
226+func TestAnthropicManualLoginUsesCopyCodeTransaction(t *testing.T) {
227+ manager := auth.NewManager(auth.NewStore(filepath.Join(t.TempDir(), "auth.json")))
228+ t.Cleanup(manager.Close)
229+ i := NewInteractive(InteractiveConfig{AuthManager: manager})
230+ i.dialog.Open(t.TempDir())
231+ i.dialog.method = "oauth"
232+ i.dialog.provider = "anthropic"
233+ i.dialog.step = loginStepOAuthMethod
234+ i.dialog.HandleKey(tui.Key{Kind: tui.KeyDown})
235+ act := i.dialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
236+ if !act.StartManual || act.Provider != "anthropic" {
237+ t.Fatalf("expected manual Anthropic transaction, got %+v", act)
238+ }
239+ i.startManualOAuthFlow(act.Provider)
240+ i.handleAuthEvent(<-manager.Events())
241+ if i.dialog.step != loginStepPasteCode {
242+ t.Fatalf("step = %v, want paste code", i.dialog.step)
243+ }
244+ u, err := url.Parse(i.dialog.url)
245+ if err != nil {
246+ t.Fatal(err)
247+ }
248+ if got := u.Query().Get("redirect_uri"); got != auth.AnthropicManualOAuth.RedirectURI() {
249+ t.Fatalf("redirect URI = %q, want copy-code redirect", got)
250+ }
251+ if err := manager.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" {
252+ t.Fatalf("manual transaction not ready for code entry: %v", err)
253+ }
254+}
255+
256+func TestLoginDialogCloseCancelsOAuthTransaction(t *testing.T) {
257+ manager := auth.NewManager(auth.NewStore(filepath.Join(t.TempDir(), "auth.json")))
258+ t.Cleanup(manager.Close)
259+ i := NewInteractive(InteractiveConfig{AuthManager: manager})
260+ i.dialog.Open(t.TempDir())
261+ i.dialog.method = "oauth"
262+ i.dialog.provider = "anthropic"
263+ if _, err := manager.StartManualOAuth("anthropic"); err != nil {
264+ t.Fatal(err)
265+ }
266+ i.dialog.ShowPasteCode("https://example.com/oauth/authorize")
267+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
268+ if i.dialog.Active() {
269+ t.Fatal("dialog still active after esc")
270+ }
271+ if err := manager.CompleteManualOAuth(context.Background(), "code"); err == nil || !strings.Contains(err.Error(), "no manual oauth flow") {
272+ t.Fatalf("oauth transaction survived dialog close: %v", err)
273+ }
274+}
A packages/agent/modes/skill_pins.go +423 -0
@@ -0,0 +1,423 @@
1+package modes
2+
3+import (
4+ "slices"
5+ "sync"
6+
7+ "github.com/bnema/zut/packages/agent/skills"
8+ "github.com/bnema/zut/packages/tui"
9+)
10+
11+// skillPinState holds the pinned-skill preload state of one interactive
12+// conversation. Interactive owns one value and guards the mutable fields with
13+// its own mutex, so every method except fetch and runToggle assumes the caller
14+// holds that lock. Nothing here contacts a provider; the pending
15+// bodies only join the first genuine user prompt (see consume).
16+//
17+// The three function fields are host callbacks (InteractiveConfig
18+// LoadSkillPins, ToggleSkillPin, SkillSnapshot). Any may be nil: without
19+// load there are no pins, without toggle the picker is read-only. They do
20+// file and directory I/O, so they must never run under the interactive mutex.
21+// Callers prefetch (fetch, runToggle) outside it and publish the result under
22+// it (armFetched, applyRefresh, completeToggle).
23+type skillPinState struct {
24+ load func(cwd string) (skills.Pins, error)
25+ toggle func(cwd, name string, global bool) error
26+ snapshot func() []*skills.Skill
27+
28+ // ioMu serializes prefetch flows (fetch or toggle followed by publish) so
29+ // a reload never publishes preferences older than a completed save. It is
30+ // taken before agentMu and the interactive mutex; nothing waits for it
31+ // while holding either.
32+ ioMu sync.Mutex
33+
34+ pending bool
35+ pins skills.Pins
36+ selected []*skills.Skill
37+ // gen changes whenever the conversation is armed or disarmed (fresh
38+ // conversation, /clear, /cd, resume, import, fork); rev also changes when
39+ // the preload is consumed or recalled. A prefetch records both so a result
40+ // overtaken by newer state is never published over it.
41+ gen uint64
42+ rev uint64
43+ // Remember the one expanded user prompt so queue recall and input history
44+ // expose the user's draft, never the injected instruction bodies. The
45+ // consumed selection is kept with it so recall restores exactly what was
46+ // consumed without touching the disk again.
47+ expanded string
48+ original string
49+ consumedSelected []*skills.Skill
50+ consumedPins skills.Pins
51+}
52+
53+// skillPinFetch is the result of prefetching preferences (and, for arming,
54+// the discoverable skills) outside the interactive mutex.
55+type skillPinFetch struct {
56+ pins skills.Pins
57+ err error
58+ available []*skills.Skill
59+}
60+
61+// skillPinMark records the state a prefetch started from.
62+type skillPinMark struct {
63+ gen, rev uint64
64+ cwd string
65+}
66+
67+// skillPinToggle is a validated picker request captured under the mutex and
68+// executed outside it. list is the dialog's already-fetched skill list.
69+type skillPinToggle struct {
70+ cwd, name string
71+ global bool
72+ list []*skills.Skill
73+}
74+
75+const skillPinsSavedNotice = "skill pins updated (apply on the next fresh conversation or /clear)"
76+
77+func newSkillPinState(load func(string) (skills.Pins, error), toggle func(string, string, bool) error, snapshot func() []*skills.Skill) skillPinState {
78+ return skillPinState{load: load, toggle: toggle, snapshot: snapshot}
79+}
80+
81+// mark captures the current generation, revision and cwd for a later publish.
82+func (s *skillPinState) mark(cwd string) skillPinMark {
83+ return skillPinMark{gen: s.gen, rev: s.rev, cwd: cwd}
84+}
85+
86+// fetch loads the preferences for cwd and, when withSkills is set, the
87+// discoverable skills. It touches no mutable state, so it runs without the
88+// interactive mutex. Without a loader it does nothing.
89+func (s *skillPinState) fetch(cwd string, withSkills bool) skillPinFetch {
90+ var f skillPinFetch
91+ if s.load == nil {
92+ return f
93+ }
94+ f.pins, f.err = s.load(cwd)
95+ if withSkills && f.err == nil && len(f.pins.Global)+len(f.pins.Project) > 0 && s.snapshot != nil {
96+ f.available = s.snapshot()
97+ }
98+ return f
99+}
100+
101+// disarm turns preloading off for a resumed, imported or forked conversation.
102+// It never performs I/O.
103+func (s *skillPinState) disarm() {
104+ s.pending = false
105+ s.selected = nil
106+ s.expanded, s.original = "", ""
107+ s.consumedSelected, s.consumedPins = nil, skills.Pins{}
108+ s.gen++
109+ s.rev++
110+}
111+
112+// armFetched prepares preloading for a fresh conversation (or /clear, /cd)
113+// from a prefetch made for the current cwd. Pending and the resolved
114+// selection are published together. It returns warnings.
115+func (s *skillPinState) armFetched(f skillPinFetch, d *skillsDialog) []string {
116+ s.disarm()
117+ if s.load == nil {
118+ return nil
119+ }
120+ s.pending = true
121+ s.pins = f.pins
122+ if d != nil {
123+ d.pins = f.pins
124+ d.canPin = s.toggle != nil
125+ }
126+ return s.resolveLocked(f)
127+}
128+
129+// resolveLocked resolves the selection from f and returns warnings for
130+// unreadable preferences and pinned names no longer discoverable.
131+func (s *skillPinState) resolveLocked(f skillPinFetch) []string {
132+ var warnings []string
133+ if f.err != nil {
134+ warnings = append(warnings, f.err.Error())
135+ }
136+ var missing []string
137+ s.selected, missing = f.pins.Resolve(f.available)
138+ return append(warnings, missing...)
139+}
140+
141+// applyRefresh publishes a prefetch made from m without changing whether the
142+// preload is pending; cwd is the current working directory. A prefetch for
143+// another cwd or conversation (gen) is dropped whole. One overtaken by a
144+// consume or recall (rev) only feeds the dialog's markers, so it cannot
145+// replace an exactly restored selection. Otherwise the state's pins are
146+// updated and, while pending, the selection is re-resolved from f.available.
147+func (s *skillPinState) applyRefresh(m skillPinMark, cwd string, f skillPinFetch, d *skillsDialog) []string {
148+ if s.load == nil || m.cwd != cwd || m.gen != s.gen {
149+ return nil
150+ }
151+ if d != nil {
152+ d.pins = f.pins
153+ d.canPin = s.toggle != nil
154+ }
155+ var warnings []string
156+ if m.rev == s.rev {
157+ s.pins = f.pins
158+ if s.pending {
159+ return s.resolveLocked(f)
160+ }
161+ }
162+ if f.err != nil {
163+ warnings = append(warnings, f.err.Error())
164+ }
165+ return warnings
166+}
167+
168+// toggleRequest validates a picker key: 'p' (project) or 'g' (global) on a
169+// pinnable, listed skill. ok is false when the key is not a pin key or the
170+// picker is not in a state that pins (closed, body view, empty, read-only
171+// host); the caller then forwards the key to the dialog.
172+func (s *skillPinState) toggleRequest(k tui.Key, cwd string, d *skillsDialog) (req skillPinToggle, ok bool) {
173+ if k.Kind != tui.KeyRune || (k.Rune != 'p' && k.Rune != 'g') {
174+ return req, false
175+ }
176+ sk := d.selectedSkill()
177+ if sk == nil || !d.canPin || s.toggle == nil {
178+ return req, false
179+ }
180+ return skillPinToggle{cwd: cwd, name: sk.Name, global: k.Rune == 'g', list: d.skills}, true
181+}
182+
183+// runToggle saves the toggle and reloads the preferences. It performs file
184+// I/O and must run without the interactive mutex.
185+func (s *skillPinState) runToggle(req skillPinToggle) (f skillPinFetch, saveErr error) {
186+ if saveErr = s.toggle(req.cwd, req.name, req.global); saveErr != nil {
187+ return f, saveErr
188+ }
189+ f = s.fetch(req.cwd, false)
190+ f.available = req.list
191+ return f, nil
192+}
193+
194+// completeToggle publishes a finished toggle. statusErr carries a save
195+// failure and statusOK the success notice; pins are refreshed on success from
196+// the dialog's own skill list.
197+func (s *skillPinState) completeToggle(m skillPinMark, cwd string, f skillPinFetch, saveErr error, d *skillsDialog) (statusOK, statusErr string, warnings []string) {
198+ if saveErr != nil {
199+ return "", "save skill pin: " + saveErr.Error(), nil
200+ }
201+ return skillPinsSavedNotice, "", s.applyRefresh(m, cwd, f, d)
202+}
203+
204+// consume folds the pending skill bodies into prompt exactly once. Later calls
205+// return the prompt unchanged until the conversation is armed again. The
206+// consumed selection stays in the state so recall can restore it exactly.
207+func (s *skillPinState) consume(prompt string) string {
208+ if !s.pending {
209+ return prompt
210+ }
211+ s.pending = false
212+ s.rev++
213+ out := skills.PreloadPrompt(s.selected, prompt)
214+ if out != prompt {
215+ s.expanded, s.original = out, prompt
216+ s.consumedSelected, s.consumedPins = s.selected, s.pins
217+ }
218+ s.selected = nil
219+ return out
220+}
221+
222+// rawPrompt removes only the exact expansion generated by this conversation.
223+// Arbitrary user text resembling a pinned-skill preamble is never stripped.
224+func (s *skillPinState) rawPrompt(prompt string) string {
225+ if s.expanded != "" && prompt == s.expanded {
226+ return s.original
227+ }
228+ return prompt
229+}
230+
231+// recall releases an undelivered preload so the next user submit can carry it.
232+// It restores exactly the consumed selection without discovery or preference
233+// I/O, so it is safe under the interactive mutex; queue removal serializes
234+// delivery.
235+func (s *skillPinState) recall(prompt string) {
236+ if s.expanded == "" || prompt != s.expanded || s.load == nil {
237+ return
238+ }
239+ s.pending = true
240+ s.selected, s.pins = s.consumedSelected, s.consumedPins
241+ s.rev++
242+}
243+
244+// notice renders the read-only "[Pinned skills]" section shown above the
245+// input while pins are pending, never as editor text.
246+func (s *skillPinState) notice(th tui.Theme, cols int) []string {
247+ if !s.pending || len(s.selected) == 0 || cols < 4 {
248+ return nil
249+ }
250+ names := make([]string, 0, len(s.selected))
251+ for _, sk := range s.selected {
252+ name := sk.Name
253+ project, global := s.pins.Scopes(s.selected, sk)
254+ switch {
255+ case project && global:
256+ name += " (project + global)"
257+ case project:
258+ name += " (project)"
259+ case global:
260+ name += " (global)"
261+ }
262+ names = append(names, name)
263+ }
264+ return tui.RenderPinnedSkills(th, names, cols)
265+}
266+
267+// SetPinnedSkillsPending arms (true) or disarms (false) the pinned-skill
268+// preload for the current conversation. Hosts disarm after loading a resumed,
269+// imported or forked session; /clear and /cd re-arm internally. Arming
270+// prefetches outside the mutex and is abandoned if the preload state changed
271+// meanwhile (including consumption or recall).
272+func (i *Interactive) SetPinnedSkillsPending(pending bool) {
273+ if !pending {
274+ i.mu.Lock()
275+ i.skillPins.disarm()
276+ i.mu.Unlock()
277+ i.invalidate()
278+ return
279+ }
280+ i.skillPins.ioMu.Lock()
281+ defer i.skillPins.ioMu.Unlock()
282+ i.mu.Lock()
283+ rev := i.skillPins.rev
284+ i.mu.Unlock()
285+ var f skillPinFetch
286+ cwd := i.skillPinCWD()
287+ for {
288+ f = i.skillPins.fetch(cwd, true)
289+ i.mu.Lock()
290+ if i.skillPins.rev != rev {
291+ i.mu.Unlock()
292+ return
293+ }
294+ if i.cfg.CWD == cwd {
295+ break
296+ }
297+ cwd = i.cfg.CWD
298+ i.mu.Unlock()
299+ }
300+ i.armSkillPinsLocked(f)
301+ i.mu.Unlock()
302+ i.invalidate()
303+}
304+
305+// skillPinCWD reads the working directory under the mutex.
306+func (i *Interactive) skillPinCWD() string {
307+ i.mu.Lock()
308+ defer i.mu.Unlock()
309+ return i.cfg.CWD
310+}
311+
312+// lockWithSkillPinFetch prefetches the preferences and discoverable skills for
313+// the current cwd without the mutex, then returns holding i.mu with the fetch
314+// still matching i.cfg.CWD (it refetches if the cwd moved meanwhile). The
315+// caller publishes with armSkillPinsLocked, unlocks i.mu, then calls done.
316+func (i *Interactive) lockWithSkillPinFetch() (f skillPinFetch, done func()) {
317+ i.skillPins.ioMu.Lock()
318+ cwd := i.skillPinCWD()
319+ for {
320+ f = i.skillPins.fetch(cwd, true)
321+ i.mu.Lock()
322+ if i.cfg.CWD == cwd {
323+ return f, i.skillPins.ioMu.Unlock
324+ }
325+ cwd = i.cfg.CWD
326+ i.mu.Unlock()
327+ }
328+}
329+
330+// armSkillPinsLocked arms the preload for a fresh conversation from a prefetch
331+// for the current cwd and reports warnings once. The caller must hold i.mu.
332+func (i *Interactive) armSkillPinsLocked(f skillPinFetch) {
333+ i.addSkillPinWarningsLocked(i.skillPins.armFetched(f, i.skillsDialog))
334+}
335+
336+// addSkillPinWarningsLocked shows pin warnings in the host-only reload error
337+// list, skipping ones already shown. The caller must hold i.mu.
338+func (i *Interactive) addSkillPinWarningsLocked(warnings []string) {
339+ for _, w := range warnings {
340+ if w = "skill pins: " + w; !slices.Contains(i.reloadErrors, w) {
341+ i.reloadErrors = append(i.reloadErrors, w)
342+ }
343+ }
344+}
345+
346+// refreshPendingSkillPins re-resolves a pending selection (entry.pre may have
347+// added or removed skills). Nothing is read when nothing is pending. The
348+// callbacks run without the mutex; a result overtaken by a consume, recall,
349+// arm or cwd change is dropped.
350+func (i *Interactive) refreshPendingSkillPins() {
351+ i.skillPins.ioMu.Lock()
352+ defer i.skillPins.ioMu.Unlock()
353+ i.mu.Lock()
354+ if !i.skillPins.pending || i.skillPins.load == nil {
355+ i.mu.Unlock()
356+ return
357+ }
358+ mark := i.skillPins.mark(i.cfg.CWD)
359+ i.mu.Unlock()
360+ f := i.skillPins.fetch(mark.cwd, true)
361+ i.mu.Lock()
362+ i.addSkillPinWarningsLocked(i.skillPins.applyRefresh(mark, i.cfg.CWD, f, i.skillsDialog))
363+ i.mu.Unlock()
364+}
365+
366+// openSkillsDialogWithPins opens the picker on a single skill snapshot. The
367+// preferences load outside the mutex, and a pending selection re-resolves from
368+// that same list rather than walking discovery again. If the cwd moved while
369+// loading, the whole prefetch is redone.
370+func (i *Interactive) openSkillsDialogWithPins() {
371+ i.skillPins.ioMu.Lock()
372+ defer i.skillPins.ioMu.Unlock()
373+ for {
374+ i.mu.Lock()
375+ mark := i.skillPins.mark(i.cfg.CWD)
376+ i.mu.Unlock()
377+ var list []*skills.Skill
378+ if i.cfg.SkillSnapshot != nil {
379+ list = i.cfg.SkillSnapshot()
380+ }
381+ f := i.skillPins.fetch(mark.cwd, false)
382+ f.available = list
383+ i.mu.Lock()
384+ if i.cfg.CWD == mark.cwd {
385+ i.skillsDialog.Open(list)
386+ i.addSkillPinWarningsLocked(i.skillPins.applyRefresh(mark, i.cfg.CWD, f, i.skillsDialog))
387+ i.mu.Unlock()
388+ return
389+ }
390+ i.mu.Unlock()
391+ }
392+}
393+
394+// consumePinnedSkills folds pending pins into the first genuine user prompt.
395+func (i *Interactive) consumePinnedSkills(prompt string) string {
396+ i.mu.Lock()
397+ defer i.mu.Unlock()
398+ return i.skillPins.consume(prompt)
399+}
400+
401+// handleSkillPinKey applies the picker's p/g keys. It reports whether the key
402+// was consumed. The preference save and reload run without the mutex; the
403+// result is applied under it from the dialog's own skill list.
404+func (i *Interactive) handleSkillPinKey(k tui.Key) bool {
405+ i.mu.Lock()
406+ req, ok := i.skillPins.toggleRequest(k, i.cfg.CWD, i.skillsDialog)
407+ i.mu.Unlock()
408+ if !ok {
409+ return false
410+ }
411+ i.skillPins.ioMu.Lock()
412+ defer i.skillPins.ioMu.Unlock()
413+ i.mu.Lock()
414+ mark := i.skillPins.mark(req.cwd)
415+ i.mu.Unlock()
416+ f, saveErr := i.skillPins.runToggle(req)
417+ i.mu.Lock()
418+ okText, errText, warnings := i.skillPins.completeToggle(mark, i.cfg.CWD, f, saveErr, i.skillsDialog)
419+ i.statusOK, i.statusErr = okText, errText
420+ i.addSkillPinWarningsLocked(warnings)
421+ i.mu.Unlock()
422+ return true
423+}
A packages/agent/modes/skill_pins_lock_test.go +415 -0
@@ -0,0 +1,415 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/zut/packages/agent/skills"
12+ "github.com/bnema/zut/packages/core"
13+ "github.com/bnema/zut/packages/tui"
14+)
15+
16+// Synchronous helpers composing the prefetch and publish steps for state-level
17+// tests that have no Interactive mutex to release.
18+
19+func (s *skillPinState) arm(pending bool, cwd string, d *skillsDialog) []string {
20+ if !pending {
21+ s.disarm()
22+ return nil
23+ }
24+ return s.armFetched(s.fetch(cwd, true), d)
25+}
26+
27+func (s *skillPinState) refresh(cwd string, d *skillsDialog) []string {
28+ m := s.mark(cwd)
29+ return s.applyRefresh(m, cwd, s.fetch(cwd, true), d)
30+}
31+
32+func (s *skillPinState) handleKey(k tui.Key, cwd string, d *skillsDialog) (handled bool, statusOK, statusErr string, warnings []string) {
33+ req, ok := s.toggleRequest(k, cwd, d)
34+ if !ok {
35+ return false, "", "", nil
36+ }
37+ m := s.mark(cwd)
38+ f, err := s.runToggle(req)
39+ statusOK, statusErr, warnings = s.completeToggle(m, cwd, f, err, d)
40+ return true, statusOK, statusErr, warnings
41+}
42+
43+// mutexProbe fails the test when a callback runs while i.mu is held.
44+type mutexProbe struct {
45+ t *testing.T
46+ i *Interactive
47+ mu sync.Mutex
48+ calls map[string]int
49+}
50+
51+func newMutexProbe(t *testing.T, i *Interactive) *mutexProbe {
52+ return &mutexProbe{t: t, i: i, calls: map[string]int{}}
53+}
54+
55+func (p *mutexProbe) hit(name string) {
56+ p.t.Helper()
57+ if !p.i.mu.TryLock() {
58+ p.t.Errorf("%s ran while i.mu was held", name)
59+ } else {
60+ p.i.mu.Unlock()
61+ }
62+ p.mu.Lock()
63+ p.calls[name]++
64+ p.mu.Unlock()
65+}
66+
67+func (p *mutexProbe) count(name string) int {
68+ p.mu.Lock()
69+ defer p.mu.Unlock()
70+ return p.calls[name]
71+}
72+
73+func (p *mutexProbe) reset() {
74+ p.mu.Lock()
75+ p.calls = map[string]int{}
76+ p.mu.Unlock()
77+}
78+
79+func newProbedInteractive(t *testing.T, preload bool) (*Interactive, *mutexProbe) {
80+ t.Helper()
81+ var i *Interactive
82+ var probe *mutexProbe
83+ pins := skills.Pins{Global: []string{"review"}}
84+ i = NewInteractive(InteractiveConfig{
85+ Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(),
86+ LoadSkillPins: func(string) (skills.Pins, error) {
87+ if probe != nil {
88+ probe.hit("load")
89+ }
90+ return pins, nil
91+ },
92+ ToggleSkillPin: func(_, name string, _ bool) error {
93+ probe.hit("toggle")
94+ pins = skills.Pins{Global: []string{name}}
95+ return nil
96+ },
97+ SkillSnapshot: func() []*skills.Skill {
98+ if probe != nil {
99+ probe.hit("snapshot")
100+ }
101+ return reviewSnapshot()
102+ },
103+ PreloadPinnedSkills: preload,
104+ })
105+ probe = newMutexProbe(t, i)
106+ return i, probe
107+}
108+
109+func TestSkillPinCallbacksRunOutsideInteractiveMutex(t *testing.T) {
110+ t.Run("startup arm", func(t *testing.T) {
111+ i, p := newProbedInteractive(t, false)
112+ i.SetPinnedSkillsPending(true)
113+ if p.count("load") != 1 || p.count("snapshot") != 1 {
114+ t.Fatalf("arm callbacks: %v", p.calls)
115+ }
116+ if !strings.Contains(i.consumePinnedSkills("x"), "Inspect carefully.") {
117+ t.Fatal("arm lost selection")
118+ }
119+ })
120+ t.Run("clear", func(t *testing.T) {
121+ i, p := newProbedInteractive(t, true)
122+ i.consumePinnedSkills("first")
123+ p.reset()
124+ i.runSlash(context.Background(), "/clear")
125+ if p.count("load") != 1 || p.count("snapshot") != 1 {
126+ t.Fatalf("clear callbacks: %v", p.calls)
127+ }
128+ if !strings.Contains(i.consumePinnedSkills("x"), "Inspect carefully.") {
129+ t.Fatal("/clear did not re-arm")
130+ }
131+ })
132+ t.Run("cd", func(t *testing.T) {
133+ i, p := newProbedInteractive(t, true)
134+ i.consumePinnedSkills("first")
135+ p.reset()
136+ i.ApplyChangedCWD(core.NewAgent(nil, "test-model", "", nil), "p", "m", t.TempDir())
137+ if p.count("load") != 1 || p.count("snapshot") != 1 {
138+ t.Fatalf("cd callbacks: %v", p.calls)
139+ }
140+ if !strings.Contains(i.consumePinnedSkills("x"), "Inspect carefully.") {
141+ t.Fatal("/cd did not re-arm")
142+ }
143+ })
144+ t.Run("skills dialog and toggle", func(t *testing.T) {
145+ i, p := newProbedInteractive(t, true)
146+ p.reset()
147+ i.openSkillsDialog()
148+ if p.count("snapshot") != 1 || p.count("load") != 1 {
149+ t.Fatalf("/skills must walk discovery once: %v", p.calls)
150+ }
151+ p.reset()
152+ if !i.handleSkillPinKey(pinKey('p')) {
153+ t.Fatal("pin key not handled")
154+ }
155+ if p.count("toggle") != 1 || p.count("load") != 1 || p.count("snapshot") != 0 {
156+ t.Fatalf("toggle callbacks: %v", p.calls)
157+ }
158+ i.mu.Lock()
159+ ok := i.statusOK
160+ i.mu.Unlock()
161+ if ok == "" {
162+ t.Fatal("toggle status missing")
163+ }
164+ })
165+ t.Run("startup pre", func(t *testing.T) {
166+ i, p := newProbedInteractive(t, true)
167+ p.reset()
168+ i.applyStartupPreResult(startupPreResult{})
169+ if p.count("load") != 1 || p.count("snapshot") != 1 {
170+ t.Fatalf("startup callbacks: %v", p.calls)
171+ }
172+ i.consumePinnedSkills("first")
173+ p.reset()
174+ i.applyStartupPreResult(startupPreResult{})
175+ if len(p.calls) != 0 {
176+ t.Fatalf("consumed pins were refreshed: %v", p.calls)
177+ }
178+ })
179+ t.Run("disarm", func(t *testing.T) {
180+ i, p := newProbedInteractive(t, true)
181+ p.reset()
182+ i.SetPinnedSkillsPending(false)
183+ i.ApplySessionAgent(core.NewAgent(nil, "test-model", "", nil), "p", "m")
184+ if len(p.calls) != 0 {
185+ t.Fatalf("disarm did IO: %v", p.calls)
186+ }
187+ })
188+}
189+
190+func TestSkillPinRecallAndDiscardDoNoIOAndRestoreExactSelection(t *testing.T) {
191+ i, p := newProbedInteractive(t, true)
192+ expanded := i.consumePinnedSkills("draft")
193+ p.reset()
194+ // Preferences and discovery change after consumption; recall must still
195+ // restore exactly what was consumed, without reading either again.
196+ i.mu.Lock()
197+ i.skillPins.load = func(string) (skills.Pins, error) { t.Error("recall loaded pins"); return skills.Pins{}, nil }
198+ i.skillPins.snapshot = func() []*skills.Skill { t.Error("recall walked discovery"); return nil }
199+ i.mu.Unlock()
200+
201+ if !i.restoreQueuedMessageToEditor(core.QueuedMessage{Text: expanded}) {
202+ t.Fatal("restore failed")
203+ }
204+ if i.ed.Value() != "draft" {
205+ t.Fatalf("editor = %q", i.ed.Value())
206+ }
207+ i.mu.Lock()
208+ notice := stripANSIBytes(strings.Join(i.skillPins.notice(tui.Theme{}, 80), ""))
209+ i.mu.Unlock()
210+ if !strings.Contains(notice, "review (global)") {
211+ t.Fatalf("scope marker lost: %q", notice)
212+ }
213+ if next := i.consumePinnedSkills("draft"); next != expanded {
214+ t.Fatalf("recalled selection differs:\n%q\n%q", next, expanded)
215+ }
216+
217+ i.agent.QueuePrompt(core.QueuedMessage{Text: expanded})
218+ i.mu.Lock()
219+ i.discardQueuedMessagesLocked(false)
220+ i.mu.Unlock()
221+ if next := i.consumePinnedSkills("again"); strings.Count(next, "Inspect carefully.") != 1 {
222+ t.Fatalf("discard lost pins: %q", next)
223+ }
224+ if len(p.calls) != 0 {
225+ t.Fatalf("recall did IO: %v", p.calls)
226+ }
227+}
228+
229+func TestSkillPinNoPendingWithEmptySelectionPublished(t *testing.T) {
230+ // A blocked prefetch must not expose pending with a stale/empty selection.
231+ release := make(chan struct{})
232+ entered := make(chan struct{}, 1)
233+ i := NewInteractive(InteractiveConfig{
234+ Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(),
235+ LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil },
236+ SkillSnapshot: func() []*skills.Skill {
237+ select {
238+ case entered <- struct{}{}:
239+ default:
240+ }
241+ <-release
242+ return reviewSnapshot()
243+ },
244+ })
245+ done := make(chan struct{})
246+ go func() { i.SetPinnedSkillsPending(true); close(done) }()
247+ <-entered
248+ i.mu.Lock()
249+ pending, selected := i.skillPins.pending, len(i.skillPins.selected)
250+ i.mu.Unlock()
251+ if pending || selected != 0 {
252+ t.Fatalf("published early: pending=%v selected=%d", pending, selected)
253+ }
254+ close(release)
255+ <-done
256+ i.mu.Lock()
257+ pending, selected = i.skillPins.pending, len(i.skillPins.selected)
258+ i.mu.Unlock()
259+ if !pending || selected != 1 {
260+ t.Fatalf("pending=%v selected=%d", pending, selected)
261+ }
262+}
263+
264+// blockingSnapshot lets a test hold one discovery walk open.
265+type blockingSnapshot struct {
266+ entered chan struct{}
267+ release chan struct{}
268+ block bool
269+ empty bool // discovery finds nothing once released
270+}
271+
272+func (b *blockingSnapshot) snapshot() []*skills.Skill {
273+ if b.block {
274+ b.block = false
275+ b.entered <- struct{}{}
276+ <-b.release
277+ if b.empty {
278+ return nil
279+ }
280+ }
281+ return reviewSnapshot()
282+}
283+
284+func TestStaleSkillPinArmDroppedAfterDisarmOrSessionReplacement(t *testing.T) {
285+ for name, overtake := range map[string]func(*Interactive){
286+ "disarm": func(i *Interactive) { i.SetPinnedSkillsPending(false) },
287+ "session replacement": func(i *Interactive) { i.ApplySessionAgent(core.NewAgent(nil, "test-model", "", nil), "p", "m") },
288+ } {
289+ t.Run(name, func(t *testing.T) {
290+ b := &blockingSnapshot{entered: make(chan struct{}, 1), release: make(chan struct{}), block: true}
291+ i := NewInteractive(InteractiveConfig{
292+ Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(),
293+ LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil },
294+ SkillSnapshot: b.snapshot,
295+ })
296+ done := make(chan struct{})
297+ go func() { i.SetPinnedSkillsPending(true); close(done) }()
298+ <-b.entered
299+ // The overtaking transition must not wait for the blocked callback.
300+ finished := make(chan struct{})
301+ go func() { overtake(i); close(finished) }()
302+ select {
303+ case <-finished:
304+ case <-time.After(2 * time.Second):
305+ t.Fatal("transition blocked behind in-flight prefetch")
306+ }
307+ close(b.release)
308+ <-done
309+ if got := i.consumePinnedSkills("hello"); got != "hello" {
310+ t.Fatalf("stale arm published pins: %q", got)
311+ }
312+ })
313+ }
314+}
315+
316+func TestStaleSkillPinRefreshDroppedAfterConsumeAndRecall(t *testing.T) {
317+ b := &blockingSnapshot{entered: make(chan struct{}, 1), release: make(chan struct{})}
318+ i := NewInteractive(InteractiveConfig{
319+ Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(),
320+ LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil },
321+ SkillSnapshot: b.snapshot, PreloadPinnedSkills: true,
322+ })
323+ // Startup refresh blocked in discovery while the user submits, then the
324+ // queued prompt is recalled. The exactly restored selection must survive
325+ // the (now stale) refresh, which would otherwise resolve to nothing.
326+ b.block = true
327+ done := make(chan struct{})
328+ go func() { i.refreshPendingSkillPins(); close(done) }()
329+ <-b.entered
330+ expanded := i.consumePinnedSkills("draft")
331+ i.mu.Lock()
332+ i.skillPins.recall(expanded)
333+ i.mu.Unlock()
334+ // The blocked discovery finds nothing: a published stale result would
335+ // empty the restored selection.
336+ b.empty = true
337+ close(b.release)
338+ <-done
339+ if got := i.consumePinnedSkills("draft"); got != expanded {
340+ t.Fatalf("stale refresh replaced restored selection: %q", got)
341+ }
342+}
343+
344+func TestStaleSkillPinPrefetchDroppedAfterCWDOrGenerationChange(t *testing.T) {
345+ load := func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil }
346+ s := newSkillPinState(load, nil, reviewSnapshot)
347+ s.armFetched(s.fetch("/a", true), nil)
348+ before := s.selected
349+
350+ mark := s.mark("/a")
351+ stale := skillPinFetch{pins: skills.Pins{Global: []string{"gone"}}}
352+ if w := s.applyRefresh(mark, "/b", stale, nil); w != nil || s.pins.Global[0] != "review" || len(s.selected) != len(before) {
353+ t.Fatalf("prefetch for another cwd published: pins=%+v warnings=%v", s.pins, w)
354+ }
355+ s.disarm()
356+ s.armFetched(s.fetch("/a", true), nil)
357+ s.applyRefresh(mark, "/a", stale, nil)
358+ if s.pins.Global[0] != "review" || len(s.selected) != 1 {
359+ t.Fatalf("prefetch from an older conversation published: %+v", s.pins)
360+ }
361+}
362+
363+func TestEmptySkillPinsSkipDiscovery(t *testing.T) {
364+ loads := 0
365+ i := NewInteractive(InteractiveConfig{
366+ Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(), PreloadPinnedSkills: true,
367+ LoadSkillPins: func(string) (skills.Pins, error) { loads++; return skills.Pins{}, nil },
368+ SkillSnapshot: func() []*skills.Skill { t.Fatal("empty pins triggered discovery"); return nil },
369+ })
370+ i.refreshPendingSkillPins()
371+ i.runSlash(context.Background(), "/clear")
372+ i.applyChangedCWD(core.NewAgent(nil, "test-model", "", nil), "p", "m", t.TempDir(), nil)
373+ if loads < 4 {
374+ t.Fatalf("pin preferences not refreshed at lifecycle boundaries: %d loads", loads)
375+ }
376+}
377+
378+func TestStaleSkillPinArmDroppedAfterConsumption(t *testing.T) {
379+ b := &blockingSnapshot{entered: make(chan struct{}, 1), release: make(chan struct{})}
380+ i := NewInteractive(InteractiveConfig{
381+ Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(), PreloadPinnedSkills: true,
382+ LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil },
383+ SkillSnapshot: b.snapshot,
384+ })
385+ b.block = true
386+ done := make(chan struct{})
387+ go func() { i.SetPinnedSkillsPending(true); close(done) }()
388+ <-b.entered
389+ if got := i.consumePinnedSkills("first"); !strings.Contains(got, "Inspect carefully.") {
390+ t.Fatalf("initial selection not consumed: %q", got)
391+ }
392+ close(b.release)
393+ <-done
394+ if got := i.consumePinnedSkills("second"); got != "second" {
395+ t.Fatalf("stale rearm duplicated preload: %q", got)
396+ }
397+}
398+
399+func TestSkillPinToggleFailureKeepsStateAndAppliesNoStatusOK(t *testing.T) {
400+ i := NewInteractive(InteractiveConfig{
401+ Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(),
402+ LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{}, nil },
403+ ToggleSkillPin: func(string, string, bool) error { return errors.New("read-only") },
404+ SkillSnapshot: reviewSnapshot,
405+ })
406+ i.openSkillsDialog()
407+ if !i.handleSkillPinKey(pinKey('g')) {
408+ t.Fatal("not handled")
409+ }
410+ i.mu.Lock()
411+ defer i.mu.Unlock()
412+ if !strings.Contains(i.statusErr, "read-only") || i.statusOK != "" {
413+ t.Fatalf("status ok=%q err=%q", i.statusOK, i.statusErr)
414+ }
415+}
A packages/agent/modes/skill_pins_recall_test.go +99 -0
@@ -0,0 +1,99 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+ "testing"
8+
9+ "github.com/bnema/zut/packages/agent/skills"
10+ "github.com/bnema/zut/packages/core"
11+ "github.com/bnema/zut/packages/provider"
12+ "github.com/bnema/zut/packages/tui"
13+)
14+
15+func newPinnedRecallInteractive(t *testing.T) *Interactive {
16+ t.Helper()
17+ return NewInteractive(InteractiveConfig{
18+ Agent: core.NewAgent(nil, "test-model", "", nil), CWD: t.TempDir(),
19+ LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil },
20+ SkillSnapshot: reviewSnapshot, PreloadPinnedSkills: true,
21+ })
22+}
23+
24+func TestPinnedQueuedDraftRecallKeepsEditorRawAndRearms(t *testing.T) {
25+ for name, key := range map[string]tui.Key{"alt-up": {Kind: tui.KeyUp, Alt: true}, "escape": {Kind: tui.KeyEsc}} {
26+ t.Run(name, func(t *testing.T) {
27+ i := newPinnedRecallInteractive(t)
28+ expanded := i.consumePinnedSkills("user draft")
29+ i.agent.QueuePrompt(core.QueuedMessage{Text: expanded})
30+ i.mu.Lock()
31+ i.busy = true
32+ i.cancelTurn = func() {}
33+ i.mu.Unlock()
34+ i.handleKey(context.Background(), key)
35+ if text := i.ed.Value(); text != "user draft" {
36+ t.Fatalf("recalled editor text=%q, want raw draft", text)
37+ }
38+ if next := i.consumePinnedSkills(i.ed.Value()); strings.Count(next, "Inspect carefully.") != 1 {
39+ t.Fatalf("recall lost or doubled pins: %q", next)
40+ }
41+ })
42+ }
43+}
44+
45+func TestDiscardPinnedQueuedDraftRearmsNextUser(t *testing.T) {
46+ for _, preserveHost := range []bool{false, true} {
47+ i := newPinnedRecallInteractive(t)
48+ expanded := i.consumePinnedSkills("discarded draft")
49+ i.agent.QueuePrompt(core.QueuedMessage{Text: expanded})
50+ i.agent.QueuePrompt(core.QueuedMessage{Text: "host report", HostEvent: true})
51+ i.mu.Lock()
52+ i.discardQueuedMessagesLocked(preserveHost)
53+ i.mu.Unlock()
54+ if next := i.consumePinnedSkills("new draft"); strings.Count(next, "Inspect carefully.") != 1 || !strings.HasSuffix(next, "new draft") {
55+ t.Fatalf("discard lost pins: %q", next)
56+ }
57+ }
58+}
59+
60+func TestCtrlCClearsPinnedQueueWithoutLosingPreload(t *testing.T) {
61+ i := newPinnedRecallInteractive(t)
62+ expanded := i.consumePinnedSkills("discarded draft")
63+ i.agent.QueuePrompt(core.QueuedMessage{Text: expanded})
64+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlC})
65+ if next := i.consumePinnedSkills("next draft"); strings.Count(next, "Inspect carefully.") != 1 {
66+ t.Fatalf("clearing queue lost pins: %q", next)
67+ }
68+}
69+
70+func TestPinnedRecallAndHistorySynchronizeWithHostArming(t *testing.T) {
71+ i := newPinnedRecallInteractive(t)
72+ expanded := i.consumePinnedSkills("user draft")
73+ i.agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: expanded}}}})
74+ var wg sync.WaitGroup
75+ wg.Add(1)
76+ go func() {
77+ defer wg.Done()
78+ for range 100 {
79+ i.SetPinnedSkillsPending(true)
80+ }
81+ }()
82+ for range 100 {
83+ i.restoreQueuedMessageToEditor(core.QueuedMessage{Text: expanded})
84+ i.inputHistory()
85+ }
86+ wg.Wait()
87+}
88+
89+func TestPinnedInputHistoryShowsRawUserText(t *testing.T) {
90+ i := newPinnedRecallInteractive(t)
91+ expanded := i.consumePinnedSkills("user draft")
92+ i.agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: expanded}}}})
93+ if history := i.inputHistory(); len(history) != 1 || history[0] != "user draft" {
94+ t.Fatalf("input history contains injected bodies: %#v", history)
95+ }
96+ if got := i.skillPins.rawPrompt("Pinned skills for this conversation.\nuser-written text"); !strings.Contains(got, "user-written text") {
97+ t.Fatal("stripped unrelated user-written preamble")
98+ }
99+}
A packages/agent/modes/skill_pins_scope_test.go +39 -0
@@ -0,0 +1,39 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/agent/skills"
8+ "github.com/bnema/zut/packages/tui"
9+)
10+
11+func TestSkillPinStateReadOnlyWithoutLoader(t *testing.T) {
12+ called := false
13+ state := newSkillPinState(nil, func(string, string, bool) error { called = true; return nil }, reviewSnapshot)
14+ dialog := newSkillsDialog()
15+ dialog.Open(reviewSnapshot())
16+ state.refresh("/workspace", dialog)
17+ if handled, _, _, _ := state.handleKey(pinKey('p'), "/workspace", dialog); handled || called {
18+ t.Fatal("read-only picker wrote pin preferences")
19+ }
20+}
21+
22+func TestSkillPinAliasesShowScopesAndPreloadOnce(t *testing.T) {
23+ skill := &skills.Skill{Name: "review", Aliases: []string{"group/review"}, Body: "Inspect carefully."}
24+ pins := skills.Pins{Project: []string{"group/review"}, Global: []string{"review"}}
25+ state := newSkillPinState(func(string) (skills.Pins, error) { return pins, nil }, func(string, string, bool) error { return nil }, func() []*skills.Skill { return []*skills.Skill{skill} })
26+ dialog := newSkillsDialog()
27+ dialog.Open([]*skills.Skill{skill})
28+ state.arm(true, "/workspace", dialog)
29+ if marker := dialog.pinMarker(skill); marker != "[pg] " {
30+ t.Fatalf("marker=%q, want [pg]", marker)
31+ }
32+ notice := stripANSIBytes(strings.Join(state.notice(tui.Theme{}, 80), ""))
33+ if !strings.Contains(notice, "review (project + global)") {
34+ t.Fatalf("alias scope missing from notice: %q", notice)
35+ }
36+ if prompt := state.consume("question"); strings.Count(prompt, "Inspect carefully.") != 1 {
37+ t.Fatalf("alias pin included body twice: %q", prompt)
38+ }
39+}
A packages/agent/modes/skill_pins_test.go +175 -0
@@ -0,0 +1,175 @@
1+package modes
2+
3+import (
4+ "errors"
5+ "slices"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/zut/packages/agent/skills"
10+ "github.com/bnema/zut/packages/tui"
11+ "github.com/mattn/go-runewidth"
12+)
13+
14+func reviewSnapshot() []*skills.Skill {
15+ return []*skills.Skill{{Name: "review", Body: "Inspect carefully."}}
16+}
17+
18+func pinKey(r rune) tui.Key { return tui.Key{Kind: tui.KeyRune, Rune: r} }
19+
20+func TestSkillPinStateConsumesOnceAndRearms(t *testing.T) {
21+ load := func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil }
22+ s := newSkillPinState(load, nil, reviewSnapshot)
23+ s.arm(true, "/w", nil)
24+ if got := stripANSIBytes(strings.Join(s.notice(tui.Theme{}, 80), "")); !strings.Contains(got, "review (global)") {
25+ t.Fatalf("notice = %q", got)
26+ }
27+ first := s.consume("first")
28+ if !strings.Contains(first, "Inspect carefully.") || !strings.HasSuffix(first, "first") {
29+ t.Fatal(first)
30+ }
31+ if got := s.consume("second"); got != "second" {
32+ t.Fatal(got)
33+ }
34+ if len(s.notice(tui.Theme{}, 80)) != 0 {
35+ t.Fatal("notice survived consumption")
36+ }
37+ // /clear and /cd re-arm.
38+ s.arm(true, "/w", nil)
39+ if !strings.Contains(s.consume("after clear"), "Inspect carefully.") {
40+ t.Fatal("re-arm did not restore pins")
41+ }
42+ // Resume/import/fork disarm even after arming.
43+ s.arm(true, "/w", nil)
44+ s.arm(false, "/w", nil)
45+ if got := s.consume("resumed"); got != "resumed" {
46+ t.Fatal("resume reinjected pins")
47+ }
48+ // Refresh (model swap, /skills) never re-arms a consumed state.
49+ s.refresh("/w", nil)
50+ if got := s.consume("later"); got != "later" {
51+ t.Fatal("refresh re-armed pins")
52+ }
53+ // No loader means nothing is ever pending.
54+ none := newSkillPinState(nil, nil, reviewSnapshot)
55+ none.arm(true, "/w", nil)
56+ if got := none.consume("x"); got != "x" {
57+ t.Fatal(got)
58+ }
59+}
60+
61+func TestSkillPinStateTogglesAndSaveFailure(t *testing.T) {
62+ pins := skills.Pins{}
63+ fail := false
64+ flip := func(names []string, name string) []string {
65+ if slices.Contains(names, name) {
66+ return nil
67+ }
68+ return []string{name}
69+ }
70+ toggle := func(_, name string, global bool) error {
71+ if fail {
72+ return errors.New("read-only preferences")
73+ }
74+ if global {
75+ pins.Global = flip(pins.Global, name)
76+ } else {
77+ pins.Project = flip(pins.Project, name)
78+ }
79+ return nil
80+ }
81+ s := newSkillPinState(func(string) (skills.Pins, error) { return pins, nil }, toggle, reviewSnapshot)
82+ d := newSkillsDialog()
83+ d.Open(reviewSnapshot())
84+ s.arm(true, "/w", d)
85+
86+ for _, r := range []rune{'p', 'g'} {
87+ handled, ok, errText, _ := s.handleKey(pinKey(r), "/w", d)
88+ if !handled || errText != "" || ok == "" {
89+ t.Fatalf("key %c: handled=%v ok=%q err=%q", r, handled, ok, errText)
90+ }
91+ }
92+ if rendered := strings.Join(d.Render(tui.Theme{}, 100), "\n"); !strings.Contains(rendered, "[pg]") {
93+ t.Fatal(rendered)
94+ }
95+ if len(s.selected) != 1 {
96+ t.Fatal("scope union duplicated skill")
97+ }
98+ s.handleKey(pinKey('p'), "/w", d)
99+ if len(s.selected) != 1 || len(d.pins.Project) != 0 {
100+ t.Fatal("project unpin removed global pin")
101+ }
102+ fail = true
103+ handled, _, errText, _ := s.handleKey(pinKey('g'), "/w", d)
104+ if !handled || !strings.Contains(errText, "read-only") || len(d.pins.Global) != 1 {
105+ t.Fatalf("save failure not preserved: %q", errText)
106+ }
107+ // Body view and other keys are not pin keys.
108+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
109+ if handled, _, _, _ := s.handleKey(pinKey('g'), "/w", d); handled {
110+ t.Fatal("body view toggled a pin")
111+ }
112+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
113+ if handled, _, _, _ := s.handleKey(pinKey('x'), "/w", d); handled {
114+ t.Fatal("unrelated key handled")
115+ }
116+}
117+
118+func TestSkillPinStateReadOnlyHost(t *testing.T) {
119+ s := newSkillPinState(func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil }, nil, reviewSnapshot)
120+ d := newSkillsDialog()
121+ d.Open(reviewSnapshot())
122+ s.arm(true, "/w", d)
123+ if handled, _, _, _ := s.handleKey(pinKey('p'), "/w", d); handled {
124+ t.Fatal("pin key handled without ToggleSkillPin")
125+ }
126+ rendered := strings.Join(d.Render(tui.Theme{}, 100), "\n")
127+ if strings.Contains(rendered, "[-g]") || strings.Contains(rendered, "p: project pin") {
128+ t.Fatalf("read-only host shows pin controls: %s", rendered)
129+ }
130+ // The pending notice still informs the user.
131+ if len(s.notice(tui.Theme{}, 80)) == 0 {
132+ t.Fatal("pending notice missing")
133+ }
134+}
135+
136+func TestSkillPinStateNoticeShowsScopesOnce(t *testing.T) {
137+ load := func(string) (skills.Pins, error) {
138+ return skills.Pins{Project: []string{"local", "shared"}, Global: []string{"everywhere", "shared"}}, nil
139+ }
140+ snap := func() []*skills.Skill {
141+ return []*skills.Skill{{Name: "local"}, {Name: "everywhere"}, {Name: "shared"}}
142+ }
143+ s := newSkillPinState(load, nil, snap)
144+ s.arm(true, "/w", nil)
145+ text := stripANSIBytes(strings.Join(s.notice(tui.Theme{}, 120), "\n"))
146+ for _, want := range []string{"local (project)", "everywhere (global)", "shared (project + global)"} {
147+ if !strings.Contains(text, want) {
148+ t.Errorf("notice missing %q: %q", want, text)
149+ }
150+ }
151+ if strings.Count(text, "shared") != 1 {
152+ t.Fatalf("both scopes duplicated the skill: %q", text)
153+ }
154+}
155+
156+func TestSkillPinStateWarningsAndNoticeWidth(t *testing.T) {
157+ load := func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"missing", "review"}}, nil }
158+ s := newSkillPinState(load, nil, reviewSnapshot)
159+ warnings := s.arm(true, "/w", nil)
160+ if len(warnings) != 1 || !strings.Contains(warnings[0], "missing") {
161+ t.Fatalf("warnings = %v", warnings)
162+ }
163+ bad := newSkillPinState(func(string) (skills.Pins, error) { return skills.Pins{}, errors.New("invalid skill-pins.json") }, nil, reviewSnapshot)
164+ if w := bad.arm(true, "/w", nil); len(w) != 1 || !strings.Contains(w[0], "invalid") {
165+ t.Fatalf("malformed warning = %v", w)
166+ }
167+ s.selected = []*skills.Skill{{Name: strings.Repeat("界", 30)}}
168+ for _, width := range []int{1, 4, 12, 30, 80} {
169+ for _, row := range s.notice(tui.Theme{}, width) {
170+ if got := runewidth.StringWidth(stripANSIBytes(row)); got > width {
171+ t.Fatalf("width %d exceeds %d", got, width)
172+ }
173+ }
174+ }
175+}
A packages/agent/modes/skill_pins_turn_test.go +432 -0
@@ -0,0 +1,432 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/agent/skills"
11+ "github.com/bnema/zut/packages/core"
12+ "github.com/bnema/zut/packages/provider"
13+ "github.com/bnema/zut/packages/tui"
14+)
15+
16+// pinRecordingClient answers every request with "ok" and records the request.
17+type pinRecordingClient struct {
18+ requests chan provider.Request
19+}
20+
21+func (c *pinRecordingClient) Name() string { return "pin-test" }
22+
23+func (c *pinRecordingClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
24+ c.requests <- req
25+ out := make(chan provider.Event, 2)
26+ out <- provider.EventTextDelta{Delta: "ok"}
27+ out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
28+ Role: provider.RoleAssistant,
29+ Content: []provider.Content{provider.TextBlock{Text: "ok"}},
30+ }}
31+ close(out)
32+ return out, nil
33+}
34+
35+func lastUserText(req provider.Request) string {
36+ for n := len(req.Messages) - 1; n >= 0; n-- {
37+ if req.Messages[n].Role != provider.RoleUser {
38+ continue
39+ }
40+ return stripMessageTime(userMessageText(req.Messages[n]))
41+ }
42+ return ""
43+}
44+
45+// stripMessageTime drops the request-time "[message time: ...]" line the
46+// context builder prefixes to user messages.
47+func stripMessageTime(text string) string {
48+ if strings.HasPrefix(text, "[message time:") {
49+ if _, rest, ok := strings.Cut(text, "\n"); ok {
50+ return rest
51+ }
52+ }
53+ return text
54+}
55+
56+type pinTurnHarness struct {
57+ i *Interactive
58+ client *pinRecordingClient
59+ mu sync.Mutex
60+ persisted []provider.Message
61+}
62+
63+func newPinTurnHarness(t *testing.T, pinned bool) *pinTurnHarness {
64+ t.Helper()
65+ h := &pinTurnHarness{client: &pinRecordingClient{requests: make(chan provider.Request, 8)}}
66+ ag := core.NewAgent(h.client, "test-model", "", nil)
67+ ag.OnMessageAppended = func(m provider.Message) {
68+ h.mu.Lock()
69+ h.persisted = append(h.persisted, m)
70+ h.mu.Unlock()
71+ }
72+ h.i = NewInteractive(InteractiveConfig{
73+ Agent: ag,
74+ Provider: "anthropic",
75+ Model: "claude-sonnet-4-5-20250929",
76+ CWD: "/work",
77+ SkillSnapshot: func() []*skills.Skill {
78+ return []*skills.Skill{{Name: "review", Body: "Inspect carefully."}}
79+ },
80+ LoadSkillPins: func(string) (skills.Pins, error) {
81+ return skills.Pins{Project: []string{"review"}}, nil
82+ },
83+ PreloadPinnedSkills: pinned,
84+ })
85+ h.i.runCtx = context.Background()
86+ return h
87+}
88+
89+// prompt submits text as genuine user input, the way the host paths do.
90+func (h *pinTurnHarness) prompt(t *testing.T, text string) provider.Request {
91+ t.Helper()
92+ h.i.submitOrQueueMessage(core.QueuedMessage{Text: text}, true)
93+ req := receiveRequest(t, h.client.requests)
94+ waitInteractiveIdle(t, h.i)
95+ return req
96+}
97+
98+func (h *pinTurnHarness) persistedUserTexts() []string {
99+ h.mu.Lock()
100+ defer h.mu.Unlock()
101+ var out []string
102+ for _, m := range h.persisted {
103+ if m.Role == provider.RoleUser {
104+ out = append(out, userMessageText(m))
105+ }
106+ }
107+ return out
108+}
109+
110+func TestPinnedSkillsJoinFirstPromptOnceAndPersist(t *testing.T) {
111+ h := newPinTurnHarness(t, true)
112+ first := lastUserText(h.prompt(t, "first request"))
113+ if strings.Count(first, "Inspect carefully.") != 1 || !strings.HasSuffix(first, "first request") {
114+ t.Fatalf("first prompt = %q", first)
115+ }
116+ if second := lastUserText(h.prompt(t, "second request")); second != "second request" {
117+ t.Fatalf("second prompt carried pins: %q", second)
118+ }
119+ texts := h.persistedUserTexts()
120+ if len(texts) != 2 || strings.Count(strings.Join(texts, "\n"), "Inspect carefully.") != 1 || !strings.Contains(texts[0], "Inspect carefully.") {
121+ t.Fatalf("persisted transcript = %q", texts)
122+ }
123+ // The notice disappears once consumed.
124+ h.i.mu.Lock()
125+ notice := h.i.skillPins.notice(h.i.cfg.Theme, 80)
126+ h.i.mu.Unlock()
127+ if len(notice) != 0 {
128+ t.Fatal("notice survived consumption")
129+ }
130+}
131+
132+func TestPinnedSkillsAbsentWithoutArming(t *testing.T) {
133+ for name, arm := range map[string]func(*pinTurnHarness){
134+ "not configured": func(*pinTurnHarness) {},
135+ "resumed": func(h *pinTurnHarness) {
136+ h.i.SetPinnedSkillsPending(true)
137+ h.i.SetPinnedSkillsPending(false)
138+ },
139+ "session applied": func(h *pinTurnHarness) {
140+ h.i.SetPinnedSkillsPending(true)
141+ h.i.ApplySessionAgent(core.NewAgent(h.client, "test-model", "", nil), "anthropic", "claude-sonnet-4-5-20250929")
142+ },
143+ } {
144+ t.Run(name, func(t *testing.T) {
145+ h := newPinTurnHarness(t, false)
146+ arm(h)
147+ if got := lastUserText(h.prompt(t, "hello")); got != "hello" {
148+ t.Fatalf("prompt = %q", got)
149+ }
150+ })
151+ }
152+}
153+
154+const pinBody = "Inspect carefully."
155+
156+func (h *pinTurnHarness) pinsPending() bool {
157+ h.i.mu.Lock()
158+ defer h.i.mu.Unlock()
159+ return h.i.skillPins.pending
160+}
161+
162+func TestPinnedSkillsIgnoreNonUserStarts(t *testing.T) {
163+ h := newPinTurnHarness(t, true)
164+
165+ // Scheduler follow-up, extension prompt and host evidence are not user
166+ // input: they must neither carry the pins nor consume them.
167+ if err := h.i.SubmitFollowUp(context.Background(), "scheduled tick"); err != nil {
168+ t.Fatal(err)
169+ }
170+ if got := lastUserText(receiveRequest(t, h.client.requests)); got != "scheduled tick" {
171+ t.Fatalf("scheduled prompt = %q", got)
172+ }
173+ waitInteractiveIdle(t, h.i)
174+
175+ h.i.startTurn(context.Background(), "extension prompt") // invokeExtensionCommand "prompt" action
176+ if got := lastUserText(receiveRequest(t, h.client.requests)); got != "extension prompt" {
177+ t.Fatalf("extension prompt = %q", got)
178+ }
179+ waitInteractiveIdle(t, h.i)
180+
181+ h.i.submitOrQueueMessage(core.QueuedMessage{Text: "worker report", HostEvent: true}, false)
182+ if got := lastUserText(receiveRequest(t, h.client.requests)); got != "worker report" {
183+ t.Fatalf("host event = %q", got)
184+ }
185+ waitInteractiveIdle(t, h.i)
186+
187+ if !h.pinsPending() {
188+ t.Fatal("non-user starts consumed the pins")
189+ }
190+ if first := lastUserText(h.prompt(t, "real prompt")); strings.Count(first, pinBody) != 1 {
191+ t.Fatalf("first genuine user prompt = %q", first)
192+ }
193+}
194+
195+func TestPinnedSkillsKeepContinuationsAndRetriesPending(t *testing.T) {
196+ h := newPinTurnHarness(t, true)
197+ // Real continuation and post-compaction/overflow retry entry points.
198+ h.i.agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "earlier"}}}})
199+ h.i.startTurnRequest(context.Background(), "", nil, true, false)
200+ receiveRequest(t, h.client.requests)
201+ waitInteractiveIdle(t, h.i)
202+ h.i.startTurnRequest(context.Background(), "", nil, true, true)
203+ receiveRequest(t, h.client.requests)
204+ waitInteractiveIdle(t, h.i)
205+ if !h.pinsPending() {
206+ t.Fatal("continuation or retry consumed the pins")
207+ }
208+}
209+
210+func TestPinnedSkillsModelSwapAndClear(t *testing.T) {
211+ h := newPinTurnHarness(t, true)
212+ h.i.swapModel("anthropic", "claude-opus-4-1-20250805", nil, false)
213+ if !h.pinsPending() {
214+ t.Fatal("model swap consumed the pins")
215+ }
216+ if first := lastUserText(h.prompt(t, "real prompt")); strings.Count(first, pinBody) != 1 {
217+ t.Fatalf("pins lost across model swap: %q", first)
218+ }
219+ h.i.swapModel("anthropic", "claude-sonnet-4-5-20250929", nil, false)
220+ if later := lastUserText(h.prompt(t, "later")); later != "later" {
221+ t.Fatalf("model swap re-armed pins: %q", later)
222+ }
223+ h.i.runSlash(context.Background(), "/clear")
224+ if again := lastUserText(h.prompt(t, "after clear")); strings.Count(again, pinBody) != 1 {
225+ t.Fatalf("/clear did not re-arm pins: %q", again)
226+ }
227+}
228+
229+// A prompt typed while a startup command still owns the agent is queued, not
230+// started. It is the first genuine user prompt and must carry the pins once.
231+func TestPinnedSkillsQueuedDuringStartupPreReachProvider(t *testing.T) {
232+ h := newPinTurnHarness(t, true)
233+ h.i.mu.Lock()
234+ h.i.busy = true
235+ h.i.awaitingStartupPre = true
236+ h.i.mu.Unlock()
237+
238+ h.i.ed.SetValue("typed while pre runs")
239+ h.i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
240+ h.i.ed.SetValue("second typed")
241+ h.i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
242+
243+ queued := h.i.agent.PendingQueuedMessages()
244+ if len(queued) != 2 || strings.Count(queued[0].Text, pinBody) != 1 || !strings.HasSuffix(queued[0].Text, "typed while pre runs") || queued[1].Text != "second typed" {
245+ t.Fatalf("queued = %+v", queued)
246+ }
247+
248+ // The startup turn ends; the agent loop delivers the queued prompts.
249+ h.i.mu.Lock()
250+ h.i.busy = false
251+ h.i.awaitingStartupPre = false
252+ h.i.mu.Unlock()
253+ h.i.startTurnRequest(context.Background(), "", nil, true, false)
254+ req := receiveRequest(t, h.client.requests)
255+ waitInteractiveIdle(t, h.i)
256+ if strings.Count(userMessagesText(req), pinBody) != 1 {
257+ t.Fatalf("provider request = %q", userMessagesText(req))
258+ }
259+ if strings.Count(strings.Join(h.persistedUserTexts(), "\n"), pinBody) != 1 {
260+ t.Fatalf("persisted = %q", h.persistedUserTexts())
261+ }
262+}
263+
264+func TestPinnedSkillsCarryOncePastPreTurnCompaction(t *testing.T) {
265+ h := newPinTurnHarness(t, true)
266+ h.i.agent.SetMessages([]provider.Message{
267+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "earlier one"}}},
268+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "reply one"}}},
269+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "earlier two"}}},
270+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "reply two"}}},
271+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "earlier three"}}},
272+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "reply three"}}},
273+ })
274+ threshold := 70
275+ h.i.cfg.AutoCompactThreshold = &threshold
276+ h.i.mu.Lock()
277+ h.i.lastCtxInput = 10_000_000
278+ h.i.mu.Unlock()
279+
280+ h.i.submitOrQueueMessage(core.QueuedMessage{Text: "big request"}, true)
281+ compaction := receiveRequest(t, h.client.requests)
282+ if strings.Contains(userMessagesText(compaction), pinBody) {
283+ t.Fatal("compaction request carried pinned skills")
284+ }
285+ h.i.mu.Lock()
286+ h.i.lastCtxInput = 0
287+ h.i.mu.Unlock()
288+ followUp := receiveRequest(t, h.client.requests)
289+ waitInteractiveIdle(t, h.i)
290+ if strings.Count(userMessagesText(followUp), pinBody) != 1 {
291+ t.Fatalf("follow-up did not carry pins exactly once: %q", userMessagesText(followUp))
292+ }
293+}
294+
295+func TestPinnedSkillsClearResetsCompactionEstimate(t *testing.T) {
296+ h := newPinTurnHarness(t, true)
297+ h.prompt(t, "before clear")
298+ h.i.mu.Lock()
299+ h.i.lastCtxInput = 10_000_000
300+ h.i.mu.Unlock()
301+ h.i.runSlash(context.Background(), "/clear")
302+ if got := lastUserText(h.prompt(t, "after clear")); strings.Count(got, pinBody) != 1 || !strings.HasSuffix(got, "after clear") {
303+ t.Fatalf("first request after clear = %q", got)
304+ }
305+}
306+
307+func TestPinnedSkillsRearmAfterFailedPreTurnCompaction(t *testing.T) {
308+ h := newPinTurnHarness(t, true)
309+ threshold := 70
310+ h.i.cfg.AutoCompactThreshold = &threshold
311+ h.i.mu.Lock()
312+ h.i.lastCtxInput = 10_000_000
313+ h.i.mu.Unlock()
314+ // An empty transcript cannot compact: the pending user prompt never reaches
315+ // the provider, and its pinned selection must be released for a retry.
316+ h.i.submitOrQueueMessage(core.QueuedMessage{Text: "undelivered request"}, true)
317+ waitInteractiveIdle(t, h.i)
318+ h.i.mu.Lock()
319+ errText := h.i.statusErr
320+ h.i.lastCtxInput = 0
321+ h.i.mu.Unlock()
322+ if !strings.Contains(errText, "nothing to compact") || !h.pinsPending() {
323+ t.Fatalf("failed compaction: status=%q pending=%v", errText, h.pinsPending())
324+ }
325+ select {
326+ case req := <-h.client.requests:
327+ t.Fatalf("failed compaction sent unexpected request: %q", userMessagesText(req))
328+ default:
329+ }
330+ if got := lastUserText(h.prompt(t, "retried request")); strings.Count(got, pinBody) != 1 || !strings.HasSuffix(got, "retried request") {
331+ t.Fatalf("retry after failed compaction = %q", got)
332+ }
333+}
334+
335+func TestPinnedSkillsRearmAfterCancelledPreTurnCompaction(t *testing.T) {
336+ client := &compactQueueClient{
337+ compactionStarted: make(chan struct{}), releaseCompaction: make(chan struct{}),
338+ followUpRequest: make(chan provider.Request, 1),
339+ }
340+ ag := core.NewAgent(client, "test-model", "", nil)
341+ ag.SetMessages([]provider.Message{
342+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "earlier context"}}},
343+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "earlier answer"}}},
344+ })
345+ threshold := 70
346+ i := NewInteractive(InteractiveConfig{
347+ Agent: ag, Provider: "anthropic", Model: "claude-sonnet-4-5-20250929", CWD: t.TempDir(),
348+ AutoCompactThreshold: &threshold, PreloadPinnedSkills: true,
349+ LoadSkillPins: func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil },
350+ SkillSnapshot: reviewSnapshot,
351+ })
352+ i.runCtx = context.Background()
353+ t.Cleanup(i.CancelTurn)
354+ i.mu.Lock()
355+ i.lastCtxInput = 150000
356+ i.mu.Unlock()
357+ i.submitOrQueueMessage(core.QueuedMessage{Text: "undelivered request"}, true)
358+ select {
359+ case <-client.compactionStarted:
360+ case <-time.After(2 * time.Second):
361+ t.Fatal("compaction did not start")
362+ }
363+ i.mu.Lock()
364+ cancel := i.cancelTurn
365+ i.mu.Unlock()
366+ if cancel == nil {
367+ t.Fatal("compaction has no cancellation")
368+ }
369+ cancel()
370+ waitInteractiveIdle(t, i)
371+ i.mu.Lock()
372+ pending := i.skillPins.pending
373+ i.lastCtxInput = 0
374+ i.mu.Unlock()
375+ if !pending {
376+ t.Fatal("cancelled compaction lost the pinned selection")
377+ }
378+ i.submitOrQueueMessage(core.QueuedMessage{Text: "retried request"}, true)
379+ select {
380+ case req := <-client.followUpRequest:
381+ if got := lastUserText(req); strings.Count(got, "Inspect carefully.") != 1 || !strings.HasSuffix(got, "retried request") {
382+ t.Fatalf("retry after cancelled compaction = %q", got)
383+ }
384+ case <-time.After(2 * time.Second):
385+ t.Fatal("retry did not reach provider")
386+ }
387+ waitInteractiveIdle(t, i)
388+}
389+
390+func userMessagesText(req provider.Request) string {
391+ var parts []string
392+ for _, m := range req.Messages {
393+ if m.Role == provider.RoleUser {
394+ parts = append(parts, userMessageText(m))
395+ }
396+ }
397+ return strings.Join(parts, "\n")
398+}
399+
400+func TestSkillsPickerPinKeysUseHostCallbacksAndDedupeWarnings(t *testing.T) {
401+ var toggled []string
402+ i := NewInteractive(InteractiveConfig{
403+ CWD: "/work",
404+ SkillSnapshot: reviewSnapshot,
405+ LoadSkillPins: func(string) (skills.Pins, error) {
406+ return skills.Pins{Global: []string{"gone"}}, nil
407+ },
408+ ToggleSkillPin: func(cwd, name string, global bool) error {
409+ toggled = append(toggled, cwd+":"+name)
410+ return nil
411+ },
412+ PreloadPinnedSkills: true,
413+ })
414+ i.openSkillsDialog()
415+ i.openSkillsDialog()
416+ i.handleKey(context.Background(), pinKey('p'))
417+ if len(toggled) != 1 || toggled[0] != "/work:review" {
418+ t.Fatalf("toggle calls = %v", toggled)
419+ }
420+ i.mu.Lock()
421+ warnings := 0
422+ for _, e := range i.reloadErrors {
423+ if strings.Contains(e, "gone") {
424+ warnings++
425+ }
426+ }
427+ notice := i.statusOK
428+ i.mu.Unlock()
429+ if warnings != 1 || notice == "" {
430+ t.Fatalf("warnings=%d status=%q reloadErrors=%q", warnings, notice, i.reloadErrors)
431+ }
432+}
M packages/agent/modes/skills_dialog.go +37 -4
@@ -8,11 +8,14 @@ import (
88 "github.com/bnema/zut/packages/tui"
99 )
1010
11-// skillsDialog lists every discovered skill and lets the user view
12-// the body of one inline. View is read-only — the model loads skills
13-// itself via the `skill` tool. This dialog is for inspection.
11+// skillsDialog lists every discovered skill, shows its pin scopes, and lets
12+// the user view the body of one inline. Viewing is read-only — the model loads
13+// skills itself via the `skill` tool. Pin persistence is handled by the
14+// interactive host (see skillPinState); the dialog only renders the markers.
1415 type skillsDialog struct {
1516 active bool
17+ pins skills.Pins // last pins loaded by the host, for the [pg] markers
18+ canPin bool // host can save pins; hides markers and the hint otherwise
1619 skills []*skills.Skill
1720 cursor int
1821 viewing *skills.Skill // when non-nil, render the body instead of the list
@@ -99,6 +102,9 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
99102 }
100103
101104 out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}
105+ if d.canPin {
106+ out = append(out, " "+th.FGColor(th.Muted, "p: project pin, g: global pin (toggle)"))
107+ }
102108 if len(d.skills) == 0 {
103109 out = append(out, " "+th.FGColor(th.Muted, "no user skills loaded"))
104110 out = append(out, " "+th.FGColor(th.Muted, "add SKILL.md under $ZUT_HOME/skills, .zut/skills, .claude/skills, or .agents/skills"))
@@ -113,7 +119,8 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
113119 }
114120 for i := start; i < end; i++ {
115121 s := d.skills[i]
116- row := formatSkillRow(s, width-2)
122+ marker := d.pinMarker(s)
123+ row := marker + formatSkillRow(s, width-2-len(marker))
117124 if i == d.cursor {
118125 out = append(out, th.PadHighlight(" "+row, width))
119126 } else {
@@ -128,6 +135,32 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
128135 return out
129136 }
130137
138+// pinMarker returns "[pg] "-style scope markers, or "" when pinning is
139+// unavailable (for example a host without pin storage).
140+func (d *skillsDialog) pinMarker(s *skills.Skill) string {
141+ if !d.canPin {
142+ return ""
143+ }
144+ project, global := "-", "-"
145+ projectPinned, globalPinned := d.pins.Scopes(d.skills, s)
146+ if projectPinned {
147+ project = "p"
148+ }
149+ if globalPinned {
150+ global = "g"
151+ }
152+ return "[" + project + global + "] "
153+}
154+
155+// selectedSkill returns the skill under the list cursor, or nil in the body
156+// view or an empty list.
157+func (d *skillsDialog) selectedSkill() *skills.Skill {
158+ if !d.Active() || d.viewing != nil || d.cursor < 0 || d.cursor >= len(d.skills) {
159+ return nil
160+ }
161+ return d.skills[d.cursor]
162+}
163+
131164 func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
132165 s := d.viewing
133166 out := []string{
M packages/agent/modes/slash_suggest.go +33 -6
@@ -59,7 +59,7 @@ var slashCatalog = []slashCommand{
5959 {Name: "/llama", Desc: "manage llama.cpp router models"},
6060 {Name: "/sessions", Desc: "resume a previous session for this directory"},
6161 {Name: "/fork", Desc: "pick a user message and fork the current session"},
62- {Name: "/session", Desc: "export/import, fork, or browse the current session tree"},
62+ {Name: "/session", Desc: "inspect, export, import, fork, or browse the current session"},
6363 {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},
6464 {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},
6565 {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"},
@@ -75,6 +75,14 @@ var slashCatalog = []slashCommand{
7575 {Name: "/exit", Desc: "exit zut"},
7676 }
7777
78+var sessionSlashCommands = []slashCommand{
79+ {Name: "/session timeline", Desc: "inspect context, messages, and tool calls"},
80+ {Name: "/session export", Desc: "write the current session to a .zutsession file"},
81+ {Name: "/session import", Desc: "load a .zutsession file into this directory"},
82+ {Name: "/session fork", Desc: "branch from a past user message"},
83+ {Name: "/session tree", Desc: "switch between session branches"},
84+}
85+
7886 // slashSuggester renders the popup that appears when the editor starts
7987 // with "/". It does not own any input state — the editor drives.
8088 const slashSuggestPageSize = 8
@@ -285,15 +293,26 @@ func newSlashSuggester() *slashSuggester { return &slashSuggester{} }
285293 // matches returns the commands whose name has input as a prefix.
286294 // If input is just "/", everything is shown.
287295 func (s *slashSuggester) matches(input string) []slashCommand {
288- input = strings.TrimRight(input, " ")
289296 if input == "" || !strings.HasPrefix(input, "/") {
290297 return nil
291298 }
292- // If there is a space, the user has moved past the command name.
293- if idx := strings.IndexByte(input, ' '); idx >= 0 {
299+ matchPrefix := strings.ToLower(input)
300+ // /session exposes its actions after the first space.
301+ if strings.HasPrefix(matchPrefix, "/session ") {
302+ var out []slashCommand
303+ for _, c := range sessionSlashCommands {
304+ if strings.HasPrefix(strings.ToLower(c.Name), matchPrefix) {
305+ out = append(out, c)
306+ }
307+ }
308+ return out
309+ }
310+ // Any other command: a space means the user has moved on to its arguments.
311+ input = strings.TrimRight(input, " ")
312+ if strings.ContainsRune(input, ' ') {
294313 return nil
295314 }
296- matchPrefix := strings.ToLower(input)
315+ matchPrefix = strings.ToLower(input)
297316 catalog := s.allCatalog()
298317 if strings.HasPrefix(matchPrefix, skillCommandPrefix) {
299318 catalog = s.skills
@@ -311,7 +330,15 @@ func (s *slashSuggester) matches(input string) []slashCommand {
311330 out = append(out, c)
312331 }
313332 }
314- return pruneOrphanHeaders(out)
333+ out = pruneOrphanHeaders(out)
334+ // Prefer an exact command over longer commands sharing its prefix so
335+ // /session stays selected instead of the earlier /sessions entry.
336+ for _, c := range out {
337+ if !c.Header && strings.EqualFold(c.Name, input) {
338+ return []slashCommand{c}
339+ }
340+ }
341+ return out
315342 }
316343
317344 // pruneOrphanHeaders removes header rows that have no commands
M packages/agent/modes/slash_suggest_test.go +32 -0
@@ -64,6 +64,38 @@ func TestSlashSuggesterHasOrchestratorToggle(t *testing.T) {
6464 }
6565 }
6666
67+func TestSlashSuggesterPrefersExactSessionCommand(t *testing.T) {
68+ s := newSlashSuggester()
69+ if got := commandNames(s.matches("/session")); !slices.Equal(got, []string{"/session"}) {
70+ t.Fatalf("matches = %v, want exact /session", got)
71+ }
72+ if got := s.Selection("/session"); got != "/session" {
73+ t.Fatalf("selection = %q, want /session", got)
74+ }
75+}
76+
77+func TestSlashSuggesterCompletesSessionActions(t *testing.T) {
78+ s := newSlashSuggester()
79+ if got, want := commandNames(s.matches("/session ")), []string{
80+ "/session timeline",
81+ "/session export",
82+ "/session import",
83+ "/session fork",
84+ "/session tree",
85+ }; !slices.Equal(got, want) {
86+ t.Fatalf("matches = %v, want %v", got, want)
87+ }
88+ if got := commandNames(s.matches("/session t")); !slices.Equal(got, []string{"/session timeline", "/session tree"}) {
89+ t.Fatalf("filtered matches = %v", got)
90+ }
91+ if got := s.Selection("/session t"); got != "/session timeline" {
92+ t.Fatalf("selection = %q, want /session timeline", got)
93+ }
94+ if got := s.matches("/model x"); len(got) != 0 {
95+ t.Fatalf("arguments of other commands must not suggest: %v", got)
96+ }
97+}
98+
6799 func TestSlashCommandsAreCaseInsensitive(t *testing.T) {
68100 s := newSlashSuggester()
69101 if got := commandNames(s.matches("/EX")); !contains(got, "/exit") {
A packages/agent/modes/timeline_view.go +888 -0
@@ -0,0 +1,888 @@
1+package modes
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "hash/fnv"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "time"
11+ "unicode"
12+
13+ "github.com/mattn/go-runewidth"
14+
15+ "github.com/bnema/zut/packages/core"
16+ "github.com/bnema/zut/packages/provider"
17+ "github.com/bnema/zut/packages/tui"
18+)
19+
20+// The session timeline is a read-only inspector for the model context: the
21+// system prompt, the visible transcript, and every tool call with its result.
22+// It renders inside the shared floating pane, so the live transcript keeps
23+// updating behind it.
24+//
25+// Privacy: entries are built only from the transcript snapshot. Encrypted
26+// reasoning, provider thought signatures and image bytes are never rendered
27+// or exported, and nothing here logs.
28+
29+const timelineTabCount = 5
30+
31+var timelineTabNames = [timelineTabCount]string{"summary", "payload", "result", "schema", "timing"}
32+
33+type timelineEntry struct {
34+ Kind string `json:"kind"`
35+ Turn int `json:"turn,omitempty"`
36+ Label string `json:"label"`
37+ Summary string `json:"summary"`
38+ Time time.Time `json:"time,omitempty"`
39+ Duration string `json:"duration,omitempty"`
40+ Payload string `json:"payload,omitempty"`
41+ Result string `json:"result,omitempty"`
42+ Schema string `json:"schema,omitempty"`
43+ IsError bool `json:"is_error,omitempty"`
44+ ToolCall string `json:"tool_call_id,omitempty"`
45+}
46+
47+// timelineCacheKey identifies an unchanged snapshot: the agent, its transcript
48+// revision, and a content hash of the system prompt and tool specs (which
49+// change without bumping the transcript revision). The zero value disables
50+// caching (used by tests and callers without a live agent).
51+type timelineCacheKey struct {
52+ agent *core.Agent
53+ rev uint64
54+ prompt uint64
55+}
56+
57+// timelineEstimates are the token estimates shown in the context header. They
58+// depend only on the snapshot, so they are computed once per snapshot.
59+type timelineEstimates struct {
60+ system, tools, messages int
61+ ok bool
62+}
63+
64+type timelineData struct {
65+ System string
66+ Tools []provider.Tool
67+ Messages []provider.Message
68+ ContextUsed int
69+ ContextMax int
70+ Key timelineCacheKey
71+ Est timelineEstimates
72+}
73+
74+type timelineView struct {
75+ active bool
76+ cursor int
77+ tab int
78+ detailTop int
79+ searching bool
80+ filter string
81+ notice string
82+
83+ // snap is the last complete snapshot; it is reused while snapKey matches
84+ // so an idle redraw copies neither the transcript nor the tool specs.
85+ snapValid bool
86+ snapKey timelineCacheKey
87+ snap timelineData
88+
89+ cacheValid bool
90+ cacheKey timelineCacheKey
91+ cacheAll []timelineEntry
92+ cacheFilter string
93+ cacheList []timelineEntry
94+ cacheListOK bool
95+}
96+
97+type timelineAction struct {
98+ Export bool
99+ Close bool
100+}
101+
102+func newTimelineView() *timelineView { return &timelineView{} }
103+
104+func (v *timelineView) Active() bool { return v != nil && v.active }
105+
106+// entries returns the (optionally filtered) timeline rows for data, reusing
107+// the previous build while the transcript revision is unchanged.
108+func (v *timelineView) entries(data timelineData) []timelineEntry {
109+ if data.Key == (timelineCacheKey{}) || !v.cacheValid || v.cacheKey != data.Key {
110+ v.cacheAll = buildTimelineEntries(data)
111+ v.cacheKey = data.Key
112+ v.cacheValid = data.Key != (timelineCacheKey{})
113+ v.cacheListOK = false
114+ }
115+ if !v.cacheListOK || v.cacheFilter != v.filter {
116+ v.cacheList = filterTimelineEntries(v.cacheAll, v.filter)
117+ v.cacheFilter = v.filter
118+ v.cacheListOK = true
119+ }
120+ return v.cacheList
121+}
122+
123+func (v *timelineView) Open(data timelineData) {
124+ v.active = true
125+ v.searching = false
126+ v.filter = ""
127+ v.notice = ""
128+ v.tab = 0
129+ v.detailTop = 0
130+ v.cacheValid = false
131+ v.cursor = len(v.entries(data)) - 1
132+ if v.cursor < 0 {
133+ v.cursor = 0
134+ }
135+}
136+
137+func (v *timelineView) Close() {
138+ v.active = false
139+ v.searching = false
140+ v.filter = ""
141+ v.notice = ""
142+ v.detailTop = 0
143+ v.cacheValid = false
144+ v.cacheAll, v.cacheList = nil, nil
145+ v.cacheListOK = false
146+ v.snapValid = false
147+ v.snap = timelineData{}
148+}
149+
150+func (v *timelineView) SetNotice(s string) { v.notice = s }
151+
152+func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
153+ entries := v.entries(data)
154+ clampTimelineCursor(v, len(entries))
155+
156+ previousCursor, previousTab := v.cursor, v.tab
157+ switch k.Kind {
158+ case tui.KeyLeft:
159+ if v.detailTop > 0 {
160+ v.detailTop--
161+ }
162+ case tui.KeyRight:
163+ v.detailTop++
164+ case tui.KeyHome:
165+ v.detailTop = 0
166+ case tui.KeyEnd:
167+ v.detailTop = int(^uint(0) >> 1)
168+ case tui.KeyUp:
169+ if v.cursor > 0 {
170+ v.cursor--
171+ }
172+ case tui.KeyDown:
173+ if v.cursor < len(entries)-1 {
174+ v.cursor++
175+ }
176+ case tui.KeyPageUp:
177+ v.cursor -= 8
178+ if v.cursor < 0 {
179+ v.cursor = 0
180+ }
181+ case tui.KeyPageDown:
182+ v.cursor += 8
183+ clampTimelineCursor(v, len(entries))
184+ case tui.KeyTab:
185+ v.tab = (v.tab + 1) % timelineTabCount
186+ case tui.KeyShiftTab:
187+ v.tab = (v.tab + timelineTabCount - 1) % timelineTabCount
188+ case tui.KeyCtrlE:
189+ return timelineAction{Export: true}
190+ case tui.KeyBackspace:
191+ if v.searching && v.filter != "" {
192+ r := []rune(v.filter)
193+ v.filter = string(r[:len(r)-1])
194+ v.cursor = 0
195+ v.detailTop = 0
196+ }
197+ case tui.KeyPaste:
198+ if v.searching {
199+ v.filter += sanitizeTimelineLine(singleLinePaste(k.Paste))
200+ v.cursor = 0
201+ v.detailTop = 0
202+ }
203+ case tui.KeyRune:
204+ if !v.searching && k.Rune == '/' {
205+ v.searching = true
206+ v.notice = ""
207+ } else if v.searching && !k.Ctrl && !k.Alt && !k.Super && !unicode.IsControl(k.Rune) {
208+ v.filter += string(k.Rune)
209+ v.cursor = 0
210+ v.detailTop = 0
211+ }
212+ case tui.KeyCtrlC:
213+ // Ctrl+C always leaves the view, like every other floating dialog.
214+ v.Close()
215+ return timelineAction{Close: true}
216+ case tui.KeyEsc:
217+ if v.searching {
218+ if v.filter != "" {
219+ v.filter = ""
220+ v.cursor = 0
221+ v.detailTop = 0
222+ } else {
223+ v.searching = false
224+ }
225+ break
226+ }
227+ v.Close()
228+ return timelineAction{Close: true}
229+ }
230+ if v.cursor != previousCursor || v.tab != previousTab {
231+ v.detailTop = 0
232+ }
233+ return timelineAction{}
234+}
235+
236+func clampTimelineCursor(v *timelineView, n int) {
237+ if n == 0 {
238+ v.cursor = 0
239+ return
240+ }
241+ if v.cursor >= n {
242+ v.cursor = n - 1
243+ }
244+ if v.cursor < 0 {
245+ v.cursor = 0
246+ }
247+}
248+
249+// Render draws the inspector for a pane content area of width x height. The
250+// first line is a frame header and the last a frame rule so the floating pane
251+// can lift the header into its border like other dialogs. After padDialogFrame
252+// and floatingDialogBody, the body never exceeds height rows: optional rows
253+// (hint, context, tabs, notice) are dropped before the list or detail shrink,
254+// down to a single list row.
255+func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData) []string {
256+ if !v.Active() {
257+ return nil
258+ }
259+ if width < 1 {
260+ width = 1
261+ }
262+ entries := v.entries(data)
263+ clampTimelineCursor(v, len(entries))
264+
265+ // padDialogFrame may add one blank row after the header and one before
266+ // the rule; both stay in the pane body.
267+ avail := max(0, height-2)
268+ noticeRows := 0
269+ if v.notice != "" && avail >= 4 {
270+ noticeRows = 1
271+ }
272+ avail -= noticeRows
273+ tabRows, hintRows, contextRows := 0, 0, 0
274+ if avail >= 3 {
275+ tabRows = 1
276+ extra := avail - 3
277+ hintRows = min(1, extra)
278+ extra -= hintRows
279+ contextRows = min(2, extra)
280+ }
281+ rest := avail - tabRows - hintRows - contextRows
282+ listRows := min(12, max(min(rest, 1), rest/3))
283+ detailRows := max(0, rest-listRows)
284+
285+ lines := []string{frameHeader(th, "timeline", width)}
286+ if contextRows > 0 {
287+ lines = append(lines, renderTimelineContext(th, width, data)[:contextRows]...)
288+ }
289+ if hintRows > 0 {
290+ hint := "↑/↓ select tab details ←/→ scroll detail home/end / search ctrl+e export esc close"
291+ if v.searching {
292+ hint = "search: " + sanitizeTimelineLine(v.filter) + "_ (esc clears)"
293+ }
294+ lines = append(lines, th.FGColor(th.Muted, fitTimelineLine(hint, width)))
295+ }
296+ if listRows > 0 {
297+ if len(entries) == 0 {
298+ lines = append(lines, th.FGColor(th.Muted, fitTimelineLine(" no matching events", width)))
299+ } else {
300+ start := v.cursor - listRows/2
301+ if start < 0 {
302+ start = 0
303+ }
304+ end := start + listRows
305+ if end > len(entries) {
306+ end = len(entries)
307+ start = max(0, end-listRows)
308+ }
309+ for idx := start; idx < end; idx++ {
310+ line := formatTimelineRow(entries[idx], width)
311+ if idx == v.cursor {
312+ lines = append(lines, th.PadHighlight(line, width))
313+ } else {
314+ lines = append(lines, timelineColor(th, entries[idx], line))
315+ }
316+ }
317+ }
318+ }
319+ if tabRows > 0 {
320+ lines = append(lines, renderTimelineTabs(th, width, v.tab))
321+ }
322+ if detailRows > 0 && len(entries) > 0 {
323+ detail := timelineDetailRows(th, width, entries[v.cursor], v.tab)
324+ maxTop := max(0, len(detail)-detailRows)
325+ if v.detailTop > maxTop {
326+ v.detailTop = maxTop
327+ }
328+ lines = append(lines, detail[v.detailTop:min(v.detailTop+detailRows, len(detail))]...)
329+ }
330+ if noticeRows > 0 {
331+ lines = append(lines, th.FGColor(th.Tool, fitTimelineLine(sanitizeTimelineLine(v.notice), width)))
332+ }
333+ lines = append(lines, frameRule(th, width))
334+ return lines
335+}
336+
337+// sanitizeTimelineLine makes untrusted transcript text one safe terminal row:
338+// escape sequences (CSI, OSC), control characters, tabs and CR/LF are removed
339+// or replaced before the text is measured, truncated or wrapped.
340+func sanitizeTimelineLine(s string) string {
341+ return sanitizeSessionTreeText(s)
342+}
343+
344+// sanitizeTimelineBlock sanitizes multi-line text row by row, keeping line
345+// breaks (LF, CRLF and lone CR) and each row's leading indentation.
346+func sanitizeTimelineBlock(s string) string {
347+ s = strings.ReplaceAll(s, "\r\n", "\n")
348+ s = strings.ReplaceAll(s, "\r", "\n")
349+ rows := strings.Split(s, "\n")
350+ for n, row := range rows {
351+ indent := 0
352+ indentation:
353+ for _, r := range row {
354+ switch r {
355+ case ' ':
356+ indent++
357+ case '\t':
358+ indent += 4
359+ default:
360+ break indentation
361+ }
362+ }
363+ rows[n] = strings.Repeat(" ", indent) + sanitizeSessionTreeText(row)
364+ }
365+ return strings.Join(rows, "\n")
366+}
367+
368+// buildTimelineEntries turns a transcript snapshot into ordered rows. Each
369+// tool call is paired with the next unclaimed result carrying its ID, so an ID
370+// a provider reuses across turns keeps every call attached to its own result.
371+func buildTimelineEntries(data timelineData) []timelineEntry {
372+ toolSchemas := make(map[string]string, len(data.Tools))
373+ for _, tool := range data.Tools {
374+ toolSchemas[tool.Name] = prettyTimelineJSON(tool.Schema)
375+ }
376+
377+ entries := make([]timelineEntry, 0, len(data.Messages)+1)
378+ if strings.TrimSpace(data.System) != "" {
379+ entries = append(entries, timelineEntry{
380+ Kind: "system", Label: "SYSTEM", Summary: firstTimelineLine(data.System), Payload: data.System,
381+ })
382+ }
383+ // pending maps a call ID to the FIFO of entry indexes still awaiting a result.
384+ pending := map[string][]int{}
385+ callTimes := map[int]time.Time{}
386+ turn := 0
387+ var previousTime time.Time
388+ for _, message := range data.Messages {
389+ if message.Role == provider.RoleUser {
390+ turn++
391+ }
392+ for _, content := range message.Content {
393+ if block, ok := content.(provider.ReasoningBlock); ok && strings.TrimSpace(block.Summary) != "" {
394+ entries = append(entries, timelineEntry{
395+ Kind: "reasoning", Turn: turn, Label: "REASONING", Summary: firstTimelineLine(block.Summary),
396+ Time: message.Time, Payload: block.Summary,
397+ })
398+ }
399+ }
400+ textParts := timelineMessageText(message)
401+ if len(textParts) > 0 {
402+ entry := timelineEntry{
403+ Kind: string(message.Role), Turn: turn, Label: strings.ToUpper(string(message.Role)),
404+ Summary: firstTimelineLine(strings.Join(textParts, "\n")), Time: message.Time,
405+ Payload: prettyTimelineValue(timelineMessagePayload(message)),
406+ }
407+ if !previousTime.IsZero() && !message.Time.IsZero() && message.Time.After(previousTime) {
408+ entry.Duration = message.Time.Sub(previousTime).Round(time.Millisecond).String()
409+ }
410+ entries = append(entries, entry)
411+ if !message.Time.IsZero() {
412+ previousTime = message.Time
413+ }
414+ }
415+ for _, content := range message.Content {
416+ switch block := content.(type) {
417+ case provider.ToolCallBlock:
418+ entries = append(entries, timelineEntry{
419+ Kind: "tool", Turn: turn, Label: "TOOL " + block.Name, Summary: summarizeToolArguments(block.Arguments),
420+ Time: message.Time, Payload: prettyTimelineJSON(block.Arguments), Schema: toolSchemas[block.Name], ToolCall: block.ID,
421+ })
422+ idx := len(entries) - 1
423+ pending[block.ID] = append(pending[block.ID], idx)
424+ callTimes[idx] = message.Time
425+ case provider.ToolResultBlock:
426+ queue := pending[block.CallID]
427+ result := prettyTimelineValue(timelineResultPayload(block))
428+ if len(queue) == 0 {
429+ // A result with no open call (for example after a
430+ // compaction boundary) stays visible, marked unpaired.
431+ entries = append(entries, timelineEntry{
432+ Kind: "tool", Turn: turn, Label: "TOOL RESULT", Summary: "(unpaired result)",
433+ Time: message.Time, Result: result, IsError: block.IsError, ToolCall: block.CallID,
434+ })
435+ continue
436+ }
437+ idx := queue[0]
438+ pending[block.CallID] = queue[1:]
439+ entries[idx].Result = result
440+ entries[idx].IsError = block.IsError
441+ if started := callTimes[idx]; !started.IsZero() && message.Time.After(started) {
442+ entries[idx].Duration = message.Time.Sub(started).Round(time.Millisecond).String()
443+ }
444+ }
445+ }
446+ }
447+ return entries
448+}
449+
450+func timelineMessageText(message provider.Message) []string {
451+ var out []string
452+ for _, content := range message.Content {
453+ switch block := content.(type) {
454+ case provider.TextBlock:
455+ if strings.TrimSpace(block.Text) != "" {
456+ out = append(out, block.Text)
457+ }
458+ case provider.ImageBlock:
459+ out = append(out, fmt.Sprintf("[image %s, %d bytes]", block.MimeType, len(block.Data)))
460+ }
461+ }
462+ return out
463+}
464+
465+// timelineMessagePayload describes a message without provider-private data:
466+// reasoning shows only its readable summary (never the encrypted content), text
467+// omits thought signatures, and images show only type and size.
468+func timelineMessagePayload(message provider.Message) any {
469+ blocks := make([]any, 0, len(message.Content))
470+ for _, content := range message.Content {
471+ switch block := content.(type) {
472+ case provider.TextBlock:
473+ blocks = append(blocks, map[string]any{"type": "text", "text": block.Text})
474+ case provider.ReasoningBlock:
475+ blocks = append(blocks, map[string]any{"type": "reasoning", "summary": block.Summary})
476+ case provider.ImageBlock:
477+ blocks = append(blocks, map[string]any{"type": "image", "mime_type": block.MimeType, "bytes": len(block.Data)})
478+ }
479+ }
480+ return map[string]any{"role": message.Role, "content": blocks, "meta": message.Meta}
481+}
482+
483+func timelineResultPayload(result provider.ToolResultBlock) any {
484+ parts := make([]any, 0, len(result.Content))
485+ for _, content := range result.Content {
486+ switch block := content.(type) {
487+ case provider.TextBlock:
488+ parts = append(parts, map[string]any{"type": "text", "text": block.Text})
489+ case provider.ImageBlock:
490+ parts = append(parts, map[string]any{"type": "image", "mime_type": block.MimeType, "bytes": len(block.Data)})
491+ default:
492+ parts = append(parts, fmt.Sprintf("%T", content))
493+ }
494+ }
495+ return map[string]any{"content": parts, "is_error": result.IsError}
496+}
497+
498+func filterTimelineEntries(entries []timelineEntry, filter string) []timelineEntry {
499+ filter = strings.ToLower(strings.TrimSpace(filter))
500+ if filter == "" {
501+ return entries
502+ }
503+ out := make([]timelineEntry, 0, len(entries))
504+ for _, entry := range entries {
505+ haystack := strings.ToLower(strings.Join([]string{entry.Label, entry.Summary, entry.Payload, entry.Result}, "\n"))
506+ if strings.Contains(haystack, filter) {
507+ out = append(out, entry)
508+ }
509+ }
510+ return out
511+}
512+
513+func renderTimelineContext(th tui.Theme, width int, data timelineData) []string {
514+ est := data.Est
515+ if !est.ok {
516+ est = computeTimelineEstimates(data.System, data.Tools, data.Messages)
517+ }
518+ system, tools, messages := est.system, est.tools, est.messages
519+
520+ usage := fmt.Sprintf("context %s", compactTimelineNumber(data.ContextUsed))
521+ if data.ContextMax > 0 {
522+ pct := float64(data.ContextUsed) * 100 / float64(data.ContextMax)
523+ usage = fmt.Sprintf("context %s / %s (%.1f%%)", compactTimelineNumber(data.ContextUsed), compactTimelineNumber(data.ContextMax), pct)
524+ }
525+ composition := fmt.Sprintf("estimated composition: system %s tools %s messages %s", compactTimelineNumber(system), compactTimelineNumber(tools), compactTimelineNumber(messages))
526+ return []string{tui.Bold(fitTimelineLine(usage, width)), th.FGColor(th.Muted, fitTimelineLine(composition, width))}
527+}
528+
529+func computeTimelineEstimates(system string, tools []provider.Tool, messages []provider.Message) timelineEstimates {
530+ toolBytes, _ := json.Marshal(tools)
531+ return timelineEstimates{
532+ system: estimateTimelineTokens(system),
533+ tools: estimateTimelineTokens(string(toolBytes)),
534+ messages: estimateTimelineMessageTokens(messages),
535+ ok: true,
536+ }
537+}
538+
539+func formatTimelineRow(entry timelineEntry, width int) string {
540+ stamp := " "
541+ if !entry.Time.IsZero() {
542+ stamp = entry.Time.Format("15:04:05")
543+ }
544+ turn := " "
545+ if entry.Turn > 0 {
546+ turn = fmt.Sprintf("%3d", entry.Turn)
547+ }
548+ line := fmt.Sprintf("%s %s %-16s %s", stamp, turn, sanitizeTimelineLine(entry.Label), sanitizeTimelineLine(entry.Summary))
549+ return fitTimelineLine(line, width)
550+}
551+
552+func timelineColor(th tui.Theme, entry timelineEntry, line string) string {
553+ color := th.Muted
554+ switch entry.Kind {
555+ case "user":
556+ color = th.User
557+ case "assistant", "reasoning":
558+ color = th.Assistant
559+ case "tool":
560+ color = th.Tool
561+ case "system":
562+ color = th.Warning
563+ }
564+ if entry.IsError {
565+ color = th.Error
566+ }
567+ return th.FGColor(color, line)
568+}
569+
570+func renderTimelineTabs(th tui.Theme, width, selected int) string {
571+ if selected < 0 || selected >= len(timelineTabNames) || width < 1 {
572+ return ""
573+ }
574+ start, end := selected, selected+1
575+ for start > 0 || end < len(timelineTabNames) {
576+ grew := false
577+ if start > 0 && timelineTabsWidth(start-1, end) <= width {
578+ start--
579+ grew = true
580+ }
581+ if end < len(timelineTabNames) && timelineTabsWidth(start, end+1) <= width {
582+ end++
583+ grew = true
584+ }
585+ if !grew {
586+ break
587+ }
588+ }
589+
590+ tabs := make([]string, 0, end-start)
591+ for idx := start; idx < end; idx++ {
592+ name := timelineTabNames[idx]
593+ if idx == selected {
594+ tabs = append(tabs, tui.Bold(th.FGColor(th.Accent, "["+name+"]")))
595+ } else {
596+ tabs = append(tabs, th.FGColor(th.Muted, " "+name+" "))
597+ }
598+ }
599+ return strings.Join(tabs, " ")
600+}
601+
602+func timelineTabsWidth(start, end int) int {
603+ width := 2 * (end - start - 1)
604+ for idx := start; idx < end; idx++ {
605+ width += runewidth.StringWidth(timelineTabNames[idx]) + 2
606+ }
607+ return width
608+}
609+
610+func timelineDetailRows(th tui.Theme, width int, entry timelineEntry, tab int) []string {
611+ var detail string
612+ switch tab {
613+ case 0:
614+ detail = fmt.Sprintf("type: %s\nturn: %d\nstatus: %s\nsummary: %s", entry.Kind, entry.Turn, timelineStatus(entry), entry.Summary)
615+ case 1:
616+ detail = entry.Payload
617+ case 2:
618+ detail = entry.Result
619+ case 3:
620+ detail = entry.Schema
621+ case 4:
622+ started := "not recorded"
623+ if !entry.Time.IsZero() {
624+ started = entry.Time.Format(time.RFC3339Nano)
625+ }
626+ duration := entry.Duration
627+ if duration == "" {
628+ duration = "not recorded"
629+ }
630+ detail = "started: " + started + "\nduration: " + duration + "\nsource: transcript timestamps"
631+ }
632+ detail = sanitizeTimelineBlock(detail)
633+ if strings.TrimSpace(detail) == "" {
634+ detail = "not available for this event"
635+ }
636+ if entry.Kind == "reasoning" && (tab == 0 || tab == 1) {
637+ return renderDialogMarkdownRows(sanitizeTimelineBlock(entry.Payload), th, width)
638+ }
639+ var rows []string
640+ for _, line := range strings.Split(detail, "\n") {
641+ for _, wrapped := range tui.WrapANSILine(line, max(1, width-2)) {
642+ rows = append(rows, " "+th.FGColor(th.ToolOut, wrapped))
643+ }
644+ }
645+ return rows
646+}
647+
648+func timelineStatus(entry timelineEntry) string {
649+ if entry.IsError {
650+ return "error"
651+ }
652+ if entry.Kind == "tool" && entry.Result == "" {
653+ return "result unavailable"
654+ }
655+ return "completed"
656+}
657+
658+// Export writes the timeline as JSON into dir (the default export directory
659+// when empty). The file is created exclusively with owner-only permissions so
660+// an existing file or symlink is never overwritten or widened.
661+func (v *timelineView) Export(dir string, data timelineData) (string, error) {
662+ if dir == "" {
663+ dir = defaultExportDir()
664+ }
665+ if err := os.MkdirAll(dir, 0o700); err != nil {
666+ return "", fmt.Errorf("create export directory: %w", err)
667+ }
668+ payload := struct {
669+ ExportedAt time.Time `json:"exported_at"`
670+ ContextUsed int `json:"context_used"`
671+ ContextMax int `json:"context_max"`
672+ Events []timelineEntry `json:"events"`
673+ }{time.Now(), data.ContextUsed, data.ContextMax, buildTimelineEntries(data)}
674+ encoded, err := json.MarshalIndent(payload, "", " ")
675+ if err != nil {
676+ return "", fmt.Errorf("encode timeline: %w", err)
677+ }
678+ name := "zut-timeline-" + time.Now().Format("20060102-150405.000000000") + ".json"
679+ path := filepath.Join(dir, name)
680+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
681+ if err != nil {
682+ return "", fmt.Errorf("write timeline: %w", err)
683+ }
684+ if _, err := file.Write(append(encoded, '\n')); err != nil {
685+ _ = file.Close()
686+ _ = os.Remove(path)
687+ return "", fmt.Errorf("write timeline: %w", err)
688+ }
689+ if err := file.Close(); err != nil {
690+ _ = os.Remove(path)
691+ return "", fmt.Errorf("write timeline: %w", err)
692+ }
693+ return path, nil
694+}
695+
696+func firstTimelineLine(s string) string {
697+ for _, line := range strings.Split(s, "\n") {
698+ if line = strings.TrimSpace(line); line != "" {
699+ return fitTimelineLine(line, 120)
700+ }
701+ }
702+ return "(empty)"
703+}
704+
705+func summarizeToolArguments(raw json.RawMessage) string {
706+ var object map[string]any
707+ if json.Unmarshal(raw, &object) == nil {
708+ for _, key := range []string{"command", "path", "query", "pattern"} {
709+ if value, ok := object[key]; ok {
710+ return firstTimelineLine(fmt.Sprint(value))
711+ }
712+ }
713+ }
714+ return firstTimelineLine(string(raw))
715+}
716+
717+func prettyTimelineJSON(raw json.RawMessage) string {
718+ if len(raw) == 0 {
719+ return ""
720+ }
721+ var value any
722+ if json.Unmarshal(raw, &value) != nil {
723+ return string(raw)
724+ }
725+ return prettyTimelineValue(value)
726+}
727+
728+func prettyTimelineValue(value any) string {
729+ encoded, err := json.MarshalIndent(value, "", " ")
730+ if err != nil {
731+ return fmt.Sprint(value)
732+ }
733+ return string(encoded)
734+}
735+
736+func estimateTimelineTokens(s string) int {
737+ if s == "" {
738+ return 0
739+ }
740+ return (len([]byte(s)) + 3) / 4
741+}
742+
743+func estimateTimelineMessageTokens(messages []provider.Message) int {
744+ bytes := 0
745+ for _, message := range messages {
746+ bytes += len(message.Role) + 12
747+ for _, content := range message.Content {
748+ switch block := content.(type) {
749+ case provider.TextBlock:
750+ bytes += len(block.Text) + len(block.ThoughtSignature)
751+ case provider.ReasoningBlock:
752+ // Sizes only: the encrypted payload is counted toward the
753+ // estimate but never displayed.
754+ bytes += len(block.Summary) + len(block.Encrypted)
755+ case provider.ImageBlock:
756+ // JSON transports image bytes as base64, which expands by roughly 4/3.
757+ bytes += (len(block.Data)*4 + 2) / 3
758+ case provider.ToolCallBlock:
759+ bytes += len(block.ID) + len(block.Name) + len(block.Arguments)
760+ case provider.ToolResultBlock:
761+ bytes += len(block.CallID) + estimateTimelineResultBytes(block.Content)
762+ }
763+ }
764+ }
765+ return (bytes + 3) / 4
766+}
767+
768+func estimateTimelineResultBytes(contents []provider.Content) int {
769+ bytes := 0
770+ for _, content := range contents {
771+ switch block := content.(type) {
772+ case provider.TextBlock:
773+ bytes += len(block.Text)
774+ case provider.ImageBlock:
775+ bytes += (len(block.Data)*4 + 2) / 3
776+ }
777+ }
778+ return bytes
779+}
780+
781+func compactTimelineNumber(n int) string {
782+ if n >= 1_000_000 {
783+ return fmt.Sprintf("%.1fm", float64(n)/1_000_000)
784+ }
785+ if n >= 1_000 {
786+ return fmt.Sprintf("%.1fk", float64(n)/1_000)
787+ }
788+ return fmt.Sprintf("%d", n)
789+}
790+
791+func fitTimelineLine(s string, width int) string {
792+ if width < 1 {
793+ return ""
794+ }
795+ if runewidth.StringWidth(s) <= width {
796+ return s
797+ }
798+ return runewidth.Truncate(s, width, "...")
799+}
800+
801+// timelineDataLocked returns the timeline snapshot for the live agent using
802+// its locked accessors. Host-internal continuation prompts are dropped exactly
803+// as the chat transcript drops them. The transcript and tool specs are only
804+// copied when the transcript revision or the system/tool content hash changed;
805+// otherwise the cached snapshot (and its token estimates) is reused. The
806+// caller must hold i.mu.
807+func (i *Interactive) timelineDataLocked() timelineData {
808+ if i.timeline == nil {
809+ i.timeline = newTimelineView()
810+ }
811+ v := i.timeline
812+ var data timelineData
813+ if i.agent != nil {
814+ system, registry := i.agent.PromptConfig()
815+ specs := registry.Specs()
816+ // Read the revision before the messages: a concurrent append can only
817+ // make the cached snapshot look stale, never newer than it is.
818+ key := timelineCacheKey{agent: i.agent, rev: i.agent.Revision(), prompt: timelinePromptHash(system, specs)}
819+ if v.snapValid && v.snapKey == key {
820+ data = v.snap
821+ } else {
822+ data = timelineData{System: system, Tools: specs, Key: key}
823+ data.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
824+ data.Est = computeTimelineEstimates(system, specs, data.Messages)
825+ v.snapKey, v.snap, v.snapValid = key, data, true
826+ }
827+ }
828+ data.ContextUsed = i.lastCtxInput
829+ if model, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
830+ data.ContextMax = model.ContextWindow
831+ }
832+ return data
833+}
834+
835+// timelinePromptHash fingerprints the system prompt and tool specs by content,
836+// so a same-length edit to either still invalidates the cached snapshot.
837+func timelinePromptHash(system string, tools []provider.Tool) uint64 {
838+ h := fnv.New64a()
839+ write := func(s string) {
840+ _, _ = h.Write([]byte(s))
841+ _, _ = h.Write([]byte{0})
842+ }
843+ write(system)
844+ for _, tool := range tools {
845+ write(tool.Name)
846+ write(tool.Description)
847+ _, _ = h.Write(tool.Schema)
848+ _, _ = h.Write([]byte{0})
849+ if tool.Deferred {
850+ write("deferred")
851+ }
852+ }
853+ return h.Sum64()
854+}
855+
856+// openTimeline shows the inspector in the floating pane.
857+func (i *Interactive) openTimeline() {
858+ i.mu.Lock()
859+ if i.timeline == nil {
860+ i.timeline = newTimelineView()
861+ }
862+ i.timeline.Open(i.timelineDataLocked())
863+ i.statusErr = ""
864+ i.statusOK = ""
865+ i.mu.Unlock()
866+ i.invalidate()
867+}
868+
869+// handleTimelineKey routes one key to the open timeline. View state is shared
870+// with the renderer, so it is only touched under i.mu; the export's file I/O
871+// runs outside the lock on the snapshot taken with the key.
872+func (i *Interactive) handleTimelineKey(k tui.Key) {
873+ i.mu.Lock()
874+ data := i.timelineDataLocked()
875+ act := i.timeline.HandleKey(k, data)
876+ i.mu.Unlock()
877+ if act.Export {
878+ path, err := i.timeline.Export("", data)
879+ i.mu.Lock()
880+ if err != nil {
881+ i.timeline.SetNotice("export failed: " + err.Error())
882+ } else {
883+ i.timeline.SetNotice("exported to " + friendlyPath(path))
884+ }
885+ i.mu.Unlock()
886+ }
887+ i.invalidate()
888+}
A packages/agent/modes/timeline_view_test.go +576 -0
@@ -0,0 +1,576 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "runtime"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/mattn/go-runewidth"
14+
15+ "github.com/bnema/zut/packages/core"
16+ "github.com/bnema/zut/packages/provider"
17+ "github.com/bnema/zut/packages/tui"
18+)
19+
20+func timelineTestData() timelineData {
21+ started := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
22+ finished := started.Add(1250 * time.Millisecond)
23+ return timelineData{
24+ System: "You are zot.",
25+ Tools: []provider.Tool{{
26+ Name: "bash", Schema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}}}`),
27+ }},
28+ Messages: []provider.Message{
29+ {Role: provider.RoleUser, Time: started.Add(-time.Second), Content: []provider.Content{provider.TextBlock{Text: "list files"}}},
30+ {Role: provider.RoleAssistant, Time: started, Content: []provider.Content{
31+ provider.TextBlock{Text: "I will inspect them."},
32+ provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"ls -la"}`)},
33+ }},
34+ {Role: provider.RoleTool, Time: finished, Content: []provider.Content{
35+ provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "README.md"}}},
36+ }},
37+ },
38+ ContextUsed: 1200,
39+ ContextMax: 200000,
40+ }
41+}
42+
43+func TestSessionTimelineOpensAlternateView(t *testing.T) {
44+ interactive := &Interactive{timeline: newTimelineView()}
45+ interactive.runSlash(context.Background(), "/session timeline")
46+ if !interactive.timeline.Active() {
47+ t.Fatal("/session timeline did not open the timeline view")
48+ }
49+}
50+
51+func TestSessionPickerIncludesTimeline(t *testing.T) {
52+ interactive := &Interactive{sessionOpsDialog: newSessionOpsDialog()}
53+ interactive.openSessionOpsDialog()
54+ if len(interactive.sessionOpsDialog.items) == 0 || interactive.sessionOpsDialog.items[0].action != "timeline" {
55+ t.Fatalf("session actions = %+v, want timeline first", interactive.sessionOpsDialog.items)
56+ }
57+}
58+
59+func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) {
60+ entries := buildTimelineEntries(timelineTestData())
61+ if len(entries) != 4 {
62+ t.Fatalf("entries = %d, want system, user, assistant, and tool", len(entries))
63+ }
64+ tool := entries[3]
65+ if tool.Kind != "tool" || tool.ToolCall != "call-1" {
66+ t.Fatalf("tool entry = %+v", tool)
67+ }
68+ if !strings.Contains(tool.Payload, "ls -la") || !strings.Contains(tool.Result, "README.md") {
69+ t.Fatalf("tool details missing: %+v", tool)
70+ }
71+ if !strings.Contains(tool.Schema, "command") {
72+ t.Fatalf("tool schema missing: %q", tool.Schema)
73+ }
74+ if tool.Duration != "1.25s" {
75+ t.Fatalf("duration = %q, want 1.25s", tool.Duration)
76+ }
77+}
78+
79+func TestTimelineReasoningIsReadableAndScrollable(t *testing.T) {
80+ data := timelineTestData()
81+ data.Messages[1].Content = append([]provider.Content{provider.ReasoningBlock{
82+ Summary: "**First** step\n" + strings.Repeat("A long line of thought. ", 25) + "\nFinal insight",
83+ }}, data.Messages[1].Content...)
84+ entries := buildTimelineEntries(data)
85+ if len(entries) != 5 || entries[2].Kind != "reasoning" || entries[3].Kind != "assistant" {
86+ t.Fatalf("reasoning entries = %+v", entries)
87+ }
88+ if strings.Contains(entries[3].Summary, "First") {
89+ t.Fatalf("reasoning duplicated in assistant: %+v", entries[3])
90+ }
91+ view := newTimelineView()
92+ view.Open(data)
93+ view.cursor = 2
94+ rows := timelineDetailRows(tui.Dark, 40, entries[2], 0)
95+ if len(rows) < 4 || !strings.Contains(stripANSIBytes(strings.Join(rows, "\n")), "Final insight") {
96+ t.Fatalf("reasoning detail not complete: %q", rows)
97+ }
98+ for _, row := range rows {
99+ if runewidth.StringWidth(stripANSIBytes(row)) > 40 {
100+ t.Fatalf("row exceeds width: %q", row)
101+ }
102+ }
103+ first := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n")
104+ view.HandleKey(tui.Key{Kind: tui.KeyEnd}, data)
105+ last := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n")
106+ if view.detailTop == 0 || first == last || !strings.Contains(stripANSIBytes(last), "Final insight") {
107+ t.Fatalf("detail did not scroll to end: top=%d, output=%q", view.detailTop, last)
108+ }
109+ view.HandleKey(tui.Key{Kind: tui.KeyLeft}, data)
110+ if view.detailTop == 0 {
111+ t.Fatal("left did not scroll detail up")
112+ }
113+ view.HandleKey(tui.Key{Kind: tui.KeyTab}, data)
114+ if view.detailTop != 0 {
115+ t.Fatal("changing tab did not reset detail scroll")
116+ }
117+}
118+
119+func TestTimelineNavigationKeysMoveSelection(t *testing.T) {
120+ data := timelineTestData()
121+ view := newTimelineView()
122+ view.Open(data)
123+ last := len(buildTimelineEntries(data)) - 1
124+ if view.cursor != last {
125+ t.Fatalf("initial cursor = %d, want %d", view.cursor, last)
126+ }
127+ view.HandleKey(tui.Key{Kind: tui.KeyUp}, data)
128+ if view.cursor != last-1 {
129+ t.Fatalf("cursor after up = %d, want %d", view.cursor, last-1)
130+ }
131+ view.HandleKey(tui.Key{Kind: tui.KeyDown}, data)
132+ if view.cursor != last {
133+ t.Fatalf("cursor after down = %d, want %d", view.cursor, last)
134+ }
135+ view.HandleKey(tui.Key{Kind: tui.KeyPageUp}, data)
136+ if view.cursor != 0 {
137+ t.Fatalf("cursor after page up = %d, want 0", view.cursor)
138+ }
139+ view.HandleKey(tui.Key{Kind: tui.KeyPageDown}, data)
140+ if view.cursor != last {
141+ t.Fatalf("cursor after page down = %d, want %d", view.cursor, last)
142+ }
143+}
144+
145+func TestTimelineSearchAndTabs(t *testing.T) {
146+ data := timelineTestData()
147+ view := newTimelineView()
148+ view.Open(data)
149+ view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}, data)
150+ for _, r := range "readme" {
151+ view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}, data)
152+ }
153+ matches := filterTimelineEntries(buildTimelineEntries(data), view.filter)
154+ if len(matches) != 1 || matches[0].Kind != "tool" {
155+ t.Fatalf("matches = %+v, want tool result", matches)
156+ }
157+ view.HandleKey(tui.Key{Kind: tui.KeyTab}, data)
158+ if view.tab != 1 {
159+ t.Fatalf("tab = %d, want payload tab", view.tab)
160+ }
161+ view.HandleKey(tui.Key{Kind: tui.KeyEsc}, data)
162+ if view.filter != "" || !view.Active() {
163+ t.Fatal("first esc should clear search without closing timeline")
164+ }
165+}
166+
167+func TestTimelineTabsKeepSelectedTabVisible(t *testing.T) {
168+ const width = 30
169+ for selected, name := range timelineTabNames {
170+ rendered := renderTimelineTabs(tui.Theme{}, width, selected)
171+ plain := stripANSIBytes(rendered)
172+ if !strings.Contains(plain, "["+name+"]") {
173+ t.Errorf("selected tab %q not visible in %q", name, plain)
174+ }
175+ if got := runewidth.StringWidth(plain); got > width {
176+ t.Errorf("tabs width = %d, want <= %d: %q", got, width, plain)
177+ }
178+ }
179+}
180+
181+func TestTimelineRenderShowsContextAndControls(t *testing.T) {
182+ data := timelineTestData()
183+ if contextLines := renderTimelineContext(tui.Theme{}, 100, data); len(contextLines) != 2 {
184+ t.Fatalf("context rows = %d, want usage and composition only", len(contextLines))
185+ }
186+ view := newTimelineView()
187+ view.Open(data)
188+ rendered := strings.Join(view.Render(tui.Theme{}, 100, 40, data), "\n")
189+ for _, want := range []string{"timeline", "context 1.2k / 200.0k", "estimated composition", "ctrl+e export", "TOOL bash"} {
190+ if !strings.Contains(rendered, want) {
191+ t.Fatalf("render missing %q:\n%s", want, rendered)
192+ }
193+ }
194+}
195+
196+func TestTimelineExportOmitsImageDataAndUsesPrivatePermissions(t *testing.T) {
197+ data := timelineTestData()
198+ data.Messages = append(data.Messages, provider.Message{
199+ Role: provider.RoleUser,
200+ Content: []provider.Content{provider.ImageBlock{MimeType: "image/png", Data: []byte("secret-image-bytes")}},
201+ })
202+ view := newTimelineView()
203+ path, err := view.Export(t.TempDir(), data)
204+ if err != nil {
205+ t.Fatal(err)
206+ }
207+ contents, err := os.ReadFile(path)
208+ if err != nil {
209+ t.Fatal(err)
210+ }
211+ if strings.Contains(string(contents), "secret-image-bytes") {
212+ t.Fatal("export embedded image data")
213+ }
214+ if !strings.Contains(string(contents), "image/png") {
215+ t.Fatal("export omitted image metadata")
216+ }
217+ info, err := os.Stat(path)
218+ if err != nil {
219+ t.Fatal(err)
220+ }
221+ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
222+ t.Fatalf("permissions = %o, want 600", info.Mode().Perm())
223+ }
224+}
225+
226+func TestTimelinePairsReusedToolCallIDs(t *testing.T) {
227+ t0 := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
228+ call := func(cmd string) provider.Content {
229+ return provider.ToolCallBlock{ID: "reused", Name: "bash", Arguments: json.RawMessage(`{"command":"` + cmd + `"}`)}
230+ }
231+ result := func(text string, isErr bool) provider.Message {
232+ return provider.Message{Role: provider.RoleTool, Time: t0.Add(time.Second), Content: []provider.Content{
233+ provider.ToolResultBlock{CallID: "reused", IsError: isErr, Content: []provider.Content{provider.TextBlock{Text: text}}},
234+ }}
235+ }
236+ data := timelineData{Messages: []provider.Message{
237+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "go"}}},
238+ {Role: provider.RoleAssistant, Time: t0, Content: []provider.Content{call("first")}},
239+ result("first-output", false),
240+ {Role: provider.RoleAssistant, Time: t0.Add(2 * time.Second), Content: []provider.Content{call("second")}},
241+ result("second-output", true),
242+ {Role: provider.RoleAssistant, Time: t0.Add(4 * time.Second), Content: []provider.Content{call("third")}},
243+ }}
244+ var tools []timelineEntry
245+ for _, e := range buildTimelineEntries(data) {
246+ if e.Kind == "tool" {
247+ tools = append(tools, e)
248+ }
249+ }
250+ if len(tools) != 3 {
251+ t.Fatalf("tool entries = %d, want 3: %+v", len(tools), tools)
252+ }
253+ if !strings.Contains(tools[0].Result, "first-output") || tools[0].IsError {
254+ t.Fatalf("first call paired wrongly: %+v", tools[0])
255+ }
256+ if !strings.Contains(tools[1].Result, "second-output") || !tools[1].IsError {
257+ t.Fatalf("second call paired wrongly: %+v", tools[1])
258+ }
259+ if tools[2].Result != "" || timelineStatus(tools[2]) != "result unavailable" {
260+ t.Fatalf("third call must stay unpaired: %+v", tools[2])
261+ }
262+}
263+
264+func TestTimelineKeepsOrphanResultVisible(t *testing.T) {
265+ data := timelineData{Messages: []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{
266+ provider.ToolResultBlock{CallID: "gone", Content: []provider.Content{provider.TextBlock{Text: "late"}}},
267+ }}}}
268+ entries := buildTimelineEntries(data)
269+ if len(entries) != 1 || !strings.Contains(entries[0].Result, "late") || !strings.Contains(entries[0].Summary, "unpaired") {
270+ t.Fatalf("orphan result = %+v", entries)
271+ }
272+}
273+
274+func TestTimelineNeverShowsOrExportsEncryptedReasoningOrSignatures(t *testing.T) {
275+ data := timelineTestData()
276+ data.Messages[1].Content = append([]provider.Content{
277+ provider.ReasoningBlock{ID: "r1", Summary: "visible summary", Encrypted: "ENCRYPTED-SECRET-BLOB"},
278+ provider.TextBlock{Text: "answer", ThoughtSignature: "SIGNATURE-SECRET"},
279+ }, data.Messages[1].Content...)
280+ view := newTimelineView()
281+ view.Open(data)
282+ var shown strings.Builder
283+ for tab := 0; tab < timelineTabCount; tab++ {
284+ view.tab = tab
285+ for cursor := range buildTimelineEntries(data) {
286+ view.cursor = cursor
287+ shown.WriteString(strings.Join(view.Render(tui.Dark, 100, 40, data), "\n"))
288+ }
289+ }
290+ path, err := view.Export(t.TempDir(), data)
291+ if err != nil {
292+ t.Fatal(err)
293+ }
294+ exported, err := os.ReadFile(path)
295+ if err != nil {
296+ t.Fatal(err)
297+ }
298+ for _, secret := range []string{"ENCRYPTED-SECRET-BLOB", "SIGNATURE-SECRET"} {
299+ if strings.Contains(shown.String(), secret) || strings.Contains(string(exported), secret) {
300+ t.Fatalf("%s leaked into the timeline", secret)
301+ }
302+ }
303+ if !strings.Contains(string(exported), "visible summary") {
304+ t.Fatal("reasoning summary missing from export")
305+ }
306+}
307+
308+func TestTimelineExportIsExclusiveAndPrivate(t *testing.T) {
309+ if runtime.GOOS == "windows" {
310+ t.Skip("POSIX permissions")
311+ }
312+ dir := filepath.Join(t.TempDir(), "exports")
313+ view := newTimelineView()
314+ path, err := view.Export(dir, timelineTestData())
315+ if err != nil {
316+ t.Fatal(err)
317+ }
318+ if info, err := os.Stat(dir); err != nil || info.Mode().Perm() != 0o700 {
319+ t.Fatalf("export dir mode = %v (%v), want 700", info.Mode().Perm(), err)
320+ }
321+ // A pre-planted symlink at the target name must not be followed.
322+ victim := filepath.Join(t.TempDir(), "victim")
323+ if err := os.WriteFile(victim, []byte("keep"), 0o600); err != nil {
324+ t.Fatal(err)
325+ }
326+ if err := os.Remove(path); err != nil {
327+ t.Fatal(err)
328+ }
329+ if err := os.Symlink(victim, path); err != nil {
330+ t.Skip("symlinks unavailable")
331+ }
332+ if _, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600); err == nil {
333+ t.Fatal("exclusive open unexpectedly succeeded on an existing symlink")
334+ }
335+ if got, _ := os.ReadFile(victim); string(got) != "keep" {
336+ t.Fatalf("victim overwritten: %q", got)
337+ }
338+}
339+
340+func TestTimelineSnapshotHidesHostInternalMessages(t *testing.T) {
341+ ag := core.NewAgent(nil, "m", "system prompt", nil)
342+ ag.SetMessages([]provider.Message{
343+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "real prompt"}}},
344+ {Role: provider.RoleUser, Meta: map[string]string{goalContinueMetaKey: "true"}, Content: []provider.Content{provider.TextBlock{Text: "hidden goal continuation"}}},
345+ {Role: provider.RoleUser, Meta: map[string]string{autoCompactContinueMetaKey: "true"}, Content: []provider.Content{provider.TextBlock{Text: "hidden compact continuation"}}},
346+ {Role: provider.RoleUser, Meta: map[string]string{core.RepetitionGuardMetaKey: "true"}, Content: []provider.Content{provider.TextBlock{Text: "hidden repetition guard"}}},
347+ })
348+ i := &Interactive{agent: ag, timeline: newTimelineView()}
349+ i.mu.Lock()
350+ data := i.timelineDataLocked()
351+ i.mu.Unlock()
352+ if data.System != "system prompt" || len(data.Messages) != 1 {
353+ t.Fatalf("snapshot = system %q, %d messages", data.System, len(data.Messages))
354+ }
355+ for _, e := range buildTimelineEntries(data) {
356+ if strings.Contains(e.Summary, "hidden") {
357+ t.Fatalf("internal message leaked: %+v", e)
358+ }
359+ }
360+}
361+
362+func TestTimelineCacheRebuildsOnlyWhenTranscriptChanges(t *testing.T) {
363+ ag := core.NewAgent(nil, "m", "sys", nil)
364+ ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}})
365+ i := &Interactive{agent: ag, timeline: newTimelineView()}
366+ i.mu.Lock()
367+ data := i.timelineDataLocked()
368+ i.timeline.Open(data)
369+ first := i.timeline.entries(data)
370+ again := i.timeline.entries(i.timelineDataLocked())
371+ i.mu.Unlock()
372+ if &first[0] != &again[0] {
373+ t.Fatal("unchanged transcript rebuilt the timeline")
374+ }
375+ ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "two"}}}})
376+ i.mu.Lock()
377+ updated := i.timeline.entries(i.timelineDataLocked())
378+ i.mu.Unlock()
379+ if !strings.Contains(updated[len(updated)-1].Summary, "two") {
380+ t.Fatalf("stale timeline after transcript change: %+v", updated)
381+ }
382+}
383+
384+func TestTimelineRendersInsideFloatingPane(t *testing.T) {
385+ data := timelineTestData()
386+ i := &Interactive{timeline: newTimelineView()}
387+ i.timeline.Open(data)
388+ pane := tui.FloatingPaneMaxRect(100, 30)
389+ lines := padDialogFrame(i.timeline.Render(tui.Dark, pane.ContentWidth(), pane.ContentHeight(), data))
390+ title, body, _ := floatingDialogBody(lines)
391+ if title != "timeline" {
392+ t.Fatalf("pane title = %q, want timeline", title)
393+ }
394+ if len(body) > pane.ContentHeight() {
395+ t.Fatalf("body has %d rows, pane content height is %d", len(body), pane.ContentHeight())
396+ }
397+ for _, row := range body {
398+ if w := runewidth.StringWidth(stripANSIBytes(row)); w > pane.ContentWidth() {
399+ t.Fatalf("row width %d exceeds pane %d: %q", w, pane.ContentWidth(), row)
400+ }
401+ }
402+ // A narrow drawer must still fit.
403+ narrow := tui.FloatingPaneMaxRect(40, 12)
404+ for _, row := range i.timeline.Render(tui.Dark, narrow.ContentWidth(), narrow.ContentHeight(), data) {
405+ if w := runewidth.StringWidth(stripANSIBytes(row)); w > narrow.ContentWidth() {
406+ t.Fatalf("narrow row width %d exceeds %d: %q", w, narrow.ContentWidth(), row)
407+ }
408+ }
409+}
410+
411+func TestTimelineKeysAreOwnedByTimelineAndCtrlCCloses(t *testing.T) {
412+ i := NewInteractive(InteractiveConfig{Agent: core.NewAgent(nil, "m", "", nil)})
413+ i.openTimeline()
414+ if !i.timeline.Active() || !i.confirmChildActive() {
415+ t.Fatal("timeline is not registered as an active overlay")
416+ }
417+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'x'})
418+ if i.ed.Value() != "" {
419+ t.Fatalf("key leaked to the main editor: %q", i.ed.Value())
420+ }
421+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlC})
422+ if i.timeline.Active() {
423+ t.Fatal("ctrl+c did not close the timeline")
424+ }
425+}
426+
427+func hostileTimelineData() timelineData {
428+ const hostile = "a\x1b]0;evil-title\x07b\x1b[31mred\x1b[0m\tc\rd\x1b"
429+ data := timelineData{
430+ System: "sys " + hostile + "\nsecond\r\nthird\rfourth",
431+ Tools: []provider.Tool{{Name: "bash", Schema: json.RawMessage(`{"type":"object"}`)}},
432+ Messages: []provider.Message{
433+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: hostile + "\nmore"}}},
434+ {Role: provider.RoleAssistant, Content: []provider.Content{
435+ provider.ReasoningBlock{Summary: "think " + hostile},
436+ provider.ToolCallBlock{ID: "c1", Name: "bash", Arguments: json.RawMessage(`{"command":"echo \u001b]0;evil-title\u0007 \t x\r y"}`)},
437+ }},
438+ {Role: provider.RoleTool, Content: []provider.Content{
439+ provider.ToolResultBlock{CallID: "c1", Content: []provider.Content{provider.TextBlock{Text: hostile}}},
440+ }},
441+ },
442+ }
443+ return data
444+}
445+
446+func TestTimelineSanitizesUntrustedText(t *testing.T) {
447+ data := hostileTimelineData()
448+ view := newTimelineView()
449+ view.Open(data)
450+ view.searching = true
451+ view.filter = "q\x1b]0;evil-title\x07\tz"
452+ entries := buildTimelineEntries(data)
453+ view.filter = ""
454+ for _, width := range []int{20, 60, 120} {
455+ for tab := 0; tab < timelineTabCount; tab++ {
456+ view.tab = tab
457+ for cursor := range entries {
458+ view.cursor = cursor
459+ out := strings.Join(view.Render(tui.Dark, width, 30, data), "\n")
460+ plain := stripANSIBytes(out)
461+ // JSON payloads show control bytes as inert \uXXXX text, so
462+ // only raw controls are checked there; the summary tab and the
463+ // list rows must have the OSC payload removed entirely.
464+ bad := []string{"\r", "\t", "\a", "\x1b"}
465+ if tab == 0 {
466+ bad = append(bad, "evil-title")
467+ }
468+ for _, b := range bad {
469+ if strings.Contains(out, b) && b != "\x1b" || b == "\x1b" && strings.Contains(plain, b) {
470+ t.Fatalf("width %d tab %d entry %d leaked %q:\n%q", width, tab, cursor, b, plain)
471+ }
472+ }
473+ for _, row := range strings.Split(plain, "\n") {
474+ if w := runewidth.StringWidth(row); w > width {
475+ t.Fatalf("width %d tab %d entry %d: row width %d: %q", width, tab, cursor, w, row)
476+ }
477+ }
478+ }
479+ }
480+ }
481+ // A typed or pasted filter cannot inject control sequences either.
482+ view.searching = true
483+ view.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "x\x1b]0;evil-title\x07\ty"}, data)
484+ view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\t'}, data)
485+ view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\r'}, data)
486+ shown := stripANSIBytes(strings.Join(view.Render(tui.Dark, 80, 30, data), "\n"))
487+ if strings.ContainsAny(view.filter, "\x1b\a\t\r") || strings.Contains(shown, "evil-title\x07") || strings.ContainsAny(shown, "\a\t\r") {
488+ t.Fatalf("filter leaked control text: filter=%q", view.filter)
489+ }
490+}
491+
492+func TestTimelineBodyFitsPaneAtEveryHeight(t *testing.T) {
493+ data := timelineTestData()
494+ data.Messages = append(data.Messages, provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{
495+ provider.ReasoningBlock{Summary: strings.Repeat("long reasoning line\n", 40)},
496+ }})
497+ view := newTimelineView()
498+ view.Open(data)
499+ view.notice = "exported to ~/x.json"
500+ for _, searching := range []bool{false, true} {
501+ view.searching = searching
502+ for height := 1; height <= 40; height++ {
503+ for _, width := range []int{12, 30, 78, 120} {
504+ for tab := 0; tab < timelineTabCount; tab++ {
505+ view.tab = tab
506+ lines := padDialogFrame(view.Render(tui.Dark, width, height, data))
507+ _, body, _ := floatingDialogBody(lines)
508+ if len(body) > height {
509+ t.Fatalf("height %d width %d tab %d: body has %d rows", height, width, tab, len(body))
510+ }
511+ for _, row := range body {
512+ if w := runewidth.StringWidth(stripANSIBytes(row)); w > width {
513+ t.Fatalf("height %d width %d: row width %d: %q", height, width, w, row)
514+ }
515+ }
516+ }
517+ }
518+ }
519+ }
520+ // Tiny panes still show the selected event before optional chrome.
521+ view.searching = false
522+ view.tab = 0
523+ lines := padDialogFrame(view.Render(tui.Dark, 80, 3, data))
524+ _, body, _ := floatingDialogBody(lines)
525+ if !strings.Contains(stripANSIBytes(strings.Join(body, "\n")), "REASONING") {
526+ t.Fatalf("selected event missing from a 3-row pane: %q", body)
527+ }
528+}
529+
530+func TestTimelineSnapshotCacheTracksPromptContentAndSkipsCopies(t *testing.T) {
531+ ag := core.NewAgent(nil, "m", "prompt-AAAA", core.Registry{})
532+ ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}})
533+ i := &Interactive{agent: ag, timeline: newTimelineView()}
534+ snapshot := func() timelineData {
535+ i.mu.Lock()
536+ defer i.mu.Unlock()
537+ return i.timelineDataLocked()
538+ }
539+
540+ first := snapshot()
541+ again := snapshot()
542+ if &first.Messages[0] != &again.Messages[0] || !again.Est.ok {
543+ t.Fatal("unchanged transcript and prompt were re-copied")
544+ }
545+ // A same-length system prompt edit changes neither the transcript
546+ // revision nor the prompt length, yet must refresh the snapshot.
547+ ag.SetSystemPrompt("prompt-BBBB")
548+ if edited := snapshot(); edited.System != "prompt-BBBB" {
549+ t.Fatalf("stale system prompt after same-length edit: %q", edited.System)
550+ }
551+ // Same for a tool whose schema changes at equal length.
552+ ag.SetTools(core.Registry{"t": schemaTool{name: "t", schema: `{"a":1}`}})
553+ if s := snapshot(); len(s.Tools) != 1 || string(s.Tools[0].Schema) != `{"a":1}` {
554+ t.Fatalf("tools = %+v", s.Tools)
555+ }
556+ ag.SetTools(core.Registry{"t": schemaTool{name: "t", schema: `{"a":2}`}})
557+ if s := snapshot(); string(s.Tools[0].Schema) != `{"a":2}` {
558+ t.Fatalf("stale tool schema after same-length edit: %s", s.Tools[0].Schema)
559+ }
560+ // The context gauge is live and never cached.
561+ i.mu.Lock()
562+ i.lastCtxInput = 42
563+ i.mu.Unlock()
564+ if snapshot().ContextUsed != 42 {
565+ t.Fatal("context usage was served from the cache")
566+ }
567+}
568+
569+type schemaTool struct{ name, schema string }
570+
571+func (t schemaTool) Name() string { return t.name }
572+func (t schemaTool) Description() string { return "d" }
573+func (t schemaTool) Schema() json.RawMessage { return json.RawMessage(t.schema) }
574+func (t schemaTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) {
575+ return core.ToolResult{}, nil
576+}
M packages/agent/scheduler_runtime.go +20 -1
@@ -12,6 +12,17 @@ import (
1212 "github.com/bnema/zut/packages/provider"
1313 )
1414
15+// scheduledSessionRegistry keeps background runs from opening foreground
16+// panels and waiting indefinitely for input from a hidden conversation.
17+func scheduledSessionRegistry(registry core.Registry) core.Registry {
18+ for name, tool := range registry {
19+ if extensions.IsInteractiveTool(tool) {
20+ delete(registry, name)
21+ }
22+ }
23+ return registry
24+}
25+
1526 // runScheduledSession reconstructs an inactive session in the current zut
1627 // process and appends the resulting turn to its existing transcript. The
1728 // caller serializes this with session transitions before invoking it.
@@ -44,7 +55,15 @@ func runScheduledSession(ctx context.Context, task scheduler.Task, args Args, ba
4455 if model == "" {
4556 model = base.Model
4657 }
47- ag, _, _, err := buildNonInteractiveSessionAgentWithRegistry(ctx, args, base, extMgr, providerName, model, prepareRegistry)
58+ // The shared manager may be attached to the foreground TUI, but this
59+ // reconstructed session has no visible user to answer interactive tools.
60+ backgroundRegistry := func(registry core.Registry) core.Registry {
61+ if prepareRegistry != nil {
62+ registry = prepareRegistry(registry)
63+ }
64+ return scheduledSessionRegistry(registry)
65+ }
66+ ag, _, _, err := buildNonInteractiveSessionAgentWithRegistry(ctx, args, base, extMgr, providerName, model, backgroundRegistry)
4867 if err != nil {
4968 return fmt.Errorf("build scheduled session agent: %w", err)
5069 }
A packages/agent/session_freshness.go +88 -0
@@ -0,0 +1,88 @@
1+package agent
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+
9+ "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/provider"
11+)
12+
13+// openOrCreateSessionState distinguishes creation from restoration even when
14+// the restored transcript is empty. Fresh runs may preload pinned skills.
15+func openOrCreateSessionState(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, bool, error) {
16+ if args.NoSess {
17+ ag.SessionID()
18+ return nil, true, nil
19+ }
20+ root := agentSessionsRoot(ZutHome(), args)
21+ // Explicit paths and UUIDs may identify empty sessions. Don't delete them
22+ // before deciding whether this is a restoration.
23+ if args.ResumeSessionID == "" && args.Session == "" {
24+ core.PruneEmptySessions(root, args.CWD)
25+ }
26+ var (
27+ sess *core.Session
28+ messages []provider.Message
29+ err error
30+ fresh bool
31+ )
32+ switch {
33+ case args.Session != "":
34+ sess, messages, err = core.OpenSession(args.Session)
35+ if errors.Is(err, os.ErrNotExist) {
36+ sess, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version)
37+ messages, fresh = nil, true
38+ }
39+ case args.Continue:
40+ if path := core.LatestSession(root, args.CWD); path != "" {
41+ sess, messages, err = core.OpenSession(path)
42+ }
43+ case args.Resume:
44+ var path string
45+ if args.ResumeSessionID != "" {
46+ path, err = core.FindManagedSessionByID(ctx, ZutHome(), args.ResumeSessionID)
47+ if err != nil {
48+ return nil, false, err
49+ }
50+ if path == "" {
51+ return nil, false, fmt.Errorf("session %q not found", args.ResumeSessionID)
52+ }
53+ } else {
54+ path, err = pickSession(root, args.CWD)
55+ if err != nil {
56+ return nil, false, err
57+ }
58+ }
59+ if path != "" {
60+ sess, messages, err = core.OpenSession(path)
61+ if err == nil && args.ResumeSessionID != "" && sess.ID != args.ResumeSessionID {
62+ return nil, false, errors.Join(fmt.Errorf("session %q changed during resume", args.ResumeSessionID), sess.Close())
63+ }
64+ }
65+ }
66+ if err != nil {
67+ return nil, false, err
68+ }
69+ if sess == nil {
70+ sess, err = core.NewSession(root, args.CWD, r.Provider, r.Model, version)
71+ if err != nil {
72+ return nil, false, err
73+ }
74+ fresh = true
75+ }
76+ if err := ag.BindSessionID(sess.Meta.ID); err != nil {
77+ return nil, false, errors.Join(err, sess.Close())
78+ }
79+ ag.SetSessionTimeContext(sess.Meta.Started, sess.Meta.Timezone, sess.Meta.TimezoneOffset)
80+ if !fresh {
81+ ag.SetMessages(messages)
82+ if cumulative, last, err := core.SessionUsageDetail(sess.Path); err == nil {
83+ ag.SeedCost(cumulative)
84+ ag.SeedLastTurnUsage(last)
85+ }
86+ }
87+ return sess, fresh, nil
88+}
A packages/agent/session_freshness_test.go +71 -0
@@ -0,0 +1,71 @@
1+package agent
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/zut/packages/core"
9+ "github.com/bnema/zut/packages/provider"
10+)
11+
12+func TestOpenOrCreateSessionStateFreshness(t *testing.T) {
13+ for _, name := range []string{"new", "no-session", "new-explicit", "existing-explicit", "continue-existing", "resume-id", "continue-missing"} {
14+ t.Run(name, func(t *testing.T) {
15+ home, cwd := t.TempDir(), t.TempDir()
16+ t.Setenv("ZUT_HOME", home)
17+ args := Args{CWD: cwd}
18+ resolved := Resolved{CWD: cwd, Provider: "test-provider", Model: "test-model"}
19+ wantFresh := true
20+ switch name {
21+ case "no-session":
22+ args.NoSess = true
23+ case "new-explicit":
24+ args.Session = filepath.Join(home, "explicit.jsonl")
25+ case "existing-explicit", "continue-existing", "resume-id":
26+ old, err := core.NewSession(home, cwd, resolved.Provider, resolved.Model, "test")
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ if name != "existing-explicit" {
31+ if err := old.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "saved prompt"}}}); err != nil {
32+ t.Fatal(err)
33+ }
34+ }
35+ path, id := old.Path, old.ID
36+ if name == "existing-explicit" {
37+ // Keep the empty session open so its valid header remains;
38+ // restoration is not inferred from message count.
39+ t.Cleanup(func() { _ = old.Close() })
40+ args.Session = path
41+ } else {
42+ if err := old.Close(); err != nil {
43+ t.Fatal(err)
44+ }
45+ if name == "continue-existing" {
46+ args.Continue = true
47+ } else {
48+ args.Resume, args.ResumeSessionID = true, id
49+ }
50+ }
51+ wantFresh = false
52+ case "continue-missing":
53+ args.Continue = true
54+ }
55+ ag := core.NewAgent(nil, resolved.Model, "system", core.Registry{})
56+ sess, fresh, err := openOrCreateSessionState(context.Background(), args, resolved, ag, "test")
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ if sess != nil {
61+ defer sess.Close()
62+ }
63+ if fresh != wantFresh {
64+ t.Fatalf("fresh=%v, want %v", fresh, wantFresh)
65+ }
66+ if args.NoSess && sess != nil {
67+ t.Fatal("session created with persistence disabled")
68+ }
69+ })
70+ }
71+}
A packages/agent/skill_pins.go +223 -0
@@ -0,0 +1,223 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "path/filepath"
11+ "slices"
12+ "sort"
13+ "strings"
14+ "sync"
15+
16+ "github.com/bnema/zut/packages/agent/skills"
17+)
18+
19+// skillPinsFileName lives directly under $ZUT_HOME. Pins are local
20+// preferences, so even project pins never write into a checkout.
21+const skillPinsFileName = "skill-pins.json"
22+
23+type skillPinsFile struct {
24+ Global []string `json:"global,omitempty"`
25+ Projects map[string][]string `json:"projects,omitempty"`
26+}
27+
28+// skillPinsMu serializes read-modify-write cycles within this process.
29+// Concurrent zut processes are not merged; the last atomic rename wins.
30+var skillPinsMu sync.Mutex
31+
32+// userSkillSnapshot rediscovers the user-facing skills with the same
33+// precedence as agent construction (user/project locations, then
34+// extension-bundled skills). Built-ins are not pinnable and are filtered out.
35+// bundled is the running extension manager's skill list, or nil.
36+func userSkillSnapshot(args Args, cwd string, bundled []*skills.Skill) []*skills.Skill {
37+ if args.NoSkill {
38+ return nil
39+ }
40+ userHome, _ := os.UserHomeDir()
41+ list, _ := skills.Discover(ZutHome(), cwd, userHome, args.WithSkills)
42+ list = mergeExtensionSkills(skills.NewTool(list), bundled)
43+ return skills.VisibleSkills(list)
44+}
45+
46+// preloadSkillPins folds the pinned skills' full bodies into the first prompt
47+// of a fresh non-interactive session. Resumed sessions and --no-skill runs
48+// return the prompt unchanged. Problems are reported as warnings on
49+// diagnostics (stderr), never on protocol output.
50+func preloadSkillPins(args Args, fresh bool, prompt string, bundled []*skills.Skill, diagnostics io.Writer) string {
51+ if !fresh || args.NoSkill {
52+ return prompt
53+ }
54+ pins, err := loadSkillPins(args.CWD)
55+ if err != nil {
56+ fmt.Fprintln(diagnostics, "warning:", err)
57+ return prompt
58+ }
59+ selected, warnings := pins.Resolve(userSkillSnapshot(args, args.CWD, bundled))
60+ for _, warning := range warnings {
61+ fmt.Fprintln(diagnostics, "warning:", warning)
62+ }
63+ return skills.PreloadPrompt(selected, prompt)
64+}
65+
66+// skillPinProject returns the canonical project key: the absolute starting
67+// directory with symlinks resolved when possible (not the Git root).
68+func skillPinProject(cwd string) (string, error) {
69+ path, err := filepath.Abs(cwd)
70+ if err != nil {
71+ return "", err
72+ }
73+ if resolved, err := filepath.EvalSymlinks(path); err == nil {
74+ path = resolved
75+ }
76+ return filepath.Clean(path), nil
77+}
78+
79+// normalizePinNames drops blanks and duplicates and sorts, so unions and
80+// saved files are deterministic.
81+func normalizePinNames(names []string) []string {
82+ out := make([]string, 0, len(names))
83+ for _, n := range names {
84+ if n = strings.TrimSpace(n); n != "" {
85+ out = append(out, n)
86+ }
87+ }
88+ sort.Strings(out)
89+ return slices.Compact(out)
90+}
91+
92+func readSkillPins() (skillPinsFile, error) {
93+ var pins skillPinsFile
94+ data, err := os.ReadFile(filepath.Join(ZutHome(), skillPinsFileName))
95+ if errors.Is(err, os.ErrNotExist) {
96+ return pins, nil
97+ }
98+ if err != nil {
99+ return pins, fmt.Errorf("read skill pins: %w", err)
100+ }
101+ // Do not echo file contents in diagnostics. The root must be a JSON
102+ // object: json.Unmarshal accepts null (a silent no-op), which would let a
103+ // toggle replace a file that is not a preferences document. Unknown
104+ // top-level keys fail closed too, because saving would drop them.
105+ invalid := fmt.Errorf("invalid %s: expected an object with only \"global\" and \"projects\" (fix or remove it; it will not be overwritten)", skillPinsFileName)
106+ if !isJSONObject(data) {
107+ return skillPinsFile{}, invalid
108+ }
109+ dec := json.NewDecoder(bytes.NewReader(data))
110+ dec.DisallowUnknownFields()
111+ if dec.Decode(&pins) != nil {
112+ return skillPinsFile{}, invalid
113+ }
114+ if _, err := dec.Token(); err != io.EOF { // trailing data
115+ return skillPinsFile{}, invalid
116+ }
117+ return pins, nil
118+}
119+
120+func isJSONObject(data []byte) bool {
121+ trimmed := bytes.TrimSpace(data)
122+ return len(trimmed) > 0 && trimmed[0] == '{'
123+}
124+
125+// loadSkillPins returns the global and current-project pins. On a malformed
126+// or unreadable file it returns the error and no pins.
127+func loadSkillPins(cwd string) (skills.Pins, error) {
128+ skillPinsMu.Lock()
129+ defer skillPinsMu.Unlock()
130+ project, err := skillPinProject(cwd)
131+ if err != nil {
132+ return skills.Pins{}, err
133+ }
134+ pins, err := readSkillPins()
135+ if err != nil {
136+ return skills.Pins{}, err
137+ }
138+ return skills.Pins{
139+ Global: normalizePinNames(pins.Global),
140+ Project: normalizePinNames(pins.Projects[project]),
141+ }, nil
142+}
143+
144+// toggleSkillPin flips one name in the global or current-project scope and
145+// saves via temp-file replacement. A malformed existing file is never
146+// overwritten.
147+func toggleSkillPin(cwd, name string, global bool) error {
148+ name = strings.TrimSpace(name)
149+ if name == "" {
150+ return fmt.Errorf("skill name is required")
151+ }
152+ skillPinsMu.Lock()
153+ defer skillPinsMu.Unlock()
154+ project, err := skillPinProject(cwd)
155+ if err != nil {
156+ return err
157+ }
158+ // A symlinked preferences file is read-only: renaming over it would
159+ // silently replace the link, and writing through it would modify a file
160+ // zut does not own. Loading may still follow it.
161+ path := filepath.Join(ZutHome(), skillPinsFileName)
162+ if info, lerr := os.Lstat(path); lerr == nil && info.Mode()&os.ModeSymlink != 0 {
163+ return fmt.Errorf("%s is a symbolic link; skill pins can be read from it but not saved (replace it with a regular file to change pins)", skillPinsFileName)
164+ }
165+ pins, err := readSkillPins()
166+ if err != nil {
167+ return err
168+ }
169+ toggle := func(names []string) []string {
170+ names = normalizePinNames(names)
171+ if slices.Contains(names, name) {
172+ return slices.DeleteFunc(names, func(n string) bool { return n == name })
173+ }
174+ return normalizePinNames(append(names, name))
175+ }
176+ if global {
177+ pins.Global = toggle(pins.Global)
178+ } else {
179+ if pins.Projects == nil {
180+ pins.Projects = make(map[string][]string)
181+ }
182+ pins.Projects[project] = toggle(pins.Projects[project])
183+ if len(pins.Projects[project]) == 0 {
184+ delete(pins.Projects, project)
185+ }
186+ }
187+ data, err := json.MarshalIndent(pins, "", " ")
188+ if err != nil {
189+ return err
190+ }
191+ return writeSkillPinsAtomic(path, append(data, '\n'))
192+}
193+
194+// writeSkillPinsAtomic replaces the preferences file with data through a
195+// same-directory temp file (mode 0600 from CreateTemp), so readers never see
196+// a partial file.
197+func writeSkillPinsAtomic(path string, data []byte) error {
198+ dir := filepath.Dir(path)
199+ if err := os.MkdirAll(dir, 0o700); err != nil {
200+ return err
201+ }
202+ f, err := os.CreateTemp(dir, ".skill-pins-*")
203+ if err != nil {
204+ return err
205+ }
206+ tmp := f.Name()
207+ defer os.Remove(tmp) // no-op after a successful rename
208+ if _, err := f.Write(data); err != nil {
209+ f.Close()
210+ return err
211+ }
212+ if err := f.Sync(); err != nil {
213+ f.Close()
214+ return err
215+ }
216+ if err := f.Close(); err != nil {
217+ return err
218+ }
219+ if err := os.Rename(tmp, path); err != nil {
220+ return fmt.Errorf("save skill pins: %w", err)
221+ }
222+ return nil
223+}
A packages/agent/skill_pins_modes_test.go +121 -0
@@ -0,0 +1,121 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+
14+ "github.com/bnema/zut/packages/core"
15+ "github.com/bnema/zut/packages/provider"
16+)
17+
18+func TestHeadlessModesPreloadSkillPinsAndPersistOnce(t *testing.T) {
19+ modes := []struct {
20+ name string
21+ run func(context.Context, Args, string) error
22+ }{
23+ {"print", runPrintMode}, {"stream", runStreamMode}, {"json", runJSONMode},
24+ }
25+ for _, mode := range modes {
26+ t.Run(mode.name, func(t *testing.T) {
27+ home, cwd := t.TempDir(), t.TempDir()
28+ t.Setenv("ZUT_HOME", home)
29+ t.Setenv("HOME", t.TempDir())
30+ t.Setenv("ZUT_AGENT_SKILLS", "")
31+ t.Setenv("ZUT_AGENT_PROFILES", "")
32+ dir := filepath.Join(cwd, ".zut", "skills", "review")
33+ if err := os.MkdirAll(dir, 0o700); err != nil {
34+ t.Fatal(err)
35+ }
36+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: review\ndescription: Review things.\n---\nPINNED_INSTRUCTIONS\n"), 0o600); err != nil {
37+ t.Fatal(err)
38+ }
39+ if err := toggleSkillPin(cwd, "review", true); err != nil {
40+ t.Fatal(err)
41+ }
42+ requests := make(chan string, 3)
43+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
44+ var request struct {
45+ Messages []struct {
46+ Role string `json:"role"`
47+ Content json.RawMessage `json:"content"`
48+ } `json:"messages"`
49+ }
50+ if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
51+ t.Error(err)
52+ http.Error(w, "bad request", http.StatusBadRequest)
53+ return
54+ }
55+ for index := len(request.Messages) - 1; index >= 0; index-- {
56+ if request.Messages[index].Role == "user" {
57+ requests <- string(request.Messages[index].Content)
58+ break
59+ }
60+ }
61+ w.Header().Set("content-type", "text/event-stream")
62+ _, _ = io.WriteString(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"done\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
63+ }))
64+ defer server.Close()
65+ stdin, err := os.Open(os.DevNull)
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ oldStdin := os.Stdin
70+ os.Stdin = stdin
71+ t.Cleanup(func() { os.Stdin = oldStdin; _ = stdin.Close() })
72+ args := Args{CWD: cwd, Provider: "openai", Model: "gpt-5", APIKey: "test-key", BaseURL: server.URL, Prompt: "first prompt", NoExt: true, WithSkills: true}
73+ if _, err := captureTestStdout(t, func() error { return mode.run(context.Background(), args, "test") }); err != nil {
74+ t.Fatal(err)
75+ }
76+ select {
77+ case prompt := <-requests:
78+ if !strings.Contains(prompt, "PINNED_INSTRUCTIONS") {
79+ t.Fatalf("provider did not receive pinned body: %s", prompt)
80+ }
81+ default:
82+ t.Fatal("no provider request")
83+ }
84+ path := core.LatestSession(home, cwd)
85+ if path == "" {
86+ t.Fatal("no persisted session")
87+ }
88+ args.Continue, args.Prompt = true, "second prompt"
89+ if _, err := captureTestStdout(t, func() error { return mode.run(context.Background(), args, "test") }); err != nil {
90+ t.Fatal(err)
91+ }
92+ select {
93+ case prompt := <-requests:
94+ if strings.Contains(prompt, "PINNED_INSTRUCTIONS") {
95+ t.Fatalf("resumed prompt reloaded pins: %s", prompt)
96+ }
97+ default:
98+ t.Fatal("no resumed provider request")
99+ }
100+ session, messages, err := core.OpenSession(path)
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+ defer session.Close()
105+ count := 0
106+ for _, message := range messages {
107+ if message.Role != provider.RoleUser {
108+ continue
109+ }
110+ for _, content := range message.Content {
111+ if text, ok := content.(provider.TextBlock); ok {
112+ count += strings.Count(text.Text, "PINNED_INSTRUCTIONS")
113+ }
114+ }
115+ }
116+ if count != 1 {
117+ t.Fatalf("persisted pinned bodies=%d, want 1", count)
118+ }
119+ })
120+ }
121+}
A packages/agent/skill_pins_test.go +285 -0
@@ -0,0 +1,285 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "runtime"
9+ "slices"
10+ "strings"
11+ "sync"
12+ "testing"
13+
14+ "github.com/bnema/zut/packages/agent/skills"
15+)
16+
17+func TestSkillPinsPersistIndependentScopes(t *testing.T) {
18+ t.Setenv("ZUT_HOME", t.TempDir())
19+ a, b := t.TempDir(), t.TempDir()
20+ for _, pin := range []struct {
21+ cwd, name string
22+ global bool
23+ }{
24+ {a, "review", false}, {a, "review", true}, {b, "other", false},
25+ } {
26+ if err := toggleSkillPin(pin.cwd, pin.name, pin.global); err != nil {
27+ t.Fatal(err)
28+ }
29+ }
30+ if err := toggleSkillPin(a, "review", false); err != nil {
31+ t.Fatal(err)
32+ }
33+ pins, err := loadSkillPins(a)
34+ if err != nil || len(pins.Project) != 0 || !slices.Equal(pins.Global, []string{"review"}) {
35+ t.Fatalf("pins = %+v, %v", pins, err)
36+ }
37+ pins, err = loadSkillPins(b)
38+ if err != nil || !slices.Equal(pins.Project, []string{"other"}) || !slices.Equal(pins.Global, []string{"review"}) {
39+ t.Fatalf("pins = %+v, %v", pins, err)
40+ }
41+ info, err := os.Stat(filepath.Join(ZutHome(), skillPinsFileName))
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
46+ t.Fatalf("mode = %o", info.Mode().Perm())
47+ }
48+ if entries, err := os.ReadDir(a); err != nil || len(entries) != 0 {
49+ t.Fatalf("project was modified: %v, %v", entries, err)
50+ }
51+ // No temp files are left next to the preferences.
52+ entries, err := os.ReadDir(ZutHome())
53+ if err != nil || len(entries) != 1 {
54+ t.Fatalf("home entries = %v, %v", entries, err)
55+ }
56+}
57+
58+func TestSkillPinsSavedSortedAndDeduplicated(t *testing.T) {
59+ t.Setenv("ZUT_HOME", t.TempDir())
60+ cwd := t.TempDir()
61+ // A hand-edited file with duplicates and unsorted names loads normalized.
62+ key, err := skillPinProject(cwd)
63+ if err != nil {
64+ t.Fatal(err)
65+ }
66+ raw, _ := json.Marshal(skillPinsFile{Global: []string{"b", "a", "b", " "}, Projects: map[string][]string{key: {"z", "a", "a"}}})
67+ if err := os.WriteFile(filepath.Join(ZutHome(), skillPinsFileName), raw, 0o600); err != nil {
68+ t.Fatal(err)
69+ }
70+ pins, err := loadSkillPins(cwd)
71+ if err != nil || !slices.Equal(pins.Global, []string{"a", "b"}) || !slices.Equal(pins.Project, []string{"a", "z"}) {
72+ t.Fatalf("pins = %+v, %v", pins, err)
73+ }
74+ if err := toggleSkillPin(cwd, "m", false); err != nil {
75+ t.Fatal(err)
76+ }
77+ pins, _ = loadSkillPins(cwd)
78+ if !slices.Equal(pins.Project, []string{"a", "m", "z"}) {
79+ t.Fatalf("project = %v", pins.Project)
80+ }
81+ selected, warnings := pins.Resolve([]*skills.Skill{{Name: "z"}, {Name: "a"}, {Name: "b"}})
82+ if len(warnings) != 1 || len(selected) != 3 || selected[0].Name != "a" || selected[1].Name != "b" || selected[2].Name != "z" {
83+ t.Fatalf("selected=%v warnings=%v", selected, warnings)
84+ }
85+}
86+
87+func TestSkillPinsDirectoryAliases(t *testing.T) {
88+ t.Setenv("ZUT_HOME", t.TempDir())
89+ root := t.TempDir()
90+ target, alias := filepath.Join(root, "target"), filepath.Join(root, "alias")
91+ if err := os.Mkdir(target, 0o700); err != nil {
92+ t.Fatal(err)
93+ }
94+ if err := os.Symlink(target, alias); err != nil {
95+ t.Skipf("symlink unavailable: %v", err)
96+ }
97+ if err := toggleSkillPin(target, "review", false); err != nil {
98+ t.Fatal(err)
99+ }
100+ pins, err := loadSkillPins(alias)
101+ if err != nil || !slices.Equal(pins.Project, []string{"review"}) {
102+ t.Fatalf("alias pins = %+v, %v", pins, err)
103+ }
104+}
105+
106+func TestSkillPinsRejectCorruptFileWithoutOverwriting(t *testing.T) {
107+ for _, original := range []string{
108+ `{"global":["review"],"projects":42}`, `not json`, ``, `null`, ` null `, `[]`, `["review"]`, `42`, `"review"`, `true`,
109+ `{"global":["review"]} trailing`,
110+ `{"global":["review"],"future":{"x":1}}`, `{"version":2,"global":[]}`, `{"projects":{},"extra":null}`,
111+ } {
112+ t.Setenv("ZUT_HOME", t.TempDir())
113+ path := filepath.Join(ZutHome(), skillPinsFileName)
114+ if err := os.WriteFile(path, []byte(original), 0o600); err != nil {
115+ t.Fatal(err)
116+ }
117+ pins, err := loadSkillPins(t.TempDir())
118+ if err == nil || len(pins.Global) != 0 {
119+ t.Fatalf("%q: pins = %+v, err = %v", original, pins, err)
120+ }
121+ if strings.Contains(err.Error(), "review") {
122+ t.Fatalf("diagnostic echoes file contents: %v", err)
123+ }
124+ if err := toggleSkillPin(t.TempDir(), "new", true); err == nil {
125+ t.Fatal("corrupt file overwritten")
126+ }
127+ got, err := os.ReadFile(path)
128+ if err != nil || !bytes.Equal(got, []byte(original)) {
129+ t.Fatalf("file changed: %q, %v", got, err)
130+ }
131+ }
132+}
133+
134+func TestSkillPinsConcurrentTogglesKeepFileValid(t *testing.T) {
135+ t.Setenv("ZUT_HOME", t.TempDir())
136+ cwd := t.TempDir()
137+ var wg sync.WaitGroup
138+ names := []string{"a", "b", "c", "d", "e", "f"}
139+ for _, n := range names {
140+ wg.Add(1)
141+ go func() {
142+ defer wg.Done()
143+ if err := toggleSkillPin(cwd, n, true); err != nil {
144+ t.Error(err)
145+ }
146+ }()
147+ }
148+ wg.Wait()
149+ pins, err := loadSkillPins(cwd)
150+ if err != nil || !slices.Equal(pins.Global, names) {
151+ t.Fatalf("pins = %+v, %v", pins, err)
152+ }
153+}
154+
155+func TestPreloadSkillPinsFreshOnlyAndNoSkill(t *testing.T) {
156+ t.Setenv("ZUT_HOME", t.TempDir())
157+ t.Setenv("ZUT_AGENT_SKILLS", "")
158+ t.Setenv("HOME", t.TempDir())
159+ cwd := t.TempDir()
160+ dir := filepath.Join(cwd, ".zut", "skills", "review")
161+ if err := os.MkdirAll(dir, 0o700); err != nil {
162+ t.Fatal(err)
163+ }
164+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: review\ndescription: Review things.\ndisable-model-invocation: true\n---\nInspect carefully.\n"), 0o600); err != nil {
165+ t.Fatal(err)
166+ }
167+ for _, pin := range []struct {
168+ name string
169+ global bool
170+ }{{"review", true}, {"review", false}, {"missing", false}} {
171+ if err := toggleSkillPin(cwd, pin.name, pin.global); err != nil {
172+ t.Fatal(err)
173+ }
174+ }
175+ for _, tt := range []struct {
176+ name string
177+ fresh, disabled bool
178+ }{
179+ {"fresh", true, false}, {"resumed", false, false}, {"disabled", true, true},
180+ } {
181+ t.Run(tt.name, func(t *testing.T) {
182+ var diagnostics bytes.Buffer
183+ args := Args{CWD: cwd, WithSkills: true, NoSkill: tt.disabled}
184+ got := preloadSkillPins(args, tt.fresh, "question", nil, &diagnostics)
185+ if tt.fresh && !tt.disabled {
186+ if strings.Count(got, "# Skill: review") != 1 || !strings.Contains(got, "Inspect carefully.") || !strings.HasSuffix(got, "User request:\nquestion") {
187+ t.Fatal(got)
188+ }
189+ if !strings.Contains(diagnostics.String(), "missing") {
190+ t.Fatal("missing warning")
191+ }
192+ } else if got != "question" || diagnostics.Len() != 0 {
193+ t.Fatalf("prompt = %q, warnings = %q", got, diagnostics.String())
194+ }
195+ })
196+ }
197+}
198+
199+func TestPreloadSkillPinsMalformedFileWarnsAndKeepsPrompt(t *testing.T) {
200+ t.Setenv("ZUT_HOME", t.TempDir())
201+ if err := os.WriteFile(filepath.Join(ZutHome(), skillPinsFileName), []byte("{"), 0o600); err != nil {
202+ t.Fatal(err)
203+ }
204+ var diagnostics bytes.Buffer
205+ got := preloadSkillPins(Args{CWD: t.TempDir(), WithSkills: true}, true, "q", nil, &diagnostics)
206+ if got != "q" || !strings.Contains(diagnostics.String(), "invalid") {
207+ t.Fatalf("got=%q diagnostics=%q", got, diagnostics.String())
208+ }
209+}
210+
211+func TestPreloadSkillPinsUsesExtensionSkills(t *testing.T) {
212+ t.Setenv("ZUT_HOME", t.TempDir())
213+ t.Setenv("ZUT_AGENT_SKILLS", "")
214+ t.Setenv("HOME", t.TempDir())
215+ cwd := t.TempDir()
216+ if err := toggleSkillPin(cwd, "ext-skill", true); err != nil {
217+ t.Fatal(err)
218+ }
219+ bundled := []*skills.Skill{{Name: "ext-skill", Body: "from extension", Source: "extension x"}}
220+ got := preloadSkillPins(Args{CWD: cwd, WithSkills: true}, true, "q", bundled, &bytes.Buffer{})
221+ if !strings.Contains(got, "from extension") {
222+ t.Fatal(got)
223+ }
224+}
225+
226+func TestSkillPinsSymlinkedFileIsReadableButNeverReplaced(t *testing.T) {
227+ t.Setenv("ZUT_HOME", t.TempDir())
228+ target := filepath.Join(t.TempDir(), "dotfiles-skill-pins.json")
229+ original := []byte("{\"global\":[\"review\"]}\n")
230+ if err := os.WriteFile(target, original, 0o644); err != nil {
231+ t.Fatal(err)
232+ }
233+ link := filepath.Join(ZutHome(), skillPinsFileName)
234+ if err := os.Symlink(target, link); err != nil {
235+ t.Skipf("symlink unavailable: %v", err)
236+ }
237+ pins, err := loadSkillPins(t.TempDir())
238+ if err != nil || !slices.Equal(pins.Global, []string{"review"}) {
239+ t.Fatalf("read through link: %+v, %v", pins, err)
240+ }
241+ err = toggleSkillPin(t.TempDir(), "other", true)
242+ if err == nil || !strings.Contains(err.Error(), "symbolic link") {
243+ t.Fatalf("err = %v", err)
244+ }
245+ if info, lerr := os.Lstat(link); lerr != nil || info.Mode()&os.ModeSymlink == 0 {
246+ t.Fatalf("link was replaced: %v, %v", info, lerr)
247+ }
248+ if got, _ := os.ReadFile(target); !bytes.Equal(got, original) {
249+ t.Fatalf("target changed: %q", got)
250+ }
251+ entries, _ := os.ReadDir(ZutHome())
252+ if len(entries) != 1 {
253+ t.Fatalf("temp file left behind: %v", entries)
254+ }
255+}
256+
257+func TestSkillPinsUnknownFieldsFailClosedWithClearError(t *testing.T) {
258+ t.Setenv("ZUT_HOME", t.TempDir())
259+ path := filepath.Join(ZutHome(), skillPinsFileName)
260+ original := []byte(`{"global":["a"],"notes":"keep me"}`)
261+ if err := os.WriteFile(path, original, 0o600); err != nil {
262+ t.Fatal(err)
263+ }
264+ _, err := loadSkillPins(t.TempDir())
265+ if err == nil || !strings.Contains(err.Error(), "global") || strings.Contains(err.Error(), "keep me") {
266+ t.Fatalf("err = %v", err)
267+ }
268+ if err := toggleSkillPin(t.TempDir(), "b", false); err == nil {
269+ t.Fatal("toggle accepted unknown fields")
270+ }
271+ if got, _ := os.ReadFile(path); !bytes.Equal(got, original) {
272+ t.Fatalf("file changed: %q", got)
273+ }
274+ // The documented schema still round-trips.
275+ if err := os.WriteFile(path, []byte(`{"global":["a"],"projects":{"/x":["b"]}}`), 0o600); err != nil {
276+ t.Fatal(err)
277+ }
278+ if err := toggleSkillPin(t.TempDir(), "c", true); err != nil {
279+ t.Fatal(err)
280+ }
281+ raw, _ := os.ReadFile(path)
282+ if !strings.Contains(string(raw), `"/x"`) {
283+ t.Fatalf("other project pins lost: %s", raw)
284+ }
285+}
A packages/agent/skills/pins.go +84 -0
@@ -0,0 +1,84 @@
1+package skills
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "strings"
7+)
8+
9+// Pins records independent global and project selections by discovered name.
10+type Pins struct {
11+ Global []string `json:"global,omitempty"`
12+ Project []string `json:"project,omitempty"`
13+}
14+
15+// Resolve returns each available pinned skill once, ordered by canonical
16+// skill name (then path). A pin may be a skill's name or one of its aliases;
17+// two pins that reach the same skill (for example a canonical name and its
18+// slash alias, or the same name in both scopes) select it once. Names that
19+// match nothing produce one warning each, in name order.
20+func (p Pins) Resolve(available []*Skill) ([]*Skill, []string) {
21+ names := make(map[string]bool)
22+ for _, name := range append(append([]string(nil), p.Global...), p.Project...) {
23+ if name != "" {
24+ names[name] = true
25+ }
26+ }
27+ sorted := make([]string, 0, len(names))
28+ for name := range names {
29+ sorted = append(sorted, name)
30+ }
31+ sort.Strings(sorted)
32+ var selected []*Skill
33+ chosen := make(map[*Skill]bool)
34+ var warnings []string
35+ for _, name := range sorted {
36+ s := FindByName(available, name)
37+ if s == nil {
38+ warnings = append(warnings, fmt.Sprintf("pinned skill %q is unavailable", name))
39+ continue
40+ }
41+ if !chosen[s] {
42+ chosen[s] = true
43+ selected = append(selected, s)
44+ }
45+ }
46+ sort.SliceStable(selected, func(i, j int) bool {
47+ if selected[i].Name != selected[j].Name {
48+ return selected[i].Name < selected[j].Name
49+ }
50+ return selected[i].Path < selected[j].Path
51+ })
52+ return selected, warnings
53+}
54+
55+// Scopes reports which scopes pin s, resolving aliases the same way Resolve
56+// does: a pin counts when its name resolves to s among available.
57+func (p Pins) Scopes(available []*Skill, s *Skill) (project, global bool) {
58+ hit := func(names []string) bool {
59+ for _, name := range names {
60+ if name != "" && FindByName(available, name) == s {
61+ return true
62+ }
63+ }
64+ return false
65+ }
66+ return hit(p.Project), hit(p.Global)
67+}
68+
69+// PreloadPrompt includes pinned instructions with the first real request.
70+// It creates no synthetic tool calls and does not start a model turn.
71+func PreloadPrompt(selected []*Skill, request string) string {
72+ if len(selected) == 0 {
73+ return request
74+ }
75+ var b strings.Builder
76+ b.WriteString("Pinned skills for this conversation. Apply these instructions where relevant to the user's requests.\n\n")
77+ for _, s := range selected {
78+ b.WriteString(InvocationPrompt(s, ""))
79+ b.WriteString("\n\n")
80+ }
81+ b.WriteString("---\n\nUser request:\n")
82+ b.WriteString(request)
83+ return b.String()
84+}
A packages/agent/skills/pins_test.go +57 -0
@@ -0,0 +1,57 @@
1+package skills
2+
3+import (
4+ "path/filepath"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestPinsResolveUnionAndMissing(t *testing.T) {
10+ a := &Skill{Name: "a", Body: "first"}
11+ b := &Skill{Name: "ext:b", Body: "second", DisableModelInvocation: true}
12+ pins := Pins{Global: []string{"ext:b", "a", "missing"}, Project: []string{"a", "ext:b"}}
13+ selected, warnings := pins.Resolve([]*Skill{b, a})
14+ if len(selected) != 2 || selected[0] != a || selected[1] != b {
15+ t.Fatalf("selected = %#v", selected)
16+ }
17+ if len(warnings) != 1 || !strings.Contains(warnings[0], "missing") {
18+ t.Fatalf("warnings = %v", warnings)
19+ }
20+}
21+
22+func TestPreloadPromptPreservesSkillLocationAndRequest(t *testing.T) {
23+ s := &Skill{Name: "review", Body: "Inspect carefully.", Path: filepath.Join("skills", "review", "SKILL.md")}
24+ got := PreloadPrompt([]*Skill{s}, "my request")
25+ for _, want := range []string{"# Skill: review", "Inspect carefully.", "Skill directory: " + filepath.Dir(s.Path), "User request:\nmy request"} {
26+ if !strings.Contains(got, want) {
27+ t.Errorf("missing %q in %q", want, got)
28+ }
29+ }
30+ if got := PreloadPrompt(nil, "unchanged"); got != "unchanged" {
31+ t.Fatal(got)
32+ }
33+}
34+
35+func TestPinsResolveDeduplicatesAliasAndCanonical(t *testing.T) {
36+ inner := &Skill{Name: "golang-patterns", Aliases: []string{"systems/subskills/golang-patterns"}, Path: "/r/systems/subskills/golang-patterns/SKILL.md"}
37+ other := &Skill{Name: "alpha", Path: "/r/alpha/SKILL.md"}
38+ pins := Pins{
39+ Global: []string{"systems/subskills/golang-patterns", "alpha"},
40+ Project: []string{"golang-patterns"},
41+ }
42+ available := []*Skill{inner, other}
43+ for i := 0; i < 20; i++ { // map iteration must not affect the result
44+ selected, warnings := pins.Resolve(available)
45+ if len(warnings) != 0 || len(selected) != 2 || selected[0] != other || selected[1] != inner {
46+ t.Fatalf("selected=%v warnings=%v", selected, warnings)
47+ }
48+ }
49+ project, global := pins.Scopes(available, inner)
50+ if !project || !global {
51+ t.Fatalf("scopes = project %v global %v", project, global)
52+ }
53+ project, global = pins.Scopes(available, other)
54+ if project || !global {
55+ t.Fatalf("scopes = project %v global %v", project, global)
56+ }
57+}
M packages/agent/skills/skills.go +200 -32
@@ -23,16 +23,20 @@
2323 // the related ecosystems works in zut unchanged. User skill roots are
2424 // walked recursively, and nested files can also be addressed by their
2525 // slash-separated path relative to the root (for example,
26-// systems-backend/subskills/golang-patterns).
26+// systems-backend/subskills/golang-patterns). Symlinked directories are
27+// followed; see scanUserSkills.
2728 package skills
2829
2930 import (
31+ "errors"
3032 "fmt"
3133 "io/fs"
3234 "os"
3335 "path/filepath"
36+ "runtime"
3437 "sort"
3538 "strings"
39+ "syscall"
3640 )
3741
3842 // Skill is one discovered SKILL.md file.
@@ -237,11 +241,58 @@ func scanBundledSkills(dir BundledSkillDir, seen map[string]*Skill) []error {
237241 }
238242 return errs
239243 }
244+ // Real directories load before symlinked ones so a link cannot claim a
245+ // fallback name ahead of the real directory it points at. Each entry
246+ // stays one level deep, and loadOne still confines every resolved
247+ // SKILL.md to dir.Root. A directory reachable both directly and through a
248+ // link is loaded once, so an unnamed skill is not duplicated under its
249+ // alias name.
250+ visited := map[string]bool{}
251+ visit := func(path string) bool {
252+ resolved, err := filepath.EvalSymlinks(path)
253+ if err != nil {
254+ if !skipLinkError(err) {
255+ errs = append(errs, fmt.Errorf("%s: %w", path, err))
256+ }
257+ return false
258+ }
259+ if visited[resolved] {
260+ return false
261+ }
262+ visited[resolved] = true
263+ return true
264+ }
265+ var linked []string
240266 for _, entry := range entries {
241- if !entry.IsDir() {
267+ if entry.IsDir() {
268+ path := filepath.Join(dir.Dir, entry.Name())
269+ if visit(path) {
270+ loadOne(filepath.Join(path, "SKILL.md"), entry.Name())
271+ }
272+ continue
273+ }
274+ if entry.Type()&fs.ModeSymlink == 0 {
242275 continue
243276 }
244- loadOne(filepath.Join(dir.Dir, entry.Name(), "SKILL.md"), entry.Name())
277+ path := filepath.Join(dir.Dir, entry.Name())
278+ info, statErr := os.Stat(path)
279+ if statErr != nil {
280+ // Dangling and cyclic links are skipped; anything else
281+ // (permissions, I/O) is reported.
282+ if !skipLinkError(statErr) {
283+ errs = append(errs, fmt.Errorf("%s: %w", path, statErr))
284+ }
285+ continue
286+ }
287+ if info.IsDir() {
288+ linked = append(linked, entry.Name())
289+ }
290+ }
291+ for _, name := range linked {
292+ path := filepath.Join(dir.Dir, name)
293+ if visit(path) {
294+ loadOne(filepath.Join(path, "SKILL.md"), name)
295+ }
245296 }
246297 return errs
247298 }
@@ -250,49 +301,166 @@ func scanBundledSkills(dir BundledSkillDir, seen map[string]*Skill) []error {
250301 // populates `seen` with first-match-wins per name or alias. Split out
251302 // so Discover's includeUser=false path doesn't have to skip over a
252303 // giant block.
304+//
305+// Symlinked directories are followed (filepath.WalkDir would not). Real
306+// directories are scanned before links so a link cannot claim a name or
307+// alias ahead of the real tree, every resolved directory is scanned once so
308+// link cycles terminate, and dangling or cyclic links are skipped. Skills
309+// reached through a link keep the walked (link) path.
253310 func scanUserSkills(zutHome, cwd, userHome string, seen map[string]*Skill) []error {
254311 var errs []error
255312 for _, loc := range searchDirs(zutHome, cwd, userHome) {
256- _ = filepath.WalkDir(loc.dir, func(path string, entry fs.DirEntry, walkErr error) error {
257- if walkErr != nil {
258- if !os.IsNotExist(walkErr) {
259- errs = append(errs, fmt.Errorf("%s: %w", path, walkErr))
313+ errs = append(errs, scanUserSkillDir(loc, seen)...)
314+ }
315+ return errs
316+}
317+
318+// Traversal bounds for one user skill location. Real skill trees are shallow
319+// and small; the caps stop a pathological or mis-linked tree (for example a
320+// link into a huge shared checkout) from stalling startup. Too-deep
321+// subtrees are skipped and the directory-count cap ends the scan of that
322+// location; either way one nonfatal diagnostic is reported and every skill
323+// already found is kept.
324+const (
325+ maxSkillScanDepth = 64
326+ maxSkillScanDirs = 10000
327+)
328+
329+type linkedDir struct {
330+ path string
331+ depth int
332+}
333+
334+func scanUserSkillDir(loc location, seen map[string]*Skill) []error {
335+ var errs []error
336+ visited := map[string]bool{}
337+ var linkedDirs []linkedDir
338+ dirCount := 0
339+ truncated := false // directory cap reached: stop the whole location
340+ reported := false
341+ report := func(why string) {
342+ if !reported {
343+ reported = true
344+ errs = append(errs, fmt.Errorf("%s: skill scan stopped early: %s; some directories were skipped", loc.dir, why))
345+ }
346+ }
347+ // A link whose target is the location itself or one of its ancestors
348+ // would pull the surrounding filesystem into the scan. Compare against
349+ // the resolved location root.
350+ rootResolved, rootErr := filepath.EvalSymlinks(loc.dir)
351+ loadSkill := func(path string) {
352+ s, err := load(path, loc.label)
353+ if err != nil {
354+ if !os.IsNotExist(err) {
355+ errs = append(errs, fmt.Errorf("%s: %w", path, err))
356+ }
357+ return
358+ }
359+ if s.Name == "" {
360+ s.Name = filepath.Base(filepath.Dir(path))
361+ }
362+ addRelativeAlias(s, loc.dir, path)
363+ registerSkill(seen, s)
364+ }
365+ var walk func(dir string, depth int)
366+ walk = func(dir string, depth int) {
367+ if truncated {
368+ return
369+ }
370+ if depth > maxSkillScanDepth {
371+ report(fmt.Sprintf("nesting deeper than %d levels", maxSkillScanDepth))
372+ return
373+ }
374+ resolved, err := filepath.EvalSymlinks(dir)
375+ if err != nil {
376+ if !skipLinkError(err) {
377+ errs = append(errs, fmt.Errorf("%s: %w", dir, err))
378+ }
379+ return
380+ }
381+ if visited[resolved] {
382+ return
383+ }
384+ if dirCount >= maxSkillScanDirs {
385+ truncated = true
386+ report(fmt.Sprintf("more than %d directories", maxSkillScanDirs))
387+ return
388+ }
389+ visited[resolved] = true
390+ dirCount++
391+ entries, err := os.ReadDir(dir)
392+ if err != nil {
393+ if !os.IsNotExist(err) {
394+ errs = append(errs, fmt.Errorf("%s: %w", dir, err))
395+ }
396+ return
397+ }
398+ for _, entry := range entries {
399+ path := filepath.Join(dir, entry.Name())
400+ isDir := entry.IsDir()
401+ if entry.Type()&fs.ModeSymlink != 0 {
402+ info, statErr := os.Stat(path)
403+ if statErr != nil {
404+ // Dangling and cyclic links are not skills; SKILL.md
405+ // links fall through to load, which ignores ENOENT.
406+ if !skipLinkError(statErr) {
407+ errs = append(errs, fmt.Errorf("%s: %w", path, statErr))
408+ }
409+ continue
410+ }
411+ if info.IsDir() {
412+ if target, err := filepath.EvalSymlinks(path); err == nil && rootErr == nil && containsPath(target, rootResolved) {
413+ // Link to the root or an ancestor of it: a cycle.
414+ continue
415+ }
416+ linkedDirs = append(linkedDirs, linkedDir{path: path, depth: depth + 1})
417+ continue
260418 }
261- return nil
262419 }
263- if entry.IsDir() || entry.Name() != "SKILL.md" {
264- return nil
420+ if isDir {
421+ walk(path, depth+1)
422+ continue
265423 }
266-
267- rel, err := filepath.Rel(loc.dir, filepath.Dir(path))
268- if err != nil {
269- errs = append(errs, fmt.Errorf("%s: determine skill name: %w", path, err))
270- return nil
424+ if entry.Name() != "SKILL.md" {
425+ continue
271426 }
272- if rel == "." {
427+ if dir == loc.dir {
273428 // Keep the existing convention that a search root is a
274429 // collection of skill directories, not a skill itself.
275- return nil
276- }
277-
278- s, err := load(path, loc.label)
279- if err != nil {
280- if !os.IsNotExist(err) {
281- errs = append(errs, fmt.Errorf("%s: %w", path, err))
282- }
283- return nil
284- }
285- if s.Name == "" {
286- s.Name = filepath.Base(filepath.Dir(path))
430+ continue
287431 }
288- addRelativeAlias(s, loc.dir, path)
289- registerSkill(seen, s)
290- return nil
291- })
432+ loadSkill(path)
433+ }
434+ }
435+ walk(loc.dir, 0)
436+ for i := 0; i < len(linkedDirs); i++ {
437+ walk(linkedDirs[i].path, linkedDirs[i].depth)
292438 }
293439 return errs
294440 }
295441
442+// containsPath reports whether child equals parent or lies beneath it. Both
443+// paths must already be resolved and absolute.
444+func containsPath(parent, child string) bool {
445+ rel, err := filepath.Rel(parent, child)
446+ if err != nil {
447+ return false
448+ }
449+ return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
450+}
451+
452+// skipLinkError reports whether err means a link points at nothing or loops.
453+func skipLinkError(err error) bool {
454+ return errors.Is(err, fs.ErrNotExist) || symlinkLoopError(err)
455+}
456+
457+func symlinkLoopError(err error) bool {
458+ // Windows reports cyclic links as ERROR_CANT_RESOLVE_FILENAME (1921),
459+ // rather than ELOOP. syscall does not export a name for this error.
460+ return errors.Is(err, syscall.ELOOP) ||
461+ (runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(1921)))
462+}
463+
296464 // addRelativeAlias makes a nested skill addressable by the directory path
297465 // used by common skill routers, for example
298466 // systems-backend/subskills/golang-patterns.
A packages/agent/skills/symlink_test.go +329 -0
@@ -0,0 +1,329 @@
1+package skills
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "strings"
9+ "testing"
10+)
11+
12+func writeSkill(t *testing.T, dir, name, desc string) {
13+ t.Helper()
14+ if err := os.MkdirAll(dir, 0o755); err != nil {
15+ t.Fatal(err)
16+ }
17+ body := "---\nname: " + name + "\ndescription: " + desc + "\n---\nbody of " + name + "\n"
18+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil {
19+ t.Fatal(err)
20+ }
21+}
22+
23+func symlinkOrSkip(t *testing.T, target, link string) {
24+ t.Helper()
25+ if err := os.Symlink(target, link); err != nil {
26+ t.Skipf("symlink unavailable: %v", err)
27+ }
28+}
29+
30+func TestDiscoverFollowsSymlinkedSkillDirectories(t *testing.T) {
31+ t.Setenv("ZUT_AGENT_SKILLS", "")
32+ tmp := t.TempDir()
33+ home := filepath.Join(tmp, "home")
34+ shared := filepath.Join(tmp, "shared")
35+ writeSkill(t, filepath.Join(shared, "linked"), "linked", "from checkout")
36+ writeSkill(t, filepath.Join(shared, "group", "inner"), "inner", "nested")
37+ root := filepath.Join(home, ".agents", "skills")
38+ if err := os.MkdirAll(root, 0o755); err != nil {
39+ t.Fatal(err)
40+ }
41+ symlinkOrSkip(t, filepath.Join(shared, "linked"), filepath.Join(root, "linked"))
42+ symlinkOrSkip(t, filepath.Join(shared, "group"), filepath.Join(root, "bundle"))
43+ symlinkOrSkip(t, filepath.Join(tmp, "missing"), filepath.Join(root, "dangling"))
44+
45+ list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
46+ if len(errs) != 0 {
47+ t.Fatalf("errors = %v", errs)
48+ }
49+ linked := FindByName(list, "linked")
50+ if linked == nil {
51+ t.Fatalf("linked skill missing: %v", list)
52+ }
53+ // The reported path is the installed (link) location.
54+ if want := filepath.Join(root, "linked", "SKILL.md"); linked.Path != want {
55+ t.Fatalf("path = %q, want %q", linked.Path, want)
56+ }
57+ inner := FindByName(list, "inner")
58+ if inner == nil {
59+ t.Fatal("skill under linked parent missing")
60+ }
61+ // Fork slash aliases follow the walked path and keep the frontmatter name.
62+ if FindByName(list, "bundle/inner") != inner {
63+ t.Fatalf("alias bundle/inner not registered: %#v", inner.Aliases)
64+ }
65+}
66+
67+func TestDiscoverSymlinkedUserRoot(t *testing.T) {
68+ t.Setenv("ZUT_AGENT_SKILLS", "")
69+ tmp := t.TempDir()
70+ real := filepath.Join(tmp, "real-skills")
71+ writeSkill(t, filepath.Join(real, "one"), "one", "d")
72+ home := filepath.Join(tmp, "home")
73+ if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
74+ t.Fatal(err)
75+ }
76+ symlinkOrSkip(t, real, filepath.Join(home, ".claude", "skills"))
77+ list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
78+ if len(errs) != 0 || FindByName(list, "one") == nil {
79+ t.Fatalf("skills=%v errs=%v", list, errs)
80+ }
81+}
82+
83+func TestDiscoverSymlinkCycleTerminates(t *testing.T) {
84+ t.Setenv("ZUT_AGENT_SKILLS", "")
85+ tmp := t.TempDir()
86+ home := filepath.Join(tmp, "home")
87+ root := filepath.Join(home, ".agents", "skills")
88+ writeSkill(t, filepath.Join(root, "a"), "a", "d")
89+ symlinkOrSkip(t, root, filepath.Join(root, "a", "loop"))
90+ symlinkOrSkip(t, filepath.Join(root, "self"), filepath.Join(root, "self")) // ELOOP
91+ list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
92+ if len(errs) != 0 {
93+ t.Fatalf("errors = %v", errs)
94+ }
95+ n := 0
96+ for _, s := range list {
97+ if !s.Builtin {
98+ n++
99+ }
100+ }
101+ if n != 1 {
102+ t.Fatalf("user skills = %d, want 1: %v", n, list)
103+ }
104+}
105+
106+func TestDiscoverPrefersRealDirectoryOverLink(t *testing.T) {
107+ t.Setenv("ZUT_AGENT_SKILLS", "")
108+ tmp := t.TempDir()
109+ home := filepath.Join(tmp, "home")
110+ root := filepath.Join(home, ".agents", "skills")
111+ // "aaa" sorts before "real" but is a link to it; the real path must win.
112+ writeSkill(t, filepath.Join(root, "real"), "real", "d")
113+ symlinkOrSkip(t, filepath.Join(root, "real"), filepath.Join(root, "aaa"))
114+ list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
115+ if len(errs) != 0 {
116+ t.Fatalf("errors = %v", errs)
117+ }
118+ s := FindByName(list, "real")
119+ if s == nil || s.Path != filepath.Join(root, "real", "SKILL.md") {
120+ t.Fatalf("skill = %#v", s)
121+ }
122+}
123+
124+func TestBundledSymlinkedEntriesStayConfinedToRoot(t *testing.T) {
125+ tmp := t.TempDir()
126+ root := filepath.Join(tmp, "ext")
127+ bundle := filepath.Join(root, "skills")
128+ writeSkill(t, filepath.Join(root, "shared", "inside"), "inside", "in root")
129+ writeSkill(t, filepath.Join(tmp, "outside", "evil"), "evil", "outside root")
130+ if err := os.MkdirAll(bundle, 0o755); err != nil {
131+ t.Fatal(err)
132+ }
133+ symlinkOrSkip(t, filepath.Join(root, "shared", "inside"), filepath.Join(bundle, "inside"))
134+ symlinkOrSkip(t, filepath.Join(tmp, "outside", "evil"), filepath.Join(bundle, "evil"))
135+ symlinkOrSkip(t, filepath.Join(tmp, "nowhere"), filepath.Join(bundle, "dangling"))
136+
137+ got, errs := LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension t"}})
138+ if FindByName(got, "inside") == nil {
139+ t.Fatalf("in-root link not followed: %v (errs %v)", got, errs)
140+ }
141+ if FindByName(got, "evil") != nil {
142+ t.Fatal("link escaped extension root")
143+ }
144+ if len(errs) == 0 {
145+ t.Fatal("expected escape diagnostic")
146+ }
147+}
148+
149+func TestBundledSymlinkCycleAndDanglingAreSilentButRealDirsWin(t *testing.T) {
150+ tmp := t.TempDir()
151+ root := filepath.Join(tmp, "ext")
152+ bundle := filepath.Join(root, "skills")
153+ // "aaa" sorts before "zzz" and links to it. Both carry the same
154+ // frontmatter name, so whichever loads first owns it: the real directory.
155+ realDir := filepath.Join(bundle, "zzz")
156+ writeSkill(t, realDir, "shared", "real")
157+ symlinkOrSkip(t, realDir, filepath.Join(bundle, "aaa"))
158+ symlinkOrSkip(t, filepath.Join(bundle, "loop"), filepath.Join(bundle, "loop"))
159+ symlinkOrSkip(t, filepath.Join(tmp, "nowhere"), filepath.Join(bundle, "dangling"))
160+ got, errs := LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension t"}})
161+ if len(errs) != 0 {
162+ t.Fatalf("dangling/cyclic links must be skipped silently: %v", errs)
163+ }
164+ s := FindByName(got, "shared")
165+ if s == nil || s.Path != filepath.Join(realDir, "SKILL.md") {
166+ t.Fatalf("real directory did not win: %#v", s)
167+ }
168+ if len(got) != 1 {
169+ t.Fatalf("skills = %v", got)
170+ }
171+}
172+
173+func TestBundledSymlinkPermissionErrorIsReported(t *testing.T) {
174+ if runtime.GOOS == "windows" || os.Geteuid() == 0 {
175+ t.Skip("permission bits are not enforced here")
176+ }
177+ tmp := t.TempDir()
178+ root := filepath.Join(tmp, "ext")
179+ bundle := filepath.Join(root, "skills")
180+ locked := filepath.Join(root, "locked")
181+ writeSkill(t, filepath.Join(locked, "hidden"), "hidden", "d")
182+ if err := os.MkdirAll(bundle, 0o755); err != nil {
183+ t.Fatal(err)
184+ }
185+ symlinkOrSkip(t, filepath.Join(locked, "hidden"), filepath.Join(bundle, "hidden"))
186+ if err := os.Chmod(locked, 0); err != nil {
187+ t.Fatal(err)
188+ }
189+ t.Cleanup(func() { os.Chmod(locked, 0o755) })
190+ got, errs := LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension t"}})
191+ if len(got) != 0 || len(errs) == 0 {
192+ t.Fatalf("permission failure was swallowed: skills=%v errs=%v", got, errs)
193+ }
194+}
195+
196+func TestDiscoverReportsUnreadableLinkTarget(t *testing.T) {
197+ if runtime.GOOS == "windows" || os.Geteuid() == 0 {
198+ t.Skip("permission bits are not enforced here")
199+ }
200+ t.Setenv("ZUT_AGENT_SKILLS", "")
201+ tmp := t.TempDir()
202+ home := filepath.Join(tmp, "home")
203+ root := filepath.Join(home, ".agents", "skills")
204+ locked := filepath.Join(tmp, "locked")
205+ writeSkill(t, filepath.Join(locked, "x"), "x", "d")
206+ if err := os.MkdirAll(root, 0o755); err != nil {
207+ t.Fatal(err)
208+ }
209+ symlinkOrSkip(t, filepath.Join(locked, "x"), filepath.Join(root, "x"))
210+ if err := os.Chmod(locked, 0); err != nil {
211+ t.Fatal(err)
212+ }
213+ t.Cleanup(func() { os.Chmod(locked, 0o755) })
214+ _, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
215+ if len(errs) == 0 {
216+ t.Fatal("permission failure on a linked directory was swallowed")
217+ }
218+}
219+
220+func userSkillCount(list []*Skill) int {
221+ n := 0
222+ for _, s := range list {
223+ if !s.Builtin {
224+ n++
225+ }
226+ }
227+ return n
228+}
229+
230+func TestDiscoverIgnoresLinkToAncestorOfRoot(t *testing.T) {
231+ t.Setenv("ZUT_AGENT_SKILLS", "")
232+ tmp := t.TempDir()
233+ home := filepath.Join(tmp, "home")
234+ root := filepath.Join(home, ".agents", "skills")
235+ writeSkill(t, filepath.Join(root, "mine"), "mine", "d")
236+ // A sibling tree that must never be pulled in through an ancestor link.
237+ writeSkill(t, filepath.Join(home, "unrelated", "stray"), "stray", "d")
238+ symlinkOrSkip(t, home, filepath.Join(root, "up-home"))
239+ symlinkOrSkip(t, filepath.Join(home, ".agents"), filepath.Join(root, "up-agents"))
240+ symlinkOrSkip(t, root, filepath.Join(root, "self"))
241+ // On Windows a bare separator depends on the process drive, which can
242+ // differ from the temporary directory's drive. Use this tree's own root.
243+ symlinkOrSkip(t, filepath.VolumeName(root)+string(filepath.Separator), filepath.Join(root, "up-fs-root"))
244+ list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
245+ if len(errs) != 0 {
246+ t.Fatalf("errors = %v", errs)
247+ }
248+ if FindByName(list, "mine") == nil || FindByName(list, "stray") != nil || userSkillCount(list) != 1 {
249+ t.Fatalf("skills = %v", list)
250+ }
251+}
252+
253+func TestDiscoverKeepsExternalSharedLinkThatDoesNotContainRoot(t *testing.T) {
254+ t.Setenv("ZUT_AGENT_SKILLS", "")
255+ tmp := t.TempDir()
256+ home := filepath.Join(tmp, "home")
257+ root := filepath.Join(home, ".agents", "skills")
258+ shared := filepath.Join(tmp, "dev", "skills-checkout")
259+ writeSkill(t, filepath.Join(shared, "team", "review"), "review", "d")
260+ if err := os.MkdirAll(root, 0o755); err != nil {
261+ t.Fatal(err)
262+ }
263+ symlinkOrSkip(t, shared, filepath.Join(root, "shared"))
264+ list, errs := Discover("", filepath.Join(tmp, "proj"), home, true)
265+ if len(errs) != 0 || FindByName(list, "review") == nil {
266+ t.Fatalf("skills=%v errs=%v", list, errs)
267+ }
268+}
269+
270+func TestScanUserSkillDirBoundsDepthWithDiagnostic(t *testing.T) {
271+ tmp := t.TempDir()
272+ root := filepath.Join(tmp, "skills")
273+ writeSkill(t, filepath.Join(root, "top"), "top", "d")
274+ deep := root
275+ for i := 0; i < maxSkillScanDepth+5; i++ {
276+ deep = filepath.Join(deep, "d")
277+ }
278+ writeSkill(t, deep, "too-deep", "d")
279+ seen := map[string]*Skill{}
280+ errs := scanUserSkillDir(location{dir: root, label: "test"}, seen)
281+ if seen["top"] == nil || seen["too-deep"] != nil {
282+ t.Fatalf("seen = %v", seen)
283+ }
284+ if len(errs) != 1 || !strings.Contains(errs[0].Error(), "skill scan stopped early") {
285+ t.Fatalf("errs = %v", errs)
286+ }
287+}
288+
289+func TestScanUserSkillDirBoundsDirectoryCount(t *testing.T) {
290+ tmp := t.TempDir()
291+ root := filepath.Join(tmp, "skills")
292+ writeSkill(t, filepath.Join(root, "a-first"), "first", "d")
293+ for i := 0; i < maxSkillScanDirs+50; i++ {
294+ if err := os.MkdirAll(filepath.Join(root, "z-bulk", fmt.Sprintf("d%05d", i)), 0o755); err != nil {
295+ t.Fatal(err)
296+ }
297+ }
298+ seen := map[string]*Skill{}
299+ errs := scanUserSkillDir(location{dir: root, label: "test"}, seen)
300+ if seen["first"] == nil {
301+ t.Fatal("skills found before the cap were dropped")
302+ }
303+ if len(errs) != 1 || !strings.Contains(errs[0].Error(), "more than") {
304+ t.Fatalf("errs = %v", errs)
305+ }
306+}
307+
308+func TestBundledRealAndAliasUnnamedSkillLoadsOnce(t *testing.T) {
309+ tmp := t.TempDir()
310+ root := filepath.Join(tmp, "ext")
311+ bundle := filepath.Join(root, "skills")
312+ bar := filepath.Join(bundle, "bar")
313+ if err := os.MkdirAll(bar, 0o755); err != nil {
314+ t.Fatal(err)
315+ }
316+ // No frontmatter name: the directory name is the fallback.
317+ if err := os.WriteFile(filepath.Join(bar, "SKILL.md"), []byte("---\ndescription: d\n---\nbody\n"), 0o644); err != nil {
318+ t.Fatal(err)
319+ }
320+ symlinkOrSkip(t, bar, filepath.Join(bundle, "foo")) // sorts after "bar" only by luck; also test the reverse
321+ symlinkOrSkip(t, bar, filepath.Join(bundle, "aaa"))
322+ got, errs := LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension t"}})
323+ if len(errs) != 0 {
324+ t.Fatalf("errs = %v", errs)
325+ }
326+ if len(got) != 1 || got[0].Name != "bar" || FindByName(got, "foo") != nil || FindByName(got, "aaa") != nil {
327+ t.Fatalf("alias duplicated the skill: %v", got)
328+ }
329+}
M packages/agent/subagent_factory.go +4 -0
@@ -7,6 +7,7 @@ import (
77 "strings"
88 "sync"
99
10+ "github.com/bnema/zut/packages/agent/extensions"
1011 "github.com/bnema/zut/packages/agent/subagents"
1112 "github.com/bnema/zut/packages/agent/tools"
1213 "github.com/bnema/zut/packages/core"
@@ -351,6 +352,9 @@ func residentChildRegistry(catalogue core.Registry, names []string) (core.Regist
351352 if !ok {
352353 return nil, fmt.Errorf("resident child declares unavailable tool %q", name)
353354 }
355+ if extensions.IsInteractiveTool(tool) {
356+ return nil, fmt.Errorf("resident child tool %q is interactive and not allowed", name)
357+ }
354358 registry[name] = tool
355359 }
356360 return registry, nil
M packages/agent/subagent_runtime.go +4 -0
@@ -10,6 +10,7 @@ import (
1010 "sync"
1111 "time"
1212
13+ "github.com/bnema/zut/packages/agent/extensions"
1314 "github.com/bnema/zut/packages/agent/subagents"
1415 "github.com/bnema/zut/packages/agent/tools"
1516 "github.com/bnema/zut/packages/core"
@@ -357,6 +358,9 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
357358 // scheduling/extension tools are never inherited.
358359 allTools := make([]string, 0, len(catalogue))
359360 for name := range catalogue {
361+ if extensions.IsInteractiveTool(catalogue[name]) {
362+ continue
363+ }
360364 switch name {
361365 case tools.SubagentToolName, "update_goal", tools.PlanToolName:
362366 continue
A packages/core/session_repeated_tool_id_test.go +24 -0
@@ -0,0 +1,24 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/provider"
8+)
9+
10+func TestRepairToolUseResultPairsPreservesRepeatedIDsAcrossTurns(t *testing.T) {
11+ messages := []provider.Message{
12+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "reused", Name: "read"}}},
13+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "reused", Content: []provider.Content{provider.TextBlock{Text: "first"}}}}},
14+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "reused", Name: "read"}}},
15+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "reused", Content: []provider.Content{provider.TextBlock{Text: "second"}}}}},
16+ }
17+ repaired := repairToolUseResultPairs(messages)
18+ if !reflect.DeepEqual(repaired, messages) {
19+ t.Fatal("session repair changed valid repeated-ID pairs")
20+ }
21+ if wire := provider.RepairOrphanedToolResults(repaired); !reflect.DeepEqual(wire, messages) {
22+ t.Fatal("provider repair dropped a valid pair after session repair")
23+ }
24+}
M packages/provider/amazon_bedrock.go +12 -6
@@ -225,14 +225,19 @@ type bedrockRequest struct {
225225 } `json:"toolConfig,omitempty"`
226226 }
227227
228+// normalizeBedrockToolResults places each tool result directly after the
229+// assistant turn that issued its call. Providers can reuse a tool ID across
230+// turns, so results are queued per ID in transcript order and consumed by
231+// successive calls. Orphaned or excess results are dropped first, using the
232+// same chronological pairing as every other provider request builder, so a
233+// stray early result cannot shift onto a later call that reuses its ID.
228234 func normalizeBedrockToolResults(msgs []Message) []Message {
229- resultByID := map[string]ToolResultBlock{}
235+ msgs = RepairOrphanedToolResults(msgs)
236+ resultsByID := map[string][]ToolResultBlock{}
230237 for _, m := range msgs {
231238 for _, c := range m.Content {
232239 if tr, ok := c.(ToolResultBlock); ok {
233- if _, exists := resultByID[tr.CallID]; !exists {
234- resultByID[tr.CallID] = tr
235- }
240+ resultsByID[tr.CallID] = append(resultsByID[tr.CallID], tr)
236241 }
237242 }
238243 }
@@ -265,8 +270,9 @@ func normalizeBedrockToolResults(msgs []Message) []Message {
265270
266271 results := make([]Content, 0, len(toolCalls))
267272 for _, tc := range toolCalls {
268- if tr, ok := resultByID[tc.ID]; ok {
269- results = append(results, tr)
273+ if queue := resultsByID[tc.ID]; len(queue) > 0 {
274+ results = append(results, queue[0])
275+ resultsByID[tc.ID] = queue[1:]
270276 continue
271277 }
272278 results = append(results, ToolResultBlock{
M packages/provider/amazon_bedrock_test.go +51 -0
@@ -162,6 +162,57 @@ func TestNormalizeBedrockToolResultsInjectsMissingResult(t *testing.T) {
162162 }
163163 }
164164
165+func bedrockResultText(t *testing.T, m Message) string {
166+ t.Helper()
167+ if m.Role != RoleTool || len(m.Content) != 1 {
168+ t.Fatalf("message = %+v, want one tool result", m)
169+ }
170+ tr, ok := m.Content[0].(ToolResultBlock)
171+ if !ok || len(tr.Content) != 1 {
172+ t.Fatalf("content = %#v, want ToolResultBlock", m.Content[0])
173+ }
174+ return tr.Content[0].(TextBlock).Text
175+}
176+
177+func TestNormalizeBedrockToolResultsPairsReusedIDsInOrder(t *testing.T) {
178+ call := Message{Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "reused", Name: "read", Arguments: json.RawMessage(`{}`)}}}
179+ result := func(text string) Message {
180+ return Message{Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "reused", Content: []Content{TextBlock{Text: text}}}}}
181+ }
182+ out := normalizeBedrockToolResults([]Message{call, result("first"), call, result("second")})
183+ if len(out) != 4 {
184+ t.Fatalf("got %d messages, want 4: %+v", len(out), out)
185+ }
186+ if got := bedrockResultText(t, out[1]); got != "first" {
187+ t.Fatalf("first result = %q", got)
188+ }
189+ if got := bedrockResultText(t, out[3]); got != "second" {
190+ t.Fatalf("second result = %q", got)
191+ }
192+}
193+
194+func TestNormalizeBedrockToolResultsDropsLeadingOrphanBeforeReusedID(t *testing.T) {
195+ call := Message{Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "reused", Name: "read", Arguments: json.RawMessage(`{}`)}}}
196+ result := func(text string) Message {
197+ return Message{Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "reused", Content: []Content{TextBlock{Text: text}}}}}
198+ }
199+ // The leading and excess results have no matching call at their position;
200+ // they must not be consumed by the later calls that reuse the ID.
201+ out := normalizeBedrockToolResults([]Message{
202+ result("orphan"), call, result("first"), result("excess"), call,
203+ })
204+ if len(out) != 4 {
205+ t.Fatalf("got %d messages, want 4: %+v", len(out), out)
206+ }
207+ if got := bedrockResultText(t, out[1]); got != "first" {
208+ t.Fatalf("first result = %q, want first", got)
209+ }
210+ tr := out[3].Content[0].(ToolResultBlock)
211+ if out[3].Role != RoleTool || tr.CallID != "reused" || !tr.IsError {
212+ t.Fatalf("second call must get a synthetic error result, got %+v", out[3])
213+ }
214+}
215+
165216 func TestBedrockModelSupportsCaching(t *testing.T) {
166217 cases := []struct {
167218 model string
M packages/provider/anthropic.go +3 -1
@@ -607,7 +607,9 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event
607607 // Remove x-api-key entirely by NOT setting it.
608608 } else {
609609 httpReq.Header.Set("accept", "text/event-stream")
610- httpReq.Header.Set("x-api-key", c.apiKey)
610+ if c.apiKey != "" {
611+ httpReq.Header.Set("x-api-key", c.apiKey)
612+ }
611613 }
612614 // Extra headers (set by anthropic-messages-compatible third parties
613615 // — kimi-coding's X-Msh-*, copilot's Editor-Plugin-Version, etc.).
M packages/provider/auth/auth_test.go +300 -0
@@ -2,8 +2,10 @@ package auth
22
33 import (
44 "context"
5+ "errors"
56 "net"
67 "net/http"
8+ "net/http/httptest"
79 "net/url"
810 "path/filepath"
911 "strings"
@@ -293,3 +295,301 @@ func TestServerCallbackError(t *testing.T) {
293295 t.Fatal("no result")
294296 }
295297 }
298+
299+func TestStartManualOAuthRejectsOpenAIWithoutCallback(t *testing.T) {
300+ m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json")))
301+ if _, err := m.StartManualOAuth("openai-codex"); err == nil {
302+ t.Fatal("expected manual OpenAI OAuth to be rejected")
303+ } else if !strings.Contains(err.Error(), "manual code login is not supported") {
304+ t.Fatalf("unexpected error: %v", err)
305+ }
306+}
307+
308+func TestBrowserOAuthSharesTransactionWithPastedCode(t *testing.T) {
309+ for _, provider := range []string{"anthropic", "openai-codex"} {
310+ t.Run(provider, func(t *testing.T) {
311+ m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json")))
312+ m.openBrowser = false
313+ t.Cleanup(m.Close)
314+ loginURL, err := m.StartOAuth(provider)
315+ if err != nil {
316+ if strings.Contains(err.Error(), "bind ") {
317+ t.Skipf("callback port unavailable: %v", err)
318+ }
319+ t.Fatal(err)
320+ }
321+ u, err := url.Parse(loginURL)
322+ if err != nil {
323+ t.Fatal(err)
324+ }
325+ state := u.Query().Get("state")
326+ if m.manualOp == nil || m.manualState != state || m.manualPKCE.Challenge != u.Query().Get("code_challenge") {
327+ t.Fatal("pasted-code path does not share the browser transaction")
328+ }
329+ if err := m.CompleteManualOAuth(context.Background(), "code#wrong-state"); err == nil || !strings.Contains(err.Error(), "state mismatch") {
330+ t.Fatalf("wrong state accepted: %v", err)
331+ }
332+ if err := m.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" {
333+ t.Fatalf("shared transaction unavailable after bad code: %v", err)
334+ }
335+ m.CancelOAuth()
336+ if err := m.CompleteManualOAuth(context.Background(), "code#"+state); err == nil || !strings.Contains(err.Error(), "no manual oauth flow") {
337+ t.Fatalf("canceled flow still accepts code: %v", err)
338+ }
339+ })
340+ }
341+}
342+
343+func TestPastedOAuthCodeUsesBrowserPKCE(t *testing.T) {
344+ requests := make(chan url.Values, 2)
345+ tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
346+ _ = r.ParseForm()
347+ requests <- r.PostForm
348+ w.Header().Set("content-type", "application/json")
349+ _, _ = w.Write([]byte(`{"access_token":"test-token","refresh_token":"test-refresh","expires_in":3600}`))
350+ }))
351+ defer tokenServer.Close()
352+ old := OpenAIOAuth.TokenURL
353+ OpenAIOAuth.TokenURL = tokenServer.URL
354+ t.Cleanup(func() { OpenAIOAuth.TokenURL = old })
355+
356+ for _, method := range []string{"paste", "callback"} {
357+ t.Run(method, func(t *testing.T) {
358+ store := NewStore(filepath.Join(t.TempDir(), "auth.json"))
359+ m := NewManager(store)
360+ m.openBrowser = false
361+ t.Cleanup(m.Close)
362+ loginURL, err := m.StartOAuth("openai-codex")
363+ if err != nil {
364+ if strings.Contains(err.Error(), "bind ") {
365+ t.Skipf("callback port unavailable: %v", err)
366+ }
367+ t.Fatal(err)
368+ }
369+ u, err := url.Parse(loginURL)
370+ if err != nil {
371+ t.Fatal(err)
372+ }
373+ verifier := m.manualPKCE.Verifier
374+ redirect := u.Query().Get("redirect_uri")
375+ codeURL := redirect + "?code=test-code&state=" + url.QueryEscape(u.Query().Get("state"))
376+ if method == "paste" {
377+ if err := m.CompleteManualOAuth(context.Background(), codeURL); err != nil {
378+ t.Fatal(err)
379+ }
380+ } else {
381+ resp, err := http.Get(codeURL)
382+ if err != nil {
383+ t.Fatal(err)
384+ }
385+ resp.Body.Close()
386+ if resp.StatusCode != http.StatusOK {
387+ t.Fatalf("callback status = %d", resp.StatusCode)
388+ }
389+ }
390+ select {
391+ case ev := <-m.Events():
392+ if ev.Kind != "started" {
393+ t.Fatalf("event = %+v", ev)
394+ }
395+ case <-time.After(2 * time.Second):
396+ t.Fatal("no started event")
397+ }
398+ select {
399+ case ev := <-m.Events():
400+ if ev.Kind != "success" || ev.Provider != "openai-codex" {
401+ t.Fatalf("event = %+v", ev)
402+ }
403+ case <-time.After(2 * time.Second):
404+ t.Fatal("no success event")
405+ }
406+ select {
407+ case form := <-requests:
408+ if form.Get("code_verifier") != verifier || form.Get("redirect_uri") != redirect || form.Get("code") != "test-code" {
409+ t.Fatalf("exchange did not use browser transaction: %v", form)
410+ }
411+ case <-time.After(2 * time.Second):
412+ t.Fatal("no token exchange")
413+ }
414+ creds, err := store.Load()
415+ if err != nil {
416+ t.Fatal(err)
417+ }
418+ if creds.OpenAI.OAuth == nil || creds.OpenAI.OAuth.AccessToken != "test-token" {
419+ t.Fatalf("stored credential: %v", creds.OpenAI.OAuth)
420+ }
421+ })
422+ }
423+}
424+
425+func TestCanceledBrowserOAuthDoesNotExchangeOrEmit(t *testing.T) {
426+ exchanged := make(chan struct{}, 1)
427+ tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
428+ exchanged <- struct{}{}
429+ w.Header().Set("content-type", "application/json")
430+ _, _ = w.Write([]byte(`{"access_token":"test-token","refresh_token":"test-refresh","expires_in":3600}`))
431+ }))
432+ defer tokenServer.Close()
433+ old := OpenAIOAuth.TokenURL
434+ OpenAIOAuth.TokenURL = tokenServer.URL
435+ t.Cleanup(func() { OpenAIOAuth.TokenURL = old })
436+
437+ store := NewStore(filepath.Join(t.TempDir(), "auth.json"))
438+ m := NewManager(store)
439+ m.openBrowser = false
440+ t.Cleanup(m.Close)
441+ loginURL, err := m.StartOAuth("openai-codex")
442+ if err != nil {
443+ if strings.Contains(err.Error(), "bind ") {
444+ t.Skipf("callback port unavailable: %v", err)
445+ }
446+ t.Fatal(err)
447+ }
448+ u, _ := url.Parse(loginURL)
449+ state := u.Query().Get("state")
450+ <-m.Events() // started
451+ m.CancelOAuth()
452+ // A pasted code after cancellation must not resurrect the transaction.
453+ if err := m.CompleteManualOAuth(context.Background(), "code#"+state); err == nil {
454+ t.Fatal("canceled transaction accepted a pasted code")
455+ }
456+ select {
457+ case <-exchanged:
458+ t.Fatal("token exchange ran after cancellation")
459+ case ev := <-m.Events():
460+ t.Fatalf("unexpected event after cancellation: %+v", ev)
461+ case <-time.After(100 * time.Millisecond):
462+ }
463+ // The callback port must be released so a new login can bind it.
464+ if _, err := m.StartOAuth("openai-codex"); err != nil {
465+ t.Fatalf("callback port not released after cancel: %v", err)
466+ }
467+}
468+
469+func TestPastedOAuthCodeConsumesTransactionOnce(t *testing.T) {
470+ tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
471+ w.Header().Set("content-type", "application/json")
472+ _, _ = w.Write([]byte(`{"access_token":"test-token","refresh_token":"test-refresh","expires_in":3600}`))
473+ }))
474+ defer tokenServer.Close()
475+ old := OpenAIOAuth.TokenURL
476+ OpenAIOAuth.TokenURL = tokenServer.URL
477+ t.Cleanup(func() { OpenAIOAuth.TokenURL = old })
478+
479+ m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json")))
480+ m.openBrowser = false
481+ t.Cleanup(m.Close)
482+ loginURL, err := m.StartOAuth("openai-codex")
483+ if err != nil {
484+ if strings.Contains(err.Error(), "bind ") {
485+ t.Skipf("callback port unavailable: %v", err)
486+ }
487+ t.Fatal(err)
488+ }
489+ u, _ := url.Parse(loginURL)
490+ state := u.Query().Get("state")
491+ if err := m.CompleteManualOAuth(context.Background(), "code#"+state); err != nil {
492+ t.Fatal(err)
493+ }
494+ if err := m.CompleteManualOAuth(context.Background(), "code#"+state); err == nil {
495+ t.Fatal("second pasted code accepted")
496+ }
497+ var success int
498+ deadline := time.After(300 * time.Millisecond)
499+loop:
500+ for {
501+ select {
502+ case ev := <-m.Events():
503+ if ev.Kind == "success" {
504+ success++
505+ }
506+ case <-deadline:
507+ break loop
508+ }
509+ }
510+ if success != 1 {
511+ t.Fatalf("success events = %d, want 1", success)
512+ }
513+}
514+
515+// A pasted-code exchange that is blocked at the token endpoint must be
516+// canceled by CancelOAuth, Close, or a newer login: no credential, no success
517+// event.
518+func TestCancelAbortsInFlightPastedCodeExchange(t *testing.T) {
519+ for _, name := range []string{"cancel", "new login", "close"} {
520+ t.Run(name, func(t *testing.T) {
521+ arrived := make(chan struct{}, 4)
522+ release := make(chan struct{})
523+ tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
524+ arrived <- struct{}{}
525+ select {
526+ case <-release:
527+ case <-r.Context().Done():
528+ return
529+ }
530+ w.Header().Set("content-type", "application/json")
531+ _, _ = w.Write([]byte(`{"access_token":"late-token","refresh_token":"late-refresh","expires_in":3600}`))
532+ }))
533+ defer tokenServer.Close()
534+ defer close(release)
535+ old := AnthropicManualOAuth.TokenURL
536+ AnthropicManualOAuth.TokenURL = tokenServer.URL
537+ t.Cleanup(func() { AnthropicManualOAuth.TokenURL = old })
538+
539+ store := NewStore(filepath.Join(t.TempDir(), "auth.json"))
540+ m := NewManager(store)
541+ m.openBrowser = false
542+ t.Cleanup(m.Close)
543+ loginURL, err := m.StartManualOAuth("anthropic")
544+ if err != nil {
545+ t.Fatal(err)
546+ }
547+ u, _ := url.Parse(loginURL)
548+ state := u.Query().Get("state")
549+ <-m.Events() // started
550+
551+ // The caller's context is never canceled: only the manager may
552+ // abort the exchange.
553+ done := make(chan error, 1)
554+ go func() { done <- m.CompleteManualOAuth(context.Background(), "code#"+state) }()
555+ select {
556+ case <-arrived:
557+ case <-time.After(2 * time.Second):
558+ t.Fatal("token exchange never started")
559+ }
560+ switch name {
561+ case "new login":
562+ if _, err := m.StartManualOAuth("anthropic"); err != nil {
563+ t.Fatal(err)
564+ }
565+ case "close":
566+ m.Close()
567+ default:
568+ m.CancelOAuth()
569+ }
570+ select {
571+ case err := <-done:
572+ if !errors.Is(err, ErrOAuthCanceled) {
573+ t.Fatalf("CompleteManualOAuth = %v, want ErrOAuthCanceled", err)
574+ }
575+ case <-time.After(2 * time.Second):
576+ t.Fatal("canceled exchange did not return")
577+ }
578+ creds, err := store.Load()
579+ if err == nil && creds.Anthropic.OAuth != nil {
580+ t.Fatalf("canceled exchange stored a credential")
581+ }
582+ for {
583+ select {
584+ case ev := <-m.Events():
585+ if ev.Kind == "success" || ev.Kind == "error" {
586+ t.Fatalf("stale event after cancel: %+v", ev)
587+ }
588+ continue
589+ default:
590+ }
591+ break
592+ }
593+ })
594+ }
595+}
M packages/provider/auth/manager.go +111 -44
@@ -40,6 +40,12 @@ type Manager struct {
4040 manualEventProvider string
4141 manualPKCE PKCE
4242 manualState string
43+
44+ // exchangeCtx/exchangeCancel own the in-flight pasted-code token exchange.
45+ // They are registered under mu so CancelOAuth, Close and a newer login can
46+ // abort it, and the exchange re-checks them before persisting anything.
47+ exchangeCtx context.Context
48+ exchangeCancel context.CancelFunc
4349 }
4450
4551 // NewManager returns a Manager bound to store.
@@ -67,14 +73,8 @@ func (m *Manager) Close() {
6773 cancel()
6874 m.keyServer = nil
6975 }
70- if m.oauthServer != nil {
71- m.oauthServer.Shutdown()
72- m.oauthServer = nil
73- }
74- if m.oauthCancel != nil {
75- m.oauthCancel()
76- m.oauthCancel = nil
77- }
76+ // stopOAuthLocked also cancels an in-flight pasted-code exchange.
77+ m.stopOAuthLocked()
7878 }
7979
8080 // ---- API key flow ----
@@ -159,13 +159,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
159159 }
160160
161161 m.mu.Lock()
162- if m.oauthServer != nil {
163- m.oauthServer.Shutdown()
164- m.oauthServer = nil
165- }
166- if m.oauthCancel != nil {
167- m.oauthCancel()
168- }
162+ m.stopOAuthLocked()
169163 m.mu.Unlock()
170164
171165 pkce, err := NewPKCE()
@@ -187,6 +181,14 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
187181 m.oauthServer = cs
188182 m.oauthCtx = ctx
189183 m.oauthCancel = cancel
184+ // The pasted-code path shares the browser transaction (same PKCE
185+ // verifier, state, and redirect URI) so a code copied from either
186+ // route is exchanged exactly once.
187+ m.manualOp = &op
188+ m.manualStoreProvider = storeProvider
189+ m.manualEventProvider = provider
190+ m.manualPKCE = pkce
191+ m.manualState = state
190192 m.mu.Unlock()
191193
192194 go m.awaitOAuth(ctx, op, storeProvider, provider, cs, pkce, state)
@@ -203,18 +205,49 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, storeProvide
203205 res, err := cs.Result(waitCtx)
204206 if err != nil {
205207 if ctx.Err() == nil {
208+ m.mu.Lock()
209+ if m.oauthCtx == ctx {
210+ m.clearManualOAuthLocked()
211+ }
212+ m.mu.Unlock()
206213 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: "timeout waiting for callback"})
207214 }
208215 return
209216 }
217+ if ctx.Err() != nil {
218+ return
219+ }
210220 if res.Err != nil {
221+ m.mu.Lock()
222+ if m.oauthCtx == ctx {
223+ m.clearManualOAuthLocked()
224+ }
225+ m.mu.Unlock()
211226 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: res.Err.Error()})
212227 return
213228 }
229+ // Claim the transaction. If a pasted code, cancellation, or a newer
230+ // login already consumed it, this callback result is stale.
231+ m.mu.Lock()
232+ if m.oauthCtx != ctx || m.manualOp == nil || ctx.Err() != nil {
233+ m.mu.Unlock()
234+ return
235+ }
236+ m.clearManualOAuthLocked()
237+ m.mu.Unlock()
214238
215- exCtx, exCancel := context.WithTimeout(context.Background(), 30*time.Second)
239+ // Derived from ctx so canceling the login also aborts the exchange.
240+ exCtx, exCancel := context.WithTimeout(ctx, 30*time.Second)
216241 defer exCancel()
217242 tok, err := op.Exchange(exCtx, res.Code, res.State, pkce)
243+ // Persist and report only while this login is still the current one. The
244+ // check and the write share the lock, so a cancel or newer login cannot
245+ // interleave between them.
246+ m.mu.Lock()
247+ defer m.mu.Unlock()
248+ if ctx.Err() != nil || m.oauthCtx != ctx {
249+ return
250+ }
218251 if err != nil {
219252 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()})
220253 return
@@ -230,9 +263,7 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, storeProvide
230263 func (m *Manager) StartKimiDeviceOAuth() (string, error) {
231264 ctx, cancel := context.WithCancel(context.Background())
232265 m.mu.Lock()
233- if m.oauthCancel != nil {
234- m.oauthCancel()
235- }
266+ m.stopOAuthLocked()
236267 m.oauthCtx = ctx
237268 m.oauthCancel = cancel
238269 m.mu.Unlock()
@@ -265,9 +296,7 @@ func (m *Manager) StartKimiDeviceOAuth() (string, error) {
265296 func (m *Manager) StartXAIDeviceOAuth() (string, error) {
266297 ctx, cancel := context.WithCancel(context.Background())
267298 m.mu.Lock()
268- if m.oauthCancel != nil {
269- m.oauthCancel()
270- }
299+ m.stopOAuthLocked()
271300 m.oauthCtx = ctx
272301 m.oauthCancel = cancel
273302 m.mu.Unlock()
@@ -300,9 +329,7 @@ func (m *Manager) StartXAIDeviceOAuth() (string, error) {
300329 func (m *Manager) StartGitHubCopilotDeviceOAuth() (string, error) {
301330 ctx, cancel := context.WithCancel(context.Background())
302331 m.mu.Lock()
303- if m.oauthCancel != nil {
304- m.oauthCancel()
305- }
332+ m.stopOAuthLocked()
306333 m.oauthCtx = ctx
307334 m.oauthCancel = cancel
308335 m.mu.Unlock()
@@ -354,8 +381,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
354381 case "anthropic":
355382 op = AnthropicManualOAuth
356383 case "openai", "openai-codex":
357- op = OpenAIOAuth
358- storeProvider = "openai"
384+ return "", fmt.Errorf("openai login requires the browser callback flow; manual code login is not supported")
359385 case "google":
360386 return "", fmt.Errorf("google login is api-key only; use api key login for gemini")
361387 case "deepseek":
@@ -364,6 +390,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
364390 return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, xai, github-copilot, deepseek, or google")
365391 }
366392
393+ m.CancelOAuth()
367394 pkce, err := NewPKCE()
368395 if err != nil {
369396 return "", err
@@ -389,27 +416,50 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
389416 // a token and stores it. Accepts either a raw code or a "code#state"
390417 // token shown by providers like Anthropic when code=true is set.
391418 func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error {
419+ code, pastedState := parseManualCodeInput(strings.TrimSpace(input))
392420 m.mu.Lock()
393- op := m.manualOp
394- storeProvider := m.manualStoreProvider
395- eventProvider := m.manualEventProvider
396- pkce := m.manualPKCE
397- state := m.manualState
398- m.mu.Unlock()
399- if op == nil {
421+ if m.manualOp == nil {
422+ m.mu.Unlock()
400423 return fmt.Errorf("no manual oauth flow in progress")
401424 }
402- code, pastedState := parseManualCodeInput(strings.TrimSpace(input))
403- if pastedState != "" {
404- state = pastedState
425+ if pastedState != "" && pastedState != m.manualState {
426+ m.mu.Unlock()
427+ return fmt.Errorf("oauth state mismatch")
405428 }
406429 if code == "" {
430+ m.mu.Unlock()
407431 return fmt.Errorf("empty code")
408432 }
433+ // Claim the transaction and stop the browser callback so only one
434+ // result can be produced.
435+ op := *m.manualOp
436+ storeProvider := m.manualStoreProvider
437+ eventProvider := m.manualEventProvider
438+ pkce := m.manualPKCE
439+ state := m.manualState
440+ m.stopOAuthLocked()
441+ // The exchange is manager-owned: derived from the caller's context but
442+ // cancelable through CancelOAuth, Close, or a newer login.
409443 exCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
444+ m.exchangeCtx, m.exchangeCancel = exCtx, cancel
445+ m.mu.Unlock()
410446 defer cancel()
447+
411448 tok, err := op.Exchange(exCtx, code, state, pkce)
449+ m.mu.Lock()
450+ defer m.mu.Unlock()
451+ if m.exchangeCtx != exCtx || exCtx.Err() != nil && err == nil {
452+ // Canceled or superseded: report the cancellation, store nothing,
453+ // emit nothing.
454+ return ErrOAuthCanceled
455+ }
456+ if m.exchangeCtx == exCtx {
457+ m.exchangeCtx, m.exchangeCancel = nil, nil
458+ }
412459 if err != nil {
460+ if errors.Is(err, context.Canceled) {
461+ return ErrOAuthCanceled
462+ }
413463 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()})
414464 return err
415465 }
@@ -417,17 +467,14 @@ func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error {
417467 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()})
418468 return err
419469 }
420- m.mu.Lock()
421- m.manualOp = nil
422- m.manualStoreProvider = ""
423- m.manualEventProvider = ""
424- m.manualPKCE = PKCE{}
425- m.manualState = ""
426- m.mu.Unlock()
427470 m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"})
428471 return nil
429472 }
430473
474+// ErrOAuthCanceled is returned to a pasted-code caller whose login was
475+// canceled or replaced while its token exchange was in flight.
476+var ErrOAuthCanceled = errors.New("oauth login canceled")
477+
431478 // parseManualCodeInput accepts any of:
432479 // - a bare authorization code
433480 // - a "code#state" pair
@@ -483,6 +530,17 @@ func HasBrowser() bool {
483530 func (m *Manager) CancelOAuth() {
484531 m.mu.Lock()
485532 defer m.mu.Unlock()
533+ m.stopOAuthLocked()
534+}
535+
536+// stopOAuthLocked cancels any in-flight OAuth flow, shuts down its callback
537+// server, and discards the shared transaction. m.mu must be held.
538+func (m *Manager) stopOAuthLocked() {
539+ if m.exchangeCancel != nil {
540+ m.exchangeCancel()
541+ m.exchangeCancel = nil
542+ m.exchangeCtx = nil
543+ }
486544 if m.oauthCancel != nil {
487545 m.oauthCancel()
488546 m.oauthCancel = nil
@@ -491,6 +549,15 @@ func (m *Manager) CancelOAuth() {
491549 m.oauthServer.Shutdown()
492550 m.oauthServer = nil
493551 }
552+ m.clearManualOAuthLocked()
553+}
554+
555+func (m *Manager) clearManualOAuthLocked() {
556+ m.manualOp = nil
557+ m.manualStoreProvider = ""
558+ m.manualEventProvider = ""
559+ m.manualPKCE = PKCE{}
560+ m.manualState = ""
494561 }
495562
496563 // ---- shared ----
M packages/provider/catalog_builtin.go +40 -13
@@ -14,7 +14,14 @@ package provider
1414 // the small seed list for kimi / deepseek / google) are not duplicated
1515 // here; they take precedence on (provider, id) match.
1616
17-func init() { Catalog = append(Catalog, builtinCatalog...) }
17+func init() {
18+ for i := range builtinCatalog {
19+ if builtinCatalog[i].Provider == ProviderGitHubCopilot {
20+ configureCopilotModel(&builtinCatalog[i])
21+ }
22+ }
23+ Catalog = append(Catalog, builtinCatalog...)
24+}
1825
1926 var builtinCatalog = []Model{
2027 // ----- amazon-bedrock -----
@@ -141,28 +148,48 @@ var builtinCatalog = []Model{
141148 {Provider: "fireworks", ID: "accounts/fireworks/routers/glm-5p1-fast", DisplayName: "GLM 5.1 Fast", ContextWindow: 202800, MaxOutput: 131072, Reasoning: true, PriceInput: 2.8, PriceOutput: 8.8, PriceCacheRead: 0.52, BaseURL: "https://api.fireworks.ai/inference"},
142149 {Provider: "fireworks", ID: "accounts/fireworks/routers/kimi-k2p6-turbo", DisplayName: "Kimi K2.6 Turbo", ContextWindow: 262000, MaxOutput: 262000, Reasoning: true, PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.3, BaseURL: "https://api.fireworks.ai/inference"},
143150 // ----- github-copilot -----
151+ // Copilot model metadata follows the upstream catalog; availability is
152+ // filtered at runtime using the account's inference model list.
153+ {Provider: "github-copilot", ID: "claude-fable-5", DisplayName: "Claude Fable 5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5, BaseURL: "https://api.individual.githubcopilot.com"},
154+ {Provider: "github-copilot", ID: "claude-fable-5.1", DisplayName: "Claude Fable 5.1", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 0.25, PriceCacheWrite: 12.5, BaseURL: "https://api.individual.githubcopilot.com"},
155+ {Provider: "github-copilot", ID: "claude-opus-4.8-fast", DisplayName: "Claude Opus 4.8 (fast mode)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, AdaptiveThinking: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5, BaseURL: "https://api.individual.githubcopilot.com"},
156+ {Provider: "github-copilot", ID: "claude-opus-5", DisplayName: "Claude Opus 5", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, AdaptiveThinking: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://api.individual.githubcopilot.com"},
157+ {Provider: "github-copilot", ID: "gemini-3.6-flash", DisplayName: "Gemini 3.6 Flash", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0.75, PriceOutput: 3.75, PriceCacheRead: 0.075, BaseURL: "https://api.individual.githubcopilot.com"},
158+ {Provider: "github-copilot", ID: "gemini-3.7-flash", DisplayName: "Gemini 3.7 Flash", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0.75, PriceOutput: 3.75, PriceCacheRead: 0.075, BaseURL: "https://api.individual.githubcopilot.com"},
159+ {Provider: "github-copilot", ID: "gemini-3.8-flash", DisplayName: "Gemini 3.8 Flash", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0.75, PriceOutput: 3.75, PriceCacheRead: 0.075, BaseURL: "https://api.individual.githubcopilot.com"},
160+ {Provider: "github-copilot", ID: "grok-4.5", DisplayName: "Grok 4.5", ContextWindow: 500000, MaxOutput: 128000, Reasoning: true, PriceInput: 2, PriceOutput: 6, PriceCacheRead: 0.5, PriceTierInputTokens: 200000, PriceInputAbove: 4, PriceOutputAbove: 12, PriceCacheReadAbove: 1, BaseURL: "https://api.individual.githubcopilot.com"},
161+ {Provider: "github-copilot", ID: "grok-4.6", DisplayName: "Grok 4.6", ContextWindow: 500000, MaxOutput: 128000, Reasoning: true, PriceInput: 2, PriceOutput: 6, PriceCacheRead: 0.5, PriceTierInputTokens: 200000, PriceInputAbove: 4, PriceOutputAbove: 12, PriceCacheReadAbove: 1, BaseURL: "https://api.individual.githubcopilot.com"},
162+ {Provider: "github-copilot", ID: "kimi-k2.7-code", DisplayName: "Kimi K2.7 Code", ContextWindow: 256000, MaxOutput: 32000, Reasoning: true, PriceInput: 0.95, PriceOutput: 4, PriceCacheRead: 0.19, BaseURL: "https://api.individual.githubcopilot.com"},
163+ {Provider: "github-copilot", ID: "kimi-k3", DisplayName: "Kimi K3", ContextWindow: 1048576, MaxOutput: 131072, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, BaseURL: "https://api.individual.githubcopilot.com"},
164+ {Provider: "github-copilot", ID: "mai-code-1.1-flash", DisplayName: "MAI-Code-1.1-Flash", ContextWindow: 256000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.2, PriceOutput: 1.2, PriceCacheRead: 0.02, BaseURL: "https://api.individual.githubcopilot.com"},
144165 {Provider: "github-copilot", ID: "claude-haiku-4.5", DisplayName: "Claude Haiku 4.5", ContextWindow: 144000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
145- {Provider: "github-copilot", ID: "claude-opus-4.5", DisplayName: "Claude Opus 4.5", ContextWindow: 160000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
146- {Provider: "github-copilot", ID: "claude-opus-4.6", DisplayName: "Claude Opus 4.6", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
147166 {Provider: "github-copilot", ID: "claude-opus-4.7", DisplayName: "Claude Opus 4.7", ContextWindow: 144000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
148167 {Provider: "github-copilot", ID: "claude-opus-4.8", DisplayName: "Claude Opus 4.8", ContextWindow: 144000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
149- {Provider: "github-copilot", ID: "claude-sonnet-4.5", DisplayName: "Claude Sonnet 4.5", ContextWindow: 144000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
150- {Provider: "github-copilot", ID: "claude-sonnet-4.6", DisplayName: "Claude Sonnet 4.6", ContextWindow: 1000000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
151168 {Provider: "github-copilot", ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
152- {Provider: "github-copilot", ID: "gemini-2.5-pro", DisplayName: "Gemini 2.5 Pro", ContextWindow: 128000, MaxOutput: 64000, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
153- {Provider: "github-copilot", ID: "gemini-3-flash-preview", DisplayName: "Gemini 3 Flash", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
154- {Provider: "github-copilot", ID: "gemini-3.1-pro-preview", DisplayName: "Gemini 3.1 Pro Preview", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
155169 {Provider: "github-copilot", ID: "gemini-3.5-flash", DisplayName: "Gemini 3.5 Flash", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
156- {Provider: "github-copilot", ID: "gpt-4.1", DisplayName: "GPT-4.1", ContextWindow: 128000, MaxOutput: 16384, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
157- {Provider: "github-copilot", ID: "gpt-4o", DisplayName: "GPT-4o", ContextWindow: 128000, MaxOutput: 4096, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
158170 {Provider: "github-copilot", ID: "gpt-5-mini", DisplayName: "GPT-5-mini", ContextWindow: 264000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
159- {Provider: "github-copilot", ID: "gpt-5.2", DisplayName: "GPT-5.2", ContextWindow: 264000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
160- {Provider: "github-copilot", ID: "gpt-5.2-codex", DisplayName: "GPT-5.2-Codex", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
161171 {Provider: "github-copilot", ID: "gpt-5.3-codex", DisplayName: "GPT-5.3-Codex", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
162172 {Provider: "github-copilot", ID: "gpt-5.4", DisplayName: "GPT-5.4", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
163173 {Provider: "github-copilot", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 Mini", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
164174 {Provider: "github-copilot", ID: "gpt-5.5", DisplayName: "GPT-5.5", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
165- {Provider: "github-copilot", ID: "grok-code-fast-1", DisplayName: "Grok Code Fast 1", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
175+ {Provider: "github-copilot", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", API: APIResponses, ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
176+ {Provider: "github-copilot", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", API: APIResponses, ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
177+ {Provider: "github-copilot", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses, ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
178+ {Provider: "github-copilot", ID: "gpt-6-astra", DisplayName: "GPT-6 Astra", API: APIResponses, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5, PriceTierInputTokens: 272000, PriceInputAbove: 20, PriceOutputAbove: 75, PriceCacheReadAbove: 2, PriceCacheWriteAbove: 25, BaseURL: "https://api.individual.githubcopilot.com"},
179+ // Availability varies by account; keep metadata for explicit selections
180+ // and existing sessions even when a model is hidden from the picker.
181+ {Provider: "github-copilot", ID: "claude-opus-4.5", DisplayName: "Claude Opus 4.5", ContextWindow: 160000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
182+ {Provider: "github-copilot", ID: "claude-opus-4.6", DisplayName: "Claude Opus 4.6", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
183+ {Provider: "github-copilot", ID: "claude-sonnet-4.5", DisplayName: "Claude Sonnet 4.5", ContextWindow: 144000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
184+ {Provider: "github-copilot", ID: "claude-sonnet-4.6", DisplayName: "Claude Sonnet 4.6", ContextWindow: 1000000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
185+ {Provider: "github-copilot", ID: "gemini-2.5-pro", DisplayName: "Gemini 2.5 Pro", ContextWindow: 128000, MaxOutput: 64000, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
186+ {Provider: "github-copilot", ID: "gemini-3-flash-preview", DisplayName: "Gemini 3 Flash", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
187+ {Provider: "github-copilot", ID: "gemini-3.1-pro-preview", DisplayName: "Gemini 3.1 Pro Preview", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
188+ {Provider: "github-copilot", ID: "gpt-4.1", API: APICompletions, DisplayName: "GPT-4.1", ContextWindow: 128000, MaxOutput: 16384, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
189+ {Provider: "github-copilot", ID: "gpt-4o", API: APICompletions, DisplayName: "GPT-4o", ContextWindow: 128000, MaxOutput: 4096, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
190+ {Provider: "github-copilot", ID: "gpt-5.2", DisplayName: "GPT-5.2", ContextWindow: 264000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
191+ {Provider: "github-copilot", ID: "gpt-5.2-codex", DisplayName: "GPT-5.2-Codex", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
192+ {Provider: "github-copilot", ID: "grok-code-fast-1", API: APICompletions, DisplayName: "Grok Code Fast 1", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
166193 // ----- google -----
167194 {Provider: "google", ID: "gemini-3-flash-preview", DisplayName: "Gemini 3 Flash Preview", ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, PriceInput: 0.5, PriceOutput: 3, PriceCacheRead: 0.05, BaseURL: "https://generativelanguage.googleapis.com/v1beta"},
168195 {Provider: "google", ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, PriceInput: 2, PriceOutput: 12, PriceCacheRead: 0.2, BaseURL: "https://generativelanguage.googleapis.com/v1beta"},
A packages/provider/copilot_catalog_test.go +110 -0
@@ -0,0 +1,110 @@
1+package provider
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestCopilotCatalogCoversPublicChatModels(t *testing.T) {
10+ groups := map[string][]string{
11+ APIAnthropicMessages: {"claude-fable-5", "claude-fable-5.1", "claude-haiku-4.5", "claude-opus-4.7", "claude-opus-4.8", "claude-opus-4.8-fast", "claude-opus-5", "claude-sonnet-5"},
12+ APIResponses: {"gpt-5-mini", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", "gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-6-astra", "grok-4.5", "grok-4.6", "mai-code-1.1-flash"},
13+ APICompletions: {"gemini-3.5-flash", "gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash", "kimi-k2.7-code", "kimi-k3"},
14+ }
15+ for api, ids := range groups {
16+ for _, id := range ids {
17+ t.Run(id, func(t *testing.T) {
18+ m, err := FindModel("github-copilot", id)
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ if m.API != api {
23+ t.Fatalf("API = %q, want %q", m.API, api)
24+ }
25+ if m.ContextWindow <= 0 || m.MaxOutput <= 0 || m.MaxOutput >= m.ContextWindow {
26+ t.Fatalf("invalid context/output limits: %d/%d", m.ContextWindow, m.MaxOutput)
27+ }
28+ if m.BaseURL != copilotDefaultBaseURL || !m.Reasoning {
29+ t.Fatal("invalid endpoint or reasoning metadata")
30+ }
31+ client := NewGithubCopilotClient("synthetic-token").(*copilotClient)
32+ captures := make(map[string]*routeCaptureClient)
33+ for _, key := range []string{APIAnthropicMessages, APIResponses, APICompletions} {
34+ capture := &routeCaptureClient{name: "github-copilot"}
35+ captures[key] = capture
36+ client.router.byAPI[key] = capture
37+ }
38+ stream, err := client.Stream(context.Background(), Request{Model: id})
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ for range stream {
43+ }
44+ for key, capture := range captures {
45+ want := 0
46+ if key == api {
47+ want = 1
48+ }
49+ if len(capture.models) != want {
50+ t.Errorf("%s routed to %s incorrectly", id, key)
51+ }
52+ }
53+ })
54+ }
55+ }
56+}
57+
58+func TestCopilotCatalogKeepsPreviouslySupportedIDsAndExcludesUtilityModels(t *testing.T) {
59+ // Rows that shipped before the catalog refresh stay selectable until
60+ // retirement is verified; account availability hides unavailable ones.
61+ kept := map[string]string{
62+ "claude-opus-4.5": APIAnthropicMessages, "claude-opus-4.6": APIAnthropicMessages,
63+ "claude-sonnet-4.5": APIAnthropicMessages, "claude-sonnet-4.6": APIAnthropicMessages,
64+ "gemini-2.5-pro": APICompletions, "gemini-3-flash-preview": APICompletions, "gemini-3.1-pro-preview": APICompletions,
65+ "gpt-4.1": APICompletions, "gpt-4o": APICompletions, "grok-code-fast-1": APICompletions,
66+ "gpt-5.2": APIResponses, "gpt-5.2-codex": APIResponses,
67+ }
68+ for id, api := range kept {
69+ m, err := FindModel("github-copilot", id)
70+ if err != nil {
71+ t.Errorf("previously supported Copilot model %q dropped: %v", id, err)
72+ continue
73+ }
74+ if m.API != api {
75+ t.Errorf("%s API = %q, want %q", id, m.API, api)
76+ }
77+ if (strings.HasSuffix(id, "-4.5") || strings.HasSuffix(id, "-4.6")) && (m.AdaptiveThinking || usesAdaptiveThinking(m)) {
78+ t.Errorf("%s must not use adaptive thinking", id)
79+ }
80+ }
81+ // GPT-5.4 nano is a Codex VS Code model, not a Copilot Chat choice.
82+ if _, err := FindModel("github-copilot", "gpt-5.4-nano"); err == nil {
83+ t.Error("Copilot catalog includes gpt-5.4-nano")
84+ }
85+}
86+
87+func TestConfigureCopilotModelAdaptiveDefaultsFollowKnownFamilies(t *testing.T) {
88+ for id, want := range map[string]bool{
89+ "claude-opus-4.6": false, "claude-sonnet-4.6": false, "claude-sonnet-4.5": false, "claude-haiku-4.5": false,
90+ "claude-opus-4.7": true, "claude-opus-4.8": true, "claude-opus-5": true, "claude-sonnet-5": true, "claude-fable-5.1": true,
91+ } {
92+ m := Model{Provider: ProviderGitHubCopilot, ID: id}
93+ configureCopilotModel(&m)
94+ if m.API != APIAnthropicMessages || m.AdaptiveThinking != want {
95+ t.Errorf("%s API=%q adaptive=%v, want adaptive=%v", id, m.API, m.AdaptiveThinking, want)
96+ }
97+ }
98+ // Explicit metadata for a 4.6 row is preserved.
99+ m := Model{Provider: ProviderGitHubCopilot, ID: "claude-opus-4.6", AdaptiveThinking: true}
100+ configureCopilotModel(&m)
101+ if !m.AdaptiveThinking {
102+ t.Error("explicit adaptive metadata was cleared")
103+ }
104+}
105+
106+func TestCopilotCatalogOmitsRetiredMAIPickerRow(t *testing.T) {
107+ if _, err := FindModel("github-copilot", "mai-code-1-flash-picker"); err == nil {
108+ t.Error("retired mai-code-1-flash-picker row is still in the catalog")
109+ }
110+}
A packages/provider/discover_copilot.go +83 -0
@@ -0,0 +1,83 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+)
10+
11+// DiscoverCopilotAvailableModels lists the account's selectable tool-capable
12+// models. It does not enable model policies or persist account-specific data.
13+func DiscoverCopilotAvailableModels(ctx context.Context, pat string) ([]string, error) {
14+ token, err := copilotCache.get(ctx, pat)
15+ if err != nil {
16+ return nil, err
17+ }
18+ baseURL := token.baseURL
19+ if baseURL == "" {
20+ baseURL = copilotDefaultBaseURL
21+ }
22+ client := &http.Client{Transport: &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat}}
23+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil)
24+ if err != nil {
25+ return nil, err
26+ }
27+ req.Header.Set("Accept", "application/json")
28+ req.Header.Set("X-GitHub-Api-Version", "2026-06-01")
29+ resp, err := client.Do(req)
30+ if err != nil {
31+ return nil, fmt.Errorf("copilot model discovery: %w", err)
32+ }
33+ defer resp.Body.Close()
34+ if resp.StatusCode != http.StatusOK {
35+ return nil, fmt.Errorf("copilot model discovery: HTTP %d", resp.StatusCode)
36+ }
37+ body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
38+ if err != nil {
39+ return nil, fmt.Errorf("copilot model discovery: %w", err)
40+ }
41+ // The fallback is deliberately restricted to the individual endpoint.
42+ individual := baseURL == copilotDefaultBaseURL
43+ return parseCopilotAvailableModels(body, individual)
44+}
45+
46+func parseCopilotAvailableModels(body []byte, allowPolicyFallback bool) ([]string, error) {
47+ var catalog struct {
48+ Data []struct {
49+ ID string `json:"id"`
50+ Picker bool `json:"model_picker_enabled"`
51+ Policy struct {
52+ State string `json:"state"`
53+ } `json:"policy"`
54+ Capabilities struct {
55+ Supports struct {
56+ ToolCalls *bool `json:"tool_calls"`
57+ } `json:"supports"`
58+ } `json:"capabilities"`
59+ } `json:"data"`
60+ }
61+ if err := json.Unmarshal(body, &catalog); err != nil || catalog.Data == nil {
62+ return nil, fmt.Errorf("copilot model discovery: invalid models response")
63+ }
64+ picker := make([]string, 0)
65+ enabled := make([]string, 0)
66+ seen := make(map[string]bool)
67+ for _, m := range catalog.Data {
68+ if m.ID == "" || seen[m.ID] || (m.Capabilities.Supports.ToolCalls != nil && !*m.Capabilities.Supports.ToolCalls) {
69+ continue
70+ }
71+ seen[m.ID] = true
72+ if m.Picker && m.Policy.State != "disabled" {
73+ picker = append(picker, m.ID)
74+ }
75+ if m.Policy.State == "enabled" {
76+ enabled = append(enabled, m.ID)
77+ }
78+ }
79+ if len(picker) == 0 && allowPolicyFallback {
80+ return enabled, nil
81+ }
82+ return picker, nil
83+}
A packages/provider/discover_copilot_test.go +103 -0
@@ -0,0 +1,103 @@
1+package provider
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "net/http/httptest"
8+ "reflect"
9+ "strings"
10+ "testing"
11+)
12+
13+func TestParseCopilotAvailableModels(t *testing.T) {
14+ for _, tc := range []struct {
15+ name, body string
16+ fallback bool
17+ want []string
18+ invalid bool
19+ }{
20+ {"picker", `{"data":[{"id":"yes","model_picker_enabled":true},{"id":"disabled","model_picker_enabled":true,"policy":{"state":"disabled"}},{"id":"hidden","policy":{"state":"enabled"}},{"id":"no-tools","model_picker_enabled":true,"capabilities":{"supports":{"tool_calls":false}}}]}`, true, []string{"yes"}, false},
21+ {"individual fallback", `{"data":[{"id":"enabled","policy":{"state":"enabled"}},{"id":"unconfigured","policy":{"state":"unconfigured"}},{"id":"disabled","policy":{"state":"disabled"}}]}`, true, []string{"enabled"}, false},
22+ {"enterprise no fallback", `{"data":[{"id":"enabled","policy":{"state":"enabled"}}]}`, false, []string{}, false},
23+ {"empty", `{"data":[]}`, true, []string{}, false},
24+ {"duplicate", `{"data":[{"id":"yes","model_picker_enabled":true},{"id":"yes","model_picker_enabled":true}]}`, false, []string{"yes"}, false},
25+ {"missing", `{}`, false, nil, true},
26+ {"null", `{"data":null}`, false, nil, true},
27+ {"invalid", `{"data":{}}`, false, nil, true},
28+ } {
29+ t.Run(tc.name, func(t *testing.T) {
30+ got, err := parseCopilotAvailableModels([]byte(tc.body), tc.fallback)
31+ if (err != nil) != tc.invalid {
32+ t.Fatalf("error = %v", err)
33+ }
34+ if !tc.invalid && !reflect.DeepEqual(got, tc.want) {
35+ t.Fatalf("got %v, want %v", got, tc.want)
36+ }
37+ })
38+ }
39+}
40+
41+func TestCopilotDiscoveryHTTP(t *testing.T) {
42+ for _, status := range []int{200, 403, 429} {
43+ t.Run(fmt.Sprint(status), func(t *testing.T) {
44+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45+ if r.Method != "GET" || r.URL.Path != "/models" {
46+ t.Error("incorrect discovery request")
47+ }
48+ if r.Header.Get("Authorization") != "Bearer synthetic-session-token" || r.Header.Get("Copilot-Integration-Id") != "vscode-chat" || r.Header.Get("X-GitHub-Api-Version") != "2026-06-01" {
49+ t.Error("incorrect discovery headers")
50+ }
51+ w.WriteHeader(status)
52+ if status != 200 {
53+ fmt.Fprint(w, "sensitive-response-body")
54+ return
55+ }
56+ fmt.Fprint(w, `{"data":[{"id":"claude-sonnet-5","model_picker_enabled":true}]}`)
57+ }))
58+ defer srv.Close()
59+ pat := seedCopilotTestToken(t, srv.URL)
60+ got, err := DiscoverCopilotAvailableModels(context.Background(), pat)
61+ if status == 200 {
62+ if err != nil || !reflect.DeepEqual(got, []string{"claude-sonnet-5"}) {
63+ t.Fatalf("got %v, %v", got, err)
64+ }
65+ } else if err == nil || strings.Contains(err.Error(), "sensitive-response-body") {
66+ t.Fatalf("unexpected error %v", err)
67+ }
68+ ctx, cancel := context.WithCancel(context.Background())
69+ cancel()
70+ if _, err := DiscoverCopilotAvailableModels(ctx, pat); err == nil {
71+ t.Error("cancellation ignored")
72+ }
73+ })
74+ }
75+}
76+
77+func TestModelAvailabilityFiltersWithoutDeletingCatalog(t *testing.T) {
78+ t.Cleanup(func() { SetModelAvailability("github-copilot", nil) })
79+ before := len(ModelsForProvider("anthropic"))
80+ ids := []string{"claude-sonnet-5"}
81+ SetModelAvailability("github-copilot", ids)
82+ ids[0] = "mutated"
83+ models := ModelsForProvider("github-copilot")
84+ if len(models) != 1 || models[0].ID != "claude-sonnet-5" {
85+ t.Fatalf("models = %v", models)
86+ }
87+ if len(ModelsForProvider("anthropic")) != before {
88+ t.Error("changed another provider")
89+ }
90+ SetModelAvailability("github-copilot", []string{})
91+ // Hiding a model must not change wire metadata for a session already using it.
92+ model, err := FindModel("github-copilot", "gpt-5.4")
93+ if err != nil || model.API != APIResponses {
94+ t.Fatalf("availability refresh lost routing metadata: %+v, %v", model, err)
95+ }
96+ if len(ModelsForProvider("github-copilot")) != 0 {
97+ t.Error("empty availability did not hide models")
98+ }
99+ SetModelAvailability("github-copilot", nil)
100+ if len(ModelsForProvider("github-copilot")) != 38 {
101+ t.Error("clearing restriction did not restore catalog")
102+ }
103+}
A packages/provider/discover_custom.go +132 -0
@@ -0,0 +1,132 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "net/url"
10+ "strings"
11+ "time"
12+)
13+
14+// Conservative metadata for models that a list endpoint describes by ID
15+// only. Explicit models.json entries override these values.
16+const (
17+ customDiscoveryContextWindow = 32768
18+ customDiscoveryMaxOutput = 4096
19+ customDiscoveryTimeout = 3 * time.Second
20+ customDiscoveryMaxBody = 8 << 20
21+)
22+
23+// customModelsURL derives the list endpoint from a custom provider base URL.
24+// A base that already ends in an API version segment such as "/v1" gets
25+// "/models" appended; a bare host gets "/v1/models".
26+func customModelsURL(baseURL string) (string, error) {
27+ u, err := url.Parse(strings.TrimSpace(baseURL))
28+ if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
29+ return "", fmt.Errorf("custom provider base URL must be an http or https URL")
30+ }
31+ u.Path = strings.TrimRight(u.Path, "/")
32+ u.RawPath = ""
33+ u.RawQuery = ""
34+ u.Fragment = ""
35+ u.User = nil
36+ base := u.String()
37+ if versionSegmentSuffix.MatchString(base) {
38+ return base + "/models", nil
39+ }
40+ return base + "/v1/models", nil
41+}
42+
43+// DiscoverCustomProvider lists models from a user-defined provider's
44+// OpenAI-style /models endpoint using the process default HTTP transport, so
45+// configured proxy settings apply. Pass an empty apiKey for keyless endpoints:
46+// no Authorization header is sent.
47+func DiscoverCustomProvider(ctx context.Context, providerID, baseURL, apiKey string) ([]Model, error) {
48+ return DiscoverCustomProviderWithClient(ctx, nil, providerID, baseURL, apiKey)
49+}
50+
51+// DiscoverCustomProviderWithClient is DiscoverCustomProvider with a caller
52+// supplied HTTP client, for example one from NewHTTPClient when the user scoped
53+// insecure TLS to custom endpoints. A nil client uses the default transport.
54+// The request is bounded by a short timeout and an 8 MiB response cap
55+// regardless of the client's own settings.
56+//
57+// Only IDs are trusted from the response; context and output limits use
58+// conservative defaults because self-hosted gateways rarely report them
59+// consistently. Entries whose ID mentions embeddings are skipped. Discovered
60+// models carry the provider's base URL so they route like models.json entries.
61+func DiscoverCustomProviderWithClient(ctx context.Context, client *http.Client, providerID, baseURL, apiKey string) ([]Model, error) {
62+ providerID = strings.TrimSpace(providerID)
63+ if providerID == "" {
64+ return nil, fmt.Errorf("custom provider discovery requires a provider id")
65+ }
66+ endpoint, err := customModelsURL(baseURL)
67+ if err != nil {
68+ return nil, err
69+ }
70+ ctx, cancel := context.WithTimeout(ctx, customDiscoveryTimeout)
71+ defer cancel()
72+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
73+ if err != nil {
74+ return nil, err
75+ }
76+ req.Header.Set("Accept", "application/json")
77+ if apiKey != "" {
78+ req.Header.Set("Authorization", "Bearer "+apiKey)
79+ }
80+ if client == nil {
81+ client = &http.Client{}
82+ }
83+ resp, err := client.Do(req)
84+ if err != nil {
85+ if ctx.Err() != nil {
86+ return nil, ctx.Err()
87+ }
88+ // The transport error can echo the URL; keep the message generic.
89+ return nil, fmt.Errorf("%s discovery request failed", providerID)
90+ }
91+ defer resp.Body.Close()
92+ if resp.StatusCode != http.StatusOK {
93+ return nil, fmt.Errorf("%s discovery HTTP %d", providerID, resp.StatusCode)
94+ }
95+ body, err := io.ReadAll(io.LimitReader(resp.Body, customDiscoveryMaxBody+1))
96+ if err != nil {
97+ return nil, fmt.Errorf("%s discovery response could not be read", providerID)
98+ }
99+ if len(body) > customDiscoveryMaxBody {
100+ return nil, fmt.Errorf("%s discovery response too large", providerID)
101+ }
102+ var page struct {
103+ Data []struct {
104+ ID string `json:"id"`
105+ } `json:"data"`
106+ }
107+ if err := json.Unmarshal(body, &page); err != nil {
108+ return nil, fmt.Errorf("%s discovery returned invalid model data", providerID)
109+ }
110+ if page.Data == nil {
111+ return nil, fmt.Errorf("%s discovery response is missing data", providerID)
112+ }
113+ models := make([]Model, 0, len(page.Data))
114+ seen := map[string]bool{}
115+ for _, d := range page.Data {
116+ id := strings.TrimSpace(d.ID)
117+ if id == "" || seen[id] || strings.Contains(strings.ToLower(id), "embed") {
118+ continue
119+ }
120+ seen[id] = true
121+ models = append(models, Model{
122+ Provider: providerID,
123+ ID: id,
124+ DisplayName: id,
125+ ContextWindow: customDiscoveryContextWindow,
126+ MaxOutput: customDiscoveryMaxOutput,
127+ BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
128+ Source: "live",
129+ })
130+ }
131+ return models, nil
132+}
A packages/provider/discover_custom_test.go +138 -0
@@ -0,0 +1,138 @@
1+package provider
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "net/http/httptest"
10+ "strings"
11+ "testing"
12+)
13+
14+func TestCustomModelsURL(t *testing.T) {
15+ cases := map[string]string{
16+ "http://127.0.0.1:48000": "http://127.0.0.1:48000/v1/models",
17+ "http://127.0.0.1:48000/": "http://127.0.0.1:48000/v1/models",
18+ "http://127.0.0.1:48000/v1": "http://127.0.0.1:48000/v1/models",
19+ "http://127.0.0.1:48000/v1/": "http://127.0.0.1:48000/v1/models",
20+ "https://llm.example.com/paas/v4": "https://llm.example.com/paas/v4/models",
21+ }
22+ for in, want := range cases {
23+ got, err := customModelsURL(in)
24+ if err != nil || got != want {
25+ t.Errorf("customModelsURL(%q) = %q, %v; want %q", in, got, err, want)
26+ }
27+ }
28+ for _, bad := range []string{"", "localhost:1234", "ftp://x", "/v1"} {
29+ if _, err := customModelsURL(bad); err == nil {
30+ t.Errorf("customModelsURL(%q) accepted", bad)
31+ }
32+ }
33+}
34+
35+func TestDiscoverCustomProvider(t *testing.T) {
36+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37+ if r.URL.Path != "/v1/models" {
38+ t.Errorf("unexpected path %s", r.URL.Path)
39+ }
40+ if r.Header.Get("Authorization") != "Bearer dummy" {
41+ t.Errorf("missing bearer header: %q", r.Header.Get("Authorization"))
42+ }
43+ fmt.Fprint(w, `{"object":"list","data":[{"id":"qwen3-coder"},{"id":"nomic-embed-text"},{"id":"qwen3-coder"},{"id":""},{"id":"gpt-oss-20b"}]}`)
44+ }))
45+ defer server.Close()
46+
47+ models, err := DiscoverCustomProvider(context.Background(), "m4", server.URL+"/v1", "dummy")
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ if len(models) != 2 {
52+ t.Fatalf("models = %+v", models)
53+ }
54+ first := models[0]
55+ if first.Provider != "m4" || first.ID != "qwen3-coder" || first.Source != "live" || first.BaseURL != server.URL+"/v1" {
56+ t.Fatalf("model = %+v", first)
57+ }
58+ if first.ContextWindow != customDiscoveryContextWindow || first.MaxOutput != customDiscoveryMaxOutput {
59+ t.Fatalf("defaults = %d/%d", first.ContextWindow, first.MaxOutput)
60+ }
61+ if models[1].ID != "gpt-oss-20b" {
62+ t.Fatalf("second model = %+v", models[1])
63+ }
64+}
65+
66+func TestDiscoverCustomProviderOmitsHeaderWithoutKey(t *testing.T) {
67+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
68+ if _, ok := r.Header["Authorization"]; ok {
69+ t.Error("unexpected Authorization header")
70+ }
71+ fmt.Fprint(w, `{"data":[{"id":"local"}]}`)
72+ }))
73+ defer server.Close()
74+ models, err := DiscoverCustomProvider(context.Background(), "m5", server.URL, "")
75+ if err != nil || len(models) != 1 {
76+ t.Fatalf("models=%+v err=%v", models, err)
77+ }
78+}
79+
80+func TestDiscoverCustomProviderErrors(t *testing.T) {
81+ status := http.StatusUnauthorized
82+ body := `{}`
83+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84+ w.WriteHeader(status)
85+ fmt.Fprint(w, body)
86+ }))
87+ defer server.Close()
88+
89+ if _, err := DiscoverCustomProvider(context.Background(), "m4", server.URL, "k"); err == nil || err.Error() != "m4 discovery HTTP 401" {
90+ t.Fatalf("status error = %v", err)
91+ }
92+ status = http.StatusOK
93+ body = `{"object":"list"}`
94+ if _, err := DiscoverCustomProvider(context.Background(), "m4", server.URL, "k"); err == nil {
95+ t.Fatal("missing data accepted")
96+ }
97+ body = `not json`
98+ if _, err := DiscoverCustomProvider(context.Background(), "m4", server.URL, "k"); err == nil {
99+ t.Fatal("invalid JSON accepted")
100+ }
101+ if _, err := DiscoverCustomProvider(context.Background(), "", server.URL, "k"); err == nil {
102+ t.Fatal("empty provider id accepted")
103+ }
104+
105+ ctx, cancel := context.WithCancel(context.Background())
106+ cancel()
107+ if _, err := DiscoverCustomProvider(ctx, "m4", server.URL, "k"); !errors.Is(err, context.Canceled) {
108+ t.Fatalf("cancellation error = %v", err)
109+ }
110+}
111+
112+func TestDiscoverCustomProviderBoundsResponseAndUsesClient(t *testing.T) {
113+ big := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
114+ fmt.Fprint(w, `{"data":[{"id":"`)
115+ chunk := make([]byte, 1<<20)
116+ for i := range chunk {
117+ chunk[i] = 'a'
118+ }
119+ for i := 0; i < 9; i++ {
120+ w.Write(chunk)
121+ }
122+ fmt.Fprint(w, `"}]}`)
123+ }))
124+ defer big.Close()
125+ if _, err := DiscoverCustomProvider(context.Background(), "m4", big.URL, ""); err == nil {
126+ t.Fatal("oversized response accepted")
127+ }
128+
129+ used := false
130+ client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
131+ used = true
132+ return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x"}]}`)), Header: http.Header{}}, nil
133+ })}
134+ models, err := DiscoverCustomProviderWithClient(context.Background(), client, "m4", "http://example.invalid/v1", "")
135+ if err != nil || !used || len(models) != 1 {
136+ t.Fatalf("custom client not used: used=%v models=%v err=%v", used, models, err)
137+ }
138+}
M packages/provider/github_copilot.go +63 -14
@@ -14,12 +14,11 @@ package provider
1414 // User-Agent). The response carries `{ "token": "...", "expires_at": <unix> }`.
1515 // 3. The short-lived token's value embeds a `proxy-ep=<host>` field that
1616 // tells us the real API host (individual users: api.individual.githubcopilot.com).
17-// 4. Inference requests go to `<host>/chat/completions` with the
17+// 4. Inference requests use Messages, Responses, or Chat Completions with the
1818 // short-lived token in `Authorization: Bearer` plus extras:
1919 // - X-Initiator: user|agent
2020 // - Openai-Intent: conversation-edits
21-// - Copilot-Vision-Request: true (when images present; not wired
22-// here because the zut openai client currently sends images inline)
21+// - Copilot-Vision-Request: true (when images are present)
2322 //
2423 // Token caching: short-lived tokens last ~30min. We cache one per PAT in
2524 // memory for the process lifetime and refresh on demand. No disk cache.
@@ -162,12 +161,21 @@ func (t *copilotRefreshTransport) RoundTrip(req *http.Request) (*http.Response,
162161 }
163162 clone := req.Clone(req.Context())
164163 clone.Header.Set("Authorization", "Bearer "+tok.value)
164+ // Messages uses Copilot Bearer auth, never an Anthropic API key.
165+ clone.Header.Del("x-api-key")
165166 // Identity headers also required on inference requests.
166167 for k, v := range copilotIdentityHeaders {
167168 clone.Header.Set(k, v)
168169 }
169- clone.Header.Set("X-Initiator", "agent")
170- clone.Header.Set("Openai-Intent", "conversation-edits")
170+ if metadata, ok := req.Context().Value(copilotRequestKey{}).(copilotRequestMetadata); ok {
171+ clone.Header.Set("X-Initiator", metadata.initiator)
172+ if metadata.vision {
173+ clone.Header.Set("Copilot-Vision-Request", "true")
174+ } else {
175+ clone.Header.Del("Copilot-Vision-Request")
176+ }
177+ clone.Header.Set("Openai-Intent", "conversation-edits")
178+ }
171179 // If the request URL host doesn't match the token's proxy-ep, rewrite
172180 // it. The openaiClient pinned a static host at construction time, but
173181 // the canonical host comes from the token.
@@ -181,20 +189,61 @@ func (t *copilotRefreshTransport) RoundTrip(req *http.Request) (*http.Response,
181189 return t.inner.RoundTrip(clone)
182190 }
183191
184-// NewGithubCopilotClient returns a Copilot-pinned OpenAI-compat client.
185-// The pat must be a GitHub Personal Access Token with Copilot access.
192+// copilotResponsesStripTransport removes the codex client's ChatGPT
193+// OAuth identity headers before handing off to copilotRefreshTransport,
194+// which applies the Copilot token and identity headers. The Copilot
195+// proxy rejects requests that carry chatgpt-account-id / openai-beta.
196+type copilotResponsesStripTransport struct {
197+ inner http.RoundTripper
198+}
199+
200+func (t *copilotResponsesStripTransport) RoundTrip(req *http.Request) (*http.Response, error) {
201+ clone := req.Clone(req.Context())
202+ clone.Header.Del("chatgpt-account-id")
203+ clone.Header.Del("openai-beta")
204+ clone.Header.Del("originator")
205+ return t.inner.RoundTrip(clone)
206+}
207+
208+const copilotDefaultBaseURL = "https://api.individual.githubcopilot.com"
209+
210+// NewGithubCopilotClient uses Messages for Claude, Responses for GPT/Grok/MAI,
211+// and Chat Completions for Gemini/Kimi. The PAT is exchanged for Copilot tokens.
186212 func NewGithubCopilotClient(pat string) Client {
187- httpClient := &http.Client{
188- Transport: &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat},
189- Timeout: 0,
190- }
213+ refresh := &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat}
214+ completionsHTTP := &http.Client{Transport: refresh, Timeout: 0}
191215 // Initial baseURL is a sane default; copilotRefreshTransport rewrites
192216 // the host on every request based on the freshly-issued token.
193- return &openaiClient{
217+ completions := &openaiClient{
194218 apiKey: pat, // unused at the wire level (transport overrides Auth) but kept for parity
195- baseURL: "https://api.individual.githubcopilot.com",
219+ baseURL: copilotDefaultBaseURL,
196220 chatCompletionsPath: "/chat/completions",
197221 name: "github-copilot",
198- http: httpClient,
222+ http: completionsHTTP,
223+ }
224+
225+ responsesHTTP := &http.Client{
226+ Transport: &copilotResponsesStripTransport{inner: refresh},
227+ Timeout: 0,
199228 }
229+ responses := &codexClient{
230+ token: pat, // unused at the wire level (transport overrides Auth) but kept for parity
231+ baseURL: copilotDefaultBaseURL + "/responses",
232+ errorLabel: "github copilot",
233+ providerName: "github-copilot",
234+ disableCLIRouting: true,
235+ http: responsesHTTP,
236+ }
237+
238+ messages := &anthropicClient{
239+ name: "github-copilot",
240+ baseURL: copilotDefaultBaseURL,
241+ // Do not set oauthTok: that would inject Claude Code identity and rename tools.
242+ http: &http.Client{Transport: refresh},
243+ }
244+ return &copilotClient{router: NewModelRouter("github-copilot", completions, map[string]Client{
245+ APICompletions: completions,
246+ APIResponses: responses,
247+ APIAnthropicMessages: messages,
248+ }).(*modelRouter)}
200249 }
A packages/provider/github_copilot_request.go +124 -0
@@ -0,0 +1,124 @@
1+package provider
2+
3+import (
4+ "context"
5+ "fmt"
6+ "regexp"
7+ "strings"
8+)
9+
10+type copilotRequestKey struct{}
11+type copilotRequestMetadata struct {
12+ initiator string
13+ vision bool
14+}
15+
16+type copilotClient struct {
17+ router *modelRouter
18+}
19+
20+func (c *copilotClient) Name() string { return "github-copilot" }
21+
22+// SetModelMetadata lets runtime-owned model switches reach the wire clients.
23+func (c *copilotClient) SetModelMetadata(model Model) { c.router.SetModelMetadata(model) }
24+
25+func (c *copilotClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
26+ metadata := copilotRequestMetadata{initiator: "user"}
27+ if len(req.Messages) > 0 && req.Messages[len(req.Messages)-1].Role != RoleUser {
28+ metadata.initiator = "agent"
29+ }
30+ for _, message := range req.Messages {
31+ if message.Role == RoleUser || message.Role == RoleTool {
32+ metadata.vision = metadata.vision || copilotHasImages(message.Content)
33+ }
34+ }
35+ // One catalog lookup decides both reasoning and route.
36+ model, findErr := FindModel(c.Name(), req.Model)
37+ tagged := findErr == nil && model.API != ""
38+ api := copilotModelAPI(req.Model)
39+ if tagged {
40+ api = model.API
41+ }
42+ // Chat Completions endpoints perform their own thinking; they reject
43+ // reasoning_effort.
44+ if api == APICompletions {
45+ req.Reasoning = ""
46+ }
47+ ctx = context.WithValue(ctx, copilotRequestKey{}, metadata)
48+ if tagged {
49+ return c.router.Stream(ctx, req)
50+ }
51+ // Use family routing for custom models that have not supplied an API tag.
52+ routed := c.router.byAPI[api]
53+ if routed == nil {
54+ return nil, fmt.Errorf("provider %q model %q has no client for wire API %q", c.Name(), req.Model, api)
55+ }
56+ return routed.Stream(ctx, req)
57+}
58+
59+func copilotHasImages(blocks []Content) bool {
60+ for _, block := range blocks {
61+ switch b := block.(type) {
62+ case ImageBlock:
63+ return true
64+ case ToolResultBlock:
65+ if copilotHasImages(b.Content) {
66+ return true
67+ }
68+ }
69+ }
70+ return false
71+}
72+
73+func copilotModelAPI(id string) string {
74+ switch {
75+ case strings.HasPrefix(id, "claude-"):
76+ return APIAnthropicMessages
77+ case strings.HasPrefix(id, "gpt-4"), strings.HasPrefix(id, "grok-code-"):
78+ // Legacy chat-only families keep Chat Completions.
79+ return APICompletions
80+ case strings.HasPrefix(id, "gpt-"), strings.HasPrefix(id, "grok-"), strings.HasPrefix(id, "mai-"), strings.HasPrefix(id, "oswe"):
81+ return APIResponses
82+ default:
83+ return APICompletions
84+ }
85+}
86+
87+func configureCopilotModel(m *Model) {
88+ switch m.API {
89+ case "":
90+ m.API = copilotModelAPI(m.ID)
91+ case "anthropic":
92+ // Legacy models.json api values map onto Copilot's wire protocols.
93+ m.API = APIAnthropicMessages
94+ case "openai":
95+ m.API = APICompletions
96+ }
97+ // Claude Haiku, 4.5 and 4.6 use explicit budgets: the canonical Anthropic
98+ // metadata (usesAdaptiveThinking) does not treat 4.6 as adaptive. Newer or
99+ // unknown Claude ids keep the adaptive default; explicit metadata wins.
100+ if m.API == APIAnthropicMessages && !usesExplicitThinkingBudget(m.ID) {
101+ m.AdaptiveThinking = true
102+ }
103+ // An explicit mapping (for example from models.json) always wins.
104+ if m.ReasoningLevelMap != nil {
105+ return
106+ }
107+ if m.API == APICompletions {
108+ m.ReasoningLevelMap = map[string]string{"minimum": "", "low": "", "medium": "", "high": "", "xhigh": "", "max": ""}
109+ }
110+ // Responses defaults expose xhigh, but these models only support low/medium/high.
111+ if m.ID == "gpt-5-mini" || m.ID == "grok-4.5" || strings.HasPrefix(m.ID, "mai-") {
112+ m.ReasoningLevelMap = map[string]string{"xhigh": "", "max": ""}
113+ }
114+}
115+
116+func usesExplicitThinkingBudget(id string) bool {
117+ if strings.HasPrefix(id, "claude-haiku-") {
118+ return true
119+ }
120+ // Claude 4 through 4.6 predate adaptive thinking (4.7 is the first).
121+ return copilotLegacyClaudeVersion.MatchString(id)
122+}
123+
124+var copilotLegacyClaudeVersion = regexp.MustCompile(`-4(\.[0-6])?$`)
M packages/provider/github_copilot_test.go +29 -0
@@ -31,6 +31,11 @@ func TestCopilotHostRewrite(t *testing.T) {
3131 baseURL: "https://api.enterprise.copilot.proxy",
3232 }
3333 copilotCache.mu.Unlock()
34+ t.Cleanup(func() {
35+ copilotCache.mu.Lock()
36+ delete(copilotCache.tokens, pat)
37+ copilotCache.mu.Unlock()
38+ })
3439
3540 mockRT := &mockRoundTripper{}
3641 transport := &copilotRefreshTransport{
@@ -68,3 +73,27 @@ func TestCopilotHostRewrite(t *testing.T) {
6873 t.Errorf("expected Host header (Request.Host) to be %q, got %q", wantHost, mockRT.lastReq.Host)
6974 }
7075 }
76+
77+func TestCopilotRoutesModernGPTToResponses(t *testing.T) {
78+ client, ok := NewGithubCopilotClient("pat").(*copilotClient)
79+ if !ok {
80+ t.Fatal("NewGithubCopilotClient did not return a copilotClient")
81+ }
82+ codex, ok := client.router.byAPI[APIResponses].(*codexClient)
83+ if !ok {
84+ t.Fatal("github-copilot Responses client is not a codexClient")
85+ }
86+ const want = "https://api.individual.githubcopilot.com/responses"
87+ if codex.baseURL != want {
88+ t.Errorf("github-copilot Responses baseURL = %q, want %q", codex.baseURL, want)
89+ }
90+ for _, id := range []string{"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra"} {
91+ m, err := FindModel("github-copilot", id)
92+ if err != nil {
93+ t.Fatalf("FindModel(%q): %v", id, err)
94+ }
95+ if m.API != APIResponses {
96+ t.Errorf("model %q API = %q, want %q", id, m.API, APIResponses)
97+ }
98+ }
99+}
A packages/provider/github_copilot_wire_test.go +221 -0
@@ -0,0 +1,221 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "testing"
11+ "time"
12+)
13+
14+func seedCopilotTestToken(t *testing.T, baseURL string) string {
15+ t.Helper()
16+ pat := "synthetic-" + t.Name()
17+ copilotCache.mu.Lock()
18+ copilotCache.tokens[pat] = copilotToken{value: "synthetic-session-token", baseURL: baseURL, expiresAt: time.Now().Add(time.Hour)}
19+ copilotCache.mu.Unlock()
20+ t.Cleanup(func() {
21+ copilotCache.mu.Lock()
22+ delete(copilotCache.tokens, pat)
23+ copilotCache.mu.Unlock()
24+ })
25+ return pat
26+}
27+
28+func TestCopilotWireProtocols(t *testing.T) {
29+ for _, tc := range []struct{ model, path string }{
30+ {"claude-sonnet-5", "/v1/messages"},
31+ {"claude-haiku-4.5", "/v1/messages"},
32+ {"gpt-5.4", "/responses"},
33+ {"grok-4.6", "/responses"},
34+ {"mai-code-1.1-flash", "/responses"},
35+ {"gemini-3.8-flash", "/chat/completions"},
36+ {"kimi-k3", "/chat/completions"},
37+ } {
38+ t.Run(tc.model, func(t *testing.T) {
39+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40+ if r.URL.Path != tc.path {
41+ t.Errorf("path = %q, want %q", r.URL.Path, tc.path)
42+ }
43+ if r.Header.Get("Authorization") != "Bearer synthetic-session-token" {
44+ t.Error("missing session Bearer auth")
45+ }
46+ if r.Header.Get("Copilot-Integration-Id") != "vscode-chat" || r.Header.Get("X-Initiator") != "user" || r.Header.Get("Openai-Intent") != "conversation-edits" {
47+ t.Error("missing Copilot identity headers")
48+ }
49+ for _, header := range []string{"x-api-key", "anthropic-beta", "x-app", "chatgpt-account-id", "openai-beta", "originator"} {
50+ if r.Header.Get(header) != "" {
51+ t.Errorf("unexpected identity header %s", header)
52+ }
53+ }
54+ var body map[string]json.RawMessage
55+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
56+ t.Error(err)
57+ w.WriteHeader(400)
58+ return
59+ }
60+ if string(body["model"]) != fmt.Sprintf("%q", tc.model) {
61+ t.Error("model ID changed on wire")
62+ }
63+ w.Header().Set("Content-Type", "text/event-stream")
64+ switch tc.path {
65+ case "/v1/messages":
66+ if len(body["messages"]) == 0 || len(body["max_tokens"]) == 0 {
67+ t.Error("missing Messages fields")
68+ }
69+ if strings.Contains(string(body["system"]), "Claude Code") {
70+ t.Error("injected Claude Code system identity")
71+ }
72+ if tc.model == "claude-sonnet-5" || tc.model == "claude-opus-5.5" {
73+ if string(body["thinking"]) != `{"type":"adaptive"}` {
74+ t.Errorf("thinking = %s", body["thinking"])
75+ }
76+ if len(body["output_config"]) == 0 || len(body["temperature"]) != 0 {
77+ t.Error("invalid adaptive thinking payload")
78+ }
79+ }
80+ fmt.Fprint(w, "event: message_start\ndata: {\"message\":{\"id\":\"test\",\"usage\":{\"input_tokens\":1}}}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\nevent: message_stop\ndata: {}\n\n")
81+ case "/responses":
82+ if len(body["input"]) == 0 || len(body["reasoning"]) == 0 {
83+ t.Error("missing Responses fields")
84+ }
85+ fmt.Fprint(w, "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
86+ default:
87+ if len(body["reasoning_effort"]) != 0 {
88+ t.Error("unsupported reasoning_effort sent")
89+ }
90+ if len(body["messages"]) == 0 {
91+ t.Error("missing chat messages")
92+ }
93+ fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
94+ }
95+ }))
96+ defer srv.Close()
97+ client := NewGithubCopilotClient(seedCopilotTestToken(t, srv.URL))
98+ stream, err := client.Stream(context.Background(), Request{Model: tc.model, Reasoning: "high", System: "Test system", Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "Hello"}}}}})
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ for event := range stream {
103+ if e, ok := event.(EventDone); ok && e.Err != nil {
104+ t.Fatalf("stream error: %v", e.Err)
105+ }
106+ if e, ok := event.(EventStart); ok && e.Provider != "github-copilot" {
107+ t.Errorf("event provider = %s", e.Provider)
108+ }
109+ }
110+ })
111+ }
112+}
113+
114+type copilotMetadataCapture struct {
115+ metadata copilotRequestMetadata
116+ reasoning string
117+}
118+
119+func (c *copilotMetadataCapture) Name() string { return "github-copilot" }
120+func (c *copilotMetadataCapture) Stream(ctx context.Context, req Request) (<-chan Event, error) {
121+ c.metadata = ctx.Value(copilotRequestKey{}).(copilotRequestMetadata)
122+ c.reasoning = req.Reasoning
123+ ch := make(chan Event)
124+ close(ch)
125+ return ch, nil
126+}
127+
128+func TestCopilotDynamicRequestMetadata(t *testing.T) {
129+ client := NewGithubCopilotClient("unused").(*copilotClient)
130+ capture := &copilotMetadataCapture{}
131+ client.router.byAPI[APICompletions] = capture
132+ for _, tc := range []struct {
133+ messages []Message
134+ initiator string
135+ vision bool
136+ }{
137+ {nil, "user", false},
138+ {[]Message{{Role: RoleUser, Content: []Content{ImageBlock{}}}}, "user", true},
139+ {[]Message{{Role: RoleTool, Content: []Content{ToolResultBlock{Content: []Content{ImageBlock{}}}}}}, "agent", true},
140+ {[]Message{{Role: RoleAssistant}}, "agent", false},
141+ {[]Message{{Role: RoleUser}}, "user", false},
142+ } {
143+ _, err := client.Stream(context.Background(), Request{Model: "kimi-k3", Reasoning: "high", Messages: tc.messages})
144+ if err != nil {
145+ t.Fatal(err)
146+ }
147+ if capture.metadata.initiator != tc.initiator || capture.metadata.vision != tc.vision || capture.reasoning != "" {
148+ t.Errorf("unexpected metadata: %+v, reasoning %q", capture.metadata, capture.reasoning)
149+ }
150+ }
151+ // The transport must propagate vision metadata and remove it on the next text-only request.
152+ mock := &mockRoundTripper{}
153+ transport := &copilotRefreshTransport{inner: mock, pat: seedCopilotTestToken(t, copilotDefaultBaseURL)}
154+ for _, vision := range []bool{true, false} {
155+ ctx := context.WithValue(context.Background(), copilotRequestKey{}, copilotRequestMetadata{initiator: "agent", vision: vision})
156+ req, _ := http.NewRequestWithContext(ctx, "POST", copilotDefaultBaseURL+"/responses", nil)
157+ req.Header.Set("Copilot-Vision-Request", "stale")
158+ resp, err := transport.RoundTrip(req)
159+ if err != nil {
160+ t.Fatal(err)
161+ }
162+ resp.Body.Close()
163+ want := ""
164+ if vision {
165+ want = "true"
166+ }
167+ if mock.lastReq.Header.Get("Copilot-Vision-Request") != want || mock.lastReq.Header.Get("X-Initiator") != "agent" {
168+ t.Error("incorrect dynamic transport headers")
169+ }
170+ if req.Header.Get("Copilot-Vision-Request") != "stale" {
171+ t.Error("transport mutated caller headers")
172+ }
173+ }
174+}
175+
176+func TestCopilotUserOnlyModelsGetProtocolDefaults(t *testing.T) {
177+ snapshot := SnapshotCatalog()
178+ t.Cleanup(func() { RestoreCatalog(snapshot) })
179+ SetUserModels([]Model{
180+ {Provider: "github-copilot", ID: "claude-custom-9", Reasoning: true, ContextWindow: 200000, MaxOutput: 8000, Source: "user"},
181+ {Provider: "github-copilot", ID: "kimi-custom", Reasoning: true, ContextWindow: 200000, MaxOutput: 8000, Source: "user"},
182+ {Provider: "github-copilot", ID: "gemini-explicit", API: APICompletions, Reasoning: true, ReasoningLevelMap: map[string]string{"high": "high"}, ContextWindow: 200000, MaxOutput: 8000, Source: "user"},
183+ {Provider: "github-copilot", ID: "legacy-anthropic", API: "anthropic", Reasoning: true, ContextWindow: 200000, MaxOutput: 8000, Source: "user"},
184+ })
185+
186+ claude, err := FindModel("github-copilot", "claude-custom-9")
187+ if err != nil || claude.API != APIAnthropicMessages || !claude.AdaptiveThinking {
188+ t.Fatalf("claude user model = %+v, %v", claude, err)
189+ }
190+ kimi, _ := FindModel("github-copilot", "kimi-custom")
191+ if kimi.API != APICompletions || ClampReasoningForModel(kimi, "high") != "" {
192+ t.Fatalf("chat user model keeps reasoning: %+v", kimi)
193+ }
194+ explicit, _ := FindModel("github-copilot", "gemini-explicit")
195+ if got := ClampReasoningForModel(explicit, "high"); got != "high" {
196+ t.Fatalf("explicit user mapping overwritten: clamp = %q", got)
197+ }
198+ legacy, _ := FindModel("github-copilot", "legacy-anthropic")
199+ if legacy.API != APIAnthropicMessages {
200+ t.Fatalf("legacy api = %q", legacy.API)
201+ }
202+ // Catalog rows overridden by models.json keep their protocol.
203+ SetUserModels([]Model{{Provider: "github-copilot", ID: "kimi-k3", API: "openai", Reasoning: true, Source: "user"}})
204+ if m, _ := FindModel("github-copilot", "kimi-k3"); m.API != APICompletions || ClampReasoningForModel(m, "high") != "" {
205+ t.Fatalf("overridden catalog row = %+v", m)
206+ }
207+
208+ // Route: family routing for untagged ids and one lookup for reasoning.
209+ SetUserModels([]Model{
210+ {Provider: "github-copilot", ID: "kimi-custom", Reasoning: true, ContextWindow: 200000, MaxOutput: 8000, Source: "user"},
211+ })
212+ client := NewGithubCopilotClient("unused").(*copilotClient)
213+ capture := &copilotMetadataCapture{}
214+ client.router.byAPI[APICompletions] = capture
215+ if _, err := client.Stream(context.Background(), Request{Model: "kimi-custom", Reasoning: "high"}); err != nil {
216+ t.Fatal(err)
217+ }
218+ if capture.reasoning != "" {
219+ t.Fatalf("reasoning_effort leaked to chat route: %q", capture.reasoning)
220+ }
221+}
M packages/provider/models.go +96 -6
@@ -491,8 +491,9 @@ var (
491491 active []Model // live overlay merged in via SetLiveModels; nil = none yet
492492 activeSet bool // true once SetLiveModels has run (even with empty live)
493493 authoritativeProviderSet map[string]struct{}
494- managedModels []Model // ephemeral models exposed by local model managers
495- userModels []Model // highest-precedence models loaded from models.json
494+ managedModels []Model // ephemeral models exposed by local model managers
495+ userModels []Model // highest-precedence models loaded from models.json
496+ modelAvailability = map[string]map[string]bool{} // account-specific, memory only
496497 )
497498
498499 // CatalogSnapshot captures the mutable catalog overlays so a caller can
@@ -505,6 +506,7 @@ type CatalogSnapshot struct {
505506 authoritativeProviderSet map[string]struct{}
506507 managedModels []Model
507508 userModels []Model
509+ modelAvailability map[string]map[string]bool
508510 }
509511
510512 // SnapshotCatalog returns a deep copy of the mutable provider catalog state.
@@ -515,6 +517,7 @@ func SnapshotCatalog() CatalogSnapshot {
515517 snapshot := CatalogSnapshot{
516518 activeSet: activeSet,
517519 authoritativeProviderSet: maps.Clone(authoritativeProviderSet),
520+ modelAvailability: cloneModelAvailability(modelAvailability),
518521 }
519522 if active != nil {
520523 snapshot.active = cloneModels(active)
@@ -540,6 +543,10 @@ func RestoreCatalog(snapshot CatalogSnapshot) {
540543 }
541544 activeSet = snapshot.activeSet
542545 authoritativeProviderSet = maps.Clone(snapshot.authoritativeProviderSet)
546+ modelAvailability = cloneModelAvailability(snapshot.modelAvailability)
547+ if modelAvailability == nil {
548+ modelAvailability = map[string]map[string]bool{}
549+ }
543550 if snapshot.managedModels != nil {
544551 managedModels = cloneModels(snapshot.managedModels)
545552 } else {
@@ -629,9 +636,18 @@ func mergeUserModels(active, users []Model) []Model {
629636 }
630637 existing.Source = "user"
631638 existing.Speculative = false
639+ if existing.Provider == ProviderGitHubCopilot {
640+ configureCopilotModel(&existing) // normalizes a legacy api value
641+ }
632642 active[idx] = existing
633643 continue
634644 }
645+ if user.Provider == ProviderGitHubCopilot {
646+ // A user-only Copilot model has no catalog row, so it would miss the
647+ // protocol-specific defaults built-in rows receive. Explicit API and
648+ // reasoning mappings from models.json are preserved.
649+ configureCopilotModel(&user)
650+ }
635651 index[key] = len(active)
636652 active = append(active, user)
637653 }
@@ -692,7 +708,11 @@ func ClearLiveModelsForProvider(name string) {
692708 // Snapshotting Catalog at var-init time would freeze the picker to the
693709 // curated seed list and drop every extra provider (openrouter, groq,
694710 // xai, ...). Deferring the read to call time avoids that ordering trap.
695-func Active() []Model {
711+func Active() []Model { return activeModels(true) }
712+
713+// activeModels builds the merged catalog. When availableOnly is set, providers
714+// with an account availability snapshot expose only the models it lists.
715+func activeModels(availableOnly bool) []Model {
696716 activeMu.RLock()
697717 defer activeMu.RUnlock()
698718 src := active
@@ -719,7 +739,56 @@ func Active() []Model {
719739 // User models are a durable, highest-precedence overlay. Apply them
720740 // after the live and managed overlays so a catalog refresh cannot
721741 // discard overrides or user-only entries.
722- return mergeUserModels(out, userModels)
742+ out = mergeUserModels(out, userModels)
743+ if availableOnly {
744+ out = filterAvailableModels(out)
745+ }
746+ return out
747+}
748+
749+func cloneModelAvailability(in map[string]map[string]bool) map[string]map[string]bool {
750+ if in == nil {
751+ return nil
752+ }
753+ out := make(map[string]map[string]bool, len(in))
754+ for name, ids := range in {
755+ out[name] = maps.Clone(ids)
756+ }
757+ return out
758+}
759+
760+// SetModelAvailability restricts a provider's visible catalog to the supplied
761+// account model IDs. A nil slice clears the restriction; an empty non-nil slice
762+// hides all of the provider's models. The snapshot is memory-only: it is never
763+// written to the shared model cache because it is account-specific.
764+func SetModelAvailability(provider string, ids []string) {
765+ activeMu.Lock()
766+ defer activeMu.Unlock()
767+ if ids == nil {
768+ delete(modelAvailability, provider)
769+ return
770+ }
771+ allowed := make(map[string]bool, len(ids))
772+ for _, id := range ids {
773+ allowed[id] = true
774+ }
775+ modelAvailability[provider] = allowed
776+}
777+
778+// filterAvailableModels removes models hidden by account availability. The
779+// caller holds activeMu and owns models.
780+func filterAvailableModels(models []Model) []Model {
781+ if len(modelAvailability) == 0 {
782+ return models
783+ }
784+ out := models[:0]
785+ for _, m := range models {
786+ allowed, restricted := modelAvailability[m.Provider]
787+ if !restricted || allowed[m.ID] {
788+ out = append(out, m)
789+ }
790+ }
791+ return out
723792 }
724793
725794 // SetManagedModels replaces the ephemeral catalog entries supplied by local
@@ -731,11 +800,32 @@ func SetManagedModels(models []Model) {
731800 managedModels = append([]Model(nil), models...)
732801 }
733802
803+// SetManagedModelsForProvider replaces only one provider's managed entries,
804+// leaving other managers' snapshots (llama.cpp, custom discovery, ...) intact.
805+// Entries whose Provider differs from providerID are ignored.
806+func SetManagedModelsForProvider(providerID string, models []Model) {
807+ activeMu.Lock()
808+ defer activeMu.Unlock()
809+ kept := make([]Model, 0, len(managedModels)+len(models))
810+ for _, m := range managedModels {
811+ if m.Provider != providerID {
812+ kept = append(kept, m)
813+ }
814+ }
815+ for _, m := range models {
816+ if m.Provider == providerID {
817+ kept = append(kept, cloneModel(m))
818+ }
819+ }
820+ managedModels = kept
821+}
822+
734823 // FindModel returns a Model by id, optionally constrained by provider.
735824 // If provider is empty, the first matching id is returned. Looks up
736-// against the merged active catalog.
825+// against the merged catalog, including models hidden by account availability:
826+// existing sessions still need stable wire and pricing metadata after a refresh.
737827 func FindModel(provider, id string) (Model, error) {
738- for _, m := range Active() {
828+ for _, m := range activeModels(false) {
739829 if m.ID == id && (provider == "" || m.Provider == provider) {
740830 return m, nil
741831 }
M packages/provider/openai.go +4 -1
@@ -614,7 +614,10 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
614614 }
615615 httpReq.Header.Set("content-type", "application/json")
616616 httpReq.Header.Set("accept", "text/event-stream")
617- httpReq.Header.Set("authorization", "Bearer "+c.apiKey)
617+ if c.apiKey != "" {
618+ // Keyless custom endpoints send no Authorization header at all.
619+ httpReq.Header.Set("authorization", "Bearer "+c.apiKey)
620+ }
618621 for k, v := range c.headers {
619622 httpReq.Header.Set(k, v)
620623 }
M packages/provider/openai_responses.go +5 -1
@@ -53,7 +53,11 @@ func (t *openaiResponsesTransport) RoundTrip(req *http.Request) (*http.Response,
5353 if clone.Header.Get("x-opencode-client") != openCodeGoClient {
5454 clone.Header.Del("user-agent")
5555 }
56- // Keep Authorization: Bearer <key> as set by the codex client.
56+ // The shared Responses client sets a Bearer header even with an empty key.
57+ // Keyless custom Responses endpoints need no Authorization header at all.
58+ if strings.TrimSpace(clone.Header.Get("authorization")) == "Bearer" {
59+ clone.Header.Del("authorization")
60+ }
5761 return t.inner.RoundTrip(clone)
5862 }
5963
M packages/provider/openai_url_test.go +6 -2
@@ -40,9 +40,13 @@ func TestChatCompletionsURL(t *testing.T) {
4040 }
4141
4242 func TestGithubCopilotChatCompletionsURL(t *testing.T) {
43- client, ok := NewGithubCopilotClient("test-token").(*openaiClient)
43+ copilot, ok := NewGithubCopilotClient("test-token").(*copilotClient)
4444 if !ok {
45- t.Fatal("NewGithubCopilotClient did not return an openaiClient")
45+ t.Fatal("NewGithubCopilotClient did not return a copilotClient")
46+ }
47+ client, ok := copilot.router.fallback.(*openaiClient)
48+ if !ok {
49+ t.Fatal("GitHub Copilot router fallback is not an openaiClient")
4650 }
4751
4852 const want = "https://api.individual.githubcopilot.com/chat/completions"
M packages/provider/provider.go +14 -17
@@ -132,30 +132,27 @@ type ReasoningBlock struct {
132132 func (ReasoningBlock) isContent() {}
133133
134134 // RepairOrphanedToolResults removes invalid tool_result content blocks
135-// and entire messages that become empty. A result is invalid when its
136-// matching tool_use ID does not appear anywhere in the messages or when
137-// an earlier result already covers the same ID. Resume tails, compaction
138-// repair, and provider request builders all need this so the upstream API
139-// sees exactly one result for each referenced tool call.
135+// and entire messages that become empty. A result is invalid when there
136+// is no preceding unmatched tool_use with the same ID in the messages.
137+// Tool IDs normally are unique, but a provider can reuse one across turns.
138+// Counting occurrences preserves each valid pair while still dropping
139+// duplicate results. Resume tails, compaction repair, and provider request
140+// builders all need this so the upstream API sees one result for each
141+// referenced tool call.
140142 func RepairOrphanedToolResults(msgs []Message) []Message {
141- useIDs := map[string]bool{}
142- for _, m := range msgs {
143- for _, c := range m.Content {
144- if tc, ok := c.(ToolCallBlock); ok {
145- useIDs[tc.ID] = true
146- }
147- }
148- }
149- resultIDs := map[string]bool{}
143+ openCalls := map[string]int{}
150144 out := make([]Message, 0, len(msgs))
151145 for _, m := range msgs {
152146 var filtered []Content
153147 for _, c := range m.Content {
154- if tr, ok := c.(ToolResultBlock); ok {
155- if !useIDs[tr.CallID] || resultIDs[tr.CallID] {
148+ switch block := c.(type) {
149+ case ToolCallBlock:
150+ openCalls[block.ID]++
151+ case ToolResultBlock:
152+ if openCalls[block.CallID] == 0 {
156153 continue
157154 }
158- resultIDs[tr.CallID] = true
155+ openCalls[block.CallID]--
159156 }
160157 filtered = append(filtered, c)
161158 }
M packages/provider/providers.go +9 -0
@@ -54,6 +54,15 @@ var builtinProviderIDs = []string{
5454 ProviderCloudflareWorkersAI, ProviderCloudflareAIGateway,
5555 }
5656
57+func isBuiltinProviderID(id string) bool {
58+ for _, p := range builtinProviderIDs {
59+ if p == id {
60+ return true
61+ }
62+ }
63+ return false
64+}
65+
5766 // BuiltinProviderIDs returns the canonical built-in provider IDs in fallback
5867 // priority order. The returned slice is independent of package state.
5968 func BuiltinProviderIDs() []string {
A packages/provider/tool_result_pairing_test.go +90 -0
@@ -0,0 +1,90 @@
1+package provider
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func pairingResult(id, text string) Message {
9+ return Message{Role: RoleTool, Content: []Content{ToolResultBlock{CallID: id, Content: []Content{TextBlock{Text: text}}}}}
10+}
11+
12+func pairingCall(id string) Message {
13+ return Message{Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: id, Name: "read"}}}
14+}
15+
16+func TestRepairOrphanedToolResultsPreservesRepeatedToolCallPairs(t *testing.T) {
17+ msgs := []Message{pairingCall("reused"), pairingResult("reused", "first"), pairingCall("reused"), pairingResult("reused", "second")}
18+ got := RepairOrphanedToolResults(msgs)
19+ if !reflect.DeepEqual(got, msgs) {
20+ t.Fatalf("got %#v, want both repeated-ID pairs preserved", got)
21+ }
22+}
23+
24+func TestAnthropicBuildRequestPreservesRepeatedToolCallPairs(t *testing.T) {
25+ c := NewAnthropic("test-key", "").(*anthropicClient)
26+ wire, err := c.buildRequest(Request{
27+ Model: "claude-sonnet-4-5",
28+ Messages: []Message{
29+ pairingCall("reused"), pairingResult("reused", "first"),
30+ pairingCall("reused"), pairingResult("reused", "second"),
31+ },
32+ })
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if len(wire.Messages) != 4 {
37+ t.Fatalf("messages=%d, want both repeated-ID tool result pairs", len(wire.Messages))
38+ }
39+ for _, index := range []int{1, 3} {
40+ if len(wire.Messages[index].Content) != 1 {
41+ t.Fatalf("message %d content=%d, want 1 tool result", index, len(wire.Messages[index].Content))
42+ }
43+ if _, ok := wire.Messages[index].Content[0].(anthToolResultBlock); !ok {
44+ t.Fatalf("message %d content type=%T, want anthToolResultBlock", index, wire.Messages[index].Content[0])
45+ }
46+ }
47+}
48+
49+func TestRepairOrphanedToolResultsDropsLeadingOrphanBeforeReusedID(t *testing.T) {
50+ user := Message{Role: RoleUser, Content: []Content{TextBlock{Text: "next turn"}}}
51+ messages := []Message{pairingResult("reused", "orphan"), user, pairingCall("reused"), pairingResult("reused", "valid")}
52+ want := messages[1:]
53+ if got := RepairOrphanedToolResults(messages); !reflect.DeepEqual(got, want) {
54+ t.Fatalf("got %#v, want leading orphan removed and later valid pair retained", got)
55+ }
56+}
57+
58+func TestRepairOrphanedToolResultsDropsExcessAndOrphans(t *testing.T) {
59+ msgs := []Message{
60+ pairingCall("a"), pairingResult("a", "one"), pairingResult("a", "dup"), pairingResult("orphan", "x"),
61+ }
62+ got := RepairOrphanedToolResults(msgs)
63+ if len(got) != 2 {
64+ t.Fatalf("messages=%d, want 2", len(got))
65+ }
66+ if tr := got[1].Content[0].(ToolResultBlock); tr.Content[0].(TextBlock).Text != "one" {
67+ t.Fatalf("kept wrong result: %#v", tr)
68+ }
69+}
70+
71+func TestRepairOrphanedToolResultsKeepsMixedTextAndDoesNotMutate(t *testing.T) {
72+ msgs := []Message{
73+ pairingCall("a"),
74+ {Role: RoleTool, Content: []Content{
75+ TextBlock{Text: "note"},
76+ ToolResultBlock{CallID: "a"},
77+ ToolResultBlock{CallID: "a"},
78+ ToolResultBlock{CallID: "orphan"},
79+ }},
80+ }
81+ orig := []Message{msgs[0], {Role: RoleTool, Content: append([]Content(nil), msgs[1].Content...)}}
82+ got := RepairOrphanedToolResults(msgs)
83+ want := []Content{TextBlock{Text: "note"}, ToolResultBlock{CallID: "a"}}
84+ if len(got) != 2 || !reflect.DeepEqual(got[1].Content, want) {
85+ t.Fatalf("got %#v, want %#v", got, want)
86+ }
87+ if !reflect.DeepEqual(msgs, orig) {
88+ t.Fatalf("input mutated: %#v", msgs)
89+ }
90+}
M packages/provider/usermodels.go +42 -6
@@ -37,9 +37,14 @@ type UserModelsFile struct {
3737
3838 // UserProvider groups models under a provider key.
3939 type UserProvider struct {
40- BaseURL string `json:"baseUrl,omitempty"`
41- API string `json:"api,omitempty"` // "openai" (default), "openai-responses", or "anthropic"
42- Models []UserModel `json:"models"`
40+ BaseURL string `json:"baseUrl,omitempty"`
41+ API string `json:"api,omitempty"` // "openai" (default), "openai-responses", or "anthropic"
42+ Auth string `json:"auth,omitempty"` // "none" disables credentials, including stored keys
43+ // Discover enables live model listing from the provider-level
44+ // baseUrl's /models endpoint. Off by default so existing custom
45+ // providers never gain unexpected network traffic.
46+ Discover bool `json:"discover,omitempty"`
47+ Models []UserModel `json:"models"`
4348 }
4449
4550 // CustomProviderConfig holds runtime config for a user-defined provider
@@ -47,6 +52,10 @@ type UserProvider struct {
4752 type CustomProviderConfig struct {
4853 BaseURL string
4954 API string // "openai", "openai-responses", or "anthropic"
55+ NoAuth bool // explicitly configured for keyless access
56+ // Discover reports whether live model discovery is enabled. It is
57+ // only true when a provider-level BaseURL is present.
58+ Discover bool
5059 }
5160
5261 var (
@@ -167,7 +176,30 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
167176 break
168177 }
169178 }
179+ // Discovery needs one endpoint to list from, so it is tied to the
180+ // provider-level baseUrl rather than per-model overrides.
181+ discover := prov.Discover
182+ if discover && strings.TrimSpace(prov.BaseURL) == "" {
183+ warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets discover without a provider-level baseUrl; discovery disabled", providerName))
184+ discover = false
185+ }
186+ if prov.Auth != "" && prov.BaseURL == "" && !hasModelBaseURL {
187+ warnings = append(warnings, fmt.Sprintf("models.json: provider %q sets auth without a baseUrl; ignored", providerName))
188+ }
170189 if prov.BaseURL != "" || prov.API != "" || hasModelBaseURL {
190+ auth := strings.ToLower(strings.TrimSpace(prov.Auth))
191+ if auth != "" && auth != "none" {
192+ warnings = append(warnings, fmt.Sprintf("models.json: provider %q has unknown auth %q; using credentials when available", providerName, prov.Auth))
193+ }
194+ if prov.BaseURL == "" && !hasModelBaseURL {
195+ auth = ""
196+ }
197+ if auth == "none" && isBuiltinProviderID(normalized) {
198+ // Built-in providers keep their normal credential flow; a
199+ // models.json override must not weaken it.
200+ warnings = append(warnings, fmt.Sprintf("models.json: provider %q is a built-in provider; auth \"none\" ignored", providerName))
201+ auth = ""
202+ }
171203 api := strings.ToLower(strings.TrimSpace(prov.API))
172204 if api == "" {
173205 api = "openai"
@@ -185,8 +217,10 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
185217 api = "openai"
186218 }
187219 customProviders[normalized] = CustomProviderConfig{
188- BaseURL: prov.BaseURL,
189- API: api,
220+ BaseURL: prov.BaseURL,
221+ API: api,
222+ NoAuth: auth == "none",
223+ Discover: discover,
190224 }
191225 }
192226
@@ -217,7 +251,9 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
217251 modelBaseURL = prov.BaseURL
218252 }
219253 modelAPI := ""
220- if cfg, ok := customProviders[normalized]; ok {
254+ if cfg, ok := customProviders[normalized]; ok && (prov.API != "" || !isBuiltinProviderID(normalized)) {
255+ // A built-in provider without an explicit api keeps its own
256+ // protocol routing; the openai default is for custom providers.
221257 modelAPI = cfg.API
222258 }
223259 m := Model{
M packages/provider/usermodels_test.go +140 -0
@@ -254,3 +254,143 @@ func TestUserPriceOverrideClearsCatalogTiers(t *testing.T) {
254254 t.Fatalf("cost = %v, want user base price %v", got, want)
255255 }
256256 }
257+
258+func TestLoadUserModelsKeylessAuthRequiresEndpoint(t *testing.T) {
259+ path := filepath.Join(t.TempDir(), "models.json")
260+ if err := os.WriteFile(path, []byte(`{"providers":{
261+ "keyless":{"baseUrl":"http://localhost:8888/v1","auth":"none","models":[{"id":"m"}]},
262+ "unknown":{"baseUrl":"http://localhost:9999/v1","auth":"typo","models":[{"id":"m"}]},
263+ "no-url":{"auth":"none","models":[{"id":"m"}]}
264+ }}`), 0o644); err != nil {
265+ t.Fatal(err)
266+ }
267+ _, warnings := LoadUserModelsWithWarnings(path)
268+ if !CustomProviders()["keyless"].NoAuth || CustomProviders()["unknown"].NoAuth || CustomProviders()["no-url"].NoAuth {
269+ t.Fatalf("invalid keyless configuration: %+v", CustomProviders())
270+ }
271+ if len(warnings) != 2 || !strings.Contains(strings.Join(warnings, "\n"), "unknown auth") || !strings.Contains(strings.Join(warnings, "\n"), "without a baseUrl") {
272+ t.Fatalf("warnings = %v, want unknown auth and missing URL warnings", warnings)
273+ }
274+}
275+
276+func TestLoadUserModelsDiscoverFlag(t *testing.T) {
277+ path := filepath.Join(t.TempDir(), "models.json")
278+ if err := os.WriteFile(path, []byte(`{
279+ "providers": {
280+ "m4": {"baseUrl": "http://127.0.0.1:48000/v1", "discover": true},
281+ "m5": {"baseUrl": "http://127.0.0.1:48001/v1", "models": [{"id": "pinned"}]},
282+ "no-base": {"discover": true, "models": [{"id": "m1", "baseUrl": "http://127.0.0.1:48002/v1"}]}
283+ }
284+ }`), 0o644); err != nil {
285+ t.Fatal(err)
286+ }
287+ _, warnings := LoadUserModelsWithWarnings(path)
288+ if len(warnings) != 1 || !strings.Contains(warnings[0], `"no-base"`) || !strings.Contains(warnings[0], "discover") {
289+ t.Fatalf("warnings = %v, want one discover warning for no-base", warnings)
290+ }
291+ cps := CustomProviders()
292+ if !cps["m4"].Discover {
293+ t.Fatal("m4 discovery not enabled")
294+ }
295+ if cps["m5"].Discover {
296+ t.Fatal("m5 discovery enabled without flag")
297+ }
298+ if cfg, ok := cps["no-base"]; !ok || cfg.Discover {
299+ t.Fatalf("no-base = %+v, %v; want registered without discovery", cfg, ok)
300+ }
301+}
302+
303+func TestLoadUserModelsBuiltinProviderIgnoresAuthNone(t *testing.T) {
304+ path := filepath.Join(t.TempDir(), "models.json")
305+ if err := os.WriteFile(path, []byte(`{"providers":{
306+ "anthropic":{"baseUrl":"http://localhost:8888/v1","auth":"none","models":[{"id":"m"}]},
307+ "local-x":{"baseUrl":"http://localhost:8889/v1","auth":"none","models":[{"id":"m"}]}
308+ }}`), 0o644); err != nil {
309+ t.Fatal(err)
310+ }
311+ _, warnings := LoadUserModelsWithWarnings(path)
312+ cps := CustomProviders()
313+ if cps["anthropic"].NoAuth {
314+ t.Fatal("built-in provider accepted auth none")
315+ }
316+ if !cps["local-x"].NoAuth {
317+ t.Fatal("custom provider lost auth none")
318+ }
319+ if len(warnings) != 1 || !strings.Contains(warnings[0], `"anthropic"`) || !strings.Contains(warnings[0], "built-in") {
320+ t.Fatalf("warnings = %v", warnings)
321+ }
322+}
323+
324+func TestLoadUserModelsBuiltinBaseURLOnlyKeepsProtocolDefaults(t *testing.T) {
325+ SetLiveModels(nil)
326+ SetUserModels(nil)
327+ t.Cleanup(func() {
328+ SetLiveModels(nil)
329+ SetUserModels(nil)
330+ LoadUserModelsWithWarnings(filepath.Join(t.TempDir(), "missing.json"))
331+ })
332+ path := filepath.Join(t.TempDir(), "models.json")
333+ if err := os.WriteFile(path, []byte(`{"providers":{
334+ "github-copilot":{"baseUrl":"https://copilot.proxy.example","models":[
335+ {"id":"claude-opus-4.7","reasoning":true},{"id":"gpt-5.5","reasoning":true},{"id":"proxy-only"}]},
336+ "company-proxy":{"baseUrl":"https://llm.example.com/v1","models":[{"id":"m"}]}
337+ }}`), 0o644); err != nil {
338+ t.Fatal(err)
339+ }
340+ models, warnings := LoadUserModelsWithWarnings(path)
341+ if len(warnings) != 0 {
342+ t.Fatalf("warnings = %v", warnings)
343+ }
344+ for _, m := range models {
345+ switch m.Provider + "/" + m.ID {
346+ case "github-copilot/claude-opus-4.7", "github-copilot/gpt-5.5", "github-copilot/proxy-only":
347+ if m.API != "" {
348+ t.Errorf("%s/%s API = %q, want empty so built-in routing applies", m.Provider, m.ID, m.API)
349+ }
350+ case "company-proxy/m":
351+ if m.API != "openai" {
352+ t.Errorf("custom provider API = %q, want openai default", m.API)
353+ }
354+ }
355+ }
356+ SetUserModels(models)
357+ for id, want := range map[string]string{"claude-opus-4.7": APIAnthropicMessages, "gpt-5.5": APIResponses, "proxy-only": APICompletions} {
358+ got, err := FindModel("github-copilot", id)
359+ if err != nil {
360+ t.Fatal(err)
361+ }
362+ if got.API != want || got.BaseURL != "https://copilot.proxy.example" {
363+ t.Errorf("%s API=%q baseURL=%q, want API %q with override URL", id, got.API, got.BaseURL, want)
364+ }
365+ }
366+}
367+
368+func TestLoadUserModelsBuiltinExplicitAPIOverridesDefaults(t *testing.T) {
369+ SetLiveModels(nil)
370+ SetUserModels(nil)
371+ t.Cleanup(func() {
372+ SetLiveModels(nil)
373+ SetUserModels(nil)
374+ LoadUserModelsWithWarnings(filepath.Join(t.TempDir(), "missing.json"))
375+ })
376+ path := filepath.Join(t.TempDir(), "models.json")
377+ if err := os.WriteFile(path, []byte(`{"providers":{
378+ "github-copilot":{"baseUrl":"https://copilot.proxy.example","api":"openai","models":[{"id":"claude-opus-4.7","reasoning":true},{"id":"gpt-5.5","reasoning":true}]}
379+ }}`), 0o644); err != nil {
380+ t.Fatal(err)
381+ }
382+ models, warnings := LoadUserModelsWithWarnings(path)
383+ if len(warnings) != 0 {
384+ t.Fatalf("warnings = %v", warnings)
385+ }
386+ SetUserModels(models)
387+ for _, id := range []string{"claude-opus-4.7", "gpt-5.5"} {
388+ got, err := FindModel("github-copilot", id)
389+ if err != nil {
390+ t.Fatal(err)
391+ }
392+ if got.API != APICompletions {
393+ t.Errorf("%s API = %q, want explicit openai alias mapped to %q", id, got.API, APICompletions)
394+ }
395+ }
396+}
M packages/tui/highlight.go +40 -11
@@ -251,13 +251,18 @@ func LanguageFromPath(p string) string {
251251 return extLang[ext]
252252 }
253253
254-// chooseLexer picks the best lexer for a language hint; falls back to
255-// nil (no highlighting) if the hint is empty or unknown.
256254 // stripANSIBackgrounds removes SGR background-color attributes emitted by
257255 // terminal syntax formatters while preserving foreground colors and styles.
258256 // Chroma's inherited styles can assign black backgrounds to a few tokens
259257 // (notably punctuation/error-ish spans), which looks like random black blocks
260258 // inside zut's dark-gray tool boxes. Foreground color is enough for code.
259+//
260+// Extended color parameters ("38;5;N", "38;2;R;G;B" and their "48;..."
261+// background twins) are consumed as whole units. The color index of an
262+// extended foreground is a value, not a standalone ANSI code: stripping a
263+// "45" or "101" out of "\x1b[38;5;45m" leaves the malformed "\x1b[38;5m",
264+// whose leftover "5" terminals read as blink (SGR 5). A truncated extended
265+// color cannot be rendered anyway, so it is dropped rather than forwarded.
261266 func stripANSIBackgrounds(s string) string {
262267 var out strings.Builder
263268 out.Grow(len(s))
@@ -280,16 +285,14 @@ func stripANSIBackgrounds(s string) string {
280285 for p := 0; p < len(params); p++ {
281286 param := params[p]
282287 switch param {
283- case "48":
284- // 48;5;N or 48;2;R;G;B background color.
285- if p+1 < len(params) && params[p+1] == "5" {
286- p += 2
287- continue
288- }
289- if p+1 < len(params) && params[p+1] == "2" {
290- p += 4
291- continue
288+ case "38", "48":
289+ span, complete := extendedColorSpan(params, p)
290+ if complete && param == "38" {
291+ kept = append(kept, span...)
292292 }
293+ // Consume the whole unit either way: a truncated
294+ // "38;5" must not leave its "5" to be read as blink.
295+ p += len(span) - 1
293296 continue
294297 case "49":
295298 continue
@@ -313,6 +316,32 @@ func stripANSIBackgrounds(s string) string {
313316 return out.String()
314317 }
315318
319+// extendedColorSpan returns the parameter run that belongs to the extended
320+// color starting at params[i] ("38" or "48"): "5;N" or "2;R;G;B". complete
321+// reports whether the run can be rendered; a truncated run is still returned
322+// so its parameters are consumed instead of being reprocessed as standalone
323+// codes (a bare "5" would mean blink).
324+func extendedColorSpan(params []string, i int) (span []string, complete bool) {
325+ if i+1 >= len(params) {
326+ return params[i : i+1], false
327+ }
328+ switch params[i+1] {
329+ case "5":
330+ if i+2 >= len(params) {
331+ return params[i : i+2], false
332+ }
333+ return params[i : i+3], true
334+ case "2":
335+ if i+5 > len(params) {
336+ return params[i:], false
337+ }
338+ return params[i : i+5], true
339+ }
340+ return params[i : i+1], false
341+}
342+
343+// chooseLexer picks the best lexer for a language hint; falls back to
344+// nil (no highlighting) if the hint is empty or unknown.
316345 func chooseLexer(lang string) chroma.Lexer {
317346 if lang == "" {
318347 return nil
A packages/tui/highlight_test.go +63 -0
@@ -0,0 +1,63 @@
1+package tui
2+
3+import (
4+ "regexp"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestStripANSIBackgroundsKeepsExtendedColors(t *testing.T) {
10+ cases := []struct{ name, in, want string }{
11+ {"256 foreground in background range", "\x1b[38;5;45mstring\x1b[0m", "\x1b[38;5;45mstring\x1b[0m"},
12+ {"256 foreground index 48", "\x1b[38;5;48mx\x1b[0m", "\x1b[38;5;48mx\x1b[0m"},
13+ {"256 foreground index 49", "\x1b[38;5;49mx\x1b[0m", "\x1b[38;5;49mx\x1b[0m"},
14+ {"256 foreground bright range", "\x1b[38;5;101mcomment\x1b[0m", "\x1b[38;5;101mcomment\x1b[0m"},
15+ {"256 foreground index 107", "\x1b[38;5;107mx\x1b[0m", "\x1b[38;5;107mx\x1b[0m"},
16+ {"bold plus foreground", "\x1b[1;38;5;45mstring\x1b[0m", "\x1b[1;38;5;45mstring\x1b[0m"},
17+ {"truecolor foreground", "\x1b[38;2;40;101;49mstring\x1b[0m", "\x1b[38;2;40;101;49mstring\x1b[0m"},
18+ {"plain text", "hello", "hello"},
19+ {"reset", "\x1b[0m", "\x1b[0m"},
20+ {"256 background dropped", "\x1b[48;5;45mbg\x1b[0m", "bg\x1b[0m"},
21+ {"separate background dropped", "\x1b[38;5;45m\x1b[48;5;0mtext", "\x1b[38;5;45mtext"},
22+ {"combined background dropped", "\x1b[38;5;45;48;5;0mtext", "\x1b[38;5;45mtext"},
23+ {"truecolor background dropped", "\x1b[48;2;9;0;21mbg\x1b[0m", "bg\x1b[0m"},
24+ {"default background dropped", "\x1b[49mbg", "bg"},
25+ {"ANSI background dropped", "\x1b[41mbg", "bg"},
26+ {"bright background dropped", "\x1b[101mbg", "bg"},
27+ {"bare foreground introducer dropped", "\x1b[38mtext", "text"},
28+ {"truncated indexed foreground", "\x1b[38;5mstring", "string"},
29+ {"truncated indexed background", "\x1b[48;5mstring", "string"},
30+ {"truncated truecolor foreground", "\x1b[38;2;1;2mtext", "text"},
31+ {"truncated truecolor background", "\x1b[48;2;1;2mtext", "text"},
32+ // An unsupported extended selector is not consumed as a color unit;
33+ // unrelated standalone attributes keep their ordinary SGR semantics.
34+ {"unknown selector preserves reverse", "\x1b[38;7mtext", "\x1b[7mtext"},
35+ }
36+ for _, tc := range cases {
37+ t.Run(tc.name, func(t *testing.T) {
38+ if got := stripANSIBackgrounds(tc.in); got != tc.want {
39+ t.Fatalf("got %q, want %q", got, tc.want)
40+ }
41+ })
42+ }
43+}
44+
45+// Chroma maps this custom type color to xterm index 45, which the old
46+// background filter removed from the foreground unit.
47+func TestHighlightCodeExtendedColorIsWellFormed(t *testing.T) {
48+ th := Dark
49+ th.Syntax = SyntaxTheme{KeywordType: "#00e5ff", NameBuiltin: "#00e5ff"}
50+ joined := strings.Join(th.HighlightCode("func splitFrontmatter(raw string) (string, string) {\n", "go"), "\n")
51+ if strings.Contains(joined, "\x1b[38;5m") || strings.Contains(joined, "\x1b[48;5m") {
52+ t.Fatalf("highlighting emitted a malformed extended color: %q", joined)
53+ }
54+ idx := strings.Index(joined, "string")
55+ if idx < 0 {
56+ t.Fatalf("type keyword missing: %q", joined)
57+ }
58+ prefix := joined[:idx]
59+ escape := strings.LastIndex(prefix, "\x1b[")
60+ if escape < 0 || !regexp.MustCompile(`^\x1b\[(?:[0-9]+;)*38;5;[0-9]+m$`).MatchString(prefix[escape:]) {
61+ t.Fatalf("type keyword lost its foreground color: %q", joined)
62+ }
63+}
M packages/tui/input.go +9 -0
@@ -275,6 +275,15 @@ func (r *Reader) readEscape() (Key, error) {
275275 return Key{}, err
276276 }
277277 switch c {
278+ // Terminals in application cursor mode send arrows as SS3.
279+ case 'A':
280+ return Key{Kind: KeyUp}, nil
281+ case 'B':
282+ return Key{Kind: KeyDown}, nil
283+ case 'C':
284+ return Key{Kind: KeyRight}, nil
285+ case 'D':
286+ return Key{Kind: KeyLeft}, nil
278287 case 'H':
279288 return Key{Kind: KeyHome}, nil
280289 case 'F':
M packages/tui/input_test.go +19 -0
@@ -243,6 +243,25 @@ func TestReaderWithoutPeekDoesNotBlockBareEscapeOrStartAnotherPump(t *testing.T)
243243 }
244244 }
245245
246+func TestReaderParsesSS3CursorKeys(t *testing.T) {
247+ cases := []struct {
248+ seq string
249+ want KeyKind
250+ }{
251+ {"\x1bOA", KeyUp},
252+ {"\x1bOB", KeyDown},
253+ {"\x1bOC", KeyRight},
254+ {"\x1bOD", KeyLeft},
255+ {"\x1bOH", KeyHome},
256+ {"\x1bOF", KeyEnd},
257+ }
258+ for _, tc := range cases {
259+ if k := readKey(t, tc.seq); k.Kind != tc.want {
260+ t.Fatalf("Read(%q) kind=%v, want %v", tc.seq, k.Kind, tc.want)
261+ }
262+ }
263+}
264+
246265 func TestReaderParsesSGRMouseWheel(t *testing.T) {
247266 cases := []struct {
248267 seq string
A packages/tui/pinned_skills.go +20 -0
@@ -0,0 +1,20 @@
1+package tui
2+
3+import (
4+ "strings"
5+
6+ "github.com/mattn/go-runewidth"
7+)
8+
9+// RenderPinnedSkills matches the startup resource sections, with an accent
10+// heading and indented, muted names. A leading blank separates it from chat.
11+func RenderPinnedSkills(th Theme, names []string, width int) []string {
12+ if len(names) == 0 || width < 4 {
13+ return nil
14+ }
15+ out := []string{"", th.FGColor(th.Accent, runewidth.Truncate("[Pinned skills]", width, "..."))}
16+ for _, line := range wrapLine(strings.Join(names, ", "), width-2, "") {
17+ out = append(out, th.FGColor(th.Muted, " "+line))
18+ }
19+ return out
20+}
A packages/tui/pinned_skills_test.go +36 -0
@@ -0,0 +1,36 @@
1+package tui
2+
3+import (
4+ "reflect"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestPinnedSkillsMatchesResourceSections(t *testing.T) {
10+ th := Theme{Accent: Color256(75), Muted: Color256(245)}
11+ got := RenderPinnedSkills(th, []string{"review", "vscode"}, 80)
12+ want := []string{"", th.FGColor(th.Accent, "[Pinned skills]"), th.FGColor(th.Muted, " review, vscode")}
13+ if !reflect.DeepEqual(got, want) {
14+ t.Fatalf("rows = %q, want %q", got, want)
15+ }
16+ if got := RenderPinnedSkills(th, nil, 80); len(got) != 0 {
17+ t.Fatalf("empty pins rendered rows: %q", got)
18+ }
19+}
20+
21+func TestPinnedSkillsWrapNamesToTerminalWidth(t *testing.T) {
22+ for _, width := range []int{1, 4, 12, 30, 80} {
23+ rows := RenderPinnedSkills(Theme{}, []string{strings.Repeat("界", 20), "review", "vscode"}, width)
24+ for _, row := range rows {
25+ if got := visibleWidth(row); got > width {
26+ t.Fatalf("row width %d exceeds %d: %q", got, width, row)
27+ }
28+ }
29+ if width >= 4 {
30+ body := strings.Join(rows[2:], "")
31+ if !strings.Contains(body, "vscode") && width >= 12 {
32+ t.Fatalf("last skill was truncated: %q", rows)
33+ }
34+ }
35+ }
36+}

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close