detached bce908a

Merge pull request #11 from bnema/feat/tasker-extension

feat(extensions): add session-aware extension APIs
commit
bce908a66c9acaefe547ab228d74877d9f37bf18
author
bnema · · 2026-08-04 14:56 UTC
committer
GitHub · · 2026-08-04 14:56 UTC
merge of
69e5a1b + e7a0d16 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
38 files · +4315 -111
M README.md +1 -1
@@ -954,7 +954,7 @@ An extension may ship only a theme: `extension.json` plus `theme.json` (or `them
954 954
955 ### Reference955 ### Reference
956 956
957-`examples/extensions/` ships reference implementations in Go, TypeScript, Node, and shell. See [docs/extensions.md](docs/extensions.md) for the full protocol, the SDK API (`packages/agent/ext`), and the phase roadmap.957+`examples/extensions/` ships reference implementations in Go, TypeScript, Node, and shell, including `tasked-phases` for spec-driven phase/checklist tracking. See [docs/extensions.md](docs/extensions.md) for the full protocol, the SDK API (`packages/agent/ext`), and the phase roadmap.
958 958
959 ## Skills959 ## Skills
960 960
M docs/extensions.md +99 -11
@@ -6,13 +6,17 @@ its stdin/stdout. Extensions can be written in **any language** that
6 can read and write JSON lines from stdio — Go, TypeScript, Python,6 can read and write JSON lines from stdio — Go, TypeScript, Python,
7 Rust, shell with `jq`, anything.7 Rust, shell with `jq`, anything.
8 8
9-Four phases shipped so far:9+Six phases shipped so far:
10 10
11 - **Phase 1**: slash commands, chat notifications, and host alerts.11 - **Phase 1**: slash commands, chat notifications, and host alerts.
12 - **Phase 2**: tools the LLM can call.12 - **Phase 2**: tools the LLM can call.
13 - **Phase 3**: lifecycle event subscriptions + tool-call interception13 - **Phase 3**: lifecycle event subscriptions + tool-call interception
14 for guardrail extensions.14 for guardrail extensions.
15 - **Phase 4**: interactive extension-owned panels rendered inside zot.15 - **Phase 4**: interactive extension-owned panels rendered inside zot.
16+- **Phase 5**: branch-aware session lifecycle events and opaque extension
17+ state persisted with session files.
18+- **Phase 6**: hidden per-turn context, persistent status/widgets, and
19+ extension-bundled skills.
16 - **Theme-only extensions**: ship `theme.json` without launching a20 - **Theme-only extensions**: ship `theme.json` without launching a
17 subprocess. See [themes.md](themes.md).21 subprocess. See [themes.md](themes.md).
18 22
@@ -118,6 +122,7 @@ manifest tells zot how to launch it:
118 "args": ["--mode", "daemon"],122 "args": ["--mode", "daemon"],
119 "language": "go",123 "language": "go",
120 "description": "current weather for any city",124 "description": "current weather for any city",
125+ "skills": ["skills"],
121 "enabled": true126 "enabled": true
122 }127 }
123 ```128 ```
@@ -130,6 +135,7 @@ manifest tells zot how to launch it:
130 | `args` | optional. extra argv passed to `exec`. |135 | `args` | optional. extra argv passed to `exec`. |
131 | `language` | optional. informational only (`go`, `python`, `typescript`, ...). |136 | `language` | optional. informational only (`go`, `python`, `typescript`, ...). |
132 | `description` | optional. shown in `zot ext list`. |137 | `description` | optional. shown in `zot ext list`. |
138+| `skills` | optional. relative directories containing bundled `<name>/SKILL.md` files or a direct `SKILL.md`. Paths must remain inside the extension directory. |
133 | `enabled` | optional, defaults to `true`. set to `false` to disable without removing. |139 | `enabled` | optional, defaults to `true`. set to `false` to disable without removing. |
134 140
135 ## Lifecycle141 ## Lifecycle
@@ -149,10 +155,12 @@ manifest tells zot how to launch it:
149 (logged in the extension's own log file).155 (logged in the extension's own log file).
150 5. **Runtime**: after `ready`, zot dispatches `command_invoked` frames156 5. **Runtime**: after `ready`, zot dispatches `command_invoked` frames
151 when the user runs a registered command; the extension responds157 when the user runs a registered command; the extension responds
152- with `command_response`. Extensions can also push `notify` and158+ with `command_response`. Extensions can also push `notify`, persistent
153- structured `alert` frames during runtime. Panel-capable extensions159+ status/widget, and structured `alert` frames during runtime. Extensions
154- may open an interactive panel, receive key events, and push redraws160+ subscribed to session lifecycle events receive the active branch identity
155- while the panel is focused.161+ and their own restored opaque state. Panel-capable extensions may open an
162+ interactive panel, receive key events, and push redraws while the panel is
163+ focused.
156 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to164 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to
157 2s for the extension to send `shutdown_ack`. Holdouts are165 2s for the extension to send `shutdown_ack`. Holdouts are
158 SIGTERM'd, then SIGKILL'd.166 SIGTERM'd, then SIGKILL'd.
@@ -236,9 +244,17 @@ registered deferred tools that become available after this result.
236 244
237 ```json245 ```json
238 {"type":"tool_result","id":"...",246 {"type":"tool_result","id":"...",
239- "content":[{"type":"text","text":"Berlin: 16°C, fog"}]}247+ "content":[{"type":"text","text":"Berlin: 16°C, fog"}],
248+ "details":{"state":{"version":1}}}
240 ```249 ```
241 250
251+`details` is opaque extension metadata. Zot excludes it from provider
252+requests and persists it as the latest extension state for the active
253+session branch. Keep it valid JSON and reasonably small; the host caps
254+persisted snapshots at 256 KiB. The next `session_opened`,
255+`session_switched`, `session_forked`, or `session_compacted` event returns
256+the extension's own snapshot in `state`.
257+
242 #### `subscribe`258 #### `subscribe`
243 259
244 Declares which lifecycle events the extension wants to observe and260 Declares which lifecycle events the extension wants to observe and
@@ -250,8 +266,14 @@ which it wants to intercept. Send once after `hello`, before `ready`.
250 "intercept":["tool_call","turn_start","assistant_message"]}266 "intercept":["tool_call","turn_start","assistant_message"]}
251 ```267 ```
252 268
253-Recognised event names: `session_start`, `turn_start`, `turn_end`,269+Recognised event names include `session_start`, `session_opened`,
254-`tool_call`, `tool_confirmation_requested`, `assistant_message`.270+`session_switched`, `session_forked`, `session_compacted`, `turn_start`,
271+`tool_call`, `tool_confirmation_requested`, `assistant_message`, and
272+`turn_end`.
273+
274+Session events contain `session` with the current branch ID, parent ID,
275+path, cwd, and fork point. They also contain `state`, but only for the
276+receiving extension. Zot never exposes another extension's state.
255 277
256 `tool_confirmation_requested` fires only when zot is about to wait for278 `tool_confirmation_requested` fires only when zot is about to wait for
257 interactive approval. Calls running in yolo mode, calls covered by a279 interactive approval. Calls running in yolo mode, calls covered by a
@@ -282,6 +304,7 @@ Reply to an `event_intercept` from the host. All fields default to
282 | `reason` | refusal text (on block) or pass-through note. |304 | `reason` | refusal text (on block) or pass-through note. |
283 | `modified_args` | for `tool_call`: rewritten JSON args the tool will actually see. Must be a valid JSON object. Ignored when `block` is true. |305 | `modified_args` | for `tool_call`: rewritten JSON args the tool will actually see. Must be a valid JSON object. Ignored when `block` is true. |
284 | `replace_text` | for `assistant_message`: replaces the user-visible text. The model's original output still lives in the transcript. Ignored when `block` is true. |306 | `replace_text` | for `assistant_message`: replaces the user-visible text. The model's original output still lives in the transcript. Ignored when `block` is true. |
307+| `context` | for `turn_start`: bounded hidden context sent only in the upcoming provider request. It is not a user message and is not persisted in the transcript. |
285 308
286 Missing the response within 5s is treated as "allow" (i.e. an309 Missing the response within 5s is treated as "allow" (i.e. an
287 unresponsive extension never stalls the agent). When multiple310 unresponsive extension never stalls the agent). When multiple
@@ -298,6 +321,9 @@ subsequent interceptor sees the previous one's output.
298 321
299 {"type":"event_intercept_response","id":"...",322 {"type":"event_intercept_response","id":"...",
300 "replace_text":"[redacted]"}323 "replace_text":"[redacted]"}
324+
325+{"type":"event_intercept_response","id":"...",
326+ "context":"Current phase: parse files\nRemaining: validate inputs"}
301 ```327 ```
302 328
303 #### `command_response` (reply to `command_invoked`)329 #### `command_response` (reply to `command_invoked`)
@@ -369,6 +395,32 @@ Closes a previously-open panel.
369 {"type":"panel_close","panel_id":"todos-main"}395 {"type":"panel_close","panel_id":"todos-main"}
370 ```396 ```
371 397
398+#### `status` (one-way, persistent)
399+
400+Sets or replaces one status item owned by the extension. Sending an empty
401+`text` clears the item.
402+
403+```json
404+{"type":"status","key":"progress","level":"success",
405+ "text":"2/4 tasks checked"}
406+```
407+
408+#### `widget` (one-way, persistent)
409+
410+Sets or replaces a compact widget. Interactive zot currently renders widgets
411+above the input; other hosts may ignore the placement hint.
412+
413+```json
414+{"type":"widget","id":"plan","position":"above_input",
415+ "title":"Tasked phases","lines":["Current phase: parse","[ ] validate inputs"]}
416+```
417+
418+#### `widget_clear` (one-way)
419+
420+```json
421+{"type":"widget_clear","id":"plan"}
422+```
423+
372 #### `notify` (one-way, any time)424 #### `notify` (one-way, any time)
373 425
374 ```json426 ```json
@@ -429,7 +481,10 @@ Sent in response to `shutdown`. Extension should exit promptly after.
429 "zot_version":"0.0.7","provider":"anthropic",481 "zot_version":"0.0.7","provider":"anthropic",
430 "model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot",482 "model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot",
431 "extension_dir":"/Users/pat/Developer/zot/.zot/extensions/todos",483 "extension_dir":"/Users/pat/Developer/zot/.zot/extensions/todos",
432- "data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos"}484+ "data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos",
485+ "session":{"id":"branch-1","parent_id":"root-1",
486+ "path":".../session.jsonl","cwd":"/Users/pat/Developer/zot",
487+ "fork_point":4}}
433 ```488 ```
434 489
435 Sent immediately after `hello`. Wait for this frame before sending490 Sent immediately after `hello`. Wait for this frame before sending
@@ -473,6 +528,9 @@ Lifecycle notification for events the extension subscribed to via
473 `subscribe`. One-way — no response expected.528 `subscribe`. One-way — no response expected.
474 529
475 ```json530 ```json
531+{"type":"event","event":"session_opened",
532+ "session":{"id":"branch-1","parent_id":"root-1","fork_point":4},
533+ "state":{"version":1}}
476 {"type":"event","event":"turn_start","step":1}534 {"type":"event","event":"turn_start","step":1}
477 {"type":"event","event":"tool_call",535 {"type":"event","event":"tool_call",
478 "tool_id":"...","tool_name":"read","tool_args":{"path":"foo.go"}}536 "tool_id":"...","tool_name":"read","tool_args":{"path":"foo.go"}}
@@ -622,6 +680,16 @@ host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`,
622 `ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for680 `ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for
623 `hello_ack`, runs `OnHello`, announces registrations, then sends `ready`.681 `hello_ack`, runs `OnHello`, announces registrations, then sends `ready`.
624 682
683+Session-aware extensions can subscribe to `session_opened`,
684+`session_switched`, `session_forked`, and `session_compacted`; `Event.Session`
685+identifies the active branch and `Event.State` contains that extension's
686+latest persisted snapshot. A tool can return opaque state with
687+`result.Details = ext.JSONDetails(value)`.
688+
689+`TurnStartDecision.Context` supplies hidden bounded context for the next model
690+request. `SetStatus`, `SetWidget`, and their clear methods update persistent
691+interactive chrome without entering the transcript.
692+
625 The SDK has four interceptor hooks, all optional:693 The SDK has four interceptor hooks, all optional:
626 694
627 ```go695 ```go
@@ -662,8 +730,17 @@ See:
662 - `examples/extensions/guard/` — event subscriptions + tool-call730 - `examples/extensions/guard/` — event subscriptions + tool-call
663 interception (refuses dangerous bash patterns)731 interception (refuses dangerous bash patterns)
664 - `examples/extensions/todo/` — interactive persistent panel + tool732 - `examples/extensions/todo/` — interactive persistent panel + tool
733+- `examples/extensions/tasked-phases/` — spec, phased checklist, persistent tool, and `/phases` panel
665 - `examples/extensions/scratchpad/` — source-run TypeScript commands + tool734 - `examples/extensions/scratchpad/` — source-run TypeScript commands + tool
666 735
736+The `tasked-phases` example bundles its companion skill under
737+`examples/extensions/tasked-phases/skills/` and declares it in
738+`extension.json`. Installing the extension therefore installs the workflow
739+skill automatically. The standalone copy at `examples/skills/tasked-phases/`
740+remains useful for project-local skill installation. Its state is restored per
741+session branch when zot session persistence is enabled; the extension keeps a
742+project-file fallback for hosts that do not persist sessions.
743+
667 ### Hot reload744 ### Hot reload
668 745
669 Type `/reload-ext` in the TUI to tear down every running extension746 Type `/reload-ext` in the TUI to tear down every running extension
@@ -713,8 +790,9 @@ Phase 2 (shipped):
713 - [x] tool result attribution surfaces extension name in details790 - [x] tool result attribution surfaces extension name in details
714 791
715 Phase 3 (shipped):792 Phase 3 (shipped):
716-- [x] event subscriptions (`session_start`, `turn_start`, `turn_end`,793+- [x] event subscriptions (`session_start`, `session_opened`,
717- `tool_call`, `assistant_message`)794+ `session_switched`, `session_forked`, `session_compacted`, `turn_start`,
795+ `turn_end`, `tool_call`, `assistant_message`)
718 - [x] tool-call interception (block before execution)796 - [x] tool-call interception (block before execution)
719 797
720 Phase 4 (shipped):798 Phase 4 (shipped):
@@ -724,6 +802,16 @@ Phase 4 (shipped):
724 - [x] rewrite user-visible assistant text via `replace_text`802 - [x] rewrite user-visible assistant text via `replace_text`
725 - [x] `/reload-ext` slash command (hot-reload without restarting zot)803 - [x] `/reload-ext` slash command (hot-reload without restarting zot)
726 804
805+Phase 5 (shipped):
806+- [x] session/branch identity in `hello_ack` and lifecycle events
807+- [x] session open/switch/fork/compaction notifications
808+- [x] opaque extension state persisted with session branches
809+
810+Phase 6 (shipped):
811+- [x] hidden bounded context returned from `turn_start` interception
812+- [x] persistent extension status and widget frames
813+- [x] manifest-declared bundled skill discovery with safe path validation
814+
727 Future (no firm timeline):815 Future (no firm timeline):
728 - [ ] TypeScript and Python SDK packages (currently the wire format816 - [ ] TypeScript and Python SDK packages (currently the wire format
729 is stable enough to hand-roll, see the Python quick-start)817 is stable enough to hand-roll, see the Python quick-start)
M docs/skills.md +13 -1
@@ -66,6 +66,17 @@ skill ecosystem works in zot unchanged. Drop your existing
66 `.claude/skills/` or `.agents/skills/` directories into a project and66 `.claude/skills/` or `.agents/skills/` directories into a project and
67 zot will pick them up.67 zot will pick them up.
68 68
69+Enabled extensions may bundle skills by declaring relative directories in
70+`extension.json`, for example `"skills": ["skills"]`, with files at
71+`skills/<name>/SKILL.md`. A declared directory may also contain a single
72+`SKILL.md` directly; its directory name supplies the fallback skill name and
73+child directories are not scanned. Bundled skills are copied by `zot ext
74+install` and loaded automatically. User/project skills take precedence over
75+bundled skills; bundled skills take precedence over embedded built-ins. When
76+two extensions provide the same skill name, the first declaration wins. The
77+host rejects manifest paths that escape the extension directory, including
78+symlinked skill files that resolve outside it.
79+
69 When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to80 When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to
70 `$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`81 `$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`
71 on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows.82 on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows.
@@ -113,9 +124,10 @@ are omitted from the model's startup context.
113 124
114 ## Examples125 ## Examples
115 126
116-See `examples/skills/` for two starter skills:127+See `examples/skills/` for starter skills:
117 128
118 - `code-review/` — self-review pass on a recent diff129 - `code-review/` — self-review pass on a recent diff
130+- `tasked-phases/` — spec-to-phase checklist workflow for the `tasked-phases` extension and its `tasked_phases` tool
119 - `test-fix/` — diagnose + minimally fix a failing test131 - `test-fix/` — diagnose + minimally fix a failing test
120 132
121 ## Comparison to other discovery layouts133 ## Comparison to other discovery layouts
A examples/extensions/tasked-phases/README.md +57 -0
@@ -0,0 +1,57 @@
1+# tasked-phases
2+
3+A zot extension for keeping a spec, phased plan, and checklist in one place.
4+It provides:
5+
6+- the `tasked_phases` tool for persistent spec/phase/task state;
7+- `/phases` for a full interactive panel;
8+- compact mutation results and full `get_status` output;
9+- project-scoped JSON persistence under the extension data directory.
10+
11+The tool actions are:
12+
13+```text
14+get_status set_spec replace_plan
15+add_phase update_phase remove_phase
16+add_task update_task remove_task
17+set_current_phase set_task_checked set_phase_checked
18+clear
19+```
20+
21+## Build and run
22+
23+From the zot repository:
24+
25+```bash
26+cd examples/extensions/tasked-phases
27+go build -o tasked-phases .
28+
29+# One-run development load, launched from the project you want to track:
30+cd /path/to/project
31+zot --no-ext --ext /path/to/zot/examples/extensions/tasked-phases
32+
33+# Or install it globally (run this from the extension directory):
34+cd /path/to/zot/examples/extensions/tasked-phases
35+zot ext install .
36+```
37+
38+Then use `/phases` or ask the model to create a spec and phased checklist with
39+`tasked_phases`. The extension keys its durable state by the host-reported
40+project CWD.
41+
42+The extension bundles its workflow skill under `skills/tasked-phases/`, so
43+`zot ext install` makes the guidance available automatically. The standalone
44+copy at `examples/skills/tasked-phases/SKILL.md` remains useful when you want
45+the same workflow without installing the extension.
46+
47+## Persistence and turn focus
48+
49+The extension stores one restrictive state file per project CWD as a fallback
50+for hosts that do not persist sessions. When zot session persistence is
51+available, the plan is also stored as extension-owned state on each session
52+branch and restored when sessions are opened, switched, or forked.
53+
54+Before each model turn, the extension supplies only the active phase, its goal,
55+and a bounded set of incomplete tasks as hidden context. Completed phases and
56+future phases are not repeated in every provider request; use `get_status` or
57+`/phases` when the full checklist is needed.
A examples/extensions/tasked-phases/extension.json +10 -0
@@ -0,0 +1,10 @@
1+{
2+ "name": "tasked-phases",
3+ "version": "1.0.0",
4+ "exec": "./tasked-phases",
5+ "args": [],
6+ "language": "go",
7+ "description": "persistent phased spec and checklist tracking",
8+ "skills": ["skills/tasked-phases"],
9+ "enabled": true
10+}
A examples/extensions/tasked-phases/main.go +244 -0
@@ -0,0 +1,244 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "strings"
8+ "sync"
9+
10+ "github.com/patriceckhart/zot/packages/agent/ext"
11+)
12+
13+const (
14+ extensionName = "tasked-phases"
15+ extensionVersion = "1.0.0"
16+ toolName = "tasked_phases"
17+ panelID = "tasked-phases"
18+)
19+
20+const toolSchema = `{
21+ "type": "object",
22+ "additionalProperties": false,
23+ "properties": {
24+ "action": {
25+ "type": "string",
26+ "enum": ["get_status", "set_spec", "replace_plan", "add_phase", "update_phase", "remove_phase", "add_task", "update_task", "remove_task", "set_current_phase", "set_task_checked", "set_phase_checked", "clear"],
27+ "description": "State operation to perform. Closed plans must be restarted with clear, or set_spec immediately followed by replace_plan."
28+ },
29+ "spec": {"type": "string", "description": "Spec text used by set_spec"},
30+ "phaseId": {"type": "string", "description": "Target phase id, raw id without the brackets shown in summaries"},
31+ "phaseTitle": {"type": "string", "description": "Phase title for add_phase or update_phase"},
32+ "phaseGoal": {"type": "string", "description": "Phase goal for add_phase or update_phase"},
33+ "taskId": {"type": "string", "description": "Target task id"},
34+ "taskText": {"type": "string", "description": "Task text for add_task or update_task"},
35+ "checked": {"type": "boolean", "description": "Checked state for set_task_checked or set_phase_checked"},
36+ "phases": {
37+ "type": "array",
38+ "description": "Full plan replacement used by replace_plan",
39+ "items": {
40+ "type": "object",
41+ "additionalProperties": false,
42+ "properties": {
43+ "id": {"type": "string", "description": "Optional phase id. If omitted, one is generated."},
44+ "title": {"type": "string", "description": "Phase title"},
45+ "goal": {"type": "string", "description": "Optional short goal for the phase"},
46+ "tasks": {
47+ "type": "array",
48+ "description": "Checklist tasks for the phase",
49+ "items": {
50+ "type": "object",
51+ "additionalProperties": false,
52+ "properties": {
53+ "id": {"type": "string", "description": "Optional task id. If omitted, one is generated."},
54+ "text": {"type": "string", "description": "Checklist task text"},
55+ "checked": {"type": "boolean", "description": "Whether the task starts checked"}
56+ },
57+ "required": ["text"]
58+ }
59+ }
60+ },
61+ "required": ["title"]
62+ }
63+ }
64+ },
65+ "required": ["action"]
66+}`
67+
68+type app struct {
69+ ext *ext.Extension
70+ store *stateStore
71+
72+ panelMu sync.Mutex
73+ panelOpen bool
74+}
75+
76+func newApp(e *ext.Extension) *app {
77+ return &app{ext: e, store: newStateStore()}
78+}
79+
80+func main() {
81+ e := ext.New(extensionName, extensionVersion)
82+ a := newApp(e)
83+
84+ e.OnHello(func(host ext.HostInfo) {
85+ dataDir := host.DataDir
86+ if dataDir == "" {
87+ dataDir = host.ExtensionDir
88+ }
89+ if err := a.store.load(dataDir, host.CWD); err != nil {
90+ e.Logf("load project state: %v", err)
91+ return
92+ }
93+ if state, err := a.store.snapshot(); err == nil {
94+ a.publishChrome(state)
95+ }
96+ })
97+ restoreSession := func(event ext.Event) {
98+ if err := a.store.activateSession(event.State); err != nil {
99+ e.Logf("restore session state: %v", err)
100+ a.publishUnavailableChrome()
101+ return
102+ }
103+ if state, err := a.store.snapshot(); err == nil {
104+ a.publishChrome(state)
105+ a.renderPanelIfOpen(state)
106+ }
107+ }
108+ e.On("session_opened", restoreSession)
109+ e.On("session_switched", restoreSession)
110+ e.On("session_forked", restoreSession)
111+ e.InterceptTurnStart(func(_ int) ext.TurnStartDecision {
112+ state, err := a.store.snapshot()
113+ if err != nil {
114+ e.Logf("load turn focus: %v", err)
115+ return ext.TurnStartDecision{}
116+ }
117+ return ext.TurnStartDecision{Context: buildTurnContext(state)}
118+ })
119+
120+ e.Tool(toolName,
121+ "Persist and update a structured spec, phased plan, current phase, and checklist tasks. Use it for spec-driven planning and progress tracking.",
122+ json.RawMessage(toolSchema), a.handleTool)
123+ e.Command("phases", "Show the current spec, phases, and checklist state", a.handleCommand)
124+ e.OnPanelKey(panelID, a.handlePanelKey, a.handlePanelClose)
125+
126+ if err := e.Run(); err != nil {
127+ fmt.Fprintln(os.Stderr, err)
128+ os.Exit(1)
129+ }
130+}
131+
132+func (a *app) handleCommand(_ string) ext.Response {
133+ state, err := a.store.snapshot()
134+ if err != nil {
135+ return ext.Errorf("load tasked phases: %v", err)
136+ }
137+ a.publishChrome(state)
138+ a.panelMu.Lock()
139+ a.panelOpen = true
140+ a.panelMu.Unlock()
141+ return ext.OpenPanel(panelID, "Tasked phases", buildViewLines(state), "Escape closes this panel")
142+}
143+
144+func (a *app) handlePanelKey(key, _ string) {
145+ if key == "esc" {
146+ a.ext.ClosePanel(panelID)
147+ a.setPanelOpen(false)
148+ }
149+}
150+
151+func (a *app) handlePanelClose() { a.setPanelOpen(false) }
152+
153+func (a *app) setPanelOpen(open bool) {
154+ a.panelMu.Lock()
155+ a.panelOpen = open
156+ a.panelMu.Unlock()
157+}
158+
159+func (a *app) renderPanelIfOpen(state PlanState) {
160+ a.panelMu.Lock()
161+ open := a.panelOpen
162+ a.panelMu.Unlock()
163+ if open {
164+ a.ext.RenderPanel(panelID, "Tasked phases", buildViewLines(state), "Escape closes this panel")
165+ }
166+}
167+
168+func (a *app) handleTool(args json.RawMessage) ext.ToolResult {
169+ var params toolArgs
170+ if err := json.Unmarshal(args, &params); err != nil {
171+ return ext.TextErrorResult("invalid args: " + err.Error())
172+ }
173+ if !isKnownAction(params.Action) {
174+ return ext.TextErrorResult("unsupported action: " + params.Action)
175+ }
176+
177+ if params.Action == actionGetStatus {
178+ state, err := a.store.snapshot()
179+ if err != nil {
180+ return toolResult(params.Action, PlanState{}, "", err)
181+ }
182+ a.publishChrome(state)
183+ a.renderPanelIfOpen(state)
184+ return toolResult(params.Action, state, "Current phased plan status", nil)
185+ }
186+
187+ state, headline, err := a.store.transact(func(current PlanState) (PlanState, string, error) {
188+ return applyAction(current, params)
189+ })
190+ // A zero state means transact could not obtain a loaded snapshot. Do not
191+ // replace persistent chrome with an invented empty plan. For action and
192+ // persistence errors, transact returns the unchanged usable state, which
193+ // should remain visible to help the user recover.
194+ if err == nil || state.Version != 0 {
195+ a.publishChrome(state)
196+ }
197+ if err == nil {
198+ a.renderPanelIfOpen(state)
199+ }
200+ return toolResult(params.Action, state, headline, err)
201+}
202+
203+func (a *app) publishUnavailableChrome() {
204+ a.ext.SetStatus("progress", "plan unavailable")
205+ a.ext.SetWidget("plan", "above_input", "Tasked phases", []string{"Plan state could not be restored for this session."})
206+ a.panelMu.Lock()
207+ open := a.panelOpen
208+ a.panelMu.Unlock()
209+ if open {
210+ a.ext.RenderPanel(panelID, "Tasked phases", []string{"Plan state could not be restored for this session."}, "Escape closes this panel")
211+ }
212+}
213+
214+func (a *app) publishChrome(state PlanState) {
215+ if isPlanClosed(state) {
216+ a.ext.SetStatus("progress", "closed: "+state.ClosedSummary)
217+ } else if done, total := getPlanProgress(state); total > 0 {
218+ a.ext.SetStatus("progress", fmt.Sprintf("%d/%d tasks checked", done, total))
219+ } else {
220+ a.ext.SetStatus("progress", "no active plan")
221+ }
222+ a.ext.SetWidget("plan", "above_input", "Tasked phases", strings.Split(buildCompactSummary(state), "\n"))
223+}
224+
225+func toolResult(action string, state PlanState, headline string, err error) ext.ToolResult {
226+ text := buildToolResultText(action, state, headline, err)
227+ var result ext.ToolResult
228+ if err != nil {
229+ result = ext.TextErrorResult(text)
230+ } else {
231+ result = ext.TextResult(text)
232+ }
233+ result.Details = ext.JSONDetails(state)
234+ return result
235+}
236+
237+func isKnownAction(action string) bool {
238+ for _, known := range toolActions {
239+ if action == known {
240+ return true
241+ }
242+ }
243+ return false
244+}
A examples/extensions/tasked-phases/manifest_test.go +48 -0
@@ -0,0 +1,48 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "slices"
8+ "testing"
9+)
10+
11+func TestManifestMatchesExtensionConstants(t *testing.T) {
12+ data, err := os.ReadFile("extension.json")
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ var manifest struct {
17+ Name string `json:"name"`
18+ Version string `json:"version"`
19+ Skills []string `json:"skills"`
20+ }
21+ if err := json.Unmarshal(data, &manifest); err != nil {
22+ t.Fatalf("extension.json is not valid JSON: %v", err)
23+ }
24+ if manifest.Name != extensionName || manifest.Version != extensionVersion {
25+ t.Fatalf("manifest = %s/%s, constants = %s/%s", manifest.Name, manifest.Version, extensionName, extensionVersion)
26+ }
27+ for _, skill := range manifest.Skills {
28+ if _, err := os.Stat(filepath.Join(skill, "SKILL.md")); err != nil {
29+ t.Errorf("declared skill %q has no SKILL.md: %v", skill, err)
30+ }
31+ }
32+}
33+
34+func TestToolSchemaEnumMatchesToolActions(t *testing.T) {
35+ var schema struct {
36+ Properties struct {
37+ Action struct {
38+ Enum []string `json:"enum"`
39+ } `json:"action"`
40+ } `json:"properties"`
41+ }
42+ if err := json.Unmarshal([]byte(toolSchema), &schema); err != nil {
43+ t.Fatalf("tool schema is not valid JSON: %v", err)
44+ }
45+ if !slices.Equal(schema.Properties.Action.Enum, toolActions) {
46+ t.Fatalf("schema enum = %v, toolActions = %v", schema.Properties.Action.Enum, toolActions)
47+ }
48+}
A examples/extensions/tasked-phases/skill_sync_test.go +24 -0
@@ -0,0 +1,24 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestBundledSkillMatchesStandaloneCopy(t *testing.T) {
11+ extensionSkill := filepath.Join("skills", "tasked-phases", "SKILL.md")
12+ standaloneSkill := filepath.Join("..", "..", "skills", "tasked-phases", "SKILL.md")
13+ extension, err := os.ReadFile(extensionSkill)
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ standalone, err := os.ReadFile(standaloneSkill)
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ if !bytes.Equal(extension, standalone) {
22+ t.Fatal("bundled and standalone tasked-phases skills have diverged")
23+ }
24+}
A examples/extensions/tasked-phases/skills/tasked-phases/SKILL.md +33 -0
@@ -0,0 +1,33 @@
1+---
2+name: tasked-phases
3+description: Use when the user wants a spec turned into phased work with checklist tasks tracked by the tasked_phases tool, or when ongoing work should be tracked phase by phase.
4+---
5+
6+# Tasked Phases
7+
8+Use the `tasked_phases` tool as the source of truth for the spec, phases, current focus, and checklist progress.
9+
10+## Workflow
11+
12+1. Clarify missing requirements when the spec is ambiguous.
13+2. Save the accepted spec with `tasked_phases` using `set_spec`.
14+3. Create or materially revise the plan with `replace_plan`.
15+4. Keep phases small and tasks concrete enough to check off.
16+5. Set the focus with `set_current_phase` and pass the target `phaseId`.
17+6. Call `set_task_checked` immediately after each checklist task is completed.
18+7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task.
19+8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` to discard the plan, or call `set_spec` immediately followed by `replace_plan` to record a new spec and a new set of phases.
20+
21+## Planning rules
22+
23+- Prefer 3–7 phases unless the task is tiny.
24+- Each task should describe an observable outcome.
25+- Avoid vague tasks such as “work on this” or “finish implementation”.
26+- Keep the stored spec and plan concise and operational.
27+
28+## Tool guidance
29+
30+- Use `get_status` before relying on remembered state or revising a long-running plan.
31+- Use `replace_plan` for a substantial restructuring; use `add_*`, `update_*`, and `remove_*` for small changes.
32+- Pass `phaseId` as the raw ID shown in brackets, without the brackets. The tool also accepts a complete phase label or a unique phase title when recovering from copied output.
33+- Do not rely on prose alone for completion state; update the tool after each completed task.
A examples/extensions/tasked-phases/state.go +1182 -0
@@ -0,0 +1,1182 @@
1+package main
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+ "regexp"
12+ "strconv"
13+ "strings"
14+ "sync"
15+ "time"
16+ "unicode"
17+ "unicode/utf8"
18+)
19+
20+const (
21+ actionGetStatus = "get_status"
22+ actionSetSpec = "set_spec"
23+ actionReplacePlan = "replace_plan"
24+ actionAddPhase = "add_phase"
25+ actionUpdatePhase = "update_phase"
26+ actionRemovePhase = "remove_phase"
27+ actionAddTask = "add_task"
28+ actionUpdateTask = "update_task"
29+ actionRemoveTask = "remove_task"
30+ actionSetCurrentPhase = "set_current_phase"
31+ actionSetTaskChecked = "set_task_checked"
32+ actionSetPhaseChecked = "set_phase_checked"
33+ actionClear = "clear"
34+)
35+
36+var (
37+ phaseIDPattern = regexp.MustCompile(`^phase-(\d+)$`)
38+ taskIDPattern = regexp.MustCompile(`^task-(\d+)$`)
39+ bracketedIDPattern = regexp.MustCompile(`\[([^\]]+)\]`)
40+)
41+
42+var toolActions = []string{
43+ actionGetStatus,
44+ actionSetSpec,
45+ actionReplacePlan,
46+ actionAddPhase,
47+ actionUpdatePhase,
48+ actionRemovePhase,
49+ actionAddTask,
50+ actionUpdateTask,
51+ actionRemoveTask,
52+ actionSetCurrentPhase,
53+ actionSetTaskChecked,
54+ actionSetPhaseChecked,
55+ actionClear,
56+}
57+
58+var closedPlanBlockedActions = map[string]bool{
59+ actionReplacePlan: true,
60+ actionAddPhase: true,
61+ actionUpdatePhase: true,
62+ actionRemovePhase: true,
63+ actionAddTask: true,
64+ actionUpdateTask: true,
65+ actionRemoveTask: true,
66+ actionSetCurrentPhase: true,
67+ actionSetTaskChecked: true,
68+ actionSetPhaseChecked: true,
69+}
70+
71+type PhaseTask struct {
72+ ID string `json:"id"`
73+ Text string `json:"text"`
74+ Checked bool `json:"checked"`
75+}
76+
77+type Phase struct {
78+ ID string `json:"id"`
79+ Title string `json:"title"`
80+ Goal string `json:"goal,omitempty"`
81+ Tasks []PhaseTask `json:"tasks"`
82+}
83+
84+type PlanState struct {
85+ Version int `json:"version"`
86+ Spec string `json:"spec,omitempty"`
87+ Phases []Phase `json:"phases"`
88+ CurrentPhaseID string `json:"currentPhaseId,omitempty"`
89+ ClosedAt *int64 `json:"closedAt,omitempty"`
90+ ClosedSummary string `json:"closedSummary,omitempty"`
91+ NextPhaseNumber int `json:"nextPhaseNumber"`
92+ NextTaskNumber int `json:"nextTaskNumber"`
93+ UpdatedAt int64 `json:"updatedAt"`
94+}
95+
96+type TaskInput struct {
97+ ID *string `json:"id"`
98+ Text string `json:"text"`
99+ Checked *bool `json:"checked"`
100+}
101+
102+type PhaseInput struct {
103+ ID *string `json:"id"`
104+ Title string `json:"title"`
105+ Goal *string `json:"goal"`
106+ Tasks *[]TaskInput `json:"tasks"`
107+}
108+
109+type toolArgs struct {
110+ Action string `json:"action"`
111+ Spec *string `json:"spec"`
112+ PhaseID string `json:"phaseId"`
113+ PhaseTitle *string `json:"phaseTitle"`
114+ PhaseGoal *string `json:"phaseGoal"`
115+ TaskID string `json:"taskId"`
116+ TaskText *string `json:"taskText"`
117+ Checked *bool `json:"checked"`
118+ Phases *[]PhaseInput `json:"phases"`
119+}
120+
121+func nowMillis() int64 { return time.Now().UnixMilli() }
122+
123+func createEmptyState() PlanState {
124+ return PlanState{
125+ Version: 1,
126+ Phases: []Phase{},
127+ NextPhaseNumber: 1,
128+ NextTaskNumber: 1,
129+ UpdatedAt: nowMillis(),
130+ }
131+}
132+
133+func cloneState(state PlanState) PlanState {
134+ cloned := state
135+ cloned.Phases = make([]Phase, len(state.Phases))
136+ for i, phase := range state.Phases {
137+ cloned.Phases[i] = phase
138+ cloned.Phases[i].Tasks = append([]PhaseTask(nil), phase.Tasks...)
139+ }
140+ return cloned
141+}
142+
143+func normalizeOptionalText(value string) string {
144+ return strings.TrimSpace(value)
145+}
146+
147+// sanitizeDisplayText removes terminal escape sequences and non-printing
148+// control characters at the rendering boundary. Persisted state is never
149+// modified, so the original extension-supplied text remains recoverable.
150+func sanitizeDisplayText(text string) string {
151+ var out strings.Builder
152+ for index := 0; index < len(text); {
153+ if text[index] == 0x1b {
154+ index++
155+ if index >= len(text) {
156+ break
157+ }
158+ switch text[index] {
159+ case '[': // CSI sequence.
160+ index++
161+ for index < len(text) {
162+ final := text[index]
163+ index++
164+ if final >= 0x40 && final <= 0x7e {
165+ break
166+ }
167+ }
168+ case ']': // OSC sequence, terminated by BEL or ST.
169+ index++
170+ for index < len(text) {
171+ if text[index] == 0x07 {
172+ index++
173+ break
174+ }
175+ if text[index] == 0x1b && index+1 < len(text) && text[index+1] == '\\' {
176+ index += 2
177+ break
178+ }
179+ index++
180+ }
181+ default:
182+ index++
183+ }
184+ continue
185+ }
186+ r, size := utf8.DecodeRuneInString(text[index:])
187+ index += size
188+ if r != '\n' && (unicode.IsControl(r) || r == 0x7f) {
189+ continue
190+ }
191+ out.WriteRune(r)
192+ }
193+ return out.String()
194+}
195+
196+func singleLine(text string) string {
197+ return strings.Join(strings.Fields(text), " ")
198+}
199+
200+func displaySingleLine(text string) string {
201+ return singleLine(sanitizeDisplayText(text))
202+}
203+
204+func truncatePlain(text string, maxLength int) string {
205+ if maxLength < 1 {
206+ return ""
207+ }
208+ runes := []rune(text)
209+ if len(runes) <= maxLength {
210+ return text
211+ }
212+ return strings.TrimRight(string(runes[:maxLength-1]), " \t\r\n") + "…"
213+}
214+
215+func getPhaseProgress(phase Phase) (done, total int) {
216+ total = len(phase.Tasks)
217+ for _, task := range phase.Tasks {
218+ if task.Checked {
219+ done++
220+ }
221+ }
222+ return done, total
223+}
224+
225+func getPlanProgress(state PlanState) (done, total int) {
226+ for _, phase := range state.Phases {
227+ phaseDone, phaseTotal := getPhaseProgress(phase)
228+ done += phaseDone
229+ total += phaseTotal
230+ }
231+ return done, total
232+}
233+
234+func isPhaseDone(phase Phase) bool {
235+ done, total := getPhaseProgress(phase)
236+ return total > 0 && done == total
237+}
238+
239+func hasStoredPlan(state PlanState) bool {
240+ return state.Spec != "" || len(state.Phases) > 0
241+}
242+
243+func isPlanComplete(state PlanState) bool {
244+ if len(state.Phases) == 0 {
245+ return false
246+ }
247+ for _, phase := range state.Phases {
248+ if !isPhaseDone(phase) {
249+ return false
250+ }
251+ }
252+ return true
253+}
254+
255+func isPlanClosed(state PlanState) bool { return state.ClosedAt != nil }
256+
257+func closePlanIfComplete(state *PlanState) {
258+ if !isPlanComplete(*state) {
259+ return
260+ }
261+ done, total := getPlanProgress(*state)
262+ if state.ClosedAt == nil {
263+ closedAt := nowMillis()
264+ state.ClosedAt = &closedAt
265+ }
266+ state.ClosedSummary = fmt.Sprintf("Completed %d/%d tasks across %d phase(s).", done, total, len(state.Phases))
267+ state.CurrentPhaseID = ""
268+}
269+
270+func getCurrentPhase(state PlanState) *Phase {
271+ if state.CurrentPhaseID == "" {
272+ return nil
273+ }
274+ for i := range state.Phases {
275+ if state.Phases[i].ID == state.CurrentPhaseID {
276+ return &state.Phases[i]
277+ }
278+ }
279+ return nil
280+}
281+
282+func getActivePhase(state PlanState) *Phase {
283+ if current := getCurrentPhase(state); current != nil {
284+ return current
285+ }
286+ for i := range state.Phases {
287+ if !isPhaseDone(state.Phases[i]) {
288+ return &state.Phases[i]
289+ }
290+ }
291+ return nil
292+}
293+
294+func getSuggestedCurrentPhaseID(state PlanState) string {
295+ if state.CurrentPhaseID != "" {
296+ for _, phase := range state.Phases {
297+ if phase.ID == state.CurrentPhaseID {
298+ return state.CurrentPhaseID
299+ }
300+ }
301+ }
302+ for _, phase := range state.Phases {
303+ if !isPhaseDone(phase) {
304+ return phase.ID
305+ }
306+ }
307+ if len(state.Phases) > 0 {
308+ return state.Phases[len(state.Phases)-1].ID
309+ }
310+ return ""
311+}
312+
313+func extractHighestIDNumber(prefix string, ids []string) int {
314+ highest := 0
315+ pattern := taskIDPattern
316+ if prefix == "phase" {
317+ pattern = phaseIDPattern
318+ }
319+ for _, id := range ids {
320+ match := pattern.FindStringSubmatch(id)
321+ if len(match) != 2 {
322+ continue
323+ }
324+ number, err := strconv.Atoi(match[1])
325+ if err == nil && number > highest {
326+ highest = number
327+ }
328+ }
329+ return highest
330+}
331+
332+func ensureState(state PlanState) PlanState {
333+ normalized := cloneState(state)
334+ normalized.Version = 1
335+ normalized.Spec = normalizeOptionalText(normalized.Spec)
336+ if normalized.Phases == nil {
337+ normalized.Phases = []Phase{}
338+ }
339+ for i := range normalized.Phases {
340+ normalized.Phases[i].Goal = optionalNormalized(normalized.Phases[i].Goal)
341+ if normalized.Phases[i].Tasks == nil {
342+ normalized.Phases[i].Tasks = []PhaseTask{}
343+ }
344+ }
345+ normalized.ClosedSummary = optionalNormalized(normalized.ClosedSummary)
346+ if isPlanComplete(normalized) {
347+ closePlanIfComplete(&normalized)
348+ } else {
349+ normalized.ClosedAt = nil
350+ normalized.ClosedSummary = ""
351+ }
352+
353+ highestPhase := make([]string, 0, len(normalized.Phases))
354+ highestTask := make([]string, 0)
355+ for _, phase := range normalized.Phases {
356+ highestPhase = append(highestPhase, phase.ID)
357+ for _, task := range phase.Tasks {
358+ highestTask = append(highestTask, task.ID)
359+ }
360+ }
361+ if normalized.NextPhaseNumber < 1 {
362+ normalized.NextPhaseNumber = 1
363+ }
364+ if normalized.NextTaskNumber < 1 {
365+ normalized.NextTaskNumber = 1
366+ }
367+ normalized.NextPhaseNumber = maxInt(normalized.NextPhaseNumber, extractHighestIDNumber("phase", highestPhase)+1)
368+ normalized.NextTaskNumber = maxInt(normalized.NextTaskNumber, extractHighestIDNumber("task", highestTask)+1)
369+ if isPlanClosed(normalized) {
370+ normalized.CurrentPhaseID = ""
371+ } else {
372+ normalized.CurrentPhaseID = getSuggestedCurrentPhaseID(normalized)
373+ }
374+ normalized.UpdatedAt = nowMillis()
375+ return normalized
376+}
377+
378+func optionalNormalized(value string) string {
379+ return normalizeOptionalText(value)
380+}
381+
382+func maxInt(a, b int) int {
383+ if a > b {
384+ return a
385+ }
386+ return b
387+}
388+
389+func nextPhaseID(state *PlanState) string {
390+ id := fmt.Sprintf("phase-%d", state.NextPhaseNumber)
391+ state.NextPhaseNumber++
392+ return id
393+}
394+
395+func nextTaskID(state *PlanState) string {
396+ id := fmt.Sprintf("task-%d", state.NextTaskNumber)
397+ state.NextTaskNumber++
398+ return id
399+}
400+
401+func extractBracketedPhaseID(value string) string {
402+ matches := bracketedIDPattern.FindAllStringSubmatch(value, -1)
403+ candidate := ""
404+ for _, match := range matches {
405+ if len(match) == 2 {
406+ if normalized := normalizeOptionalText(match[1]); normalized != "" {
407+ candidate = normalized
408+ }
409+ }
410+ }
411+ return candidate
412+}
413+
414+func findUniquePhaseByTitle(state PlanState, phaseTitle string) *Phase {
415+ normalizedTitle := singleLine(phaseTitle)
416+ var match *Phase
417+ matches := 0
418+ for i := range state.Phases {
419+ if singleLine(state.Phases[i].Title) == normalizedTitle {
420+ match = &state.Phases[i]
421+ matches++
422+ }
423+ }
424+ if matches == 1 {
425+ return match
426+ }
427+ return nil
428+}
429+
430+// findPhase returns a pointer into state.Phases, aliasing the caller's slice
431+// backing array so mutations are applied to the caller's state copy.
432+func findPhase(state PlanState, phaseID string) *Phase {
433+ candidate := normalizeOptionalText(phaseID)
434+ if candidate == "" {
435+ return nil
436+ }
437+ for i := range state.Phases {
438+ if state.Phases[i].ID == candidate {
439+ return &state.Phases[i]
440+ }
441+ }
442+ if bracketedID := extractBracketedPhaseID(candidate); bracketedID != "" {
443+ for i := range state.Phases {
444+ if state.Phases[i].ID == bracketedID {
445+ return &state.Phases[i]
446+ }
447+ }
448+ }
449+ return findUniquePhaseByTitle(state, candidate)
450+}
451+
452+func formatValidPhaseIDs(state PlanState) string {
453+ if len(state.Phases) == 0 {
454+ return "none"
455+ }
456+ parts := make([]string, 0, len(state.Phases))
457+ for _, phase := range state.Phases {
458+ parts = append(parts, fmt.Sprintf("%s (%s)", phase.ID, singleLine(phase.Title)))
459+ }
460+ return strings.Join(parts, ", ")
461+}
462+
463+func phaseIDError(state PlanState, action, phaseID string) string {
464+ candidate := normalizeOptionalText(phaseID)
465+ validIDs := formatValidPhaseIDs(state)
466+ if candidate == "" {
467+ return fmt.Sprintf("phaseId is required for %s. Use a valid phase id. Valid phase ids: %s.", action, validIDs)
468+ }
469+ return fmt.Sprintf("phaseId %q was not found for %s. Use the raw id from brackets without brackets. Valid phase ids: %s.", singleLine(candidate), action, validIDs)
470+}
471+
472+func formatValidTaskIDs(state PlanState) string {
473+ parts := make([]string, 0)
474+ for _, phase := range state.Phases {
475+ for _, task := range phase.Tasks {
476+ if !task.Checked {
477+ parts = append(parts, fmt.Sprintf("%s (%s)", task.ID, displaySingleLine(phase.Title)))
478+ }
479+ }
480+ }
481+ if len(parts) == 0 {
482+ return "none"
483+ }
484+ return strings.Join(parts, ", ")
485+}
486+
487+func taskIDError(state PlanState, action, taskID string) string {
488+ candidate := normalizeOptionalText(taskID)
489+ validIDs := formatValidTaskIDs(state)
490+ if candidate == "" {
491+ return fmt.Sprintf("taskId is required for %s. Use a valid task id. Valid incomplete task ids: %s.", action, validIDs)
492+ }
493+ return fmt.Sprintf("taskId %q was not found for %s. Use the task id from brackets. Valid incomplete task ids: %s.", displaySingleLine(candidate), action, validIDs)
494+}
495+
496+// findTask returns pointers into state.Phases and their Tasks, aliasing the
497+// caller's slice backing arrays so mutations are applied to the caller's state.
498+func findTask(state PlanState, taskID, phaseID string) (phase *Phase, task *PhaseTask) {
499+ if taskID == "" {
500+ return nil, nil
501+ }
502+ if phaseID != "" {
503+ phase = findPhase(state, phaseID)
504+ if phase == nil {
505+ return nil, nil
506+ }
507+ for i := range phase.Tasks {
508+ if phase.Tasks[i].ID == taskID {
509+ return phase, &phase.Tasks[i]
510+ }
511+ }
512+ return nil, nil
513+ }
514+ for i := range state.Phases {
515+ for j := range state.Phases[i].Tasks {
516+ if state.Phases[i].Tasks[j].ID == taskID {
517+ return &state.Phases[i], &state.Phases[i].Tasks[j]
518+ }
519+ }
520+ }
521+ return nil, nil
522+}
523+
524+func buildPhaseFromInput(state *PlanState, input PhaseInput, phaseIDs, taskIDs map[string]struct{}) (Phase, error) {
525+ id := ""
526+ if input.ID != nil {
527+ id = normalizeOptionalText(*input.ID)
528+ }
529+ if id == "" {
530+ id = nextPhaseID(state)
531+ }
532+ if _, exists := phaseIDs[id]; exists {
533+ return Phase{}, fmt.Errorf("duplicate phase id %q", id)
534+ }
535+ phaseIDs[id] = struct{}{}
536+ phase := Phase{
537+ ID: id,
538+ Title: strings.TrimSpace(input.Title),
539+ Goal: optionalInputText(input.Goal),
540+ Tasks: []PhaseTask{},
541+ }
542+ if input.Tasks != nil {
543+ phase.Tasks = make([]PhaseTask, 0, len(*input.Tasks))
544+ for _, inputTask := range *input.Tasks {
545+ taskID := ""
546+ if inputTask.ID != nil {
547+ taskID = normalizeOptionalText(*inputTask.ID)
548+ }
549+ if taskID == "" {
550+ taskID = nextTaskID(state)
551+ }
552+ if _, exists := taskIDs[taskID]; exists {
553+ return Phase{}, fmt.Errorf("duplicate task id %q", taskID)
554+ }
555+ taskIDs[taskID] = struct{}{}
556+ checked := false
557+ if inputTask.Checked != nil {
558+ checked = *inputTask.Checked
559+ }
560+ phase.Tasks = append(phase.Tasks, PhaseTask{ID: taskID, Text: strings.TrimSpace(inputTask.Text), Checked: checked})
561+ }
562+ }
563+ return phase, nil
564+}
565+
566+func optionalInputText(value *string) string {
567+ if value == nil {
568+ return ""
569+ }
570+ return normalizeOptionalText(*value)
571+}
572+
573+// applyAction is the state transition used by the protocol handler. It does
574+// not persist the result; stateStore performs that step while holding its
575+// mutex, so a failed write never publishes an unpersisted state.
576+func applyAction(state PlanState, params toolArgs) (PlanState, string, error) {
577+ state = cloneState(state)
578+ nextState := cloneState(state)
579+ if isPlanClosed(state) && closedPlanBlockedActions[params.Action] {
580+ reopen := (params.Action == actionSetTaskChecked || params.Action == actionSetPhaseChecked) && params.Checked != nil && !*params.Checked
581+ if !reopen {
582+ return state, "Plan is closed", errors.New("this plan is already complete and closed; call clear before starting new work, or call set_spec immediately followed by replace_plan")
583+ }
584+ }
585+
586+ switch params.Action {
587+ case actionGetStatus:
588+ return state, "Current phased plan status", nil
589+ case actionSetSpec:
590+ spec := ""
591+ if params.Spec != nil {
592+ spec = normalizeOptionalText(*params.Spec)
593+ }
594+ if spec == "" {
595+ return state, "Spec not updated", errors.New("spec is required for set_spec")
596+ }
597+ nextState.Spec = spec
598+ if isPlanClosed(state) {
599+ nextState.Phases = []Phase{}
600+ nextState.CurrentPhaseID = ""
601+ nextState.NextPhaseNumber = 1
602+ nextState.NextTaskNumber = 1
603+ }
604+ nextState.ClosedAt = nil
605+ nextState.ClosedSummary = ""
606+ return nextState, "Saved spec", nil
607+ case actionReplacePlan:
608+ if params.Phases == nil {
609+ return state, "Plan not replaced", errors.New("phases is required for replace_plan")
610+ }
611+ nextState.Phases = []Phase{}
612+ nextState.CurrentPhaseID = ""
613+ nextState.NextPhaseNumber = 1
614+ nextState.NextTaskNumber = 1
615+ phaseIDs := map[string]struct{}{}
616+ taskIDs := map[string]struct{}{}
617+ for _, phaseInput := range *params.Phases {
618+ phase, err := buildPhaseFromInput(&nextState, phaseInput, phaseIDs, taskIDs)
619+ if err != nil {
620+ return state, "Plan not replaced", err
621+ }
622+ nextState.Phases = append(nextState.Phases, phase)
623+ }
624+ nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState)
625+ return nextState, fmt.Sprintf("Replaced plan with %d phase(s)", len(nextState.Phases)), nil
626+ case actionAddPhase:
627+ phaseTitle := ""
628+ if params.PhaseTitle != nil {
629+ phaseTitle = normalizeOptionalText(*params.PhaseTitle)
630+ }
631+ if phaseTitle == "" {
632+ return state, "Phase not added", errors.New("phaseTitle is required for add_phase")
633+ }
634+ nextState.Phases = append(nextState.Phases, Phase{ID: nextPhaseID(&nextState), Title: phaseTitle, Tasks: []PhaseTask{}})
635+ if params.PhaseGoal != nil {
636+ nextState.Phases[len(nextState.Phases)-1].Goal = normalizeOptionalText(*params.PhaseGoal)
637+ }
638+ if nextState.CurrentPhaseID == "" {
639+ nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState)
640+ }
641+ return nextState, "Added phase " + phaseTitle, nil
642+ case actionUpdatePhase:
643+ phase := findPhase(nextState, params.PhaseID)
644+ if phase == nil {
645+ return state, "Phase not updated", errors.New(phaseIDError(nextState, actionUpdatePhase, params.PhaseID))
646+ }
647+ if params.PhaseTitle != nil {
648+ if phaseTitle := normalizeOptionalText(*params.PhaseTitle); phaseTitle != "" {
649+ phase.Title = phaseTitle
650+ }
651+ }
652+ if params.PhaseGoal != nil {
653+ phase.Goal = normalizeOptionalText(*params.PhaseGoal)
654+ }
655+ return nextState, "Updated phase " + phase.Title, nil
656+ case actionRemovePhase:
657+ phase := findPhase(nextState, params.PhaseID)
658+ if phase == nil {
659+ return state, "Phase not removed", errors.New(phaseIDError(nextState, actionRemovePhase, params.PhaseID))
660+ }
661+ removedID := phase.ID
662+ filtered := make([]Phase, 0, len(nextState.Phases)-1)
663+ for _, entry := range nextState.Phases {
664+ if entry.ID != removedID {
665+ filtered = append(filtered, entry)
666+ }
667+ }
668+ nextState.Phases = filtered
669+ nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState)
670+ return nextState, "Removed phase " + removedID, nil
671+ case actionAddTask:
672+ phase := findPhase(nextState, params.PhaseID)
673+ taskText := ""
674+ if params.TaskText != nil {
675+ taskText = normalizeOptionalText(*params.TaskText)
676+ }
677+ if phase == nil {
678+ return state, "Task not added", errors.New(phaseIDError(nextState, actionAddTask, params.PhaseID))
679+ }
680+ if taskText == "" {
681+ return state, "Task not added", errors.New("taskText is required for add_task")
682+ }
683+ phase.Tasks = append(phase.Tasks, PhaseTask{ID: nextTaskID(&nextState), Text: taskText})
684+ return nextState, "Added task to " + phase.Title, nil
685+ case actionUpdateTask:
686+ if params.TaskID == "" {
687+ return state, "Task not updated", errors.New("taskId is required for update_task")
688+ }
689+ _, task := findTask(nextState, params.TaskID, params.PhaseID)
690+ taskText := ""
691+ if params.TaskText != nil {
692+ taskText = normalizeOptionalText(*params.TaskText)
693+ }
694+ if task == nil {
695+ return state, "Task not updated", errors.New(taskIDError(nextState, actionUpdateTask, params.TaskID))
696+ }
697+ if taskText == "" {
698+ return state, "Task not updated", errors.New("taskText is required for update_task")
699+ }
700+ task.Text = taskText
701+ return nextState, "Updated task " + task.ID, nil
702+ case actionRemoveTask:
703+ if params.TaskID == "" {
704+ return state, "Task not removed", errors.New("taskId is required")
705+ }
706+ phase, task := findTask(nextState, params.TaskID, params.PhaseID)
707+ if task == nil {
708+ return state, "Task not removed", errors.New("taskId was not found")
709+ }
710+ filtered := make([]PhaseTask, 0, len(phase.Tasks)-1)
711+ for _, entry := range phase.Tasks {
712+ if entry.ID != params.TaskID {
713+ filtered = append(filtered, entry)
714+ }
715+ }
716+ phase.Tasks = filtered
717+ return nextState, "Removed task " + params.TaskID, nil
718+ case actionSetCurrentPhase:
719+ phase := findPhase(nextState, params.PhaseID)
720+ if phase == nil {
721+ return state, "Current phase not updated", errors.New(phaseIDError(nextState, actionSetCurrentPhase, params.PhaseID))
722+ }
723+ nextState.CurrentPhaseID = phase.ID
724+ return nextState, "Current phase set to " + phase.Title, nil
725+ case actionSetTaskChecked:
726+ if params.TaskID == "" {
727+ return state, "Task not updated", errors.New("taskId is required for set_task_checked")
728+ }
729+ phase, task := findTask(nextState, params.TaskID, params.PhaseID)
730+ if task == nil {
731+ return state, "Task not updated", errors.New(taskIDError(nextState, actionSetTaskChecked, params.TaskID))
732+ }
733+ if params.Checked == nil {
734+ return state, "Task not updated", errors.New("checked must be provided for set_task_checked")
735+ }
736+ task.Checked = *params.Checked
737+ if *params.Checked && nextState.CurrentPhaseID == phase.ID && isPhaseDone(*phase) {
738+ nextState.CurrentPhaseID = ""
739+ }
740+ return nextState, fmt.Sprintf("%s task %s", checkedWord(*params.Checked), task.ID), nil
741+ case actionSetPhaseChecked:
742+ phase := findPhase(nextState, params.PhaseID)
743+ if phase == nil {
744+ return state, "Phase not updated", errors.New(phaseIDError(nextState, actionSetPhaseChecked, params.PhaseID))
745+ }
746+ if params.Checked == nil {
747+ return state, "Phase not updated", errors.New("checked must be provided for set_phase_checked")
748+ }
749+ if len(phase.Tasks) == 0 {
750+ return state, "Phase not updated", errors.New("set_phase_checked requires the phase to have at least one task")
751+ }
752+ for i := range phase.Tasks {
753+ phase.Tasks[i].Checked = *params.Checked
754+ }
755+ if *params.Checked && nextState.CurrentPhaseID == phase.ID {
756+ nextState.CurrentPhaseID = ""
757+ }
758+ if !*params.Checked {
759+ nextState.CurrentPhaseID = phase.ID
760+ }
761+ return nextState, fmt.Sprintf("%s phase %s", checkedWord(*params.Checked), phase.Title), nil
762+ case actionClear:
763+ return createEmptyState(), "Cleared stored spec and phased checklist", nil
764+ default:
765+ return state, "Unsupported action", errors.New("unknown action")
766+ }
767+}
768+
769+func checkedWord(checked bool) string {
770+ if checked {
771+ return "Checked"
772+ }
773+ return "Unchecked"
774+}
775+
776+const (
777+ compactVisibleIncompleteTasks = 4
778+ turnContextVisibleIncompleteTasks = 3
779+ maxTurnContextChars = 2048
780+)
781+
782+func getIncompleteTasks(phase Phase) []PhaseTask {
783+ incomplete := make([]PhaseTask, 0)
784+ for _, task := range phase.Tasks {
785+ if !task.Checked {
786+ incomplete = append(incomplete, task)
787+ }
788+ }
789+ return incomplete
790+}
791+
792+func formatRemainingTaskCount(count int) string {
793+ if count == 1 {
794+ return "1 remaining"
795+ }
796+ return fmt.Sprintf("%d remaining", count)
797+}
798+
799+func formatPhaseTitle(phase Phase) string { return truncatePlain(displaySingleLine(phase.Title), 120) }
800+func formatTaskText(task PhaseTask) string { return truncatePlain(displaySingleLine(task.Text), 180) }
801+
802+func formatTurnContextID(id string) string { return truncatePlain(singleLine(id), 96) }
803+
804+func appendIncompleteTaskLines(lines *[]string, tasks []PhaseTask, visibleCount int, indent string) {
805+ visible := tasks
806+ if len(visible) > visibleCount {
807+ visible = visible[:visibleCount]
808+ }
809+ for _, task := range visible {
810+ *lines = append(*lines, fmt.Sprintf("%s[ ] %s [%s]", indent, formatTaskText(task), formatTurnContextID(task.ID)))
811+ }
812+ if omitted := len(tasks) - len(visible); omitted > 0 {
813+ *lines = append(*lines, fmt.Sprintf("%s... %d more incomplete task(s) omitted; call tasked_phases get_status for the full checklist.", indent, omitted))
814+ }
815+}
816+
817+func buildSummary(state PlanState) string {
818+ if !hasStoredPlan(state) {
819+ return "No spec or phased checklist has been stored yet."
820+ }
821+ lines := make([]string, 0)
822+ if state.Spec != "" {
823+ lines = append(lines, "Spec:", sanitizeDisplayText(state.Spec), "")
824+ }
825+ if len(state.Phases) == 0 {
826+ return strings.Join(append(lines, "Phases: (none)"), "\n")
827+ }
828+ done, total := getPlanProgress(state)
829+ lines = append(lines, fmt.Sprintf("Plan progress: %d/%d tasks checked", done, total))
830+ if isPlanClosed(state) {
831+ closedSummary := state.ClosedSummary
832+ if closedSummary == "" {
833+ closedSummary = "all phases complete"
834+ }
835+ lines = append(lines, "Plan closed: "+displaySingleLine(closedSummary))
836+ }
837+ lines = append(lines, "Phases:")
838+ for _, phase := range state.Phases {
839+ phaseDone, phaseTotal := getPhaseProgress(phase)
840+ currentMarker := " "
841+ if phase.ID == state.CurrentPhaseID {
842+ currentMarker = ">"
843+ }
844+ phaseMarker := "[ ]"
845+ if isPhaseDone(phase) {
846+ phaseMarker = "[x]"
847+ }
848+ goalSuffix := ""
849+ if phase.Goal != "" {
850+ goalSuffix = " - " + displaySingleLine(phase.Goal)
851+ }
852+ lines = append(lines, fmt.Sprintf("%s %s %s [%s] (%d/%d)%s", currentMarker, phaseMarker, formatPhaseTitle(phase), formatTurnContextID(phase.ID), phaseDone, phaseTotal, goalSuffix))
853+ if len(phase.Tasks) == 0 {
854+ lines = append(lines, " - No tasks yet")
855+ continue
856+ }
857+ for _, task := range phase.Tasks {
858+ marker := "[ ]"
859+ if task.Checked {
860+ marker = "[x]"
861+ }
862+ lines = append(lines, fmt.Sprintf(" %s %s [%s]", marker, formatTaskText(task), formatTurnContextID(task.ID)))
863+ }
864+ }
865+ return strings.Join(lines, "\n")
866+}
867+
868+func buildCompactSummary(state PlanState) string {
869+ if !hasStoredPlan(state) {
870+ return "No spec or phased checklist has been stored yet."
871+ }
872+ lines := make([]string, 0)
873+ if state.Spec != "" {
874+ lines = append(lines, "Spec: "+truncatePlain(displaySingleLine(state.Spec), 220))
875+ }
876+ done, total := getPlanProgress(state)
877+ lines = append(lines, fmt.Sprintf("Progress: %d/%d tasks checked", done, total))
878+ if isPlanClosed(state) {
879+ closedSummary := state.ClosedSummary
880+ if closedSummary == "" {
881+ closedSummary = "all phases complete"
882+ }
883+ lines = append(lines, "Plan closed: "+displaySingleLine(closedSummary))
884+ return strings.Join(lines, "\n")
885+ }
886+ if currentPhase := getActivePhase(state); currentPhase != nil {
887+ incompleteTasks := getIncompleteTasks(*currentPhase)
888+ goalSuffix := ""
889+ if currentPhase.Goal != "" {
890+ goalSuffix = " - " + truncatePlain(displaySingleLine(currentPhase.Goal), 120)
891+ }
892+ lines = append(lines, fmt.Sprintf("Current phase: %s [%s] (%s)%s", formatPhaseTitle(*currentPhase), formatTurnContextID(currentPhase.ID), formatRemainingTaskCount(len(incompleteTasks)), goalSuffix))
893+ if len(incompleteTasks) > 0 {
894+ lines = append(lines, "Incomplete tasks:")
895+ appendIncompleteTaskLines(&lines, incompleteTasks, compactVisibleIncompleteTasks, " ")
896+ }
897+ } else if len(state.Phases) > 0 {
898+ lines = append(lines, "Current phase: none")
899+ }
900+ return strings.Join(lines, "\n")
901+}
902+
903+// buildTurnContext returns the small amount of plan state the model needs at
904+// the start of a turn. It deliberately excludes the spec, completed phases,
905+// and future phases; get_status and /phases remain the full-state views.
906+func buildTurnContext(state PlanState) string {
907+ if !hasStoredPlan(state) {
908+ return ""
909+ }
910+ if isPlanClosed(state) {
911+ return truncatePlain("Tasked phases focus: the current plan is complete and closed. Use clear before creating a plan for unrelated work.", maxTurnContextChars)
912+ }
913+ phase := getActivePhase(state)
914+ if phase == nil {
915+ return ""
916+ }
917+ lines := []string{
918+ "Tasked phases focus:",
919+ fmt.Sprintf("Current phase: %s [%s]", formatPhaseTitle(*phase), formatTurnContextID(phase.ID)),
920+ }
921+ if phase.Goal != "" {
922+ lines = append(lines, "Goal: "+truncatePlain(displaySingleLine(phase.Goal), 160))
923+ }
924+ incompleteTasks := getIncompleteTasks(*phase)
925+ if len(incompleteTasks) == 0 {
926+ lines = append(lines, "No incomplete tasks remain in this phase; update the plan or advance the phase as appropriate.")
927+ return truncatePlain(strings.Join(lines, "\n"), maxTurnContextChars)
928+ }
929+ lines = append(lines, "Incomplete tasks:")
930+ appendIncompleteTaskLines(&lines, incompleteTasks, turnContextVisibleIncompleteTasks, " ")
931+ return truncatePlain(strings.Join(lines, "\n"), maxTurnContextChars)
932+}
933+
934+func shouldReturnFullSummary(action string, err error) bool {
935+ return err != nil || action == actionGetStatus || action == actionReplacePlan || action == actionClear
936+}
937+
938+func buildToolResultText(action string, state PlanState, headline string, err error) string {
939+ summary := buildCompactSummary(state)
940+ if shouldReturnFullSummary(action, err) {
941+ summary = buildSummary(state)
942+ }
943+ if err != nil {
944+ return "Error: " + err.Error() + "\n\n" + summary
945+ }
946+ return headline + "\n\n" + summary
947+}
948+
949+func buildViewLines(state PlanState) []string {
950+ lines := []string{"", " Tasked phases ", ""}
951+ if !hasStoredPlan(state) {
952+ return append(lines,
953+ " No spec or phases stored yet.",
954+ "",
955+ " Ask the agent to create a spec and phased checklist.",
956+ "",
957+ " Press Escape to close",
958+ )
959+ }
960+ if state.Spec != "" {
961+ lines = append(lines, " Spec")
962+ for _, specLine := range strings.Split(sanitizeDisplayText(state.Spec), "\n") {
963+ lines = append(lines, " "+specLine)
964+ }
965+ lines = append(lines, "")
966+ }
967+ done, total := getPlanProgress(state)
968+ lines = append(lines, fmt.Sprintf(" Progress: %d/%d tasks checked", done, total))
969+ if isPlanClosed(state) {
970+ closedSummary := state.ClosedSummary
971+ if closedSummary == "" {
972+ closedSummary = "all phases complete"
973+ }
974+ lines = append(lines,
975+ " Closed: "+displaySingleLine(closedSummary),
976+ " New work should create a fresh plan instead of extending this one.",
977+ )
978+ }
979+ lines = append(lines, "")
980+ for _, phase := range state.Phases {
981+ phaseDone, phaseTotal := getPhaseProgress(phase)
982+ marker := "[ ]"
983+ if isPhaseDone(phase) {
984+ marker = "[x]"
985+ }
986+ currentTitle := phase.Title
987+ if phase.ID == state.CurrentPhaseID {
988+ currentTitle = "> " + currentTitle
989+ }
990+ lines = append(lines, fmt.Sprintf(" %s %s [%s] (%d/%d)", marker, displaySingleLine(currentTitle), formatTurnContextID(phase.ID), phaseDone, phaseTotal))
991+ if phase.Goal != "" {
992+ lines = append(lines, " "+displaySingleLine(phase.Goal))
993+ }
994+ if len(phase.Tasks) == 0 {
995+ lines = append(lines, " - No tasks yet")
996+ continue
997+ }
998+ for _, task := range phase.Tasks {
999+ taskMarker := "[ ]"
1000+ if task.Checked {
1001+ taskMarker = "[x]"
1002+ }
1003+ lines = append(lines, fmt.Sprintf(" %s %s [%s]", taskMarker, formatTaskText(task), formatTurnContextID(task.ID)))
1004+ }
1005+ lines = append(lines, "")
1006+ }
1007+ return append(lines, " Press Escape to close")
1008+}
1009+
1010+type stateStore struct {
1011+ mu sync.Mutex
1012+ state PlanState
1013+ path string
1014+ loaded bool
1015+ loadErr error
1016+ // pathErr records a persistence fault independently of the active
1017+ // session state. Activating a session must never clear this fault.
1018+ pathErr error
1019+}
1020+
1021+func newStateStore() *stateStore {
1022+ return &stateStore{state: createEmptyState()}
1023+}
1024+
1025+func projectStatePath(dataDir, cwd string) string {
1026+ key := sha256.Sum256([]byte(cwd))
1027+ return filepath.Join(dataDir, "projects", hex.EncodeToString(key[:])+".json")
1028+}
1029+
1030+func (s *stateStore) load(dataDir, cwd string) error {
1031+ s.mu.Lock()
1032+ defer s.mu.Unlock()
1033+ if s.loaded {
1034+ return s.loadErr
1035+ }
1036+ s.loaded = true
1037+ s.state = createEmptyState()
1038+ if strings.TrimSpace(dataDir) == "" {
1039+ s.loadErr = errors.New("host did not provide extension data_dir")
1040+ s.pathErr = s.loadErr
1041+ return s.loadErr
1042+ }
1043+ s.path = projectStatePath(dataDir, cwd)
1044+ if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
1045+ s.loadErr = fmt.Errorf("create state directory: %w", err)
1046+ s.pathErr = s.loadErr
1047+ return s.loadErr
1048+ }
1049+ // #nosec G302 -- this is a directory; 0700 is the minimum mode that permits traversal.
1050+ if err := os.Chmod(filepath.Dir(s.path), 0o700); err != nil {
1051+ s.loadErr = fmt.Errorf("restrict state directory: %w", err)
1052+ s.pathErr = s.loadErr
1053+ return s.loadErr
1054+ }
1055+ data, err := os.ReadFile(s.path)
1056+ if os.IsNotExist(err) {
1057+ return nil
1058+ }
1059+ if err != nil {
1060+ s.loadErr = fmt.Errorf("read state: %w", err)
1061+ s.pathErr = s.loadErr
1062+ return s.loadErr
1063+ }
1064+ if err := os.Chmod(s.path, 0o600); err != nil {
1065+ s.loadErr = fmt.Errorf("restrict state file: %w", err)
1066+ s.pathErr = s.loadErr
1067+ return s.loadErr
1068+ }
1069+ if len(data) == 0 {
1070+ return nil
1071+ }
1072+ var state PlanState
1073+ if err := json.Unmarshal(data, &state); err != nil {
1074+ s.loadErr = fmt.Errorf("parse state: %w", err)
1075+ s.pathErr = s.loadErr
1076+ return s.loadErr
1077+ }
1078+ s.state = ensureState(state)
1079+ return nil
1080+}
1081+
1082+// activateSession replaces the project fallback with the opaque snapshot
1083+// supplied by zot for the active session branch. An empty snapshot means the
1084+// branch has no extension state yet and therefore starts clean.
1085+func (s *stateStore) activateSession(raw json.RawMessage) error {
1086+ s.mu.Lock()
1087+ defer s.mu.Unlock()
1088+ if !s.loaded {
1089+ return errors.New("state has not been loaded from hello")
1090+ }
1091+ if s.pathErr != nil {
1092+ return s.pathErr
1093+ }
1094+ if len(raw) == 0 || strings.TrimSpace(string(raw)) == "null" {
1095+ s.state = createEmptyState()
1096+ s.loadErr = nil
1097+ return nil
1098+ }
1099+ var state PlanState
1100+ if err := json.Unmarshal(raw, &state); err != nil {
1101+ return fmt.Errorf("parse session state: %w", err)
1102+ }
1103+ s.state = ensureState(state)
1104+ s.loadErr = nil
1105+ return nil
1106+}
1107+
1108+func (s *stateStore) snapshot() (PlanState, error) {
1109+ s.mu.Lock()
1110+ defer s.mu.Unlock()
1111+ if !s.loaded {
1112+ return PlanState{}, errors.New("state has not been loaded from hello")
1113+ }
1114+ if s.loadErr != nil {
1115+ return cloneState(s.state), s.loadErr
1116+ }
1117+ return cloneState(s.state), nil
1118+}
1119+
1120+func (s *stateStore) transact(fn func(PlanState) (PlanState, string, error)) (PlanState, string, error) {
1121+ s.mu.Lock()
1122+ defer s.mu.Unlock()
1123+ if !s.loaded {
1124+ return PlanState{}, "", errors.New("state has not been loaded from hello")
1125+ }
1126+ if s.pathErr != nil {
1127+ return cloneState(s.state), "", s.pathErr
1128+ }
1129+ if s.loadErr != nil {
1130+ return cloneState(s.state), "", s.loadErr
1131+ }
1132+ current := cloneState(s.state)
1133+ next, headline, err := fn(current)
1134+ if err != nil {
1135+ return current, headline, err
1136+ }
1137+ next = ensureState(next)
1138+ if err := writeJSONAtomic(s.path, next); err != nil {
1139+ return current, headline, fmt.Errorf("persist state: %w", err)
1140+ }
1141+ s.state = next
1142+ return cloneState(next), headline, nil
1143+}
1144+
1145+func writeJSONAtomic(path string, value any) error {
1146+ if strings.TrimSpace(path) == "" {
1147+ return errors.New("state path is empty")
1148+ }
1149+ data, err := json.MarshalIndent(value, "", " ")
1150+ if err != nil {
1151+ return err
1152+ }
1153+ data = append(data, '\n')
1154+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
1155+ return err
1156+ }
1157+ tmp, err := os.CreateTemp(filepath.Dir(path), ".tasked-phases-*.tmp")
1158+ if err != nil {
1159+ return err
1160+ }
1161+ tmpName := tmp.Name()
1162+ defer os.Remove(tmpName)
1163+ if err := tmp.Chmod(0o600); err != nil {
1164+ _ = tmp.Close()
1165+ return err
1166+ }
1167+ if _, err := tmp.Write(data); err != nil {
1168+ _ = tmp.Close()
1169+ return err
1170+ }
1171+ if err := tmp.Sync(); err != nil {
1172+ _ = tmp.Close()
1173+ return err
1174+ }
1175+ if err := tmp.Close(); err != nil {
1176+ return err
1177+ }
1178+ if err := os.Rename(tmpName, path); err != nil {
1179+ return err
1180+ }
1181+ return nil
1182+}
A examples/extensions/tasked-phases/state_test.go +463 -0
@@ -0,0 +1,463 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "strings"
9+ "sync"
10+ "testing"
11+)
12+
13+func stringPtr(value string) *string { return &value }
14+func boolPtr(value bool) *bool { return &value }
15+
16+func transition(t *testing.T, state PlanState, params toolArgs) PlanState {
17+ t.Helper()
18+ next, _, err := applyAction(ensureState(state), params)
19+ if err != nil {
20+ t.Fatalf("apply %s: %v", params.Action, err)
21+ }
22+ return ensureState(next)
23+}
24+
25+func sampleState() PlanState {
26+ return ensureState(PlanState{
27+ Version: 1,
28+ Spec: "Ship compact tasked phases output without losing source-of-truth state.",
29+ Phases: []Phase{
30+ {
31+ ID: "phase-1",
32+ Title: "Finished discovery",
33+ Goal: "Understand the old behavior",
34+ Tasks: []PhaseTask{
35+ {ID: "task-1", Text: "Completed discovery task", Checked: true},
36+ {ID: "task-2", Text: "Completed contract task", Checked: true},
37+ },
38+ },
39+ {
40+ ID: "phase-2",
41+ Title: "Implement compact output",
42+ Goal: "Reduce repeated context",
43+ Tasks: []PhaseTask{
44+ {ID: "task-3", Text: "Completed implementation task", Checked: true},
45+ {ID: "task-4", Text: "Remaining implementation task", Checked: false},
46+ },
47+ },
48+ },
49+ CurrentPhaseID: "phase-2",
50+ NextPhaseNumber: 3,
51+ NextTaskNumber: 5,
52+ })
53+}
54+
55+func TestTransitionsGenerateIDsAndSelectCurrentPhase(t *testing.T) {
56+ state := transition(t, createEmptyState(), toolArgs{
57+ Action: actionReplacePlan,
58+ Phases: &[]PhaseInput{
59+ {Title: "First", Tasks: &[]TaskInput{{Text: "one"}}},
60+ {Title: "Second", Tasks: &[]TaskInput{{Text: "two"}}},
61+ },
62+ })
63+ if got := state.Phases[0].ID; got != "phase-1" {
64+ t.Fatalf("first generated phase id = %q", got)
65+ }
66+ if got := state.Phases[1].ID; got != "phase-2" {
67+ t.Fatalf("second generated phase id = %q", got)
68+ }
69+ if got := state.Phases[0].Tasks[0].ID; got != "task-1" {
70+ t.Fatalf("first generated task id = %q", got)
71+ }
72+ if state.CurrentPhaseID != "phase-1" || state.NextPhaseNumber != 3 || state.NextTaskNumber != 3 {
73+ t.Fatalf("generated counters/current phase = %q, %d, %d", state.CurrentPhaseID, state.NextPhaseNumber, state.NextTaskNumber)
74+ }
75+
76+ state = transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(true)})
77+ if state.CurrentPhaseID != "phase-2" {
78+ t.Fatalf("current phase did not advance to the first incomplete phase: %q", state.CurrentPhaseID)
79+ }
80+ state = transition(t, state, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr("Third")})
81+ if got := state.Phases[2].ID; got != "phase-3" {
82+ t.Fatalf("added phase id = %q", got)
83+ }
84+ state = transition(t, state, toolArgs{Action: actionAddTask, PhaseID: "phase-3", TaskText: stringPtr("three")})
85+ if got := state.Phases[2].Tasks[0].ID; got != "task-3" {
86+ t.Fatalf("added task id = %q", got)
87+ }
88+}
89+
90+func TestReplacePlanRejectsDuplicateIDs(t *testing.T) {
91+ state := createEmptyState()
92+ _, _, err := applyAction(state, toolArgs{
93+ Action: actionReplacePlan,
94+ Phases: &[]PhaseInput{
95+ {ID: stringPtr("phase-1"), Title: "First"},
96+ {ID: stringPtr("phase-1"), Title: "Duplicate"},
97+ },
98+ })
99+ if err == nil || !strings.Contains(err.Error(), "duplicate phase id") {
100+ t.Fatalf("duplicate phase id error = %v", err)
101+ }
102+
103+ _, _, err = applyAction(state, toolArgs{
104+ Action: actionReplacePlan,
105+ Phases: &[]PhaseInput{
106+ {Title: "First", Tasks: &[]TaskInput{{ID: stringPtr("task-1"), Text: "one"}}},
107+ {Title: "Second", Tasks: &[]TaskInput{{ID: stringPtr("task-1"), Text: "duplicate"}}},
108+ },
109+ })
110+ if err == nil || !strings.Contains(err.Error(), "duplicate task id") {
111+ t.Fatalf("duplicate task id error = %v", err)
112+ }
113+}
114+
115+func TestClosedPlanRulesAndReopen(t *testing.T) {
116+ state := transition(t, createEmptyState(), toolArgs{
117+ Action: actionReplacePlan,
118+ Phases: &[]PhaseInput{{Title: "Done", Tasks: &[]TaskInput{{Text: "finished", Checked: boolPtr(true)}}}},
119+ })
120+ if !isPlanClosed(state) || state.CurrentPhaseID != "" {
121+ t.Fatalf("complete plan was not closed: %+v", state)
122+ }
123+ if !strings.Contains(buildSummary(state), "Plan closed: Completed 1/1 tasks across 1 phase(s).") {
124+ t.Fatalf("closed summary missing: %s", buildSummary(state))
125+ }
126+
127+ if _, _, err := applyAction(state, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr("new")}); err == nil || !strings.Contains(err.Error(), "already complete and closed") {
128+ t.Fatalf("add to closed plan error = %v", err)
129+ }
130+ if _, _, err := applyAction(state, toolArgs{Action: actionReplacePlan, Phases: &[]PhaseInput{}}); err == nil {
131+ t.Fatal("replace_plan unexpectedly bypassed closed-plan guard")
132+ }
133+
134+ state = transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(false)})
135+ if isPlanClosed(state) || state.CurrentPhaseID != "phase-1" || state.Phases[0].Tasks[0].Checked {
136+ t.Fatalf("unchecked reopen produced %+v", state)
137+ }
138+
139+ state = transition(t, state, toolArgs{Action: actionSetSpec, Spec: stringPtr("new work")})
140+ if len(state.Phases) != 1 {
141+ t.Fatalf("set_spec on reopened plan unexpectedly cleared phases")
142+ }
143+ closed := transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(true)})
144+ if !isPlanClosed(closed) {
145+ t.Fatal("plan did not close again")
146+ }
147+ state = transition(t, closed, toolArgs{Action: actionSetSpec, Spec: stringPtr("fresh work")})
148+ if len(state.Phases) != 0 || state.Spec != "fresh work" || state.NextPhaseNumber != 1 || state.NextTaskNumber != 1 {
149+ t.Fatalf("set_spec did not restart a closed plan: %+v", state)
150+ }
151+ state = transition(t, state, toolArgs{Action: actionReplacePlan, Phases: &[]PhaseInput{{Title: "Fresh"}}})
152+ if len(state.Phases) != 1 || isPlanClosed(state) {
153+ t.Fatalf("replace after set_spec failed: %+v", state)
154+ }
155+}
156+
157+func TestPhaseLookupAcceptsRawBracketedSummaryAndUniqueTitle(t *testing.T) {
158+ state := sampleState()
159+ for _, input := range []string{
160+ "phase-2",
161+ "[phase-2]",
162+ "Implement compact output [phase-2]",
163+ "Implement compact output [phase-2] (1 remaining) - Reduce repeated context",
164+ "Implement compact output",
165+ } {
166+ if phase := findPhase(state, input); phase == nil || phase.ID != "phase-2" {
167+ t.Fatalf("findPhase(%q) = %#v", input, phase)
168+ }
169+ }
170+ if findPhase(state, "missing") != nil || findPhase(state, "") != nil {
171+ t.Fatal("missing phase lookup unexpectedly matched")
172+ }
173+ duplicate := cloneState(state)
174+ duplicate.Phases[0].Title = "Duplicate"
175+ duplicate.Phases[1].Title = "Duplicate"
176+ if findPhase(duplicate, "Duplicate") != nil {
177+ t.Fatal("ambiguous title unexpectedly matched")
178+ }
179+ if phase := findPhase(duplicate, "phase-2"); phase == nil || phase.ID != "phase-2" {
180+ t.Fatal("raw id did not win over duplicate title")
181+ }
182+}
183+
184+func TestSummariesAreFullOrCompactAsAppropriate(t *testing.T) {
185+ state := sampleState()
186+ compact := buildToolResultText(actionSetTaskChecked, state, "Checked task task-3", nil)
187+ if !strings.Contains(compact, "Progress: 3/4 tasks checked") || !strings.Contains(compact, "[ ] Remaining implementation task [task-4]") {
188+ t.Fatalf("compact summary omitted useful active state: %s", compact)
189+ }
190+ if strings.Contains(compact, "Phases:") || strings.Contains(compact, "Completed discovery task") {
191+ t.Fatalf("compact summary included full checklist: %s", compact)
192+ }
193+ full := buildToolResultText(actionGetStatus, state, "Current phased plan status", nil)
194+ if !strings.Contains(full, "Phases:") || !strings.Contains(full, "Completed discovery task [task-1]") {
195+ t.Fatalf("full summary omitted checklist: %s", full)
196+ }
197+ if !strings.Contains(buildSummary(state), "[phase-2] (1/2) - Reduce repeated context") {
198+ t.Fatal("phase goal/progress missing from full summary")
199+ }
200+}
201+
202+func TestTurnContextContainsOnlyCurrentFocus(t *testing.T) {
203+ state := sampleState()
204+ state.Phases[1].Tasks = append(state.Phases[1].Tasks,
205+ PhaseTask{ID: "task-5", Text: "Next focused task"},
206+ PhaseTask{ID: "task-6", Text: "Another focused task"},
207+ PhaseTask{ID: "task-7", Text: "Deferred focused task"},
208+ )
209+ state.Phases = append(state.Phases, Phase{
210+ ID: "phase-3",
211+ Title: "Future phase",
212+ Tasks: []PhaseTask{{ID: "task-8", Text: "Future task"}},
213+ })
214+ context := buildTurnContext(state)
215+ for _, want := range []string{
216+ "Tasked phases focus:",
217+ "Current phase: Implement compact output [phase-2]",
218+ "Goal: Reduce repeated context",
219+ "[ ] Remaining implementation task [task-4]",
220+ "[ ] Next focused task [task-5]",
221+ "[ ] Another focused task [task-6]",
222+ } {
223+ if !strings.Contains(context, want) {
224+ t.Errorf("turn context missing %q: %s", want, context)
225+ }
226+ }
227+ for _, unwanted := range []string{
228+ "Ship compact tasked phases output",
229+ "Finished discovery",
230+ "Completed discovery task",
231+ "Future phase",
232+ "Future task",
233+ "Deferred focused task [task-7]",
234+ } {
235+ if strings.Contains(context, unwanted) {
236+ t.Errorf("turn context included %q: %s", unwanted, context)
237+ }
238+ }
239+}
240+
241+func TestTurnContextIsBoundedForUntrustedIdentifiers(t *testing.T) {
242+ state := sampleState()
243+ state.Phases[1].ID = strings.Repeat("phase-id-", 1000)
244+ state.CurrentPhaseID = state.Phases[1].ID
245+ state.Phases[1].Tasks[0].ID = strings.Repeat("task-id-", 1000)
246+
247+ context := buildTurnContext(state)
248+ if got := len([]rune(context)); got > maxTurnContextChars {
249+ t.Fatalf("turn context length = %d; want at most %d", got, maxTurnContextChars)
250+ }
251+ if !strings.Contains(context, "Tasked phases focus:") || !strings.Contains(context, "Current phase:") || !strings.Contains(context, "Goal: Reduce repeated context") {
252+ t.Fatalf("bounded turn context lost its useful focus: %q", context)
253+ }
254+ if !strings.Contains(context, "Remaining implementation task") {
255+ t.Fatalf("per-field id bounding did not leave room for task lines: %q", context)
256+ }
257+ for _, line := range strings.Split(context, "\n") {
258+ if got := len([]rune(line)); got > 400 {
259+ t.Fatalf("unbounded field produced a %d-rune line: %q", got, line)
260+ }
261+ }
262+}
263+
264+func TestDisplayRenderingSanitizesUntrustedTextWithoutMutatingState(t *testing.T) {
265+ state := sampleState()
266+ state.Spec = "spec \x1b[31mred\x1b[0m\x00"
267+ state.Phases[1].Title = "title \x1b]0;evil\a"
268+ state.Phases[1].Goal = "goal\r\t\x1b[2J"
269+ state.Phases[1].Tasks[0].Text = "task \x1b[?25l\x7f"
270+ original := state
271+
272+ for _, rendered := range []string{
273+ buildSummary(state), buildCompactSummary(state), buildTurnContext(state), strings.Join(buildViewLines(state), "\n"),
274+ } {
275+ if strings.Contains(rendered, "\x1b") || strings.ContainsRune(rendered, '\x00') || strings.ContainsRune(rendered, '\x7f') || strings.ContainsRune(rendered, '\r') || strings.ContainsRune(rendered, '\t') {
276+ t.Fatalf("rendered text contains terminal/control injection: %q", rendered)
277+ }
278+ }
279+ if state.Spec != original.Spec || state.Phases[1].Title != original.Phases[1].Title || state.Phases[1].Tasks[0].Text != original.Phases[1].Tasks[0].Text {
280+ t.Fatal("render sanitization mutated persisted plan state")
281+ }
282+}
283+
284+func TestTaskErrorsDistinguishMissingAndUnknownIDs(t *testing.T) {
285+ state := sampleState()
286+ for _, action := range []string{actionUpdateTask, actionSetTaskChecked} {
287+ if _, _, err := applyAction(state, toolArgs{Action: action}); err == nil || !strings.Contains(err.Error(), "taskId is required") {
288+ t.Fatalf("%s missing task id error = %v", action, err)
289+ }
290+ if _, _, err := applyAction(state, toolArgs{Action: action, TaskID: "missing"}); err == nil || !strings.Contains(err.Error(), "Valid incomplete task ids: task-4") {
291+ t.Fatalf("%s unknown task id error = %v", action, err)
292+ }
293+ }
294+}
295+
296+func TestPanelRendersSpecGoalsTasksAndEmptyState(t *testing.T) {
297+ empty := strings.Join(buildViewLines(createEmptyState()), "\n")
298+ if !strings.Contains(empty, "No spec or phases stored yet.") || !strings.Contains(empty, "Press Escape to close") {
299+ t.Fatalf("empty panel = %s", empty)
300+ }
301+ panel := strings.Join(buildViewLines(sampleState()), "\n")
302+ for _, want := range []string{
303+ "Tasked phases",
304+ "Spec",
305+ "Progress: 3/4 tasks checked",
306+ "[x] Completed discovery task [task-1]",
307+ "[ ] Remaining implementation task [task-4]",
308+ "Understand the old behavior",
309+ "Press Escape to close",
310+ } {
311+ if !strings.Contains(panel, want) {
312+ t.Errorf("panel missing %q: %s", want, panel)
313+ }
314+ }
315+}
316+
317+func TestStatePersistenceIsProjectScopedAtomicAndRestrictive(t *testing.T) {
318+ dataDir := t.TempDir()
319+ first := newStateStore()
320+ if err := first.load(dataDir, "/projects/one"); err != nil {
321+ t.Fatal(err)
322+ }
323+ state, _, err := first.transact(func(current PlanState) (PlanState, string, error) {
324+ return applyAction(current, toolArgs{
325+ Action: actionReplacePlan,
326+ Phases: &[]PhaseInput{{Title: "Persisted", Tasks: &[]TaskInput{{Text: "remember me"}}}},
327+ })
328+ })
329+ if err != nil {
330+ t.Fatal(err)
331+ }
332+ if state.Phases[0].ID != "phase-1" {
333+ t.Fatalf("persisted state = %+v", state)
334+ }
335+ if runtime.GOOS != "windows" {
336+ info, err := os.Stat(first.path)
337+ if err != nil {
338+ t.Fatal(err)
339+ }
340+ if got := info.Mode().Perm(); got != 0o600 {
341+ t.Fatalf("state file mode = %o, want 600", got)
342+ }
343+ if got := (mustStat(t, filepath.Dir(first.path))).Mode().Perm(); got != 0o700 {
344+ t.Fatalf("state directory mode = %o, want 700", got)
345+ }
346+ }
347+
348+ second := newStateStore()
349+ if err := second.load(dataDir, "/projects/one"); err != nil {
350+ t.Fatal(err)
351+ }
352+ loaded, err := second.snapshot()
353+ if err != nil {
354+ t.Fatal(err)
355+ }
356+ if loaded.Phases[0].Tasks[0].Text != "remember me" {
357+ t.Fatalf("loaded state = %+v", loaded)
358+ }
359+ otherProject := newStateStore()
360+ if err := otherProject.load(dataDir, "/projects/two"); err != nil {
361+ t.Fatal(err)
362+ }
363+ other, err := otherProject.snapshot()
364+ if err != nil {
365+ t.Fatal(err)
366+ }
367+ if hasStoredPlan(other) {
368+ t.Fatal("state leaked between project CWDs")
369+ }
370+ files, err := os.ReadDir(filepath.Dir(first.path))
371+ if err != nil {
372+ t.Fatal(err)
373+ }
374+ for _, file := range files {
375+ if strings.HasSuffix(file.Name(), ".tmp") {
376+ t.Fatalf("temporary file left behind: %s", file.Name())
377+ }
378+ }
379+
380+ var decoded PlanState
381+ data, err := os.ReadFile(first.path)
382+ if err != nil {
383+ t.Fatal(err)
384+ }
385+ if err := json.Unmarshal(data, &decoded); err != nil {
386+ t.Fatal(err)
387+ }
388+ if decoded.Phases[0].Tasks[0].ID != "task-1" {
389+ t.Fatalf("on-disk state = %+v", decoded)
390+ }
391+}
392+
393+func TestStateStoreDoesNotOverwriteCorruptStateAfterSessionActivation(t *testing.T) {
394+ dataDir := t.TempDir()
395+ cwd := "/projects/corrupt"
396+ path := projectStatePath(dataDir, cwd)
397+ original := []byte("not valid plan json")
398+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
399+ t.Fatal(err)
400+ }
401+ if err := os.WriteFile(path, original, 0o600); err != nil {
402+ t.Fatal(err)
403+ }
404+
405+ store := newStateStore()
406+ if err := store.load(dataDir, cwd); err == nil {
407+ t.Fatal("corrupt state unexpectedly loaded")
408+ }
409+ if err := store.activateSession(nil); err == nil {
410+ t.Fatal("session activation cleared the corrupt-state fault")
411+ }
412+ if _, _, err := store.transact(func(current PlanState) (PlanState, string, error) {
413+ return applyAction(current, toolArgs{Action: actionClear})
414+ }); err == nil {
415+ t.Fatal("transaction unexpectedly wrote through a corrupt-state fault")
416+ }
417+ got, err := os.ReadFile(path)
418+ if err != nil {
419+ t.Fatal(err)
420+ }
421+ if string(got) != string(original) {
422+ t.Fatalf("corrupt state bytes changed from %q to %q", original, got)
423+ }
424+}
425+
426+func TestStateStoreSerializesConcurrentTransitions(t *testing.T) {
427+ store := newStateStore()
428+ if err := store.load(t.TempDir(), "/projects/concurrent"); err != nil {
429+ t.Fatal(err)
430+ }
431+ const count = 12
432+ var wg sync.WaitGroup
433+ for i := 0; i < count; i++ {
434+ wg.Add(1)
435+ go func(i int) {
436+ defer wg.Done()
437+ title := "phase " + string(rune('a'+i))
438+ _, _, err := store.transact(func(current PlanState) (PlanState, string, error) {
439+ return applyAction(current, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr(title)})
440+ })
441+ if err != nil {
442+ t.Errorf("concurrent transition: %v", err)
443+ }
444+ }(i)
445+ }
446+ wg.Wait()
447+ state, err := store.snapshot()
448+ if err != nil {
449+ t.Fatal(err)
450+ }
451+ if len(state.Phases) != count {
452+ t.Fatalf("serialized phase count = %d, want %d", len(state.Phases), count)
453+ }
454+}
455+
456+func mustStat(t *testing.T, path string) os.FileInfo {
457+ t.Helper()
458+ info, err := os.Stat(path)
459+ if err != nil {
460+ t.Fatal(err)
461+ }
462+ return info
463+}
A examples/skills/tasked-phases/SKILL.md +33 -0
@@ -0,0 +1,33 @@
1+---
2+name: tasked-phases
3+description: Use when the user wants a spec turned into phased work with checklist tasks tracked by the tasked_phases tool, or when ongoing work should be tracked phase by phase.
4+---
5+
6+# Tasked Phases
7+
8+Use the `tasked_phases` tool as the source of truth for the spec, phases, current focus, and checklist progress.
9+
10+## Workflow
11+
12+1. Clarify missing requirements when the spec is ambiguous.
13+2. Save the accepted spec with `tasked_phases` using `set_spec`.
14+3. Create or materially revise the plan with `replace_plan`.
15+4. Keep phases small and tasks concrete enough to check off.
16+5. Set the focus with `set_current_phase` and pass the target `phaseId`.
17+6. Call `set_task_checked` immediately after each checklist task is completed.
18+7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task.
19+8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` to discard the plan, or call `set_spec` immediately followed by `replace_plan` to record a new spec and a new set of phases.
20+
21+## Planning rules
22+
23+- Prefer 3–7 phases unless the task is tiny.
24+- Each task should describe an observable outcome.
25+- Avoid vague tasks such as “work on this” or “finish implementation”.
26+- Keep the stored spec and plan concise and operational.
27+
28+## Tool guidance
29+
30+- Use `get_status` before relying on remembered state or revising a long-running plan.
31+- Use `replace_plan` for a substantial restructuring; use `add_*`, `update_*`, and `remove_*` for small changes.
32+- Pass `phaseId` as the raw ID shown in brackets, without the brackets. The tool also accepts a complete phase label or a unique phase title when recovering from copied output.
33+- Do not rely on prose alone for completion state; update the tool after each completed task.
M packages/agent/build.go +83 -10
@@ -61,11 +61,16 @@ type Resolved struct {
61 // messages to the provider transcript.61 // messages to the provider transcript.
62 ContextFiles []ContextFile62 ContextFiles []ContextFile
63 63
64+ // skillsEnabled is true when discovery ran, even when it found no
65+ // skills. It is false only for an explicit clean-room disable.
66+ skillsEnabled bool
67+
64 // Bookkeeping for MergeExtensionTools. Captured at Resolve time68 // Bookkeeping for MergeExtensionTools. Captured at Resolve time
65 // so the system prompt can be rebuilt later without re-running69 // so the system prompt can be rebuilt later without re-running
66 // resolve.70 // resolve.
67 systemAppend []string71 systemAppend []string
68 systemCustom string72 systemCustom string
73+ skillAddendum string
69 toolDescriptions map[string]string74 toolDescriptions map[string]string
70 }75 }
71 76
@@ -83,33 +88,91 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
83 if mgr == nil {88 if mgr == nil {
84 return89 return
85 }90 }
86- infos := mgr.Tools()
87- if len(infos) == 0 {
88- return
89- }
90 changed := false91 changed := false
91- for _, info := range infos {92+ for _, info := range mgr.Tools() {
92 if _, exists := r.ToolRegistry[info.Name]; exists {93 if _, exists := r.ToolRegistry[info.Name]; exists {
93 continue94 continue
94 }95 }
95 r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)96 r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
96 changed = true97 changed = true
97 }98 }
99+
100+ // A nil SkillTool can mean discovery ran and found nothing, so use the
101+ // explicit disable flag rather than the discovery result as the gate.
102+ if source, ok := mgr.(ExtensionSkillSource); ok && r.skillsEnabled {
103+ if bundled := source.Skills(); len(bundled) > 0 {
104+ merged := mergeExtensionSkills(r.SkillTool, bundled)
105+ if r.SkillTool == nil {
106+ r.SkillTool = skills.NewTool(merged)
107+ r.ToolRegistry[r.SkillTool.Name()] = r.SkillTool
108+ } else {
109+ r.SkillTool.SetSkills(merged)
110+ }
111+ append_ := append([]string(nil), r.systemAppend...)
112+ if r.skillAddendum != "" {
113+ filtered := append_[:0]
114+ for _, item := range append_ {
115+ if item != r.skillAddendum {
116+ filtered = append(filtered, item)
117+ }
118+ }
119+ append_ = filtered
120+ }
121+ r.skillAddendum = skills.SystemPromptAddendum(merged)
122+ if r.skillAddendum != "" {
123+ append_ = append(append_, r.skillAddendum)
124+ }
125+ r.systemAppend = append_
126+ changed = true
127+ }
128+ }
98 if !changed {129 if !changed {
99 return130 return
100 }131 }
101- // Re-render the system prompt with the merged tool list. Skill132+ // Re-render the system prompt with the merged tool and skill lists.
102- // addendum is preserved by walking the existing append slice.133+ r.ToolSummary = toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions)
103- append_ := r.systemAppend
104 r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{134 r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
105 CWD: r.CWD,135 CWD: r.CWD,
106- Tools: toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),136+ Tools: r.ToolSummary,
107 Custom: r.systemCustom,137 Custom: r.systemCustom,
108- Append: append_,138+ Append: r.systemAppend,
109 ZotDocsDir: filepath.Join(ZotHome(), "docs"),139 ZotDocsDir: filepath.Join(ZotHome(), "docs"),
110 })140 })
111 }141 }
112 142
143+func mergeExtensionSkills(tool *skills.Tool, bundled []*skills.Skill) []*skills.Skill {
144+ var existing []*skills.Skill
145+ if tool != nil {
146+ existing = tool.Skills()
147+ }
148+ merged := make([]*skills.Skill, 0, len(existing)+len(bundled))
149+ seen := map[string]bool{}
150+ add := func(list []*skills.Skill) {
151+ for _, skill := range list {
152+ if skill == nil || skill.Name == "" || seen[skill.Name] {
153+ continue
154+ }
155+ seen[skill.Name] = true
156+ merged = append(merged, skill)
157+ }
158+ }
159+ var user, builtins []*skills.Skill
160+ for _, skill := range existing {
161+ if skill == nil || strings.HasPrefix(skill.Source, "extension ") {
162+ continue
163+ }
164+ if skill.Builtin {
165+ builtins = append(builtins, skill)
166+ } else {
167+ user = append(user, skill)
168+ }
169+ }
170+ add(user)
171+ add(bundled)
172+ add(builtins)
173+ return merged
174+}
175+
113 // ExtensionToolSource is the slice of the extension manager that176 // ExtensionToolSource is the slice of the extension manager that
114 // MergeExtensionTools needs. Lives here as an interface so the177 // MergeExtensionTools needs. Lives here as an interface so the
115 // build package doesn't import packages/agent/extensions (which178 // build package doesn't import packages/agent/extensions (which
@@ -119,6 +182,12 @@ type ExtensionToolSource interface {
119 NewExtensionTool(info ExtensionToolInfo) core.Tool182 NewExtensionTool(info ExtensionToolInfo) core.Tool
120 }183 }
121 184
185+// ExtensionSkillSource exposes the optional bundled-skill surface without
186+// forcing older ExtensionToolSource implementations to add it.
187+type ExtensionSkillSource interface {
188+ Skills() []*skills.Skill
189+}
190+
122 // ExtensionToolInfo mirrors extensions.ToolInfo so we can declare191 // ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
123 // ExtensionToolSource here without importing the extensions192 // ExtensionToolSource here without importing the extensions
124 // package. The cli wires a tiny adapter to bridge them.193 // package. The cli wires a tiny adapter to bridge them.
@@ -640,8 +709,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
640 discovered []*skills.Skill709 discovered []*skills.Skill
641 skillTool *skills.Tool710 skillTool *skills.Tool
642 skillAddendum string711 skillAddendum string
712+ skillsEnabled bool
643 )713 )
644 if !args.NoSkill && (selectedProfile == nil || selectedProfile.InheritSkills == nil || *selectedProfile.InheritSkills) {714 if !args.NoSkill && (selectedProfile == nil || selectedProfile.InheritSkills == nil || *selectedProfile.InheritSkills) {
715+ skillsEnabled = true
645 homeDir, _ := os.UserHomeDir()716 homeDir, _ := os.UserHomeDir()
646 discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)717 discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
647 if len(discovered) > 0 {718 if len(discovered) > 0 {
@@ -728,9 +799,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
728 MaxOutput: resolvedModel.MaxOutput,799 MaxOutput: resolvedModel.MaxOutput,
729 Sandbox: sandbox,800 Sandbox: sandbox,
730 SkillTool: skillTool,801 SkillTool: skillTool,
802+ skillsEnabled: skillsEnabled,
731 ContextFiles: contextFiles,803 ContextFiles: contextFiles,
732 systemAppend: append_,804 systemAppend: append_,
733 systemCustom: custom,805 systemCustom: custom,
806+ skillAddendum: skillAddendum,
734 toolDescriptions: descMapFromSummaries(summaries),807 toolDescriptions: descMapFromSummaries(summaries),
735 }, nil808 }, nil
736 }809 }
M packages/agent/build_test.go +48 -0
@@ -9,10 +9,58 @@ import (
9 "strings"9 "strings"
10 "testing"10 "testing"
11 11
12+ "github.com/patriceckhart/zot/packages/agent/skills"
12 "github.com/patriceckhart/zot/packages/agent/tools"13 "github.com/patriceckhart/zot/packages/agent/tools"
14+ "github.com/patriceckhart/zot/packages/core"
13 "github.com/patriceckhart/zot/packages/provider"15 "github.com/patriceckhart/zot/packages/provider"
14 )16 )
15 17
18+type bundledSkillSource struct {
19+ bundled []*skills.Skill
20+}
21+
22+func (s bundledSkillSource) Tools() []ExtensionToolInfo { return nil }
23+func (s bundledSkillSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
24+ return nil
25+}
26+func (s bundledSkillSource) Skills() []*skills.Skill { return s.bundled }
27+
28+func TestMergeExtensionToolsCreatesSkillToolWhenDiscoveryFoundNoSkills(t *testing.T) {
29+ r := Resolved{
30+ CWD: t.TempDir(),
31+ ToolRegistry: make(core.Registry),
32+ skillsEnabled: true,
33+ }
34+ bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
35+
36+ r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
37+
38+ if r.SkillTool == nil {
39+ t.Fatal("MergeExtensionTools did not create a skill tool for bundled skills")
40+ }
41+ if _, ok := r.ToolRegistry[r.SkillTool.Name()]; !ok {
42+ t.Fatalf("skill tool %q was not registered", r.SkillTool.Name())
43+ }
44+ if got := r.SkillTool.Skills(); len(got) != 1 || got[0].Name != bundled.Name {
45+ t.Fatalf("merged bundled skills = %#v", got)
46+ }
47+}
48+
49+func TestMergeExtensionToolsRespectsDisabledSkillDiscovery(t *testing.T) {
50+ r := Resolved{
51+ CWD: t.TempDir(),
52+ ToolRegistry: make(core.Registry),
53+ skillsEnabled: false,
54+ }
55+ bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
56+
57+ r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
58+
59+ if r.SkillTool != nil {
60+ t.Fatal("disabled skill discovery created a skill tool")
61+ }
62+}
63+
16 func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {64 func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
17 root := t.TempDir()65 root := t.TempDir()
18 registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false)66 registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false)
M packages/agent/cli.go +203 -21
@@ -37,9 +37,11 @@ import (
37 const maxBufferedInteractiveAlerts = 6437 const maxBufferedInteractiveAlerts = 64
38 38
39 type interactiveExtHooks struct {39 type interactiveExtHooks struct {
40- mu sync.Mutex40+ mu sync.Mutex
41- interactive *modes.Interactive41+ interactive *modes.Interactive
42- pendingAlerts []interactiveAlert42+ pendingAlerts []interactiveAlert
43+ pendingStatuses map[string]interactiveStatus
44+ pendingWidgets map[string]interactiveWidget
43 }45 }
44 46
45 type interactiveAlert struct {47 type interactiveAlert struct {
@@ -47,6 +49,21 @@ type interactiveAlert struct {
47 alert extproto.AlertRequest49 alert extproto.AlertRequest
48 }50 }
49 51
52+type interactiveStatus struct {
53+ extName string
54+ key string
55+ level string
56+ text string
57+}
58+
59+type interactiveWidget struct {
60+ extName string
61+ id string
62+ position string
63+ title string
64+ lines []string
65+}
66+
50 func (h *interactiveExtHooks) iv() *modes.Interactive {67 func (h *interactiveExtHooks) iv() *modes.Interactive {
51 if h == nil {68 if h == nil {
52 return nil69 return nil
@@ -62,12 +79,22 @@ func (h *interactiveExtHooks) attachInteractive(iv *modes.Interactive) {
62 }79 }
63 h.mu.Lock()80 h.mu.Lock()
64 h.interactive = iv81 h.interactive = iv
65- pending := h.pendingAlerts82+ pendingAlerts := h.pendingAlerts
83+ pendingStatuses := h.pendingStatuses
84+ pendingWidgets := h.pendingWidgets
66 h.pendingAlerts = nil85 h.pendingAlerts = nil
86+ h.pendingStatuses = nil
87+ h.pendingWidgets = nil
67 h.mu.Unlock()88 h.mu.Unlock()
68- for _, item := range pending {89+ for _, item := range pendingAlerts {
69 iv.Alert(item.extName, item.alert)90 iv.Alert(item.extName, item.alert)
70 }91 }
92+ for _, item := range pendingStatuses {
93+ iv.SetStatus(item.extName, item.key, item.level, item.text)
94+ }
95+ for _, item := range pendingWidgets {
96+ iv.SetWidget(item.extName, item.id, item.position, item.title, item.lines)
97+ }
71 }98 }
72 99
73 func (h *interactiveExtHooks) Notify(extName, level, message string) {100 func (h *interactiveExtHooks) Notify(extName, level, message string) {
@@ -131,6 +158,65 @@ func (h *interactiveExtHooks) ClosePanel(extName, panelID string) {
131 iv.ClosePanel(extName, panelID)158 iv.ClosePanel(extName, panelID)
132 }159 }
133 }160 }
161+func pendingChromeKey(extName, key string) string { return extName + "\x00" + key }
162+
163+func (h *interactiveExtHooks) SetStatus(extName, key, level, text string) {
164+ if h == nil {
165+ return
166+ }
167+ h.mu.Lock()
168+ if h.interactive == nil {
169+ if h.pendingStatuses == nil {
170+ h.pendingStatuses = map[string]interactiveStatus{}
171+ }
172+ pendingKey := pendingChromeKey(extName, key)
173+ if strings.TrimSpace(text) == "" {
174+ delete(h.pendingStatuses, pendingKey)
175+ } else {
176+ h.pendingStatuses[pendingKey] = interactiveStatus{extName: extName, key: key, level: level, text: text}
177+ }
178+ h.mu.Unlock()
179+ return
180+ }
181+ iv := h.interactive
182+ h.mu.Unlock()
183+ iv.SetStatus(extName, key, level, text)
184+}
185+func (h *interactiveExtHooks) SetWidget(extName, id, position, title string, lines []string) {
186+ if h == nil {
187+ return
188+ }
189+ h.mu.Lock()
190+ if h.interactive == nil {
191+ if h.pendingWidgets == nil {
192+ h.pendingWidgets = map[string]interactiveWidget{}
193+ }
194+ h.pendingWidgets[pendingChromeKey(extName, id)] = interactiveWidget{
195+ extName: extName, id: id, position: position, title: title, lines: append([]string(nil), lines...),
196+ }
197+ h.mu.Unlock()
198+ return
199+ }
200+ iv := h.interactive
201+ h.mu.Unlock()
202+ iv.SetWidget(extName, id, position, title, lines)
203+}
204+func (h *interactiveExtHooks) ClearWidget(extName, id string) {
205+ if h == nil {
206+ return
207+ }
208+ h.mu.Lock()
209+ if h.interactive == nil {
210+ if h.pendingWidgets != nil {
211+ delete(h.pendingWidgets, pendingChromeKey(extName, id))
212+ }
213+ h.mu.Unlock()
214+ return
215+ }
216+ iv := h.interactive
217+ h.mu.Unlock()
218+ iv.ClearWidget(extName, id)
219+}
134 220
135 // extToolAdapter bridges *extensions.Manager to the221 // extToolAdapter bridges *extensions.Manager to the
136 // ExtensionToolSource interface declared in build.go (kept narrow to222 // ExtensionToolSource interface declared in build.go (kept narrow to
@@ -141,6 +227,16 @@ type extToolAdapter struct {
141 mgr *extensions.Manager227 mgr *extensions.Manager
142 }228 }
143 229
230+var _ ExtensionToolSource = (*extToolAdapter)(nil)
231+var _ ExtensionSkillSource = (*extToolAdapter)(nil)
232+
233+func (a *extToolAdapter) Skills() []*skills.Skill {
234+ if a == nil || a.mgr == nil {
235+ return nil
236+ }
237+ return a.mgr.Skills()
238+}
239+
144 func (a *extToolAdapter) Tools() []ExtensionToolInfo {240 func (a *extToolAdapter) Tools() []ExtensionToolInfo {
145 infos := a.mgr.Tools()241 infos := a.mgr.Tools()
146 out := make([]ExtensionToolInfo, len(infos))242 out := make([]ExtensionToolInfo, len(infos))
@@ -436,6 +532,61 @@ func applyInitialSessionResume(ctx context.Context, args Args, base Resolved, ex
436 // the events that have a clear extension-facing meaning are532 // the events that have a clear extension-facing meaning are
437 // forwarded; internal-only ones (text_delta, tool_progress) are533 // forwarded; internal-only ones (text_delta, tool_progress) are
438 // dropped to keep the per-extension stream sane.534 // dropped to keep the per-extension stream sane.
535+func sessionContext(sess *core.Session) *extproto.SessionContext {
536+ if sess == nil {
537+ return nil
538+ }
539+ return &extproto.SessionContext{
540+ ID: sess.Meta.ID,
541+ ParentID: sess.Meta.Parent,
542+ Path: sess.Path,
543+ CWD: sess.Meta.CWD,
544+ ForkPoint: sess.Meta.ForkPoint,
545+ }
546+}
547+
548+func announceSession(mgr *extensions.Manager, sess *core.Session) {
549+ if mgr == nil {
550+ return
551+ }
552+ var states map[string]json.RawMessage
553+ if sess != nil {
554+ states = copyExtensionStates(sess.ExtensionState)
555+ }
556+ mgr.EmitSessionEvent("session_start", sessionContext(sess), states)
557+ if sess != nil {
558+ mgr.EmitSessionEvent("session_opened", sessionContext(sess), states)
559+ }
560+}
561+
562+func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawMessage {
563+ if len(states) == 0 {
564+ return nil
565+ }
566+ out := make(map[string]json.RawMessage, len(states))
567+ for name, state := range states {
568+ out[name] = append(json.RawMessage(nil), state...)
569+ }
570+ return out
571+}
572+
573+func persistExtensionToolResult(mgr *extensions.Manager, sess *core.Session, result core.ToolResult) {
574+ if sess == nil {
575+ return
576+ }
577+ details, ok := result.Details.(extensions.ToolResultDetails)
578+ if !ok || details.Extension == "" || len(details.State) == 0 {
579+ return
580+ }
581+ if err := sess.AppendExtensionState(details.Extension, details.State); err != nil {
582+ fmt.Fprintf(os.Stderr, "extension state: %v\n", err)
583+ return
584+ }
585+ if mgr != nil {
586+ mgr.UpdateSessionState(details.Extension, details.State)
587+ }
588+}
589+
439 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {590 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
440 if mgr == nil {591 if mgr == nil {
441 return592 return
@@ -557,6 +708,9 @@ func (nonInteractiveExtHooks) ClearNotes(string)
557 func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}708 func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
558 func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}709 func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
559 func (nonInteractiveExtHooks) ClosePanel(string, string) {}710 func (nonInteractiveExtHooks) ClosePanel(string, string) {}
711+func (nonInteractiveExtHooks) SetStatus(string, string, string, string) {}
712+func (nonInteractiveExtHooks) SetWidget(string, string, string, string, []string) {}
713+func (nonInteractiveExtHooks) ClearWidget(string, string) {}
560 714
561 // setupNonInteractiveExtensions loads --ext paths and (unless715 // setupNonInteractiveExtensions loads --ext paths and (unless
562 // --no-ext) runs discovery. Returns the manager so the caller can716 // --no-ext) runs discovery. Returns the manager so the caller can
@@ -574,7 +728,6 @@ func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved,
574 }728 }
575 extMgr.WaitForReady(3 * time.Second)729 extMgr.WaitForReady(3 * time.Second)
576 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})730 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
577- extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
578 return extMgr, func() { extMgr.Stop(2 * time.Second) }731 return extMgr, func() { extMgr.Stop(2 * time.Second) }
579 }732 }
580 733
@@ -593,9 +746,9 @@ func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr
593 }746 }
594 return true, "", res.ModifiedArgs747 return true, "", res.ModifiedArgs
595 }748 }
596- ag.BeforeTurn = func(step int) (bool, string) {749+ ag.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
597- res := extMgr.InterceptTurnStart(ctx, step)750+ res := extMgr.InterceptTurnStart(turnCtx, step)
598- return !res.Block, res.Reason751+ return !res.Block, res.Reason, res.Context
599 }752 }
600 ag.BeforeAssistantMessage = func(text string) (bool, string, string) {753 ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
601 res := extMgr.InterceptAssistantMessage(ctx, text)754 res := extMgr.InterceptAssistantMessage(ctx, text)
@@ -677,8 +830,10 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
677 return err830 return err
678 }831 }
679 r.Provider, r.Model = providerName, model832 r.Provider, r.Model = providerName, model
833+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
680 defer sess.Close()834 defer sess.Close()
681 }835 }
836+ announceSession(extMgr, sess)
682 837
683 prompt := args.Prompt838 prompt := args.Prompt
684 if prompt == "" {839 if prompt == "" {
@@ -738,8 +893,10 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
738 return err893 return err
739 }894 }
740 r.Provider, r.Model = providerName, model895 r.Provider, r.Model = providerName, model
896+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
741 defer sess.Close()897 defer sess.Close()
742 }898 }
899+ announceSession(extMgr, sess)
743 900
744 prompt := args.Prompt901 prompt := args.Prompt
745 if prompt == "" {902 if prompt == "" {
@@ -838,8 +995,10 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
838 return err995 return err
839 }996 }
840 r.Provider, r.Model = providerName, model997 r.Provider, r.Model = providerName, model
998+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
841 defer sess.Close()999 defer sess.Close()
842 }1000 }
1001+ announceSession(extMgr, sess)
843 1002
844 prompt := args.Prompt1003 prompt := args.Prompt
845 if prompt == "" {1004 if prompt == "" {
@@ -1170,9 +1329,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1170 }1329 }
1171 return true, "", r.ModifiedArgs1330 return true, "", r.ModifiedArgs
1172 }1331 }
1173- a.BeforeTurn = func(step int) (bool, string) {1332+ a.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
1174- r := extMgr.InterceptTurnStart(ctx, step)1333+ r := extMgr.InterceptTurnStart(turnCtx, step)
1175- return !r.Block, r.Reason1334+ return !r.Block, r.Reason, r.Context
1176 }1335 }
1177 a.BeforeAssistantMessage = func(text string) (bool, string, string) {1336 a.BeforeAssistantMessage = func(text string) (bool, string, string) {
1178 r := extMgr.InterceptAssistantMessage(ctx, text)1337 r := extMgr.InterceptAssistantMessage(ctx, text)
@@ -1261,9 +1420,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1261 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)1420 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
1262 })1421 })
1263 1422
1264- // Fire session_start once we know the manager's running.
1265- extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
1266-
1267 var sess *core.Session1423 var sess *core.Session
1268 var sessBaselineMsgs int // messages already on disk when current session opened1424 var sessBaselineMsgs int // messages already on disk when current session opened
1269 var sessionTitlePending bool1425 var sessionTitlePending bool
@@ -1296,6 +1452,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1296 sessionTitlePending = sess.Meta.Parent != "" && sess.Title == "" && len(ag.Messages()) <= sess.Meta.ForkPoint1452 sessionTitlePending = sess.Meta.Parent != "" && sess.Title == "" && len(ag.Messages()) <= sess.Meta.ForkPoint
1297 }1453 }
1298 }1454 }
1455+ announceSession(extMgr, sess)
1299 defer func() {1456 defer func() {
1300 persistMu.Lock()1457 persistMu.Lock()
1301 defer persistMu.Unlock()1458 defer persistMu.Unlock()
@@ -1334,16 +1491,27 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1334 }1491 }
1335 persistCompaction := func(messages []provider.Message) {1492 persistCompaction := func(messages []provider.Message) {
1336 sessionTransitionMu.RLock()1493 sessionTransitionMu.RLock()
1337- defer sessionTransitionMu.RUnlock()1494+ var session *extproto.SessionContext
1495+ var states map[string]json.RawMessage
1338 persistMu.Lock()1496 persistMu.Lock()
1339- defer persistMu.Unlock()1497+ if sess != nil {
1340- if sess == nil {1498+ if err := sess.AppendCompaction(messages); err == nil {
1341- return1499+ sessBaselineMsgs = len(messages)
1500+ session = sessionContext(sess)
1501+ states = copyExtensionStates(sess.ExtensionState)
1502+ }
1342 }1503 }
1343- if err := sess.AppendCompaction(messages); err == nil {1504+ persistMu.Unlock()
1344- sessBaselineMsgs = len(messages)1505+ sessionTransitionMu.RUnlock()
1506+ if session != nil {
1507+ extMgr.EmitSessionEvent("session_compacted", session, states)
1345 }1508 }
1346 }1509 }
1510+ persistToolResult := func(_ string, result core.ToolResult) {
1511+ persistMu.Lock()
1512+ defer persistMu.Unlock()
1513+ persistExtensionToolResult(extMgr, sess, result)
1514+ }
1347 wireAgentPersist := func(a *core.Agent) *core.Agent {1515 wireAgentPersist := func(a *core.Agent) *core.Agent {
1348 if a == nil {1516 if a == nil {
1349 return a1517 return a
@@ -1351,6 +1519,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1351 a.OnMessageAppended = persistMessage1519 a.OnMessageAppended = persistMessage
1352 a.OnUsage = persistUsage1520 a.OnUsage = persistUsage
1353 a.OnTranscriptCompacted = persistCompaction1521 a.OnTranscriptCompacted = persistCompaction
1522+ a.OnToolResult = persistToolResult
1354 return a1523 return a
1355 }1524 }
1356 wireAgentPersist(ag)1525 wireAgentPersist(ag)
@@ -1443,6 +1612,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1443 persistMu.Unlock()1612 persistMu.Unlock()
1444 return fmt.Errorf("session changed while loading; please try again")1613 return fmt.Errorf("session changed while loading; please try again")
1445 }1614 }
1615+ newStates := copyExtensionStates(candidate.session.ExtensionState)
1446 if oldSess != nil {1616 if oldSess != nil {
1447 _ = oldSess.Close()1617 _ = oldSess.Close()
1448 }1618 }
@@ -1477,6 +1647,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1477 }1647 }
1478 committed = true1648 committed = true
1479 persistMu.Unlock()1649 persistMu.Unlock()
1650+ if candidate.session.Meta.Parent != "" {
1651+ extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
1652+ }
1653+ extMgr.EmitSessionEvent("session_switched", sessionContext(candidate.session), newStates)
1480 return nil1654 return nil
1481 }1655 }
1482 1656
@@ -1626,12 +1800,16 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1626 1800
1627 // Commit only after every fallible operation has succeeded. Close1801 // Commit only after every fallible operation has succeeded. Close
1628 // the old session last so rollback never has to resurrect it.1802 // the old session last so rollback never has to resurrect it.
1803+ var newStates map[string]json.RawMessage
1629 persistMu.Lock()1804 persistMu.Lock()
1630 if sess != nil {1805 if sess != nil {
1631 writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)1806 writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
1632 _ = sess.Close()1807 _ = sess.Close()
1633 }1808 }
1634 sess = newSess1809 sess = newSess
1810+ if newSess != nil {
1811+ newStates = copyExtensionStates(newSess.ExtensionState)
1812+ }
1635 sessBaselineMsgs = 01813 sessBaselineMsgs = 0
1636 sessionTitlePending = false1814 sessionTitlePending = false
1637 activeProvider = newProvider1815 activeProvider = newProvider
@@ -1650,6 +1828,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1650 if swarmMgr != nil && newSess != nil {1828 if swarmMgr != nil && newSess != nil {
1651 swarmMgr.SetActiveSession(newSess.ID)1829 swarmMgr.SetActiveSession(newSess.ID)
1652 }1830 }
1831+ if newSess != nil {
1832+ extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
1833+ }
1653 return nil1834 return nil
1654 }1835 }
1655 1836
@@ -1906,6 +2087,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1906 // manifest and the skill tool.2087 // manifest and the skill tool.
1907 userHome, _ := os.UserHomeDir()2088 userHome, _ := os.UserHomeDir()
1908 list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)2089 list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)
2090+ list = mergeExtensionSkills(skills.NewTool(list), extMgr.Skills())
1909 return skills.VisibleSkills(list)2091 return skills.VisibleSkills(list)
1910 },2092 },
1911 NoYolo: args.NoYolo,2093 NoYolo: args.NoYolo,
M packages/agent/cli_alert_test.go +23 -0
@@ -7,6 +7,29 @@ import (
7 "github.com/patriceckhart/zot/packages/agent/modes"7 "github.com/patriceckhart/zot/packages/agent/modes"
8 )8 )
9 9
10+func TestInteractiveExtHooksBufferStartupChrome(t *testing.T) {
11+ hooks := &interactiveExtHooks{}
12+ hooks.SetStatus("tasked-phases", "progress", "success", "2/4")
13+ hooks.SetWidget("tasked-phases", "plan", "above_input", "Plan", []string{"one"})
14+ hooks.SetWidget("tasked-phases", "cleared", "above_input", "old", []string{"old"})
15+ hooks.ClearWidget("tasked-phases", "cleared")
16+ hooks.SetStatus("tasked-phases", "removed", "info", "old")
17+ hooks.SetStatus("tasked-phases", "removed", "info", "")
18+
19+ hooks.mu.Lock()
20+ if len(hooks.pendingStatuses) != 1 || len(hooks.pendingWidgets) != 1 {
21+ t.Fatalf("pending chrome = statuses %d widgets %d, want one each", len(hooks.pendingStatuses), len(hooks.pendingWidgets))
22+ }
23+ hooks.mu.Unlock()
24+
25+ hooks.attachInteractive(&modes.Interactive{})
26+ hooks.mu.Lock()
27+ defer hooks.mu.Unlock()
28+ if len(hooks.pendingStatuses) != 0 || len(hooks.pendingWidgets) != 0 {
29+ t.Fatalf("pending chrome was not flushed: statuses=%v widgets=%v", hooks.pendingStatuses, hooks.pendingWidgets)
30+ }
31+}
32+
10 func TestInteractiveExtHooksBufferStartupAlerts(t *testing.T) {33 func TestInteractiveExtHooksBufferStartupAlerts(t *testing.T) {
11 hooks := &interactiveExtHooks{}34 hooks := &interactiveExtHooks{}
12 hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})35 hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
M packages/agent/ext/ext.go +56 -5
@@ -66,7 +66,11 @@ type EventHandler func(ev Event)
66 // Event is a lifecycle notification from zot. The fields populated66 // Event is a lifecycle notification from zot. The fields populated
67 // depend on Name (the host's event_name string):67 // depend on Name (the host's event_name string):
68 //68 //
69-// session_start : (no extra fields)69+// session_start : Session
70+// session_opened : Session + extension-owned State snapshot
71+// session_switched : Session + extension-owned State snapshot
72+// session_forked : Session + extension-owned State snapshot
73+// session_compacted: Session + extension-owned State snapshot
70 // turn_start : Step74 // turn_start : Step
71 // turn_end : Stop, optional Error75 // turn_end : Stop, optional Error
72 // tool_call : ToolID, ToolName, ToolArgs76 // tool_call : ToolID, ToolName, ToolArgs
@@ -75,6 +79,9 @@ type EventHandler func(ev Event)
75 type Event struct {79 type Event struct {
76 Name string80 Name string
77 81
82+ Session *extproto.SessionContext
83+ State json.RawMessage
84+
78 Step int85 Step int
79 Stop string86 Stop string
80 Error string87 Error string
@@ -122,6 +129,9 @@ type ToolCallHandler func(toolName string, args json.RawMessage) ToolCallDecisio
122 type TurnStartDecision struct {129 type TurnStartDecision struct {
123 Block bool130 Block bool
124 Reason string131 Reason string
132+ // Context is hidden, per-turn context sent only in the next provider
133+ // request. It is not recorded as a user or assistant message.
134+ Context string
125 }135 }
126 136
127 // TurnStartHandler is called before every turn's model call. Return137 // TurnStartHandler is called before every turn's model call. Return
@@ -156,6 +166,20 @@ type ToolResult struct {
156 Content []ToolContent166 Content []ToolContent
157 IsError bool167 IsError bool
158 ActivateTools []string168 ActivateTools []string
169+ // Details is opaque JSON kept out of provider-visible tool results. The
170+ // host may persist it with the active session and return it on the next
171+ // session lifecycle event.
172+ Details json.RawMessage
173+}
174+
175+// JSONDetails encodes an extension-owned value for ToolResult.Details. Invalid
176+// values return nil, causing the host to ignore the metadata.
177+func JSONDetails(value any) json.RawMessage {
178+ data, err := json.Marshal(value)
179+ if err != nil || !json.Valid(data) {
180+ return nil
181+ }
182+ return data
159 }183 }
160 184
161 // AlertRequest asks the host to surface a structured alert. The host owns185 // AlertRequest asks the host to surface a structured alert. The host owns
@@ -301,6 +325,7 @@ type HostInfo struct {
301 CWD string325 CWD string
302 ExtensionDir string326 ExtensionDir string
303 DataDir string327 DataDir string
328+ Session *extproto.SessionContext
304 }329 }
305 330
306 // New constructs an Extension with the given identifier. name should331 // New constructs an Extension with the given identifier. name should
@@ -317,7 +342,7 @@ func New(name, version string) *Extension {
317 eventHandlers: map[string]EventHandler{},342 eventHandlers: map[string]EventHandler{},
318 panelKeys: map[string]func(key, text string){},343 panelKeys: map[string]func(key, text string){},
319 panelCloses: map[string]func(){},344 panelCloses: map[string]func(){},
320- caps: []string{"commands", "tools", "events", "alerts", "panels"},345+ caps: []string{"commands", "tools", "events", "alerts", "panels", "session_state", "status", "widgets"},
321 }346 }
322 }347 }
323 348
@@ -416,8 +441,9 @@ func (e *Extension) registerTool(name, description string, schema json.RawMessag
416 441
417 // On subscribes to a lifecycle event. fn is called for each442 // On subscribes to a lifecycle event. fn is called for each
418 // notification; the same name can only have one handler (later443 // notification; the same name can only have one handler (later
419-// registrations replace earlier ones). Recognised names:444+// registrations replace earlier ones). Recognised names include:
420-// session_start, turn_start, turn_end, tool_call,445+// session_start, session_opened, session_switched, session_forked,
446+// session_compacted, turn_start, turn_end, tool_call,
421 // tool_confirmation_requested, assistant_message.447 // tool_confirmation_requested, assistant_message.
422 func (e *Extension) On(name string, fn EventHandler) {448 func (e *Extension) On(name string, fn EventHandler) {
423 e.mu.Lock()449 e.mu.Lock()
@@ -485,6 +511,25 @@ func (e *Extension) Notify(level, message string) {
485 })511 })
486 }512 }
487 513
514+// SetStatus replaces one persistent status item owned by this extension.
515+// Passing an empty text clears the item.
516+func (e *Extension) SetStatus(key, text string) {
517+ _ = e.send(extproto.StatusFromExt{Type: "status", Key: key, Text: text})
518+}
519+
520+// ClearStatus removes one persistent status item owned by this extension.
521+func (e *Extension) ClearStatus(key string) { e.SetStatus(key, "") }
522+
523+// SetWidget replaces one persistent widget owned by this extension.
524+func (e *Extension) SetWidget(id, position, title string, lines []string) {
525+ _ = e.send(extproto.WidgetFromExt{Type: "widget", ID: id, Position: position, Title: title, Lines: lines})
526+}
527+
528+// ClearWidget removes one persistent widget owned by this extension.
529+func (e *Extension) ClearWidget(id string) {
530+ _ = e.send(extproto.ClearWidgetFromExt{Type: "widget_clear", ID: id})
531+}
532+
488 // Alert asks the host to surface a structured alert. It is fire-and-forget;533 // Alert asks the host to surface a structured alert. It is fire-and-forget;
489 // hosts that do not support the requested kind may ignore it.534 // hosts that do not support the requested kind may ignore it.
490 func (e *Extension) Alert(alert AlertRequest) {535 func (e *Extension) Alert(alert AlertRequest) {
@@ -529,6 +574,7 @@ func (e *Extension) Run() error {
529 CWD: ack.CWD,574 CWD: ack.CWD,
530 ExtensionDir: ack.ExtensionDir,575 ExtensionDir: ack.ExtensionDir,
531 DataDir: ack.DataDir,576 DataDir: ack.DataDir,
577+ Session: ack.Session,
532 }578 }
533 e.mu.Lock()579 e.mu.Lock()
534 onHello := e.onHello580 onHello := e.onHello
@@ -657,7 +703,8 @@ func (e *Extension) Run() error {
657 }703 }
658 }()704 }()
659 handler(Event{705 handler(Event{
660- Name: ef.Event, Step: ef.Step, Stop: ef.Stop,706+ Name: ef.Event, Session: ef.Session, State: append(json.RawMessage(nil), ef.State...),
707+ Step: ef.Step, Stop: ef.Stop,
661 Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName,708 Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName,
662 ToolArgs: ef.ToolArgs, ToolPreview: ef.ToolPreview, Text: ef.Text,709 ToolArgs: ef.ToolArgs, ToolPreview: ef.ToolPreview, Text: ef.Text,
663 })710 })
@@ -739,6 +786,7 @@ func (e *Extension) respondTool(id string, r ToolResult) {
739 Content: blocks,786 Content: blocks,
740 IsError: r.IsError,787 IsError: r.IsError,
741 ActivateTools: r.ActivateTools,788 ActivateTools: r.ActivateTools,
789+ Details: append(json.RawMessage(nil), r.Details...),
742 })790 })
743 }791 }
744 792
@@ -802,6 +850,9 @@ func (e *Extension) dispatchIntercept(ei extproto.EventInterceptFromHost) {
802 d := fn(ei.Step)850 d := fn(ei.Step)
803 resp.Block = d.Block851 resp.Block = d.Block
804 resp.Reason = d.Reason852 resp.Reason = d.Reason
853+ if !d.Block {
854+ resp.Context = d.Context
855+ }
805 }856 }
806 case "assistant_message":857 case "assistant_message":
807 e.mu.Lock()858 e.mu.Lock()
M packages/agent/ext/ext_test.go +113 -0
@@ -3,6 +3,7 @@ package ext
3 import (3 import (
4 "bufio"4 "bufio"
5 "encoding/json"5 "encoding/json"
6+ "errors"
6 "io"7 "io"
7 "strings"8 "strings"
8 "testing"9 "testing"
@@ -64,6 +65,25 @@ func newHarness(name string) *extHarness {
64 return h65 return h
65 }66 }
66 67
68+func (h *extHarness) startRun(t *testing.T) {
69+ t.Helper()
70+ runDone := make(chan error, 1)
71+ go func() { runDone <- h.ext.Run() }()
72+ t.Cleanup(func() {
73+ if err := h.hostW.Close(); err != nil && !errors.Is(err, io.ErrClosedPipe) {
74+ t.Errorf("close extension input: %v", err)
75+ }
76+ select {
77+ case err := <-runDone:
78+ if err != nil {
79+ t.Errorf("extension Run: %v", err)
80+ }
81+ case <-time.After(2 * time.Second):
82+ t.Error("extension Run did not exit after closing host input")
83+ }
84+ })
85+}
86+
67 // next returns the next frame, timing out after 2 s.87 // next returns the next frame, timing out after 2 s.
68 func (h *extHarness) next(t *testing.T) rawFrame {88 func (h *extHarness) next(t *testing.T) rawFrame {
69 t.Helper()89 t.Helper()
@@ -220,6 +240,99 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
220 h.hostW.Close()240 h.hostW.Close()
221 }241 }
222 242
243+func TestPersistentChromeFrames(t *testing.T) {
244+ h := newHarness("chrome-ext")
245+ h.startRun(t)
246+ h.handshake(t)
247+
248+ h.ext.SetStatus("progress", "2/4 tasks")
249+ status := h.drainUntil(t, "status")
250+ var sf extproto.StatusFromExt
251+ if err := json.Unmarshal(status.raw, &sf); err != nil {
252+ t.Fatal(err)
253+ }
254+ if sf.Key != "progress" || sf.Text != "2/4 tasks" {
255+ t.Fatalf("status = %+v", sf)
256+ }
257+
258+ h.ext.SetWidget("plan", "above_input", "Plan", []string{"one"})
259+ widget := h.drainUntil(t, "widget")
260+ var wf extproto.WidgetFromExt
261+ if err := json.Unmarshal(widget.raw, &wf); err != nil {
262+ t.Fatal(err)
263+ }
264+ if wf.ID != "plan" || wf.Position != "above_input" || wf.Title != "Plan" || len(wf.Lines) != 1 || wf.Lines[0] != "one" {
265+ t.Fatalf("widget = %+v", wf)
266+ }
267+
268+ h.ext.ClearWidget("plan")
269+ clear := h.drainUntil(t, "widget_clear")
270+ var cf extproto.ClearWidgetFromExt
271+ if err := json.Unmarshal(clear.raw, &cf); err != nil {
272+ t.Fatal(err)
273+ }
274+ if cf.ID != "plan" {
275+ t.Fatalf("widget clear = %+v", cf)
276+ }
277+}
278+
279+func TestToolResultDetailsEmitsOpaqueMetadata(t *testing.T) {
280+ h := newHarness("details-ext")
281+ h.ext.Tool("details", "returns metadata", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult {
282+ return ToolResult{Content: []ToolContent{Text("ok")}, Details: JSONDetails(map[string]any{"state": 1})}
283+ })
284+ h.startRun(t)
285+ h.handshake(t)
286+ h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "call-1", Name: "details", Args: json.RawMessage(`{}`)})
287+ frame := h.drainUntil(t, "tool_result")
288+ var result extproto.ToolResultFromExt
289+ if err := json.Unmarshal(frame.raw, &result); err != nil {
290+ t.Fatal(err)
291+ }
292+ if string(result.Details) != `{"state":1}` {
293+ t.Fatalf("details = %s", result.Details)
294+ }
295+}
296+
297+func TestSessionEventCarriesIdentityAndState(t *testing.T) {
298+ h := newHarness("session-ext")
299+ received := make(chan Event, 1)
300+ h.ext.On("session_opened", func(event Event) { received <- event })
301+ h.startRun(t)
302+ h.handshake(t)
303+ h.sendToExt(t, extproto.EventFromHost{
304+ Type: "event", Event: "session_opened",
305+ Session: &extproto.SessionContext{ID: "branch-1", ParentID: "root-1", Path: "/tmp/session.jsonl", ForkPoint: 4},
306+ State: json.RawMessage(`{"version":1}`),
307+ })
308+ select {
309+ case event := <-received:
310+ if event.Session == nil || event.Session.ID != "branch-1" || event.Session.ParentID != "root-1" || event.Session.Path != "/tmp/session.jsonl" || event.Session.ForkPoint != 4 || string(event.State) != `{"version":1}` {
311+ t.Fatalf("session event = %+v state=%s", event.Session, event.State)
312+ }
313+ case <-time.After(2 * time.Second):
314+ t.Fatal("timeout waiting for session event")
315+ }
316+}
317+
318+func TestTurnStartContextIsReturned(t *testing.T) {
319+ h := newHarness("context-ext")
320+ h.ext.InterceptTurnStart(func(int) TurnStartDecision {
321+ return TurnStartDecision{Context: "current phase: parse"}
322+ })
323+ h.startRun(t)
324+ h.handshake(t)
325+ h.sendToExt(t, extproto.EventInterceptFromHost{Type: "event_intercept", ID: "intercept-1", Event: "turn_start", Step: 2})
326+ frame := h.drainUntil(t, "event_intercept_response")
327+ var response extproto.EventInterceptResponseFromExt
328+ if err := json.Unmarshal(frame.raw, &response); err != nil {
329+ t.Fatal(err)
330+ }
331+ if response.ID != "intercept-1" || response.Context != "current phase: parse" || response.Block {
332+ t.Fatalf("intercept response = %+v", response)
333+ }
334+}
335+
223 func TestAlertEmitsStructuredFrame(t *testing.T) {336 func TestAlertEmitsStructuredFrame(t *testing.T) {
224 h := newHarness("alert-ext")337 h := newHarness("alert-ext")
225 runDone := make(chan error, 1)338 runDone := make(chan error, 1)
M packages/agent/extcmd.go +3 -0
@@ -142,6 +142,9 @@ func (extDoctorHooks) ClearNotes(string) {}
142 func (extDoctorHooks) OpenPanel(string, extproto.PanelSpec) {}142 func (extDoctorHooks) OpenPanel(string, extproto.PanelSpec) {}
143 func (extDoctorHooks) UpdatePanel(string, string, string, []string, string) {}143 func (extDoctorHooks) UpdatePanel(string, string, string, []string, string) {}
144 func (extDoctorHooks) ClosePanel(string, string) {}144 func (extDoctorHooks) ClosePanel(string, string) {}
145+func (extDoctorHooks) SetStatus(string, string, string, string) {}
146+func (extDoctorHooks) SetWidget(string, string, string, string, []string) {}
147+func (extDoctorHooks) ClearWidget(string, string) {}
145 148
146 type extDoctorStaticRow struct {149 type extDoctorStaticRow struct {
147 Scope string150 Scope string
M packages/agent/extensions/events.go +131 -9
@@ -4,7 +4,9 @@ import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7+ "strings"
7 "time"8 "time"
9+ "unicode/utf8"
8 10
9 "github.com/patriceckhart/zot/packages/agent/extproto"11 "github.com/patriceckhart/zot/packages/agent/extproto"
10 )12 )
@@ -36,16 +38,111 @@ func (m *Manager) EmitEvent(ev extproto.EventFromHost) {
36 38
37 for _, ext := range subs {39 for _, ext := range subs {
38 go func(ext *Extension) {40 go func(ext *Extension) {
39- defer func() {41+ defer func() { _ = recover() }()
40- // A panicking write to a closed pipe shouldn't kill42+ _, _ = ext.writeFrame(frame)
41- // the calling goroutine.
42- _ = recover()
43- }()
44- _, _ = ext.stdin.Write(frame)
45 }(ext)43 }(ext)
46 }44 }
47 }45 }
48 46
47+// UpdateSessionState updates the cached state used when an extension is
48+// reloaded during the active session. The session file remains the durable
49+// source of truth; this only keeps the in-memory lifecycle replay current.
50+func (m *Manager) UpdateSessionState(extension string, state json.RawMessage) {
51+ if m == nil || strings.TrimSpace(extension) == "" || len(state) > maxSessionStateBytes {
52+ return
53+ }
54+ m.mu.Lock()
55+ defer m.mu.Unlock()
56+ if m.activeSession == nil {
57+ return
58+ }
59+ if m.activeSession.states == nil {
60+ m.activeSession.states = map[string]json.RawMessage{}
61+ }
62+ if len(state) == 0 || strings.TrimSpace(string(state)) == "null" {
63+ delete(m.activeSession.states, extension)
64+ return
65+ }
66+ m.activeSession.states[extension] = append(json.RawMessage(nil), state...)
67+}
68+
69+// EmitSessionEvent broadcasts a session lifecycle event with the active
70+// session identity. Each extension receives only its own opaque state
71+// snapshot, preventing one extension from reading another extension's data.
72+// The latest event is retained and replayed to extensions loaded later.
73+func (m *Manager) EmitSessionEvent(event string, session *extproto.SessionContext, states map[string]json.RawMessage) {
74+ if event == "" {
75+ return
76+ }
77+ m.sessionEmitMu.Lock()
78+ defer m.sessionEmitMu.Unlock()
79+ base := extproto.EventFromHost{Type: "event", Event: event, Session: cloneSessionContext(session)}
80+ storedStates := cloneExtensionStates(states)
81+ m.mu.Lock()
82+ m.activeSession = &sessionEventState{event: base, states: storedStates}
83+ subs := make([]*Extension, 0, len(m.ext))
84+ for _, ext := range m.ext {
85+ ext.mu.Lock()
86+ _, subscribed := ext.eventSubs[event]
87+ ext.mu.Unlock()
88+ if subscribed {
89+ subs = append(subs, ext)
90+ }
91+ }
92+ m.mu.Unlock()
93+ for _, ext := range subs {
94+ m.sendSessionEvent(ext, base, storedStates[ext.Manifest.Name])
95+ }
96+}
97+
98+func (m *Manager) sendSessionEvent(ext *Extension, base extproto.EventFromHost, state json.RawMessage) {
99+ if ext == nil {
100+ return
101+ }
102+ frame := base
103+ frame.State = append(json.RawMessage(nil), state...)
104+ encoded, err := extproto.Encode(frame)
105+ if err != nil {
106+ return
107+ }
108+ if !ext.enqueueLifecycleFrame(encoded) {
109+ ext.recordDiagnostic("session lifecycle frame dropped because the extension queue is full or stopped")
110+ }
111+}
112+
113+func (m *Manager) replaySessionEvent(ext *Extension) {
114+ m.sessionEmitMu.Lock()
115+ defer m.sessionEmitMu.Unlock()
116+ m.mu.RLock()
117+ if m.activeSession == nil {
118+ m.mu.RUnlock()
119+ return
120+ }
121+ base := m.activeSession.event
122+ state := append(json.RawMessage(nil), m.activeSession.states[ext.Manifest.Name]...)
123+ m.mu.RUnlock()
124+ m.sendSessionEvent(ext, base, state)
125+}
126+
127+func cloneSessionContext(in *extproto.SessionContext) *extproto.SessionContext {
128+ if in == nil {
129+ return nil
130+ }
131+ out := *in
132+ return &out
133+}
134+
135+func cloneExtensionStates(in map[string]json.RawMessage) map[string]json.RawMessage {
136+ if len(in) == 0 {
137+ return nil
138+ }
139+ out := make(map[string]json.RawMessage, len(in))
140+ for name, state := range in {
141+ out[name] = append(json.RawMessage(nil), state...)
142+ }
143+ return out
144+}
145+
49 // InterceptResult aggregates the outcome of walking every subscribed146 // InterceptResult aggregates the outcome of walking every subscribed
50 // interceptor for one event. The zero value means "allow, no147 // interceptor for one event. The zero value means "allow, no
51 // modification". Callers check Block first; if allowed, use the148 // modification". Callers check Block first; if allowed, use the
@@ -56,9 +153,29 @@ type InterceptResult struct {
56 Reason string153 Reason string
57 ModifiedArgs json.RawMessage154 ModifiedArgs json.RawMessage
58 ReplaceText string155 ReplaceText string
156+ Context string
59 }157 }
60 158
61-const interceptTimeout = 5 * time.Second159+const (
160+ interceptTimeout = 5 * time.Second
161+ maxInterceptContext = 16 * 1024
162+ maxSessionStateBytes = 256 * 1024
163+)
164+
165+const interceptContextTruncatedMarker = "\n[extension context truncated]"
166+
167+func boundInterceptContext(text string) string {
168+ text = strings.TrimSpace(text)
169+ if len(text) <= maxInterceptContext {
170+ return text
171+ }
172+ limit := maxInterceptContext - len(interceptContextTruncatedMarker)
173+ cut := limit
174+ for cut > 0 && !utf8.RuneStart(text[cut]) {
175+ cut--
176+ }
177+ return strings.TrimSpace(text[:cut]) + interceptContextTruncatedMarker
178+}
62 179
63 // InterceptToolCall is the typed entry point for tool_call180 // InterceptToolCall is the typed entry point for tool_call
64 // interception. Subscribers are invoked serially; the first to return181 // interception. Subscribers are invoked serially; the first to return
@@ -100,6 +217,7 @@ func (m *Manager) InterceptTurnStart(ctx context.Context, step int) InterceptRes
100 if len(subs) == 0 {217 if len(subs) == 0 {
101 return InterceptResult{}218 return InterceptResult{}
102 }219 }
220+ var contexts []string
103 for _, ext := range subs {221 for _, ext := range subs {
104 r := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{222 r := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{
105 Event: "turn_start",223 Event: "turn_start",
@@ -108,8 +226,11 @@ func (m *Manager) InterceptTurnStart(ctx context.Context, step int) InterceptRes
108 if r.Block {226 if r.Block {
109 return r227 return r
110 }228 }
229+ if text := strings.TrimSpace(r.Context); text != "" {
230+ contexts = append(contexts, "["+ext.Manifest.Name+"]\n"+text)
231+ }
111 }232 }
112- return InterceptResult{}233+ return InterceptResult{Context: boundInterceptContext(strings.Join(contexts, "\n\n"))}
113 }234 }
114 235
115 // InterceptAssistantMessage asks every subscriber to approve, block,236 // InterceptAssistantMessage asks every subscriber to approve, block,
@@ -179,7 +300,7 @@ func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extp
179 ext.mu.Unlock()300 ext.mu.Unlock()
180 return InterceptResult{}301 return InterceptResult{}
181 }302 }
182- if _, err := ext.stdin.Write(frame); err != nil {303+ if _, err := ext.writeFrame(frame); err != nil {
183 ext.mu.Lock()304 ext.mu.Lock()
184 delete(ext.pendingIntercept, id)305 delete(ext.pendingIntercept, id)
185 ext.mu.Unlock()306 ext.mu.Unlock()
@@ -194,6 +315,7 @@ func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extp
194 Reason: resp.Reason,315 Reason: resp.Reason,
195 ModifiedArgs: resp.ModifiedArgs,316 ModifiedArgs: resp.ModifiedArgs,
196 ReplaceText: resp.ReplaceText,317 ReplaceText: resp.ReplaceText,
318+ Context: resp.Context,
197 }319 }
198 case <-time.After(interceptTimeout):320 case <-time.After(interceptTimeout):
199 ext.mu.Lock()321 ext.mu.Lock()
M packages/agent/extensions/intercept_test.go +14 -0
@@ -8,8 +8,22 @@ import (
8 "strings"8 "strings"
9 "testing"9 "testing"
10 "time"10 "time"
11+ "unicode/utf8"
11 )12 )
12 13
14+func TestBoundInterceptContextPreservesUTF8AndLimit(t *testing.T) {
15+ got := boundInterceptContext(strings.Repeat("界", maxInterceptContext))
16+ if len(got) > maxInterceptContext {
17+ t.Fatalf("bounded context bytes = %d, want <= %d", len(got), maxInterceptContext)
18+ }
19+ if !utf8.ValidString(got) {
20+ t.Fatal("bounded context is not valid UTF-8")
21+ }
22+ if !strings.HasSuffix(got, interceptContextTruncatedMarker) {
23+ t.Fatalf("bounded context missing truncation marker")
24+ }
25+}
26+
13 // TestInterceptAllThreeEvents exercises tool_call / turn_start /27 // TestInterceptAllThreeEvents exercises tool_call / turn_start /
14 // assistant_message interception end-to-end via a bash extension that28 // assistant_message interception end-to-end via a bash extension that
15 // blocks `rm -rf`, rewrites any other bash command to `echo GUARDED`,29 // blocks `rm -rf`, rewrites any other bash command to `echo GUARDED`,
M packages/agent/extensions/manager.go +239 -14
@@ -29,6 +29,7 @@ import (
29 "time"29 "time"
30 30
31 "github.com/patriceckhart/zot/packages/agent/extproto"31 "github.com/patriceckhart/zot/packages/agent/extproto"
32+ "github.com/patriceckhart/zot/packages/agent/skills"
32 "github.com/patriceckhart/zot/packages/tui"33 "github.com/patriceckhart/zot/packages/tui"
33 )34 )
34 35
@@ -43,6 +44,7 @@ type Manifest struct {
43 Language string `json:"language,omitempty"` // informational ("go", "python", "typescript", ...)44 Language string `json:"language,omitempty"` // informational ("go", "python", "typescript", ...)
44 Enabled *bool `json:"enabled,omitempty"` // nil = enabled45 Enabled *bool `json:"enabled,omitempty"` // nil = enabled
45 Description string `json:"description,omitempty"`46 Description string `json:"description,omitempty"`
47+ Skills []string `json:"skills,omitempty"` // bundled skill directories
46 }48 }
47 49
48 // IsEnabled returns the manifest's effective enabled state. Default50 // IsEnabled returns the manifest's effective enabled state. Default
@@ -52,6 +54,48 @@ func (m Manifest) IsEnabled() bool {
52 return m.Enabled == nil || *m.Enabled54 return m.Enabled == nil || *m.Enabled
53 }55 }
54 56
57+func loadManifestSkills(extDir string, mf Manifest) ([]*skills.Skill, []error) {
58+ if len(mf.Skills) == 0 {
59+ return nil, nil
60+ }
61+ root, err := filepath.Abs(extDir)
62+ if err != nil {
63+ return nil, []error{fmt.Errorf("resolve extension directory: %w", err)}
64+ }
65+ root, err = filepath.EvalSymlinks(root)
66+ if err != nil {
67+ return nil, []error{fmt.Errorf("resolve extension directory: %w", err)}
68+ }
69+ declared := make([]skills.BundledSkillDir, 0, len(mf.Skills))
70+ var errs []error
71+ for _, raw := range mf.Skills {
72+ rel := strings.TrimSpace(raw)
73+ if rel == "" || filepath.IsAbs(rel) {
74+ errs = append(errs, fmt.Errorf("skill path %q must be a relative directory", raw))
75+ continue
76+ }
77+ clean := filepath.Clean(rel)
78+ if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
79+ errs = append(errs, fmt.Errorf("skill path %q escapes the extension directory", raw))
80+ continue
81+ }
82+ candidate := filepath.Join(root, clean)
83+ resolved, err := filepath.EvalSymlinks(candidate)
84+ if err != nil {
85+ errs = append(errs, fmt.Errorf("skill path %q: %w", raw, err))
86+ continue
87+ }
88+ relResolved, err := filepath.Rel(root, resolved)
89+ if err != nil || relResolved == ".." || strings.HasPrefix(relResolved, ".."+string(filepath.Separator)) {
90+ errs = append(errs, fmt.Errorf("skill path %q escapes the extension directory through a symlink", raw))
91+ continue
92+ }
93+ declared = append(declared, skills.BundledSkillDir{Dir: resolved, Root: root, Source: "extension " + mf.Name})
94+ }
95+ loaded, loadErrs := skills.LoadBundled(declared)
96+ return loaded, append(errs, loadErrs...)
97+}
98+
55 // Extension is a running extension subprocess and the metadata zot99 // Extension is a running extension subprocess and the metadata zot
56 // tracks about it.100 // tracks about it.
57 type Extension struct {101 type Extension struct {
@@ -66,6 +110,16 @@ type Extension struct {
66 helloAck bool110 helloAck bool
67 commands []extproto.RegisterCommandFromExt111 commands []extproto.RegisterCommandFromExt
68 tools []extproto.RegisterToolFromExt112 tools []extproto.RegisterToolFromExt
113+ skills []*skills.Skill
114+
115+ writeMu sync.Mutex // serializes concurrent host→extension frames
116+
117+ // lifecycleFrames decouples session-event delivery from the host's
118+ // synchronous control paths. The queue preserves lifecycle ordering while
119+ // bounding backpressure from an extension that stops reading stdin.
120+ lifecycleFrames chan []byte
121+ lifecycleStop chan struct{}
122+ lifecycleStopOnce sync.Once
69 123
70 // readyCh is closed when the extension sends a ReadyFromExt124 // readyCh is closed when the extension sends a ReadyFromExt
71 // frame, or when the host gives up waiting (registrationGrace).125 // frame, or when the host gives up waiting (registrationGrace).
@@ -130,6 +184,12 @@ type HostHooks interface {
130 OpenPanel(extName string, spec extproto.PanelSpec)184 OpenPanel(extName string, spec extproto.PanelSpec)
131 UpdatePanel(extName, panelID, title string, lines []string, footer string)185 UpdatePanel(extName, panelID, title string, lines []string, footer string)
132 ClosePanel(extName, panelID string)186 ClosePanel(extName, panelID string)
187+
188+ // SetStatus and SetWidget maintain persistent extension-owned UI state.
189+ // Hosts without a persistent UI may ignore these calls.
190+ SetStatus(extName, key, level, text string)
191+ SetWidget(extName, id, position, title string, lines []string)
192+ ClearWidget(extName, id string)
133 }193 }
134 194
135 // AlertHostHooks is an optional extension to HostHooks. Hosts that support195 // AlertHostHooks is an optional extension to HostHooks. Hosts that support
@@ -173,6 +233,16 @@ type Manager struct {
173 // by the host so it can rebuild the agent's tool registry with233 // by the host so it can rebuild the agent's tool registry with
174 // the freshly-registered extension tools.234 // the freshly-registered extension tools.
175 onReload func()235 onReload func()
236+
237+ // activeSession is replayed to extensions loaded after the current
238+ // session was opened, such as after /reload-ext.
239+ activeSession *sessionEventState
240+ sessionEmitMu sync.Mutex
241+}
242+
243+type sessionEventState struct {
244+ event extproto.EventFromHost
245+ states map[string]json.RawMessage
176 }246 }
177 247
178 // New constructs an empty Manager. Call Discover to populate it from248 // New constructs an empty Manager. Call Discover to populate it from
@@ -287,6 +357,7 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
287 return nil357 return nil
288 }358 }
289 359
360+ bundledSkills, skillErrs := loadManifestSkills(dir, mf)
290 ext := &Extension{361 ext := &Extension{
291 Manifest: mf,362 Manifest: mf,
292 Dir: dir,363 Dir: dir,
@@ -296,6 +367,12 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
296 pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{},367 pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{},
297 eventSubs: map[string]struct{}{},368 eventSubs: map[string]struct{}{},
298 interceptSubs: map[string]struct{}{},369 interceptSubs: map[string]struct{}{},
370+ skills: bundledSkills,
371+ lifecycleFrames: make(chan []byte, 64),
372+ lifecycleStop: make(chan struct{}),
373+ }
374+ for _, skillErr := range skillErrs {
375+ ext.diagnostics = append(ext.diagnostics, "skill: "+skillErr.Error())
299 }376 }
300 if mf.Exec != "" {377 if mf.Exec != "" {
301 if err := m.spawn(ctx, ext); err != nil {378 if err := m.spawn(ctx, ext); err != nil {
@@ -627,6 +704,12 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
627 // Trust the manifest's name; ignore mismatch from the hello.704 // Trust the manifest's name; ignore mismatch from the hello.
628 ext.helloAck = true705 ext.helloAck = true
629 706
707+ m.mu.RLock()
708+ var activeSession *extproto.SessionContext
709+ if m.activeSession != nil {
710+ activeSession = cloneSessionContext(m.activeSession.event.Session)
711+ }
712+ m.mu.RUnlock()
630 ack, _ := extproto.Encode(extproto.HelloAckFromHost{713 ack, _ := extproto.Encode(extproto.HelloAckFromHost{
631 Type: "hello_ack",714 Type: "hello_ack",
632 ProtocolVersion: extproto.ProtocolVersion,715 ProtocolVersion: extproto.ProtocolVersion,
@@ -636,6 +719,7 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
636 CWD: m.cwd,719 CWD: m.cwd,
637 ExtensionDir: ext.Dir,720 ExtensionDir: ext.Dir,
638 DataDir: ext.Dir,721 DataDir: ext.Dir,
722+ Session: activeSession,
639 })723 })
640 if _, err := stdin.Write(ack); err != nil {724 if _, err := stdin.Write(ack); err != nil {
641 return fmt.Errorf("send hello_ack (stderr log: %s): %w", logPath, err)725 return fmt.Errorf("send hello_ack (stderr log: %s): %w", logPath, err)
@@ -643,7 +727,8 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
643 727
644 cleanup = false728 cleanup = false
645 729
646- // Spin up the read loop now that the handshake is done.730+ // Spin up the lifecycle writer and read loop now that the handshake is done.
731+ ext.startLifecycleWriter()
647 go m.readLoop(ext, scanner)732 go m.readLoop(ext, scanner)
648 733
649 // Compatibility shim: extensions built against the phase-1 SDK734 // Compatibility shim: extensions built against the phase-1 SDK
@@ -737,6 +822,7 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
737 }822 }
738 }823 }
739 m.mu.Unlock()824 m.mu.Unlock()
825+ ext.stopLifecycleWriter()
740 ext.readyOnce.Do(func() { close(ext.readyCh) })826 ext.readyOnce.Do(func() { close(ext.readyCh) })
741 fmt.Fprintf(ext.logFile, "[zot] extension %s read loop exited at %s\n", ext.Manifest.Name, time.Now().Format(time.RFC3339))827 fmt.Fprintf(ext.logFile, "[zot] extension %s read loop exited at %s\n", ext.Manifest.Name, time.Now().Format(time.RFC3339))
742 }()828 }()
@@ -800,6 +886,7 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
800 }886 }
801 case "ready":887 case "ready":
802 ext.readyOnce.Do(func() { close(ext.readyCh) })888 ext.readyOnce.Do(func() { close(ext.readyCh) })
889+ m.replaySessionEvent(ext)
803 case "subscribe":890 case "subscribe":
804 var sub extproto.SubscribeFromExt891 var sub extproto.SubscribeFromExt
805 if err := json.Unmarshal(line, &sub); err == nil {892 if err := json.Unmarshal(line, &sub); err == nil {
@@ -849,21 +936,38 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
849 }936 }
850 case "notify":937 case "notify":
851 var n extproto.NotifyFromExt938 var n extproto.NotifyFromExt
852- if err := json.Unmarshal(line, &n); err == nil {939+ if err := json.Unmarshal(line, &n); err == nil && m.hooks != nil {
853 m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)940 m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)
854 }941 }
942+ case "status":
943+ var s extproto.StatusFromExt
944+ if err := json.Unmarshal(line, &s); err == nil && m.hooks != nil {
945+ m.hooks.SetStatus(ext.Manifest.Name, s.Key, s.Level, s.Text)
946+ }
947+ case "widget":
948+ var w extproto.WidgetFromExt
949+ if err := json.Unmarshal(line, &w); err == nil && m.hooks != nil {
950+ m.hooks.SetWidget(ext.Manifest.Name, w.ID, w.Position, w.Title, w.Lines)
951+ }
952+ case "widget_clear":
953+ var w extproto.ClearWidgetFromExt
954+ if err := json.Unmarshal(line, &w); err == nil && m.hooks != nil {
955+ m.hooks.ClearWidget(ext.Manifest.Name, w.ID)
956+ }
855 case "alert":957 case "alert":
856 var a extproto.AlertFromExt958 var a extproto.AlertFromExt
857- if err := json.Unmarshal(line, &a); err == nil {959+ if err := json.Unmarshal(line, &a); err == nil && m.hooks != nil {
858 if hooks, ok := m.hooks.(AlertHostHooks); ok {960 if hooks, ok := m.hooks.(AlertHostHooks); ok {
859 hooks.Alert(ext.Manifest.Name, a.AlertRequest)961 hooks.Alert(ext.Manifest.Name, a.AlertRequest)
860 }962 }
861 }963 }
862 case "clear_notes":964 case "clear_notes":
863- m.hooks.ClearNotes(ext.Manifest.Name)965+ if m.hooks != nil {
966+ m.hooks.ClearNotes(ext.Manifest.Name)
967+ }
864 case "submit":968 case "submit":
865 var s extproto.SubmitFromExt969 var s extproto.SubmitFromExt
866- if err := json.Unmarshal(line, &s); err == nil {970+ if err := json.Unmarshal(line, &s); err == nil && m.hooks != nil {
867 text := strings.TrimSpace(s.Text)971 text := strings.TrimSpace(s.Text)
868 if text != "" {972 if text != "" {
869 m.hooks.Submit(text)973 m.hooks.Submit(text)
@@ -877,7 +981,7 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
877 var s extproto.SubmitSlashFromExt981 var s extproto.SubmitSlashFromExt
878 if err := json.Unmarshal(line, &s); err == nil {982 if err := json.Unmarshal(line, &s); err == nil {
879 text := strings.TrimSpace(s.Text)983 text := strings.TrimSpace(s.Text)
880- if strings.HasPrefix(text, "/") {984+ if strings.HasPrefix(text, "/") && m.hooks != nil {
881 m.hooks.SubmitSlash(text)985 m.hooks.SubmitSlash(text)
882 } else {986 } else {
883 fmt.Fprintf(ext.logFile, "[zot] submit_slash refused (not a slash command): %q\n", s.Text)987 fmt.Fprintf(ext.logFile, "[zot] submit_slash refused (not a slash command): %q\n", s.Text)
@@ -901,17 +1005,17 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
901 }1005 }
902 case "open_panel":1006 case "open_panel":
903 var op extproto.OpenPanelFromExt1007 var op extproto.OpenPanelFromExt
904- if err := json.Unmarshal(line, &op); err == nil {1008+ if err := json.Unmarshal(line, &op); err == nil && m.hooks != nil {
905 m.hooks.OpenPanel(ext.Manifest.Name, op.Panel)1009 m.hooks.OpenPanel(ext.Manifest.Name, op.Panel)
906 }1010 }
907 case "panel_render":1011 case "panel_render":
908 var pr extproto.PanelRenderFromExt1012 var pr extproto.PanelRenderFromExt
909- if err := json.Unmarshal(line, &pr); err == nil {1013+ if err := json.Unmarshal(line, &pr); err == nil && m.hooks != nil {
910 m.hooks.UpdatePanel(ext.Manifest.Name, pr.PanelID, pr.Title, pr.Lines, pr.Footer)1014 m.hooks.UpdatePanel(ext.Manifest.Name, pr.PanelID, pr.Title, pr.Lines, pr.Footer)
911 }1015 }
912 case "panel_close":1016 case "panel_close":
913 var pc extproto.PanelCloseFromExt1017 var pc extproto.PanelCloseFromExt
914- if err := json.Unmarshal(line, &pc); err == nil {1018+ if err := json.Unmarshal(line, &pc); err == nil && m.hooks != nil {
915 m.hooks.ClosePanel(ext.Manifest.Name, pc.PanelID)1019 m.hooks.ClosePanel(ext.Manifest.Name, pc.PanelID)
916 }1020 }
917 case "shutdown_ack":1021 case "shutdown_ack":
@@ -923,6 +1027,109 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
923 }1027 }
924 }1028 }
925 1029
1030+const lifecycleWriteGrace = 250 * time.Millisecond
1031+
1032+func (ext *Extension) writeFrame(frame []byte) (int, error) {
1033+ if ext == nil || ext.stdin == nil {
1034+ return 0, fmt.Errorf("extension stdin is closed")
1035+ }
1036+ ext.writeMu.Lock()
1037+ defer ext.writeMu.Unlock()
1038+ return ext.stdin.Write(frame)
1039+}
1040+
1041+// tryWriteLifecycleFrame bounds only a lifecycle writer's wait for the shared
1042+// writer lock. Synchronous host frames retain their existing write behavior;
1043+// a stalled lifecycle snapshot is dropped rather than delaying them.
1044+func (ext *Extension) tryWriteLifecycleFrame(frame []byte) error {
1045+ if ext == nil || ext.stdin == nil {
1046+ return fmt.Errorf("extension stdin is closed")
1047+ }
1048+ if !ext.writeMu.TryLock() {
1049+ timer := time.NewTimer(lifecycleWriteGrace)
1050+ defer timer.Stop()
1051+ ticker := time.NewTicker(5 * time.Millisecond)
1052+ defer ticker.Stop()
1053+ for {
1054+ select {
1055+ case <-timer.C:
1056+ return fmt.Errorf("extension lifecycle write is blocked")
1057+ case <-ticker.C:
1058+ if ext.writeMu.TryLock() {
1059+ goto locked
1060+ }
1061+ }
1062+ }
1063+ }
1064+locked:
1065+ defer ext.writeMu.Unlock()
1066+ _, err := ext.stdin.Write(frame)
1067+ return err
1068+}
1069+
1070+func (ext *Extension) startLifecycleWriter() {
1071+ if ext.lifecycleFrames == nil {
1072+ ext.lifecycleFrames = make(chan []byte, 64)
1073+ }
1074+ if ext.lifecycleStop == nil {
1075+ ext.lifecycleStop = make(chan struct{})
1076+ }
1077+ go ext.lifecycleWriter()
1078+}
1079+
1080+func (ext *Extension) stopLifecycleWriter() {
1081+ if ext == nil || ext.lifecycleStop == nil {
1082+ return
1083+ }
1084+ ext.lifecycleStopOnce.Do(func() { close(ext.lifecycleStop) })
1085+}
1086+
1087+func (ext *Extension) enqueueLifecycleFrame(frame []byte) bool {
1088+ if ext == nil {
1089+ return false
1090+ }
1091+ if ext.lifecycleFrames == nil || ext.lifecycleStop == nil {
1092+ _, err := ext.writeFrame(frame)
1093+ return err == nil
1094+ }
1095+ copyFrame := append([]byte(nil), frame...)
1096+ select {
1097+ case <-ext.lifecycleStop:
1098+ return false
1099+ default:
1100+ }
1101+ select {
1102+ case ext.lifecycleFrames <- copyFrame:
1103+ return true
1104+ default:
1105+ // Prefer the newest lifecycle snapshot when a broken extension stops
1106+ // draining its pipe. The latest event still carries the current state.
1107+ select {
1108+ case <-ext.lifecycleFrames:
1109+ default:
1110+ }
1111+ select {
1112+ case ext.lifecycleFrames <- copyFrame:
1113+ return true
1114+ default:
1115+ return false
1116+ }
1117+ }
1118+}
1119+
1120+func (ext *Extension) lifecycleWriter() {
1121+ for {
1122+ select {
1123+ case <-ext.lifecycleStop:
1124+ return
1125+ case frame := <-ext.lifecycleFrames:
1126+ if err := ext.tryWriteLifecycleFrame(frame); err != nil {
1127+ return
1128+ }
1129+ }
1130+ }
1131+}
1132+
926 func (ext *Extension) recordDiagnostic(msg string) {1133 func (ext *Extension) recordDiagnostic(msg string) {
927 ext.mu.Lock()1134 ext.mu.Lock()
928 defer ext.mu.Unlock()1135 defer ext.mu.Unlock()
@@ -1075,6 +1282,24 @@ func (m *Manager) Tools() []ToolInfo {
1075 return out1282 return out
1076 }1283 }
1077 1284
1285+// Skills returns extension-bundled skills in deterministic extension-name
1286+// order. The build layer merges them after user skills and before built-ins.
1287+func (m *Manager) Skills() []*skills.Skill {
1288+ m.mu.RLock()
1289+ names := make([]string, 0, len(m.ext))
1290+ for name := range m.ext {
1291+ names = append(names, name)
1292+ }
1293+ sort.Strings(names)
1294+ var out []*skills.Skill
1295+ for _, name := range names {
1296+ ext := m.ext[name]
1297+ out = append(out, ext.skills...)
1298+ }
1299+ m.mu.RUnlock()
1300+ return out
1301+}
1302+
1078 // HasTool reports whether name is registered by any extension.1303 // HasTool reports whether name is registered by any extension.
1079 func (m *Manager) HasTool(name string) bool {1304 func (m *Manager) HasTool(name string) bool {
1080 m.mu.RLock()1305 m.mu.RLock()
@@ -1106,7 +1331,7 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess
1106 Name: name,1331 Name: name,
1107 Args: args,1332 Args: args,
1108 })1333 })
1109- if _, err := ext.stdin.Write(frame); err != nil {1334+ if _, err := ext.writeFrame(frame); err != nil {
1110 ext.mu.Lock()1335 ext.mu.Lock()
1111 delete(ext.pendingTool, id)1336 delete(ext.pendingTool, id)
1112 ext.mu.Unlock()1337 ext.mu.Unlock()
@@ -1172,7 +1397,7 @@ func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Du
1172 Name: name,1397 Name: name,
1173 Args: args,1398 Args: args,
1174 })1399 })
1175- if _, err := ext.stdin.Write(frame); err != nil {1400+ if _, err := ext.writeFrame(frame); err != nil {
1176 ext.mu.Lock()1401 ext.mu.Lock()
1177 delete(ext.pending, id)1402 delete(ext.pending, id)
1178 ext.mu.Unlock()1403 ext.mu.Unlock()
@@ -1206,7 +1431,7 @@ func (m *Manager) SendPanelKey(extName, panelID, key, text string) error {
1206 return fmt.Errorf("no extension %q", extName)1431 return fmt.Errorf("no extension %q", extName)
1207 }1432 }
1208 frame, _ := extproto.Encode(extproto.PanelKeyFromHost{Type: "panel_key", PanelID: panelID, Key: key, Text: text})1433 frame, _ := extproto.Encode(extproto.PanelKeyFromHost{Type: "panel_key", PanelID: panelID, Key: key, Text: text})
1209- _, err := ext.stdin.Write(frame)1434+ _, err := ext.writeFrame(frame)
1210 return err1435 return err
1211 }1436 }
1212 1437
@@ -1218,7 +1443,7 @@ func (m *Manager) SendPanelClose(extName, panelID string) error {
1218 return fmt.Errorf("no extension %q", extName)1443 return fmt.Errorf("no extension %q", extName)
1219 }1444 }
1220 frame, _ := extproto.Encode(extproto.PanelCloseFromHost{Type: "panel_close", PanelID: panelID})1445 frame, _ := extproto.Encode(extproto.PanelCloseFromHost{Type: "panel_close", PanelID: panelID})
1221- _, err := ext.stdin.Write(frame)1446+ _, err := ext.writeFrame(frame)
1222 return err1447 return err
1223 }1448 }
1224 1449
@@ -1238,7 +1463,7 @@ func stopExtensions(exts []*Extension, gracePeriod time.Duration) {
1238 continue1463 continue
1239 }1464 }
1240 if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {1465 if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {
1241- _, _ = ext.stdin.Write(frame)1466+ _, _ = ext.writeFrame(frame)
1242 }1467 }
1243 _ = ext.stdin.Close()1468 _ = ext.stdin.Close()
1244 }1469 }
M packages/agent/extensions/manager_test.go +85 -0
@@ -3,6 +3,7 @@ package extensions
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "io"
6 "os"7 "os"
7 8
8 "github.com/patriceckhart/zot/packages/agent/extproto"9 "github.com/patriceckhart/zot/packages/agent/extproto"
@@ -14,6 +15,30 @@ import (
14 "time"15 "time"
15 )16 )
16 17
18+func TestLifecycleWriteDropsWhenSynchronousWriterOwnsLock(t *testing.T) {
19+ ext := &Extension{stdin: &nopWriteCloser{}}
20+ ext.writeMu.Lock()
21+ defer ext.writeMu.Unlock()
22+
23+ done := make(chan error, 1)
24+ go func() { done <- ext.tryWriteLifecycleFrame([]byte("lifecycle")) }()
25+ select {
26+ case err := <-done:
27+ if err == nil {
28+ t.Fatal("lifecycle write unexpectedly acquired the synchronous writer lock")
29+ }
30+ case <-time.After(lifecycleWriteGrace + time.Second):
31+ t.Fatal("lifecycle write remained blocked on the synchronous writer lock")
32+ }
33+}
34+
35+type nopWriteCloser struct{}
36+
37+func (*nopWriteCloser) Write(data []byte) (int, error) { return len(data), nil }
38+func (*nopWriteCloser) Close() error { return nil }
39+
40+var _ io.WriteCloser = (*nopWriteCloser)(nil)
41+
17 // stubHooks records every callback so the test can assert on them.42 // stubHooks records every callback so the test can assert on them.
18 type stubHooks struct {43 type stubHooks struct {
19 mu sync.Mutex44 mu sync.Mutex
@@ -68,6 +93,9 @@ func (s *stubHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
68 }93 }
69 func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}94 func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
70 func (s *stubHooks) ClosePanel(string, string) {}95 func (s *stubHooks) ClosePanel(string, string) {}
96+func (s *stubHooks) SetStatus(string, string, string, string) {}
97+func (s *stubHooks) SetWidget(string, string, string, string, []string) {}
98+func (s *stubHooks) ClearWidget(string, string) {}
71 99
72 // writeMockExtension creates a minimal extension on disk that uses a100 // writeMockExtension creates a minimal extension on disk that uses a
73 // shell script (or batch file on windows) to drive the protocol. The101 // shell script (or batch file on windows) to drive the protocol. The
@@ -259,6 +287,63 @@ func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
259 }287 }
260 }288 }
261 289
290+func TestManifestBundlesSkillsWithSafePrecedence(t *testing.T) {
291+ tmp := t.TempDir()
292+ extRoot := filepath.Join(tmp, "extensions")
293+ extDir := filepath.Join(extRoot, "bundle")
294+ if err := os.MkdirAll(filepath.Join(extDir, "skills", "phases"), 0o755); err != nil {
295+ t.Fatal(err)
296+ }
297+ if err := os.WriteFile(filepath.Join(extDir, "skills", "phases", "SKILL.md"), []byte("---\nname: phases\ndescription: bundled phases\n---\nbody\n"), 0o644); err != nil {
298+ t.Fatal(err)
299+ }
300+ manifest := `{"name":"bundle","skills":["skills"],"description":"bundle"}`
301+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
302+ t.Fatal(err)
303+ }
304+ if err := os.WriteFile(filepath.Join(extDir, "theme.json"), []byte(`{"name":"bundle","colors":{"dark":{"accent":1}}}`), 0o644); err != nil {
305+ t.Fatal(err)
306+ }
307+
308+ mgr := New(tmp, "", "0.0.0-test", "", "", &stubHooks{})
309+ if errs := mgr.Discover(context.Background()); len(errs) > 0 {
310+ t.Fatalf("discover errors: %v", errs)
311+ }
312+ defer mgr.Stop(10 * time.Millisecond)
313+ bundled := mgr.Skills()
314+ if len(bundled) != 1 || bundled[0].Name != "phases" || bundled[0].Source != "extension bundle" {
315+ t.Fatalf("bundled skills = %#v", bundled)
316+ }
317+ if _, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{"../outside"}}); len(errs) == 0 {
318+ t.Fatal("path traversal manifest was accepted")
319+ }
320+ for _, path := range []string{"", filepath.Join(string(filepath.Separator), "outside")} {
321+ if _, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{path}}); len(errs) == 0 {
322+ t.Fatalf("unsafe skill path %q was accepted", path)
323+ }
324+ }
325+ if runtime.GOOS != "windows" {
326+ outside := filepath.Join(tmp, "outside", "SKILL.md")
327+ if err := os.MkdirAll(filepath.Dir(outside), 0o755); err != nil {
328+ t.Fatal(err)
329+ }
330+ if err := os.WriteFile(outside, []byte("---\nname: escape\ndescription: outside\n---\nbody\n"), 0o644); err != nil {
331+ t.Fatal(err)
332+ }
333+ unsafeDir := filepath.Join(extDir, "unsafe")
334+ if err := os.MkdirAll(unsafeDir, 0o755); err != nil {
335+ t.Fatal(err)
336+ }
337+ if err := os.Symlink(outside, filepath.Join(unsafeDir, "SKILL.md")); err != nil {
338+ t.Fatal(err)
339+ }
340+ loaded, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{"unsafe"}})
341+ if len(errs) == 0 || len(loaded) != 0 {
342+ t.Fatalf("symlinked skill outside extension was accepted: skills=%#v errors=%v", loaded, errs)
343+ }
344+ }
345+}
346+
262 func TestManagerSpawnAndInvoke(t *testing.T) {347 func TestManagerSpawnAndInvoke(t *testing.T) {
263 tmp := t.TempDir()348 tmp := t.TempDir()
264 extRoot := filepath.Join(tmp, "extensions")349 extRoot := filepath.Join(tmp, "extensions")
M packages/agent/extensions/tool.go +14 -1
@@ -18,6 +18,15 @@ import (
18 // One concrete type instead of a closure-driven anonymous tool18 // One concrete type instead of a closure-driven anonymous tool
19 // keeps the schema, name, and ownership inspectable for logs and19 // keeps the schema, name, and ownership inspectable for logs and
20 // dialogs.20 // dialogs.
21+// ToolResultDetails identifies extension metadata carried out of the
22+// subprocess. State is persisted with the active session when present and
23+// is never serialized into a provider request.
24+type ToolResultDetails struct {
25+ Extension string
26+ Tool string
27+ State json.RawMessage
28+}
29+
21 type extensionTool struct {30 type extensionTool struct {
22 name string31 name string
23 description string32 description string
@@ -90,7 +99,11 @@ func (t *extensionTool) Execute(ctx context.Context, args json.RawMessage, _ fun
90 // Defensive: an empty content slice would confuse the model.99 // Defensive: an empty content slice would confuse the model.
91 out.Content = []provider.Content{provider.TextBlock{Text: "(extension returned no content)"}}100 out.Content = []provider.Content{provider.TextBlock{Text: "(extension returned no content)"}}
92 }101 }
93- out.Details = map[string]any{"extension": t.extension, "tool": t.name}102+ out.Details = ToolResultDetails{
103+ Extension: t.extension,
104+ Tool: t.name,
105+ State: append(json.RawMessage(nil), resp.Details...),
106+ }
94 return out, nil107 return out, nil
95 }108 }
96 109
M packages/agent/extproto/extproto.go +54 -8
@@ -64,6 +64,9 @@ type EventInterceptResponseFromExt struct {
64 Reason string `json:"reason,omitempty"`64 Reason string `json:"reason,omitempty"`
65 ModifiedArgs json.RawMessage `json:"modified_args,omitempty"`65 ModifiedArgs json.RawMessage `json:"modified_args,omitempty"`
66 ReplaceText string `json:"replace_text,omitempty"`66 ReplaceText string `json:"replace_text,omitempty"`
67+ // Context is hidden, bounded context for the upcoming model turn. It is
68+ // not appended to the transcript or shown as a user message.
69+ Context string `json:"context,omitempty"`
67 }70 }
68 71
69 type ToolResultFromExt struct {72 type ToolResultFromExt struct {
@@ -72,6 +75,9 @@ type ToolResultFromExt struct {
72 Content []ContentBlock `json:"content"`75 Content []ContentBlock `json:"content"`
73 IsError bool `json:"is_error,omitempty"`76 IsError bool `json:"is_error,omitempty"`
74 ActivateTools []string `json:"activate_tools,omitempty"`77 ActivateTools []string `json:"activate_tools,omitempty"`
78+ // Details is extension-owned metadata. The host never includes it in
79+ // provider requests, but may persist it with the active session.
80+ Details json.RawMessage `json:"details,omitempty"`
75 }81 }
76 82
77 type ContentBlock struct {83 type ContentBlock struct {
@@ -121,6 +127,31 @@ type PanelCloseFromExt struct {
121 PanelID string `json:"panel_id"`127 PanelID string `json:"panel_id"`
122 }128 }
123 129
130+// StatusFromExt sets or replaces one persistent status item owned by an
131+// extension. An empty text clears the item.
132+type StatusFromExt struct {
133+ Type string `json:"type"`
134+ Key string `json:"key"`
135+ Level string `json:"level,omitempty"`
136+ Text string `json:"text,omitempty"`
137+}
138+
139+// WidgetFromExt sets or replaces one persistent widget owned by an
140+// extension. Position is a host-defined placement hint; interactive hosts
141+// currently support "above_input" and treat unknown values as that default.
142+type WidgetFromExt struct {
143+ Type string `json:"type"`
144+ ID string `json:"id"`
145+ Position string `json:"position,omitempty"`
146+ Title string `json:"title,omitempty"`
147+ Lines []string `json:"lines,omitempty"`
148+}
149+
150+type ClearWidgetFromExt struct {
151+ Type string `json:"type"`
152+ ID string `json:"id"`
153+}
154+
124 const AlertKindBell = "bell"155 const AlertKindBell = "bell"
125 156
126 // AlertRequest is a host-owned alert request. Extensions provide semantic157 // AlertRequest is a host-owned alert request. Extensions provide semantic
@@ -174,14 +205,25 @@ type ShutdownAckFromExt struct {
174 }205 }
175 206
176 type HelloAckFromHost struct {207 type HelloAckFromHost struct {
177- Type string `json:"type"`208+ Type string `json:"type"`
178- ProtocolVersion int `json:"protocol_version"`209+ ProtocolVersion int `json:"protocol_version"`
179- ZotVersion string `json:"zot_version"`210+ ZotVersion string `json:"zot_version"`
180- Provider string `json:"provider"`211+ Provider string `json:"provider"`
181- Model string `json:"model"`212+ Model string `json:"model"`
182- CWD string `json:"cwd"`213+ CWD string `json:"cwd"`
183- ExtensionDir string `json:"extension_dir,omitempty"`214+ ExtensionDir string `json:"extension_dir,omitempty"`
184- DataDir string `json:"data_dir,omitempty"`215+ DataDir string `json:"data_dir,omitempty"`
216+ Session *SessionContext `json:"session,omitempty"`
217+}
218+
219+// SessionContext identifies the active transcript branch. ID is the current
220+// session/branch id; ParentID is empty for a root session.
221+type SessionContext struct {
222+ ID string `json:"id"`
223+ ParentID string `json:"parent_id,omitempty"`
224+ Path string `json:"path,omitempty"`
225+ CWD string `json:"cwd,omitempty"`
226+ ForkPoint int `json:"fork_point,omitempty"`
185 }227 }
186 228
187 type CommandInvokedFromHost struct {229 type CommandInvokedFromHost struct {
@@ -209,6 +251,10 @@ type EventFromHost struct {
209 ToolArgs json.RawMessage `json:"tool_args,omitempty"`251 ToolArgs json.RawMessage `json:"tool_args,omitempty"`
210 ToolPreview string `json:"tool_preview,omitempty"`252 ToolPreview string `json:"tool_preview,omitempty"`
211 Text string `json:"text,omitempty"`253 Text string `json:"text,omitempty"`
254+ Session *SessionContext `json:"session,omitempty"`
255+ // State is private to the receiving extension. The manager selects the
256+ // matching extension-owned snapshot before sending the event.
257+ State json.RawMessage `json:"state,omitempty"`
212 }258 }
213 259
214 type EventInterceptFromHost struct {260 type EventInterceptFromHost struct {
M packages/agent/modes/ext_notes_test.go +65 -0
@@ -1,6 +1,7 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "fmt"
4 "strings"5 "strings"
5 "testing"6 "testing"
6 7
@@ -44,3 +45,67 @@ func TestClearNotesNoMatchKeepsNotes(t *testing.T) {
44 t.Fatalf("expected note to survive, got %d", len(i.extNotes))45 t.Fatalf("expected note to survive, got %d", len(i.extNotes))
45 }46 }
46 }47 }
48+
49+func TestPersistentExtensionWidgetRowsAreBounded(t *testing.T) {
50+ i := newNotesTestInteractive()
51+ lines := make([]string, 20)
52+ for idx := range lines {
53+ lines[idx] = "line"
54+ }
55+ i.SetWidget("extension", "widget", "above_input", "Title", lines)
56+
57+ i.mu.Lock()
58+ got := i.extensionChromeLinesLocked(80)
59+ i.mu.Unlock()
60+ if len(got) != maxExtensionWidgetRows {
61+ t.Fatalf("widget rows = %d, want %d: %v", len(got), maxExtensionWidgetRows, got)
62+ }
63+ if !strings.Contains(strings.Join(got, "\n"), "extension widgets truncated") {
64+ t.Fatalf("bounded widget rows omitted truncation marker: %v", got)
65+ }
66+}
67+
68+func TestPersistentExtensionStatusRowsAreBounded(t *testing.T) {
69+ i := newNotesTestInteractive()
70+ for n := 0; n < maxExtensionStatusRows+4; n++ {
71+ i.SetStatus("extension", fmt.Sprintf("status-%d", n), "info", "line")
72+ }
73+
74+ i.mu.Lock()
75+ got := i.extensionChromeLinesLocked(80)
76+ i.mu.Unlock()
77+ if len(got) != maxExtensionStatusRows {
78+ t.Fatalf("status rows = %d, want %d: %v", len(got), maxExtensionStatusRows, got)
79+ }
80+ if !strings.Contains(strings.Join(got, "\n"), "extension statuses truncated") {
81+ t.Fatalf("bounded status rows omitted truncation marker: %v", got)
82+ }
83+}
84+
85+func TestPersistentExtensionStatusAndWidget(t *testing.T) {
86+ i := newNotesTestInteractive()
87+ i.SetStatus("tasked-phases", "progress", "success", "2/4 tasks checked")
88+ i.SetWidget("tasked-phases", "plan", "above_input", "Tasked phases", []string{"Current phase: parse", "[ ] read files"})
89+
90+ i.mu.Lock()
91+ lines := i.extensionChromeLinesLocked(80)
92+ statusKey := i.extensionStatusesKeyLocked()
93+ widgetKey := i.extensionWidgetsKeyLocked()
94+ i.mu.Unlock()
95+ joined := strings.Join(lines, "\n")
96+ if !strings.Contains(joined, "2/4 tasks checked") || !strings.Contains(joined, "Tasked phases") || !strings.Contains(joined, "[ ] read files") {
97+ t.Fatalf("persistent extension chrome = %q", joined)
98+ }
99+ if !strings.Contains(statusKey, "tasked-phases/progress/success/2/4 tasks checked") {
100+ t.Fatalf("status cache key = %q", statusKey)
101+ }
102+ if !strings.Contains(widgetKey, "tasked-phases/plan/above_input/Tasked phases") {
103+ t.Fatalf("widget cache key = %q", widgetKey)
104+ }
105+
106+ i.SetStatus("tasked-phases", "progress", "", "")
107+ i.ClearWidget("tasked-phases", "plan")
108+ if len(i.extStatuses) != 0 || len(i.extWidgets) != 0 {
109+ t.Fatalf("persistent extension chrome was not cleared: statuses=%v widgets=%v", i.extStatuses, i.extWidgets)
110+ }
111+}
M packages/agent/modes/interactive.go +216 -1
@@ -7,6 +7,7 @@ import (
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9 "runtime"9 "runtime"
10+ "sort"
10 "strconv"11 "strconv"
11 "strings"12 "strings"
12 "sync"13 "sync"
@@ -376,6 +377,8 @@ type chatCacheKey struct {
376 statusErr string377 statusErr string
377 help string378 help string
378 extNotes string379 extNotes string
380+ extStatuses string
381+ extWidgets string
379 reloadErrors string382 reloadErrors string
380 updateAvailable bool383 updateAvailable bool
381 updateCurrent string384 updateCurrent string
@@ -392,6 +395,17 @@ type QuickModelShortcut struct {
392 Model string395 Model string
393 }396 }
394 397
398+type extensionStatus struct {
399+ Level string
400+ Text string
401+}
402+
403+type extensionWidget struct {
404+ Position string
405+ Title string
406+ Lines []string
407+}
408+
395 // SettingsStore persists user-toggleable settings surfaced by /settings.409 // SettingsStore persists user-toggleable settings surfaced by /settings.
396 type SettingsStore interface {410 type SettingsStore interface {
397 SetQuickModelShortcut(slot int, providerName, model string) error411 SetQuickModelShortcut(slot int, providerName, model string) error
@@ -480,6 +494,8 @@ type Interactive struct {
480 statusErr string494 statusErr string
481 statusOK string495 statusOK string
482 reloadStatusSeq uint64496 reloadStatusSeq uint64
497+ extStatuses map[string]map[string]extensionStatus
498+ extWidgets map[string]map[string]extensionWidget
483 liveBlock []string // live streaming/tool progress rendered outside scrollback499 liveBlock []string // live streaming/tool progress rendered outside scrollback
484 helpBlock []string // rendered above the chat when /help was typed500 helpBlock []string // rendered above the chat when /help was typed
485 cumUsage provider.Usage501 cumUsage provider.Usage
@@ -748,6 +764,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
748 sessionOpsDialog: newSessionOpsDialog(),764 sessionOpsDialog: newSessionOpsDialog(),
749 sessionTreeDialog: newSessionTreeDialog(),765 sessionTreeDialog: newSessionTreeDialog(),
750 extPanel: newExtPanelDialog(),766 extPanel: newExtPanelDialog(),
767+ extStatuses: map[string]map[string]extensionStatus{},
768+ extWidgets: map[string]map[string]extensionWidget{},
751 suggest: newSlashSuggester(),769 suggest: newSlashSuggester(),
752 fileSuggest: newFileSuggester(),770 fileSuggest: newFileSuggester(),
753 spin: newSpinner(cfg.Theme),771 spin: newSpinner(cfg.Theme),
@@ -1105,6 +1123,8 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
1105 statusErr: i.statusErr,1123 statusErr: i.statusErr,
1106 help: strings.Join(i.helpBlock, "\n"),1124 help: strings.Join(i.helpBlock, "\n"),
1107 extNotes: strings.Join(i.extNotes, "\n"),1125 extNotes: strings.Join(i.extNotes, "\n"),
1126+ extStatuses: i.extensionStatusesKeyLocked(),
1127+ extWidgets: i.extensionWidgetsKeyLocked(),
1108 reloadErrors: strings.Join(i.reloadErrors, "\n"),1128 reloadErrors: strings.Join(i.reloadErrors, "\n"),
1109 updateAvailable: i.updateInfo.Available,1129 updateAvailable: i.updateInfo.Available,
1110 updateCurrent: i.updateInfo.Current,1130 updateCurrent: i.updateInfo.Current,
@@ -1116,6 +1136,138 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
1116 }, true1136 }, true
1117 }1137 }
1118 1138
1139+func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string {
1140+ keys := make([]string, 0, len(groups))
1141+ for key := range groups {
1142+ keys = append(keys, key)
1143+ }
1144+ sort.Strings(keys)
1145+ return keys
1146+}
1147+
1148+func sortedNestedInnerKeys[T any](groups map[string]map[string]T, outer string) []string {
1149+ items := groups[outer]
1150+ keys := make([]string, 0, len(items))
1151+ for key := range items {
1152+ keys = append(keys, key)
1153+ }
1154+ sort.Strings(keys)
1155+ return keys
1156+}
1157+
1158+func (i *Interactive) extensionStatusesKeyLocked() string {
1159+ var sb strings.Builder
1160+ extNames := sortedNestedOuterKeys(i.extStatuses)
1161+ for _, extName := range extNames {
1162+ keys := sortedNestedInnerKeys(i.extStatuses, extName)
1163+ for _, key := range keys {
1164+ status := i.extStatuses[extName][key]
1165+ fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, key, status.Level, status.Text)
1166+ }
1167+ }
1168+ return sb.String()
1169+}
1170+
1171+func (i *Interactive) extensionWidgetsKeyLocked() string {
1172+ var sb strings.Builder
1173+ extNames := sortedNestedOuterKeys(i.extWidgets)
1174+ for _, extName := range extNames {
1175+ ids := sortedNestedInnerKeys(i.extWidgets, extName)
1176+ for _, id := range ids {
1177+ widget := i.extWidgets[extName][id]
1178+ fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, id, widget.Position, widget.Title)
1179+ for _, line := range widget.Lines {
1180+ sb.WriteString(line)
1181+ sb.WriteByte('\n')
1182+ }
1183+ }
1184+ }
1185+ return sb.String()
1186+}
1187+
1188+const maxExtensionWidgetRows = 12
1189+const maxExtensionStatusRows = 6
1190+
1191+func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
1192+ var out []string
1193+ bodyWidth := cols - 2
1194+ if bodyWidth < 1 {
1195+ bodyWidth = 1
1196+ }
1197+ widgetRows := 0
1198+ widgetTruncated := false
1199+ appendWidgetRow := func(line string) bool {
1200+ if widgetRows >= maxExtensionWidgetRows-1 {
1201+ widgetTruncated = true
1202+ return false
1203+ }
1204+ out = append(out, line)
1205+ widgetRows++
1206+ return true
1207+ }
1208+
1209+ stopWidgets := false
1210+ for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
1211+ if stopWidgets {
1212+ break
1213+ }
1214+ for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1215+ widget := i.extWidgets[extName][id]
1216+ label := " [" + extName + "]"
1217+ if widget.Title != "" {
1218+ label += " " + widget.Title
1219+ }
1220+ if !appendWidgetRow(i.cfg.Theme.FG256(i.cfg.Theme.Accent, truncateLine(label, cols))) {
1221+ stopWidgets = true
1222+ break
1223+ }
1224+ for _, line := range widget.Lines {
1225+ if !appendWidgetRow(" " + truncateLine(line, bodyWidth)) {
1226+ stopWidgets = true
1227+ break
1228+ }
1229+ }
1230+ if stopWidgets {
1231+ break
1232+ }
1233+ }
1234+ }
1235+ if widgetTruncated {
1236+ out = append(out, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols)))
1237+ }
1238+
1239+ statusRows := 0
1240+ statusTruncated := false
1241+ for _, extName := range sortedNestedOuterKeys(i.extStatuses) {
1242+ for _, key := range sortedNestedInnerKeys(i.extStatuses, extName) {
1243+ if statusRows >= maxExtensionStatusRows-1 {
1244+ statusTruncated = true
1245+ break
1246+ }
1247+ status := i.extStatuses[extName][key]
1248+ color := i.cfg.Theme.Muted
1249+ switch status.Level {
1250+ case "warn":
1251+ color = i.cfg.Theme.Warning
1252+ case "error":
1253+ color = i.cfg.Theme.Error
1254+ case "success":
1255+ color = i.cfg.Theme.Tool
1256+ }
1257+ label := " [" + extName + "] " + status.Text
1258+ out = append(out, i.cfg.Theme.FG256(color, truncateLine(label, cols)))
1259+ statusRows++
1260+ }
1261+ if statusTruncated {
1262+ break
1263+ }
1264+ }
1265+ if statusTruncated {
1266+ out = append(out, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension statuses truncated ...", cols)))
1267+ }
1268+ return out
1269+}
1270+
1119 func (i *Interactive) buildChatLocked(cols int) []string {1271 func (i *Interactive) buildChatLocked(cols int) []string {
1120 if i.agent != nil {1272 if i.agent != nil {
1121 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())1273 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
@@ -1599,13 +1751,15 @@ func (i *Interactive) redraw() {
1599 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))1751 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
1600 }1752 }
1601 1753
1754+ extensionLines := i.extensionChromeLinesLocked(cols)
1755+
1602 // Bottom-sticky sections (always visible, never scroll). Each1756 // Bottom-sticky sections (always visible, never scroll). Each
1603 // non-empty subsection (dialog, suggest popup, sliding-in queue)1757 // non-empty subsection (dialog, suggest popup, sliding-in queue)
1604 // is preceded by one blank row so it has air above the chat1758 // is preceded by one blank row so it has air above the chat
1605 // content. The status block and editor get their own dedicated1759 // content. The status block and editor get their own dedicated
1606 // blanks so spacing stays consistent whether or not a dialog or1760 // blanks so spacing stays consistent whether or not a dialog or
1607 // popup is showing.1761 // popup is showing.
1608- bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(statusLines)+len(edLines)+9)1762+ bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(edLines)+9)
1609 inputStartRow := -11763 inputStartRow := -1
1610 if len(dialog) > 0 {1764 if len(dialog) > 0 {
1611 bottom = append(bottom, "")1765 bottom = append(bottom, "")
@@ -1627,6 +1781,7 @@ func (i *Interactive) redraw() {
1627 workingBelow := workingPosition == tui.WorkingPositionBelowInput1781 workingBelow := workingPosition == tui.WorkingPositionBelowInput
1628 1782
1629 var aboveInput []string1783 var aboveInput []string
1784+ aboveInput = append(aboveInput, extensionLines...)
1630 if !statusBelow {1785 if !statusBelow {
1631 aboveInput = append(aboveInput, statusLines...)1786 aboveInput = append(aboveInput, statusLines...)
1632 }1787 }
@@ -3486,6 +3641,66 @@ func (i *Interactive) Display(extName, text string) {
3486 i.invalidate()3641 i.invalidate()
3487 }3642 }
3488 3643
3644+// SetStatus replaces one persistent status item owned by an extension.
3645+func (i *Interactive) SetStatus(extName, key, level, text string) {
3646+ if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {
3647+ return
3648+ }
3649+ i.mu.Lock()
3650+ if i.extStatuses == nil {
3651+ i.extStatuses = map[string]map[string]extensionStatus{}
3652+ }
3653+ if strings.TrimSpace(text) == "" {
3654+ if items := i.extStatuses[extName]; items != nil {
3655+ delete(items, key)
3656+ if len(items) == 0 {
3657+ delete(i.extStatuses, extName)
3658+ }
3659+ }
3660+ } else {
3661+ if i.extStatuses[extName] == nil {
3662+ i.extStatuses[extName] = map[string]extensionStatus{}
3663+ }
3664+ i.extStatuses[extName][key] = extensionStatus{Level: level, Text: text}
3665+ }
3666+ i.mu.Unlock()
3667+ i.invalidate()
3668+}
3669+
3670+// SetWidget replaces one persistent widget owned by an extension.
3671+func (i *Interactive) SetWidget(extName, id, position, title string, lines []string) {
3672+ if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {
3673+ return
3674+ }
3675+ i.mu.Lock()
3676+ if i.extWidgets == nil {
3677+ i.extWidgets = map[string]map[string]extensionWidget{}
3678+ }
3679+ if i.extWidgets[extName] == nil {
3680+ i.extWidgets[extName] = map[string]extensionWidget{}
3681+ }
3682+ i.extWidgets[extName][id] = extensionWidget{
3683+ Position: position,
3684+ Title: title,
3685+ Lines: append([]string(nil), lines...),
3686+ }
3687+ i.mu.Unlock()
3688+ i.invalidate()
3689+}
3690+
3691+// ClearWidget removes one persistent widget owned by an extension.
3692+func (i *Interactive) ClearWidget(extName, id string) {
3693+ i.mu.Lock()
3694+ if items := i.extWidgets[extName]; items != nil {
3695+ delete(items, id)
3696+ if len(items) == 0 {
3697+ delete(i.extWidgets, extName)
3698+ }
3699+ }
3700+ i.mu.Unlock()
3701+ i.invalidate()
3702+}
3703+
3489 func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {3704 func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
3490 i.mu.Lock()3705 i.mu.Lock()
3491 defer i.mu.Unlock()3706 defer i.mu.Unlock()
M packages/agent/rpc.go +28 -4
@@ -38,6 +38,9 @@ import (
38 //38 //
39 // Responses (stdout): {"type":"response","id":"1","command":"prompt","success":true}39 // Responses (stdout): {"type":"response","id":"1","command":"prompt","success":true}
40 // Events (stdout): one JSON object per AgentEvent (same schema as --json mode).40 // Events (stdout): one JSON object per AgentEvent (same schema as --json mode).
41+// Extensions may additionally emit ext_status with extension/key/level/text,
42+// ext_widget with extension/id/position/title/lines, and ext_widget_clear
43+// with extension/id when loaded extensions publish persistent UI state.
41 //44 //
42 // Auth: if $ZOTCORE_RPC_TOKEN is set, the first command must be45 // Auth: if $ZOTCORE_RPC_TOKEN is set, the first command must be
43 // {"type":"hello","token":"..."} or the connection is closed.46 // {"type":"hello","token":"..."} or the connection is closed.
@@ -89,9 +92,9 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
89 }92 }
90 return true, "", r.ModifiedArgs93 return true, "", r.ModifiedArgs
91 }94 }
92- ag.BeforeTurn = func(step int) (bool, string) {95+ ag.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
93- r := extMgr.InterceptTurnStart(ctx, step)96+ r := extMgr.InterceptTurnStart(turnCtx, step)
94- return !r.Block, r.Reason97+ return !r.Block, r.Reason, r.Context
95 }98 }
96 ag.BeforeAssistantMessage = func(text string) (bool, string, string) {99 ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
97 r := extMgr.InterceptAssistantMessage(ctx, text)100 r := extMgr.InterceptAssistantMessage(ctx, text)
@@ -120,7 +123,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
120 }123 }
121 })124 })
122 125
123- extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})126+ extMgr.EmitSessionEvent("session_start", nil, nil)
124 127
125 return server.run(os.Stdin)128 return server.run(os.Stdin)
126 }129 }
@@ -176,6 +179,27 @@ func (h *rpcExtHooks) Insert(string) {} /
176 func (h *rpcExtHooks) OpenPanel(string, extproto.PanelSpec) {}179 func (h *rpcExtHooks) OpenPanel(string, extproto.PanelSpec) {}
177 func (h *rpcExtHooks) UpdatePanel(string, string, string, []string, string) {}180 func (h *rpcExtHooks) UpdatePanel(string, string, string, []string, string) {}
178 func (h *rpcExtHooks) ClosePanel(string, string) {}181 func (h *rpcExtHooks) ClosePanel(string, string) {}
182+func (h *rpcExtHooks) SetStatus(extName, key, level, text string) {
183+ if h.server != nil {
184+ h.server.writeExtensionEvent(map[string]any{
185+ "type": "ext_status", "extension": extName, "key": key, "level": level, "text": text,
186+ })
187+ }
188+}
189+func (h *rpcExtHooks) SetWidget(extName, id, position, title string, lines []string) {
190+ if h.server != nil {
191+ h.server.writeExtensionEvent(map[string]any{
192+ "type": "ext_widget", "extension": extName, "id": id, "position": position, "title": title, "lines": lines,
193+ })
194+ }
195+}
196+func (h *rpcExtHooks) ClearWidget(extName, id string) {
197+ if h.server != nil {
198+ h.server.writeExtensionEvent(map[string]any{
199+ "type": "ext_widget_clear", "extension": extName, "id": id,
200+ })
201+ }
202+}
179 203
180 const maxPendingRPCExtEvents = 64204 const maxPendingRPCExtEvents = 64
181 205
A packages/agent/skills/bundled_test.go +119 -0
@@ -0,0 +1,119 @@
1+package skills
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "runtime"
7+ "testing"
8+)
9+
10+func TestLoadBundledLoadsDeclaredDirectoriesInOrder(t *testing.T) {
11+ tmp := t.TempDir()
12+ collectionA := filepath.Join(tmp, "extension-a", "skills")
13+ collectionB := filepath.Join(tmp, "extension-b", "skills")
14+ direct := filepath.Join(tmp, "extension-c", "one-skill")
15+
16+ writeSkill := func(dir, name, description string) {
17+ t.Helper()
18+ if err := os.MkdirAll(dir, 0o755); err != nil {
19+ t.Fatal(err)
20+ }
21+ body := "---\nname: " + name + "\ndescription: " + description + "\n---\nbody\n"
22+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil {
23+ t.Fatal(err)
24+ }
25+ }
26+
27+ writeSkill(filepath.Join(collectionA, "shared"), "shared", "first declaration")
28+ writeSkill(filepath.Join(collectionA, "alpha"), "alpha", "from a")
29+ writeSkill(filepath.Join(collectionB, "shared"), "shared", "later declaration")
30+ writeSkill(filepath.Join(collectionB, "beta"), "beta", "from b")
31+ writeSkill(direct, "direct", "from a direct skill directory")
32+
33+ got, errs := LoadBundled([]BundledSkillDir{
34+ {Dir: collectionA, Source: "extension alpha"},
35+ {Dir: collectionB, Source: "extension beta"},
36+ {Dir: direct, Source: "extension direct"},
37+ })
38+ if len(errs) != 0 {
39+ t.Fatalf("LoadBundled() errors = %v", errs)
40+ }
41+
42+ if len(got) != 4 {
43+ t.Fatalf("LoadBundled() returned %d skills, want 4: %#v", len(got), got)
44+ }
45+ if shared := FindByName(got, "shared"); shared == nil {
46+ t.Fatal("shared skill was not loaded")
47+ } else {
48+ if shared.Description != "first declaration" || shared.Source != "extension alpha" {
49+ t.Fatalf("shared skill = %#v, want first declaration and source label", shared)
50+ }
51+ }
52+ if beta := FindByName(got, "beta"); beta == nil || beta.Source != "extension beta" {
53+ t.Fatalf("beta skill = %#v, want extension beta source", beta)
54+ }
55+ if directSkill := FindByName(got, "direct"); directSkill == nil {
56+ t.Fatal("direct skill directory was not loaded")
57+ } else if directSkill.Path != filepath.Join(direct, "SKILL.md") {
58+ t.Fatalf("direct skill path = %q, want %q", directSkill.Path, filepath.Join(direct, "SKILL.md"))
59+ }
60+}
61+
62+func TestLoadBundledRejectsSkillFilesOutsideRoot(t *testing.T) {
63+ tmp := t.TempDir()
64+ root := filepath.Join(tmp, "extension")
65+ bundle := filepath.Join(root, "skills")
66+ outside := filepath.Join(tmp, "outside", "SKILL.md")
67+ if err := os.MkdirAll(bundle, 0o755); err != nil {
68+ t.Fatal(err)
69+ }
70+ if err := os.MkdirAll(filepath.Dir(outside), 0o755); err != nil {
71+ t.Fatal(err)
72+ }
73+ if err := os.WriteFile(outside, []byte("---\nname: escape\ndescription: outside\n---\nbody\n"), 0o644); err != nil {
74+ t.Fatal(err)
75+ }
76+ got, errs := LoadBundled([]BundledSkillDir{{Dir: filepath.Dir(outside), Root: root, Source: "extension test"}})
77+ if len(errs) == 0 || len(got) != 0 {
78+ t.Fatalf("outside bundle was accepted: skills=%#v errors=%v", got, errs)
79+ }
80+ if runtime.GOOS == "windows" {
81+ return
82+ }
83+ if err := os.Symlink(outside, filepath.Join(bundle, "SKILL.md")); err != nil {
84+ t.Fatal(err)
85+ }
86+
87+ got, errs = LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension test"}})
88+ if len(errs) == 0 || len(got) != 0 {
89+ t.Fatalf("outside skill was accepted: skills=%#v errors=%v", got, errs)
90+ }
91+}
92+
93+func TestLoadBundledDoesNotIncludeUserOrBuiltinSkills(t *testing.T) {
94+ tmp := t.TempDir()
95+ dir := filepath.Join(tmp, "skills", "declared")
96+ if err := os.MkdirAll(dir, 0o755); err != nil {
97+ t.Fatal(err)
98+ }
99+ content := "---\nname: declared\ndescription: declared only\n---\nbody\n"
100+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
101+ t.Fatal(err)
102+ }
103+
104+ got, errs := LoadBundled([]BundledSkillDir{{Dir: filepath.Dir(dir), Source: "extension test"}})
105+ if len(errs) != 0 {
106+ t.Fatalf("LoadBundled() errors = %v", errs)
107+ }
108+ if FindByName(got, "declared") == nil {
109+ t.Fatalf("declared skill missing from %#v", got)
110+ }
111+ for _, skill := range got {
112+ if skill.Builtin {
113+ t.Fatalf("LoadBundled() returned builtin skill %q", skill.Name)
114+ }
115+ if skill.Name != "declared" {
116+ t.Fatalf("LoadBundled() returned unexpected skill %q", skill.Name)
117+ }
118+ }
119+}
M packages/agent/skills/skills.go +127 -2
@@ -73,6 +73,17 @@ type Skill struct {
73 Permissions map[string][]string73 Permissions map[string][]string
74 }74 }
75 75
76+// BundledSkillDir identifies a skill directory shipped by an extension.
77+// The directory contains one subdirectory per skill, each with SKILL.md.
78+// Root is optional; when set, every loaded SKILL.md must resolve beneath it.
79+// Extension managers set Root to keep symlinked skill files inside the
80+// extension directory.
81+type BundledSkillDir struct {
82+ Dir string
83+ Source string
84+ Root string
85+}
86+
76 // VisibleSkills returns the subset of skills users should see in87 // VisibleSkills returns the subset of skills users should see in
77 // pickers, /skills, and other interactive surfaces. Built-ins are88 // pickers, /skills, and other interactive surfaces. Built-ins are
78 // hidden because they're implementation detail; the model still89 // hidden because they're implementation detail; the model still
@@ -102,13 +113,22 @@ func VisibleSkills(in []*Skill) []*Skill {
102 // Errors per skill are returned alongside the partial result so a113 // Errors per skill are returned alongside the partial result so a
103 // single broken file doesn't suppress the rest.114 // single broken file doesn't suppress the rest.
104 func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []error) {115 func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []error) {
116+ return DiscoverWithBundled(zotHome, cwd, userHome, includeUser, nil)
117+}
118+
119+// DiscoverWithBundled merges normal user skills with extension-bundled skills
120+// and built-ins. Precedence is user/project locations, then bundled skills in
121+// declaration order, then built-ins. The first matching name wins.
122+func DiscoverWithBundled(zotHome, cwd, userHome string, includeUser bool, bundled []BundledSkillDir) ([]*Skill, []error) {
105 var errs []error123 var errs []error
106 seen := map[string]*Skill{}124 seen := map[string]*Skill{}
107 if includeUser {125 if includeUser {
108 errs = append(errs, scanUserSkills(zotHome, cwd, userHome, seen)...)126 errs = append(errs, scanUserSkills(zotHome, cwd, userHome, seen)...)
109 }127 }
110- // Built-ins fill in any name the user didn't already provide128+ for _, dir := range bundled {
111- // (or every name, when includeUser is false).129+ errs = append(errs, scanBundledSkills(dir, seen)...)
130+ }
131+ // Built-ins fill in any name the user or an extension didn't provide.
112 for _, s := range loadBuiltins() {132 for _, s := range loadBuiltins() {
113 if _, dup := seen[s.Name]; dup {133 if _, dup := seen[s.Name]; dup {
114 continue134 continue
@@ -123,6 +143,111 @@ func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []erro
123 return out, errs143 return out, errs
124 }144 }
125 145
146+// LoadBundled loads only the skills declared by extension manifests. The
147+// first declaration with a given name wins; built-in and user locations are
148+// intentionally not considered here.
149+func LoadBundled(bundled []BundledSkillDir) ([]*Skill, []error) {
150+ seen := map[string]*Skill{}
151+ var errs []error
152+ for _, dir := range bundled {
153+ errs = append(errs, scanBundledSkills(dir, seen)...)
154+ }
155+ out := make([]*Skill, 0, len(seen))
156+ for _, s := range seen {
157+ out = append(out, s)
158+ }
159+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
160+ return out, errs
161+}
162+
163+func pathWithinRoot(root, path string) (bool, error) {
164+ rootResolved, err := filepath.EvalSymlinks(root)
165+ if err != nil {
166+ return false, err
167+ }
168+ rootAbs, err := filepath.Abs(rootResolved)
169+ if err != nil {
170+ return false, err
171+ }
172+ resolved, err := filepath.EvalSymlinks(path)
173+ if err != nil {
174+ return false, err
175+ }
176+ resolvedAbs, err := filepath.Abs(resolved)
177+ if err != nil {
178+ return false, err
179+ }
180+ rel, err := filepath.Rel(rootAbs, resolvedAbs)
181+ if err != nil {
182+ return false, err
183+ }
184+ return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)), nil
185+}
186+
187+// scanBundledSkills walks one caller-resolved bundled-skill directory and
188+// populates `seen` with first-match-wins per name.
189+func scanBundledSkills(dir BundledSkillDir, seen map[string]*Skill) []error {
190+ if strings.TrimSpace(dir.Dir) == "" {
191+ return nil
192+ }
193+ label := strings.TrimSpace(dir.Source)
194+ if label == "" {
195+ label = "extension (unnamed)"
196+ }
197+ var errs []error
198+ loadOne := func(path, fallbackName string) {
199+ if dir.Root != "" {
200+ safe, err := pathWithinRoot(dir.Root, path)
201+ if err != nil {
202+ if !os.IsNotExist(err) {
203+ errs = append(errs, fmt.Errorf("%s: validate path: %w", path, err))
204+ }
205+ return
206+ }
207+ if !safe {
208+ errs = append(errs, fmt.Errorf("%s: resolved path escapes bundled extension directory", path))
209+ return
210+ }
211+ }
212+ s, err := load(path, label)
213+ if err != nil {
214+ if !os.IsNotExist(err) {
215+ errs = append(errs, fmt.Errorf("%s: %w", path, err))
216+ }
217+ return
218+ }
219+ if s.Name == "" {
220+ s.Name = fallbackName
221+ }
222+ if _, dup := seen[s.Name]; !dup {
223+ seen[s.Name] = s
224+ }
225+ }
226+ skillPath := filepath.Join(dir.Dir, "SKILL.md")
227+ if info, err := os.Stat(skillPath); err == nil {
228+ if !info.IsDir() {
229+ loadOne(skillPath, filepath.Base(dir.Dir))
230+ return errs
231+ }
232+ } else if !os.IsNotExist(err) {
233+ errs = append(errs, fmt.Errorf("%s: %w", skillPath, err))
234+ }
235+ entries, err := os.ReadDir(dir.Dir)
236+ if err != nil {
237+ if !os.IsNotExist(err) {
238+ errs = append(errs, fmt.Errorf("%s: %w", dir.Dir, err))
239+ }
240+ return errs
241+ }
242+ for _, entry := range entries {
243+ if !entry.IsDir() {
244+ continue
245+ }
246+ loadOne(filepath.Join(dir.Dir, entry.Name(), "SKILL.md"), entry.Name())
247+ }
248+ return errs
249+}
250+
126 // scanUserSkills walks the user-skill search dirs and populates251 // scanUserSkills walks the user-skill search dirs and populates
127 // `seen` with first-match-wins per name. Split out so Discover's252 // `seen` with first-match-wins per name. Split out so Discover's
128 // includeUser=false path doesn't have to skip over a giant block.253 // includeUser=false path doesn't have to skip over a giant block.
M packages/agent/skills/skills_test.go +53 -0
@@ -94,6 +94,59 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
94 }94 }
95 }95 }
96 96
97+func TestDiscoverWithBundledSkillsPrecedence(t *testing.T) {
98+ t.Setenv("ZOT_AGENT_SKILLS", "")
99+ tmp := t.TempDir()
100+ cwd := filepath.Join(tmp, "project")
101+ userDir := filepath.Join(cwd, ".zot", "skills")
102+ bundleA := filepath.Join(tmp, "ext-a", "skills")
103+ bundleB := filepath.Join(tmp, "ext-b", "skills")
104+ write := func(root, name, description string) {
105+ dir := filepath.Join(root, name)
106+ if err := os.MkdirAll(dir, 0o755); err != nil {
107+ t.Fatal(err)
108+ }
109+ body := "---\nname: " + name + "\ndescription: " + description + "\n---\nbody\n"
110+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil {
111+ t.Fatal(err)
112+ }
113+ }
114+ write(userDir, "shared", "user")
115+ write(bundleA, "shared", "bundle-a")
116+ write(bundleA, "only-a", "only a")
117+ write(bundleB, "only-a", "bundle-b")
118+ write(bundleB, "only-b", "only b")
119+
120+ list, errs := DiscoverWithBundled("", cwd, "", true, []BundledSkillDir{
121+ {Dir: bundleA, Source: "extension a"},
122+ {Dir: bundleB, Source: "extension b"},
123+ })
124+ if len(errs) > 0 {
125+ t.Fatalf("errs: %v", errs)
126+ }
127+ if got := FindByName(list, "shared"); got == nil || got.Description != "user" {
128+ t.Fatalf("user precedence = %#v", got)
129+ }
130+ if got := FindByName(list, "only-a"); got == nil || got.Description != "only a" || got.Source != "extension a" {
131+ t.Fatalf("bundle declaration precedence = %#v", got)
132+ }
133+ if got := FindByName(list, "only-b"); got == nil || got.Source != "extension b" {
134+ t.Fatalf("second bundle skill = %#v", got)
135+ }
136+ builtins := loadBuiltins()
137+ if len(builtins) == 0 {
138+ t.Fatal("expected at least one builtin skill")
139+ }
140+ write(bundleA, builtins[0].Name, "bundled override")
141+ list, errs = DiscoverWithBundled("", cwd, "", true, []BundledSkillDir{{Dir: bundleA, Source: "extension a"}})
142+ if len(errs) > 0 {
143+ t.Fatalf("builtin override errors: %v", errs)
144+ }
145+ if got := FindByName(list, builtins[0].Name); got == nil || got.Description != "bundled override" || got.Builtin {
146+ t.Fatalf("bundled builtin override = %#v", got)
147+ }
148+}
149+
97 func TestVisibleSkillsHidesBuiltins(t *testing.T) {150 func TestVisibleSkillsHidesBuiltins(t *testing.T) {
98 in := []*Skill{151 in := []*Skill{
99 {Name: "user-one"},152 {Name: "user-one"},
M packages/agent/swarm_agent.go +2 -0
@@ -78,8 +78,10 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
78 return err78 return err
79 }79 }
80 r.Provider, r.Model = providerName, model80 r.Provider, r.Model = providerName, model
81+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
81 defer sess.Close()82 defer sess.Close()
82 }83 }
84+ announceSession(extMgr, sess)
83 85
84 // Open the inbox listener BEFORE emitting agent_ready so the86 // Open the inbox listener BEFORE emitting agent_ready so the
85 // supervisor can dial through on the very first send. The87 // supervisor can dial through on the very first send. The
M packages/core/agent.go +60 -4
@@ -8,6 +8,7 @@ import (
8 "strings"8 "strings"
9 "sync"9 "sync"
10 "time"10 "time"
11+ "unicode/utf8"
11 12
12 "github.com/patriceckhart/zot/packages/provider"13 "github.com/patriceckhart/zot/packages/provider"
13 )14 )
@@ -49,6 +50,20 @@ type Agent struct {
49 // limiting, business-hour gates, and deny-by-default setups.50 // limiting, business-hour gates, and deny-by-default setups.
50 BeforeTurn func(step int) (allowed bool, reason string)51 BeforeTurn func(step int) (allowed bool, reason string)
51 52
53+ // BeforeTurnContext is the richer turn-start hook. It receives the
54+ // current turn context so blocking interception can honor cancellation.
55+ // It may return a bounded, non-transcript context string that is appended
56+ // to the provider request's system prompt for this turn only. When set, it
57+ // replaces BeforeTurn so a host can combine approval and context
58+ // collection into one lifecycle round trip.
59+ BeforeTurnContext func(ctx context.Context, step int) (allowed bool, reason, context string)
60+
61+ // OnToolResult, if set, receives extension/tool metadata immediately
62+ // after each tool call completes. Details are host-owned metadata and
63+ // are never sent to the provider; hosts may persist selected details
64+ // with the active session.
65+ OnToolResult func(id string, result ToolResult)
66+
52 // BeforeAssistantMessage, if set, is called after the model's67 // BeforeAssistantMessage, if set, is called after the model's
53 // final assistant message is assembled but before it's appended68 // final assistant message is assembled but before it's appended
54 // to the transcript. Returning (allowed=false) suppresses both69 // to the transcript. Returning (allowed=false) suppresses both
@@ -384,7 +399,20 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
384 }399 }
385 400
386 sink(EvTurnStart{Step: step})401 sink(EvTurnStart{Step: step})
387- if a.BeforeTurn != nil {402+ turnContext := ""
403+ if a.BeforeTurnContext != nil {
404+ var allowed bool
405+ var reason string
406+ allowed, reason, turnContext = a.BeforeTurnContext(ctx, step)
407+ if !allowed {
408+ if reason == "" {
409+ reason = "turn blocked by extension guard"
410+ }
411+ sink(EvTurnEnd{Stop: provider.StopError, Err: fmt.Errorf("%s", reason)})
412+ sink(EvDone{})
413+ return nil
414+ }
415+ } else if a.BeforeTurn != nil {
388 if allowed, reason := a.BeforeTurn(step); !allowed {416 if allowed, reason := a.BeforeTurn(step); !allowed {
389 if reason == "" {417 if reason == "" {
390 reason = "turn blocked by extension guard"418 reason = "turn blocked by extension guard"
@@ -401,7 +429,7 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
401 err error429 err error
402 )430 )
403 for attempt := 0; ; attempt++ {431 for attempt := 0; ; attempt++ {
404- stop, assistantMsg, err = a.oneTurn(ctx, sink)432+ stop, assistantMsg, err = a.oneTurn(ctx, sink, turnContext)
405 sink(EvTurnEnd{Stop: stop, Err: err})433 sink(EvTurnEnd{Stop: stop, Err: err})
406 if err == nil || !a.canRetryError(err, attempt) {434 if err == nil || !a.canRetryError(err, attempt) {
407 break435 break
@@ -561,14 +589,21 @@ func (a *Agent) dropLastAssistantMessage() {
561 589
562 // oneTurn calls the LLM once, forwards events, returns the stop reason590 // oneTurn calls the LLM once, forwards events, returns the stop reason
563 // and the assembled assistant message (already appended to the transcript).591 // and the assembled assistant message (already appended to the transcript).
564-func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.StopReason, provider.Message, error) {592+func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext string) (provider.StopReason, provider.Message, error) {
565 fastMode := a.FastModeEnabled()593 fastMode := a.FastModeEnabled()
566 if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil {594 if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil {
567 return provider.StopError, provider.Message{}, err595 return provider.StopError, provider.Message{}, err
568 }596 }
597+ system := a.System
598+ if contextText := boundedTurnContext(turnContext); contextText != "" {
599+ if system != "" {
600+ system += "\n\n"
601+ }
602+ system += "[Extension context for this turn]\n" + contextText
603+ }
569 req := provider.Request{604 req := provider.Request{
570 Model: a.Model,605 Model: a.Model,
571- System: a.System,606+ System: system,
572 // Repair any dangling tool_use blocks before sending. A turn607 // Repair any dangling tool_use blocks before sending. A turn
573 // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a608 // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a
574 // dev server, etc.) can leave an assistant tool_use with no609 // dev server, etc.) can leave an assistant tool_use with no
@@ -708,6 +743,9 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
708 addedTools = append(addedTools, name)743 addedTools = append(addedTools, name)
709 }744 }
710 }745 }
746+ if a.OnToolResult != nil {
747+ a.OnToolResult(tc.ID, res)
748+ }
711 sink(EvToolResult{ID: tc.ID, Result: res})749 sink(EvToolResult{ID: tc.ID, Result: res})
712 }750 }
713 751
@@ -821,6 +859,24 @@ func mirrorToolImagesAsUser(msg provider.Message) provider.Message {
821 return provider.Message{Role: provider.RoleUser, Content: content, Time: time.Now()}859 return provider.Message{Role: provider.RoleUser, Content: content, Time: time.Now()}
822 }860 }
823 861
862+const (
863+ maxTurnContextBytes = 16 * 1024
864+ turnContextTruncatedMarker = "\n[extension context truncated]"
865+)
866+
867+func boundedTurnContext(text string) string {
868+ text = strings.TrimSpace(text)
869+ if len(text) <= maxTurnContextBytes {
870+ return text
871+ }
872+ limit := maxTurnContextBytes - len(turnContextTruncatedMarker)
873+ cut := limit
874+ for cut > 0 && !utf8.RuneStart(text[cut]) {
875+ cut--
876+ }
877+ return strings.TrimSpace(text[:cut]) + turnContextTruncatedMarker
878+}
879+
824 func containsString(values []string, want string) bool {880 func containsString(values []string, want string) bool {
825 for _, value := range values {881 for _, value := range values {
826 if value == want {882 if value == want {
M packages/core/agent_retry_test.go +41 -0
@@ -8,6 +8,7 @@ import (
8 "sync/atomic"8 "sync/atomic"
9 "testing"9 "testing"
10 "time"10 "time"
11+ "unicode/utf8"
11 12
12 "github.com/patriceckhart/zot/packages/provider"13 "github.com/patriceckhart/zot/packages/provider"
13 )14 )
@@ -221,6 +222,46 @@ func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-cha
221 return out, nil222 return out, nil
222 }223 }
223 224
225+func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) {
226+ client := &captureClient{}
227+ a := NewAgent(client, "fake-model", "system", Registry{})
228+ a.BeforeTurnContext = func(context.Context, int) (bool, string, string) {
229+ return true, "", "current phase: parse files"
230+ }
231+ if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
232+ t.Fatalf("Prompt returned %v", err)
233+ }
234+ if !strings.Contains(client.lastReq.System, "current phase: parse files") {
235+ t.Fatalf("request system prompt = %q; missing hidden context", client.lastReq.System)
236+ }
237+ if len(a.Messages()) != 2 {
238+ t.Fatalf("transcript message count = %d; hidden context must not be persisted", len(a.Messages()))
239+ }
240+ for _, msg := range a.Messages() {
241+ if strings.Contains(extractText(msg), "current phase: parse files") {
242+ t.Fatal("hidden turn context leaked into transcript")
243+ }
244+ }
245+}
246+
247+func TestBoundedTurnContextPreservesUTF8AndLimit(t *testing.T) {
248+ contextText := strings.Repeat("界", maxTurnContextBytes)
249+ got := boundedTurnContext(contextText)
250+ if len(got) > maxTurnContextBytes {
251+ t.Fatalf("bounded context bytes = %d, want <= %d", len(got), maxTurnContextBytes)
252+ }
253+ if !utf8.ValidString(got) {
254+ t.Fatal("bounded context is not valid UTF-8")
255+ }
256+ if !strings.HasSuffix(got, turnContextTruncatedMarker) {
257+ tail := got
258+ if len(tail) > 64 {
259+ tail = tail[len(tail)-64:]
260+ }
261+ t.Fatalf("bounded context missing truncation marker: %q", tail)
262+ }
263+}
264+
224 func TestAgentPropagatesMaxTokens(t *testing.T) {265 func TestAgentPropagatesMaxTokens(t *testing.T) {
225 client := &captureClient{}266 client := &captureClient{}
226 a := NewAgent(client, "fake-model", "system", Registry{})267 a := NewAgent(client, "fake-model", "system", Registry{})
M packages/core/core_test.go +73 -0
@@ -2,6 +2,7 @@ package core
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "os"6 "os"
6 "testing"7 "testing"
7 "time"8 "time"
@@ -163,6 +164,78 @@ func TestNewSessionAtPathCreatesAtExplicitPath(t *testing.T) {
163 }164 }
164 }165 }
165 166
167+func TestSessionExtensionStateRoundTrip(t *testing.T) {
168+ dir := t.TempDir()
169+ sess, err := NewSession(dir, "/tmp/project", "anthropic", "claude", "test")
170+ if err != nil {
171+ t.Fatal(err)
172+ }
173+ if err := sess.AppendMessage(provider.Message{
174+ Role: provider.RoleUser,
175+ Content: []provider.Content{provider.TextBlock{Text: "prompt"}},
176+ }); err != nil {
177+ t.Fatal(err)
178+ }
179+ state := json.RawMessage(`{"phases":[{"id":"phase-1"}]}`)
180+ if err := sess.AppendExtensionState("tasked-phases", state); err != nil {
181+ t.Fatal(err)
182+ }
183+ if err := sess.Close(); err != nil {
184+ t.Fatal(err)
185+ }
186+
187+ reopened, _, err := OpenSession(sess.Path)
188+ if err != nil {
189+ t.Fatal(err)
190+ }
191+ firstReopened := reopened
192+ t.Cleanup(func() { _ = firstReopened.Close() })
193+ if got := string(reopened.ExtensionState["tasked-phases"]); got != string(state) {
194+ t.Fatalf("extension state = %q, want %q", got, state)
195+ }
196+ if err := reopened.AppendExtensionState("tasked-phases", nil); err != nil {
197+ t.Fatal(err)
198+ }
199+ if _, ok := reopened.ExtensionState["tasked-phases"]; ok {
200+ t.Fatal("nil extension state did not clear the snapshot")
201+ }
202+ if err := reopened.Close(); err != nil {
203+ t.Fatal(err)
204+ }
205+ reopened, _, err = OpenSession(sess.Path)
206+ if err != nil {
207+ t.Fatal(err)
208+ }
209+ t.Cleanup(func() { _ = reopened.Close() })
210+ if _, ok := reopened.ExtensionState["tasked-phases"]; ok {
211+ t.Fatal("cleared extension state was restored after reopening")
212+ }
213+}
214+
215+func TestSessionRetainsExtensionStateWithoutMessages(t *testing.T) {
216+ dir := t.TempDir()
217+ sess, err := NewSession(dir, "/tmp/project", "anthropic", "claude", "test")
218+ if err != nil {
219+ t.Fatal(err)
220+ }
221+ state := json.RawMessage(`{"version":1}`)
222+ if err := sess.AppendExtensionState("tasked-phases", state); err != nil {
223+ t.Fatal(err)
224+ }
225+ path := sess.Path
226+ if err := sess.Close(); err != nil {
227+ t.Fatal(err)
228+ }
229+ reopened, _, err := OpenSession(path)
230+ if err != nil {
231+ t.Fatal(err)
232+ }
233+ defer reopened.Close()
234+ if got := string(reopened.ExtensionState["tasked-phases"]); got != string(state) {
235+ t.Fatalf("extension state = %q, want %q", got, state)
236+ }
237+}
238+
166 func TestCostAdd(t *testing.T) {239 func TestCostAdd(t *testing.T) {
167 var c CostTracker240 var c CostTracker
168 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01})241 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01})
M packages/core/session.go +73 -16
@@ -26,9 +26,12 @@ type Session struct {
26 Meta SessionMeta26 Meta SessionMeta
27 // Title is the effective title after applying append-only rename rows.27 // Title is the effective title after applying append-only rename rows.
28 // Meta.Title remains the original meta-line value for compatibility.28 // Meta.Title remains the original meta-line value for compatibility.
29- Title string29+ Title string
30- writer *os.File30+ // ExtensionState contains the latest opaque state snapshot recorded by
31- buf *bufio.Writer31+ // each extension for this session/branch. It is never sent to providers.
32+ ExtensionState map[string]json.RawMessage
33+ writer *os.File
34+ buf *bufio.Writer
32 35
33 // freshFile is true when the file was created by NewSession (this36 // freshFile is true when the file was created by NewSession (this
34 // process owns it) and false when OpenSession reopened an existing37 // process owns it) and false when OpenSession reopened an existing
@@ -81,6 +84,8 @@ type sessionLine struct {
81 Messages *[]provider.Message `json:"messages,omitempty"`84 Messages *[]provider.Message `json:"messages,omitempty"`
82 Usage *provider.Usage `json:"usage,omitempty"`85 Usage *provider.Usage `json:"usage,omitempty"`
83 Cumulative *provider.Usage `json:"cumulative,omitempty"`86 Cumulative *provider.Usage `json:"cumulative,omitempty"`
87+ Extension string `json:"extension,omitempty"`
88+ State json.RawMessage `json:"state,omitempty"`
84 }89 }
85 90
86 type sessionLineHead struct {91 type sessionLineHead struct {
@@ -104,6 +109,7 @@ type SessionSnapshot struct {
104 Meta SessionMeta109 Meta SessionMeta
105 Title string110 Title string
106 Messages []provider.Message111 Messages []provider.Message
112+ ExtensionState map[string]json.RawMessage
107 UsageCheckpoints []SessionUsageCheckpoint113 UsageCheckpoints []SessionUsageCheckpoint
108 CompactionGeneration int114 CompactionGeneration int
109 }115 }
@@ -168,12 +174,13 @@ func newSessionAt(p, cwd, providerName, model, version string) (*Session, error)
168 return nil, err174 return nil, err
169 }175 }
170 s := &Session{176 s := &Session{
171- ID: id,177+ ID: id,
172- Path: p,178+ Path: p,
173- Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version},179+ Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version},
174- writer: f,180+ writer: f,
175- buf: bufio.NewWriter(f),181+ buf: bufio.NewWriter(f),
176- freshFile: true,182+ freshFile: true,
183+ ExtensionState: map[string]json.RawMessage{},
177 }184 }
178 if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {185 if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
179 f.Close()186 f.Close()
@@ -277,6 +284,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
277 }284 }
278 var rawCheckpoints []rawCheckpoint285 var rawCheckpoints []rawCheckpoint
279 titleFromRename := false286 titleFromRename := false
287+ extensionState := map[string]json.RawMessage{}
280 288
281 err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {289 err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
282 var head sessionLineHead290 var head sessionLineHead
@@ -350,6 +358,19 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
350 titleFromRename = true358 titleFromRename = true
351 snapshot.Title = *row.Title359 snapshot.Title = *row.Title
352 360
361+ case "extension_state":
362+ var row sessionLine
363+ if err := json.Unmarshal(line, &row); err != nil {
364+ return fmt.Errorf("line %d: invalid extension state row: %w", lineNo, err)
365+ }
366+ if row.Extension != "" && len(row.State) <= maxExtensionStateBytes {
367+ if len(row.State) == 0 || strings.TrimSpace(string(row.State)) == "null" {
368+ delete(extensionState, row.Extension)
369+ } else if json.Valid(row.State) {
370+ extensionState[row.Extension] = append(json.RawMessage(nil), row.State...)
371+ }
372+ }
373+
353 default:374 default:
354 return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)375 return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
355 }376 }
@@ -363,6 +384,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
363 }384 }
364 385
365 snapshot.CompactionGeneration = generation386 snapshot.CompactionGeneration = generation
387+ snapshot.ExtensionState = extensionState
366 388
367 // Repair only after compaction replacement. This makes synthetic tool389 // Repair only after compaction replacement. This makes synthetic tool
368 // results and the removal of orphaned results part of the same indexed390 // results and the removal of orphaned results part of the same indexed
@@ -703,12 +725,13 @@ func OpenSession(path string) (*Session, []provider.Message, error) {
703 return nil, nil, err725 return nil, nil, err
704 }726 }
705 s := &Session{727 s := &Session{
706- ID: snapshot.Meta.ID,728+ ID: snapshot.Meta.ID,
707- Path: path,729+ Path: path,
708- Meta: snapshot.Meta,730+ Meta: snapshot.Meta,
709- Title: NormalizeSessionTitle(snapshot.Title),731+ Title: NormalizeSessionTitle(snapshot.Title),
710- writer: out,732+ ExtensionState: snapshot.ExtensionState,
711- buf: bufio.NewWriter(out),733+ writer: out,
734+ buf: bufio.NewWriter(out),
712 }735 }
713 return s, snapshot.Messages, nil736 return s, snapshot.Messages, nil
714 }737 }
@@ -1110,6 +1133,40 @@ func (s *Session) AppendUsage(u, cum provider.Usage) error {
1110 return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum})1133 return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum})
1111 }1134 }
1112 1135
1136+const maxExtensionStateBytes = 256 * 1024
1137+
1138+// AppendExtensionState records the latest opaque extension snapshot for the
1139+// active session/branch. State is persisted in the session file but is never
1140+// included in provider requests. A nil or JSON null state clears the key.
1141+func (s *Session) AppendExtensionState(extension string, state json.RawMessage) error {
1142+ if s == nil {
1143+ return nil
1144+ }
1145+ extension = strings.TrimSpace(extension)
1146+ if extension == "" {
1147+ return fmt.Errorf("extension state: extension name is empty")
1148+ }
1149+ if len(state) > maxExtensionStateBytes {
1150+ return fmt.Errorf("extension state %q exceeds %d bytes", extension, maxExtensionStateBytes)
1151+ }
1152+ if len(state) > 0 && !json.Valid(state) {
1153+ return fmt.Errorf("extension state %q is not valid JSON", extension)
1154+ }
1155+ copyState := append(json.RawMessage(nil), state...)
1156+ if err := s.writeLine(sessionLine{Type: "extension_state", Extension: extension, State: copyState}); err != nil {
1157+ return fmt.Errorf("append extension state: %w", err)
1158+ }
1159+ if s.ExtensionState == nil {
1160+ s.ExtensionState = map[string]json.RawMessage{}
1161+ }
1162+ if len(copyState) == 0 || strings.TrimSpace(string(copyState)) == "null" {
1163+ delete(s.ExtensionState, extension)
1164+ } else {
1165+ s.ExtensionState[extension] = copyState
1166+ }
1167+ return nil
1168+}
1169+
1113 // Close flushes and closes the session file. If the session was1170 // Close flushes and closes the session file. If the session was
1114 // freshly created in this process and never had any messages1171 // freshly created in this process and never had any messages
1115 // appended (the user opened zot, looked around, and exited without1172 // appended (the user opened zot, looked around, and exited without
@@ -1121,7 +1178,7 @@ func (s *Session) Close() error {
1121 }1178 }
1122 flushErr := s.buf.Flush()1179 flushErr := s.buf.Flush()
1123 closeErr := s.writer.Close()1180 closeErr := s.writer.Close()
1124- if s.freshFile && s.messagesAppended == 0 {1181+ if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 {
1125 // Best-effort cleanup. We deliberately don't propagate the1182 // Best-effort cleanup. We deliberately don't propagate the
1126 // remove error: if it fails (file already gone, perms changed)1183 // remove error: if it fails (file already gone, perms changed)
1127 // the worst case is one stale empty file in the listing.1184 // the worst case is one stale empty file in the listing.
M packages/core/session_portable.go +109 -3
@@ -9,6 +9,7 @@ import (
9 "io"9 "io"
10 "os"10 "os"
11 "path/filepath"11 "path/filepath"
12+ "sort"
12 "strings"13 "strings"
13 "time"14 "time"
14 15
@@ -308,7 +309,11 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
308 limit++309 limit++
309 }310 }
310 311
311- return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions)312+ extensionState, err := readExtensionStateAtFork(parentPath, limit)
313+ if err != nil {
314+ return "", err
315+ }
316+ return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState)
312 }317 }
313 318
314 // BranchSessionHiddenFromHistory creates a hidden tree branch from a319 // BranchSessionHiddenFromHistory creates a hidden tree branch from a
@@ -334,10 +339,91 @@ func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segme
334 if limit > 0 && limit < len(segment.Messages) && messageHasToolCalls(segment.Messages[limit-1]) && segment.Messages[limit].Role == provider.RoleTool {339 if limit > 0 && limit < len(segment.Messages) && messageHasToolCalls(segment.Messages[limit-1]) && segment.Messages[limit].Role == provider.RoleTool {
335 limit++340 limit++
336 }341 }
337- return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true)342+ extensionState, err := readExtensionStateAtFork(parentPath, limit)
343+ if err != nil {
344+ return "", err
345+ }
346+ return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState)
338 }347 }
339 348
340-func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool) (string, error) {349+// readExtensionStateAtFork reconstructs the latest extension snapshot that was
350+// persisted at or before a branch boundary. Extension state is independent of
351+// provider messages, but its timeline still follows the effective message
352+// stream across compaction rows.
353+func readExtensionStateAtFork(path string, limit int) (map[string]json.RawMessage, error) {
354+ f, err := os.Open(path)
355+ if err != nil {
356+ return nil, fmt.Errorf("branch: open parent for extension state: %w", err)
357+ }
358+ defer f.Close()
359+
360+ state := make(map[string]json.RawMessage)
361+ forkState := make(map[string]json.RawMessage)
362+ cloneState := func(source map[string]json.RawMessage) map[string]json.RawMessage {
363+ clone := make(map[string]json.RawMessage, len(source))
364+ for name, raw := range source {
365+ clone[name] = append(json.RawMessage(nil), raw...)
366+ }
367+ return clone
368+ }
369+ applyState := func(target map[string]json.RawMessage, row sessionLine) {
370+ if len(row.State) == 0 || strings.TrimSpace(string(row.State)) == "null" {
371+ delete(target, row.Extension)
372+ return
373+ }
374+ if json.Valid(row.State) {
375+ target[row.Extension] = append(json.RawMessage(nil), row.State...)
376+ }
377+ }
378+ effectiveCount := 0
379+ err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
380+ var head sessionLineHead
381+ if err := json.Unmarshal(line, &head); err != nil {
382+ return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
383+ }
384+ switch head.Type {
385+ case "meta", "usage", "rename":
386+ return nil
387+ case "message":
388+ if _, err := hydrateMessage(line); err != nil {
389+ return fmt.Errorf("line %d: invalid message row: %w", lineNo, err)
390+ }
391+ effectiveCount++
392+ case "compaction":
393+ messages, err := hydrateCompaction(line)
394+ if err != nil {
395+ return fmt.Errorf("line %d: invalid compaction row: %w", lineNo, err)
396+ }
397+ effectiveCount = len(messages)
398+ // A compaction replaces the effective transcript. State that was
399+ // recorded anywhere before this boundary belongs to every fork
400+ // inside the replacement transcript, even when its old message
401+ // index was after the requested fork point.
402+ forkState = cloneState(state)
403+ case "extension_state":
404+ var row sessionLine
405+ if err := json.Unmarshal(line, &row); err != nil {
406+ return fmt.Errorf("line %d: invalid extension state row: %w", lineNo, err)
407+ }
408+ if row.Extension == "" || len(row.State) > maxExtensionStateBytes {
409+ return nil
410+ }
411+ applyState(state, row)
412+ if effectiveCount <= limit {
413+ applyState(forkState, row)
414+ }
415+ default:
416+ return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
417+ }
418+ return nil
419+ })
420+ if err != nil {
421+ return nil, fmt.Errorf("branch: read extension state: %w", err)
422+ }
423+ return forkState, nil
424+}
425+
426+func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage) (string, error) {
341 dir := SessionsDir(root, cwd)427 dir := SessionsDir(root, cwd)
342 if err := os.MkdirAll(dir, 0o755); err != nil {428 if err := os.MkdirAll(dir, 0o755); err != nil {
343 return "", err429 return "", err
@@ -404,6 +490,26 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages
404 }490 }
405 }491 }
406 492
493+ // Extension snapshots are branch state, not provider-visible transcript
494+ // content. Write the latest snapshot that existed at the fork point so a
495+ // child branch starts from the same extension state as its copied prefix.
496+ extensionNames := make([]string, 0, len(extensionState))
497+ for name := range extensionState {
498+ extensionNames = append(extensionNames, name)
499+ }
500+ sort.Strings(extensionNames)
501+ for _, name := range extensionNames {
502+ line, err := json.Marshal(sessionLine{Type: "extension_state", Extension: name, State: extensionState[name]})
503+ if err != nil {
504+ return "", fmt.Errorf("branch: marshal extension state: %w", err)
505+ }
506+ if _, err := bw.Write(line); err != nil {
507+ return "", fmt.Errorf("branch: write extension state: %w", err)
508+ }
509+ if err := bw.WriteByte('\n'); err != nil {
510+ return "", fmt.Errorf("branch: terminate extension state row: %w", err)
511+ }
512+ }
407 // Preserve every cumulative checkpoint that belongs to this prefix. The513 // Preserve every cumulative checkpoint that belongs to this prefix. The
408 // final two rows are needed to reconstruct LastTurnUsage as a delta.514 // final two rows are needed to reconstruct LastTurnUsage as a delta.
409 for _, checkpoint := range checkpoints {515 for _, checkpoint := range checkpoints {
M packages/core/session_portable_test.go +76 -0
@@ -491,6 +491,82 @@ func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) {
491 }491 }
492 }492 }
493 493
494+func TestBranchSessionCopiesExtensionStateAtFork(t *testing.T) {
495+ root := t.TempDir()
496+ cwd := "/project"
497+ parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
498+ if err != nil {
499+ t.Fatal(err)
500+ }
501+ if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}}); err != nil {
502+ t.Fatal(err)
503+ }
504+ if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
505+ t.Fatal(err)
506+ }
507+ if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second"}}}); err != nil {
508+ t.Fatal(err)
509+ }
510+ if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":2}`)); err != nil {
511+ t.Fatal(err)
512+ }
513+ if err := parent.Close(); err != nil {
514+ t.Fatal(err)
515+ }
516+
517+ branchPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
518+ if err != nil {
519+ t.Fatal(err)
520+ }
521+ branch, _, err := OpenSession(branchPath)
522+ if err != nil {
523+ t.Fatal(err)
524+ }
525+ t.Cleanup(func() { _ = branch.Close() })
526+ if got := string(branch.ExtensionState["tasked-phases"]); got != `{"version":1}` {
527+ t.Fatalf("branch extension state = %q, want version 1", got)
528+ }
529+}
530+
531+func TestBranchSessionCarriesStateAcrossCompactionBoundary(t *testing.T) {
532+ root := t.TempDir()
533+ cwd := "/project"
534+ parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
535+ if err != nil {
536+ t.Fatal(err)
537+ }
538+ for _, text := range []string{"before-a", "before-b"} {
539+ if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}); err != nil {
540+ t.Fatal(err)
541+ }
542+ }
543+ if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
544+ t.Fatal(err)
545+ }
546+ if err := parent.AppendCompaction([]provider.Message{
547+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "summary"}}},
548+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "tail"}}},
549+ }); err != nil {
550+ t.Fatal(err)
551+ }
552+ if err := parent.Close(); err != nil {
553+ t.Fatal(err)
554+ }
555+
556+ branchPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
557+ if err != nil {
558+ t.Fatal(err)
559+ }
560+ branch, _, err := OpenSession(branchPath)
561+ if err != nil {
562+ t.Fatal(err)
563+ }
564+ t.Cleanup(func() { _ = branch.Close() })
565+ if got := string(branch.ExtensionState["tasked-phases"]); got != `{"version":1}` {
566+ t.Fatalf("compacted branch extension state = %q, want version 1", got)
567+ }
568+}
569+
494 // TestBuildSessionTree verifies parent/child edges are rebuilt570 // TestBuildSessionTree verifies parent/child edges are rebuilt
495 // from meta + sibling-scan.571 // from meta + sibling-scan.
496 func TestBuildSessionTree(t *testing.T) {572 func TestBuildSessionTree(t *testing.T) {

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close