detached 4091863

feat(agent): add default-on Ponytail coding mode

commit
4091863beacbff51442e41285b9b0f2f479a42cc
author
brice · · 2026-08-05 06:52 UTC
committer
brice · · 2026-08-05 07:29 UTC
parent
6588644
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +818 -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 +179 -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
@@ -3650,6 +3663,18 @@ func (i *Interactive) Display(extName, text string) {
36503663 i.invalidate()
36513664 }
36523665
3666+// ReportError surfaces a host-side error in the interactive status area.
3667+func (i *Interactive) ReportError(err error) {
3668+ if err == nil {
3669+ return
3670+ }
3671+ i.mu.Lock()
3672+ i.statusOK = ""
3673+ i.statusErr = err.Error()
3674+ i.mu.Unlock()
3675+ i.invalidate()
3676+}
3677+
36533678 // SetStatus replaces one persistent status item owned by an extension.
36543679 func (i *Interactive) SetStatus(extName, key, level, text string) {
36553680 if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {
@@ -3866,6 +3891,13 @@ func (i *Interactive) openSettingsDialog() {
38663891 autoSubagents = false
38673892 }
38683893
3894+ ponytailEnabled := i.ponytailEnabled()
3895+ ponytailDisabled := !i.ponytailSettingsAvailable()
3896+ ponytailHint := ""
3897+ if ponytailDisabled {
3898+ ponytailHint = "requires persistent settings and live prompt refresh support"
3899+ }
3900+
38693901 fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
38703902 fastModeHint := "OpenAI service tier"
38713903 if !provider.SupportsFastMode(i.cfg.Provider) {
@@ -3992,6 +4024,14 @@ func (i *Interactive) openSettingsDialog() {
39924024 disabled: autoSubagentsDisabled,
39934025 hint: autoSubagentsHint,
39944026 },
4027+ {
4028+ key: "ponytail_enabled",
4029+ label: "ponytail coding mode",
4030+ desc: "apply compact coding guidance that favors small, validated, maintainable changes",
4031+ value: ponytailEnabled,
4032+ disabled: ponytailDisabled,
4033+ hint: ponytailHint,
4034+ },
39954035 {
39964036 key: "fast_mode",
39974037 label: "fast mode",
@@ -4321,6 +4361,15 @@ func (i *Interactive) resetSettingsToggle(key string, value bool) {
43214361 }
43224362 }
43234363
4364+func (i *Interactive) ponytailEnabled() bool {
4365+ return i.cfg.PonytailEnabled == nil || *i.cfg.PonytailEnabled
4366+}
4367+
4368+func (i *Interactive) ponytailSettingsAvailable() bool {
4369+ _, persistent := i.cfg.SettingsStore.(ponytailSettingsStore)
4370+ return persistent && i.cfg.RefreshPrompt != nil
4371+}
4372+
43244373 func (i *Interactive) applySettingToggle(key string, value bool) {
43254374 // Every setting toggle forces a full repaint at the end — same
43264375 // effect as the user pressing Ctrl+L — so any per-setting visual
@@ -4396,6 +4445,56 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
43964445 i.statusOK = "AI terminal titles " + onOff(value)
43974446 i.statusErr = ""
43984447 i.mu.Unlock()
4448+ case "ponytail_enabled":
4449+ previous := i.ponytailEnabled()
4450+ store, available := i.cfg.SettingsStore.(ponytailSettingsStore)
4451+ if !available || i.cfg.RefreshPrompt == nil {
4452+ i.resetSettingsToggle(key, previous)
4453+ i.mu.Lock()
4454+ i.statusOK = ""
4455+ i.statusErr = "ponytail coding mode unavailable: persistent settings and live prompt refresh are required"
4456+ i.mu.Unlock()
4457+ return
4458+ }
4459+ if err := store.SetPonytailEnabled(value); err != nil {
4460+ i.resetSettingsToggle(key, previous)
4461+ i.mu.Lock()
4462+ i.statusOK = ""
4463+ i.statusErr = "settings: " + err.Error()
4464+ i.mu.Unlock()
4465+ return
4466+ }
4467+ val := value
4468+ i.cfg.PonytailEnabled = &val
4469+ if err := i.cfg.RefreshPrompt(); err != nil {
4470+ errMsg := "settings: ponytail prompt refresh: " + err.Error()
4471+ if rollbackErr := store.SetPonytailEnabled(previous); rollbackErr != nil {
4472+ // The first write succeeded, so keep the in-memory setting at
4473+ // the value that remains durable instead of claiming that the
4474+ // rollback took effect.
4475+ i.resetSettingsToggle(key, value)
4476+ errMsg += "; rollback persistence: " + rollbackErr.Error()
4477+ if reconcileErr := i.cfg.RefreshPrompt(); reconcileErr != nil {
4478+ errMsg += "; durable-state refresh: " + reconcileErr.Error()
4479+ }
4480+ } else {
4481+ previousVal := previous
4482+ i.cfg.PonytailEnabled = &previousVal
4483+ i.resetSettingsToggle(key, previous)
4484+ if refreshErr := i.cfg.RefreshPrompt(); refreshErr != nil {
4485+ errMsg += "; rollback refresh: " + refreshErr.Error()
4486+ }
4487+ }
4488+ i.mu.Lock()
4489+ i.statusOK = ""
4490+ i.statusErr = errMsg
4491+ i.mu.Unlock()
4492+ return
4493+ }
4494+ i.mu.Lock()
4495+ i.statusOK = "ponytail coding mode " + onOff(value)
4496+ i.statusErr = ""
4497+ i.mu.Unlock()
43994498 case "auto_subagents_enabled":
44004499 if value && !i.autoSubagentsAvailable() {
44014500 i.mu.Lock()
@@ -4460,7 +4559,9 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
44604559 i.statusErr = ""
44614560 i.mu.Unlock()
44624561 case "lsp_enabled":
4463- if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
4562+ previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
4563+ store, hasStore := i.cfg.SettingsStore.(lspSettingsStore)
4564+ if hasStore {
44644565 if err := store.SetLSPEnabled(value); err != nil {
44654566 i.mu.Lock()
44664567 i.statusErr = "settings: " + err.Error()
@@ -4471,7 +4572,23 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
44714572 val := value
44724573 i.cfg.LSPEnabled = &val
44734574 if i.cfg.RefreshTools != nil {
4474- i.cfg.RefreshTools()
4575+ if err := i.cfg.RefreshTools(); err != nil {
4576+ rollbackErr := error(nil)
4577+ if hasStore {
4578+ rollbackErr = store.SetLSPEnabled(previous)
4579+ }
4580+ previousVal := previous
4581+ i.cfg.LSPEnabled = &previousVal
4582+ errMsg := "settings: refresh tools: " + err.Error()
4583+ if rollbackErr != nil {
4584+ errMsg += "; rollback persistence: " + rollbackErr.Error()
4585+ }
4586+ i.mu.Lock()
4587+ i.statusOK = ""
4588+ i.statusErr = errMsg
4589+ i.mu.Unlock()
4590+ return
4591+ }
44754592 }
44764593 i.mu.Lock()
44774594 i.statusOK = "lsp in main session " + onOff(value)
@@ -6057,7 +6174,9 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
60576174 if ag == nil {
60586175 i.mu.Lock()
60596176 i.busy = false
6177+ pendingIdleWork := i.takePendingIdleWorkLocked()
60606178 i.mu.Unlock()
6179+ runPendingIdleWork(pendingIdleWork)
60616180 i.invalidate()
60626181 return
60636182 }
@@ -6126,6 +6245,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
61266245 i.resetStreamingStateLocked()
61276246 i.cancelTurn = nil
61286247 i.autoCompacting = false
6248+ pendingIdleWork := i.takePendingIdleWorkLocked()
61296249
61306250 // Drain pending work after the transcript is clean. Overflow recovery
61316251 // continues the user message already in the transcript. Pre-turn
@@ -6210,6 +6330,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
62106330 i.busy = true
62116331 }
62126332 i.mu.Unlock()
6333+ runPendingIdleWork(pendingIdleWork)
62136334 i.invalidate()
62146335
62156336 if hasNext || continueExisting || continueAutomatically {
@@ -6333,6 +6454,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
63336454 i.shellLive = ""
63346455 i.busy = false
63356456 i.cancelTurn = nil
6457+ pendingIdleWork := i.takePendingIdleWorkLocked()
63366458 awaitingPre := i.awaitingStartupPre
63376459 if failed {
63386460 if cancelled {
@@ -6346,6 +6468,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
63466468 i.statusErr = ""
63476469 }
63486470 i.mu.Unlock()
6471+ runPendingIdleWork(pendingIdleWork)
63496472 i.invalidate()
63506473 if awaitingPre {
63516474 i.completeStartupPre()
@@ -6472,6 +6595,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
64726595 i.streamOn = false
64736596 }
64746597 i.cancelTurn = nil
6598+ pendingIdleWork := i.takePendingIdleWorkLocked()
64756599 if err != nil && ctx.Err() == nil {
64766600 i.statusErr = err.Error()
64776601 }
@@ -6523,6 +6647,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
65236647 // baseline so subsequent flushes only write new rows).
65246648 flush := i.cfg.FlushSession
65256649 i.mu.Unlock()
6650+ runPendingIdleWork(pendingIdleWork)
65266651 if flush != nil {
65276652 flush()
65286653 }
@@ -6909,6 +7034,57 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
69097034 }
69107035 }
69117036
7037+// ApplyAgentPromptConfig commits a resolved prompt and tool registry only if
7038+// ag is still the live interactive agent. It serializes the identity check
7039+// with agent replacement and swaps both values atomically in core.Agent.
7040+func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) {
7041+ if ag == nil {
7042+ return nil, false
7043+ }
7044+ // Match the lock order used by dynamic tool mutations so a refresh
7045+ // cannot race a snapshot-and-replace operation that would reintroduce
7046+ // the old registry after the atomic prompt swap.
7047+ i.agentMu.Lock()
7048+ defer i.agentMu.Unlock()
7049+ i.mu.Lock()
7050+ defer i.mu.Unlock()
7051+ if i.agent != ag {
7052+ return nil, false
7053+ }
7054+ return ag.SetPromptConfig(system, tools), true
7055+}
7056+
7057+// DeferUntilIdle runs work immediately when the interactive agent is idle,
7058+// or after the current turn/compaction/shell operation releases the busy
7059+// state. Callbacks run outside i.mu.
7060+func (i *Interactive) DeferUntilIdle(fn func()) {
7061+ if fn == nil {
7062+ return
7063+ }
7064+ i.mu.Lock()
7065+ if i.busy {
7066+ i.pendingIdleWork = append(i.pendingIdleWork, fn)
7067+ i.mu.Unlock()
7068+ return
7069+ }
7070+ i.mu.Unlock()
7071+ fn()
7072+}
7073+
7074+func (i *Interactive) takePendingIdleWorkLocked() []func() {
7075+ work := i.pendingIdleWork
7076+ i.pendingIdleWork = nil
7077+ return work
7078+}
7079+
7080+func runPendingIdleWork(work []func()) {
7081+ for _, fn := range work {
7082+ if fn != nil {
7083+ fn()
7084+ }
7085+ }
7086+}
7087+
69127088 // Agent returns the current agent, if any. Used by cli.go to flush the
69137089 // final transcript to the session file.
69147090 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
@@ -3650,6 +3663,18 @@ func (i *Interactive) Display(extName, text string) {
3650 i.invalidate()3663 i.invalidate()
3651 }3664 }
3652 3665
3666+// ReportError surfaces a host-side error in the interactive status area.
3667+func (i *Interactive) ReportError(err error) {
3668+ if err == nil {
3669+ return
3670+ }
3671+ i.mu.Lock()
3672+ i.statusOK = ""
3673+ i.statusErr = err.Error()
3674+ i.mu.Unlock()
3675+ i.invalidate()
3676+}
3677+
3653 // SetStatus replaces one persistent status item owned by an extension.3678 // SetStatus replaces one persistent status item owned by an extension.
3654 func (i *Interactive) SetStatus(extName, key, level, text string) {3679 func (i *Interactive) SetStatus(extName, key, level, text string) {
3655 if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {3680 if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {
@@ -3866,6 +3891,13 @@ func (i *Interactive) openSettingsDialog() {
3866 autoSubagents = false3891 autoSubagents = false
3867 }3892 }
3868 3893
3894+ ponytailEnabled := i.ponytailEnabled()
3895+ ponytailDisabled := !i.ponytailSettingsAvailable()
3896+ ponytailHint := ""
3897+ if ponytailDisabled {
3898+ ponytailHint = "requires persistent settings and live prompt refresh support"
3899+ }
3900+
3869 fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode3901 fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
3870 fastModeHint := "OpenAI service tier"3902 fastModeHint := "OpenAI service tier"
3871 if !provider.SupportsFastMode(i.cfg.Provider) {3903 if !provider.SupportsFastMode(i.cfg.Provider) {
@@ -3992,6 +4024,14 @@ func (i *Interactive) openSettingsDialog() {
3992 disabled: autoSubagentsDisabled,4024 disabled: autoSubagentsDisabled,
3993 hint: autoSubagentsHint,4025 hint: autoSubagentsHint,
3994 },4026 },
4027+ {
4028+ key: "ponytail_enabled",
4029+ label: "ponytail coding mode",
4030+ desc: "apply compact coding guidance that favors small, validated, maintainable changes",
4031+ value: ponytailEnabled,
4032+ disabled: ponytailDisabled,
4033+ hint: ponytailHint,
4034+ },
3995 {4035 {
3996 key: "fast_mode",4036 key: "fast_mode",
3997 label: "fast mode",4037 label: "fast mode",
@@ -4321,6 +4361,15 @@ func (i *Interactive) resetSettingsToggle(key string, value bool) {
4321 }4361 }
4322 }4362 }
4323 4363
4364+func (i *Interactive) ponytailEnabled() bool {
4365+ return i.cfg.PonytailEnabled == nil || *i.cfg.PonytailEnabled
4366+}
4367+
4368+func (i *Interactive) ponytailSettingsAvailable() bool {
4369+ _, persistent := i.cfg.SettingsStore.(ponytailSettingsStore)
4370+ return persistent && i.cfg.RefreshPrompt != nil
4371+}
4372+
4324 func (i *Interactive) applySettingToggle(key string, value bool) {4373 func (i *Interactive) applySettingToggle(key string, value bool) {
4325 // Every setting toggle forces a full repaint at the end — same4374 // Every setting toggle forces a full repaint at the end — same
4326 // effect as the user pressing Ctrl+L — so any per-setting visual4375 // effect as the user pressing Ctrl+L — so any per-setting visual
@@ -4396,6 +4445,56 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
4396 i.statusOK = "AI terminal titles " + onOff(value)4445 i.statusOK = "AI terminal titles " + onOff(value)
4397 i.statusErr = ""4446 i.statusErr = ""
4398 i.mu.Unlock()4447 i.mu.Unlock()
4448+ case "ponytail_enabled":
4449+ previous := i.ponytailEnabled()
4450+ store, available := i.cfg.SettingsStore.(ponytailSettingsStore)
4451+ if !available || i.cfg.RefreshPrompt == nil {
4452+ i.resetSettingsToggle(key, previous)
4453+ i.mu.Lock()
4454+ i.statusOK = ""
4455+ i.statusErr = "ponytail coding mode unavailable: persistent settings and live prompt refresh are required"
4456+ i.mu.Unlock()
4457+ return
4458+ }
4459+ if err := store.SetPonytailEnabled(value); err != nil {
4460+ i.resetSettingsToggle(key, previous)
4461+ i.mu.Lock()
4462+ i.statusOK = ""
4463+ i.statusErr = "settings: " + err.Error()
4464+ i.mu.Unlock()
4465+ return
4466+ }
4467+ val := value
4468+ i.cfg.PonytailEnabled = &val
4469+ if err := i.cfg.RefreshPrompt(); err != nil {
4470+ errMsg := "settings: ponytail prompt refresh: " + err.Error()
4471+ if rollbackErr := store.SetPonytailEnabled(previous); rollbackErr != nil {
4472+ // The first write succeeded, so keep the in-memory setting at
4473+ // the value that remains durable instead of claiming that the
4474+ // rollback took effect.
4475+ i.resetSettingsToggle(key, value)
4476+ errMsg += "; rollback persistence: " + rollbackErr.Error()
4477+ if reconcileErr := i.cfg.RefreshPrompt(); reconcileErr != nil {
4478+ errMsg += "; durable-state refresh: " + reconcileErr.Error()
4479+ }
4480+ } else {
4481+ previousVal := previous
4482+ i.cfg.PonytailEnabled = &previousVal
4483+ i.resetSettingsToggle(key, previous)
4484+ if refreshErr := i.cfg.RefreshPrompt(); refreshErr != nil {
4485+ errMsg += "; rollback refresh: " + refreshErr.Error()
4486+ }
4487+ }
4488+ i.mu.Lock()
4489+ i.statusOK = ""
4490+ i.statusErr = errMsg
4491+ i.mu.Unlock()
4492+ return
4493+ }
4494+ i.mu.Lock()
4495+ i.statusOK = "ponytail coding mode " + onOff(value)
4496+ i.statusErr = ""
4497+ i.mu.Unlock()
4399 case "auto_subagents_enabled":4498 case "auto_subagents_enabled":
4400 if value && !i.autoSubagentsAvailable() {4499 if value && !i.autoSubagentsAvailable() {
4401 i.mu.Lock()4500 i.mu.Lock()
@@ -4460,7 +4559,9 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
4460 i.statusErr = ""4559 i.statusErr = ""
4461 i.mu.Unlock()4560 i.mu.Unlock()
4462 case "lsp_enabled":4561 case "lsp_enabled":
4463- if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {4562+ previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
4563+ store, hasStore := i.cfg.SettingsStore.(lspSettingsStore)
4564+ if hasStore {
4464 if err := store.SetLSPEnabled(value); err != nil {4565 if err := store.SetLSPEnabled(value); err != nil {
4465 i.mu.Lock()4566 i.mu.Lock()
4466 i.statusErr = "settings: " + err.Error()4567 i.statusErr = "settings: " + err.Error()
@@ -4471,7 +4572,23 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
4471 val := value4572 val := value
4472 i.cfg.LSPEnabled = &val4573 i.cfg.LSPEnabled = &val
4473 if i.cfg.RefreshTools != nil {4574 if i.cfg.RefreshTools != nil {
4474- i.cfg.RefreshTools()4575+ if err := i.cfg.RefreshTools(); err != nil {
4576+ rollbackErr := error(nil)
4577+ if hasStore {
4578+ rollbackErr = store.SetLSPEnabled(previous)
4579+ }
4580+ previousVal := previous
4581+ i.cfg.LSPEnabled = &previousVal
4582+ errMsg := "settings: refresh tools: " + err.Error()
4583+ if rollbackErr != nil {
4584+ errMsg += "; rollback persistence: " + rollbackErr.Error()
4585+ }
4586+ i.mu.Lock()
4587+ i.statusOK = ""
4588+ i.statusErr = errMsg
4589+ i.mu.Unlock()
4590+ return
4591+ }
4475 }4592 }
4476 i.mu.Lock()4593 i.mu.Lock()
4477 i.statusOK = "lsp in main session " + onOff(value)4594 i.statusOK = "lsp in main session " + onOff(value)
@@ -6057,7 +6174,9 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
6057 if ag == nil {6174 if ag == nil {
6058 i.mu.Lock()6175 i.mu.Lock()
6059 i.busy = false6176 i.busy = false
6177+ pendingIdleWork := i.takePendingIdleWorkLocked()
6060 i.mu.Unlock()6178 i.mu.Unlock()
6179+ runPendingIdleWork(pendingIdleWork)
6061 i.invalidate()6180 i.invalidate()
6062 return6181 return
6063 }6182 }
@@ -6126,6 +6245,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
6126 i.resetStreamingStateLocked()6245 i.resetStreamingStateLocked()
6127 i.cancelTurn = nil6246 i.cancelTurn = nil
6128 i.autoCompacting = false6247 i.autoCompacting = false
6248+ pendingIdleWork := i.takePendingIdleWorkLocked()
6129 6249
6130 // Drain pending work after the transcript is clean. Overflow recovery6250 // Drain pending work after the transcript is clean. Overflow recovery
6131 // continues the user message already in the transcript. Pre-turn6251 // continues the user message already in the transcript. Pre-turn
@@ -6210,6 +6330,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
6210 i.busy = true6330 i.busy = true
6211 }6331 }
6212 i.mu.Unlock()6332 i.mu.Unlock()
6333+ runPendingIdleWork(pendingIdleWork)
6213 i.invalidate()6334 i.invalidate()
6214 6335
6215 if hasNext || continueExisting || continueAutomatically {6336 if hasNext || continueExisting || continueAutomatically {
@@ -6333,6 +6454,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
6333 i.shellLive = ""6454 i.shellLive = ""
6334 i.busy = false6455 i.busy = false
6335 i.cancelTurn = nil6456 i.cancelTurn = nil
6457+ pendingIdleWork := i.takePendingIdleWorkLocked()
6336 awaitingPre := i.awaitingStartupPre6458 awaitingPre := i.awaitingStartupPre
6337 if failed {6459 if failed {
6338 if cancelled {6460 if cancelled {
@@ -6346,6 +6468,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
6346 i.statusErr = ""6468 i.statusErr = ""
6347 }6469 }
6348 i.mu.Unlock()6470 i.mu.Unlock()
6471+ runPendingIdleWork(pendingIdleWork)
6349 i.invalidate()6472 i.invalidate()
6350 if awaitingPre {6473 if awaitingPre {
6351 i.completeStartupPre()6474 i.completeStartupPre()
@@ -6472,6 +6595,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
6472 i.streamOn = false6595 i.streamOn = false
6473 }6596 }
6474 i.cancelTurn = nil6597 i.cancelTurn = nil
6598+ pendingIdleWork := i.takePendingIdleWorkLocked()
6475 if err != nil && ctx.Err() == nil {6599 if err != nil && ctx.Err() == nil {
6476 i.statusErr = err.Error()6600 i.statusErr = err.Error()
6477 }6601 }
@@ -6523,6 +6647,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
6523 // baseline so subsequent flushes only write new rows).6647 // baseline so subsequent flushes only write new rows).
6524 flush := i.cfg.FlushSession6648 flush := i.cfg.FlushSession
6525 i.mu.Unlock()6649 i.mu.Unlock()
6650+ runPendingIdleWork(pendingIdleWork)
6526 if flush != nil {6651 if flush != nil {
6527 flush()6652 flush()
6528 }6653 }
@@ -6909,6 +7034,57 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
6909 }7034 }
6910 }7035 }
6911 7036
7037+// ApplyAgentPromptConfig commits a resolved prompt and tool registry only if
7038+// ag is still the live interactive agent. It serializes the identity check
7039+// with agent replacement and swaps both values atomically in core.Agent.
7040+func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) {
7041+ if ag == nil {
7042+ return nil, false
7043+ }
7044+ // Match the lock order used by dynamic tool mutations so a refresh
7045+ // cannot race a snapshot-and-replace operation that would reintroduce
7046+ // the old registry after the atomic prompt swap.
7047+ i.agentMu.Lock()
7048+ defer i.agentMu.Unlock()
7049+ i.mu.Lock()
7050+ defer i.mu.Unlock()
7051+ if i.agent != ag {
7052+ return nil, false
7053+ }
7054+ return ag.SetPromptConfig(system, tools), true
7055+}
7056+
7057+// DeferUntilIdle runs work immediately when the interactive agent is idle,
7058+// or after the current turn/compaction/shell operation releases the busy
7059+// state. Callbacks run outside i.mu.
7060+func (i *Interactive) DeferUntilIdle(fn func()) {
7061+ if fn == nil {
7062+ return
7063+ }
7064+ i.mu.Lock()
7065+ if i.busy {
7066+ i.pendingIdleWork = append(i.pendingIdleWork, fn)
7067+ i.mu.Unlock()
7068+ return
7069+ }
7070+ i.mu.Unlock()
7071+ fn()
7072+}
7073+
7074+func (i *Interactive) takePendingIdleWorkLocked() []func() {
7075+ work := i.pendingIdleWork
7076+ i.pendingIdleWork = nil
7077+ return work
7078+}
7079+
7080+func runPendingIdleWork(work []func()) {
7081+ for _, fn := range work {
7082+ if fn != nil {
7083+ fn()
7084+ }
7085+ }
7086+}
7087+
6912 // Agent returns the current agent, if any. Used by cli.go to flush the7088 // Agent returns the current agent, if any. Used by cli.go to flush the
6913 // final transcript to the session file.7089 // final transcript to the session file.
6914 func (i *Interactive) Agent() *core.Agent {7090 func (i *Interactive) Agent() *core.Agent {
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 476

↑↓ moveenter openesc close