detached 1513f55

Merge pull request #21 from bnema/feature/ponytail-mode

feat(agent): add default-on Ponytail coding mode
commit
1513f55b280276ffc94f2a89bab57255d04109a0
author
bnema · · 2026-08-05 07:55 UTC
committer
GitHub · · 2026-08-05 07:55 UTC
merge of
b6de308 + dca6cce · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
21 files · +881 -36
M README.md +10 -5
@@ -148,14 +148,18 @@ $ZOT_HOME/
148148 ├── sessions/ # jsonl transcripts, one dir per cwd
149149 ├── models-cache.json # live /v1/models discovery cache (6h ttl)
150150 ├── AGENTS.md # optional: global instructions appended to the prompt
151-├── SYSTEM.md # optional: replaces the default system prompt
151+├── SYSTEM.md # optional: replaces the built-in identity; addenda remain
152152 ├── skills/ # optional: user SKILL.md files
153153 ├── themes/ # optional: user theme JSON files
154154 ├── extensions/ # installed extensions, one dir per extension
155155 └── logs/ # app log files
156156 ```
157157
158-Drop a `SYSTEM.md` in `$ZOT_HOME` to replace the built-in identity and guidelines for every run. `--system-prompt` still wins per-invocation. Delete the file to revert to the default.
158+Drop a `SYSTEM.md` in `$ZOT_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.
159+
160+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 `$ZOT_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.
161+
162+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.
159163
160164 ## Persistent instructions (AGENTS.md)
161165
@@ -180,9 +184,9 @@ Treat questions and discussions as requests for explanation. Do not edit files o
180184 |---|---|---|
181185 | `$ZOT_HOME/AGENTS.md` | global, every run | appended to the default prompt |
182186 | `./AGENTS.md` (and parent dirs) | project | appended to the default prompt |
183-| `$ZOT_HOME/SYSTEM.md` | global, every run | replaces the default prompt entirely |
187+| `$ZOT_HOME/SYSTEM.md` | global, every run | replaces the built-in identity; append addenda remain |
184188 | `--append-system-prompt <text>` | single run | appends for one invocation (repeatable) |
185-| `--system-prompt <text>` | single run | replaces the prompt for one invocation |
189+| `--system-prompt <text>` | single run | replaces the built-in identity for one invocation; append addenda remain |
186190
187191 > **Note:** zot 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 zot will use it.
188192
@@ -298,7 +302,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
298302 | `/unjail` | Allow tools to touch paths outside again. |
299303 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
300304 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
301-| `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-subagents, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. |
305+| `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-subagents, Ponytail coding mode, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. |
302306 | `/clear` | Clear the chat transcript. |
303307 | `/exit` | Exit zot. |
304308
@@ -405,6 +409,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
405409 - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all.
406410 - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zot: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.
407411 - **auto-subagents** — let the main agent spawn background sub-agents in parallel via the canonical `subagent_spawn` tool. Off by default. The tool accepts named profiles, reasoning, timeout, turn limits, and shared/worktree isolation; lifecycle and result references are persisted. Completion updates still arrive through the auto-subagents watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.
412+- **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZOT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zotfile agents.
408413 - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account.
409414 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
410415 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.
@@ -148,14 +148,18 @@ $ZOT_HOME/
148 ├── sessions/ # jsonl transcripts, one dir per cwd148 ├── sessions/ # jsonl transcripts, one dir per cwd
149 ├── models-cache.json # live /v1/models discovery cache (6h ttl)149 ├── models-cache.json # live /v1/models discovery cache (6h ttl)
150 ├── AGENTS.md # optional: global instructions appended to the prompt150 ├── AGENTS.md # optional: global instructions appended to the prompt
151-├── SYSTEM.md # optional: replaces the default system prompt151+├── SYSTEM.md # optional: replaces the built-in identity; addenda remain
152 ├── skills/ # optional: user SKILL.md files152 ├── skills/ # optional: user SKILL.md files
153 ├── themes/ # optional: user theme JSON files153 ├── themes/ # optional: user theme JSON files
154 ├── extensions/ # installed extensions, one dir per extension154 ├── extensions/ # installed extensions, one dir per extension
155 └── logs/ # app log files155 └── logs/ # app log files
156 ```156 ```
157 157
158-Drop a `SYSTEM.md` in `$ZOT_HOME` to replace the built-in identity and guidelines for every run. `--system-prompt` still wins per-invocation. Delete the file to revert to the default.158+Drop a `SYSTEM.md` in `$ZOT_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.
159+
160+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 `$ZOT_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.
161+
162+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.
159 163
160 ## Persistent instructions (AGENTS.md)164 ## Persistent instructions (AGENTS.md)
161 165
@@ -180,9 +184,9 @@ Treat questions and discussions as requests for explanation. Do not edit files o
180 |---|---|---|184 |---|---|---|
181 | `$ZOT_HOME/AGENTS.md` | global, every run | appended to the default prompt |185 | `$ZOT_HOME/AGENTS.md` | global, every run | appended to the default prompt |
182 | `./AGENTS.md` (and parent dirs) | project | appended to the default prompt |186 | `./AGENTS.md` (and parent dirs) | project | appended to the default prompt |
183-| `$ZOT_HOME/SYSTEM.md` | global, every run | replaces the default prompt entirely |187+| `$ZOT_HOME/SYSTEM.md` | global, every run | replaces the built-in identity; append addenda remain |
184 | `--append-system-prompt <text>` | single run | appends for one invocation (repeatable) |188 | `--append-system-prompt <text>` | single run | appends for one invocation (repeatable) |
185-| `--system-prompt <text>` | single run | replaces the prompt for one invocation |189+| `--system-prompt <text>` | single run | replaces the built-in identity for one invocation; append addenda remain |
186 190
187 > **Note:** zot 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 zot will use it.191 > **Note:** zot 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 zot will use it.
188 192
@@ -298,7 +302,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
298 | `/unjail` | Allow tools to touch paths outside again. |302 | `/unjail` | Allow tools to touch paths outside again. |
299 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |303 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
300 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |304 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
301-| `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-subagents, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. |305+| `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-subagents, Ponytail coding mode, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. |
302 | `/clear` | Clear the chat transcript. |306 | `/clear` | Clear the chat transcript. |
303 | `/exit` | Exit zot. |307 | `/exit` | Exit zot. |
304 308
@@ -405,6 +409,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
405 - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all.409 - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all.
406 - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zot: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.410 - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zot: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.
407 - **auto-subagents** — let the main agent spawn background sub-agents in parallel via the canonical `subagent_spawn` tool. Off by default. The tool accepts named profiles, reasoning, timeout, turn limits, and shared/worktree isolation; lifecycle and result references are persisted. Completion updates still arrive through the auto-subagents watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.411 - **auto-subagents** — let the main agent spawn background sub-agents in parallel via the canonical `subagent_spawn` tool. Off by default. The tool accepts named profiles, reasoning, timeout, turn limits, and shared/worktree isolation; lifecycle and result references are persisted. Completion updates still arrive through the auto-subagents watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.
412+- **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZOT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zotfile agents.
408 - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account.413 - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account.
409 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.414 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
410 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.415 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.
M docs/rpc.md +2 -0
@@ -25,6 +25,8 @@ You'll see one JSON object per line on stdout: a response acknowledging the prom
2525
2626 `zot 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.
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 `$ZOT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity, while enabled append addenda remain present.
29+
2830 ## Auth
2931
3032 If the environment variable `ZOTCORE_RPC_TOKEN` is set on the spawned process, the first line on stdin **must** be a `hello` command containing the matching token:
@@ -25,6 +25,8 @@ You'll see one JSON object per line on stdout: a response acknowledging the prom
25 25
26 `zot 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 `zot 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.
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 `$ZOT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity, while enabled append addenda remain present.
29+
28 ## Auth30 ## Auth
29 31
30 If the environment variable `ZOTCORE_RPC_TOKEN` is set on the spawned process, the first line on stdin **must** be a `hello` command containing the matching token:32 If the environment variable `ZOTCORE_RPC_TOKEN` is set on the spawned process, the first line on stdin **must** be a `hello` command containing the matching token:
M docs/subagents.md +1 -1
@@ -84,7 +84,7 @@ The interactive command also supports the same selection explicitly:
8484 /subagents new --agent implementer --reasoning high Implement the parser change
8585 ```
8686
87-Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zot creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting.
87+Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zot creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; global append addenda, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting.
8888
8989 ## Lifecycle, results, and recovery
9090
@@ -84,7 +84,7 @@ The interactive command also supports the same selection explicitly:
84 /subagents new --agent implementer --reasoning high Implement the parser change84 /subagents new --agent implementer --reasoning high Implement the parser change
85 ```85 ```
86 86
87-Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zot creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting.87+Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zot creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. A profile's `systemPromptMode` controls its own body relative to the built-in identity; global append addenda, including enabled Ponytail coding guidance, remain present. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting.
88 88
89 ## Lifecycle, results, and recovery89 ## Lifecycle, results, and recovery
90 90
M docs/zotfiles.md +4 -4
@@ -103,11 +103,11 @@ Symlinks are not supported and cause `zot pack` to fail.
103103
104104 ## `AGENT.md`
105105
106-`AGENT.md` is optional. When present, it is appended to zot's system prompt by default. It should describe the agent's role, workflow, constraints, output format, and when to load bundled skills. When absent, the system prompt is left unchanged.
106+`AGENT.md` is optional. When present, it is appended to zot's system prompt by default. It should describe the agent's role, workflow, constraints, output format, and when to load bundled skills. When absent, it contributes no agent-specific text; other enabled global addenda still follow their normal rules.
107107
108108 Keep capability and security declarations out of this file. Permissions come only from `manifest.json` and are enforced independently of agent-authored prose.
109109
110-Set `replace_system_prompt` to `true` in the manifest to use `AGENT.md` as the replacement system prompt:
110+Set `replace_system_prompt` to `true` in the manifest to use `AGENT.md` as the replacement base identity:
111111
112112 ```json
113113 {
@@ -115,7 +115,7 @@ Set `replace_system_prompt` to `true` in the manifest to use `AGENT.md` as the r
115115 }
116116 ```
117117
118-Replacement is intended for fully specialized agents. The default layering behavior is usually preferable because it retains zot's normal identity and tool-use guidance.
118+Replacement is intended for fully specialized agents. It replaces zot's built-in identity, while global append addenda such as enabled Ponytail coding guidance, project context, and skills retain their normal inclusion rules. The default layering behavior is usually preferable because it retains zot's normal identity and tool-use guidance.
119119
120120 ## Bundled skills
121121
@@ -205,7 +205,7 @@ The current manifest shape is:
205205 | `permissions` | no | Filesystem and bash permission ceiling. Omitted scopes deny access. |
206206 | `requirements` | no | Required operating systems and executables. |
207207 | `entry` | no | Initial presentation and prompt metadata. |
208-| `replace_system_prompt` | no | Replaces rather than appends to the normal system prompt when `true`. Defaults to `false`. |
208+| `replace_system_prompt` | no | Replaces the built-in identity rather than appending the agent body when `true`; global append addenda retain their normal inclusion rules. Defaults to `false`. |
209209
210210 ### Name rules
211211
@@ -103,11 +103,11 @@ Symlinks are not supported and cause `zot pack` to fail.
103 103
104 ## `AGENT.md`104 ## `AGENT.md`
105 105
106-`AGENT.md` is optional. When present, it is appended to zot's system prompt by default. It should describe the agent's role, workflow, constraints, output format, and when to load bundled skills. When absent, the system prompt is left unchanged.106+`AGENT.md` is optional. When present, it is appended to zot's system prompt by default. It should describe the agent's role, workflow, constraints, output format, and when to load bundled skills. When absent, it contributes no agent-specific text; other enabled global addenda still follow their normal rules.
107 107
108 Keep capability and security declarations out of this file. Permissions come only from `manifest.json` and are enforced independently of agent-authored prose.108 Keep capability and security declarations out of this file. Permissions come only from `manifest.json` and are enforced independently of agent-authored prose.
109 109
110-Set `replace_system_prompt` to `true` in the manifest to use `AGENT.md` as the replacement system prompt:110+Set `replace_system_prompt` to `true` in the manifest to use `AGENT.md` as the replacement base identity:
111 111
112 ```json112 ```json
113 {113 {
@@ -115,7 +115,7 @@ Set `replace_system_prompt` to `true` in the manifest to use `AGENT.md` as the r
115 }115 }
116 ```116 ```
117 117
118-Replacement is intended for fully specialized agents. The default layering behavior is usually preferable because it retains zot's normal identity and tool-use guidance.118+Replacement is intended for fully specialized agents. It replaces zot's built-in identity, while global append addenda such as enabled Ponytail coding guidance, project context, and skills retain their normal inclusion rules. The default layering behavior is usually preferable because it retains zot's normal identity and tool-use guidance.
119 119
120 ## Bundled skills120 ## Bundled skills
121 121
@@ -205,7 +205,7 @@ The current manifest shape is:
205 | `permissions` | no | Filesystem and bash permission ceiling. Omitted scopes deny access. |205 | `permissions` | no | Filesystem and bash permission ceiling. Omitted scopes deny access. |
206 | `requirements` | no | Required operating systems and executables. |206 | `requirements` | no | Required operating systems and executables. |
207 | `entry` | no | Initial presentation and prompt metadata. |207 | `entry` | no | Initial presentation and prompt metadata. |
208-| `replace_system_prompt` | no | Replaces rather than appends to the normal system prompt when `true`. Defaults to `false`. |208+| `replace_system_prompt` | no | Replaces the built-in identity rather than appending the agent body when `true`; global append addenda retain their normal inclusion rules. Defaults to `false`. |
209 209
210 ### Name rules210 ### Name rules
211 211
M packages/agent/build.go +7 -1
@@ -726,7 +726,13 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
726726 if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext {
727727 contextFiles = loadAgentsContext(args.CWD, ZotHome())
728728 }
729- append_ := append([]string(nil), args.AppendSystemPrompt...)
729+ append_ := []string(nil)
730+ if cfg.PonytailModeEnabled() {
731+ if ponytailAddendum := PonytailSystemAddendum(); ponytailAddendum != "" {
732+ append_ = append(append_, ponytailAddendum)
733+ }
734+ }
735+ append_ = append(append_, args.AppendSystemPrompt...)
730736 if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
731737 append_ = append(append_, agentsAddendum)
732738 }
@@ -726,7 +726,13 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
726 if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext {726 if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext {
727 contextFiles = loadAgentsContext(args.CWD, ZotHome())727 contextFiles = loadAgentsContext(args.CWD, ZotHome())
728 }728 }
729- append_ := append([]string(nil), args.AppendSystemPrompt...)729+ append_ := []string(nil)
730+ if cfg.PonytailModeEnabled() {
731+ if ponytailAddendum := PonytailSystemAddendum(); ponytailAddendum != "" {
732+ append_ = append(append_, ponytailAddendum)
733+ }
734+ }
735+ append_ = append(append_, args.AppendSystemPrompt...)
730 if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {736 if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
731 append_ = append(append_, agentsAddendum)737 append_ = append(append_, agentsAddendum)
732 }738 }
M packages/agent/build_test.go +19 -0
@@ -62,6 +62,22 @@ func TestMergeExtensionToolsRespectsDisabledSkillDiscovery(t *testing.T) {
6262 }
6363 }
6464
65+func TestMergeExtensionToolsRetainsPonytailPromptAddendum(t *testing.T) {
66+ r := Resolved{
67+ CWD: t.TempDir(),
68+ ToolRegistry: make(core.Registry),
69+ skillsEnabled: true,
70+ systemAppend: []string{PonytailSystemAddendum()},
71+ }
72+ bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
73+
74+ r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
75+
76+ if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) {
77+ t.Fatalf("extension prompt rebuild lost Ponytail addendum:\n%s", r.SystemPrompt)
78+ }
79+}
80+
6581 func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
6682 root := t.TempDir()
6783 registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false)
@@ -247,6 +263,9 @@ You are a read-only reviewer.
247263 if !strings.Contains(r.SystemPrompt, "You are a read-only reviewer.") || strings.Contains(r.SystemPrompt, "global context") {
248264 t.Fatalf("profile system prompt inheritance is wrong:\n%s", r.SystemPrompt)
249265 }
266+ if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) {
267+ t.Fatalf("replace-mode profile lost global Ponytail addendum:\n%s", r.SystemPrompt)
268+ }
250269 }
251270
252271 func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) {
@@ -62,6 +62,22 @@ func TestMergeExtensionToolsRespectsDisabledSkillDiscovery(t *testing.T) {
62 }62 }
63 }63 }
64 64
65+func TestMergeExtensionToolsRetainsPonytailPromptAddendum(t *testing.T) {
66+ r := Resolved{
67+ CWD: t.TempDir(),
68+ ToolRegistry: make(core.Registry),
69+ skillsEnabled: true,
70+ systemAppend: []string{PonytailSystemAddendum()},
71+ }
72+ bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
73+
74+ r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
75+
76+ if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) {
77+ t.Fatalf("extension prompt rebuild lost Ponytail addendum:\n%s", r.SystemPrompt)
78+ }
79+}
80+
65 func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {81 func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
66 root := t.TempDir()82 root := t.TempDir()
67 registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false)83 registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false)
@@ -247,6 +263,9 @@ You are a read-only reviewer.
247 if !strings.Contains(r.SystemPrompt, "You are a read-only reviewer.") || strings.Contains(r.SystemPrompt, "global context") {263 if !strings.Contains(r.SystemPrompt, "You are a read-only reviewer.") || strings.Contains(r.SystemPrompt, "global context") {
248 t.Fatalf("profile system prompt inheritance is wrong:\n%s", r.SystemPrompt)264 t.Fatalf("profile system prompt inheritance is wrong:\n%s", r.SystemPrompt)
249 }265 }
266+ if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) {
267+ t.Fatalf("replace-mode profile lost global Ponytail addendum:\n%s", r.SystemPrompt)
268+ }
250 }269 }
251 270
252 func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) {271 func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) {
M packages/agent/cli.go +48 -18
@@ -322,7 +322,7 @@ func newPersistModelCallback(persistMu *sync.Mutex, sess **core.Session, activeP
322322
323323 func closeAgentLSP(ag *core.Agent) {
324324 if ag != nil {
325- _ = tools.CloseLSPManagers(ag.Tools)
325+ _ = tools.CloseLSPManagers(ag.ToolsSnapshot())
326326 }
327327 }
328328
@@ -903,7 +903,9 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
903903 if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil {
904904 return err
905905 }
906- reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
906+ if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
907+ return err
908+ }
907909 started := time.Now()
908910 usage, err := modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)
909911 elapsed := time.Since(started)
@@ -969,7 +971,9 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
969971 return err
970972 }
971973 finishPre()
972- reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
974+ if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
975+ return err
976+ }
973977 err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)
974978 WriteNewTranscript(ag, sess, start)
975979 return err
@@ -1073,7 +1077,10 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
10731077 _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
10741078 return err
10751079 }
1076- reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
1080+ if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1081+ _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
1082+ return err
1083+ }
10771084 err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
10781085 WriteNewTranscript(ag, sess, start)
10791086 return err
@@ -1137,18 +1144,21 @@ func runZotfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.S
11371144 return ag.Prompt(ctx, pre, nil, sink)
11381145 }
11391146
1147+var errInteractiveAgentChanged = errors.New("interactive agent changed during prompt refresh")
1148+
11401149 // refreshAgentToolsAndPrompt re-resolves tools (including rediscovered
11411150 // skills and currently loaded extension tools) and updates the live
11421151 // agent's registry and system prompt. Used after /reload-ext and after
11431152 // zotfile entry.pre installs new skills or extensions.
11441153 // mutateRegistry, if non-nil, can inject session-specific tools (e.g. subagent_spawn).
1145-func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) {
1154+// interactive, when non-nil, serializes the final commit with agent replacement.
1155+func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry, interactive *modes.Interactive) error {
11461156 if ag == nil {
1147- return
1157+ return nil
11481158 }
11491159 resolved, err := Resolve(args, true)
11501160 if err != nil {
1151- return
1161+ return fmt.Errorf("refresh agent prompt and tools: %w", err)
11521162 }
11531163 if sharedSandbox != nil {
11541164 resolved.UseSandbox(sharedSandbox)
@@ -1160,24 +1170,34 @@ func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extTool
11601170 if mutateRegistry != nil {
11611171 reg = mutateRegistry(reg)
11621172 }
1163- oldTools := ag.Tools
1164- ag.SetTools(reg)
1165- ag.System = resolved.SystemPrompt
1173+ if interactive != nil {
1174+ oldTools, applied := interactive.ApplyAgentPromptConfig(ag, resolved.SystemPrompt, reg)
1175+ if !applied {
1176+ _ = tools.CloseLSPManagers(reg)
1177+ return errInteractiveAgentChanged
1178+ }
1179+ interactive.DeferUntilIdle(func() {
1180+ _ = tools.CloseLSPManagers(oldTools)
1181+ })
1182+ return nil
1183+ }
1184+ oldTools := ag.SetPromptConfig(resolved.SystemPrompt, reg)
11661185 _ = tools.CloseLSPManagers(oldTools)
1186+ return nil
11671187 }
11681188
11691189 // reloadResourcesAfterStartupPre reloads extensions (if any) and
11701190 // refreshes the agent tool registry + system prompt so skills/extensions
11711191 // installed by entry.pre are visible to the following turn.
1172-func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) {
1192+func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) error {
11731193 if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
1174- return
1194+ return nil
11751195 }
11761196 adapter := &extToolAdapter{mgr: extMgr}
11771197 if extMgr != nil {
11781198 _ = extMgr.Reload(ctx, 2*time.Second)
11791199 }
1180- refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil)
1200+ return refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil, nil)
11811201 }
11821202
11831203 // ---- interactive mode: opens the TUI even without credentials ----
@@ -1416,7 +1436,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14161436 }
14171437 if confirmGate != nil {
14181438 var content strings.Builder
1419- if tool, err := a.Tools.Get(call.Name); err == nil {
1439+ _, currentTools := a.PromptConfig()
1440+ if tool, err := currentTools.Get(call.Name); err == nil {
14201441 if previewer, ok := tool.(core.ToolPreviewer); ok {
14211442 preview, err := previewer.Preview(ctx, effectiveArgs)
14221443 if err != nil {
@@ -1544,7 +1565,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
15441565 if current == nil {
15451566 return
15461567 }
1547- refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn)
1568+ if err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv); err != nil && iv != nil {
1569+ iv.ReportError(err)
1570+ }
15481571 })
15491572
15501573 var sess *core.Session
@@ -2059,6 +2082,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
20592082 TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
20602083 TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
20612084 AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,
2085+ PonytailEnabled: initialCfg.PonytailEnabled,
20622086 AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,
20632087 FastMode: &fastMode,
20642088 LSPEnabled: initialCfg.LSPEnabled,
@@ -2117,12 +2141,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
21172141 _ = extMgr.Reload(context.Background(), 2*time.Second)
21182142 }
21192143 if current != nil {
2120- refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn)
2144+ if err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv); err != nil {
2145+ iv.ReportError(err)
2146+ }
21212147 }
21222148 },
2123- RefreshTools: func() {
2149+ RefreshTools: func() error {
2150+ current := liveInteractiveAgent(iv, ag)
2151+ return refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv)
2152+ },
2153+ RefreshPrompt: func() error {
21242154 current := liveInteractiveAgent(iv, ag)
2125- refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn)
2155+ return refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv)
21262156 },
21272157 AuthManager: mgr,
21282158 LlamaCPPConfig: ResolveLlamaCPPConfig,
@@ -322,7 +322,7 @@ func newPersistModelCallback(persistMu *sync.Mutex, sess **core.Session, activeP
322 322
323 func closeAgentLSP(ag *core.Agent) {323 func closeAgentLSP(ag *core.Agent) {
324 if ag != nil {324 if ag != nil {
325- _ = tools.CloseLSPManagers(ag.Tools)325+ _ = tools.CloseLSPManagers(ag.ToolsSnapshot())
326 }326 }
327 }327 }
328 328
@@ -903,7 +903,9 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
903 if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil {903 if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil {
904 return err904 return err
905 }905 }
906- reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)906+ if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
907+ return err
908+ }
907 started := time.Now()909 started := time.Now()
908 usage, err := modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)910 usage, err := modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)
909 elapsed := time.Since(started)911 elapsed := time.Since(started)
@@ -969,7 +971,9 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
969 return err971 return err
970 }972 }
971 finishPre()973 finishPre()
972- reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)974+ if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
975+ return err
976+ }
973 err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)977 err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)
974 WriteNewTranscript(ag, sess, start)978 WriteNewTranscript(ag, sess, start)
975 return err979 return err
@@ -1073,7 +1077,10 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
1073 _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})1077 _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
1074 return err1078 return err
1075 }1079 }
1076- reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)1080+ if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1081+ _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
1082+ return err
1083+ }
1077 err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)1084 err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
1078 WriteNewTranscript(ag, sess, start)1085 WriteNewTranscript(ag, sess, start)
1079 return err1086 return err
@@ -1137,18 +1144,21 @@ func runZotfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.S
1137 return ag.Prompt(ctx, pre, nil, sink)1144 return ag.Prompt(ctx, pre, nil, sink)
1138 }1145 }
1139 1146
1147+var errInteractiveAgentChanged = errors.New("interactive agent changed during prompt refresh")
1148+
1140 // refreshAgentToolsAndPrompt re-resolves tools (including rediscovered1149 // refreshAgentToolsAndPrompt re-resolves tools (including rediscovered
1141 // skills and currently loaded extension tools) and updates the live1150 // skills and currently loaded extension tools) and updates the live
1142 // agent's registry and system prompt. Used after /reload-ext and after1151 // agent's registry and system prompt. Used after /reload-ext and after
1143 // zotfile entry.pre installs new skills or extensions.1152 // zotfile entry.pre installs new skills or extensions.
1144 // mutateRegistry, if non-nil, can inject session-specific tools (e.g. subagent_spawn).1153 // mutateRegistry, if non-nil, can inject session-specific tools (e.g. subagent_spawn).
1145-func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) {1154+// interactive, when non-nil, serializes the final commit with agent replacement.
1155+func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry, interactive *modes.Interactive) error {
1146 if ag == nil {1156 if ag == nil {
1147- return1157+ return nil
1148 }1158 }
1149 resolved, err := Resolve(args, true)1159 resolved, err := Resolve(args, true)
1150 if err != nil {1160 if err != nil {
1151- return1161+ return fmt.Errorf("refresh agent prompt and tools: %w", err)
1152 }1162 }
1153 if sharedSandbox != nil {1163 if sharedSandbox != nil {
1154 resolved.UseSandbox(sharedSandbox)1164 resolved.UseSandbox(sharedSandbox)
@@ -1160,24 +1170,34 @@ func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extTool
1160 if mutateRegistry != nil {1170 if mutateRegistry != nil {
1161 reg = mutateRegistry(reg)1171 reg = mutateRegistry(reg)
1162 }1172 }
1163- oldTools := ag.Tools1173+ if interactive != nil {
1164- ag.SetTools(reg)1174+ oldTools, applied := interactive.ApplyAgentPromptConfig(ag, resolved.SystemPrompt, reg)
1165- ag.System = resolved.SystemPrompt1175+ if !applied {
1176+ _ = tools.CloseLSPManagers(reg)
1177+ return errInteractiveAgentChanged
1178+ }
1179+ interactive.DeferUntilIdle(func() {
1180+ _ = tools.CloseLSPManagers(oldTools)
1181+ })
1182+ return nil
1183+ }
1184+ oldTools := ag.SetPromptConfig(resolved.SystemPrompt, reg)
1166 _ = tools.CloseLSPManagers(oldTools)1185 _ = tools.CloseLSPManagers(oldTools)
1186+ return nil
1167 }1187 }
1168 1188
1169 // reloadResourcesAfterStartupPre reloads extensions (if any) and1189 // reloadResourcesAfterStartupPre reloads extensions (if any) and
1170 // refreshes the agent tool registry + system prompt so skills/extensions1190 // refreshes the agent tool registry + system prompt so skills/extensions
1171 // installed by entry.pre are visible to the following turn.1191 // installed by entry.pre are visible to the following turn.
1172-func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) {1192+func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) error {
1173 if strings.TrimSpace(args.StartupPre) == "" || ag == nil {1193 if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
1174- return1194+ return nil
1175 }1195 }
1176 adapter := &extToolAdapter{mgr: extMgr}1196 adapter := &extToolAdapter{mgr: extMgr}
1177 if extMgr != nil {1197 if extMgr != nil {
1178 _ = extMgr.Reload(ctx, 2*time.Second)1198 _ = extMgr.Reload(ctx, 2*time.Second)
1179 }1199 }
1180- refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil)1200+ return refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil, nil)
1181 }1201 }
1182 1202
1183 // ---- interactive mode: opens the TUI even without credentials ----1203 // ---- interactive mode: opens the TUI even without credentials ----
@@ -1416,7 +1436,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1416 }1436 }
1417 if confirmGate != nil {1437 if confirmGate != nil {
1418 var content strings.Builder1438 var content strings.Builder
1419- if tool, err := a.Tools.Get(call.Name); err == nil {1439+ _, currentTools := a.PromptConfig()
1440+ if tool, err := currentTools.Get(call.Name); err == nil {
1420 if previewer, ok := tool.(core.ToolPreviewer); ok {1441 if previewer, ok := tool.(core.ToolPreviewer); ok {
1421 preview, err := previewer.Preview(ctx, effectiveArgs)1442 preview, err := previewer.Preview(ctx, effectiveArgs)
1422 if err != nil {1443 if err != nil {
@@ -1544,7 +1565,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1544 if current == nil {1565 if current == nil {
1545 return1566 return
1546 }1567 }
1547- refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn)1568+ if err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv); err != nil && iv != nil {
1569+ iv.ReportError(err)
1570+ }
1548 })1571 })
1549 1572
1550 var sess *core.Session1573 var sess *core.Session
@@ -2059,6 +2082,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2059 TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,2082 TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
2060 TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,2083 TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
2061 AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,2084 AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,
2085+ PonytailEnabled: initialCfg.PonytailEnabled,
2062 AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,2086 AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,
2063 FastMode: &fastMode,2087 FastMode: &fastMode,
2064 LSPEnabled: initialCfg.LSPEnabled,2088 LSPEnabled: initialCfg.LSPEnabled,
@@ -2117,12 +2141,18 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2117 _ = extMgr.Reload(context.Background(), 2*time.Second)2141 _ = extMgr.Reload(context.Background(), 2*time.Second)
2118 }2142 }
2119 if current != nil {2143 if current != nil {
2120- refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn)2144+ if err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv); err != nil {
2145+ iv.ReportError(err)
2146+ }
2121 }2147 }
2122 },2148 },
2123- RefreshTools: func() {2149+ RefreshTools: func() error {
2150+ current := liveInteractiveAgent(iv, ag)
2151+ return refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv)
2152+ },
2153+ RefreshPrompt: func() error {
2124 current := liveInteractiveAgent(iv, ag)2154 current := liveInteractiveAgent(iv, ag)
2125- refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn)2155+ return refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn, iv)
2126 },2156 },
2127 AuthManager: mgr,2157 AuthManager: mgr,
2128 LlamaCPPConfig: ResolveLlamaCPPConfig,2158 LlamaCPPConfig: ResolveLlamaCPPConfig,
M packages/agent/config.go +10 -0
@@ -95,6 +95,10 @@ type Config struct {
9595 // default; nil/missing means disabled. Toggle from /settings.
9696 AutoSubagentsEnabled *bool `json:"auto_subagents_enabled,omitempty"`
9797
98+ // PonytailEnabled controls the compact coding-guidance addendum. On by
99+ // default; nil/missing means enabled. Toggle from /settings.
100+ PonytailEnabled *bool `json:"ponytail_enabled,omitempty"`
101+
98102 // LSPEnabled controls the built-in lsp tool and write-time diagnostics
99103 // for the main session. nil/missing means enabled. Toggle from
100104 // /settings.
@@ -246,6 +250,12 @@ func (c Config) LSPEnabledFor(subagent bool) bool {
246250 return value == nil || *value
247251 }
248252
253+// PonytailModeEnabled reports the default-on coding-guidance preference
254+// without requiring a generated config entry.
255+func (c Config) PonytailModeEnabled() bool {
256+ return c.PonytailEnabled == nil || *c.PonytailEnabled
257+}
258+
249259 // LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics
250260 // preference without requiring a generated config entry.
251261 func (c Config) LSPDiagnosticsOnWriteEnabled(subagent bool) bool {
@@ -95,6 +95,10 @@ type Config struct {
95 // default; nil/missing means disabled. Toggle from /settings.95 // default; nil/missing means disabled. Toggle from /settings.
96 AutoSubagentsEnabled *bool `json:"auto_subagents_enabled,omitempty"`96 AutoSubagentsEnabled *bool `json:"auto_subagents_enabled,omitempty"`
97 97
98+ // PonytailEnabled controls the compact coding-guidance addendum. On by
99+ // default; nil/missing means enabled. Toggle from /settings.
100+ PonytailEnabled *bool `json:"ponytail_enabled,omitempty"`
101+
98 // LSPEnabled controls the built-in lsp tool and write-time diagnostics102 // LSPEnabled controls the built-in lsp tool and write-time diagnostics
99 // for the main session. nil/missing means enabled. Toggle from103 // for the main session. nil/missing means enabled. Toggle from
100 // /settings.104 // /settings.
@@ -246,6 +250,12 @@ func (c Config) LSPEnabledFor(subagent bool) bool {
246 return value == nil || *value250 return value == nil || *value
247 }251 }
248 252
253+// PonytailModeEnabled reports the default-on coding-guidance preference
254+// without requiring a generated config entry.
255+func (c Config) PonytailModeEnabled() bool {
256+ return c.PonytailEnabled == nil || *c.PonytailEnabled
257+}
258+
249 // LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics259 // LSPDiagnosticsOnWriteEnabled reports the default-on write diagnostics
250 // preference without requiring a generated config entry.260 // preference without requiring a generated config entry.
251 func (c Config) LSPDiagnosticsOnWriteEnabled(subagent bool) bool {261 func (c Config) LSPDiagnosticsOnWriteEnabled(subagent bool) bool {
M packages/agent/modes/btw_dialog.go +1 -1
@@ -272,7 +272,7 @@ func (d *btwDialog) submit(invalidate func()) {
272272 }
273273
274274 func newBtwAgent(main *core.Agent, system, model string) *core.Agent {
275- agent := core.NewAgent(main.Client, model, system, main.Tools)
275+ agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot())
276276 agent.MaxSteps = main.MaxSteps
277277 agent.Reasoning = main.Reasoning
278278 agent.Temperature = main.Temperature
@@ -272,7 +272,7 @@ func (d *btwDialog) submit(invalidate func()) {
272 }272 }
273 273
274 func newBtwAgent(main *core.Agent, system, model string) *core.Agent {274 func newBtwAgent(main *core.Agent, system, model string) *core.Agent {
275- agent := core.NewAgent(main.Client, model, system, main.Tools)275+ agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot())
276 agent.MaxSteps = main.MaxSteps276 agent.MaxSteps = main.MaxSteps
277 agent.Reasoning = main.Reasoning277 agent.Reasoning = main.Reasoning
278 agent.Temperature = main.Temperature278 agent.Temperature = main.Temperature
A packages/agent/modes/idle_work_test.go +31 -0
@@ -0,0 +1,31 @@
1+package modes
2+
3+import "testing"
4+
5+func TestDeferUntilIdleRunsImmediatelyWhenIdle(t *testing.T) {
6+ interactive := &Interactive{}
7+ ran := false
8+ interactive.DeferUntilIdle(func() { ran = true })
9+ if !ran {
10+ t.Fatal("idle callback did not run immediately")
11+ }
12+}
13+
14+func TestDeferUntilIdleQueuesWhileBusy(t *testing.T) {
15+ interactive := &Interactive{}
16+ interactive.busy = true
17+ ran := false
18+ interactive.DeferUntilIdle(func() { ran = true })
19+ if ran {
20+ t.Fatal("busy callback ran before the operation became idle")
21+ }
22+
23+ interactive.mu.Lock()
24+ interactive.busy = false
25+ work := interactive.takePendingIdleWorkLocked()
26+ interactive.mu.Unlock()
27+ runPendingIdleWork(work)
28+ if !ran {
29+ t.Fatal("queued idle callback did not run after the operation became idle")
30+ }
31+}
@@ -0,0 +1,31 @@
1+package modes
2+
3+import "testing"
4+
5+func TestDeferUntilIdleRunsImmediatelyWhenIdle(t *testing.T) {
6+ interactive := &Interactive{}
7+ ran := false
8+ interactive.DeferUntilIdle(func() { ran = true })
9+ if !ran {
10+ t.Fatal("idle callback did not run immediately")
11+ }
12+}
13+
14+func TestDeferUntilIdleQueuesWhileBusy(t *testing.T) {
15+ interactive := &Interactive{}
16+ interactive.busy = true
17+ ran := false
18+ interactive.DeferUntilIdle(func() { ran = true })
19+ if ran {
20+ t.Fatal("busy callback ran before the operation became idle")
21+ }
22+
23+ interactive.mu.Lock()
24+ interactive.busy = false
25+ work := interactive.takePendingIdleWorkLocked()
26+ interactive.mu.Unlock()
27+ runPendingIdleWork(work)
28+ if !ran {
29+ t.Fatal("queued idle callback did not run after the operation became idle")
30+ }
31+}
M packages/agent/modes/interactive.go +190 -3
@@ -72,6 +72,10 @@ type InteractiveConfig struct {
7272 // re-reading config.json on every open.
7373 AutoSubagentsEnabled *bool
7474
75+ // PonytailEnabled mirrors the persisted default-on coding-guidance
76+ // setting at startup. nil/missing means enabled.
77+ PonytailEnabled *bool
78+
7579 // AutoSubagentsToolAllowed is nil for normal sessions and false when the
7680 // launch-time --no-tools/--tools policy excludes delegation.
7781 AutoSubagentsToolAllowed *bool
@@ -173,7 +177,11 @@ type InteractiveConfig struct {
173177
174178 // RefreshTools re-resolves the live agent registry after a setting
175179 // changes the main session's tool availability. Optional for embedders.
176- RefreshTools func()
180+ RefreshTools func() error
181+
182+ // RefreshPrompt re-resolves the complete live prompt and tool registry
183+ // after a prompt-affecting setting changes. Optional for embedders.
184+ RefreshPrompt func() error
177185
178186 // AutoSubmitInitial, when true, auto-submits InitialInput after
179187 // StartupPre completes (or immediately when StartupPre is empty).
@@ -450,6 +458,10 @@ type fastModeSettingsStore interface {
450458 SetFastMode(enabled bool) error
451459 }
452460
461+type ponytailSettingsStore interface {
462+ SetPonytailEnabled(enabled bool) error
463+}
464+
453465 type Interactive struct {
454466 cfg InteractiveConfig
455467 view *tui.View
@@ -507,6 +519,7 @@ type Interactive struct {
507519 cumUsage provider.Usage
508520 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
509521 busy bool
522+ pendingIdleWork []func()
510523 dirty chan struct{}
511524 modelRefresh chan modelRefreshResult
512525 modelRefreshing bool
@@ -3648,6 +3661,18 @@ func (i *Interactive) Display(extName, text string) {
36483661 i.invalidate()
36493662 }
36503663
3664+// ReportError surfaces a host-side error in the interactive status area.
3665+func (i *Interactive) ReportError(err error) {
3666+ if err == nil {
3667+ return
3668+ }
3669+ i.mu.Lock()
3670+ i.statusOK = ""
3671+ i.statusErr = err.Error()
3672+ i.mu.Unlock()
3673+ i.invalidate()
3674+}
3675+
36513676 // SetStatus replaces one persistent status item owned by an extension.
36523677 func (i *Interactive) SetStatus(extName, key, level, text string) {
36533678 if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {
@@ -3864,6 +3889,13 @@ func (i *Interactive) openSettingsDialog() {
38643889 autoSubagents = false
38653890 }
38663891
3892+ ponytailEnabled := i.ponytailEnabled()
3893+ ponytailDisabled := !i.ponytailSettingsAvailable()
3894+ ponytailHint := ""
3895+ if ponytailDisabled {
3896+ ponytailHint = "requires persistent settings and live prompt refresh support"
3897+ }
3898+
38673899 fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
38683900 fastModeHint := "OpenAI service tier"
38693901 if !provider.SupportsFastMode(i.cfg.Provider) {
@@ -3990,6 +4022,14 @@ func (i *Interactive) openSettingsDialog() {
39904022 disabled: autoSubagentsDisabled,
39914023 hint: autoSubagentsHint,
39924024 },
4025+ {
4026+ key: "ponytail_enabled",
4027+ label: "ponytail coding mode",
4028+ desc: "apply compact coding guidance that favors small, validated, maintainable changes",
4029+ value: ponytailEnabled,
4030+ disabled: ponytailDisabled,
4031+ hint: ponytailHint,
4032+ },
39934033 {
39944034 key: "fast_mode",
39954035 label: "fast mode",
@@ -4319,6 +4359,15 @@ func (i *Interactive) resetSettingsToggle(key string, value bool) {
43194359 }
43204360 }
43214361
4362+func (i *Interactive) ponytailEnabled() bool {
4363+ return i.cfg.PonytailEnabled == nil || *i.cfg.PonytailEnabled
4364+}
4365+
4366+func (i *Interactive) ponytailSettingsAvailable() bool {
4367+ _, persistent := i.cfg.SettingsStore.(ponytailSettingsStore)
4368+ return persistent && i.cfg.RefreshPrompt != nil
4369+}
4370+
43224371 func (i *Interactive) applySettingToggle(key string, value bool) {
43234372 // Every setting toggle forces a full repaint at the end — same
43244373 // effect as the user pressing Ctrl+L — so any per-setting visual
@@ -4394,6 +4443,56 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
43944443 i.statusOK = "AI terminal titles " + onOff(value)
43954444 i.statusErr = ""
43964445 i.mu.Unlock()
4446+ case "ponytail_enabled":
4447+ previous := i.ponytailEnabled()
4448+ store, available := i.cfg.SettingsStore.(ponytailSettingsStore)
4449+ if !available || i.cfg.RefreshPrompt == nil {
4450+ i.resetSettingsToggle(key, previous)
4451+ i.mu.Lock()
4452+ i.statusOK = ""
4453+ i.statusErr = "ponytail coding mode unavailable: persistent settings and live prompt refresh are required"
4454+ i.mu.Unlock()
4455+ return
4456+ }
4457+ if err := store.SetPonytailEnabled(value); err != nil {
4458+ i.resetSettingsToggle(key, previous)
4459+ i.mu.Lock()
4460+ i.statusOK = ""
4461+ i.statusErr = "settings: " + err.Error()
4462+ i.mu.Unlock()
4463+ return
4464+ }
4465+ val := value
4466+ i.cfg.PonytailEnabled = &val
4467+ if err := i.cfg.RefreshPrompt(); err != nil {
4468+ errMsg := "settings: ponytail prompt refresh: " + err.Error()
4469+ if rollbackErr := store.SetPonytailEnabled(previous); rollbackErr != nil {
4470+ // The first write succeeded, so keep the in-memory setting at
4471+ // the value that remains durable instead of claiming that the
4472+ // rollback took effect.
4473+ i.resetSettingsToggle(key, value)
4474+ errMsg += "; rollback persistence: " + rollbackErr.Error()
4475+ if reconcileErr := i.cfg.RefreshPrompt(); reconcileErr != nil {
4476+ errMsg += "; durable-state refresh: " + reconcileErr.Error()
4477+ }
4478+ } else {
4479+ previousVal := previous
4480+ i.cfg.PonytailEnabled = &previousVal
4481+ i.resetSettingsToggle(key, previous)
4482+ if refreshErr := i.cfg.RefreshPrompt(); refreshErr != nil {
4483+ errMsg += "; rollback refresh: " + refreshErr.Error()
4484+ }
4485+ }
4486+ i.mu.Lock()
4487+ i.statusOK = ""
4488+ i.statusErr = errMsg
4489+ i.mu.Unlock()
4490+ return
4491+ }
4492+ i.mu.Lock()
4493+ i.statusOK = "ponytail coding mode " + onOff(value)
4494+ i.statusErr = ""
4495+ i.mu.Unlock()
43974496 case "auto_subagents_enabled":
43984497 if value && !i.autoSubagentsAvailable() {
43994498 i.mu.Lock()
@@ -4458,7 +4557,9 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
44584557 i.statusErr = ""
44594558 i.mu.Unlock()
44604559 case "lsp_enabled":
4461- if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
4560+ previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
4561+ store, hasStore := i.cfg.SettingsStore.(lspSettingsStore)
4562+ if hasStore {
44624563 if err := store.SetLSPEnabled(value); err != nil {
44634564 i.mu.Lock()
44644565 i.statusErr = "settings: " + err.Error()
@@ -4469,7 +4570,33 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
44694570 val := value
44704571 i.cfg.LSPEnabled = &val
44714572 if i.cfg.RefreshTools != nil {
4472- i.cfg.RefreshTools()
4573+ if err := i.cfg.RefreshTools(); err != nil {
4574+ errMsg := "settings: refresh tools: " + err.Error()
4575+ rollbackErr := error(nil)
4576+ if hasStore {
4577+ rollbackErr = store.SetLSPEnabled(previous)
4578+ }
4579+ if rollbackErr != nil {
4580+ // The first write succeeded, so keep the in-memory setting at
4581+ // the value that remains durable instead of claiming that the
4582+ // rollback took effect.
4583+ errMsg += "; rollback persistence: " + rollbackErr.Error()
4584+ if reconcileErr := i.cfg.RefreshTools(); reconcileErr != nil {
4585+ errMsg += "; durable-state refresh: " + reconcileErr.Error()
4586+ }
4587+ } else {
4588+ previousVal := previous
4589+ i.cfg.LSPEnabled = &previousVal
4590+ if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
4591+ errMsg += "; rollback refresh: " + refreshErr.Error()
4592+ }
4593+ }
4594+ i.mu.Lock()
4595+ i.statusOK = ""
4596+ i.statusErr = errMsg
4597+ i.mu.Unlock()
4598+ return
4599+ }
44734600 }
44744601 i.mu.Lock()
44754602 i.statusOK = "lsp in main session " + onOff(value)
@@ -6053,7 +6180,9 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
60536180 if ag == nil {
60546181 i.mu.Lock()
60556182 i.busy = false
6183+ pendingIdleWork := i.takePendingIdleWorkLocked()
60566184 i.mu.Unlock()
6185+ runPendingIdleWork(pendingIdleWork)
60576186 i.invalidate()
60586187 return
60596188 }
@@ -6122,6 +6251,8 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
61226251 // inspecting the queues lets them race a new turn or be stranded.
61236252 i.resetStreamingStateLocked()
61246253 i.cancelTurn = nil
6254+ i.autoCompacting = false
6255+ pendingIdleWork := i.takePendingIdleWorkLocked()
61256256
61266257 // Drain pending work after the transcript is clean. Overflow recovery
61276258 // continues the user message already in the transcript. Pre-turn
@@ -6207,6 +6338,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
62076338 i.compacting = false
62086339 i.autoCompacting = false
62096340 i.mu.Unlock()
6341+ runPendingIdleWork(pendingIdleWork)
62106342 i.invalidate()
62116343
62126344 if hasNext || continueExisting || continueAutomatically {
@@ -6330,6 +6462,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
63306462 i.shellLive = ""
63316463 i.busy = false
63326464 i.cancelTurn = nil
6465+ pendingIdleWork := i.takePendingIdleWorkLocked()
63336466 awaitingPre := i.awaitingStartupPre
63346467 if failed {
63356468 if cancelled {
@@ -6343,6 +6476,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
63436476 i.statusErr = ""
63446477 }
63456478 i.mu.Unlock()
6479+ runPendingIdleWork(pendingIdleWork)
63466480 i.invalidate()
63476481 if awaitingPre {
63486482 i.completeStartupPre()
@@ -6483,6 +6617,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
64836617 i.streamOn = false
64846618 }
64856619 i.cancelTurn = nil
6620+ pendingIdleWork := i.takePendingIdleWorkLocked()
64866621 if err != nil && ctx.Err() == nil {
64876622 i.statusErr = err.Error()
64886623 }
@@ -6534,6 +6669,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
65346669 // baseline so subsequent flushes only write new rows).
65356670 flush := i.cfg.FlushSession
65366671 i.mu.Unlock()
6672+ runPendingIdleWork(pendingIdleWork)
65376673 if flush != nil {
65386674 flush()
65396675 }
@@ -6924,6 +7060,57 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
69247060 }
69257061 }
69267062
7063+// ApplyAgentPromptConfig commits a resolved prompt and tool registry only if
7064+// ag is still the live interactive agent. It serializes the identity check
7065+// with agent replacement and swaps both values atomically in core.Agent.
7066+func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) {
7067+ if ag == nil {
7068+ return nil, false
7069+ }
7070+ // Match the lock order used by dynamic tool mutations so a refresh
7071+ // cannot race a snapshot-and-replace operation that would reintroduce
7072+ // the old registry after the atomic prompt swap.
7073+ i.agentMu.Lock()
7074+ defer i.agentMu.Unlock()
7075+ i.mu.Lock()
7076+ defer i.mu.Unlock()
7077+ if i.agent != ag {
7078+ return nil, false
7079+ }
7080+ return ag.SetPromptConfig(system, tools), true
7081+}
7082+
7083+// DeferUntilIdle runs work immediately when the interactive agent is idle,
7084+// or after the current turn/compaction/shell operation releases the busy
7085+// state. Callbacks run outside i.mu.
7086+func (i *Interactive) DeferUntilIdle(fn func()) {
7087+ if fn == nil {
7088+ return
7089+ }
7090+ i.mu.Lock()
7091+ if i.busy {
7092+ i.pendingIdleWork = append(i.pendingIdleWork, fn)
7093+ i.mu.Unlock()
7094+ return
7095+ }
7096+ i.mu.Unlock()
7097+ fn()
7098+}
7099+
7100+func (i *Interactive) takePendingIdleWorkLocked() []func() {
7101+ work := i.pendingIdleWork
7102+ i.pendingIdleWork = nil
7103+ return work
7104+}
7105+
7106+func runPendingIdleWork(work []func()) {
7107+ for _, fn := range work {
7108+ if fn != nil {
7109+ fn()
7110+ }
7111+ }
7112+}
7113+
69277114 // Agent returns the current agent, if any. Used by cli.go to flush the
69287115 // final transcript to the session file.
69297116 func (i *Interactive) Agent() *core.Agent {
@@ -72,6 +72,10 @@ type InteractiveConfig struct {
72 // re-reading config.json on every open.72 // re-reading config.json on every open.
73 AutoSubagentsEnabled *bool73 AutoSubagentsEnabled *bool
74 74
75+ // PonytailEnabled mirrors the persisted default-on coding-guidance
76+ // setting at startup. nil/missing means enabled.
77+ PonytailEnabled *bool
78+
75 // AutoSubagentsToolAllowed is nil for normal sessions and false when the79 // AutoSubagentsToolAllowed is nil for normal sessions and false when the
76 // launch-time --no-tools/--tools policy excludes delegation.80 // launch-time --no-tools/--tools policy excludes delegation.
77 AutoSubagentsToolAllowed *bool81 AutoSubagentsToolAllowed *bool
@@ -173,7 +177,11 @@ type InteractiveConfig struct {
173 177
174 // RefreshTools re-resolves the live agent registry after a setting178 // RefreshTools re-resolves the live agent registry after a setting
175 // changes the main session's tool availability. Optional for embedders.179 // changes the main session's tool availability. Optional for embedders.
176- RefreshTools func()180+ RefreshTools func() error
181+
182+ // RefreshPrompt re-resolves the complete live prompt and tool registry
183+ // after a prompt-affecting setting changes. Optional for embedders.
184+ RefreshPrompt func() error
177 185
178 // AutoSubmitInitial, when true, auto-submits InitialInput after186 // AutoSubmitInitial, when true, auto-submits InitialInput after
179 // StartupPre completes (or immediately when StartupPre is empty).187 // StartupPre completes (or immediately when StartupPre is empty).
@@ -450,6 +458,10 @@ type fastModeSettingsStore interface {
450 SetFastMode(enabled bool) error458 SetFastMode(enabled bool) error
451 }459 }
452 460
461+type ponytailSettingsStore interface {
462+ SetPonytailEnabled(enabled bool) error
463+}
464+
453 type Interactive struct {465 type Interactive struct {
454 cfg InteractiveConfig466 cfg InteractiveConfig
455 view *tui.View467 view *tui.View
@@ -507,6 +519,7 @@ type Interactive struct {
507 cumUsage provider.Usage519 cumUsage provider.Usage
508 lastCtxInput int // input_tokens of the most recent turn — approximates current context size520 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
509 busy bool521 busy bool
522+ pendingIdleWork []func()
510 dirty chan struct{}523 dirty chan struct{}
511 modelRefresh chan modelRefreshResult524 modelRefresh chan modelRefreshResult
512 modelRefreshing bool525 modelRefreshing bool
@@ -3648,6 +3661,18 @@ func (i *Interactive) Display(extName, text string) {
3648 i.invalidate()3661 i.invalidate()
3649 }3662 }
3650 3663
3664+// ReportError surfaces a host-side error in the interactive status area.
3665+func (i *Interactive) ReportError(err error) {
3666+ if err == nil {
3667+ return
3668+ }
3669+ i.mu.Lock()
3670+ i.statusOK = ""
3671+ i.statusErr = err.Error()
3672+ i.mu.Unlock()
3673+ i.invalidate()
3674+}
3675+
3651 // SetStatus replaces one persistent status item owned by an extension.3676 // SetStatus replaces one persistent status item owned by an extension.
3652 func (i *Interactive) SetStatus(extName, key, level, text string) {3677 func (i *Interactive) SetStatus(extName, key, level, text string) {
3653 if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {3678 if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {
@@ -3864,6 +3889,13 @@ func (i *Interactive) openSettingsDialog() {
3864 autoSubagents = false3889 autoSubagents = false
3865 }3890 }
3866 3891
3892+ ponytailEnabled := i.ponytailEnabled()
3893+ ponytailDisabled := !i.ponytailSettingsAvailable()
3894+ ponytailHint := ""
3895+ if ponytailDisabled {
3896+ ponytailHint = "requires persistent settings and live prompt refresh support"
3897+ }
3898+
3867 fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode3899 fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
3868 fastModeHint := "OpenAI service tier"3900 fastModeHint := "OpenAI service tier"
3869 if !provider.SupportsFastMode(i.cfg.Provider) {3901 if !provider.SupportsFastMode(i.cfg.Provider) {
@@ -3990,6 +4022,14 @@ func (i *Interactive) openSettingsDialog() {
3990 disabled: autoSubagentsDisabled,4022 disabled: autoSubagentsDisabled,
3991 hint: autoSubagentsHint,4023 hint: autoSubagentsHint,
3992 },4024 },
4025+ {
4026+ key: "ponytail_enabled",
4027+ label: "ponytail coding mode",
4028+ desc: "apply compact coding guidance that favors small, validated, maintainable changes",
4029+ value: ponytailEnabled,
4030+ disabled: ponytailDisabled,
4031+ hint: ponytailHint,
4032+ },
3993 {4033 {
3994 key: "fast_mode",4034 key: "fast_mode",
3995 label: "fast mode",4035 label: "fast mode",
@@ -4319,6 +4359,15 @@ func (i *Interactive) resetSettingsToggle(key string, value bool) {
4319 }4359 }
4320 }4360 }
4321 4361
4362+func (i *Interactive) ponytailEnabled() bool {
4363+ return i.cfg.PonytailEnabled == nil || *i.cfg.PonytailEnabled
4364+}
4365+
4366+func (i *Interactive) ponytailSettingsAvailable() bool {
4367+ _, persistent := i.cfg.SettingsStore.(ponytailSettingsStore)
4368+ return persistent && i.cfg.RefreshPrompt != nil
4369+}
4370+
4322 func (i *Interactive) applySettingToggle(key string, value bool) {4371 func (i *Interactive) applySettingToggle(key string, value bool) {
4323 // Every setting toggle forces a full repaint at the end — same4372 // Every setting toggle forces a full repaint at the end — same
4324 // effect as the user pressing Ctrl+L — so any per-setting visual4373 // effect as the user pressing Ctrl+L — so any per-setting visual
@@ -4394,6 +4443,56 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
4394 i.statusOK = "AI terminal titles " + onOff(value)4443 i.statusOK = "AI terminal titles " + onOff(value)
4395 i.statusErr = ""4444 i.statusErr = ""
4396 i.mu.Unlock()4445 i.mu.Unlock()
4446+ case "ponytail_enabled":
4447+ previous := i.ponytailEnabled()
4448+ store, available := i.cfg.SettingsStore.(ponytailSettingsStore)
4449+ if !available || i.cfg.RefreshPrompt == nil {
4450+ i.resetSettingsToggle(key, previous)
4451+ i.mu.Lock()
4452+ i.statusOK = ""
4453+ i.statusErr = "ponytail coding mode unavailable: persistent settings and live prompt refresh are required"
4454+ i.mu.Unlock()
4455+ return
4456+ }
4457+ if err := store.SetPonytailEnabled(value); err != nil {
4458+ i.resetSettingsToggle(key, previous)
4459+ i.mu.Lock()
4460+ i.statusOK = ""
4461+ i.statusErr = "settings: " + err.Error()
4462+ i.mu.Unlock()
4463+ return
4464+ }
4465+ val := value
4466+ i.cfg.PonytailEnabled = &val
4467+ if err := i.cfg.RefreshPrompt(); err != nil {
4468+ errMsg := "settings: ponytail prompt refresh: " + err.Error()
4469+ if rollbackErr := store.SetPonytailEnabled(previous); rollbackErr != nil {
4470+ // The first write succeeded, so keep the in-memory setting at
4471+ // the value that remains durable instead of claiming that the
4472+ // rollback took effect.
4473+ i.resetSettingsToggle(key, value)
4474+ errMsg += "; rollback persistence: " + rollbackErr.Error()
4475+ if reconcileErr := i.cfg.RefreshPrompt(); reconcileErr != nil {
4476+ errMsg += "; durable-state refresh: " + reconcileErr.Error()
4477+ }
4478+ } else {
4479+ previousVal := previous
4480+ i.cfg.PonytailEnabled = &previousVal
4481+ i.resetSettingsToggle(key, previous)
4482+ if refreshErr := i.cfg.RefreshPrompt(); refreshErr != nil {
4483+ errMsg += "; rollback refresh: " + refreshErr.Error()
4484+ }
4485+ }
4486+ i.mu.Lock()
4487+ i.statusOK = ""
4488+ i.statusErr = errMsg
4489+ i.mu.Unlock()
4490+ return
4491+ }
4492+ i.mu.Lock()
4493+ i.statusOK = "ponytail coding mode " + onOff(value)
4494+ i.statusErr = ""
4495+ i.mu.Unlock()
4397 case "auto_subagents_enabled":4496 case "auto_subagents_enabled":
4398 if value && !i.autoSubagentsAvailable() {4497 if value && !i.autoSubagentsAvailable() {
4399 i.mu.Lock()4498 i.mu.Lock()
@@ -4458,7 +4557,9 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
4458 i.statusErr = ""4557 i.statusErr = ""
4459 i.mu.Unlock()4558 i.mu.Unlock()
4460 case "lsp_enabled":4559 case "lsp_enabled":
4461- if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {4560+ previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
4561+ store, hasStore := i.cfg.SettingsStore.(lspSettingsStore)
4562+ if hasStore {
4462 if err := store.SetLSPEnabled(value); err != nil {4563 if err := store.SetLSPEnabled(value); err != nil {
4463 i.mu.Lock()4564 i.mu.Lock()
4464 i.statusErr = "settings: " + err.Error()4565 i.statusErr = "settings: " + err.Error()
@@ -4469,7 +4570,33 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
4469 val := value4570 val := value
4470 i.cfg.LSPEnabled = &val4571 i.cfg.LSPEnabled = &val
4471 if i.cfg.RefreshTools != nil {4572 if i.cfg.RefreshTools != nil {
4472- i.cfg.RefreshTools()4573+ if err := i.cfg.RefreshTools(); err != nil {
4574+ errMsg := "settings: refresh tools: " + err.Error()
4575+ rollbackErr := error(nil)
4576+ if hasStore {
4577+ rollbackErr = store.SetLSPEnabled(previous)
4578+ }
4579+ if rollbackErr != nil {
4580+ // The first write succeeded, so keep the in-memory setting at
4581+ // the value that remains durable instead of claiming that the
4582+ // rollback took effect.
4583+ errMsg += "; rollback persistence: " + rollbackErr.Error()
4584+ if reconcileErr := i.cfg.RefreshTools(); reconcileErr != nil {
4585+ errMsg += "; durable-state refresh: " + reconcileErr.Error()
4586+ }
4587+ } else {
4588+ previousVal := previous
4589+ i.cfg.LSPEnabled = &previousVal
4590+ if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
4591+ errMsg += "; rollback refresh: " + refreshErr.Error()
4592+ }
4593+ }
4594+ i.mu.Lock()
4595+ i.statusOK = ""
4596+ i.statusErr = errMsg
4597+ i.mu.Unlock()
4598+ return
4599+ }
4473 }4600 }
4474 i.mu.Lock()4601 i.mu.Lock()
4475 i.statusOK = "lsp in main session " + onOff(value)4602 i.statusOK = "lsp in main session " + onOff(value)
@@ -6053,7 +6180,9 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
6053 if ag == nil {6180 if ag == nil {
6054 i.mu.Lock()6181 i.mu.Lock()
6055 i.busy = false6182 i.busy = false
6183+ pendingIdleWork := i.takePendingIdleWorkLocked()
6056 i.mu.Unlock()6184 i.mu.Unlock()
6185+ runPendingIdleWork(pendingIdleWork)
6057 i.invalidate()6186 i.invalidate()
6058 return6187 return
6059 }6188 }
@@ -6122,6 +6251,8 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
6122 // inspecting the queues lets them race a new turn or be stranded.6251 // inspecting the queues lets them race a new turn or be stranded.
6123 i.resetStreamingStateLocked()6252 i.resetStreamingStateLocked()
6124 i.cancelTurn = nil6253 i.cancelTurn = nil
6254+ i.autoCompacting = false
6255+ pendingIdleWork := i.takePendingIdleWorkLocked()
6125 6256
6126 // Drain pending work after the transcript is clean. Overflow recovery6257 // Drain pending work after the transcript is clean. Overflow recovery
6127 // continues the user message already in the transcript. Pre-turn6258 // continues the user message already in the transcript. Pre-turn
@@ -6207,6 +6338,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
6207 i.compacting = false6338 i.compacting = false
6208 i.autoCompacting = false6339 i.autoCompacting = false
6209 i.mu.Unlock()6340 i.mu.Unlock()
6341+ runPendingIdleWork(pendingIdleWork)
6210 i.invalidate()6342 i.invalidate()
6211 6343
6212 if hasNext || continueExisting || continueAutomatically {6344 if hasNext || continueExisting || continueAutomatically {
@@ -6330,6 +6462,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
6330 i.shellLive = ""6462 i.shellLive = ""
6331 i.busy = false6463 i.busy = false
6332 i.cancelTurn = nil6464 i.cancelTurn = nil
6465+ pendingIdleWork := i.takePendingIdleWorkLocked()
6333 awaitingPre := i.awaitingStartupPre6466 awaitingPre := i.awaitingStartupPre
6334 if failed {6467 if failed {
6335 if cancelled {6468 if cancelled {
@@ -6343,6 +6476,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
6343 i.statusErr = ""6476 i.statusErr = ""
6344 }6477 }
6345 i.mu.Unlock()6478 i.mu.Unlock()
6479+ runPendingIdleWork(pendingIdleWork)
6346 i.invalidate()6480 i.invalidate()
6347 if awaitingPre {6481 if awaitingPre {
6348 i.completeStartupPre()6482 i.completeStartupPre()
@@ -6483,6 +6617,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
6483 i.streamOn = false6617 i.streamOn = false
6484 }6618 }
6485 i.cancelTurn = nil6619 i.cancelTurn = nil
6620+ pendingIdleWork := i.takePendingIdleWorkLocked()
6486 if err != nil && ctx.Err() == nil {6621 if err != nil && ctx.Err() == nil {
6487 i.statusErr = err.Error()6622 i.statusErr = err.Error()
6488 }6623 }
@@ -6534,6 +6669,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
6534 // baseline so subsequent flushes only write new rows).6669 // baseline so subsequent flushes only write new rows).
6535 flush := i.cfg.FlushSession6670 flush := i.cfg.FlushSession
6536 i.mu.Unlock()6671 i.mu.Unlock()
6672+ runPendingIdleWork(pendingIdleWork)
6537 if flush != nil {6673 if flush != nil {
6538 flush()6674 flush()
6539 }6675 }
@@ -6924,6 +7060,57 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
6924 }7060 }
6925 }7061 }
6926 7062
7063+// ApplyAgentPromptConfig commits a resolved prompt and tool registry only if
7064+// ag is still the live interactive agent. It serializes the identity check
7065+// with agent replacement and swaps both values atomically in core.Agent.
7066+func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) {
7067+ if ag == nil {
7068+ return nil, false
7069+ }
7070+ // Match the lock order used by dynamic tool mutations so a refresh
7071+ // cannot race a snapshot-and-replace operation that would reintroduce
7072+ // the old registry after the atomic prompt swap.
7073+ i.agentMu.Lock()
7074+ defer i.agentMu.Unlock()
7075+ i.mu.Lock()
7076+ defer i.mu.Unlock()
7077+ if i.agent != ag {
7078+ return nil, false
7079+ }
7080+ return ag.SetPromptConfig(system, tools), true
7081+}
7082+
7083+// DeferUntilIdle runs work immediately when the interactive agent is idle,
7084+// or after the current turn/compaction/shell operation releases the busy
7085+// state. Callbacks run outside i.mu.
7086+func (i *Interactive) DeferUntilIdle(fn func()) {
7087+ if fn == nil {
7088+ return
7089+ }
7090+ i.mu.Lock()
7091+ if i.busy {
7092+ i.pendingIdleWork = append(i.pendingIdleWork, fn)
7093+ i.mu.Unlock()
7094+ return
7095+ }
7096+ i.mu.Unlock()
7097+ fn()
7098+}
7099+
7100+func (i *Interactive) takePendingIdleWorkLocked() []func() {
7101+ work := i.pendingIdleWork
7102+ i.pendingIdleWork = nil
7103+ return work
7104+}
7105+
7106+func runPendingIdleWork(work []func()) {
7107+ for _, fn := range work {
7108+ if fn != nil {
7109+ fn()
7110+ }
7111+ }
7112+}
7113+
6927 // Agent returns the current agent, if any. Used by cli.go to flush the7114 // Agent returns the current agent, if any. Used by cli.go to flush the
6928 // final transcript to the session file.7115 // final transcript to the session file.
6929 func (i *Interactive) Agent() *core.Agent {7116 func (i *Interactive) Agent() *core.Agent {
M packages/agent/modes/lsp_setting_test.go +52 -0
@@ -2,6 +2,7 @@ package modes
22
33 import (
44 "errors"
5+ "strings"
56 "testing"
67 )
78
@@ -17,6 +18,25 @@ func (failingLSPSettingsStore) SetSubagentLSPEnabled(bool) error {
1718 return errors.New("persist subagent lsp setting")
1819 }
1920
21+type rollbackFailingLSPSettingsStore struct {
22+ SettingsStore
23+ calls int
24+ values []bool
25+}
26+
27+func (s *rollbackFailingLSPSettingsStore) SetLSPEnabled(enabled bool) error {
28+ s.calls++
29+ if s.calls == 2 {
30+ return errors.New("rollback disk full")
31+ }
32+ s.values = append(s.values, enabled)
33+ return nil
34+}
35+
36+func (s *rollbackFailingLSPSettingsStore) SetSubagentLSPEnabled(bool) error {
37+ return nil
38+}
39+
2040 func TestLSPSettingToggleKeepsMemoryOnPersistenceFailure(t *testing.T) {
2141 mainEnabled := false
2242 subagentEnabled := false
@@ -35,3 +55,35 @@ func TestLSPSettingToggleKeepsMemoryOnPersistenceFailure(t *testing.T) {
3555 t.Fatal("sub-agent LSP changed after persistence failure")
3656 }
3757 }
58+
59+func TestLSPSettingToggleKeepsDurableStateWhenRollbackPersistenceFails(t *testing.T) {
60+ mainEnabled := true
61+ store := &rollbackFailingLSPSettingsStore{}
62+ refreshCalls := 0
63+ interactive := NewInteractive(InteractiveConfig{
64+ LSPEnabled: &mainEnabled,
65+ SettingsStore: store,
66+ RefreshTools: func() error {
67+ refreshCalls++
68+ if refreshCalls == 1 {
69+ return errors.New("refresh failed")
70+ }
71+ return nil
72+ },
73+ })
74+ interactive.rend = nil
75+
76+ interactive.applySettingToggle("lsp_enabled", false)
77+ if *interactive.cfg.LSPEnabled {
78+ t.Fatal("in-memory LSP setting reverted despite rollback persistence failure")
79+ }
80+ if len(store.values) != 1 || store.values[0] {
81+ t.Fatalf("durable writes = %v, want only the successful disable", store.values)
82+ }
83+ if refreshCalls != 2 {
84+ t.Fatalf("refresh calls = %d, want failed refresh plus durable-state reconciliation", refreshCalls)
85+ }
86+ if !strings.Contains(interactive.statusErr, "rollback disk full") {
87+ t.Fatalf("status error = %q, want rollback failure", interactive.statusErr)
88+ }
89+}
@@ -2,6 +2,7 @@ package modes
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "strings"
5 "testing"6 "testing"
6 )7 )
7 8
@@ -17,6 +18,25 @@ func (failingLSPSettingsStore) SetSubagentLSPEnabled(bool) error {
17 return errors.New("persist subagent lsp setting")18 return errors.New("persist subagent lsp setting")
18 }19 }
19 20
21+type rollbackFailingLSPSettingsStore struct {
22+ SettingsStore
23+ calls int
24+ values []bool
25+}
26+
27+func (s *rollbackFailingLSPSettingsStore) SetLSPEnabled(enabled bool) error {
28+ s.calls++
29+ if s.calls == 2 {
30+ return errors.New("rollback disk full")
31+ }
32+ s.values = append(s.values, enabled)
33+ return nil
34+}
35+
36+func (s *rollbackFailingLSPSettingsStore) SetSubagentLSPEnabled(bool) error {
37+ return nil
38+}
39+
20 func TestLSPSettingToggleKeepsMemoryOnPersistenceFailure(t *testing.T) {40 func TestLSPSettingToggleKeepsMemoryOnPersistenceFailure(t *testing.T) {
21 mainEnabled := false41 mainEnabled := false
22 subagentEnabled := false42 subagentEnabled := false
@@ -35,3 +55,35 @@ func TestLSPSettingToggleKeepsMemoryOnPersistenceFailure(t *testing.T) {
35 t.Fatal("sub-agent LSP changed after persistence failure")55 t.Fatal("sub-agent LSP changed after persistence failure")
36 }56 }
37 }57 }
58+
59+func TestLSPSettingToggleKeepsDurableStateWhenRollbackPersistenceFails(t *testing.T) {
60+ mainEnabled := true
61+ store := &rollbackFailingLSPSettingsStore{}
62+ refreshCalls := 0
63+ interactive := NewInteractive(InteractiveConfig{
64+ LSPEnabled: &mainEnabled,
65+ SettingsStore: store,
66+ RefreshTools: func() error {
67+ refreshCalls++
68+ if refreshCalls == 1 {
69+ return errors.New("refresh failed")
70+ }
71+ return nil
72+ },
73+ })
74+ interactive.rend = nil
75+
76+ interactive.applySettingToggle("lsp_enabled", false)
77+ if *interactive.cfg.LSPEnabled {
78+ t.Fatal("in-memory LSP setting reverted despite rollback persistence failure")
79+ }
80+ if len(store.values) != 1 || store.values[0] {
81+ t.Fatalf("durable writes = %v, want only the successful disable", store.values)
82+ }
83+ if refreshCalls != 2 {
84+ t.Fatalf("refresh calls = %d, want failed refresh plus durable-state reconciliation", refreshCalls)
85+ }
86+ if !strings.Contains(interactive.statusErr, "rollback disk full") {
87+ t.Fatalf("status error = %q, want rollback failure", interactive.statusErr)
88+ }
89+}
A packages/agent/modes/ponytail_setting_test.go +230 -0
@@ -0,0 +1,230 @@
1+package modes
2+
3+import (
4+ "errors"
5+ "strings"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+)
10+
11+type recordingPonytailSettingsStore struct {
12+ SettingsStore
13+ values []bool
14+ calls int
15+ err error
16+ failures map[int]error
17+}
18+
19+func (s *recordingPonytailSettingsStore) SetPonytailEnabled(enabled bool) error {
20+ s.calls++
21+ if s.err != nil {
22+ return s.err
23+ }
24+ if err := s.failures[s.calls]; err != nil {
25+ return err
26+ }
27+ s.values = append(s.values, enabled)
28+ return nil
29+}
30+
31+func TestSettingsDialogOffersPonytailEnabledByDefault(t *testing.T) {
32+ store := &recordingPonytailSettingsStore{}
33+ interactive := NewInteractive(InteractiveConfig{
34+ SettingsStore: store,
35+ RefreshPrompt: func() error { return nil },
36+ })
37+ interactive.openSettingsDialog()
38+
39+ item := findSettingsItem(interactive.settingsDialog.items, "ponytail_enabled")
40+ if item == nil {
41+ t.Fatal("settings dialog is missing Ponytail mode")
42+ }
43+ if !item.value {
44+ t.Fatal("Ponytail mode should be enabled when the config value is absent")
45+ }
46+ if item.disabled {
47+ t.Fatal("Ponytail mode should be available with persistence and refresh capabilities")
48+ }
49+}
50+
51+func TestSettingsDialogDisablesPonytailWithoutCapabilities(t *testing.T) {
52+ cases := []struct {
53+ name string
54+ cfg InteractiveConfig
55+ }{
56+ {name: "neither capability", cfg: InteractiveConfig{}},
57+ {name: "persistence only", cfg: InteractiveConfig{SettingsStore: &recordingPonytailSettingsStore{}}},
58+ {name: "refresh only", cfg: InteractiveConfig{RefreshPrompt: func() error { return nil }}},
59+ }
60+ for _, tc := range cases {
61+ t.Run(tc.name, func(t *testing.T) {
62+ interactive := NewInteractive(tc.cfg)
63+ interactive.openSettingsDialog()
64+
65+ item := findSettingsItem(interactive.settingsDialog.items, "ponytail_enabled")
66+ if item == nil {
67+ t.Fatal("settings dialog is missing Ponytail mode")
68+ }
69+ if !item.disabled {
70+ t.Fatal("Ponytail mode should be disabled without both capabilities")
71+ }
72+ if item.hint == "" {
73+ t.Fatal("disabled Ponytail mode should explain why it is unavailable")
74+ }
75+ })
76+ }
77+}
78+
79+func TestApplyPonytailToggleRefreshesAndPersists(t *testing.T) {
80+ store := &recordingPonytailSettingsStore{}
81+ refreshCalls := 0
82+ interactive := NewInteractive(InteractiveConfig{
83+ SettingsStore: store,
84+ RefreshPrompt: func() error {
85+ refreshCalls++
86+ return nil
87+ },
88+ })
89+ interactive.rend = nil
90+
91+ interactive.applySettingToggle("ponytail_enabled", false)
92+ if interactive.ponytailEnabled() {
93+ t.Fatal("Ponytail mode remained enabled after disabling it")
94+ }
95+ interactive.applySettingToggle("ponytail_enabled", true)
96+ if !interactive.ponytailEnabled() {
97+ t.Fatal("Ponytail mode remained disabled after enabling it")
98+ }
99+ if refreshCalls != 2 {
100+ t.Fatalf("refresh calls = %d, want 2", refreshCalls)
101+ }
102+ if len(store.values) != 2 || store.values[0] || !store.values[1] {
103+ t.Fatalf("persisted values = %v, want [false true]", store.values)
104+ }
105+ if interactive.statusErr != "" {
106+ t.Fatalf("unexpected status error: %q", interactive.statusErr)
107+ }
108+}
109+
110+func TestApplyPonytailToggleRollsBackWhenRefreshFails(t *testing.T) {
111+ store := &recordingPonytailSettingsStore{}
112+ refreshCalls := 0
113+ interactive := NewInteractive(InteractiveConfig{
114+ SettingsStore: store,
115+ RefreshPrompt: func() error {
116+ refreshCalls++
117+ if refreshCalls == 1 {
118+ return errors.New("refresh failed")
119+ }
120+ return nil
121+ },
122+ })
123+ interactive.rend = nil
124+
125+ interactive.applySettingToggle("ponytail_enabled", false)
126+ if !interactive.ponytailEnabled() {
127+ t.Fatal("Ponytail mode stayed disabled after refresh rollback")
128+ }
129+ if refreshCalls != 2 {
130+ t.Fatalf("refresh calls = %d, want initial refresh and rollback refresh", refreshCalls)
131+ }
132+ if len(store.values) != 2 || store.values[0] || !store.values[1] {
133+ t.Fatalf("persisted rollback values = %v, want [false true]", store.values)
134+ }
135+ if !strings.Contains(interactive.statusErr, "refresh failed") {
136+ t.Fatalf("status error = %q, want refresh failure", interactive.statusErr)
137+ }
138+}
139+
140+func TestApplyPonytailToggleKeepsDurableStateWhenRollbackPersistenceFails(t *testing.T) {
141+ store := &recordingPonytailSettingsStore{
142+ failures: map[int]error{2: errors.New("rollback disk full")},
143+ }
144+ refreshCalls := 0
145+ interactive := NewInteractive(InteractiveConfig{
146+ SettingsStore: store,
147+ RefreshPrompt: func() error {
148+ refreshCalls++
149+ if refreshCalls == 1 {
150+ return errors.New("refresh failed")
151+ }
152+ return nil
153+ },
154+ })
155+ interactive.rend = nil
156+
157+ interactive.applySettingToggle("ponytail_enabled", false)
158+ if interactive.ponytailEnabled() {
159+ t.Fatal("in-memory setting reverted despite rollback persistence failure")
160+ }
161+ if len(store.values) != 1 || store.values[0] {
162+ t.Fatalf("durable writes = %v, want only the successful disable", store.values)
163+ }
164+ if refreshCalls != 2 {
165+ t.Fatalf("refresh calls = %d, want failed refresh plus durable-state reconciliation", refreshCalls)
166+ }
167+ if !strings.Contains(interactive.statusErr, "rollback disk full") {
168+ t.Fatalf("status error = %q, want rollback failure", interactive.statusErr)
169+ }
170+}
171+
172+func TestApplyPonytailToggleKeepsStateWhenPersistenceFails(t *testing.T) {
173+ store := &recordingPonytailSettingsStore{err: errors.New("disk full")}
174+ refreshCalls := 0
175+ interactive := NewInteractive(InteractiveConfig{
176+ SettingsStore: store,
177+ RefreshPrompt: func() error {
178+ refreshCalls++
179+ return nil
180+ },
181+ })
182+ interactive.rend = nil
183+
184+ interactive.applySettingToggle("ponytail_enabled", false)
185+ if !interactive.ponytailEnabled() {
186+ t.Fatal("Ponytail mode changed despite persistence failure")
187+ }
188+ if refreshCalls != 0 {
189+ t.Fatalf("refresh calls = %d, want none", refreshCalls)
190+ }
191+ if !strings.Contains(interactive.statusErr, "disk full") {
192+ t.Fatalf("status error = %q, want persistence failure", interactive.statusErr)
193+ }
194+}
195+
196+func TestApplyPonytailToggleUpdatesLiveAgentPrompt(t *testing.T) {
197+ const ponytailBlock = "[ponytail test block]"
198+ store := &recordingPonytailSettingsStore{}
199+ ag := core.NewAgent(nil, "model", "base\n\n"+ponytailBlock, nil)
200+ var interactive *Interactive
201+ refreshCalls := 0
202+ interactive = NewInteractive(InteractiveConfig{
203+ Agent: ag,
204+ SettingsStore: store,
205+ RefreshPrompt: func() error {
206+ refreshCalls++
207+ system := "base"
208+ if interactive.ponytailEnabled() {
209+ system += "\n\n" + ponytailBlock
210+ }
211+ ag.SetSystemPrompt(system)
212+ return nil
213+ },
214+ })
215+ interactive.rend = nil
216+
217+ interactive.applySettingToggle("ponytail_enabled", false)
218+ system, _ := ag.PromptConfig()
219+ if strings.Contains(system, ponytailBlock) {
220+ t.Fatalf("live prompt still contains disabled Ponytail block: %q", system)
221+ }
222+ interactive.applySettingToggle("ponytail_enabled", true)
223+ system, _ = ag.PromptConfig()
224+ if strings.Count(system, ponytailBlock) != 1 {
225+ t.Fatalf("live prompt block count = %d, want 1: %q", strings.Count(system, ponytailBlock), system)
226+ }
227+ if refreshCalls != 2 {
228+ t.Fatalf("refresh calls = %d, want 2", refreshCalls)
229+ }
230+}
@@ -0,0 +1,230 @@
1+package modes
2+
3+import (
4+ "errors"
5+ "strings"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+)
10+
11+type recordingPonytailSettingsStore struct {
12+ SettingsStore
13+ values []bool
14+ calls int
15+ err error
16+ failures map[int]error
17+}
18+
19+func (s *recordingPonytailSettingsStore) SetPonytailEnabled(enabled bool) error {
20+ s.calls++
21+ if s.err != nil {
22+ return s.err
23+ }
24+ if err := s.failures[s.calls]; err != nil {
25+ return err
26+ }
27+ s.values = append(s.values, enabled)
28+ return nil
29+}
30+
31+func TestSettingsDialogOffersPonytailEnabledByDefault(t *testing.T) {
32+ store := &recordingPonytailSettingsStore{}
33+ interactive := NewInteractive(InteractiveConfig{
34+ SettingsStore: store,
35+ RefreshPrompt: func() error { return nil },
36+ })
37+ interactive.openSettingsDialog()
38+
39+ item := findSettingsItem(interactive.settingsDialog.items, "ponytail_enabled")
40+ if item == nil {
41+ t.Fatal("settings dialog is missing Ponytail mode")
42+ }
43+ if !item.value {
44+ t.Fatal("Ponytail mode should be enabled when the config value is absent")
45+ }
46+ if item.disabled {
47+ t.Fatal("Ponytail mode should be available with persistence and refresh capabilities")
48+ }
49+}
50+
51+func TestSettingsDialogDisablesPonytailWithoutCapabilities(t *testing.T) {
52+ cases := []struct {
53+ name string
54+ cfg InteractiveConfig
55+ }{
56+ {name: "neither capability", cfg: InteractiveConfig{}},
57+ {name: "persistence only", cfg: InteractiveConfig{SettingsStore: &recordingPonytailSettingsStore{}}},
58+ {name: "refresh only", cfg: InteractiveConfig{RefreshPrompt: func() error { return nil }}},
59+ }
60+ for _, tc := range cases {
61+ t.Run(tc.name, func(t *testing.T) {
62+ interactive := NewInteractive(tc.cfg)
63+ interactive.openSettingsDialog()
64+
65+ item := findSettingsItem(interactive.settingsDialog.items, "ponytail_enabled")
66+ if item == nil {
67+ t.Fatal("settings dialog is missing Ponytail mode")
68+ }
69+ if !item.disabled {
70+ t.Fatal("Ponytail mode should be disabled without both capabilities")
71+ }
72+ if item.hint == "" {
73+ t.Fatal("disabled Ponytail mode should explain why it is unavailable")
74+ }
75+ })
76+ }
77+}
78+
79+func TestApplyPonytailToggleRefreshesAndPersists(t *testing.T) {
80+ store := &recordingPonytailSettingsStore{}
81+ refreshCalls := 0
82+ interactive := NewInteractive(InteractiveConfig{
83+ SettingsStore: store,
84+ RefreshPrompt: func() error {
85+ refreshCalls++
86+ return nil
87+ },
88+ })
89+ interactive.rend = nil
90+
91+ interactive.applySettingToggle("ponytail_enabled", false)
92+ if interactive.ponytailEnabled() {
93+ t.Fatal("Ponytail mode remained enabled after disabling it")
94+ }
95+ interactive.applySettingToggle("ponytail_enabled", true)
96+ if !interactive.ponytailEnabled() {
97+ t.Fatal("Ponytail mode remained disabled after enabling it")
98+ }
99+ if refreshCalls != 2 {
100+ t.Fatalf("refresh calls = %d, want 2", refreshCalls)
101+ }
102+ if len(store.values) != 2 || store.values[0] || !store.values[1] {
103+ t.Fatalf("persisted values = %v, want [false true]", store.values)
104+ }
105+ if interactive.statusErr != "" {
106+ t.Fatalf("unexpected status error: %q", interactive.statusErr)
107+ }
108+}
109+
110+func TestApplyPonytailToggleRollsBackWhenRefreshFails(t *testing.T) {
111+ store := &recordingPonytailSettingsStore{}
112+ refreshCalls := 0
113+ interactive := NewInteractive(InteractiveConfig{
114+ SettingsStore: store,
115+ RefreshPrompt: func() error {
116+ refreshCalls++
117+ if refreshCalls == 1 {
118+ return errors.New("refresh failed")
119+ }
120+ return nil
121+ },
122+ })
123+ interactive.rend = nil
124+
125+ interactive.applySettingToggle("ponytail_enabled", false)
126+ if !interactive.ponytailEnabled() {
127+ t.Fatal("Ponytail mode stayed disabled after refresh rollback")
128+ }
129+ if refreshCalls != 2 {
130+ t.Fatalf("refresh calls = %d, want initial refresh and rollback refresh", refreshCalls)
131+ }
132+ if len(store.values) != 2 || store.values[0] || !store.values[1] {
133+ t.Fatalf("persisted rollback values = %v, want [false true]", store.values)
134+ }
135+ if !strings.Contains(interactive.statusErr, "refresh failed") {
136+ t.Fatalf("status error = %q, want refresh failure", interactive.statusErr)
137+ }
138+}
139+
140+func TestApplyPonytailToggleKeepsDurableStateWhenRollbackPersistenceFails(t *testing.T) {
141+ store := &recordingPonytailSettingsStore{
142+ failures: map[int]error{2: errors.New("rollback disk full")},
143+ }
144+ refreshCalls := 0
145+ interactive := NewInteractive(InteractiveConfig{
146+ SettingsStore: store,
147+ RefreshPrompt: func() error {
148+ refreshCalls++
149+ if refreshCalls == 1 {
150+ return errors.New("refresh failed")
151+ }
152+ return nil
153+ },
154+ })
155+ interactive.rend = nil
156+
157+ interactive.applySettingToggle("ponytail_enabled", false)
158+ if interactive.ponytailEnabled() {
159+ t.Fatal("in-memory setting reverted despite rollback persistence failure")
160+ }
161+ if len(store.values) != 1 || store.values[0] {
162+ t.Fatalf("durable writes = %v, want only the successful disable", store.values)
163+ }
164+ if refreshCalls != 2 {
165+ t.Fatalf("refresh calls = %d, want failed refresh plus durable-state reconciliation", refreshCalls)
166+ }
167+ if !strings.Contains(interactive.statusErr, "rollback disk full") {
168+ t.Fatalf("status error = %q, want rollback failure", interactive.statusErr)
169+ }
170+}
171+
172+func TestApplyPonytailToggleKeepsStateWhenPersistenceFails(t *testing.T) {
173+ store := &recordingPonytailSettingsStore{err: errors.New("disk full")}
174+ refreshCalls := 0
175+ interactive := NewInteractive(InteractiveConfig{
176+ SettingsStore: store,
177+ RefreshPrompt: func() error {
178+ refreshCalls++
179+ return nil
180+ },
181+ })
182+ interactive.rend = nil
183+
184+ interactive.applySettingToggle("ponytail_enabled", false)
185+ if !interactive.ponytailEnabled() {
186+ t.Fatal("Ponytail mode changed despite persistence failure")
187+ }
188+ if refreshCalls != 0 {
189+ t.Fatalf("refresh calls = %d, want none", refreshCalls)
190+ }
191+ if !strings.Contains(interactive.statusErr, "disk full") {
192+ t.Fatalf("status error = %q, want persistence failure", interactive.statusErr)
193+ }
194+}
195+
196+func TestApplyPonytailToggleUpdatesLiveAgentPrompt(t *testing.T) {
197+ const ponytailBlock = "[ponytail test block]"
198+ store := &recordingPonytailSettingsStore{}
199+ ag := core.NewAgent(nil, "model", "base\n\n"+ponytailBlock, nil)
200+ var interactive *Interactive
201+ refreshCalls := 0
202+ interactive = NewInteractive(InteractiveConfig{
203+ Agent: ag,
204+ SettingsStore: store,
205+ RefreshPrompt: func() error {
206+ refreshCalls++
207+ system := "base"
208+ if interactive.ponytailEnabled() {
209+ system += "\n\n" + ponytailBlock
210+ }
211+ ag.SetSystemPrompt(system)
212+ return nil
213+ },
214+ })
215+ interactive.rend = nil
216+
217+ interactive.applySettingToggle("ponytail_enabled", false)
218+ system, _ := ag.PromptConfig()
219+ if strings.Contains(system, ponytailBlock) {
220+ t.Fatalf("live prompt still contains disabled Ponytail block: %q", system)
221+ }
222+ interactive.applySettingToggle("ponytail_enabled", true)
223+ system, _ = ag.PromptConfig()
224+ if strings.Count(system, ponytailBlock) != 1 {
225+ t.Fatalf("live prompt block count = %d, want 1: %q", strings.Count(system, ponytailBlock), system)
226+ }
227+ if refreshCalls != 2 {
228+ t.Fatalf("refresh calls = %d, want 2", refreshCalls)
229+ }
230+}
A packages/agent/ponytail.go +15 -0
@@ -0,0 +1,15 @@
1+package agent
2+
3+import (
4+ _ "embed"
5+ "strings"
6+)
7+
8+//go:embed ponytail.md
9+var ponytailSystemAddendum string
10+
11+// PonytailSystemAddendum returns the compact coding-guidance block included
12+// in resolved system prompts when Ponytail mode is enabled.
13+func PonytailSystemAddendum() string {
14+ return strings.TrimSpace(ponytailSystemAddendum)
15+}
@@ -0,0 +1,15 @@
1+package agent
2+
3+import (
4+ _ "embed"
5+ "strings"
6+)
7+
8+//go:embed ponytail.md
9+var ponytailSystemAddendum string
10+
11+// PonytailSystemAddendum returns the compact coding-guidance block included
12+// in resolved system prompts when Ponytail mode is enabled.
13+func PonytailSystemAddendum() string {
14+ return strings.TrimSpace(ponytailSystemAddendum)
15+}
A packages/agent/ponytail.md +14 -0
@@ -0,0 +1,14 @@
1+# Ponytail coding mode
2+
3+When coding, debugging, reviewing, or changing software:
4+
5+- Understand the real flow and the owning boundary before editing.
6+- Prefer the smallest correct implementation; follow YAGNI.
7+- Reuse existing code and patterns before introducing abstractions.
8+- Prefer the standard library and native platform features before dependencies.
9+- Fix root causes rather than masking symptoms.
10+- Keep validation, error handling, security, accessibility, data-loss protection, and runnable checks intact.
11+- Avoid speculative features, boilerplate, and unrelated refactors.
12+- Before finishing non-trivial work, run the narrowest useful check and report what remains uncertain.
13+
14+This is coding guidance, not a permission or security policy. Apply it to engineering work, not ordinary conversation or creative requests.
@@ -0,0 +1,14 @@
1+# Ponytail coding mode
2+
3+When coding, debugging, reviewing, or changing software:
4+
5+- Understand the real flow and the owning boundary before editing.
6+- Prefer the smallest correct implementation; follow YAGNI.
7+- Reuse existing code and patterns before introducing abstractions.
8+- Prefer the standard library and native platform features before dependencies.
9+- Fix root causes rather than masking symptoms.
10+- Keep validation, error handling, security, accessibility, data-loss protection, and runnable checks intact.
11+- Avoid speculative features, boilerplate, and unrelated refactors.
12+- Before finishing non-trivial work, run the narrowest useful check and report what remains uncertain.
13+
14+This is coding guidance, not a permission or security policy. Apply it to engineering work, not ordinary conversation or creative requests.
A packages/agent/ponytail_test.go +199 -0
@@ -0,0 +1,199 @@
1+package agent
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestConfigPonytailModeDefaultsToEnabled(t *testing.T) {
11+ cfg := Config{}
12+ if !cfg.PonytailModeEnabled() {
13+ t.Fatal("Ponytail mode should default to enabled")
14+ }
15+
16+ disabled := false
17+ cfg.PonytailEnabled = &disabled
18+ if cfg.PonytailModeEnabled() {
19+ t.Fatal("Ponytail mode should honor an explicit false value")
20+ }
21+
22+ enabled := true
23+ cfg.PonytailEnabled = &enabled
24+ if !cfg.PonytailModeEnabled() {
25+ t.Fatal("Ponytail mode should honor an explicit true value")
26+ }
27+}
28+
29+func TestPonytailSystemAddendumIsCompact(t *testing.T) {
30+ addendum := PonytailSystemAddendum()
31+ if addendum == "" {
32+ t.Fatal("Ponytail system addendum is empty")
33+ }
34+ if len(addendum) > 2000 {
35+ t.Fatalf("Ponytail system addendum is %d bytes, want at most 2000", len(addendum))
36+ }
37+ for _, want := range []string{"YAGNI", "standard library", "validation", "ordinary conversation"} {
38+ if !strings.Contains(addendum, want) {
39+ t.Fatalf("Ponytail system addendum missing %q:\n%s", want, addendum)
40+ }
41+ }
42+}
43+
44+func TestResolvePonytailModeDefaultAndExplicitDisable(t *testing.T) {
45+ cases := []struct {
46+ name string
47+ disabled bool
48+ want bool
49+ }{
50+ {name: "default enabled", want: true},
51+ {name: "explicitly disabled", disabled: true, want: false},
52+ }
53+
54+ for _, tc := range cases {
55+ t.Run(tc.name, func(t *testing.T) {
56+ root := t.TempDir()
57+ t.Setenv("ZOT_HOME", filepath.Join(root, "zot-home"))
58+ t.Setenv("HOME", filepath.Join(root, "home"))
59+ if tc.disabled {
60+ disabled := false
61+ if err := SaveConfig(Config{PonytailEnabled: &disabled}); err != nil {
62+ t.Fatal(err)
63+ }
64+ }
65+
66+ resolved, err := Resolve(Args{
67+ Provider: "ollama",
68+ Model: "any-local-model",
69+ CWD: filepath.Join(root, "project"),
70+ NoLSP: true,
71+ NoSkill: true,
72+ NoTools: true,
73+ }, false)
74+ if err != nil {
75+ t.Fatalf("Resolve failed: %v", err)
76+ }
77+ got := strings.Contains(resolved.SystemPrompt, PonytailSystemAddendum())
78+ if got != tc.want {
79+ t.Fatalf("Ponytail prompt inclusion = %v, want %v:\n%s", got, tc.want, resolved.SystemPrompt)
80+ }
81+ if count := strings.Count(resolved.SystemPrompt, PonytailSystemAddendum()); count != btoi(tc.want) {
82+ t.Fatalf("Ponytail prompt occurrence count = %d, want %d", count, btoi(tc.want))
83+ }
84+ })
85+ }
86+}
87+
88+func btoi(value bool) int {
89+ if value {
90+ return 1
91+ }
92+ return 0
93+}
94+
95+func TestResolvePonytailModeRetainsAddendumWithSystemFile(t *testing.T) {
96+ root := t.TempDir()
97+ zotHome := filepath.Join(root, "zot-home")
98+ t.Setenv("ZOT_HOME", zotHome)
99+ t.Setenv("HOME", filepath.Join(root, "home"))
100+ if err := os.MkdirAll(zotHome, 0o755); err != nil {
101+ t.Fatal(err)
102+ }
103+ if err := os.WriteFile(filepath.Join(zotHome, "SYSTEM.md"), []byte("custom system identity"), 0o600); err != nil {
104+ t.Fatal(err)
105+ }
106+
107+ resolved, err := Resolve(Args{
108+ Provider: "ollama",
109+ Model: "any-local-model",
110+ CWD: filepath.Join(root, "project"),
111+ NoLSP: true,
112+ NoSkill: true,
113+ NoTools: true,
114+ }, false)
115+ if err != nil {
116+ t.Fatalf("Resolve failed: %v", err)
117+ }
118+ if !strings.Contains(resolved.SystemPrompt, "custom system identity") {
119+ t.Fatalf("SYSTEM.md content missing:\n%s", resolved.SystemPrompt)
120+ }
121+ if !strings.Contains(resolved.SystemPrompt, PonytailSystemAddendum()) {
122+ t.Fatalf("SYSTEM.md replacement lost Ponytail addendum:\n%s", resolved.SystemPrompt)
123+ }
124+}
125+
126+func TestResolvePonytailModePrecedesExplicitAppendPrompt(t *testing.T) {
127+ root := t.TempDir()
128+ t.Setenv("ZOT_HOME", filepath.Join(root, "zot-home"))
129+ t.Setenv("HOME", filepath.Join(root, "home"))
130+
131+ const explicit = "explicit invocation guidance"
132+ resolved, err := Resolve(Args{
133+ Provider: "ollama",
134+ Model: "any-local-model",
135+ CWD: filepath.Join(root, "project"),
136+ NoLSP: true,
137+ NoSkill: true,
138+ NoTools: true,
139+ AppendSystemPrompt: []string{explicit},
140+ }, false)
141+ if err != nil {
142+ t.Fatalf("Resolve failed: %v", err)
143+ }
144+ ponytailIndex := strings.Index(resolved.SystemPrompt, PonytailSystemAddendum())
145+ explicitIndex := strings.Index(resolved.SystemPrompt, explicit)
146+ if ponytailIndex < 0 || explicitIndex < 0 || ponytailIndex > explicitIndex {
147+ t.Fatalf("prompt ordering = Ponytail %d, explicit %d:\n%s", ponytailIndex, explicitIndex, resolved.SystemPrompt)
148+ }
149+}
150+
151+func TestResolvePonytailModeAppliesAcrossHeadlessModesAndCustomIdentity(t *testing.T) {
152+ root := t.TempDir()
153+ t.Setenv("ZOT_HOME", filepath.Join(root, "zot-home"))
154+ t.Setenv("HOME", filepath.Join(root, "home"))
155+
156+ modes := []Mode{"", ModePrint, ModeStream, ModeJSON, ModeRPC, ModeSubagentWorker}
157+ for _, mode := range modes {
158+ resolved, err := Resolve(Args{
159+ Mode: mode,
160+ Provider: "ollama",
161+ Model: "any-local-model",
162+ CWD: filepath.Join(root, "project"),
163+ NoLSP: true,
164+ NoSkill: true,
165+ NoTools: true,
166+ SystemPrompt: "custom identity",
167+ }, false)
168+ if err != nil {
169+ t.Fatalf("Resolve(%q) failed: %v", mode, err)
170+ }
171+ if !strings.Contains(resolved.SystemPrompt, "custom identity") {
172+ t.Fatalf("Resolve(%q) lost custom identity:\n%s", mode, resolved.SystemPrompt)
173+ }
174+ if !strings.Contains(resolved.SystemPrompt, PonytailSystemAddendum()) {
175+ t.Fatalf("Resolve(%q) omitted Ponytail guidance:\n%s", mode, resolved.SystemPrompt)
176+ }
177+ }
178+}
179+
180+func TestConfigSettingsStorePersistsPonytailWithoutChangingKnownFields(t *testing.T) {
181+ t.Setenv("ZOT_HOME", t.TempDir())
182+ if err := SaveConfig(Config{Theme: "dark", Provider: "ollama", Model: "local"}); err != nil {
183+ t.Fatal(err)
184+ }
185+
186+ if err := (configSettingsStore{}).SetPonytailEnabled(false); err != nil {
187+ t.Fatal(err)
188+ }
189+ cfg, err := LoadConfig()
190+ if err != nil {
191+ t.Fatal(err)
192+ }
193+ if cfg.PonytailEnabled == nil || *cfg.PonytailEnabled {
194+ t.Fatal("Ponytail mode was not persisted as disabled")
195+ }
196+ if cfg.Theme != "dark" || cfg.Provider != "ollama" || cfg.Model != "local" {
197+ t.Fatalf("known config fields changed: %#v", cfg)
198+ }
199+}
@@ -0,0 +1,199 @@
1+package agent
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestConfigPonytailModeDefaultsToEnabled(t *testing.T) {
11+ cfg := Config{}
12+ if !cfg.PonytailModeEnabled() {
13+ t.Fatal("Ponytail mode should default to enabled")
14+ }
15+
16+ disabled := false
17+ cfg.PonytailEnabled = &disabled
18+ if cfg.PonytailModeEnabled() {
19+ t.Fatal("Ponytail mode should honor an explicit false value")
20+ }
21+
22+ enabled := true
23+ cfg.PonytailEnabled = &enabled
24+ if !cfg.PonytailModeEnabled() {
25+ t.Fatal("Ponytail mode should honor an explicit true value")
26+ }
27+}
28+
29+func TestPonytailSystemAddendumIsCompact(t *testing.T) {
30+ addendum := PonytailSystemAddendum()
31+ if addendum == "" {
32+ t.Fatal("Ponytail system addendum is empty")
33+ }
34+ if len(addendum) > 2000 {
35+ t.Fatalf("Ponytail system addendum is %d bytes, want at most 2000", len(addendum))
36+ }
37+ for _, want := range []string{"YAGNI", "standard library", "validation", "ordinary conversation"} {
38+ if !strings.Contains(addendum, want) {
39+ t.Fatalf("Ponytail system addendum missing %q:\n%s", want, addendum)
40+ }
41+ }
42+}
43+
44+func TestResolvePonytailModeDefaultAndExplicitDisable(t *testing.T) {
45+ cases := []struct {
46+ name string
47+ disabled bool
48+ want bool
49+ }{
50+ {name: "default enabled", want: true},
51+ {name: "explicitly disabled", disabled: true, want: false},
52+ }
53+
54+ for _, tc := range cases {
55+ t.Run(tc.name, func(t *testing.T) {
56+ root := t.TempDir()
57+ t.Setenv("ZOT_HOME", filepath.Join(root, "zot-home"))
58+ t.Setenv("HOME", filepath.Join(root, "home"))
59+ if tc.disabled {
60+ disabled := false
61+ if err := SaveConfig(Config{PonytailEnabled: &disabled}); err != nil {
62+ t.Fatal(err)
63+ }
64+ }
65+
66+ resolved, err := Resolve(Args{
67+ Provider: "ollama",
68+ Model: "any-local-model",
69+ CWD: filepath.Join(root, "project"),
70+ NoLSP: true,
71+ NoSkill: true,
72+ NoTools: true,
73+ }, false)
74+ if err != nil {
75+ t.Fatalf("Resolve failed: %v", err)
76+ }
77+ got := strings.Contains(resolved.SystemPrompt, PonytailSystemAddendum())
78+ if got != tc.want {
79+ t.Fatalf("Ponytail prompt inclusion = %v, want %v:\n%s", got, tc.want, resolved.SystemPrompt)
80+ }
81+ if count := strings.Count(resolved.SystemPrompt, PonytailSystemAddendum()); count != btoi(tc.want) {
82+ t.Fatalf("Ponytail prompt occurrence count = %d, want %d", count, btoi(tc.want))
83+ }
84+ })
85+ }
86+}
87+
88+func btoi(value bool) int {
89+ if value {
90+ return 1
91+ }
92+ return 0
93+}
94+
95+func TestResolvePonytailModeRetainsAddendumWithSystemFile(t *testing.T) {
96+ root := t.TempDir()
97+ zotHome := filepath.Join(root, "zot-home")
98+ t.Setenv("ZOT_HOME", zotHome)
99+ t.Setenv("HOME", filepath.Join(root, "home"))
100+ if err := os.MkdirAll(zotHome, 0o755); err != nil {
101+ t.Fatal(err)
102+ }
103+ if err := os.WriteFile(filepath.Join(zotHome, "SYSTEM.md"), []byte("custom system identity"), 0o600); err != nil {
104+ t.Fatal(err)
105+ }
106+
107+ resolved, err := Resolve(Args{
108+ Provider: "ollama",
109+ Model: "any-local-model",
110+ CWD: filepath.Join(root, "project"),
111+ NoLSP: true,
112+ NoSkill: true,
113+ NoTools: true,
114+ }, false)
115+ if err != nil {
116+ t.Fatalf("Resolve failed: %v", err)
117+ }
118+ if !strings.Contains(resolved.SystemPrompt, "custom system identity") {
119+ t.Fatalf("SYSTEM.md content missing:\n%s", resolved.SystemPrompt)
120+ }
121+ if !strings.Contains(resolved.SystemPrompt, PonytailSystemAddendum()) {
122+ t.Fatalf("SYSTEM.md replacement lost Ponytail addendum:\n%s", resolved.SystemPrompt)
123+ }
124+}
125+
126+func TestResolvePonytailModePrecedesExplicitAppendPrompt(t *testing.T) {
127+ root := t.TempDir()
128+ t.Setenv("ZOT_HOME", filepath.Join(root, "zot-home"))
129+ t.Setenv("HOME", filepath.Join(root, "home"))
130+
131+ const explicit = "explicit invocation guidance"
132+ resolved, err := Resolve(Args{
133+ Provider: "ollama",
134+ Model: "any-local-model",
135+ CWD: filepath.Join(root, "project"),
136+ NoLSP: true,
137+ NoSkill: true,
138+ NoTools: true,
139+ AppendSystemPrompt: []string{explicit},
140+ }, false)
141+ if err != nil {
142+ t.Fatalf("Resolve failed: %v", err)
143+ }
144+ ponytailIndex := strings.Index(resolved.SystemPrompt, PonytailSystemAddendum())
145+ explicitIndex := strings.Index(resolved.SystemPrompt, explicit)
146+ if ponytailIndex < 0 || explicitIndex < 0 || ponytailIndex > explicitIndex {
147+ t.Fatalf("prompt ordering = Ponytail %d, explicit %d:\n%s", ponytailIndex, explicitIndex, resolved.SystemPrompt)
148+ }
149+}
150+
151+func TestResolvePonytailModeAppliesAcrossHeadlessModesAndCustomIdentity(t *testing.T) {
152+ root := t.TempDir()
153+ t.Setenv("ZOT_HOME", filepath.Join(root, "zot-home"))
154+ t.Setenv("HOME", filepath.Join(root, "home"))
155+
156+ modes := []Mode{"", ModePrint, ModeStream, ModeJSON, ModeRPC, ModeSubagentWorker}
157+ for _, mode := range modes {
158+ resolved, err := Resolve(Args{
159+ Mode: mode,
160+ Provider: "ollama",
161+ Model: "any-local-model",
162+ CWD: filepath.Join(root, "project"),
163+ NoLSP: true,
164+ NoSkill: true,
165+ NoTools: true,
166+ SystemPrompt: "custom identity",
167+ }, false)
168+ if err != nil {
169+ t.Fatalf("Resolve(%q) failed: %v", mode, err)
170+ }
171+ if !strings.Contains(resolved.SystemPrompt, "custom identity") {
172+ t.Fatalf("Resolve(%q) lost custom identity:\n%s", mode, resolved.SystemPrompt)
173+ }
174+ if !strings.Contains(resolved.SystemPrompt, PonytailSystemAddendum()) {
175+ t.Fatalf("Resolve(%q) omitted Ponytail guidance:\n%s", mode, resolved.SystemPrompt)
176+ }
177+ }
178+}
179+
180+func TestConfigSettingsStorePersistsPonytailWithoutChangingKnownFields(t *testing.T) {
181+ t.Setenv("ZOT_HOME", t.TempDir())
182+ if err := SaveConfig(Config{Theme: "dark", Provider: "ollama", Model: "local"}); err != nil {
183+ t.Fatal(err)
184+ }
185+
186+ if err := (configSettingsStore{}).SetPonytailEnabled(false); err != nil {
187+ t.Fatal(err)
188+ }
189+ cfg, err := LoadConfig()
190+ if err != nil {
191+ t.Fatal(err)
192+ }
193+ if cfg.PonytailEnabled == nil || *cfg.PonytailEnabled {
194+ t.Fatal("Ponytail mode was not persisted as disabled")
195+ }
196+ if cfg.Theme != "dark" || cfg.Provider != "ollama" || cfg.Model != "local" {
197+ t.Fatalf("known config fields changed: %#v", cfg)
198+ }
199+}
M packages/agent/rpc.go +1 -2
@@ -116,8 +116,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
116116 return
117117 }
118118 resolved.MergeExtensionTools(adapter)
119- oldTools := ag.Tools
120- ag.SetTools(resolved.ToolRegistry)
119+ oldTools := ag.SetPromptConfig(resolved.SystemPrompt, resolved.ToolRegistry)
121120 if closeErr := tools.CloseLSPManagers(oldTools); closeErr != nil {
122121 fmt.Fprintln(os.Stderr, "reload LSP cleanup:", closeErr)
123122 }
@@ -116,8 +116,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
116 return116 return
117 }117 }
118 resolved.MergeExtensionTools(adapter)118 resolved.MergeExtensionTools(adapter)
119- oldTools := ag.Tools119+ oldTools := ag.SetPromptConfig(resolved.SystemPrompt, resolved.ToolRegistry)
120- ag.SetTools(resolved.ToolRegistry)
121 if closeErr := tools.CloseLSPManagers(oldTools); closeErr != nil {120 if closeErr := tools.CloseLSPManagers(oldTools); closeErr != nil {
122 fmt.Fprintln(os.Stderr, "reload LSP cleanup:", closeErr)121 fmt.Fprintln(os.Stderr, "reload LSP cleanup:", closeErr)
123 }122 }
M packages/agent/sdk/sdk.go +1 -1
@@ -361,7 +361,7 @@ func (r *Runtime) Close() error {
361361 ag := r.agent
362362 r.mu.Unlock()
363363 if ag != nil {
364- return agenttools.CloseLSPManagers(ag.Tools)
364+ return agenttools.CloseLSPManagers(ag.ToolsSnapshot())
365365 }
366366 return nil
367367 }
@@ -361,7 +361,7 @@ func (r *Runtime) Close() error {
361 ag := r.agent361 ag := r.agent
362 r.mu.Unlock()362 r.mu.Unlock()
363 if ag != nil {363 if ag != nil {
364- return agenttools.CloseLSPManagers(ag.Tools)364+ return agenttools.CloseLSPManagers(ag.ToolsSnapshot())
365 }365 }
366 return nil366 return nil
367 }367 }
M packages/agent/settings_store.go +14 -0
@@ -1,6 +1,8 @@
11 package agent
22
33 import (
4+ "fmt"
5+
46 "github.com/patriceckhart/zot/packages/provider"
57 "github.com/patriceckhart/zot/packages/tui"
68 )
@@ -68,6 +70,18 @@ func (configSettingsStore) SetAutoSubagents(enabled bool) error {
6870 return SaveConfig(cfg)
6971 }
7072
73+func (configSettingsStore) SetPonytailEnabled(enabled bool) error {
74+ cfg, err := LoadConfig()
75+ if err != nil {
76+ return fmt.Errorf("load config for Ponytail setting: %w", err)
77+ }
78+ cfg.PonytailEnabled = &enabled
79+ if err := SaveConfig(cfg); err != nil {
80+ return fmt.Errorf("save Ponytail setting: %w", err)
81+ }
82+ return nil
83+}
84+
7185 func (configSettingsStore) SetFastMode(enabled bool) error {
7286 cfg, err := LoadConfig()
7387 if err != nil {
@@ -1,6 +1,8 @@
1 package agent1 package agent
2 2
3 import (3 import (
4+ "fmt"
5+
4 "github.com/patriceckhart/zot/packages/provider"6 "github.com/patriceckhart/zot/packages/provider"
5 "github.com/patriceckhart/zot/packages/tui"7 "github.com/patriceckhart/zot/packages/tui"
6 )8 )
@@ -68,6 +70,18 @@ func (configSettingsStore) SetAutoSubagents(enabled bool) error {
68 return SaveConfig(cfg)70 return SaveConfig(cfg)
69 }71 }
70 72
73+func (configSettingsStore) SetPonytailEnabled(enabled bool) error {
74+ cfg, err := LoadConfig()
75+ if err != nil {
76+ return fmt.Errorf("load config for Ponytail setting: %w", err)
77+ }
78+ cfg.PonytailEnabled = &enabled
79+ if err := SaveConfig(cfg); err != nil {
80+ return fmt.Errorf("save Ponytail setting: %w", err)
81+ }
82+ return nil
83+}
84+
71 func (configSettingsStore) SetFastMode(enabled bool) error {85 func (configSettingsStore) SetFastMode(enabled bool) error {
72 cfg, err := LoadConfig()86 cfg, err := LoadConfig()
73 if err != nil {87 if err != nil {
M packages/core/agent.go +12 -0
@@ -275,6 +275,18 @@ func (a *Agent) SetSystemPrompt(system string) {
275275 a.mu.Unlock()
276276 }
277277
278+// SetPromptConfig atomically replaces the system prompt and tool registry.
279+// It returns the previous registry so the caller can close resources that are
280+// no longer owned by the agent.
281+func (a *Agent) SetPromptConfig(system string, tools Registry) Registry {
282+ a.mu.Lock()
283+ defer a.mu.Unlock()
284+ oldTools := a.Tools
285+ a.System = system
286+ a.Tools = tools
287+ return oldTools
288+}
289+
278290 // PromptConfig returns a consistent snapshot of the system prompt and tools
279291 // used to construct a provider request.
280292 func (a *Agent) PromptConfig() (string, Registry) {
@@ -275,6 +275,18 @@ func (a *Agent) SetSystemPrompt(system string) {
275 a.mu.Unlock()275 a.mu.Unlock()
276 }276 }
277 277
278+// SetPromptConfig atomically replaces the system prompt and tool registry.
279+// It returns the previous registry so the caller can close resources that are
280+// no longer owned by the agent.
281+func (a *Agent) SetPromptConfig(system string, tools Registry) Registry {
282+ a.mu.Lock()
283+ defer a.mu.Unlock()
284+ oldTools := a.Tools
285+ a.System = system
286+ a.Tools = tools
287+ return oldTools
288+}
289+
278 // PromptConfig returns a consistent snapshot of the system prompt and tools290 // PromptConfig returns a consistent snapshot of the system prompt and tools
279 // used to construct a provider request.291 // used to construct a provider request.
280 func (a *Agent) PromptConfig() (string, Registry) {292 func (a *Agent) PromptConfig() (string, Registry) {
M packages/core/core_test.go +20 -0
@@ -20,6 +20,26 @@ func TestAgentRejectsEmptyPrompt(t *testing.T) {
2020 }
2121 }
2222
23+func TestAgentSetPromptConfigSwapsPromptAndToolsAtomically(t *testing.T) {
24+ oldTool := &recordingTool{}
25+ newTool := &recordingTool{}
26+ oldRegistry := Registry{"old": oldTool}
27+ newRegistry := Registry{"new": newTool}
28+ ag := NewAgent(nil, "model", "old system", oldRegistry)
29+
30+ previous := ag.SetPromptConfig("new system", newRegistry)
31+ if previous["old"] != oldTool {
32+ t.Fatalf("previous registry = %#v, want old registry", previous)
33+ }
34+ system, tools := ag.PromptConfig()
35+ if system != "new system" {
36+ t.Fatalf("system prompt = %q, want new system", system)
37+ }
38+ if tools["new"] != newTool || len(tools) != 1 {
39+ t.Fatalf("current registry = %#v, want new registry", tools)
40+ }
41+}
42+
2343 func TestSessionRoundTrip(t *testing.T) {
2444 dir := t.TempDir()
2545 os.Setenv("ZOT_HOME", dir)
@@ -20,6 +20,26 @@ func TestAgentRejectsEmptyPrompt(t *testing.T) {
20 }20 }
21 }21 }
22 22
23+func TestAgentSetPromptConfigSwapsPromptAndToolsAtomically(t *testing.T) {
24+ oldTool := &recordingTool{}
25+ newTool := &recordingTool{}
26+ oldRegistry := Registry{"old": oldTool}
27+ newRegistry := Registry{"new": newTool}
28+ ag := NewAgent(nil, "model", "old system", oldRegistry)
29+
30+ previous := ag.SetPromptConfig("new system", newRegistry)
31+ if previous["old"] != oldTool {
32+ t.Fatalf("previous registry = %#v, want old registry", previous)
33+ }
34+ system, tools := ag.PromptConfig()
35+ if system != "new system" {
36+ t.Fatalf("system prompt = %q, want new system", system)
37+ }
38+ if tools["new"] != newTool || len(tools) != 1 {
39+ t.Fatalf("current registry = %#v, want new registry", tools)
40+ }
41+}
42+
23 func TestSessionRoundTrip(t *testing.T) {43 func TestSessionRoundTrip(t *testing.T) {
24 dir := t.TempDir()44 dir := t.TempDir()
25 os.Setenv("ZOT_HOME", dir)45 os.Setenv("ZOT_HOME", dir)

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