detached cb06820

Merge pull request #115 from bnema/integrate-upstream-prs

feat: integrate upstream improvements
commit
cb068204379efacac50df352d40a72c9e63f6952
author
bnema · · 2026-08-11 07:26 UTC
committer
GitHub · · 2026-08-11 07:26 UTC
merge of
59c2524 + b081de6 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
30 files · +610 -79
M README.md +19 -4
@@ -145,12 +145,24 @@ $ZUT_HOME/
145145 └── logs/ # app log files
146146 ```
147147
148-Drop a `SYSTEM.md` in `$ZUT_HOME` to replace the built-in identity for every run. Existing append addenda, including `AGENTS.md`, skills, and enabled Ponytail coding mode, remain layered on top. `--system-prompt` wins per invocation unless a selected replace-mode subagent profile supplies the child's identity. Delete the file to revert to the default identity.
148+Drop a `SYSTEM.md` in `$ZUT_HOME` to replace the built-in identity and docs guidance for every run. Existing append addenda, including `AGENTS.md`, skills, and enabled Ponytail coding mode, remain layered on top. `--system-prompt` wins per invocation unless a selected replace-mode subagent profile supplies the child's identity. Delete the file to revert to the default identity.
149149
150150 Ponytail coding mode is enabled by default, including when an existing `config.json` does not contain a `ponytail_enabled` field. When disabled, its compact guidance is omitted from resolved system prompts; when enabled, the guidance tells the model to apply itself only to engineering work. The setting persists its explicit on/off choice in `$ZUT_HOME/config.json` and can be changed from `/settings`. It is advisory guidance only: it does not change tool permissions, confirmations, jail behavior, or sandboxing.
151151
152152 A selected subagent profile with `systemPromptMode: replace` supplies the child's base identity even when `--system-prompt` was provided for the parent run. Global append addenda, including enabled Ponytail guidance, still follow their normal inclusion rules.
153153
154+### HTTP proxy
155+
156+To route zut-managed HTTP and HTTPS requests through one proxy, add `http_proxy` to `$ZUT_HOME/config.json`:
157+
158+```json
159+{
160+ "http_proxy": "http://127.0.0.1:7890"
161+}
162+```
163+
164+The setting is applied at startup to both HTTP and HTTPS traffic. Existing `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, and `https_proxy` environment variables take precedence for their corresponding protocol. `NO_PROXY` and `no_proxy` continue to control bypasses. Restart zut after changing the config file. If the URL contains proxy credentials, prefer protected environment variables because `config.json` is not a credential store.
165+
154166 ## Persistent instructions (AGENTS.md)
155167
156168 Use `AGENTS.md` to give zut standing instructions that layer **on top of** the default system prompt, without replacing it. This is the friendliest way to shape behavior (for example, taming local models that jump straight to code edits) because it adds guidance rather than taking over the base identity the way `SYSTEM.md` does.
@@ -174,9 +186,9 @@ Treat questions and discussions as requests for explanation. Do not edit files o
174186 |---|---|---|
175187 | `$ZUT_HOME/AGENTS.md` | global, every run | appended to the default prompt |
176188 | `./AGENTS.md` (and parent dirs) | project | appended to the default prompt |
177-| `$ZUT_HOME/SYSTEM.md` | global, every run | replaces the built-in identity; append addenda remain |
189+| `$ZUT_HOME/SYSTEM.md` | global, every run | replaces the built-in identity and docs guidance; append addenda remain |
178190 | `--append-system-prompt <text>` | single run | appends for one invocation (repeatable) |
179-| `--system-prompt <text>` | single run | replaces the built-in identity for one invocation; append addenda remain |
191+| `--system-prompt <text>` | single run | replaces the built-in identity and docs guidance for one invocation; append addenda remain |
180192
181193 > **Note:** zut does not read a `CLAUDE.md` instruction file. The only Claude-compatible thing it picks up is skills under `.claude/skills/`. If you are migrating from Claude Code, move that content into `AGENTS.md` (global or per-project) and zut will use it.
182194
@@ -190,6 +202,7 @@ The first time you launch a newer zut binary, the TUI shows the GitHub release n
190202 zut # interactive tui
191203 zut "fix the failing test" # tui, pre-filled prompt
192204 zut -p "list all go files" # print final text, exit
205+cat README.md | zut "summarize this text" # pipe input and infer print mode
193206 zut -p --stats stats.json "task" # print final text and write generation stats
194207 zut --json "refactor main.go" # newline-delimited json events, exit
195208 zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize"
@@ -228,6 +241,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
228241 | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). |
229242 | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |
230243 | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. |
244+| `--no-context-files`, `-nc` | Disable discovery and loading of global and project `AGENTS.md` files. |
231245 | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Press `/` while the dialog is focused to run a slash command first; closing its input or child dialog returns to the pending confirmation. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. |
232246
233247 ## Tools
@@ -248,6 +262,7 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r
248262 - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands.
249263 - **Print**: `zut -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout.
250264 - **Stream**: `zut --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr.
265+- **Piped input**: stdin implies print mode when no explicit mode is selected. Print, stream, and JSON modes prepend piped input to the positional prompt, separated by a newline. For example, `cat README.md | zut "summarize this text"`.
251266 - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md).
252267 - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.
253268
@@ -518,7 +533,7 @@ Place a `models.json` in `$ZUT_HOME` (`$XDG_STATE_HOME/zut/` when set, otherwise
518533
519534 Supported fields per model: `id` (required), `name`, `reasoning`, `reasoningLevelMap`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
520535
521-`reasoningLevelMap` is optional. Protocol defaults apply when it is omitted. Add only model-specific exceptions using `minimum`, `low`, `medium`, `high`, `xhigh`, or `max` as keys. Map a key to another level when both inputs are equivalent, or to an empty string or `off` to remove it. The same effective mapping drives `/reasoning` and provider requests.
536+`reasoningLevelMap` is optional. Protocol defaults apply when it is omitted. Add only model-specific exceptions using `minimum`, `low`, `medium`, `high`, `xhigh`, or `max` as keys. Map a key to another level when both inputs are equivalent, use an identity mapping such as `"max": "max"` to enable a level beyond the protocol default, or map it to an empty string or `off` to remove it. The same effective mapping drives `/reasoning` and provider requests.
522537
523538 Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`, `moonshot`, `moonshot-ai`, and `kimi-code` map to `kimi`, and `deepseek-chat` and `deepseek-ai` map to `deepseek`. Built-in provider ids such as `groq`, `openrouter`, `github-copilot`, `amazon-bedrock`, `google-vertex`, `azure-openai-responses`, `fireworks`, `vercel-ai-gateway`, `mistral`, and `xai` can also be used directly.
524539
@@ -145,12 +145,24 @@ $ZUT_HOME/
145 └── logs/ # app log files145 └── logs/ # app log files
146 ```146 ```
147 147
148-Drop a `SYSTEM.md` in `$ZUT_HOME` to replace the built-in identity for every run. Existing append addenda, including `AGENTS.md`, skills, and enabled Ponytail coding mode, remain layered on top. `--system-prompt` wins per invocation unless a selected replace-mode subagent profile supplies the child's identity. Delete the file to revert to the default identity.148+Drop a `SYSTEM.md` in `$ZUT_HOME` to replace the built-in identity and docs guidance for every run. Existing append addenda, including `AGENTS.md`, skills, and enabled Ponytail coding mode, remain layered on top. `--system-prompt` wins per invocation unless a selected replace-mode subagent profile supplies the child's identity. Delete the file to revert to the default identity.
149 149
150 Ponytail coding mode is enabled by default, including when an existing `config.json` does not contain a `ponytail_enabled` field. When disabled, its compact guidance is omitted from resolved system prompts; when enabled, the guidance tells the model to apply itself only to engineering work. The setting persists its explicit on/off choice in `$ZUT_HOME/config.json` and can be changed from `/settings`. It is advisory guidance only: it does not change tool permissions, confirmations, jail behavior, or sandboxing.150 Ponytail coding mode is enabled by default, including when an existing `config.json` does not contain a `ponytail_enabled` field. When disabled, its compact guidance is omitted from resolved system prompts; when enabled, the guidance tells the model to apply itself only to engineering work. The setting persists its explicit on/off choice in `$ZUT_HOME/config.json` and can be changed from `/settings`. It is advisory guidance only: it does not change tool permissions, confirmations, jail behavior, or sandboxing.
151 151
152 A selected subagent profile with `systemPromptMode: replace` supplies the child's base identity even when `--system-prompt` was provided for the parent run. Global append addenda, including enabled Ponytail guidance, still follow their normal inclusion rules.152 A selected subagent profile with `systemPromptMode: replace` supplies the child's base identity even when `--system-prompt` was provided for the parent run. Global append addenda, including enabled Ponytail guidance, still follow their normal inclusion rules.
153 153
154+### HTTP proxy
155+
156+To route zut-managed HTTP and HTTPS requests through one proxy, add `http_proxy` to `$ZUT_HOME/config.json`:
157+
158+```json
159+{
160+ "http_proxy": "http://127.0.0.1:7890"
161+}
162+```
163+
164+The setting is applied at startup to both HTTP and HTTPS traffic. Existing `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, and `https_proxy` environment variables take precedence for their corresponding protocol. `NO_PROXY` and `no_proxy` continue to control bypasses. Restart zut after changing the config file. If the URL contains proxy credentials, prefer protected environment variables because `config.json` is not a credential store.
165+
154 ## Persistent instructions (AGENTS.md)166 ## Persistent instructions (AGENTS.md)
155 167
156 Use `AGENTS.md` to give zut standing instructions that layer **on top of** the default system prompt, without replacing it. This is the friendliest way to shape behavior (for example, taming local models that jump straight to code edits) because it adds guidance rather than taking over the base identity the way `SYSTEM.md` does.168 Use `AGENTS.md` to give zut standing instructions that layer **on top of** the default system prompt, without replacing it. This is the friendliest way to shape behavior (for example, taming local models that jump straight to code edits) because it adds guidance rather than taking over the base identity the way `SYSTEM.md` does.
@@ -174,9 +186,9 @@ Treat questions and discussions as requests for explanation. Do not edit files o
174 |---|---|---|186 |---|---|---|
175 | `$ZUT_HOME/AGENTS.md` | global, every run | appended to the default prompt |187 | `$ZUT_HOME/AGENTS.md` | global, every run | appended to the default prompt |
176 | `./AGENTS.md` (and parent dirs) | project | appended to the default prompt |188 | `./AGENTS.md` (and parent dirs) | project | appended to the default prompt |
177-| `$ZUT_HOME/SYSTEM.md` | global, every run | replaces the built-in identity; append addenda remain |189+| `$ZUT_HOME/SYSTEM.md` | global, every run | replaces the built-in identity and docs guidance; append addenda remain |
178 | `--append-system-prompt <text>` | single run | appends for one invocation (repeatable) |190 | `--append-system-prompt <text>` | single run | appends for one invocation (repeatable) |
179-| `--system-prompt <text>` | single run | replaces the built-in identity for one invocation; append addenda remain |191+| `--system-prompt <text>` | single run | replaces the built-in identity and docs guidance for one invocation; append addenda remain |
180 192
181 > **Note:** zut does not read a `CLAUDE.md` instruction file. The only Claude-compatible thing it picks up is skills under `.claude/skills/`. If you are migrating from Claude Code, move that content into `AGENTS.md` (global or per-project) and zut will use it.193 > **Note:** zut does not read a `CLAUDE.md` instruction file. The only Claude-compatible thing it picks up is skills under `.claude/skills/`. If you are migrating from Claude Code, move that content into `AGENTS.md` (global or per-project) and zut will use it.
182 194
@@ -190,6 +202,7 @@ The first time you launch a newer zut binary, the TUI shows the GitHub release n
190 zut # interactive tui202 zut # interactive tui
191 zut "fix the failing test" # tui, pre-filled prompt203 zut "fix the failing test" # tui, pre-filled prompt
192 zut -p "list all go files" # print final text, exit204 zut -p "list all go files" # print final text, exit
205+cat README.md | zut "summarize this text" # pipe input and infer print mode
193 zut -p --stats stats.json "task" # print final text and write generation stats206 zut -p --stats stats.json "task" # print final text and write generation stats
194 zut --json "refactor main.go" # newline-delimited json events, exit207 zut --json "refactor main.go" # newline-delimited json events, exit
195 zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize"208 zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize"
@@ -228,6 +241,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
228 | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). |241 | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). |
229 | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |242 | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |
230 | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. |243 | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. |
244+| `--no-context-files`, `-nc` | Disable discovery and loading of global and project `AGENTS.md` files. |
231 | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Press `/` while the dialog is focused to run a slash command first; closing its input or child dialog returns to the pending confirmation. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. |245 | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Press `/` while the dialog is focused to run a slash command first; closing its input or child dialog returns to the pending confirmation. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. |
232 246
233 ## Tools247 ## Tools
@@ -248,6 +262,7 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r
248 - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands.262 - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands.
249 - **Print**: `zut -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout.263 - **Print**: `zut -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout.
250 - **Stream**: `zut --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr.264 - **Stream**: `zut --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr.
265+- **Piped input**: stdin implies print mode when no explicit mode is selected. Print, stream, and JSON modes prepend piped input to the positional prompt, separated by a newline. For example, `cat README.md | zut "summarize this text"`.
251 - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md).266 - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md).
252 - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.267 - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.
253 268
@@ -518,7 +533,7 @@ Place a `models.json` in `$ZUT_HOME` (`$XDG_STATE_HOME/zut/` when set, otherwise
518 533
519 Supported fields per model: `id` (required), `name`, `reasoning`, `reasoningLevelMap`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.534 Supported fields per model: `id` (required), `name`, `reasoning`, `reasoningLevelMap`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
520 535
521-`reasoningLevelMap` is optional. Protocol defaults apply when it is omitted. Add only model-specific exceptions using `minimum`, `low`, `medium`, `high`, `xhigh`, or `max` as keys. Map a key to another level when both inputs are equivalent, or to an empty string or `off` to remove it. The same effective mapping drives `/reasoning` and provider requests.536+`reasoningLevelMap` is optional. Protocol defaults apply when it is omitted. Add only model-specific exceptions using `minimum`, `low`, `medium`, `high`, `xhigh`, or `max` as keys. Map a key to another level when both inputs are equivalent, use an identity mapping such as `"max": "max"` to enable a level beyond the protocol default, or map it to an empty string or `off` to remove it. The same effective mapping drives `/reasoning` and provider requests.
522 537
523 Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`, `moonshot`, `moonshot-ai`, and `kimi-code` map to `kimi`, and `deepseek-chat` and `deepseek-ai` map to `deepseek`. Built-in provider ids such as `groq`, `openrouter`, `github-copilot`, `amazon-bedrock`, `google-vertex`, `azure-openai-responses`, `fireworks`, `vercel-ai-gateway`, `mistral`, and `xai` can also be used directly.538 Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`, `moonshot`, `moonshot-ai`, and `kimi-code` map to `kimi`, and `deepseek-chat` and `deepseek-ai` map to `deepseek`. Built-in provider ids such as `groq`, `openrouter`, `github-copilot`, `amazon-bedrock`, `google-vertex`, `azure-openai-responses`, `fireworks`, `vercel-ai-gateway`, `mistral`, and `xai` can also be used directly.
524 539
M docs/providers.md +14 -0
@@ -4,6 +4,20 @@ zut ships with built-in providers and a model catalog. You can select models
44 with `/model`, list them with `zut --list-models`, and add private models in
55 `$ZUT_HOME/models.json`.
66
7+## HTTP proxy
8+
9+Set one global proxy for zut-managed HTTP and HTTPS traffic with the `http_proxy` key in `$ZUT_HOME/config.json`:
10+
11+```json
12+{
13+ "http_proxy": "http://127.0.0.1:7890"
14+}
15+```
16+
17+zut applies this setting at startup. Existing `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, and `https_proxy` environment variables take precedence for their corresponding protocol. Standard `NO_PROXY` and `no_proxy` bypass lists remain effective. Restart zut after changing the setting.
18+
19+`config.json` is not a credential store. If the proxy URL contains a username or password, prefer setting the proxy environment variables in a protected shell or service configuration rather than saving those credentials in `config.json`.
20+
721 ## Login methods
822
923 Use `/login` in interactive mode. Type in either provider picker to filter the list by provider ID or display name.
@@ -4,6 +4,20 @@ zut ships with built-in providers and a model catalog. You can select models
4 with `/model`, list them with `zut --list-models`, and add private models in4 with `/model`, list them with `zut --list-models`, and add private models in
5 `$ZUT_HOME/models.json`.5 `$ZUT_HOME/models.json`.
6 6
7+## HTTP proxy
8+
9+Set one global proxy for zut-managed HTTP and HTTPS traffic with the `http_proxy` key in `$ZUT_HOME/config.json`:
10+
11+```json
12+{
13+ "http_proxy": "http://127.0.0.1:7890"
14+}
15+```
16+
17+zut applies this setting at startup. Existing `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, and `https_proxy` environment variables take precedence for their corresponding protocol. Standard `NO_PROXY` and `no_proxy` bypass lists remain effective. Restart zut after changing the setting.
18+
19+`config.json` is not a credential store. If the proxy URL contains a username or password, prefer setting the proxy environment variables in a protected shell or service configuration rather than saving those credentials in `config.json`.
20+
7 ## Login methods21 ## Login methods
8 22
9 Use `/login` in interactive mode. Type in either provider picker to filter the list by provider ID or display name.23 Use `/login` in interactive mode. Type in either provider picker to filter the list by provider ID or display name.
M docs/rpc.md +2 -2
@@ -23,9 +23,9 @@ You'll see one JSON object per line on stdout: a response acknowledging the prom
2323
2424 ## Flags
2525
26-`zut rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`. Sessions are disabled by default in RPC mode — the embedding application owns persistence.
26+`zut rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`, `--no-context-files`. Sessions are disabled by default in RPC mode — the embedding application owns persistence.
2727
28-RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZUT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity, while enabled append addenda remain present.
28+RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZUT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity and docs guidance, while enabled append addenda remain present.
2929
3030 ## Auth
3131
@@ -23,9 +23,9 @@ You'll see one JSON object per line on stdout: a response acknowledging the prom
23 23
24 ## Flags24 ## Flags
25 25
26-`zut rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`. Sessions are disabled by default in RPC mode — the embedding application owns persistence.26+`zut rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`, `--no-context-files`. Sessions are disabled by default in RPC mode — the embedding application owns persistence.
27 27
28-RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZUT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity, while enabled append addenda remain present.28+RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZUT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity and docs guidance, while enabled append addenda remain present.
29 29
30 ## Auth30 ## Auth
31 31
M go.mod +1 -0
@@ -9,6 +9,7 @@ require (
99 github.com/mattn/go-runewidth v0.0.16
1010 github.com/sahilm/fuzzy v0.1.1
1111 golang.org/x/image v0.43.0
12+ golang.org/x/mod v0.35.0
1213 golang.org/x/net v0.57.0
1314 )
1415
@@ -9,6 +9,7 @@ require (
9 github.com/mattn/go-runewidth v0.0.169 github.com/mattn/go-runewidth v0.0.16
10 github.com/sahilm/fuzzy v0.1.110 github.com/sahilm/fuzzy v0.1.1
11 golang.org/x/image v0.43.011 golang.org/x/image v0.43.0
12+ golang.org/x/mod v0.35.0
12 golang.org/x/net v0.57.013 golang.org/x/net v0.57.0
13 )14 )
14 15
M go.sum +2 -0
@@ -22,6 +22,8 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
2222 github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
2323 golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
2424 golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
25+golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
26+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
2527 golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
2628 golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
2729 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
@@ -22,6 +22,8 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
22 github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=22 github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
23 golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=23 golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
24 golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=24 golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
25+golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
26+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
25 golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=27 golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
26 golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=28 golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
27 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=29 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
M packages/agent/args.go +7 -0
@@ -106,6 +106,10 @@ type Args struct {
106106 // skill discovery, including built-ins.
107107 WithSkills bool
108108
109+ // NoContextFiles disables discovery and loading of AGENTS.md files
110+ // from $ZUT_HOME and the cwd's ancestor directories.
111+ NoContextFiles bool
112+
109113 // InsecureTLS skips TLS verification for custom inference endpoints.
110114 InsecureTLS bool
111115
@@ -275,6 +279,8 @@ func ParseArgs(in []string) (Args, error) {
275279 a.NoExt = true
276280 case "--no-skill", "--no-skills":
277281 a.NoSkill = true
282+ case "--no-context-files", "-nc":
283+ a.NoContextFiles = true
278284 case "--with-skills", "--with-skill":
279285 // Deprecated no-op: user skills are loaded by default.
280286 a.WithSkills = true
@@ -575,6 +581,7 @@ func PrintHelp(version string) {
575581 row{"-y, --yes", "accept zut run consent without prompting"},
576582 row{"--no-ext", "skip extension discovery for this run"},
577583 row{"--no-skill", "skip all skill discovery for this run"},
584+ row{"--no-context-files, -nc", "skip AGENTS.md discovery for this run"},
578585 )
579586 section("misc",
580587 row{"--max-steps N", "agent loop iteration cap (default: unlimited)"},
@@ -106,6 +106,10 @@ type Args struct {
106 // skill discovery, including built-ins.106 // skill discovery, including built-ins.
107 WithSkills bool107 WithSkills bool
108 108
109+ // NoContextFiles disables discovery and loading of AGENTS.md files
110+ // from $ZUT_HOME and the cwd's ancestor directories.
111+ NoContextFiles bool
112+
109 // InsecureTLS skips TLS verification for custom inference endpoints.113 // InsecureTLS skips TLS verification for custom inference endpoints.
110 InsecureTLS bool114 InsecureTLS bool
111 115
@@ -275,6 +279,8 @@ func ParseArgs(in []string) (Args, error) {
275 a.NoExt = true279 a.NoExt = true
276 case "--no-skill", "--no-skills":280 case "--no-skill", "--no-skills":
277 a.NoSkill = true281 a.NoSkill = true
282+ case "--no-context-files", "-nc":
283+ a.NoContextFiles = true
278 case "--with-skills", "--with-skill":284 case "--with-skills", "--with-skill":
279 // Deprecated no-op: user skills are loaded by default.285 // Deprecated no-op: user skills are loaded by default.
280 a.WithSkills = true286 a.WithSkills = true
@@ -575,6 +581,7 @@ func PrintHelp(version string) {
575 row{"-y, --yes", "accept zut run consent without prompting"},581 row{"-y, --yes", "accept zut run consent without prompting"},
576 row{"--no-ext", "skip extension discovery for this run"},582 row{"--no-ext", "skip extension discovery for this run"},
577 row{"--no-skill", "skip all skill discovery for this run"},583 row{"--no-skill", "skip all skill discovery for this run"},
584+ row{"--no-context-files, -nc", "skip AGENTS.md discovery for this run"},
578 )585 )
579 section("misc",586 section("misc",
580 row{"--max-steps N", "agent loop iteration cap (default: unlimited)"},587 row{"--max-steps N", "agent loop iteration cap (default: unlimited)"},
M packages/agent/args_test.go +12 -0
@@ -188,6 +188,18 @@ func TestParseArgsStatsRequiresPrintMode(t *testing.T) {
188188 }
189189 }
190190
191+func TestParseArgsNoContextFiles(t *testing.T) {
192+ for _, flag := range []string{"--no-context-files", "-nc"} {
193+ args, err := ParseArgs([]string{flag})
194+ if err != nil {
195+ t.Fatalf("ParseArgs(%q): %v", flag, err)
196+ }
197+ if !args.NoContextFiles {
198+ t.Fatalf("ParseArgs(%q): NoContextFiles = false", flag)
199+ }
200+ }
201+}
202+
191203 func TestParseArgsStream(t *testing.T) {
192204 args, err := ParseArgs([]string{"--stream", "hi"})
193205 if err != nil {
@@ -188,6 +188,18 @@ func TestParseArgsStatsRequiresPrintMode(t *testing.T) {
188 }188 }
189 }189 }
190 190
191+func TestParseArgsNoContextFiles(t *testing.T) {
192+ for _, flag := range []string{"--no-context-files", "-nc"} {
193+ args, err := ParseArgs([]string{flag})
194+ if err != nil {
195+ t.Fatalf("ParseArgs(%q): %v", flag, err)
196+ }
197+ if !args.NoContextFiles {
198+ t.Fatalf("ParseArgs(%q): NoContextFiles = false", flag)
199+ }
200+ }
201+}
202+
191 func TestParseArgsStream(t *testing.T) {203 func TestParseArgsStream(t *testing.T) {
192 args, err := ParseArgs([]string{"--stream", "hi"})204 args, err := ParseArgs([]string{"--stream", "hi"})
193 if err != nil {205 if err != nil {
M packages/agent/build.go +1 -1
@@ -842,7 +842,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
842842 summaries := toolSummaries(reg, args)
843843
844844 contextFiles := []ContextFile(nil)
845- if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext {
845+ if !args.NoContextFiles && (selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext) {
846846 contextFiles = loadAgentsContext(args.CWD, ZutHome())
847847 }
848848 append_ := []string(nil)
@@ -842,7 +842,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
842 summaries := toolSummaries(reg, args)842 summaries := toolSummaries(reg, args)
843 843
844 contextFiles := []ContextFile(nil)844 contextFiles := []ContextFile(nil)
845- if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext {845+ if !args.NoContextFiles && (selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext) {
846 contextFiles = loadAgentsContext(args.CWD, ZutHome())846 contextFiles = loadAgentsContext(args.CWD, ZutHome())
847 }847 }
848 append_ := []string(nil)848 append_ := []string(nil)
M packages/agent/build_test.go +29 -0
@@ -708,6 +708,35 @@ func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
708708 }
709709 }
710710
711+func TestResolveNoContextFilesSkipsAgentsInstructions(t *testing.T) {
712+ zutHome := t.TempDir()
713+ cwd := t.TempDir()
714+ t.Setenv("ZUT_HOME", zutHome)
715+ t.Setenv("OPENAI_API_KEY", "test-key")
716+ if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
717+ t.Fatal(err)
718+ }
719+ if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("project rule"), 0o644); err != nil {
720+ t.Fatal(err)
721+ }
722+
723+ r, err := Resolve(Args{
724+ Provider: "openai",
725+ Model: "gpt-5",
726+ CWD: cwd,
727+ NoContextFiles: true,
728+ }, false)
729+ if err != nil {
730+ t.Fatal(err)
731+ }
732+ if len(r.ContextFiles) != 0 {
733+ t.Fatalf("ContextFiles = %#v, want empty", r.ContextFiles)
734+ }
735+ if strings.Contains(r.SystemPrompt, "global rule") || strings.Contains(r.SystemPrompt, "project rule") {
736+ t.Fatalf("system prompt contains disabled context files:\n%s", r.SystemPrompt)
737+ }
738+}
739+
711740 // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
712741 // startup failure caught by the user's screenshot: the persisted
713742 // config.json points at a model id that's no longer in the active
@@ -708,6 +708,35 @@ func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
708 }708 }
709 }709 }
710 710
711+func TestResolveNoContextFilesSkipsAgentsInstructions(t *testing.T) {
712+ zutHome := t.TempDir()
713+ cwd := t.TempDir()
714+ t.Setenv("ZUT_HOME", zutHome)
715+ t.Setenv("OPENAI_API_KEY", "test-key")
716+ if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
717+ t.Fatal(err)
718+ }
719+ if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("project rule"), 0o644); err != nil {
720+ t.Fatal(err)
721+ }
722+
723+ r, err := Resolve(Args{
724+ Provider: "openai",
725+ Model: "gpt-5",
726+ CWD: cwd,
727+ NoContextFiles: true,
728+ }, false)
729+ if err != nil {
730+ t.Fatal(err)
731+ }
732+ if len(r.ContextFiles) != 0 {
733+ t.Fatalf("ContextFiles = %#v, want empty", r.ContextFiles)
734+ }
735+ if strings.Contains(r.SystemPrompt, "global rule") || strings.Contains(r.SystemPrompt, "project rule") {
736+ t.Fatalf("system prompt contains disabled context files:\n%s", r.SystemPrompt)
737+ }
738+}
739+
711 // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the740 // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
712 // startup failure caught by the user's screenshot: the persisted741 // startup failure caught by the user's screenshot: the persisted
713 // config.json points at a model id that's no longer in the active742 // config.json points at a model id that's no longer in the active
M packages/agent/cli.go +31 -15
@@ -765,6 +765,10 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
765765
766766 // Run is the top-level entrypoint for the zut binary.
767767 func Run(rawArgs []string, version string) error {
768+ // Apply network configuration before any subcommand can make an HTTP
769+ // request. Standard proxy environment variables retain precedence.
770+ applyConfiguredHTTPProxy()
771+
768772 // Extension installs can invoke external build and clone processes.
769773 // Give those processes a cancellation context without changing signal
770774 // handling for the interactive modes below.
@@ -800,6 +804,9 @@ func runWithArgsRaw(rawArgs []string, version string) error {
800804 rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
801805 }
802806
807+ if fi, err := os.Stdin.Stat(); err == nil {
808+ rawArgs = argsForStdin(rawArgs, fi.Mode())
809+ }
803810 args, err := ParseArgs(rawArgs)
804811 if err != nil {
805812 PrintHelp(version)
@@ -1005,11 +1012,8 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error)
10051012 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
10061013 }
10071014
1008- prompt := args.Prompt
1009- if prompt == "" {
1010- piped, _ := readAllStdin()
1011- prompt = strings.TrimSpace(piped)
1012- }
1015+ piped, _ := readAllStdin()
1016+ prompt := combinePromptInput(piped, args.Prompt)
10131017 if prompt == "" {
10141018 return fmt.Errorf("print mode requires a prompt (arg or stdin)")
10151019 }
@@ -1090,11 +1094,8 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error
10901094 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
10911095 }
10921096
1093- prompt := args.Prompt
1094- if prompt == "" {
1095- piped, _ := readAllStdin()
1096- prompt = strings.TrimSpace(piped)
1097- }
1097+ piped, _ := readAllStdin()
1098+ prompt := combinePromptInput(piped, args.Prompt)
10981099 if prompt == "" {
10991100 return fmt.Errorf("stream mode requires a prompt (arg or stdin)")
11001101 }
@@ -1214,11 +1215,8 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error)
12141215 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
12151216 }
12161217
1217- prompt := args.Prompt
1218- if prompt == "" {
1219- piped, _ := readAllStdin()
1220- prompt = strings.TrimSpace(piped)
1221- }
1218+ piped, _ := readAllStdin()
1219+ prompt := combinePromptInput(piped, args.Prompt)
12221220 if prompt == "" {
12231221 return fmt.Errorf("json mode requires a prompt (arg or stdin)")
12241222 }
@@ -2887,6 +2885,24 @@ func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (nex
28872885 return next, nil
28882886 }
28892887
2888+func argsForStdin(rawArgs []string, mode os.FileMode) []string {
2889+ if mode&os.ModeCharDevice != 0 {
2890+ return rawArgs
2891+ }
2892+ return append([]string{"--print"}, rawArgs...)
2893+}
2894+
2895+func combinePromptInput(stdin, positional string) string {
2896+ parts := make([]string, 0, 2)
2897+ if stdin = strings.TrimSpace(stdin); stdin != "" {
2898+ parts = append(parts, stdin)
2899+ }
2900+ if positional = strings.TrimSpace(positional); positional != "" {
2901+ parts = append(parts, positional)
2902+ }
2903+ return strings.Join(parts, "\n")
2904+}
2905+
28902906 func readAllStdin() (string, error) {
28912907 fi, err := os.Stdin.Stat()
28922908 if err != nil {
@@ -765,6 +765,10 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
765 765
766 // Run is the top-level entrypoint for the zut binary.766 // Run is the top-level entrypoint for the zut binary.
767 func Run(rawArgs []string, version string) error {767 func Run(rawArgs []string, version string) error {
768+ // Apply network configuration before any subcommand can make an HTTP
769+ // request. Standard proxy environment variables retain precedence.
770+ applyConfiguredHTTPProxy()
771+
768 // Extension installs can invoke external build and clone processes.772 // Extension installs can invoke external build and clone processes.
769 // Give those processes a cancellation context without changing signal773 // Give those processes a cancellation context without changing signal
770 // handling for the interactive modes below.774 // handling for the interactive modes below.
@@ -800,6 +804,9 @@ func runWithArgsRaw(rawArgs []string, version string) error {
800 rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)804 rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
801 }805 }
802 806
807+ if fi, err := os.Stdin.Stat(); err == nil {
808+ rawArgs = argsForStdin(rawArgs, fi.Mode())
809+ }
803 args, err := ParseArgs(rawArgs)810 args, err := ParseArgs(rawArgs)
804 if err != nil {811 if err != nil {
805 PrintHelp(version)812 PrintHelp(version)
@@ -1005,11 +1012,8 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error)
1005 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()1012 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
1006 }1013 }
1007 1014
1008- prompt := args.Prompt1015+ piped, _ := readAllStdin()
1009- if prompt == "" {1016+ prompt := combinePromptInput(piped, args.Prompt)
1010- piped, _ := readAllStdin()
1011- prompt = strings.TrimSpace(piped)
1012- }
1013 if prompt == "" {1017 if prompt == "" {
1014 return fmt.Errorf("print mode requires a prompt (arg or stdin)")1018 return fmt.Errorf("print mode requires a prompt (arg or stdin)")
1015 }1019 }
@@ -1090,11 +1094,8 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error
1090 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()1094 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
1091 }1095 }
1092 1096
1093- prompt := args.Prompt1097+ piped, _ := readAllStdin()
1094- if prompt == "" {1098+ prompt := combinePromptInput(piped, args.Prompt)
1095- piped, _ := readAllStdin()
1096- prompt = strings.TrimSpace(piped)
1097- }
1098 if prompt == "" {1099 if prompt == "" {
1099 return fmt.Errorf("stream mode requires a prompt (arg or stdin)")1100 return fmt.Errorf("stream mode requires a prompt (arg or stdin)")
1100 }1101 }
@@ -1214,11 +1215,8 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error)
1214 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()1215 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
1215 }1216 }
1216 1217
1217- prompt := args.Prompt1218+ piped, _ := readAllStdin()
1218- if prompt == "" {1219+ prompt := combinePromptInput(piped, args.Prompt)
1219- piped, _ := readAllStdin()
1220- prompt = strings.TrimSpace(piped)
1221- }
1222 if prompt == "" {1220 if prompt == "" {
1223 return fmt.Errorf("json mode requires a prompt (arg or stdin)")1221 return fmt.Errorf("json mode requires a prompt (arg or stdin)")
1224 }1222 }
@@ -2887,6 +2885,24 @@ func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (nex
2887 return next, nil2885 return next, nil
2888 }2886 }
2889 2887
2888+func argsForStdin(rawArgs []string, mode os.FileMode) []string {
2889+ if mode&os.ModeCharDevice != 0 {
2890+ return rawArgs
2891+ }
2892+ return append([]string{"--print"}, rawArgs...)
2893+}
2894+
2895+func combinePromptInput(stdin, positional string) string {
2896+ parts := make([]string, 0, 2)
2897+ if stdin = strings.TrimSpace(stdin); stdin != "" {
2898+ parts = append(parts, stdin)
2899+ }
2900+ if positional = strings.TrimSpace(positional); positional != "" {
2901+ parts = append(parts, positional)
2902+ }
2903+ return strings.Join(parts, "\n")
2904+}
2905+
2890 func readAllStdin() (string, error) {2906 func readAllStdin() (string, error) {
2891 fi, err := os.Stdin.Stat()2907 fi, err := os.Stdin.Stat()
2892 if err != nil {2908 if err != nil {
M packages/agent/config.go +24 -0
@@ -181,6 +181,10 @@ type Config struct {
181181 // Insecure skips TLS verification for custom inference endpoints.
182182 Insecure bool `json:"insecure,omitempty"`
183183
184+ // HTTPProxy is a global proxy URL used for HTTP and HTTPS requests when
185+ // the corresponding standard proxy environment variable is not already set.
186+ HTTPProxy string `json:"http_proxy,omitempty"`
187+
184188 // LastChangelogShown is the version whose release-notes
185189 // dialog the user has already seen. When the running binary's
186190 // version differs, the next interactive run shows the
@@ -332,6 +336,26 @@ func SaveConfig(c Config) error {
332336 return os.WriteFile(ConfigPath(), b, 0o644)
333337 }
334338
339+// applyConfiguredHTTPProxy makes the persisted proxy available to Go's
340+// standard HTTP transport and to child processes. Explicit environment
341+// variables take precedence over config.json.
342+func applyConfiguredHTTPProxy() {
343+ cfg, err := LoadConfig()
344+ if err != nil {
345+ return
346+ }
347+ proxy := strings.TrimSpace(cfg.HTTPProxy)
348+ if proxy == "" {
349+ return
350+ }
351+ if os.Getenv("HTTP_PROXY") == "" && os.Getenv("http_proxy") == "" {
352+ _ = os.Setenv("HTTP_PROXY", proxy)
353+ }
354+ if os.Getenv("HTTPS_PROXY") == "" && os.Getenv("https_proxy") == "" {
355+ _ = os.Setenv("HTTPS_PROXY", proxy)
356+ }
357+}
358+
335359 // AuthStoreFor returns the auth.Store backed by AuthPath().
336360 func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) }
337361
@@ -181,6 +181,10 @@ type Config struct {
181 // Insecure skips TLS verification for custom inference endpoints.181 // Insecure skips TLS verification for custom inference endpoints.
182 Insecure bool `json:"insecure,omitempty"`182 Insecure bool `json:"insecure,omitempty"`
183 183
184+ // HTTPProxy is a global proxy URL used for HTTP and HTTPS requests when
185+ // the corresponding standard proxy environment variable is not already set.
186+ HTTPProxy string `json:"http_proxy,omitempty"`
187+
184 // LastChangelogShown is the version whose release-notes188 // LastChangelogShown is the version whose release-notes
185 // dialog the user has already seen. When the running binary's189 // dialog the user has already seen. When the running binary's
186 // version differs, the next interactive run shows the190 // version differs, the next interactive run shows the
@@ -332,6 +336,26 @@ func SaveConfig(c Config) error {
332 return os.WriteFile(ConfigPath(), b, 0o644)336 return os.WriteFile(ConfigPath(), b, 0o644)
333 }337 }
334 338
339+// applyConfiguredHTTPProxy makes the persisted proxy available to Go's
340+// standard HTTP transport and to child processes. Explicit environment
341+// variables take precedence over config.json.
342+func applyConfiguredHTTPProxy() {
343+ cfg, err := LoadConfig()
344+ if err != nil {
345+ return
346+ }
347+ proxy := strings.TrimSpace(cfg.HTTPProxy)
348+ if proxy == "" {
349+ return
350+ }
351+ if os.Getenv("HTTP_PROXY") == "" && os.Getenv("http_proxy") == "" {
352+ _ = os.Setenv("HTTP_PROXY", proxy)
353+ }
354+ if os.Getenv("HTTPS_PROXY") == "" && os.Getenv("https_proxy") == "" {
355+ _ = os.Setenv("HTTPS_PROXY", proxy)
356+ }
357+}
358+
335 // AuthStoreFor returns the auth.Store backed by AuthPath().359 // AuthStoreFor returns the auth.Store backed by AuthPath().
336 func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) }360 func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) }
337 361
A packages/agent/config_proxy_test.go +92 -0
@@ -0,0 +1,92 @@
1+package agent
2+
3+import (
4+ "os"
5+ "testing"
6+)
7+
8+func TestApplyConfiguredHTTPProxy(t *testing.T) {
9+ t.Setenv("ZUT_HOME", t.TempDir())
10+ for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} {
11+ t.Setenv(key, "")
12+ }
13+ if err := SaveConfig(Config{HTTPProxy: " http://proxy.example:8080 "}); err != nil {
14+ t.Fatal(err)
15+ }
16+
17+ applyConfiguredHTTPProxy()
18+
19+ if got := getenvForTest("HTTP_PROXY"); got != "http://proxy.example:8080" {
20+ t.Fatalf("HTTP_PROXY = %q", got)
21+ }
22+ if got := getenvForTest("HTTPS_PROXY"); got != "http://proxy.example:8080" {
23+ t.Fatalf("HTTPS_PROXY = %q", got)
24+ }
25+}
26+
27+func TestApplyConfiguredHTTPProxyPreservesUppercaseEnvironment(t *testing.T) {
28+ t.Setenv("ZUT_HOME", t.TempDir())
29+ // Set lowercase first because environment variable names are
30+ // case-insensitive on Windows.
31+ t.Setenv("http_proxy", "")
32+ t.Setenv("https_proxy", "")
33+ t.Setenv("HTTP_PROXY", "http://http-env.example:8080")
34+ t.Setenv("HTTPS_PROXY", "http://https-env.example:8080")
35+ if err := SaveConfig(Config{HTTPProxy: "http://config.example:8080"}); err != nil {
36+ t.Fatal(err)
37+ }
38+
39+ applyConfiguredHTTPProxy()
40+
41+ if got := getenvForTest("HTTP_PROXY"); got != "http://http-env.example:8080" {
42+ t.Fatalf("HTTP_PROXY = %q", got)
43+ }
44+ if got := getenvForTest("HTTPS_PROXY"); got != "http://https-env.example:8080" {
45+ t.Fatalf("HTTPS_PROXY = %q", got)
46+ }
47+}
48+
49+func TestApplyConfiguredHTTPProxyPreservesLowercaseEnvironment(t *testing.T) {
50+ t.Setenv("ZUT_HOME", t.TempDir())
51+ // Set uppercase first because environment variable names are
52+ // case-insensitive on Windows.
53+ t.Setenv("HTTP_PROXY", "")
54+ t.Setenv("HTTPS_PROXY", "")
55+ t.Setenv("http_proxy", "http://http-env.example:8080")
56+ t.Setenv("https_proxy", "http://https-env.example:8080")
57+ if err := SaveConfig(Config{HTTPProxy: "http://config.example:8080"}); err != nil {
58+ t.Fatal(err)
59+ }
60+
61+ applyConfiguredHTTPProxy()
62+
63+ if got := getenvForTest("http_proxy"); got != "http://http-env.example:8080" {
64+ t.Fatalf("http_proxy = %q", got)
65+ }
66+ if got := getenvForTest("https_proxy"); got != "http://https-env.example:8080" {
67+ t.Fatalf("https_proxy = %q", got)
68+ }
69+}
70+
71+func TestApplyConfiguredHTTPProxyIgnoresEmptySetting(t *testing.T) {
72+ t.Setenv("ZUT_HOME", t.TempDir())
73+ for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} {
74+ t.Setenv(key, "")
75+ }
76+ if err := SaveConfig(Config{HTTPProxy: " "}); err != nil {
77+ t.Fatal(err)
78+ }
79+
80+ applyConfiguredHTTPProxy()
81+
82+ if got := getenvForTest("HTTP_PROXY"); got != "" {
83+ t.Fatalf("HTTP_PROXY = %q", got)
84+ }
85+ if got := getenvForTest("HTTPS_PROXY"); got != "" {
86+ t.Fatalf("HTTPS_PROXY = %q", got)
87+ }
88+}
89+
90+func getenvForTest(key string) string {
91+ return os.Getenv(key)
92+}
@@ -0,0 +1,92 @@
1+package agent
2+
3+import (
4+ "os"
5+ "testing"
6+)
7+
8+func TestApplyConfiguredHTTPProxy(t *testing.T) {
9+ t.Setenv("ZUT_HOME", t.TempDir())
10+ for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} {
11+ t.Setenv(key, "")
12+ }
13+ if err := SaveConfig(Config{HTTPProxy: " http://proxy.example:8080 "}); err != nil {
14+ t.Fatal(err)
15+ }
16+
17+ applyConfiguredHTTPProxy()
18+
19+ if got := getenvForTest("HTTP_PROXY"); got != "http://proxy.example:8080" {
20+ t.Fatalf("HTTP_PROXY = %q", got)
21+ }
22+ if got := getenvForTest("HTTPS_PROXY"); got != "http://proxy.example:8080" {
23+ t.Fatalf("HTTPS_PROXY = %q", got)
24+ }
25+}
26+
27+func TestApplyConfiguredHTTPProxyPreservesUppercaseEnvironment(t *testing.T) {
28+ t.Setenv("ZUT_HOME", t.TempDir())
29+ // Set lowercase first because environment variable names are
30+ // case-insensitive on Windows.
31+ t.Setenv("http_proxy", "")
32+ t.Setenv("https_proxy", "")
33+ t.Setenv("HTTP_PROXY", "http://http-env.example:8080")
34+ t.Setenv("HTTPS_PROXY", "http://https-env.example:8080")
35+ if err := SaveConfig(Config{HTTPProxy: "http://config.example:8080"}); err != nil {
36+ t.Fatal(err)
37+ }
38+
39+ applyConfiguredHTTPProxy()
40+
41+ if got := getenvForTest("HTTP_PROXY"); got != "http://http-env.example:8080" {
42+ t.Fatalf("HTTP_PROXY = %q", got)
43+ }
44+ if got := getenvForTest("HTTPS_PROXY"); got != "http://https-env.example:8080" {
45+ t.Fatalf("HTTPS_PROXY = %q", got)
46+ }
47+}
48+
49+func TestApplyConfiguredHTTPProxyPreservesLowercaseEnvironment(t *testing.T) {
50+ t.Setenv("ZUT_HOME", t.TempDir())
51+ // Set uppercase first because environment variable names are
52+ // case-insensitive on Windows.
53+ t.Setenv("HTTP_PROXY", "")
54+ t.Setenv("HTTPS_PROXY", "")
55+ t.Setenv("http_proxy", "http://http-env.example:8080")
56+ t.Setenv("https_proxy", "http://https-env.example:8080")
57+ if err := SaveConfig(Config{HTTPProxy: "http://config.example:8080"}); err != nil {
58+ t.Fatal(err)
59+ }
60+
61+ applyConfiguredHTTPProxy()
62+
63+ if got := getenvForTest("http_proxy"); got != "http://http-env.example:8080" {
64+ t.Fatalf("http_proxy = %q", got)
65+ }
66+ if got := getenvForTest("https_proxy"); got != "http://https-env.example:8080" {
67+ t.Fatalf("https_proxy = %q", got)
68+ }
69+}
70+
71+func TestApplyConfiguredHTTPProxyIgnoresEmptySetting(t *testing.T) {
72+ t.Setenv("ZUT_HOME", t.TempDir())
73+ for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} {
74+ t.Setenv(key, "")
75+ }
76+ if err := SaveConfig(Config{HTTPProxy: " "}); err != nil {
77+ t.Fatal(err)
78+ }
79+
80+ applyConfiguredHTTPProxy()
81+
82+ if got := getenvForTest("HTTP_PROXY"); got != "" {
83+ t.Fatalf("HTTP_PROXY = %q", got)
84+ }
85+ if got := getenvForTest("HTTPS_PROXY"); got != "" {
86+ t.Fatalf("HTTPS_PROXY = %q", got)
87+ }
88+}
89+
90+func getenvForTest(key string) string {
91+ return os.Getenv(key)
92+}
A packages/agent/prompt_input_test.go +73 -0
@@ -0,0 +1,73 @@
1+package agent
2+
3+import (
4+ "os"
5+ "testing"
6+)
7+
8+func TestArgsForStdinSelectsPrintModeForPipe(t *testing.T) {
9+ args, err := ParseArgs(argsForStdin([]string{"summarize"}, os.ModeNamedPipe))
10+ if err != nil {
11+ t.Fatal(err)
12+ }
13+ if args.Mode != ModePrint || args.Prompt != "summarize" {
14+ t.Fatalf("Mode=%q Prompt=%q, want print mode with prompt", args.Mode, args.Prompt)
15+ }
16+}
17+
18+func TestArgsForStdinPreservesTerminalAndExplicitModes(t *testing.T) {
19+ tests := []struct {
20+ name string
21+ in []string
22+ mode os.FileMode
23+ want Mode
24+ }{
25+ {name: "terminal stays interactive", mode: os.ModeCharDevice, want: ModeInteractive},
26+ {name: "explicit stream overrides pipe default", in: []string{"--stream", "prompt"}, mode: os.ModeNamedPipe, want: ModeStream},
27+ {name: "explicit json overrides redirected file default", in: []string{"--json", "prompt"}, want: ModeJSON},
28+ {name: "explicit rpc overrides pipe default", in: []string{"--rpc"}, mode: os.ModeNamedPipe, want: ModeRPC},
29+ }
30+ for _, tc := range tests {
31+ t.Run(tc.name, func(t *testing.T) {
32+ args, err := ParseArgs(argsForStdin(tc.in, tc.mode))
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if args.Mode != tc.want {
37+ t.Fatalf("Mode=%q, want %q", args.Mode, tc.want)
38+ }
39+ })
40+ }
41+}
42+
43+func TestArgsForStdinAllowsPrintStats(t *testing.T) {
44+ args, err := ParseArgs(argsForStdin([]string{"--stats", "stats.json"}, os.ModeNamedPipe))
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ if args.Mode != ModePrint || args.StatsPath != "stats.json" {
49+ t.Fatalf("Mode=%q StatsPath=%q, want print mode with stats path", args.Mode, args.StatsPath)
50+ }
51+}
52+
53+func TestCombinePromptInput(t *testing.T) {
54+ tests := []struct {
55+ name string
56+ stdin string
57+ positional string
58+ want string
59+ }{
60+ {name: "stdin and instruction", stdin: "input text\n", positional: "summarize it", want: "input text\nsummarize it"},
61+ {name: "stdin only", stdin: " input text\n", want: "input text"},
62+ {name: "positional only", positional: " summarize it ", want: "summarize it"},
63+ {name: "empty", stdin: " \n", positional: " ", want: ""},
64+ }
65+
66+ for _, tc := range tests {
67+ t.Run(tc.name, func(t *testing.T) {
68+ if got := combinePromptInput(tc.stdin, tc.positional); got != tc.want {
69+ t.Fatalf("combinePromptInput(%q, %q) = %q, want %q", tc.stdin, tc.positional, got, tc.want)
70+ }
71+ })
72+ }
73+}
@@ -0,0 +1,73 @@
1+package agent
2+
3+import (
4+ "os"
5+ "testing"
6+)
7+
8+func TestArgsForStdinSelectsPrintModeForPipe(t *testing.T) {
9+ args, err := ParseArgs(argsForStdin([]string{"summarize"}, os.ModeNamedPipe))
10+ if err != nil {
11+ t.Fatal(err)
12+ }
13+ if args.Mode != ModePrint || args.Prompt != "summarize" {
14+ t.Fatalf("Mode=%q Prompt=%q, want print mode with prompt", args.Mode, args.Prompt)
15+ }
16+}
17+
18+func TestArgsForStdinPreservesTerminalAndExplicitModes(t *testing.T) {
19+ tests := []struct {
20+ name string
21+ in []string
22+ mode os.FileMode
23+ want Mode
24+ }{
25+ {name: "terminal stays interactive", mode: os.ModeCharDevice, want: ModeInteractive},
26+ {name: "explicit stream overrides pipe default", in: []string{"--stream", "prompt"}, mode: os.ModeNamedPipe, want: ModeStream},
27+ {name: "explicit json overrides redirected file default", in: []string{"--json", "prompt"}, want: ModeJSON},
28+ {name: "explicit rpc overrides pipe default", in: []string{"--rpc"}, mode: os.ModeNamedPipe, want: ModeRPC},
29+ }
30+ for _, tc := range tests {
31+ t.Run(tc.name, func(t *testing.T) {
32+ args, err := ParseArgs(argsForStdin(tc.in, tc.mode))
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if args.Mode != tc.want {
37+ t.Fatalf("Mode=%q, want %q", args.Mode, tc.want)
38+ }
39+ })
40+ }
41+}
42+
43+func TestArgsForStdinAllowsPrintStats(t *testing.T) {
44+ args, err := ParseArgs(argsForStdin([]string{"--stats", "stats.json"}, os.ModeNamedPipe))
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ if args.Mode != ModePrint || args.StatsPath != "stats.json" {
49+ t.Fatalf("Mode=%q StatsPath=%q, want print mode with stats path", args.Mode, args.StatsPath)
50+ }
51+}
52+
53+func TestCombinePromptInput(t *testing.T) {
54+ tests := []struct {
55+ name string
56+ stdin string
57+ positional string
58+ want string
59+ }{
60+ {name: "stdin and instruction", stdin: "input text\n", positional: "summarize it", want: "input text\nsummarize it"},
61+ {name: "stdin only", stdin: " input text\n", want: "input text"},
62+ {name: "positional only", positional: " summarize it ", want: "summarize it"},
63+ {name: "empty", stdin: " \n", positional: " ", want: ""},
64+ }
65+
66+ for _, tc := range tests {
67+ t.Run(tc.name, func(t *testing.T) {
68+ if got := combinePromptInput(tc.stdin, tc.positional); got != tc.want {
69+ t.Fatalf("combinePromptInput(%q, %q) = %q, want %q", tc.stdin, tc.positional, got, tc.want)
70+ }
71+ })
72+ }
73+}
M packages/agent/systemprompt.go +2 -2
@@ -21,7 +21,7 @@ type ToolSummary struct {
2121 type SystemPromptOpts struct {
2222 CWD string
2323 Tools []ToolSummary
24- Custom string // if set, replaces the default identity entirely
24+ Custom string // if set, replaces the built-in identity and docs guidance
2525 Append []string // extra text appended at the end
2626 Now time.Time
2727 ZutDocsDir string
@@ -67,7 +67,7 @@ func BuildSystemPrompt(o SystemPromptOpts) string {
6767 sb.WriteString("\n\n")
6868 sb.WriteString(compactedSummaryHandoffInstruction)
6969
70- if strings.TrimSpace(o.ZutDocsDir) != "" {
70+ if o.Custom == "" && strings.TrimSpace(o.ZutDocsDir) != "" {
7171 sb.WriteString("\n\nZut's own docs are installed under ")
7272 sb.WriteString(o.ZutDocsDir)
7373 sb.WriteString("; use the read tool there when you need details about zut RPC, extensions, skills, or built-in behaviour.")
@@ -21,7 +21,7 @@ type ToolSummary struct {
21 type SystemPromptOpts struct {21 type SystemPromptOpts struct {
22 CWD string22 CWD string
23 Tools []ToolSummary23 Tools []ToolSummary
24- Custom string // if set, replaces the default identity entirely24+ Custom string // if set, replaces the built-in identity and docs guidance
25 Append []string // extra text appended at the end25 Append []string // extra text appended at the end
26 Now time.Time26 Now time.Time
27 ZutDocsDir string27 ZutDocsDir string
@@ -67,7 +67,7 @@ func BuildSystemPrompt(o SystemPromptOpts) string {
67 sb.WriteString("\n\n")67 sb.WriteString("\n\n")
68 sb.WriteString(compactedSummaryHandoffInstruction)68 sb.WriteString(compactedSummaryHandoffInstruction)
69 69
70- if strings.TrimSpace(o.ZutDocsDir) != "" {70+ if o.Custom == "" && strings.TrimSpace(o.ZutDocsDir) != "" {
71 sb.WriteString("\n\nZut's own docs are installed under ")71 sb.WriteString("\n\nZut's own docs are installed under ")
72 sb.WriteString(o.ZutDocsDir)72 sb.WriteString(o.ZutDocsDir)
73 sb.WriteString("; use the read tool there when you need details about zut RPC, extensions, skills, or built-in behaviour.")73 sb.WriteString("; use the read tool there when you need details about zut RPC, extensions, skills, or built-in behaviour.")
M packages/agent/systemprompt_test.go +37 -0
@@ -3,6 +3,7 @@ package agent
33 import (
44 "strings"
55 "testing"
6+ "time"
67 )
78
89 func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) {
@@ -22,3 +23,39 @@ func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) {
2223 }
2324 }
2425 }
26+
27+func TestBuildSystemPromptCustomOmitsBuiltInDocs(t *testing.T) {
28+ got := BuildSystemPrompt(SystemPromptOpts{
29+ CWD: "/workspace",
30+ Custom: "Custom instructions",
31+ Append: []string{"Additional context"},
32+ Now: time.Date(2026, time.August, 6, 0, 0, 0, 0, time.UTC),
33+ ZutDocsDir: "/zut/docs",
34+ })
35+
36+ if strings.Contains(got, "Zut's own docs") || strings.Contains(got, "/zut/docs") {
37+ t.Fatalf("custom prompt includes built-in docs guidance:\n%s", got)
38+ }
39+ for _, want := range []string{
40+ "Custom instructions",
41+ "Additional context",
42+ "Current date: 2026-08-06",
43+ "Current working directory: /workspace",
44+ } {
45+ if !strings.Contains(got, want) {
46+ t.Errorf("custom prompt missing %q:\n%s", want, got)
47+ }
48+ }
49+}
50+
51+func TestBuildSystemPromptDefaultIncludesBuiltInDocs(t *testing.T) {
52+ got := BuildSystemPrompt(SystemPromptOpts{
53+ CWD: "/workspace",
54+ Now: time.Date(2026, time.August, 6, 0, 0, 0, 0, time.UTC),
55+ ZutDocsDir: "/zut/docs",
56+ })
57+
58+ if !strings.Contains(got, "Zut's own docs are installed under /zut/docs") {
59+ t.Fatalf("default prompt missing built-in docs guidance:\n%s", got)
60+ }
61+}
@@ -3,6 +3,7 @@ package agent
3 import (3 import (
4 "strings"4 "strings"
5 "testing"5 "testing"
6+ "time"
6 )7 )
7 8
8 func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) {9 func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) {
@@ -22,3 +23,39 @@ func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) {
22 }23 }
23 }24 }
24 }25 }
26+
27+func TestBuildSystemPromptCustomOmitsBuiltInDocs(t *testing.T) {
28+ got := BuildSystemPrompt(SystemPromptOpts{
29+ CWD: "/workspace",
30+ Custom: "Custom instructions",
31+ Append: []string{"Additional context"},
32+ Now: time.Date(2026, time.August, 6, 0, 0, 0, 0, time.UTC),
33+ ZutDocsDir: "/zut/docs",
34+ })
35+
36+ if strings.Contains(got, "Zut's own docs") || strings.Contains(got, "/zut/docs") {
37+ t.Fatalf("custom prompt includes built-in docs guidance:\n%s", got)
38+ }
39+ for _, want := range []string{
40+ "Custom instructions",
41+ "Additional context",
42+ "Current date: 2026-08-06",
43+ "Current working directory: /workspace",
44+ } {
45+ if !strings.Contains(got, want) {
46+ t.Errorf("custom prompt missing %q:\n%s", want, got)
47+ }
48+ }
49+}
50+
51+func TestBuildSystemPromptDefaultIncludesBuiltInDocs(t *testing.T) {
52+ got := BuildSystemPrompt(SystemPromptOpts{
53+ CWD: "/workspace",
54+ Now: time.Date(2026, time.August, 6, 0, 0, 0, 0, time.UTC),
55+ ZutDocsDir: "/zut/docs",
56+ })
57+
58+ if !strings.Contains(got, "Zut's own docs are installed under /zut/docs") {
59+ t.Fatalf("default prompt missing built-in docs guidance:\n%s", got)
60+ }
61+}
M packages/agent/update.go +22 -30
@@ -7,9 +7,10 @@ import (
77 "net/http"
88 "os"
99 "path/filepath"
10- "strconv"
1110 "strings"
1211 "time"
12+
13+ "golang.org/x/mod/semver"
1314 )
1415
1516 // updateCheckTTL is how often we hit the GitHub API to look for a new
@@ -132,40 +133,31 @@ func buildInfo(current, latest, url string) UpdateInfo {
132133 return info
133134 }
134135
135-// versionLess returns a < b for dotted semver-ish tags like "0.0.4".
136-// Non-numeric components compare as zero, which is fine for zut's
137-// x.y.z-only scheme.
136+// versionLess reports whether a is an older semantic version than b.
137+// Invalid versions return false so update checks never offer a downgrade.
138138 func versionLess(a, b string) bool {
139- as := splitVersion(a)
140- bs := splitVersion(b)
141- for i := 0; i < 3; i++ {
142- av, bv := 0, 0
143- if i < len(as) {
144- av = as[i]
145- }
146- if i < len(bs) {
147- bv = bs[i]
148- }
149- if av != bv {
150- return av < bv
151- }
152- }
153- return false
139+ comparison, err := compareVersions(a, b)
140+ return err == nil && comparison < 0
154141 }
155142
156-func splitVersion(s string) []int {
157- s = strings.TrimPrefix(s, "v")
158- // Strip build-metadata suffix like "(abc1234, 2026-04-18)".
159- if i := strings.IndexAny(s, " ("); i > 0 {
160- s = s[:i]
143+func compareVersions(a, b string) (int, error) {
144+ a = normalizedVersion(a)
145+ b = normalizedVersion(b)
146+ if !semver.IsValid(a) {
147+ return 0, fmt.Errorf("invalid semantic version %q", strings.TrimPrefix(a, "v"))
148+ }
149+ if !semver.IsValid(b) {
150+ return 0, fmt.Errorf("invalid semantic version %q", strings.TrimPrefix(b, "v"))
161151 }
162- parts := strings.Split(s, ".")
163- out := make([]int, 0, len(parts))
164- for _, p := range parts {
165- n, _ := strconv.Atoi(p)
166- out = append(out, n)
152+ return semver.Compare(a, b), nil
153+}
154+
155+func normalizedVersion(v string) string {
156+ v = versionOnly(v)
157+ if !strings.HasPrefix(v, "v") {
158+ v = "v" + v
167159 }
168- return out
160+ return v
169161 }
170162
171163 // fetchLatestRelease queries the GitHub API for the latest published
@@ -7,9 +7,10 @@ import (
7 "net/http"7 "net/http"
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10- "strconv"
11 "strings"10 "strings"
12 "time"11 "time"
12+
13+ "golang.org/x/mod/semver"
13 )14 )
14 15
15 // updateCheckTTL is how often we hit the GitHub API to look for a new16 // updateCheckTTL is how often we hit the GitHub API to look for a new
@@ -132,40 +133,31 @@ func buildInfo(current, latest, url string) UpdateInfo {
132 return info133 return info
133 }134 }
134 135
135-// versionLess returns a < b for dotted semver-ish tags like "0.0.4".136+// versionLess reports whether a is an older semantic version than b.
136-// Non-numeric components compare as zero, which is fine for zut's137+// Invalid versions return false so update checks never offer a downgrade.
137-// x.y.z-only scheme.
138 func versionLess(a, b string) bool {138 func versionLess(a, b string) bool {
139- as := splitVersion(a)139+ comparison, err := compareVersions(a, b)
140- bs := splitVersion(b)140+ return err == nil && comparison < 0
141- for i := 0; i < 3; i++ {
142- av, bv := 0, 0
143- if i < len(as) {
144- av = as[i]
145- }
146- if i < len(bs) {
147- bv = bs[i]
148- }
149- if av != bv {
150- return av < bv
151- }
152- }
153- return false
154 }141 }
155 142
156-func splitVersion(s string) []int {143+func compareVersions(a, b string) (int, error) {
157- s = strings.TrimPrefix(s, "v")144+ a = normalizedVersion(a)
158- // Strip build-metadata suffix like "(abc1234, 2026-04-18)".145+ b = normalizedVersion(b)
159- if i := strings.IndexAny(s, " ("); i > 0 {146+ if !semver.IsValid(a) {
160- s = s[:i]147+ return 0, fmt.Errorf("invalid semantic version %q", strings.TrimPrefix(a, "v"))
148+ }
149+ if !semver.IsValid(b) {
150+ return 0, fmt.Errorf("invalid semantic version %q", strings.TrimPrefix(b, "v"))
161 }151 }
162- parts := strings.Split(s, ".")152+ return semver.Compare(a, b), nil
163- out := make([]int, 0, len(parts))153+}
164- for _, p := range parts {154+
165- n, _ := strconv.Atoi(p)155+func normalizedVersion(v string) string {
166- out = append(out, n)156+ v = versionOnly(v)
157+ if !strings.HasPrefix(v, "v") {
158+ v = "v" + v
167 }159 }
168- return out160+ return v
169 }161 }
170 162
171 // fetchLatestRelease queries the GitHub API for the latest published163 // fetchLatestRelease queries the GitHub API for the latest published
M packages/agent/update_test.go +32 -0
@@ -52,3 +52,35 @@ func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) {
5252 t.Fatalf("development check made %d network requests, want 0", transport.calls)
5353 }
5454 }
55+
56+func TestVersionLess(t *testing.T) {
57+ pseudo := "0.3.35-0.20260809165004-9e3c28d4b65"
58+ cases := []struct {
59+ name string
60+ a, b string
61+ want bool
62+ }{
63+ {name: "older release", a: "0.3.33", b: "0.3.34", want: true},
64+ {name: "newer release", a: "0.3.35", b: "0.3.34", want: false},
65+ {name: "pseudo after prior release", a: pseudo, b: "0.3.34", want: false},
66+ {name: "pseudo before final release", a: pseudo, b: "0.3.35", want: true},
67+ {name: "prior release before pseudo", a: "0.3.34", b: pseudo, want: true},
68+ {name: "decorated build", a: "0.3.33 (abc1234, 2026-08-09)", b: "0.3.34", want: true},
69+ {name: "invalid current", a: "not-a-version", b: "0.3.34", want: false},
70+ }
71+ for _, tc := range cases {
72+ t.Run(tc.name, func(t *testing.T) {
73+ if got := versionLess(tc.a, tc.b); got != tc.want {
74+ t.Fatalf("versionLess(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
75+ }
76+ })
77+ }
78+}
79+
80+func TestBuildInfoDoesNotOfferPseudoVersionDowngrade(t *testing.T) {
81+ current := "0.3.35-0.20260809165004-9e3c28d4b65"
82+ info := buildInfo(current, "v0.3.34", "https://example.com/release")
83+ if info.Available {
84+ t.Fatalf("buildInfo(%q, %q) offered a downgrade", current, info.Latest)
85+ }
86+}
@@ -52,3 +52,35 @@ func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) {
52 t.Fatalf("development check made %d network requests, want 0", transport.calls)52 t.Fatalf("development check made %d network requests, want 0", transport.calls)
53 }53 }
54 }54 }
55+
56+func TestVersionLess(t *testing.T) {
57+ pseudo := "0.3.35-0.20260809165004-9e3c28d4b65"
58+ cases := []struct {
59+ name string
60+ a, b string
61+ want bool
62+ }{
63+ {name: "older release", a: "0.3.33", b: "0.3.34", want: true},
64+ {name: "newer release", a: "0.3.35", b: "0.3.34", want: false},
65+ {name: "pseudo after prior release", a: pseudo, b: "0.3.34", want: false},
66+ {name: "pseudo before final release", a: pseudo, b: "0.3.35", want: true},
67+ {name: "prior release before pseudo", a: "0.3.34", b: pseudo, want: true},
68+ {name: "decorated build", a: "0.3.33 (abc1234, 2026-08-09)", b: "0.3.34", want: true},
69+ {name: "invalid current", a: "not-a-version", b: "0.3.34", want: false},
70+ }
71+ for _, tc := range cases {
72+ t.Run(tc.name, func(t *testing.T) {
73+ if got := versionLess(tc.a, tc.b); got != tc.want {
74+ t.Fatalf("versionLess(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
75+ }
76+ })
77+ }
78+}
79+
80+func TestBuildInfoDoesNotOfferPseudoVersionDowngrade(t *testing.T) {
81+ current := "0.3.35-0.20260809165004-9e3c28d4b65"
82+ info := buildInfo(current, "v0.3.34", "https://example.com/release")
83+ if info.Available {
84+ t.Fatalf("buildInfo(%q, %q) offered a downgrade", current, info.Latest)
85+ }
86+}
M packages/agent/zutfile.go +5 -1
@@ -765,7 +765,11 @@ func checkZutfileRequirements(zf zutfileLoaded, version string) error {
765765 if isDevVersion(version) {
766766 return fmt.Errorf("agent requires zut %s or newer; unversioned development builds cannot satisfy min_zut", min)
767767 }
768- if versionLess(version, min) {
768+ comparison, err := compareVersions(version, min)
769+ if err != nil {
770+ return fmt.Errorf("validate min_zut requirement: %w", err)
771+ }
772+ if comparison < 0 {
769773 return fmt.Errorf("agent requires zut %s or newer; running %s", min, versionOnly(version))
770774 }
771775 }
@@ -765,7 +765,11 @@ func checkZutfileRequirements(zf zutfileLoaded, version string) error {
765 if isDevVersion(version) {765 if isDevVersion(version) {
766 return fmt.Errorf("agent requires zut %s or newer; unversioned development builds cannot satisfy min_zut", min)766 return fmt.Errorf("agent requires zut %s or newer; unversioned development builds cannot satisfy min_zut", min)
767 }767 }
768- if versionLess(version, min) {768+ comparison, err := compareVersions(version, min)
769+ if err != nil {
770+ return fmt.Errorf("validate min_zut requirement: %w", err)
771+ }
772+ if comparison < 0 {
769 return fmt.Errorf("agent requires zut %s or newer; running %s", min, versionOnly(version))773 return fmt.Errorf("agent requires zut %s or newer; running %s", min, versionOnly(version))
770 }774 }
771 }775 }
M packages/agent/zutfile_test.go +8 -0
@@ -246,6 +246,14 @@ func TestCheckZutfileMinVersion(t *testing.T) {
246246 if err := checkZutfileRequirements(zf, "0.3.0"); err != nil {
247247 t.Fatalf("minimum version rejected: %v", err)
248248 }
249+ if err := checkZutfileRequirements(zf, "not-a-version"); err == nil {
250+ t.Fatal("invalid zut version accepted")
251+ }
252+
253+ zf.Manifest.Runtime.MinZut = "not-a-version"
254+ if err := checkZutfileRequirements(zf, "0.3.0"); err == nil {
255+ t.Fatal("invalid min_zut requirement accepted")
256+ }
249257 }
250258
251259 func TestApplyZutfileModelRequirementsRejectsUnsupportedFields(t *testing.T) {
@@ -246,6 +246,14 @@ func TestCheckZutfileMinVersion(t *testing.T) {
246 if err := checkZutfileRequirements(zf, "0.3.0"); err != nil {246 if err := checkZutfileRequirements(zf, "0.3.0"); err != nil {
247 t.Fatalf("minimum version rejected: %v", err)247 t.Fatalf("minimum version rejected: %v", err)
248 }248 }
249+ if err := checkZutfileRequirements(zf, "not-a-version"); err == nil {
250+ t.Fatal("invalid zut version accepted")
251+ }
252+
253+ zf.Manifest.Runtime.MinZut = "not-a-version"
254+ if err := checkZutfileRequirements(zf, "0.3.0"); err == nil {
255+ t.Fatal("invalid min_zut requirement accepted")
256+ }
249 }257 }
250 258
251 func TestApplyZutfileModelRequirementsRejectsUnsupportedFields(t *testing.T) {259 func TestApplyZutfileModelRequirementsRejectsUnsupportedFields(t *testing.T) {
M packages/provider/catalog_builtin.go +1 -0
@@ -308,6 +308,7 @@ var builtinCatalog = []Model{
308308 {Provider: "opencode-go", ID: "glm-5", DisplayName: "GLM-5", ContextWindow: 202752, MaxOutput: 32768, Reasoning: true, PriceInput: 1, PriceOutput: 3.2, PriceCacheRead: 0.2, BaseURL: "https://opencode.ai/zen/go/v1"},
309309 {Provider: "opencode-go", ID: "glm-5.1", DisplayName: "GLM-5.1", ContextWindow: 202752, MaxOutput: 32768, Reasoning: true, PriceInput: 1.4, PriceOutput: 4.4, PriceCacheRead: 0.26, BaseURL: "https://opencode.ai/zen/go/v1"},
310310 {Provider: "opencode-go", ID: "glm-5.2", DisplayName: "GLM-5.2", ContextWindow: 1024000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.91, PriceOutput: 2.86, PriceCacheRead: 0.169, BaseURL: "https://opencode.ai/zen/go/v1"},
311+ {Provider: "opencode-go", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.2, PriceOutput: 1.2, PriceCacheRead: 0.02, PriceCacheWrite: 0.25, PriceTierInputTokens: 272000, PriceInputAbove: 0.4, PriceOutputAbove: 1.8, PriceCacheReadAbove: 0.04, PriceCacheWriteAbove: 0.5, BaseURL: "https://opencode.ai/zen/go/v1"},
311312 {Provider: "opencode-go", ID: "grok-4.5", DisplayName: "Grok 4.5", ContextWindow: 500000, MaxOutput: 500000, Reasoning: true, PriceInput: 2, PriceOutput: 6, PriceCacheRead: 0.3, BaseURL: "https://opencode.ai/zen/go/v1"},
312313 {Provider: "opencode-go", ID: "kimi-k2.5", DisplayName: "Kimi K2.5", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.6, PriceOutput: 3, PriceCacheRead: 0.1, BaseURL: "https://opencode.ai/zen/go/v1"},
313314 {Provider: "opencode-go", ID: "kimi-k2.6", DisplayName: "Kimi K2.6", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.95, PriceOutput: 4, PriceCacheRead: 0.16, BaseURL: "https://opencode.ai/zen/go/v1"},
@@ -308,6 +308,7 @@ var builtinCatalog = []Model{
308 {Provider: "opencode-go", ID: "glm-5", DisplayName: "GLM-5", ContextWindow: 202752, MaxOutput: 32768, Reasoning: true, PriceInput: 1, PriceOutput: 3.2, PriceCacheRead: 0.2, BaseURL: "https://opencode.ai/zen/go/v1"},308 {Provider: "opencode-go", ID: "glm-5", DisplayName: "GLM-5", ContextWindow: 202752, MaxOutput: 32768, Reasoning: true, PriceInput: 1, PriceOutput: 3.2, PriceCacheRead: 0.2, BaseURL: "https://opencode.ai/zen/go/v1"},
309 {Provider: "opencode-go", ID: "glm-5.1", DisplayName: "GLM-5.1", ContextWindow: 202752, MaxOutput: 32768, Reasoning: true, PriceInput: 1.4, PriceOutput: 4.4, PriceCacheRead: 0.26, BaseURL: "https://opencode.ai/zen/go/v1"},309 {Provider: "opencode-go", ID: "glm-5.1", DisplayName: "GLM-5.1", ContextWindow: 202752, MaxOutput: 32768, Reasoning: true, PriceInput: 1.4, PriceOutput: 4.4, PriceCacheRead: 0.26, BaseURL: "https://opencode.ai/zen/go/v1"},
310 {Provider: "opencode-go", ID: "glm-5.2", DisplayName: "GLM-5.2", ContextWindow: 1024000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.91, PriceOutput: 2.86, PriceCacheRead: 0.169, BaseURL: "https://opencode.ai/zen/go/v1"},310 {Provider: "opencode-go", ID: "glm-5.2", DisplayName: "GLM-5.2", ContextWindow: 1024000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.91, PriceOutput: 2.86, PriceCacheRead: 0.169, BaseURL: "https://opencode.ai/zen/go/v1"},
311+ {Provider: "opencode-go", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.2, PriceOutput: 1.2, PriceCacheRead: 0.02, PriceCacheWrite: 0.25, PriceTierInputTokens: 272000, PriceInputAbove: 0.4, PriceOutputAbove: 1.8, PriceCacheReadAbove: 0.04, PriceCacheWriteAbove: 0.5, BaseURL: "https://opencode.ai/zen/go/v1"},
311 {Provider: "opencode-go", ID: "grok-4.5", DisplayName: "Grok 4.5", ContextWindow: 500000, MaxOutput: 500000, Reasoning: true, PriceInput: 2, PriceOutput: 6, PriceCacheRead: 0.3, BaseURL: "https://opencode.ai/zen/go/v1"},312 {Provider: "opencode-go", ID: "grok-4.5", DisplayName: "Grok 4.5", ContextWindow: 500000, MaxOutput: 500000, Reasoning: true, PriceInput: 2, PriceOutput: 6, PriceCacheRead: 0.3, BaseURL: "https://opencode.ai/zen/go/v1"},
312 {Provider: "opencode-go", ID: "kimi-k2.5", DisplayName: "Kimi K2.5", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.6, PriceOutput: 3, PriceCacheRead: 0.1, BaseURL: "https://opencode.ai/zen/go/v1"},313 {Provider: "opencode-go", ID: "kimi-k2.5", DisplayName: "Kimi K2.5", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.6, PriceOutput: 3, PriceCacheRead: 0.1, BaseURL: "https://opencode.ai/zen/go/v1"},
313 {Provider: "opencode-go", ID: "kimi-k2.6", DisplayName: "Kimi K2.6", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.95, PriceOutput: 4, PriceCacheRead: 0.16, BaseURL: "https://opencode.ai/zen/go/v1"},314 {Provider: "opencode-go", ID: "kimi-k2.6", DisplayName: "Kimi K2.6", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.95, PriceOutput: 4, PriceCacheRead: 0.16, BaseURL: "https://opencode.ai/zen/go/v1"},
M packages/provider/extra_providers.go +7 -2
@@ -124,9 +124,14 @@ func NewOpenCode(apiKey, baseURL string) Client {
124124 return NewOpenAICompat("opencode", apiKey, baseURL, "https://opencode.ai/zen/v1")
125125 }
126126
127-// NewOpenCodeGo is the opencode-go variant.
127+// NewOpenCodeGo routes each model through the wire API exposed by OpenCode Go.
128128 func NewOpenCodeGo(apiKey, baseURL string) Client {
129- return NewOpenAICompat("opencode-go", apiKey, baseURL, "https://opencode.ai/zen/go/v1")
129+ const defaultBaseURL = "https://opencode.ai/zen/go/v1"
130+ return NewModelRouter("opencode-go",
131+ NewOpenAICompat("opencode-go", apiKey, baseURL, defaultBaseURL),
132+ map[string]Client{
133+ APIResponses: NewOpenAIResponsesNamed(apiKey, firstNonEmptyString(baseURL, defaultBaseURL), "opencode-go"),
134+ })
130135 }
131136
132137 // NewMinimaxOpenAI is the OpenAI-completions flavor of MiniMax, in case
@@ -124,9 +124,14 @@ func NewOpenCode(apiKey, baseURL string) Client {
124 return NewOpenAICompat("opencode", apiKey, baseURL, "https://opencode.ai/zen/v1")124 return NewOpenAICompat("opencode", apiKey, baseURL, "https://opencode.ai/zen/v1")
125 }125 }
126 126
127-// NewOpenCodeGo is the opencode-go variant.127+// NewOpenCodeGo routes each model through the wire API exposed by OpenCode Go.
128 func NewOpenCodeGo(apiKey, baseURL string) Client {128 func NewOpenCodeGo(apiKey, baseURL string) Client {
129- return NewOpenAICompat("opencode-go", apiKey, baseURL, "https://opencode.ai/zen/go/v1")129+ const defaultBaseURL = "https://opencode.ai/zen/go/v1"
130+ return NewModelRouter("opencode-go",
131+ NewOpenAICompat("opencode-go", apiKey, baseURL, defaultBaseURL),
132+ map[string]Client{
133+ APIResponses: NewOpenAIResponsesNamed(apiKey, firstNonEmptyString(baseURL, defaultBaseURL), "opencode-go"),
134+ })
130 }135 }
131 136
132 // NewMinimaxOpenAI is the OpenAI-completions flavor of MiniMax, in case137 // NewMinimaxOpenAI is the OpenAI-completions flavor of MiniMax, in case
M packages/provider/llamacpp.go +16 -2
@@ -47,6 +47,10 @@ type LlamaCPPModel struct {
4747 Size int64 `json:"size,omitempty"`
4848 FileType string `json:"ftype,omitempty"`
4949 } `json:"meta,omitempty"`
50+ // Progress is emitted at the model's top level by current llama.cpp
51+ // routers. Status.Progress supports versions that follow the documented
52+ // nested shape.
53+ Progress map[string]LlamaCPPBytes `json:"progress,omitempty"`
5054 }
5155
5256 // LlamaCPPBytes describes byte progress for one downloaded file.
@@ -204,7 +208,13 @@ func (c *LlamaCPPClient) watch(ctx context.Context, events chan<- llamaCPPEvent)
204208 if c.APIKey != "" {
205209 req.Header.Set("authorization", "Bearer "+c.APIKey)
206210 }
207- resp, err := c.HTTP.Do(req)
211+ // http.Client.Timeout covers the complete response body lifetime, which is
212+ // appropriate for ordinary management requests but would terminate this
213+ // long-lived SSE stream. Preserve the configured transport and other client
214+ // behavior while letting ctx control the stream lifetime.
215+ streamClient := *c.HTTP
216+ streamClient.Timeout = 0
217+ resp, err := streamClient.Do(req)
208218 if err != nil {
209219 return err
210220 }
@@ -408,7 +418,11 @@ func (c *LlamaCPPClient) DownloadAndWait(ctx context.Context, model string, upda
408418 }
409419 if entry.Status.Value == "downloading" {
410420 sawDownload = true
411- if progress, ok := downloadProgress(entry.Status.Progress); ok {
421+ files := entry.Status.Progress
422+ if len(files) == 0 {
423+ files = entry.Progress
424+ }
425+ if progress, ok := downloadProgress(files); ok {
412426 update(progress)
413427 }
414428 } else if sawDownload || polls >= 2 {
@@ -47,6 +47,10 @@ type LlamaCPPModel struct {
47 Size int64 `json:"size,omitempty"`47 Size int64 `json:"size,omitempty"`
48 FileType string `json:"ftype,omitempty"`48 FileType string `json:"ftype,omitempty"`
49 } `json:"meta,omitempty"`49 } `json:"meta,omitempty"`
50+ // Progress is emitted at the model's top level by current llama.cpp
51+ // routers. Status.Progress supports versions that follow the documented
52+ // nested shape.
53+ Progress map[string]LlamaCPPBytes `json:"progress,omitempty"`
50 }54 }
51 55
52 // LlamaCPPBytes describes byte progress for one downloaded file.56 // LlamaCPPBytes describes byte progress for one downloaded file.
@@ -204,7 +208,13 @@ func (c *LlamaCPPClient) watch(ctx context.Context, events chan<- llamaCPPEvent)
204 if c.APIKey != "" {208 if c.APIKey != "" {
205 req.Header.Set("authorization", "Bearer "+c.APIKey)209 req.Header.Set("authorization", "Bearer "+c.APIKey)
206 }210 }
207- resp, err := c.HTTP.Do(req)211+ // http.Client.Timeout covers the complete response body lifetime, which is
212+ // appropriate for ordinary management requests but would terminate this
213+ // long-lived SSE stream. Preserve the configured transport and other client
214+ // behavior while letting ctx control the stream lifetime.
215+ streamClient := *c.HTTP
216+ streamClient.Timeout = 0
217+ resp, err := streamClient.Do(req)
208 if err != nil {218 if err != nil {
209 return err219 return err
210 }220 }
@@ -408,7 +418,11 @@ func (c *LlamaCPPClient) DownloadAndWait(ctx context.Context, model string, upda
408 }418 }
409 if entry.Status.Value == "downloading" {419 if entry.Status.Value == "downloading" {
410 sawDownload = true420 sawDownload = true
411- if progress, ok := downloadProgress(entry.Status.Progress); ok {421+ files := entry.Status.Progress
422+ if len(files) == 0 {
423+ files = entry.Progress
424+ }
425+ if progress, ok := downloadProgress(files); ok {
412 update(progress)426 update(progress)
413 }427 }
414 } else if sawDownload || polls >= 2 {428 } else if sawDownload || polls >= 2 {
M packages/provider/llamacpp_test.go +56 -0
@@ -32,6 +32,62 @@ func TestLlamaCPPLoadProgressStages(t *testing.T) {
3232 }
3333 }
3434
35+func TestLlamaCPPWatchOutlivesRequestTimeout(t *testing.T) {
36+ requestStarted := make(chan struct{})
37+ releaseEvent := make(chan struct{})
38+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39+ w.Header().Set("content-type", "text/event-stream")
40+ w.WriteHeader(http.StatusOK)
41+ w.(http.Flusher).Flush()
42+ close(requestStarted)
43+ select {
44+ case <-releaseEvent:
45+ fmt.Fprint(w, "data: {\"model\":\"test\",\"event\":\"download_progress\",\"data\":{}}\n\n")
46+ w.(http.Flusher).Flush()
47+ case <-r.Context().Done():
48+ }
49+ }))
50+ defer server.Close()
51+
52+ client, err := NewLlamaCPPClient(server.URL, "")
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ client.HTTP.Timeout = 20 * time.Millisecond
57+ ctx, cancel := context.WithCancel(context.Background())
58+ defer cancel()
59+ events := make(chan llamaCPPEvent, 1)
60+ watchDone := make(chan error, 1)
61+ go func() { watchDone <- client.watch(ctx, events) }()
62+ <-requestStarted
63+
64+ select {
65+ case err := <-watchDone:
66+ t.Fatalf("watch stopped at ordinary request timeout: %v", err)
67+ case <-time.After(3 * client.HTTP.Timeout):
68+ }
69+ close(releaseEvent)
70+ select {
71+ case event := <-events:
72+ if event.Model != "test" || event.Event != "download_progress" {
73+ t.Fatalf("event = %#v", event)
74+ }
75+ case <-time.After(time.Second):
76+ t.Fatal("timed out waiting for delayed SSE event")
77+ }
78+}
79+
80+func TestLlamaCPPModelAcceptsTopLevelDownloadProgress(t *testing.T) {
81+ var model LlamaCPPModel
82+ if err := json.Unmarshal([]byte(`{"id":"test","status":{"value":"downloading"},"progress":{"model.gguf":{"done":512,"total":1024}}}`), &model); err != nil {
83+ t.Fatal(err)
84+ }
85+ progress, ok := downloadProgress(model.Progress)
86+ if !ok || progress.Ratio != 0.5 {
87+ t.Fatalf("progress = %#v, ok = %v", progress, ok)
88+ }
89+}
90+
3591 func TestLlamaCPPClientLoadAndDownloadProgress(t *testing.T) {
3692 var mu sync.Mutex
3793 status := "unloaded"
@@ -32,6 +32,62 @@ func TestLlamaCPPLoadProgressStages(t *testing.T) {
32 }32 }
33 }33 }
34 34
35+func TestLlamaCPPWatchOutlivesRequestTimeout(t *testing.T) {
36+ requestStarted := make(chan struct{})
37+ releaseEvent := make(chan struct{})
38+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39+ w.Header().Set("content-type", "text/event-stream")
40+ w.WriteHeader(http.StatusOK)
41+ w.(http.Flusher).Flush()
42+ close(requestStarted)
43+ select {
44+ case <-releaseEvent:
45+ fmt.Fprint(w, "data: {\"model\":\"test\",\"event\":\"download_progress\",\"data\":{}}\n\n")
46+ w.(http.Flusher).Flush()
47+ case <-r.Context().Done():
48+ }
49+ }))
50+ defer server.Close()
51+
52+ client, err := NewLlamaCPPClient(server.URL, "")
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ client.HTTP.Timeout = 20 * time.Millisecond
57+ ctx, cancel := context.WithCancel(context.Background())
58+ defer cancel()
59+ events := make(chan llamaCPPEvent, 1)
60+ watchDone := make(chan error, 1)
61+ go func() { watchDone <- client.watch(ctx, events) }()
62+ <-requestStarted
63+
64+ select {
65+ case err := <-watchDone:
66+ t.Fatalf("watch stopped at ordinary request timeout: %v", err)
67+ case <-time.After(3 * client.HTTP.Timeout):
68+ }
69+ close(releaseEvent)
70+ select {
71+ case event := <-events:
72+ if event.Model != "test" || event.Event != "download_progress" {
73+ t.Fatalf("event = %#v", event)
74+ }
75+ case <-time.After(time.Second):
76+ t.Fatal("timed out waiting for delayed SSE event")
77+ }
78+}
79+
80+func TestLlamaCPPModelAcceptsTopLevelDownloadProgress(t *testing.T) {
81+ var model LlamaCPPModel
82+ if err := json.Unmarshal([]byte(`{"id":"test","status":{"value":"downloading"},"progress":{"model.gguf":{"done":512,"total":1024}}}`), &model); err != nil {
83+ t.Fatal(err)
84+ }
85+ progress, ok := downloadProgress(model.Progress)
86+ if !ok || progress.Ratio != 0.5 {
87+ t.Fatalf("progress = %#v, ok = %v", progress, ok)
88+ }
89+}
90+
35 func TestLlamaCPPClientLoadAndDownloadProgress(t *testing.T) {91 func TestLlamaCPPClientLoadAndDownloadProgress(t *testing.T) {
36 var mu sync.Mutex92 var mu sync.Mutex
37 status := "unloaded"93 status := "unloaded"
M packages/provider/model_router_test.go +31 -0
@@ -48,3 +48,34 @@ func TestModelRouterRejectsMissingAPIClient(t *testing.T) {
4848 t.Fatal("expected missing API client error")
4949 }
5050 }
51+
52+func TestOpenCodeGoRoutesLunaToResponses(t *testing.T) {
53+ router := NewOpenCodeGo("token", "https://example.com/go/v1").(*modelRouter)
54+ if got := router.fallback.(*openaiClient).baseURL; got != "https://example.com/go/v1" {
55+ t.Fatalf("Completions base URL = %q", got)
56+ }
57+ responses := router.byAPI[APIResponses].(*renamedClient).inner.(*codexClient)
58+ if got := responses.baseURL; got != "https://example.com/go/v1/responses" {
59+ t.Fatalf("Responses base URL = %q", got)
60+ }
61+
62+ completionsCapture := &routeCaptureClient{name: "opencode-go"}
63+ responsesCapture := &routeCaptureClient{name: "opencode-go"}
64+ router.fallback = completionsCapture
65+ router.byAPI[APIResponses] = responsesCapture
66+ for _, model := range []string{"gpt-5.6-luna", "kimi-k3"} {
67+ stream, err := router.Stream(context.Background(), Request{Model: model})
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ for range stream {
72+ }
73+ }
74+
75+ if len(responsesCapture.models) != 1 || responsesCapture.models[0] != "gpt-5.6-luna" {
76+ t.Fatalf("Responses models = %v", responsesCapture.models)
77+ }
78+ if len(completionsCapture.models) != 1 || completionsCapture.models[0] != "kimi-k3" {
79+ t.Fatalf("Completions models = %v", completionsCapture.models)
80+ }
81+}
@@ -48,3 +48,34 @@ func TestModelRouterRejectsMissingAPIClient(t *testing.T) {
48 t.Fatal("expected missing API client error")48 t.Fatal("expected missing API client error")
49 }49 }
50 }50 }
51+
52+func TestOpenCodeGoRoutesLunaToResponses(t *testing.T) {
53+ router := NewOpenCodeGo("token", "https://example.com/go/v1").(*modelRouter)
54+ if got := router.fallback.(*openaiClient).baseURL; got != "https://example.com/go/v1" {
55+ t.Fatalf("Completions base URL = %q", got)
56+ }
57+ responses := router.byAPI[APIResponses].(*renamedClient).inner.(*codexClient)
58+ if got := responses.baseURL; got != "https://example.com/go/v1/responses" {
59+ t.Fatalf("Responses base URL = %q", got)
60+ }
61+
62+ completionsCapture := &routeCaptureClient{name: "opencode-go"}
63+ responsesCapture := &routeCaptureClient{name: "opencode-go"}
64+ router.fallback = completionsCapture
65+ router.byAPI[APIResponses] = responsesCapture
66+ for _, model := range []string{"gpt-5.6-luna", "kimi-k3"} {
67+ stream, err := router.Stream(context.Background(), Request{Model: model})
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ for range stream {
72+ }
73+ }
74+
75+ if len(responsesCapture.models) != 1 || responsesCapture.models[0] != "gpt-5.6-luna" {
76+ t.Fatalf("Responses models = %v", responsesCapture.models)
77+ }
78+ if len(completionsCapture.models) != 1 || completionsCapture.models[0] != "kimi-k3" {
79+ t.Fatalf("Completions models = %v", completionsCapture.models)
80+ }
81+}
M packages/provider/openai.go +5 -0
@@ -264,6 +264,11 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
264264 // don't clamp zut's "maximum" to "high" for those models.
265265 effort = OpenAICompatAnthropicEffort(reasoning)
266266 }
267+ if hasReasoningLevelOverride(m, req.Reasoning) {
268+ // Explicit model mappings describe the endpoint's actual effort
269+ // values and take precedence over conservative protocol defaults.
270+ effort = reasoning
271+ }
267272 if effort != "" {
268273 out.ReasoningEffort = effort
269274 }
@@ -264,6 +264,11 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
264 // don't clamp zut's "maximum" to "high" for those models.264 // don't clamp zut's "maximum" to "high" for those models.
265 effort = OpenAICompatAnthropicEffort(reasoning)265 effort = OpenAICompatAnthropicEffort(reasoning)
266 }266 }
267+ if hasReasoningLevelOverride(m, req.Reasoning) {
268+ // Explicit model mappings describe the endpoint's actual effort
269+ // values and take precedence over conservative protocol defaults.
270+ effort = reasoning
271+ }
267 if effort != "" {272 if effort != "" {
268 out.ReasoningEffort = effort273 out.ReasoningEffort = effort
269 }274 }
M packages/provider/openai_codex.go +5 -1
@@ -206,7 +206,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
206206 body.ServiceTier = fastModeServiceTier
207207 }
208208 if m.Reasoning {
209- if effort := OpenAICodexReasoningEffort(reasoning, req.Model); effort != "" {
209+ effort := OpenAICodexReasoningEffort(reasoning, req.Model)
210+ if hasReasoningLevelOverride(m, req.Reasoning) {
211+ effort = reasoning
212+ }
213+ if effort != "" {
210214 body.Reasoning = &codexReasoningConfig{Effort: effort}
211215 }
212216 }
@@ -206,7 +206,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
206 body.ServiceTier = fastModeServiceTier206 body.ServiceTier = fastModeServiceTier
207 }207 }
208 if m.Reasoning {208 if m.Reasoning {
209- if effort := OpenAICodexReasoningEffort(reasoning, req.Model); effort != "" {209+ effort := OpenAICodexReasoningEffort(reasoning, req.Model)
210+ if hasReasoningLevelOverride(m, req.Reasoning) {
211+ effort = reasoning
212+ }
213+ if effort != "" {
210 body.Reasoning = &codexReasoningConfig{Effort: effort}214 body.Reasoning = &codexReasoningConfig{Effort: effort}
211 }215 }
212 }216 }
M packages/provider/openai_codex_test.go +21 -0
@@ -126,6 +126,27 @@ func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
126126 }
127127 }
128128
129+func TestResponsesRequestUsesExplicitReasoningLevelMap(t *testing.T) {
130+ SetLiveModels([]Model{{
131+ Provider: "custom-responses",
132+ ID: "reasoning-model",
133+ API: APIResponses,
134+ Reasoning: true,
135+ ReasoningLevelMap: map[string]string{"max": "max"},
136+ }})
137+ t.Cleanup(func() { SetLiveModels(nil) })
138+
139+ named := NewOpenAIResponsesNamed("token", "https://example.test/v1", "custom-responses").(*renamedClient)
140+ c := named.inner.(*codexClient)
141+ wire, err := c.buildRequest(Request{Model: "reasoning-model", Reasoning: "max"})
142+ if err != nil {
143+ t.Fatal(err)
144+ }
145+ if wire.Reasoning == nil || wire.Reasoning.Effort != "max" {
146+ t.Fatalf("reasoning = %+v", wire.Reasoning)
147+ }
148+}
149+
129150 func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) {
130151 named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient)
131152 c := named.inner.(*codexClient)
@@ -126,6 +126,27 @@ func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
126 }126 }
127 }127 }
128 128
129+func TestResponsesRequestUsesExplicitReasoningLevelMap(t *testing.T) {
130+ SetLiveModels([]Model{{
131+ Provider: "custom-responses",
132+ ID: "reasoning-model",
133+ API: APIResponses,
134+ Reasoning: true,
135+ ReasoningLevelMap: map[string]string{"max": "max"},
136+ }})
137+ t.Cleanup(func() { SetLiveModels(nil) })
138+
139+ named := NewOpenAIResponsesNamed("token", "https://example.test/v1", "custom-responses").(*renamedClient)
140+ c := named.inner.(*codexClient)
141+ wire, err := c.buildRequest(Request{Model: "reasoning-model", Reasoning: "max"})
142+ if err != nil {
143+ t.Fatal(err)
144+ }
145+ if wire.Reasoning == nil || wire.Reasoning.Effort != "max" {
146+ t.Fatalf("reasoning = %+v", wire.Reasoning)
147+ }
148+}
149+
129 func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) {150 func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) {
130 named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient)151 named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient)
131 c := named.inner.(*codexClient)152 c := named.inner.(*codexClient)
M packages/provider/provider_test.go +14 -4
@@ -649,10 +649,8 @@ func TestGPT56CatalogEntries(t *testing.T) {
649649 }
650650 }
651651
652-// TestOpenCodeGoCatalog pins the Grok 4.5 and Kimi K3 entries added to
653-// the OpenCode Go provider. Both are listed in the vendor's current Go
654-// model lineup (https://opencode.ai/docs/go) and were missing from the
655-// baked-in catalog.
652+// TestOpenCodeGoCatalog pins entries added from the vendor's current Go
653+// model lineup (https://opencode.ai/docs/go).
656654 func TestOpenCodeGoCatalog(t *testing.T) {
657655 cases := []struct {
658656 id string
@@ -662,6 +660,7 @@ func TestOpenCodeGoCatalog(t *testing.T) {
662660 priceOut float64
663661 cacheRead float64
664662 }{
663+ {"gpt-5.6-luna", 1050000, 128000, 0.2, 1.2, 0.02},
665664 {"grok-4.5", 500000, 500000, 2, 6, 0.3},
666665 {"kimi-k3", 1048576, 131072, 3, 15, 0.3},
667666 }
@@ -680,6 +679,17 @@ func TestOpenCodeGoCatalog(t *testing.T) {
680679 t.Fatalf("opencode-go/%s baseURL: %s", tc.id, m.BaseURL)
681680 }
682681 }
682+
683+ luna, err := FindModel("opencode-go", "gpt-5.6-luna")
684+ if err != nil {
685+ t.Fatal(err)
686+ }
687+ if luna.API != APIResponses || luna.PriceCacheWrite != 0.25 {
688+ t.Fatalf("opencode-go/gpt-5.6-luna API or cache-write price: %+v", luna)
689+ }
690+ if luna.PriceTierInputTokens != 272000 || luna.PriceInputAbove != 0.4 || luna.PriceOutputAbove != 1.8 || luna.PriceCacheReadAbove != 0.04 || luna.PriceCacheWriteAbove != 0.5 {
691+ t.Fatalf("opencode-go/gpt-5.6-luna long-context prices: %+v", luna)
692+ }
683693 }
684694
685695 // TestOpenAIOmitsZeroMaxTokens guards against sending max_tokens: 0 for
@@ -649,10 +649,8 @@ func TestGPT56CatalogEntries(t *testing.T) {
649 }649 }
650 }650 }
651 651
652-// TestOpenCodeGoCatalog pins the Grok 4.5 and Kimi K3 entries added to652+// TestOpenCodeGoCatalog pins entries added from the vendor's current Go
653-// the OpenCode Go provider. Both are listed in the vendor's current Go653+// model lineup (https://opencode.ai/docs/go).
654-// model lineup (https://opencode.ai/docs/go) and were missing from the
655-// baked-in catalog.
656 func TestOpenCodeGoCatalog(t *testing.T) {654 func TestOpenCodeGoCatalog(t *testing.T) {
657 cases := []struct {655 cases := []struct {
658 id string656 id string
@@ -662,6 +660,7 @@ func TestOpenCodeGoCatalog(t *testing.T) {
662 priceOut float64660 priceOut float64
663 cacheRead float64661 cacheRead float64
664 }{662 }{
663+ {"gpt-5.6-luna", 1050000, 128000, 0.2, 1.2, 0.02},
665 {"grok-4.5", 500000, 500000, 2, 6, 0.3},664 {"grok-4.5", 500000, 500000, 2, 6, 0.3},
666 {"kimi-k3", 1048576, 131072, 3, 15, 0.3},665 {"kimi-k3", 1048576, 131072, 3, 15, 0.3},
667 }666 }
@@ -680,6 +679,17 @@ func TestOpenCodeGoCatalog(t *testing.T) {
680 t.Fatalf("opencode-go/%s baseURL: %s", tc.id, m.BaseURL)679 t.Fatalf("opencode-go/%s baseURL: %s", tc.id, m.BaseURL)
681 }680 }
682 }681 }
682+
683+ luna, err := FindModel("opencode-go", "gpt-5.6-luna")
684+ if err != nil {
685+ t.Fatal(err)
686+ }
687+ if luna.API != APIResponses || luna.PriceCacheWrite != 0.25 {
688+ t.Fatalf("opencode-go/gpt-5.6-luna API or cache-write price: %+v", luna)
689+ }
690+ if luna.PriceTierInputTokens != 272000 || luna.PriceInputAbove != 0.4 || luna.PriceOutputAbove != 1.8 || luna.PriceCacheReadAbove != 0.04 || luna.PriceCacheWriteAbove != 0.5 {
691+ t.Fatalf("opencode-go/gpt-5.6-luna long-context prices: %+v", luna)
692+ }
683 }693 }
684 694
685 // TestOpenAIOmitsZeroMaxTokens guards against sending max_tokens: 0 for695 // TestOpenAIOmitsZeroMaxTokens guards against sending max_tokens: 0 for
M packages/provider/reasoning.go +7 -5
@@ -5,8 +5,8 @@ import "strings"
55 var reasoningLevelOrder = []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
66
77 // AvailableReasoningLevels returns the distinct reasoning levels supported by
8-// a model. Protocol defaults are adjusted by optional per-model overrides. The
9-// empty string represents off.
8+// a model. Optional per-model overrides can remove, remap, or extend protocol
9+// defaults. The empty string represents off.
1010 func AvailableReasoningLevels(model Model) []string {
1111 defaults := defaultReasoningLevels(model)
1212 if !model.Reasoning || len(model.ReasoningLevelMap) == 0 {
@@ -24,9 +24,6 @@ func AvailableReasoningLevels(model Model) []string {
2424 if mapped, overridden := model.ReasoningLevelMap[level]; overridden {
2525 effective = NormalizeReasoning(mapped)
2626 }
27- if effective != "" && !containsReasoningLevel(defaults, effective) {
28- effective = nearestReasoningLevel(defaults, effective)
29- }
3027 if reasoningLevelRank(effective) > 0 {
3128 available[effective] = true
3229 }
@@ -90,6 +87,11 @@ func containsReasoningLevel(levels []string, target string) bool {
9087 return false
9188 }
9289
90+func hasReasoningLevelOverride(model Model, level string) bool {
91+ _, ok := model.ReasoningLevelMap[NormalizeReasoning(level)]
92+ return ok
93+}
94+
9395 // ClampReasoningForModel maps a configured level to the nearest level exposed
9496 // for the active model. Ties prefer the higher level.
9597 func ClampReasoningForModel(model Model, level string) string {
@@ -5,8 +5,8 @@ import "strings"
5 var reasoningLevelOrder = []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}5 var reasoningLevelOrder = []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
6 6
7 // AvailableReasoningLevels returns the distinct reasoning levels supported by7 // AvailableReasoningLevels returns the distinct reasoning levels supported by
8-// a model. Protocol defaults are adjusted by optional per-model overrides. The8+// a model. Optional per-model overrides can remove, remap, or extend protocol
9-// empty string represents off.9+// defaults. The empty string represents off.
10 func AvailableReasoningLevels(model Model) []string {10 func AvailableReasoningLevels(model Model) []string {
11 defaults := defaultReasoningLevels(model)11 defaults := defaultReasoningLevels(model)
12 if !model.Reasoning || len(model.ReasoningLevelMap) == 0 {12 if !model.Reasoning || len(model.ReasoningLevelMap) == 0 {
@@ -24,9 +24,6 @@ func AvailableReasoningLevels(model Model) []string {
24 if mapped, overridden := model.ReasoningLevelMap[level]; overridden {24 if mapped, overridden := model.ReasoningLevelMap[level]; overridden {
25 effective = NormalizeReasoning(mapped)25 effective = NormalizeReasoning(mapped)
26 }26 }
27- if effective != "" && !containsReasoningLevel(defaults, effective) {
28- effective = nearestReasoningLevel(defaults, effective)
29- }
30 if reasoningLevelRank(effective) > 0 {27 if reasoningLevelRank(effective) > 0 {
31 available[effective] = true28 available[effective] = true
32 }29 }
@@ -90,6 +87,11 @@ func containsReasoningLevel(levels []string, target string) bool {
90 return false87 return false
91 }88 }
92 89
90+func hasReasoningLevelOverride(model Model, level string) bool {
91+ _, ok := model.ReasoningLevelMap[NormalizeReasoning(level)]
92+ return ok
93+}
94+
93 // ClampReasoningForModel maps a configured level to the nearest level exposed95 // ClampReasoningForModel maps a configured level to the nearest level exposed
94 // for the active model. Ties prefer the higher level.96 // for the active model. Ties prefer the higher level.
95 func ClampReasoningForModel(model Model, level string) string {97 func ClampReasoningForModel(model Model, level string) string {
M packages/provider/reasoning_test.go +34 -10
@@ -29,7 +29,17 @@ func TestAvailableReasoningLevels(t *testing.T) {
2929 Provider: "anthropic", Reasoning: true,
3030 ReasoningLevelMap: map[string]string{"minimum": "low", "high": "", "max": "max"},
3131 },
32- want: []string{"", "low", "medium", "xhigh"},
32+ want: []string{"", "low", "medium", "xhigh", "max"},
33+ },
34+ {
35+ name: "explicit levels extend generic defaults",
36+ model: Model{
37+ Provider: "custom", Reasoning: true,
38+ ReasoningLevelMap: map[string]string{
39+ "minimum": "", "low": "", "medium": "", "high": "high", "xhigh": "xhigh", "max": "max",
40+ },
41+ },
42+ want: []string{"", "high", "xhigh", "max"},
3343 },
3444 }
3545 for _, tt := range tests {
@@ -59,6 +69,12 @@ func TestClampReasoningForModel(t *testing.T) {
5969 level: "minimum",
6070 want: "high",
6171 },
72+ {
73+ name: "explicit max extends generic defaults",
74+ model: Model{Reasoning: true, ReasoningLevelMap: map[string]string{"max": "max"}},
75+ level: "max",
76+ want: "max",
77+ },
6278 }
6379 for _, tt := range tests {
6480 t.Run(tt.name, func(t *testing.T) {
@@ -71,20 +87,28 @@ func TestClampReasoningForModel(t *testing.T) {
7187
7288 func TestOpenAIRequestUsesReasoningLevelMap(t *testing.T) {
7389 SetLiveModels([]Model{{
74- Provider: "openai",
90+ Provider: "custom",
7591 ID: "mapped-reasoning-model",
7692 Reasoning: true,
77- ReasoningLevelMap: map[string]string{"high": "low"},
93+ ReasoningLevelMap: map[string]string{"high": "low", "max": "max"},
7894 }})
7995 t.Cleanup(func() { SetLiveModels(nil) })
8096
81- client := NewOpenAI("test", "").(*openaiClient)
82- request, err := client.buildRequest(Request{Model: "mapped-reasoning-model", Reasoning: "high"})
83- if err != nil {
84- t.Fatal(err)
85- }
86- if request.ReasoningEffort != "low" {
87- t.Fatalf("reasoning effort = %q, want low", request.ReasoningEffort)
97+ client := NewOpenAICompat("custom", "test", "", "").(*openaiClient)
98+ for _, tt := range []struct {
99+ requested string
100+ want string
101+ }{
102+ {requested: "high", want: "low"},
103+ {requested: "max", want: "max"},
104+ } {
105+ request, err := client.buildRequest(Request{Model: "mapped-reasoning-model", Reasoning: tt.requested})
106+ if err != nil {
107+ t.Fatal(err)
108+ }
109+ if request.ReasoningEffort != tt.want {
110+ t.Errorf("reasoning effort for %q = %q, want %q", tt.requested, request.ReasoningEffort, tt.want)
111+ }
88112 }
89113 }
90114
@@ -29,7 +29,17 @@ func TestAvailableReasoningLevels(t *testing.T) {
29 Provider: "anthropic", Reasoning: true,29 Provider: "anthropic", Reasoning: true,
30 ReasoningLevelMap: map[string]string{"minimum": "low", "high": "", "max": "max"},30 ReasoningLevelMap: map[string]string{"minimum": "low", "high": "", "max": "max"},
31 },31 },
32- want: []string{"", "low", "medium", "xhigh"},32+ want: []string{"", "low", "medium", "xhigh", "max"},
33+ },
34+ {
35+ name: "explicit levels extend generic defaults",
36+ model: Model{
37+ Provider: "custom", Reasoning: true,
38+ ReasoningLevelMap: map[string]string{
39+ "minimum": "", "low": "", "medium": "", "high": "high", "xhigh": "xhigh", "max": "max",
40+ },
41+ },
42+ want: []string{"", "high", "xhigh", "max"},
33 },43 },
34 }44 }
35 for _, tt := range tests {45 for _, tt := range tests {
@@ -59,6 +69,12 @@ func TestClampReasoningForModel(t *testing.T) {
59 level: "minimum",69 level: "minimum",
60 want: "high",70 want: "high",
61 },71 },
72+ {
73+ name: "explicit max extends generic defaults",
74+ model: Model{Reasoning: true, ReasoningLevelMap: map[string]string{"max": "max"}},
75+ level: "max",
76+ want: "max",
77+ },
62 }78 }
63 for _, tt := range tests {79 for _, tt := range tests {
64 t.Run(tt.name, func(t *testing.T) {80 t.Run(tt.name, func(t *testing.T) {
@@ -71,20 +87,28 @@ func TestClampReasoningForModel(t *testing.T) {
71 87
72 func TestOpenAIRequestUsesReasoningLevelMap(t *testing.T) {88 func TestOpenAIRequestUsesReasoningLevelMap(t *testing.T) {
73 SetLiveModels([]Model{{89 SetLiveModels([]Model{{
74- Provider: "openai",90+ Provider: "custom",
75 ID: "mapped-reasoning-model",91 ID: "mapped-reasoning-model",
76 Reasoning: true,92 Reasoning: true,
77- ReasoningLevelMap: map[string]string{"high": "low"},93+ ReasoningLevelMap: map[string]string{"high": "low", "max": "max"},
78 }})94 }})
79 t.Cleanup(func() { SetLiveModels(nil) })95 t.Cleanup(func() { SetLiveModels(nil) })
80 96
81- client := NewOpenAI("test", "").(*openaiClient)97+ client := NewOpenAICompat("custom", "test", "", "").(*openaiClient)
82- request, err := client.buildRequest(Request{Model: "mapped-reasoning-model", Reasoning: "high"})98+ for _, tt := range []struct {
83- if err != nil {99+ requested string
84- t.Fatal(err)100+ want string
85- }101+ }{
86- if request.ReasoningEffort != "low" {102+ {requested: "high", want: "low"},
87- t.Fatalf("reasoning effort = %q, want low", request.ReasoningEffort)103+ {requested: "max", want: "max"},
104+ } {
105+ request, err := client.buildRequest(Request{Model: "mapped-reasoning-model", Reasoning: tt.requested})
106+ if err != nil {
107+ t.Fatal(err)
108+ }
109+ if request.ReasoningEffort != tt.want {
110+ t.Errorf("reasoning effort for %q = %q, want %q", tt.requested, request.ReasoningEffort, tt.want)
111+ }
88 }112 }
89 }113 }
90 114

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close