detached 2880a25

feat(extensions): add session-aware extension APIs

commit
2880a253af7d91f32f73a184c1bea9b47539dc2b
author
brice · · 2026-08-04 13:04 UTC
committer
brice · · 2026-08-04 14:19 UTC
parent
69e5a1b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
35 files · +3755 -106
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 +225 -0
@@ -0,0 +1,225 @@
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+ return
101+ }
102+ if state, err := a.store.snapshot(); err == nil {
103+ a.publishChrome(state)
104+ a.renderPanelIfOpen(state)
105+ }
106+ }
107+ e.On("session_opened", restoreSession)
108+ e.On("session_switched", restoreSession)
109+ e.On("session_forked", restoreSession)
110+ e.InterceptTurnStart(func(_ int) ext.TurnStartDecision {
111+ state, err := a.store.snapshot()
112+ if err != nil {
113+ e.Logf("load turn focus: %v", err)
114+ return ext.TurnStartDecision{}
115+ }
116+ return ext.TurnStartDecision{Context: buildTurnContext(state)}
117+ })
118+
119+ e.Tool(toolName,
120+ "Persist and update a structured spec, phased plan, current phase, and checklist tasks. Use it for spec-driven planning and progress tracking.",
121+ json.RawMessage(toolSchema), a.handleTool)
122+ e.Command("phases", "Show the current spec, phases, and checklist state", a.handleCommand)
123+ e.OnPanelKey(panelID, a.handlePanelKey, a.handlePanelClose)
124+
125+ if err := e.Run(); err != nil {
126+ fmt.Fprintln(os.Stderr, err)
127+ }
128+}
129+
130+func (a *app) handleCommand(_ string) ext.Response {
131+ state, err := a.store.snapshot()
132+ if err != nil {
133+ return ext.Errorf("load tasked phases: %v", err)
134+ }
135+ a.publishChrome(state)
136+ a.panelMu.Lock()
137+ a.panelOpen = true
138+ a.panelMu.Unlock()
139+ return ext.OpenPanel(panelID, "Tasked phases", buildViewLines(state), "Escape closes this panel")
140+}
141+
142+func (a *app) handlePanelKey(key, _ string) {
143+ if key == "esc" {
144+ a.ext.ClosePanel(panelID)
145+ a.setPanelOpen(false)
146+ }
147+}
148+
149+func (a *app) handlePanelClose() { a.setPanelOpen(false) }
150+
151+func (a *app) setPanelOpen(open bool) {
152+ a.panelMu.Lock()
153+ a.panelOpen = open
154+ a.panelMu.Unlock()
155+}
156+
157+func (a *app) renderPanelIfOpen(state PlanState) {
158+ a.panelMu.Lock()
159+ open := a.panelOpen
160+ a.panelMu.Unlock()
161+ if open {
162+ a.ext.RenderPanel(panelID, "Tasked phases", buildViewLines(state), "Escape closes this panel")
163+ }
164+}
165+
166+func (a *app) handleTool(args json.RawMessage) ext.ToolResult {
167+ var params toolArgs
168+ if err := json.Unmarshal(args, &params); err != nil {
169+ return ext.TextErrorResult("invalid args: " + err.Error())
170+ }
171+ if !isKnownAction(params.Action) {
172+ return ext.TextErrorResult("unsupported action: " + params.Action)
173+ }
174+
175+ if params.Action == actionGetStatus {
176+ state, err := a.store.snapshot()
177+ if err != nil {
178+ return toolResult(params.Action, PlanState{}, "", err)
179+ }
180+ a.publishChrome(state)
181+ a.renderPanelIfOpen(state)
182+ return toolResult(params.Action, state, "Current phased plan status", nil)
183+ }
184+
185+ state, headline, err := a.store.transact(func(current PlanState) (PlanState, string, error) {
186+ return applyAction(current, params)
187+ })
188+ a.publishChrome(state)
189+ if err == nil {
190+ a.renderPanelIfOpen(state)
191+ }
192+ return toolResult(params.Action, state, headline, err)
193+}
194+
195+func (a *app) publishChrome(state PlanState) {
196+ if isPlanClosed(state) {
197+ a.ext.SetStatus("progress", "closed: "+state.ClosedSummary)
198+ } else if done, total := getPlanProgress(state); total > 0 {
199+ a.ext.SetStatus("progress", fmt.Sprintf("%d/%d tasks checked", done, total))
200+ } else {
201+ a.ext.SetStatus("progress", "no active plan")
202+ }
203+ a.ext.SetWidget("plan", "above_input", "Tasked phases", strings.Split(buildCompactSummary(state), "\n"))
204+}
205+
206+func toolResult(action string, state PlanState, headline string, err error) ext.ToolResult {
207+ text := buildToolResultText(action, state, headline, err)
208+ var result ext.ToolResult
209+ if err != nil {
210+ result = ext.TextErrorResult(text)
211+ } else {
212+ result = ext.TextResult(text)
213+ }
214+ result.Details = ext.JSONDetails(state)
215+ return result
216+}
217+
218+func isKnownAction(action string) bool {
219+ for _, known := range toolActions {
220+ if action == known {
221+ return true
222+ }
223+ }
224+ return false
225+}
A examples/extensions/tasked-phases/skill_sync_test.go +29 -0
@@ -0,0 +1,29 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "testing"
9+)
10+
11+func TestBundledSkillMatchesStandaloneCopy(t *testing.T) {
12+ _, file, _, ok := runtime.Caller(0)
13+ if !ok {
14+ t.Fatal("runtime.Caller failed")
15+ }
16+ extensionSkill := filepath.Join(filepath.Dir(file), "skills", "tasked-phases", "SKILL.md")
17+ standaloneSkill := filepath.Join(filepath.Dir(file), "..", "..", "skills", "tasked-phases", "SKILL.md")
18+ extension, err := os.ReadFile(extensionSkill)
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ standalone, err := os.ReadFile(standaloneSkill)
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ if !bytes.Equal(extension, standalone) {
27+ t.Fatal("bundled and standalone tasked-phases skills have diverged")
28+ }
29+}
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` before creating a new plan.
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 +1079 -0
@@ -0,0 +1,1079 @@
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+ "strings"
13+ "sync"
14+ "time"
15+)
16+
17+const (
18+ actionGetStatus = "get_status"
19+ actionSetSpec = "set_spec"
20+ actionReplacePlan = "replace_plan"
21+ actionAddPhase = "add_phase"
22+ actionUpdatePhase = "update_phase"
23+ actionRemovePhase = "remove_phase"
24+ actionAddTask = "add_task"
25+ actionUpdateTask = "update_task"
26+ actionRemoveTask = "remove_task"
27+ actionSetCurrentPhase = "set_current_phase"
28+ actionSetTaskChecked = "set_task_checked"
29+ actionSetPhaseChecked = "set_phase_checked"
30+ actionClear = "clear"
31+)
32+
33+var toolActions = []string{
34+ actionGetStatus,
35+ actionSetSpec,
36+ actionReplacePlan,
37+ actionAddPhase,
38+ actionUpdatePhase,
39+ actionRemovePhase,
40+ actionAddTask,
41+ actionUpdateTask,
42+ actionRemoveTask,
43+ actionSetCurrentPhase,
44+ actionSetTaskChecked,
45+ actionSetPhaseChecked,
46+ actionClear,
47+}
48+
49+var closedPlanBlockedActions = map[string]bool{
50+ actionReplacePlan: true,
51+ actionAddPhase: true,
52+ actionUpdatePhase: true,
53+ actionRemovePhase: true,
54+ actionAddTask: true,
55+ actionUpdateTask: true,
56+ actionRemoveTask: true,
57+ actionSetCurrentPhase: true,
58+ actionSetTaskChecked: true,
59+ actionSetPhaseChecked: true,
60+}
61+
62+type PhaseTask struct {
63+ ID string `json:"id"`
64+ Text string `json:"text"`
65+ Checked bool `json:"checked"`
66+}
67+
68+type Phase struct {
69+ ID string `json:"id"`
70+ Title string `json:"title"`
71+ Goal string `json:"goal,omitempty"`
72+ Tasks []PhaseTask `json:"tasks"`
73+}
74+
75+type PlanState struct {
76+ Version int `json:"version"`
77+ Spec string `json:"spec,omitempty"`
78+ Phases []Phase `json:"phases"`
79+ CurrentPhaseID string `json:"currentPhaseId,omitempty"`
80+ ClosedAt *int64 `json:"closedAt,omitempty"`
81+ ClosedSummary string `json:"closedSummary,omitempty"`
82+ NextPhaseNumber int `json:"nextPhaseNumber"`
83+ NextTaskNumber int `json:"nextTaskNumber"`
84+ UpdatedAt int64 `json:"updatedAt"`
85+}
86+
87+type TaskInput struct {
88+ ID *string `json:"id"`
89+ Text string `json:"text"`
90+ Checked *bool `json:"checked"`
91+}
92+
93+type PhaseInput struct {
94+ ID *string `json:"id"`
95+ Title string `json:"title"`
96+ Goal *string `json:"goal"`
97+ Tasks *[]TaskInput `json:"tasks"`
98+}
99+
100+type toolArgs struct {
101+ Action string `json:"action"`
102+ Spec *string `json:"spec"`
103+ PhaseID string `json:"phaseId"`
104+ PhaseTitle *string `json:"phaseTitle"`
105+ PhaseGoal *string `json:"phaseGoal"`
106+ TaskID string `json:"taskId"`
107+ TaskText *string `json:"taskText"`
108+ Checked *bool `json:"checked"`
109+ Phases *[]PhaseInput `json:"phases"`
110+}
111+
112+func nowMillis() int64 { return time.Now().UnixMilli() }
113+
114+func createEmptyState() PlanState {
115+ return PlanState{
116+ Version: 1,
117+ Phases: []Phase{},
118+ NextPhaseNumber: 1,
119+ NextTaskNumber: 1,
120+ UpdatedAt: nowMillis(),
121+ }
122+}
123+
124+func cloneState(state PlanState) PlanState {
125+ cloned := state
126+ cloned.Phases = make([]Phase, len(state.Phases))
127+ for i, phase := range state.Phases {
128+ cloned.Phases[i] = phase
129+ cloned.Phases[i].Tasks = append([]PhaseTask(nil), phase.Tasks...)
130+ }
131+ return cloned
132+}
133+
134+func normalizeOptionalText(value string) string {
135+ return strings.TrimSpace(value)
136+}
137+
138+func singleLine(text string) string {
139+ return strings.Join(strings.Fields(text), " ")
140+}
141+
142+func truncatePlain(text string, maxLength int) string {
143+ if maxLength < 1 {
144+ return ""
145+ }
146+ runes := []rune(text)
147+ if len(runes) <= maxLength {
148+ return text
149+ }
150+ return strings.TrimRight(string(runes[:maxLength-1]), " \t\r\n") + "…"
151+}
152+
153+func getPhaseProgress(phase Phase) (done, total int) {
154+ total = len(phase.Tasks)
155+ for _, task := range phase.Tasks {
156+ if task.Checked {
157+ done++
158+ }
159+ }
160+ return done, total
161+}
162+
163+func getPlanProgress(state PlanState) (done, total int) {
164+ for _, phase := range state.Phases {
165+ phaseDone, phaseTotal := getPhaseProgress(phase)
166+ done += phaseDone
167+ total += phaseTotal
168+ }
169+ return done, total
170+}
171+
172+func isPhaseDone(phase Phase) bool {
173+ done, total := getPhaseProgress(phase)
174+ return total > 0 && done == total
175+}
176+
177+func hasStoredPlan(state PlanState) bool {
178+ return state.Spec != "" || len(state.Phases) > 0
179+}
180+
181+func isPlanComplete(state PlanState) bool {
182+ if len(state.Phases) == 0 {
183+ return false
184+ }
185+ for _, phase := range state.Phases {
186+ if !isPhaseDone(phase) {
187+ return false
188+ }
189+ }
190+ return true
191+}
192+
193+func isPlanClosed(state PlanState) bool { return state.ClosedAt != nil }
194+
195+func closePlanIfComplete(state *PlanState) {
196+ if !isPlanComplete(*state) {
197+ return
198+ }
199+ done, total := getPlanProgress(*state)
200+ if state.ClosedAt == nil {
201+ closedAt := nowMillis()
202+ state.ClosedAt = &closedAt
203+ }
204+ state.ClosedSummary = fmt.Sprintf("Completed %d/%d tasks across %d phase(s).", done, total, len(state.Phases))
205+ state.CurrentPhaseID = ""
206+}
207+
208+func getCurrentPhase(state PlanState) *Phase {
209+ if state.CurrentPhaseID == "" {
210+ return nil
211+ }
212+ for i := range state.Phases {
213+ if state.Phases[i].ID == state.CurrentPhaseID {
214+ return &state.Phases[i]
215+ }
216+ }
217+ return nil
218+}
219+
220+func getActivePhase(state PlanState) *Phase {
221+ if current := getCurrentPhase(state); current != nil {
222+ return current
223+ }
224+ for i := range state.Phases {
225+ if !isPhaseDone(state.Phases[i]) {
226+ return &state.Phases[i]
227+ }
228+ }
229+ return nil
230+}
231+
232+func getSuggestedCurrentPhaseID(state PlanState) string {
233+ if state.CurrentPhaseID != "" {
234+ for _, phase := range state.Phases {
235+ if phase.ID == state.CurrentPhaseID {
236+ return state.CurrentPhaseID
237+ }
238+ }
239+ }
240+ for _, phase := range state.Phases {
241+ if !isPhaseDone(phase) {
242+ return phase.ID
243+ }
244+ }
245+ if len(state.Phases) > 0 {
246+ return state.Phases[len(state.Phases)-1].ID
247+ }
248+ return ""
249+}
250+
251+func extractHighestIDNumber(prefix string, ids []string) int {
252+ highest := 0
253+ pattern := regexp.MustCompile(`^` + regexp.QuoteMeta(prefix) + `-(\d+)$`)
254+ for _, id := range ids {
255+ match := pattern.FindStringSubmatch(id)
256+ if len(match) != 2 {
257+ continue
258+ }
259+ var number int
260+ if _, err := fmt.Sscanf(match[1], "%d", &number); err == nil && number > highest {
261+ highest = number
262+ }
263+ }
264+ return highest
265+}
266+
267+func ensureState(state PlanState) PlanState {
268+ normalized := cloneState(state)
269+ normalized.Version = 1
270+ normalized.Spec = normalizeOptionalText(normalized.Spec)
271+ if normalized.Phases == nil {
272+ normalized.Phases = []Phase{}
273+ }
274+ for i := range normalized.Phases {
275+ normalized.Phases[i].Goal = optionalNormalized(normalized.Phases[i].Goal)
276+ if normalized.Phases[i].Tasks == nil {
277+ normalized.Phases[i].Tasks = []PhaseTask{}
278+ }
279+ }
280+ normalized.ClosedSummary = optionalNormalized(normalized.ClosedSummary)
281+ if isPlanComplete(normalized) {
282+ closePlanIfComplete(&normalized)
283+ } else {
284+ normalized.ClosedAt = nil
285+ normalized.ClosedSummary = ""
286+ }
287+
288+ highestPhase := make([]string, 0, len(normalized.Phases))
289+ highestTask := make([]string, 0)
290+ for _, phase := range normalized.Phases {
291+ highestPhase = append(highestPhase, phase.ID)
292+ for _, task := range phase.Tasks {
293+ highestTask = append(highestTask, task.ID)
294+ }
295+ }
296+ if normalized.NextPhaseNumber < 1 {
297+ normalized.NextPhaseNumber = 1
298+ }
299+ if normalized.NextTaskNumber < 1 {
300+ normalized.NextTaskNumber = 1
301+ }
302+ normalized.NextPhaseNumber = maxInt(normalized.NextPhaseNumber, extractHighestIDNumber("phase", highestPhase)+1)
303+ normalized.NextTaskNumber = maxInt(normalized.NextTaskNumber, extractHighestIDNumber("task", highestTask)+1)
304+ if isPlanClosed(normalized) {
305+ normalized.CurrentPhaseID = ""
306+ } else {
307+ normalized.CurrentPhaseID = getSuggestedCurrentPhaseID(normalized)
308+ }
309+ normalized.UpdatedAt = nowMillis()
310+ return normalized
311+}
312+
313+func optionalNormalized(value string) string {
314+ return normalizeOptionalText(value)
315+}
316+
317+func maxInt(a, b int) int {
318+ if a > b {
319+ return a
320+ }
321+ return b
322+}
323+
324+func nextPhaseID(state *PlanState) string {
325+ id := fmt.Sprintf("phase-%d", state.NextPhaseNumber)
326+ state.NextPhaseNumber++
327+ return id
328+}
329+
330+func nextTaskID(state *PlanState) string {
331+ id := fmt.Sprintf("task-%d", state.NextTaskNumber)
332+ state.NextTaskNumber++
333+ return id
334+}
335+
336+func extractBracketedPhaseID(value string) string {
337+ matches := regexp.MustCompile(`\[([^\]]+)\]`).FindAllStringSubmatch(value, -1)
338+ candidate := ""
339+ for _, match := range matches {
340+ if len(match) == 2 {
341+ if normalized := normalizeOptionalText(match[1]); normalized != "" {
342+ candidate = normalized
343+ }
344+ }
345+ }
346+ return candidate
347+}
348+
349+func findUniquePhaseByTitle(state PlanState, phaseTitle string) *Phase {
350+ normalizedTitle := singleLine(phaseTitle)
351+ var match *Phase
352+ matches := 0
353+ for i := range state.Phases {
354+ if singleLine(state.Phases[i].Title) == normalizedTitle {
355+ match = &state.Phases[i]
356+ matches++
357+ }
358+ }
359+ if matches == 1 {
360+ return match
361+ }
362+ return nil
363+}
364+
365+func findPhase(state PlanState, phaseID string) *Phase {
366+ candidate := normalizeOptionalText(phaseID)
367+ if candidate == "" {
368+ return nil
369+ }
370+ for i := range state.Phases {
371+ if state.Phases[i].ID == candidate {
372+ return &state.Phases[i]
373+ }
374+ }
375+ if bracketedID := extractBracketedPhaseID(candidate); bracketedID != "" {
376+ for i := range state.Phases {
377+ if state.Phases[i].ID == bracketedID {
378+ return &state.Phases[i]
379+ }
380+ }
381+ }
382+ return findUniquePhaseByTitle(state, candidate)
383+}
384+
385+func formatValidPhaseIDs(state PlanState) string {
386+ if len(state.Phases) == 0 {
387+ return "none"
388+ }
389+ parts := make([]string, 0, len(state.Phases))
390+ for _, phase := range state.Phases {
391+ parts = append(parts, fmt.Sprintf("%s (%s)", phase.ID, singleLine(phase.Title)))
392+ }
393+ return strings.Join(parts, ", ")
394+}
395+
396+func phaseIDError(state PlanState, action, phaseID string) string {
397+ candidate := normalizeOptionalText(phaseID)
398+ validIDs := formatValidPhaseIDs(state)
399+ if candidate == "" {
400+ return fmt.Sprintf("phaseId is required for %s. Use a valid phase id. Valid phase ids: %s.", action, validIDs)
401+ }
402+ 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)
403+}
404+
405+func findTask(state PlanState, taskID, phaseID string) (phase *Phase, task *PhaseTask) {
406+ if taskID == "" {
407+ return nil, nil
408+ }
409+ if phaseID != "" {
410+ phase = findPhase(state, phaseID)
411+ if phase == nil {
412+ return nil, nil
413+ }
414+ for i := range phase.Tasks {
415+ if phase.Tasks[i].ID == taskID {
416+ return phase, &phase.Tasks[i]
417+ }
418+ }
419+ return nil, nil
420+ }
421+ for i := range state.Phases {
422+ for j := range state.Phases[i].Tasks {
423+ if state.Phases[i].Tasks[j].ID == taskID {
424+ return &state.Phases[i], &state.Phases[i].Tasks[j]
425+ }
426+ }
427+ }
428+ return nil, nil
429+}
430+
431+func buildPhaseFromInput(state *PlanState, input PhaseInput, phaseIDs, taskIDs map[string]struct{}) (Phase, error) {
432+ id := ""
433+ if input.ID != nil {
434+ id = normalizeOptionalText(*input.ID)
435+ }
436+ if id == "" {
437+ id = nextPhaseID(state)
438+ }
439+ if _, exists := phaseIDs[id]; exists {
440+ return Phase{}, fmt.Errorf("duplicate phase id %q", id)
441+ }
442+ phaseIDs[id] = struct{}{}
443+ phase := Phase{
444+ ID: id,
445+ Title: strings.TrimSpace(input.Title),
446+ Goal: optionalInputText(input.Goal),
447+ Tasks: []PhaseTask{},
448+ }
449+ if input.Tasks != nil {
450+ phase.Tasks = make([]PhaseTask, 0, len(*input.Tasks))
451+ for _, inputTask := range *input.Tasks {
452+ taskID := ""
453+ if inputTask.ID != nil {
454+ taskID = normalizeOptionalText(*inputTask.ID)
455+ }
456+ if taskID == "" {
457+ taskID = nextTaskID(state)
458+ }
459+ if _, exists := taskIDs[taskID]; exists {
460+ return Phase{}, fmt.Errorf("duplicate task id %q", taskID)
461+ }
462+ taskIDs[taskID] = struct{}{}
463+ checked := false
464+ if inputTask.Checked != nil {
465+ checked = *inputTask.Checked
466+ }
467+ phase.Tasks = append(phase.Tasks, PhaseTask{ID: taskID, Text: strings.TrimSpace(inputTask.Text), Checked: checked})
468+ }
469+ }
470+ return phase, nil
471+}
472+
473+func optionalInputText(value *string) string {
474+ if value == nil {
475+ return ""
476+ }
477+ return normalizeOptionalText(*value)
478+}
479+
480+// applyAction is the state transition used by the protocol handler. It does
481+// not persist the result; stateStore performs that step while holding its
482+// mutex, so a failed write never publishes an unpersisted state.
483+func applyAction(state PlanState, params toolArgs) (PlanState, string, error) {
484+ state = cloneState(state)
485+ nextState := cloneState(state)
486+ if isPlanClosed(state) && closedPlanBlockedActions[params.Action] {
487+ reopen := (params.Action == actionSetTaskChecked || params.Action == actionSetPhaseChecked) && params.Checked != nil && !*params.Checked
488+ if !reopen {
489+ 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")
490+ }
491+ }
492+
493+ switch params.Action {
494+ case actionGetStatus:
495+ return state, "Current phased plan status", nil
496+ case actionSetSpec:
497+ spec := ""
498+ if params.Spec != nil {
499+ spec = normalizeOptionalText(*params.Spec)
500+ }
501+ if spec == "" {
502+ return state, "Spec not updated", errors.New("spec is required for set_spec")
503+ }
504+ nextState.Spec = spec
505+ if isPlanClosed(state) {
506+ nextState.Phases = []Phase{}
507+ nextState.CurrentPhaseID = ""
508+ nextState.NextPhaseNumber = 1
509+ nextState.NextTaskNumber = 1
510+ }
511+ nextState.ClosedAt = nil
512+ nextState.ClosedSummary = ""
513+ return nextState, "Saved spec", nil
514+ case actionReplacePlan:
515+ if params.Phases == nil {
516+ return state, "Plan not replaced", errors.New("phases is required for replace_plan")
517+ }
518+ nextState.Phases = []Phase{}
519+ nextState.CurrentPhaseID = ""
520+ nextState.NextPhaseNumber = 1
521+ nextState.NextTaskNumber = 1
522+ phaseIDs := map[string]struct{}{}
523+ taskIDs := map[string]struct{}{}
524+ for _, phaseInput := range *params.Phases {
525+ phase, err := buildPhaseFromInput(&nextState, phaseInput, phaseIDs, taskIDs)
526+ if err != nil {
527+ return state, "Plan not replaced", err
528+ }
529+ nextState.Phases = append(nextState.Phases, phase)
530+ }
531+ nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState)
532+ return nextState, fmt.Sprintf("Replaced plan with %d phase(s)", len(nextState.Phases)), nil
533+ case actionAddPhase:
534+ phaseTitle := ""
535+ if params.PhaseTitle != nil {
536+ phaseTitle = normalizeOptionalText(*params.PhaseTitle)
537+ }
538+ if phaseTitle == "" {
539+ return state, "Phase not added", errors.New("phaseTitle is required for add_phase")
540+ }
541+ nextState.Phases = append(nextState.Phases, Phase{ID: nextPhaseID(&nextState), Title: phaseTitle, Tasks: []PhaseTask{}})
542+ if params.PhaseGoal != nil {
543+ nextState.Phases[len(nextState.Phases)-1].Goal = normalizeOptionalText(*params.PhaseGoal)
544+ }
545+ if nextState.CurrentPhaseID == "" {
546+ nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState)
547+ }
548+ return nextState, "Added phase " + phaseTitle, nil
549+ case actionUpdatePhase:
550+ phase := findPhase(nextState, params.PhaseID)
551+ if phase == nil {
552+ return state, "Phase not updated", errors.New(phaseIDError(nextState, actionUpdatePhase, params.PhaseID))
553+ }
554+ if params.PhaseTitle != nil {
555+ if phaseTitle := normalizeOptionalText(*params.PhaseTitle); phaseTitle != "" {
556+ phase.Title = phaseTitle
557+ }
558+ }
559+ if params.PhaseGoal != nil {
560+ phase.Goal = normalizeOptionalText(*params.PhaseGoal)
561+ }
562+ return nextState, "Updated phase " + phase.Title, nil
563+ case actionRemovePhase:
564+ phase := findPhase(nextState, params.PhaseID)
565+ if phase == nil {
566+ return state, "Phase not removed", errors.New(phaseIDError(nextState, actionRemovePhase, params.PhaseID))
567+ }
568+ removedID := phase.ID
569+ filtered := make([]Phase, 0, len(nextState.Phases)-1)
570+ for _, entry := range nextState.Phases {
571+ if entry.ID != removedID {
572+ filtered = append(filtered, entry)
573+ }
574+ }
575+ nextState.Phases = filtered
576+ nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState)
577+ return nextState, "Removed phase " + removedID, nil
578+ case actionAddTask:
579+ phase := findPhase(nextState, params.PhaseID)
580+ taskText := ""
581+ if params.TaskText != nil {
582+ taskText = normalizeOptionalText(*params.TaskText)
583+ }
584+ if phase == nil {
585+ return state, "Task not added", errors.New(phaseIDError(nextState, actionAddTask, params.PhaseID))
586+ }
587+ if taskText == "" {
588+ return state, "Task not added", errors.New("taskText is required for add_task")
589+ }
590+ phase.Tasks = append(phase.Tasks, PhaseTask{ID: nextTaskID(&nextState), Text: taskText})
591+ return nextState, "Added task to " + phase.Title, nil
592+ case actionUpdateTask:
593+ _, task := findTask(nextState, params.TaskID, params.PhaseID)
594+ taskText := ""
595+ if params.TaskText != nil {
596+ taskText = normalizeOptionalText(*params.TaskText)
597+ }
598+ if task == nil {
599+ return state, "Task not updated", errors.New("taskId was not found")
600+ }
601+ if taskText == "" {
602+ return state, "Task not updated", errors.New("taskText is required for update_task")
603+ }
604+ task.Text = taskText
605+ return nextState, "Updated task " + task.ID, nil
606+ case actionRemoveTask:
607+ if params.TaskID == "" {
608+ return state, "Task not removed", errors.New("taskId is required")
609+ }
610+ phase, task := findTask(nextState, params.TaskID, params.PhaseID)
611+ if task == nil {
612+ return state, "Task not removed", errors.New("taskId was not found")
613+ }
614+ filtered := make([]PhaseTask, 0, len(phase.Tasks)-1)
615+ for _, entry := range phase.Tasks {
616+ if entry.ID != params.TaskID {
617+ filtered = append(filtered, entry)
618+ }
619+ }
620+ phase.Tasks = filtered
621+ return nextState, "Removed task " + params.TaskID, nil
622+ case actionSetCurrentPhase:
623+ phase := findPhase(nextState, params.PhaseID)
624+ if phase == nil {
625+ return state, "Current phase not updated", errors.New(phaseIDError(nextState, actionSetCurrentPhase, params.PhaseID))
626+ }
627+ nextState.CurrentPhaseID = phase.ID
628+ return nextState, "Current phase set to " + phase.Title, nil
629+ case actionSetTaskChecked:
630+ phase, task := findTask(nextState, params.TaskID, params.PhaseID)
631+ if task == nil {
632+ return state, "Task not updated", errors.New("taskId was not found")
633+ }
634+ if params.Checked == nil {
635+ return state, "Task not updated", errors.New("checked must be provided for set_task_checked")
636+ }
637+ task.Checked = *params.Checked
638+ if *params.Checked && nextState.CurrentPhaseID == phase.ID && isPhaseDone(*phase) {
639+ nextState.CurrentPhaseID = ""
640+ }
641+ return nextState, fmt.Sprintf("%s task %s", checkedWord(*params.Checked), task.ID), nil
642+ case actionSetPhaseChecked:
643+ phase := findPhase(nextState, params.PhaseID)
644+ if phase == nil {
645+ return state, "Phase not updated", errors.New(phaseIDError(nextState, actionSetPhaseChecked, params.PhaseID))
646+ }
647+ if params.Checked == nil {
648+ return state, "Phase not updated", errors.New("checked must be provided for set_phase_checked")
649+ }
650+ if len(phase.Tasks) == 0 {
651+ return state, "Phase not updated", errors.New("set_phase_checked requires the phase to have at least one task")
652+ }
653+ for i := range phase.Tasks {
654+ phase.Tasks[i].Checked = *params.Checked
655+ }
656+ if *params.Checked && nextState.CurrentPhaseID == phase.ID {
657+ nextState.CurrentPhaseID = ""
658+ }
659+ if !*params.Checked {
660+ nextState.CurrentPhaseID = phase.ID
661+ }
662+ return nextState, fmt.Sprintf("%s phase %s", checkedWord(*params.Checked), phase.Title), nil
663+ case actionClear:
664+ return createEmptyState(), "Cleared stored spec and phased checklist", nil
665+ default:
666+ return state, "Unsupported action", errors.New("unknown action")
667+ }
668+}
669+
670+func checkedWord(checked bool) string {
671+ if checked {
672+ return "Checked"
673+ }
674+ return "Unchecked"
675+}
676+
677+const (
678+ compactVisibleIncompleteTasks = 4
679+ turnContextVisibleIncompleteTasks = 3
680+ maxTurnContextChars = 2048
681+)
682+
683+func getIncompleteTasks(phase Phase) []PhaseTask {
684+ incomplete := make([]PhaseTask, 0)
685+ for _, task := range phase.Tasks {
686+ if !task.Checked {
687+ incomplete = append(incomplete, task)
688+ }
689+ }
690+ return incomplete
691+}
692+
693+func formatRemainingTaskCount(count int) string {
694+ if count == 1 {
695+ return "1 remaining"
696+ }
697+ return fmt.Sprintf("%d remaining", count)
698+}
699+
700+func formatPhaseTitle(phase Phase) string { return truncatePlain(singleLine(phase.Title), 120) }
701+func formatTaskText(task PhaseTask) string { return truncatePlain(singleLine(task.Text), 180) }
702+
703+func formatTurnContextID(id string) string { return truncatePlain(singleLine(id), 96) }
704+
705+func appendIncompleteTaskLines(lines *[]string, tasks []PhaseTask, visibleCount int, indent string) {
706+ visible := tasks
707+ if len(visible) > visibleCount {
708+ visible = visible[:visibleCount]
709+ }
710+ for _, task := range visible {
711+ *lines = append(*lines, fmt.Sprintf("%s[ ] %s [%s]", indent, formatTaskText(task), task.ID))
712+ }
713+ if omitted := len(tasks) - len(visible); omitted > 0 {
714+ *lines = append(*lines, fmt.Sprintf("%s... %d more incomplete task(s) omitted; call tasked_phases get_status for the full checklist.", indent, omitted))
715+ }
716+}
717+
718+func buildSummary(state PlanState) string {
719+ if !hasStoredPlan(state) {
720+ return "No spec or phased checklist has been stored yet."
721+ }
722+ lines := make([]string, 0)
723+ if state.Spec != "" {
724+ lines = append(lines, "Spec:", state.Spec, "")
725+ }
726+ if len(state.Phases) == 0 {
727+ return strings.Join(append(lines, "Phases: (none)"), "\n")
728+ }
729+ done, total := getPlanProgress(state)
730+ lines = append(lines, fmt.Sprintf("Plan progress: %d/%d tasks checked", done, total))
731+ if isPlanClosed(state) {
732+ closedSummary := state.ClosedSummary
733+ if closedSummary == "" {
734+ closedSummary = "all phases complete"
735+ }
736+ lines = append(lines, "Plan closed: "+closedSummary)
737+ }
738+ lines = append(lines, "Phases:")
739+ for _, phase := range state.Phases {
740+ phaseDone, phaseTotal := getPhaseProgress(phase)
741+ currentMarker := " "
742+ if phase.ID == state.CurrentPhaseID {
743+ currentMarker = ">"
744+ }
745+ phaseMarker := "[ ]"
746+ if isPhaseDone(phase) {
747+ phaseMarker = "[x]"
748+ }
749+ goalSuffix := ""
750+ if phase.Goal != "" {
751+ goalSuffix = " - " + phase.Goal
752+ }
753+ lines = append(lines, fmt.Sprintf("%s %s %s [%s] (%d/%d)%s", currentMarker, phaseMarker, phase.Title, phase.ID, phaseDone, phaseTotal, goalSuffix))
754+ if len(phase.Tasks) == 0 {
755+ lines = append(lines, " - No tasks yet")
756+ continue
757+ }
758+ for _, task := range phase.Tasks {
759+ marker := "[ ]"
760+ if task.Checked {
761+ marker = "[x]"
762+ }
763+ lines = append(lines, fmt.Sprintf(" %s %s [%s]", marker, task.Text, task.ID))
764+ }
765+ }
766+ return strings.Join(lines, "\n")
767+}
768+
769+func buildCompactSummary(state PlanState) string {
770+ if !hasStoredPlan(state) {
771+ return "No spec or phased checklist has been stored yet."
772+ }
773+ lines := make([]string, 0)
774+ if state.Spec != "" {
775+ lines = append(lines, "Spec: "+truncatePlain(singleLine(state.Spec), 220))
776+ }
777+ done, total := getPlanProgress(state)
778+ lines = append(lines, fmt.Sprintf("Progress: %d/%d tasks checked", done, total))
779+ if isPlanClosed(state) {
780+ closedSummary := state.ClosedSummary
781+ if closedSummary == "" {
782+ closedSummary = "all phases complete"
783+ }
784+ lines = append(lines, "Plan closed: "+closedSummary)
785+ return strings.Join(lines, "\n")
786+ }
787+ if currentPhase := getActivePhase(state); currentPhase != nil {
788+ incompleteTasks := getIncompleteTasks(*currentPhase)
789+ goalSuffix := ""
790+ if currentPhase.Goal != "" {
791+ goalSuffix = " - " + truncatePlain(singleLine(currentPhase.Goal), 120)
792+ }
793+ lines = append(lines, fmt.Sprintf("Current phase: %s [%s] (%s)%s", formatPhaseTitle(*currentPhase), currentPhase.ID, formatRemainingTaskCount(len(incompleteTasks)), goalSuffix))
794+ if len(incompleteTasks) > 0 {
795+ lines = append(lines, "Incomplete tasks:")
796+ appendIncompleteTaskLines(&lines, incompleteTasks, compactVisibleIncompleteTasks, " ")
797+ }
798+ } else if len(state.Phases) > 0 {
799+ lines = append(lines, "Current phase: none")
800+ }
801+ return strings.Join(lines, "\n")
802+}
803+
804+// buildTurnContext returns the small amount of plan state the model needs at
805+// the start of a turn. It deliberately excludes the spec, completed phases,
806+// and future phases; get_status and /phases remain the full-state views.
807+func buildTurnContext(state PlanState) string {
808+ if !hasStoredPlan(state) {
809+ return ""
810+ }
811+ if isPlanClosed(state) {
812+ return truncatePlain("Tasked phases focus: the current plan is complete and closed. Use clear before creating a plan for unrelated work.", maxTurnContextChars)
813+ }
814+ phase := getActivePhase(state)
815+ if phase == nil {
816+ return ""
817+ }
818+ lines := []string{
819+ "Tasked phases focus:",
820+ fmt.Sprintf("Current phase: %s [%s]", formatPhaseTitle(*phase), formatTurnContextID(phase.ID)),
821+ }
822+ if phase.Goal != "" {
823+ lines = append(lines, "Goal: "+truncatePlain(singleLine(phase.Goal), 160))
824+ }
825+ incompleteTasks := getIncompleteTasks(*phase)
826+ if len(incompleteTasks) == 0 {
827+ lines = append(lines, "No incomplete tasks remain in this phase; update the plan or advance the phase as appropriate.")
828+ return truncatePlain(strings.Join(lines, "\n"), maxTurnContextChars)
829+ }
830+ lines = append(lines, "Incomplete tasks:")
831+ visible := incompleteTasks
832+ if len(visible) > turnContextVisibleIncompleteTasks {
833+ visible = visible[:turnContextVisibleIncompleteTasks]
834+ }
835+ for _, task := range visible {
836+ lines = append(lines, fmt.Sprintf(" [ ] %s [%s]", formatTaskText(task), formatTurnContextID(task.ID)))
837+ }
838+ if omitted := len(incompleteTasks) - len(visible); omitted > 0 {
839+ lines = append(lines, fmt.Sprintf(" ... %d more incomplete task(s) omitted; call tasked_phases get_status for the full checklist.", omitted))
840+ }
841+ return truncatePlain(strings.Join(lines, "\n"), maxTurnContextChars)
842+}
843+
844+func shouldReturnFullSummary(action string, err error) bool {
845+ return err != nil || action == actionGetStatus || action == actionReplacePlan || action == actionClear
846+}
847+
848+func buildToolResultText(action string, state PlanState, headline string, err error) string {
849+ summary := buildCompactSummary(state)
850+ if shouldReturnFullSummary(action, err) {
851+ summary = buildSummary(state)
852+ }
853+ if err != nil {
854+ return "Error: " + err.Error() + "\n\n" + summary
855+ }
856+ return headline + "\n\n" + summary
857+}
858+
859+func buildViewLines(state PlanState) []string {
860+ lines := []string{"", " Tasked phases ", ""}
861+ if !hasStoredPlan(state) {
862+ return append(lines,
863+ " No spec or phases stored yet.",
864+ "",
865+ " Ask the agent to create a spec and phased checklist.",
866+ "",
867+ " Press Escape to close",
868+ )
869+ }
870+ if state.Spec != "" {
871+ lines = append(lines, " Spec")
872+ for _, specLine := range strings.Split(state.Spec, "\n") {
873+ lines = append(lines, " "+specLine)
874+ }
875+ lines = append(lines, "")
876+ }
877+ done, total := getPlanProgress(state)
878+ lines = append(lines, fmt.Sprintf(" Progress: %d/%d tasks checked", done, total))
879+ if isPlanClosed(state) {
880+ closedSummary := state.ClosedSummary
881+ if closedSummary == "" {
882+ closedSummary = "all phases complete"
883+ }
884+ lines = append(lines,
885+ " Closed: "+closedSummary,
886+ " New work should create a fresh plan instead of extending this one.",
887+ )
888+ }
889+ lines = append(lines, "")
890+ for _, phase := range state.Phases {
891+ phaseDone, phaseTotal := getPhaseProgress(phase)
892+ marker := "[ ]"
893+ if isPhaseDone(phase) {
894+ marker = "[x]"
895+ }
896+ currentTitle := phase.Title
897+ if phase.ID == state.CurrentPhaseID {
898+ currentTitle = "> " + currentTitle
899+ }
900+ lines = append(lines, fmt.Sprintf(" %s %s [%s] (%d/%d)", marker, currentTitle, phase.ID, phaseDone, phaseTotal))
901+ if phase.Goal != "" {
902+ lines = append(lines, " "+phase.Goal)
903+ }
904+ if len(phase.Tasks) == 0 {
905+ lines = append(lines, " - No tasks yet")
906+ continue
907+ }
908+ for _, task := range phase.Tasks {
909+ taskMarker := "[ ]"
910+ if task.Checked {
911+ taskMarker = "[x]"
912+ }
913+ lines = append(lines, fmt.Sprintf(" %s %s [%s]", taskMarker, task.Text, task.ID))
914+ }
915+ lines = append(lines, "")
916+ }
917+ return append(lines, " Press Escape to close")
918+}
919+
920+type stateStore struct {
921+ mu sync.Mutex
922+ state PlanState
923+ path string
924+ loaded bool
925+ loadErr error
926+}
927+
928+func newStateStore() *stateStore {
929+ return &stateStore{state: createEmptyState()}
930+}
931+
932+func projectStatePath(dataDir, cwd string) string {
933+ key := sha256.Sum256([]byte(cwd))
934+ return filepath.Join(dataDir, "projects", hex.EncodeToString(key[:])+".json")
935+}
936+
937+func (s *stateStore) load(dataDir, cwd string) error {
938+ s.mu.Lock()
939+ defer s.mu.Unlock()
940+ if s.loaded {
941+ return s.loadErr
942+ }
943+ s.loaded = true
944+ s.state = createEmptyState()
945+ if strings.TrimSpace(dataDir) == "" {
946+ s.loadErr = errors.New("host did not provide extension data_dir")
947+ return s.loadErr
948+ }
949+ s.path = projectStatePath(dataDir, cwd)
950+ if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
951+ s.loadErr = fmt.Errorf("create state directory: %w", err)
952+ return s.loadErr
953+ }
954+ if err := os.Chmod(filepath.Dir(s.path), 0o700); err != nil {
955+ s.loadErr = fmt.Errorf("restrict state directory: %w", err)
956+ return s.loadErr
957+ }
958+ data, err := os.ReadFile(s.path)
959+ if os.IsNotExist(err) {
960+ return nil
961+ }
962+ if err != nil {
963+ s.loadErr = fmt.Errorf("read state: %w", err)
964+ return s.loadErr
965+ }
966+ if err := os.Chmod(s.path, 0o600); err != nil {
967+ s.loadErr = fmt.Errorf("restrict state file: %w", err)
968+ return s.loadErr
969+ }
970+ if len(data) == 0 {
971+ return nil
972+ }
973+ var state PlanState
974+ if err := json.Unmarshal(data, &state); err != nil {
975+ s.loadErr = fmt.Errorf("parse state: %w", err)
976+ return s.loadErr
977+ }
978+ s.state = ensureState(state)
979+ return nil
980+}
981+
982+// activateSession replaces the project fallback with the opaque snapshot
983+// supplied by zot for the active session branch. An empty snapshot means the
984+// branch has no extension state yet and therefore starts clean.
985+func (s *stateStore) activateSession(raw json.RawMessage) error {
986+ s.mu.Lock()
987+ defer s.mu.Unlock()
988+ if !s.loaded {
989+ return errors.New("state has not been loaded from hello")
990+ }
991+ if s.path == "" && s.loadErr != nil {
992+ return s.loadErr
993+ }
994+ if len(raw) == 0 || strings.TrimSpace(string(raw)) == "null" {
995+ s.state = createEmptyState()
996+ s.loadErr = nil
997+ return nil
998+ }
999+ var state PlanState
1000+ if err := json.Unmarshal(raw, &state); err != nil {
1001+ return fmt.Errorf("parse session state: %w", err)
1002+ }
1003+ s.state = ensureState(state)
1004+ s.loadErr = nil
1005+ return nil
1006+}
1007+
1008+func (s *stateStore) snapshot() (PlanState, error) {
1009+ s.mu.Lock()
1010+ defer s.mu.Unlock()
1011+ if !s.loaded {
1012+ return PlanState{}, errors.New("state has not been loaded from hello")
1013+ }
1014+ if s.loadErr != nil {
1015+ return cloneState(s.state), s.loadErr
1016+ }
1017+ return cloneState(s.state), nil
1018+}
1019+
1020+func (s *stateStore) transact(fn func(PlanState) (PlanState, string, error)) (PlanState, string, error) {
1021+ s.mu.Lock()
1022+ defer s.mu.Unlock()
1023+ if !s.loaded {
1024+ return PlanState{}, "", errors.New("state has not been loaded from hello")
1025+ }
1026+ if s.loadErr != nil {
1027+ return cloneState(s.state), "", s.loadErr
1028+ }
1029+ current := cloneState(s.state)
1030+ next, headline, err := fn(current)
1031+ if err != nil {
1032+ return current, headline, err
1033+ }
1034+ next = ensureState(next)
1035+ if err := writeJSONAtomic(s.path, next); err != nil {
1036+ return current, headline, fmt.Errorf("persist state: %w", err)
1037+ }
1038+ s.state = next
1039+ return cloneState(next), headline, nil
1040+}
1041+
1042+func writeJSONAtomic(path string, value any) error {
1043+ if strings.TrimSpace(path) == "" {
1044+ return errors.New("state path is empty")
1045+ }
1046+ data, err := json.MarshalIndent(value, "", " ")
1047+ if err != nil {
1048+ return err
1049+ }
1050+ data = append(data, '\n')
1051+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
1052+ return err
1053+ }
1054+ tmp, err := os.CreateTemp(filepath.Dir(path), ".tasked-phases-*.tmp")
1055+ if err != nil {
1056+ return err
1057+ }
1058+ tmpName := tmp.Name()
1059+ defer os.Remove(tmpName)
1060+ if err := tmp.Chmod(0o600); err != nil {
1061+ _ = tmp.Close()
1062+ return err
1063+ }
1064+ if _, err := tmp.Write(data); err != nil {
1065+ _ = tmp.Close()
1066+ return err
1067+ }
1068+ if err := tmp.Sync(); err != nil {
1069+ _ = tmp.Close()
1070+ return err
1071+ }
1072+ if err := tmp.Close(); err != nil {
1073+ return err
1074+ }
1075+ if err := os.Rename(tmpName, path); err != nil {
1076+ return err
1077+ }
1078+ return nil
1079+}
A examples/extensions/tasked-phases/state_test.go +390 -0
@@ -0,0 +1,390 @@
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+}
255+
256+func TestPanelRendersSpecGoalsTasksAndEmptyState(t *testing.T) {
257+ empty := strings.Join(buildViewLines(createEmptyState()), "\n")
258+ if !strings.Contains(empty, "No spec or phases stored yet.") || !strings.Contains(empty, "Press Escape to close") {
259+ t.Fatalf("empty panel = %s", empty)
260+ }
261+ panel := strings.Join(buildViewLines(sampleState()), "\n")
262+ for _, want := range []string{
263+ "Tasked phases",
264+ "Spec",
265+ "Progress: 3/4 tasks checked",
266+ "[x] Completed discovery task [task-1]",
267+ "[ ] Remaining implementation task [task-4]",
268+ "Understand the old behavior",
269+ "Press Escape to close",
270+ } {
271+ if !strings.Contains(panel, want) {
272+ t.Errorf("panel missing %q: %s", want, panel)
273+ }
274+ }
275+}
276+
277+func TestStatePersistenceIsProjectScopedAtomicAndRestrictive(t *testing.T) {
278+ dataDir := t.TempDir()
279+ first := newStateStore()
280+ if err := first.load(dataDir, "/projects/one"); err != nil {
281+ t.Fatal(err)
282+ }
283+ state, _, err := first.transact(func(current PlanState) (PlanState, string, error) {
284+ return applyAction(current, toolArgs{
285+ Action: actionReplacePlan,
286+ Phases: &[]PhaseInput{{Title: "Persisted", Tasks: &[]TaskInput{{Text: "remember me"}}}},
287+ })
288+ })
289+ if err != nil {
290+ t.Fatal(err)
291+ }
292+ if state.Phases[0].ID != "phase-1" {
293+ t.Fatalf("persisted state = %+v", state)
294+ }
295+ if runtime.GOOS != "windows" {
296+ info, err := os.Stat(first.path)
297+ if err != nil {
298+ t.Fatal(err)
299+ }
300+ if got := info.Mode().Perm(); got != 0o600 {
301+ t.Fatalf("state file mode = %o, want 600", got)
302+ }
303+ if got := (mustStat(t, filepath.Dir(first.path))).Mode().Perm(); got != 0o700 {
304+ t.Fatalf("state directory mode = %o, want 700", got)
305+ }
306+ }
307+
308+ second := newStateStore()
309+ if err := second.load(dataDir, "/projects/one"); err != nil {
310+ t.Fatal(err)
311+ }
312+ loaded, err := second.snapshot()
313+ if err != nil {
314+ t.Fatal(err)
315+ }
316+ if loaded.Phases[0].Tasks[0].Text != "remember me" {
317+ t.Fatalf("loaded state = %+v", loaded)
318+ }
319+ otherProject := newStateStore()
320+ if err := otherProject.load(dataDir, "/projects/two"); err != nil {
321+ t.Fatal(err)
322+ }
323+ other, err := otherProject.snapshot()
324+ if err != nil {
325+ t.Fatal(err)
326+ }
327+ if hasStoredPlan(other) {
328+ t.Fatal("state leaked between project CWDs")
329+ }
330+ files, err := os.ReadDir(filepath.Dir(first.path))
331+ if err != nil {
332+ t.Fatal(err)
333+ }
334+ for _, file := range files {
335+ if strings.HasSuffix(file.Name(), ".tmp") {
336+ t.Fatalf("temporary file left behind: %s", file.Name())
337+ }
338+ }
339+
340+ var decoded PlanState
341+ data, err := os.ReadFile(first.path)
342+ if err != nil {
343+ t.Fatal(err)
344+ }
345+ if err := json.Unmarshal(data, &decoded); err != nil {
346+ t.Fatal(err)
347+ }
348+ if decoded.Phases[0].Tasks[0].ID != "task-1" {
349+ t.Fatalf("on-disk state = %+v", decoded)
350+ }
351+}
352+
353+func TestStateStoreSerializesConcurrentTransitions(t *testing.T) {
354+ store := newStateStore()
355+ if err := store.load(t.TempDir(), "/projects/concurrent"); err != nil {
356+ t.Fatal(err)
357+ }
358+ const count = 12
359+ var wg sync.WaitGroup
360+ for i := 0; i < count; i++ {
361+ wg.Add(1)
362+ go func(i int) {
363+ defer wg.Done()
364+ title := "phase " + string(rune('a'+i))
365+ _, _, err := store.transact(func(current PlanState) (PlanState, string, error) {
366+ return applyAction(current, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr(title)})
367+ })
368+ if err != nil {
369+ t.Errorf("concurrent transition: %v", err)
370+ }
371+ }(i)
372+ }
373+ wg.Wait()
374+ state, err := store.snapshot()
375+ if err != nil {
376+ t.Fatal(err)
377+ }
378+ if len(state.Phases) != count {
379+ t.Fatalf("serialized phase count = %d, want %d", len(state.Phases), count)
380+ }
381+}
382+
383+func mustStat(t *testing.T, path string) os.FileInfo {
384+ t.Helper()
385+ info, err := os.Stat(path)
386+ if err != nil {
387+ t.Fatal(err)
388+ }
389+ return info
390+}
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` before creating a new plan.
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 +72 -10
@@ -66,6 +66,7 @@ type Resolved struct {
66 // resolve.66 // resolve.
67 systemAppend []string67 systemAppend []string
68 systemCustom string68 systemCustom string
69+ skillAddendum string
69 toolDescriptions map[string]string70 toolDescriptions map[string]string
70 }71 }
71 72
@@ -83,33 +84,87 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
83 if mgr == nil {84 if mgr == nil {
84 return85 return
85 }86 }
86- infos := mgr.Tools()
87- if len(infos) == 0 {
88- return
89- }
90 changed := false87 changed := false
91- for _, info := range infos {88+ for _, info := range mgr.Tools() {
92 if _, exists := r.ToolRegistry[info.Name]; exists {89 if _, exists := r.ToolRegistry[info.Name]; exists {
93 continue90 continue
94 }91 }
95 r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)92 r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
96 changed = true93 changed = true
97 }94 }
95+
96+ // A nil SkillTool means skill discovery was explicitly disabled (for
97+ // example --no-skill or a profile with inheritSkills=false). Bundled
98+ // extension skills must honor that same clean-room switch.
99+ if source, ok := mgr.(ExtensionSkillSource); ok && r.SkillTool != nil {
100+ if bundled := source.Skills(); len(bundled) > 0 {
101+ merged := mergeExtensionSkills(r.SkillTool, bundled)
102+ r.SkillTool.SetSkills(merged)
103+ append_ := append([]string(nil), r.systemAppend...)
104+ if r.skillAddendum != "" {
105+ filtered := append_[:0]
106+ for _, item := range append_ {
107+ if item != r.skillAddendum {
108+ filtered = append(filtered, item)
109+ }
110+ }
111+ append_ = filtered
112+ }
113+ r.skillAddendum = skills.SystemPromptAddendum(merged)
114+ if r.skillAddendum != "" {
115+ append_ = append(append_, r.skillAddendum)
116+ }
117+ r.systemAppend = append_
118+ changed = true
119+ }
120+ }
98 if !changed {121 if !changed {
99 return122 return
100 }123 }
101- // Re-render the system prompt with the merged tool list. Skill124+ // Re-render the system prompt with the merged tool and skill lists.
102- // addendum is preserved by walking the existing append slice.125+ r.ToolSummary = toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions)
103- append_ := r.systemAppend
104 r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{126 r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
105 CWD: r.CWD,127 CWD: r.CWD,
106- Tools: toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),128+ Tools: r.ToolSummary,
107 Custom: r.systemCustom,129 Custom: r.systemCustom,
108- Append: append_,130+ Append: r.systemAppend,
109 ZotDocsDir: filepath.Join(ZotHome(), "docs"),131 ZotDocsDir: filepath.Join(ZotHome(), "docs"),
110 })132 })
111 }133 }
112 134
135+func mergeExtensionSkills(tool *skills.Tool, bundled []*skills.Skill) []*skills.Skill {
136+ var existing []*skills.Skill
137+ if tool != nil {
138+ existing = tool.Skills()
139+ }
140+ merged := make([]*skills.Skill, 0, len(existing)+len(bundled))
141+ seen := map[string]bool{}
142+ add := func(list []*skills.Skill) {
143+ for _, skill := range list {
144+ if skill == nil || skill.Name == "" || seen[skill.Name] {
145+ continue
146+ }
147+ seen[skill.Name] = true
148+ merged = append(merged, skill)
149+ }
150+ }
151+ var user, builtins []*skills.Skill
152+ for _, skill := range existing {
153+ if skill == nil || strings.HasPrefix(skill.Source, "extension ") {
154+ continue
155+ }
156+ if skill.Builtin {
157+ builtins = append(builtins, skill)
158+ } else {
159+ user = append(user, skill)
160+ }
161+ }
162+ add(user)
163+ add(bundled)
164+ add(builtins)
165+ return merged
166+}
167+
113 // ExtensionToolSource is the slice of the extension manager that168 // ExtensionToolSource is the slice of the extension manager that
114 // MergeExtensionTools needs. Lives here as an interface so the169 // MergeExtensionTools needs. Lives here as an interface so the
115 // build package doesn't import packages/agent/extensions (which170 // build package doesn't import packages/agent/extensions (which
@@ -119,6 +174,12 @@ type ExtensionToolSource interface {
119 NewExtensionTool(info ExtensionToolInfo) core.Tool174 NewExtensionTool(info ExtensionToolInfo) core.Tool
120 }175 }
121 176
177+// ExtensionSkillSource exposes the optional bundled-skill surface without
178+// forcing older ExtensionToolSource implementations to add it.
179+type ExtensionSkillSource interface {
180+ Skills() []*skills.Skill
181+}
182+
122 // ExtensionToolInfo mirrors extensions.ToolInfo so we can declare183 // ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
123 // ExtensionToolSource here without importing the extensions184 // ExtensionToolSource here without importing the extensions
124 // package. The cli wires a tiny adapter to bridge them.185 // package. The cli wires a tiny adapter to bridge them.
@@ -731,6 +792,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
731 ContextFiles: contextFiles,792 ContextFiles: contextFiles,
732 systemAppend: append_,793 systemAppend: append_,
733 systemCustom: custom,794 systemCustom: custom,
795+ skillAddendum: skillAddendum,
734 toolDescriptions: descMapFromSummaries(summaries),796 toolDescriptions: descMapFromSummaries(summaries),
735 }, nil797 }, nil
736 }798 }
M packages/agent/cli.go +127 -16
@@ -131,6 +131,21 @@ func (h *interactiveExtHooks) ClosePanel(extName, panelID string) {
131 iv.ClosePanel(extName, panelID)131 iv.ClosePanel(extName, panelID)
132 }132 }
133 }133 }
134+func (h *interactiveExtHooks) SetStatus(extName, key, level, text string) {
135+ if iv := h.iv(); iv != nil {
136+ iv.SetStatus(extName, key, level, text)
137+ }
138+}
139+func (h *interactiveExtHooks) SetWidget(extName, id, position, title string, lines []string) {
140+ if iv := h.iv(); iv != nil {
141+ iv.SetWidget(extName, id, position, title, lines)
142+ }
143+}
144+func (h *interactiveExtHooks) ClearWidget(extName, id string) {
145+ if iv := h.iv(); iv != nil {
146+ iv.ClearWidget(extName, id)
147+ }
148+}
134 149
135 // extToolAdapter bridges *extensions.Manager to the150 // extToolAdapter bridges *extensions.Manager to the
136 // ExtensionToolSource interface declared in build.go (kept narrow to151 // ExtensionToolSource interface declared in build.go (kept narrow to
@@ -141,6 +156,16 @@ type extToolAdapter struct {
141 mgr *extensions.Manager156 mgr *extensions.Manager
142 }157 }
143 158
159+var _ ExtensionToolSource = (*extToolAdapter)(nil)
160+var _ ExtensionSkillSource = (*extToolAdapter)(nil)
161+
162+func (a *extToolAdapter) Skills() []*skills.Skill {
163+ if a == nil || a.mgr == nil {
164+ return nil
165+ }
166+ return a.mgr.Skills()
167+}
168+
144 func (a *extToolAdapter) Tools() []ExtensionToolInfo {169 func (a *extToolAdapter) Tools() []ExtensionToolInfo {
145 infos := a.mgr.Tools()170 infos := a.mgr.Tools()
146 out := make([]ExtensionToolInfo, len(infos))171 out := make([]ExtensionToolInfo, len(infos))
@@ -436,6 +461,61 @@ func applyInitialSessionResume(ctx context.Context, args Args, base Resolved, ex
436 // the events that have a clear extension-facing meaning are461 // the events that have a clear extension-facing meaning are
437 // forwarded; internal-only ones (text_delta, tool_progress) are462 // forwarded; internal-only ones (text_delta, tool_progress) are
438 // dropped to keep the per-extension stream sane.463 // dropped to keep the per-extension stream sane.
464+func sessionContext(sess *core.Session) *extproto.SessionContext {
465+ if sess == nil {
466+ return nil
467+ }
468+ return &extproto.SessionContext{
469+ ID: sess.Meta.ID,
470+ ParentID: sess.Meta.Parent,
471+ Path: sess.Path,
472+ CWD: sess.Meta.CWD,
473+ ForkPoint: sess.Meta.ForkPoint,
474+ }
475+}
476+
477+func announceSession(mgr *extensions.Manager, sess *core.Session) {
478+ if mgr == nil {
479+ return
480+ }
481+ var states map[string]json.RawMessage
482+ if sess != nil {
483+ states = copyExtensionStates(sess.ExtensionState)
484+ }
485+ mgr.EmitSessionEvent("session_start", sessionContext(sess), states)
486+ if sess != nil {
487+ mgr.EmitSessionEvent("session_opened", sessionContext(sess), states)
488+ }
489+}
490+
491+func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawMessage {
492+ if len(states) == 0 {
493+ return nil
494+ }
495+ out := make(map[string]json.RawMessage, len(states))
496+ for name, state := range states {
497+ out[name] = append(json.RawMessage(nil), state...)
498+ }
499+ return out
500+}
501+
502+func persistExtensionToolResult(mgr *extensions.Manager, sess *core.Session, result core.ToolResult) {
503+ if sess == nil {
504+ return
505+ }
506+ details, ok := result.Details.(extensions.ToolResultDetails)
507+ if !ok || details.Extension == "" || len(details.State) == 0 {
508+ return
509+ }
510+ if err := sess.AppendExtensionState(details.Extension, details.State); err != nil {
511+ fmt.Fprintf(os.Stderr, "extension state: %v\n", err)
512+ return
513+ }
514+ if mgr != nil {
515+ mgr.UpdateSessionState(details.Extension, details.State)
516+ }
517+}
518+
439 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {519 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
440 if mgr == nil {520 if mgr == nil {
441 return521 return
@@ -557,6 +637,9 @@ func (nonInteractiveExtHooks) ClearNotes(string)
557 func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}637 func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
558 func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}638 func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
559 func (nonInteractiveExtHooks) ClosePanel(string, string) {}639 func (nonInteractiveExtHooks) ClosePanel(string, string) {}
640+func (nonInteractiveExtHooks) SetStatus(string, string, string, string) {}
641+func (nonInteractiveExtHooks) SetWidget(string, string, string, string, []string) {}
642+func (nonInteractiveExtHooks) ClearWidget(string, string) {}
560 643
561 // setupNonInteractiveExtensions loads --ext paths and (unless644 // setupNonInteractiveExtensions loads --ext paths and (unless
562 // --no-ext) runs discovery. Returns the manager so the caller can645 // --no-ext) runs discovery. Returns the manager so the caller can
@@ -574,7 +657,6 @@ func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved,
574 }657 }
575 extMgr.WaitForReady(3 * time.Second)658 extMgr.WaitForReady(3 * time.Second)
576 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})659 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
577- extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
578 return extMgr, func() { extMgr.Stop(2 * time.Second) }660 return extMgr, func() { extMgr.Stop(2 * time.Second) }
579 }661 }
580 662
@@ -593,9 +675,9 @@ func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr
593 }675 }
594 return true, "", res.ModifiedArgs676 return true, "", res.ModifiedArgs
595 }677 }
596- ag.BeforeTurn = func(step int) (bool, string) {678+ ag.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
597- res := extMgr.InterceptTurnStart(ctx, step)679+ res := extMgr.InterceptTurnStart(turnCtx, step)
598- return !res.Block, res.Reason680+ return !res.Block, res.Reason, res.Context
599 }681 }
600 ag.BeforeAssistantMessage = func(text string) (bool, string, string) {682 ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
601 res := extMgr.InterceptAssistantMessage(ctx, text)683 res := extMgr.InterceptAssistantMessage(ctx, text)
@@ -677,8 +759,10 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
677 return err759 return err
678 }760 }
679 r.Provider, r.Model = providerName, model761 r.Provider, r.Model = providerName, model
762+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
680 defer sess.Close()763 defer sess.Close()
681 }764 }
765+ announceSession(extMgr, sess)
682 766
683 prompt := args.Prompt767 prompt := args.Prompt
684 if prompt == "" {768 if prompt == "" {
@@ -738,8 +822,10 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
738 return err822 return err
739 }823 }
740 r.Provider, r.Model = providerName, model824 r.Provider, r.Model = providerName, model
825+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
741 defer sess.Close()826 defer sess.Close()
742 }827 }
828+ announceSession(extMgr, sess)
743 829
744 prompt := args.Prompt830 prompt := args.Prompt
745 if prompt == "" {831 if prompt == "" {
@@ -838,8 +924,10 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
838 return err924 return err
839 }925 }
840 r.Provider, r.Model = providerName, model926 r.Provider, r.Model = providerName, model
927+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
841 defer sess.Close()928 defer sess.Close()
842 }929 }
930+ announceSession(extMgr, sess)
843 931
844 prompt := args.Prompt932 prompt := args.Prompt
845 if prompt == "" {933 if prompt == "" {
@@ -1170,9 +1258,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1170 }1258 }
1171 return true, "", r.ModifiedArgs1259 return true, "", r.ModifiedArgs
1172 }1260 }
1173- a.BeforeTurn = func(step int) (bool, string) {1261+ a.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
1174- r := extMgr.InterceptTurnStart(ctx, step)1262+ r := extMgr.InterceptTurnStart(turnCtx, step)
1175- return !r.Block, r.Reason1263+ return !r.Block, r.Reason, r.Context
1176 }1264 }
1177 a.BeforeAssistantMessage = func(text string) (bool, string, string) {1265 a.BeforeAssistantMessage = func(text string) (bool, string, string) {
1178 r := extMgr.InterceptAssistantMessage(ctx, text)1266 r := extMgr.InterceptAssistantMessage(ctx, text)
@@ -1261,9 +1349,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1261 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)1349 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
1262 })1350 })
1263 1351
1264- // Fire session_start once we know the manager's running.
1265- extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
1266-
1267 var sess *core.Session1352 var sess *core.Session
1268 var sessBaselineMsgs int // messages already on disk when current session opened1353 var sessBaselineMsgs int // messages already on disk when current session opened
1269 var sessionTitlePending bool1354 var sessionTitlePending bool
@@ -1296,6 +1381,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1296 sessionTitlePending = sess.Meta.Parent != "" && sess.Title == "" && len(ag.Messages()) <= sess.Meta.ForkPoint1381 sessionTitlePending = sess.Meta.Parent != "" && sess.Title == "" && len(ag.Messages()) <= sess.Meta.ForkPoint
1297 }1382 }
1298 }1383 }
1384+ announceSession(extMgr, sess)
1299 defer func() {1385 defer func() {
1300 persistMu.Lock()1386 persistMu.Lock()
1301 defer persistMu.Unlock()1387 defer persistMu.Unlock()
@@ -1334,16 +1420,27 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1334 }1420 }
1335 persistCompaction := func(messages []provider.Message) {1421 persistCompaction := func(messages []provider.Message) {
1336 sessionTransitionMu.RLock()1422 sessionTransitionMu.RLock()
1337- defer sessionTransitionMu.RUnlock()1423+ var session *extproto.SessionContext
1424+ var states map[string]json.RawMessage
1338 persistMu.Lock()1425 persistMu.Lock()
1339- defer persistMu.Unlock()1426+ if sess != nil {
1340- if sess == nil {1427+ if err := sess.AppendCompaction(messages); err == nil {
1341- return1428+ sessBaselineMsgs = len(messages)
1429+ session = sessionContext(sess)
1430+ states = copyExtensionStates(sess.ExtensionState)
1431+ }
1342 }1432 }
1343- if err := sess.AppendCompaction(messages); err == nil {1433+ persistMu.Unlock()
1344- sessBaselineMsgs = len(messages)1434+ sessionTransitionMu.RUnlock()
1435+ if session != nil {
1436+ extMgr.EmitSessionEvent("session_compacted", session, states)
1345 }1437 }
1346 }1438 }
1439+ persistToolResult := func(_ string, result core.ToolResult) {
1440+ persistMu.Lock()
1441+ defer persistMu.Unlock()
1442+ persistExtensionToolResult(extMgr, sess, result)
1443+ }
1347 wireAgentPersist := func(a *core.Agent) *core.Agent {1444 wireAgentPersist := func(a *core.Agent) *core.Agent {
1348 if a == nil {1445 if a == nil {
1349 return a1446 return a
@@ -1351,6 +1448,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1351 a.OnMessageAppended = persistMessage1448 a.OnMessageAppended = persistMessage
1352 a.OnUsage = persistUsage1449 a.OnUsage = persistUsage
1353 a.OnTranscriptCompacted = persistCompaction1450 a.OnTranscriptCompacted = persistCompaction
1451+ a.OnToolResult = persistToolResult
1354 return a1452 return a
1355 }1453 }
1356 wireAgentPersist(ag)1454 wireAgentPersist(ag)
@@ -1443,6 +1541,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1443 persistMu.Unlock()1541 persistMu.Unlock()
1444 return fmt.Errorf("session changed while loading; please try again")1542 return fmt.Errorf("session changed while loading; please try again")
1445 }1543 }
1544+ newStates := copyExtensionStates(candidate.session.ExtensionState)
1446 if oldSess != nil {1545 if oldSess != nil {
1447 _ = oldSess.Close()1546 _ = oldSess.Close()
1448 }1547 }
@@ -1477,6 +1576,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1477 }1576 }
1478 committed = true1577 committed = true
1479 persistMu.Unlock()1578 persistMu.Unlock()
1579+ if candidate.session.Meta.Parent != "" {
1580+ extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
1581+ }
1582+ extMgr.EmitSessionEvent("session_switched", sessionContext(candidate.session), newStates)
1480 return nil1583 return nil
1481 }1584 }
1482 1585
@@ -1626,12 +1729,16 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1626 1729
1627 // Commit only after every fallible operation has succeeded. Close1730 // Commit only after every fallible operation has succeeded. Close
1628 // the old session last so rollback never has to resurrect it.1731 // the old session last so rollback never has to resurrect it.
1732+ var newStates map[string]json.RawMessage
1629 persistMu.Lock()1733 persistMu.Lock()
1630 if sess != nil {1734 if sess != nil {
1631 writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)1735 writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
1632 _ = sess.Close()1736 _ = sess.Close()
1633 }1737 }
1634 sess = newSess1738 sess = newSess
1739+ if newSess != nil {
1740+ newStates = copyExtensionStates(newSess.ExtensionState)
1741+ }
1635 sessBaselineMsgs = 01742 sessBaselineMsgs = 0
1636 sessionTitlePending = false1743 sessionTitlePending = false
1637 activeProvider = newProvider1744 activeProvider = newProvider
@@ -1650,6 +1757,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1650 if swarmMgr != nil && newSess != nil {1757 if swarmMgr != nil && newSess != nil {
1651 swarmMgr.SetActiveSession(newSess.ID)1758 swarmMgr.SetActiveSession(newSess.ID)
1652 }1759 }
1760+ if newSess != nil {
1761+ extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
1762+ }
1653 return nil1763 return nil
1654 }1764 }
1655 1765
@@ -1906,6 +2016,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1906 // manifest and the skill tool.2016 // manifest and the skill tool.
1907 userHome, _ := os.UserHomeDir()2017 userHome, _ := os.UserHomeDir()
1908 list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)2018 list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)
2019+ list = mergeExtensionSkills(skills.NewTool(list), extMgr.Skills())
1909 return skills.VisibleSkills(list)2020 return skills.VisibleSkills(list)
1910 },2021 },
1911 NoYolo: args.NoYolo,2022 NoYolo: args.NoYolo,
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 +97 -0
@@ -220,6 +220,103 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
220 h.hostW.Close()220 h.hostW.Close()
221 }221 }
222 222
223+func TestPersistentChromeFrames(t *testing.T) {
224+ h := newHarness("chrome-ext")
225+ go h.ext.Run()
226+ h.handshake(t)
227+
228+ h.ext.SetStatus("progress", "2/4 tasks")
229+ status := h.drainUntil(t, "status")
230+ var sf extproto.StatusFromExt
231+ if err := json.Unmarshal(status.raw, &sf); err != nil {
232+ t.Fatal(err)
233+ }
234+ if sf.Key != "progress" || sf.Text != "2/4 tasks" {
235+ t.Fatalf("status = %+v", sf)
236+ }
237+
238+ h.ext.SetWidget("plan", "above_input", "Plan", []string{"one"})
239+ widget := h.drainUntil(t, "widget")
240+ var wf extproto.WidgetFromExt
241+ if err := json.Unmarshal(widget.raw, &wf); err != nil {
242+ t.Fatal(err)
243+ }
244+ if wf.ID != "plan" || wf.Position != "above_input" || wf.Title != "Plan" || len(wf.Lines) != 1 || wf.Lines[0] != "one" {
245+ t.Fatalf("widget = %+v", wf)
246+ }
247+
248+ h.ext.ClearWidget("plan")
249+ clear := h.drainUntil(t, "widget_clear")
250+ var cf extproto.ClearWidgetFromExt
251+ if err := json.Unmarshal(clear.raw, &cf); err != nil {
252+ t.Fatal(err)
253+ }
254+ if cf.ID != "plan" {
255+ t.Fatalf("widget clear = %+v", cf)
256+ }
257+ h.hostW.Close()
258+}
259+
260+func TestToolResultDetailsEmitsOpaqueMetadata(t *testing.T) {
261+ h := newHarness("details-ext")
262+ h.ext.Tool("details", "returns metadata", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult {
263+ return ToolResult{Content: []ToolContent{Text("ok")}, Details: JSONDetails(map[string]any{"state": 1})}
264+ })
265+ go h.ext.Run()
266+ h.handshake(t)
267+ h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "call-1", Name: "details", Args: json.RawMessage(`{}`)})
268+ frame := h.drainUntil(t, "tool_result")
269+ var result extproto.ToolResultFromExt
270+ if err := json.Unmarshal(frame.raw, &result); err != nil {
271+ t.Fatal(err)
272+ }
273+ if string(result.Details) != `{"state":1}` {
274+ t.Fatalf("details = %s", result.Details)
275+ }
276+ h.hostW.Close()
277+}
278+
279+func TestSessionEventCarriesIdentityAndState(t *testing.T) {
280+ h := newHarness("session-ext")
281+ received := make(chan Event, 1)
282+ h.ext.On("session_opened", func(event Event) { received <- event })
283+ go h.ext.Run()
284+ h.handshake(t)
285+ h.sendToExt(t, extproto.EventFromHost{
286+ Type: "event", Event: "session_opened",
287+ Session: &extproto.SessionContext{ID: "branch-1", ParentID: "root-1", Path: "/tmp/session.jsonl", ForkPoint: 4},
288+ State: json.RawMessage(`{"version":1}`),
289+ })
290+ select {
291+ case event := <-received:
292+ 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}` {
293+ t.Fatalf("session event = %+v state=%s", event.Session, event.State)
294+ }
295+ case <-time.After(2 * time.Second):
296+ t.Fatal("timeout waiting for session event")
297+ }
298+ h.hostW.Close()
299+}
300+
301+func TestTurnStartContextIsReturned(t *testing.T) {
302+ h := newHarness("context-ext")
303+ h.ext.InterceptTurnStart(func(int) TurnStartDecision {
304+ return TurnStartDecision{Context: "current phase: parse"}
305+ })
306+ go h.ext.Run()
307+ h.handshake(t)
308+ h.sendToExt(t, extproto.EventInterceptFromHost{Type: "event_intercept", ID: "intercept-1", Event: "turn_start", Step: 2})
309+ frame := h.drainUntil(t, "event_intercept_response")
310+ var response extproto.EventInterceptResponseFromExt
311+ if err := json.Unmarshal(frame.raw, &response); err != nil {
312+ t.Fatal(err)
313+ }
314+ if response.ID != "intercept-1" || response.Context != "current phase: parse" || response.Block {
315+ t.Fatalf("intercept response = %+v", response)
316+ }
317+ h.hostW.Close()
318+}
319+
223 func TestAlertEmitsStructuredFrame(t *testing.T) {320 func TestAlertEmitsStructuredFrame(t *testing.T) {
224 h := newHarness("alert-ext")321 h := newHarness("alert-ext")
225 runDone := make(chan error, 1)322 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 +208 -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,78 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
923 }1027 }
924 }1028 }
925 1029
1030+func (ext *Extension) writeFrame(frame []byte) (int, error) {
1031+ if ext == nil || ext.stdin == nil {
1032+ return 0, fmt.Errorf("extension stdin is closed")
1033+ }
1034+ ext.writeMu.Lock()
1035+ defer ext.writeMu.Unlock()
1036+ return ext.stdin.Write(frame)
1037+}
1038+
1039+func (ext *Extension) startLifecycleWriter() {
1040+ if ext.lifecycleFrames == nil {
1041+ ext.lifecycleFrames = make(chan []byte, 64)
1042+ }
1043+ if ext.lifecycleStop == nil {
1044+ ext.lifecycleStop = make(chan struct{})
1045+ }
1046+ go ext.lifecycleWriter()
1047+}
1048+
1049+func (ext *Extension) stopLifecycleWriter() {
1050+ if ext == nil || ext.lifecycleStop == nil {
1051+ return
1052+ }
1053+ ext.lifecycleStopOnce.Do(func() { close(ext.lifecycleStop) })
1054+}
1055+
1056+func (ext *Extension) enqueueLifecycleFrame(frame []byte) bool {
1057+ if ext == nil {
1058+ return false
1059+ }
1060+ if ext.lifecycleFrames == nil || ext.lifecycleStop == nil {
1061+ _, err := ext.writeFrame(frame)
1062+ return err == nil
1063+ }
1064+ copyFrame := append([]byte(nil), frame...)
1065+ select {
1066+ case <-ext.lifecycleStop:
1067+ return false
1068+ default:
1069+ }
1070+ select {
1071+ case ext.lifecycleFrames <- copyFrame:
1072+ return true
1073+ default:
1074+ // Prefer the newest lifecycle snapshot when a broken extension stops
1075+ // draining its pipe. The latest event still carries the current state.
1076+ select {
1077+ case <-ext.lifecycleFrames:
1078+ default:
1079+ }
1080+ select {
1081+ case ext.lifecycleFrames <- copyFrame:
1082+ return true
1083+ default:
1084+ return false
1085+ }
1086+ }
1087+}
1088+
1089+func (ext *Extension) lifecycleWriter() {
1090+ for {
1091+ select {
1092+ case <-ext.lifecycleStop:
1093+ return
1094+ case frame := <-ext.lifecycleFrames:
1095+ if _, err := ext.writeFrame(frame); err != nil {
1096+ return
1097+ }
1098+ }
1099+ }
1100+}
1101+
926 func (ext *Extension) recordDiagnostic(msg string) {1102 func (ext *Extension) recordDiagnostic(msg string) {
927 ext.mu.Lock()1103 ext.mu.Lock()
928 defer ext.mu.Unlock()1104 defer ext.mu.Unlock()
@@ -1075,6 +1251,24 @@ func (m *Manager) Tools() []ToolInfo {
1075 return out1251 return out
1076 }1252 }
1077 1253
1254+// Skills returns extension-bundled skills in deterministic extension-name
1255+// order. The build layer merges them after user skills and before built-ins.
1256+func (m *Manager) Skills() []*skills.Skill {
1257+ m.mu.RLock()
1258+ names := make([]string, 0, len(m.ext))
1259+ for name := range m.ext {
1260+ names = append(names, name)
1261+ }
1262+ sort.Strings(names)
1263+ var out []*skills.Skill
1264+ for _, name := range names {
1265+ ext := m.ext[name]
1266+ out = append(out, ext.skills...)
1267+ }
1268+ m.mu.RUnlock()
1269+ return out
1270+}
1271+
1078 // HasTool reports whether name is registered by any extension.1272 // HasTool reports whether name is registered by any extension.
1079 func (m *Manager) HasTool(name string) bool {1273 func (m *Manager) HasTool(name string) bool {
1080 m.mu.RLock()1274 m.mu.RLock()
@@ -1106,7 +1300,7 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess
1106 Name: name,1300 Name: name,
1107 Args: args,1301 Args: args,
1108 })1302 })
1109- if _, err := ext.stdin.Write(frame); err != nil {1303+ if _, err := ext.writeFrame(frame); err != nil {
1110 ext.mu.Lock()1304 ext.mu.Lock()
1111 delete(ext.pendingTool, id)1305 delete(ext.pendingTool, id)
1112 ext.mu.Unlock()1306 ext.mu.Unlock()
@@ -1172,7 +1366,7 @@ func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Du
1172 Name: name,1366 Name: name,
1173 Args: args,1367 Args: args,
1174 })1368 })
1175- if _, err := ext.stdin.Write(frame); err != nil {1369+ if _, err := ext.writeFrame(frame); err != nil {
1176 ext.mu.Lock()1370 ext.mu.Lock()
1177 delete(ext.pending, id)1371 delete(ext.pending, id)
1178 ext.mu.Unlock()1372 ext.mu.Unlock()
@@ -1206,7 +1400,7 @@ func (m *Manager) SendPanelKey(extName, panelID, key, text string) error {
1206 return fmt.Errorf("no extension %q", extName)1400 return fmt.Errorf("no extension %q", extName)
1207 }1401 }
1208 frame, _ := extproto.Encode(extproto.PanelKeyFromHost{Type: "panel_key", PanelID: panelID, Key: key, Text: text})1402 frame, _ := extproto.Encode(extproto.PanelKeyFromHost{Type: "panel_key", PanelID: panelID, Key: key, Text: text})
1209- _, err := ext.stdin.Write(frame)1403+ _, err := ext.writeFrame(frame)
1210 return err1404 return err
1211 }1405 }
1212 1406
@@ -1218,7 +1412,7 @@ func (m *Manager) SendPanelClose(extName, panelID string) error {
1218 return fmt.Errorf("no extension %q", extName)1412 return fmt.Errorf("no extension %q", extName)
1219 }1413 }
1220 frame, _ := extproto.Encode(extproto.PanelCloseFromHost{Type: "panel_close", PanelID: panelID})1414 frame, _ := extproto.Encode(extproto.PanelCloseFromHost{Type: "panel_close", PanelID: panelID})
1221- _, err := ext.stdin.Write(frame)1415+ _, err := ext.writeFrame(frame)
1222 return err1416 return err
1223 }1417 }
1224 1418
@@ -1238,7 +1432,7 @@ func stopExtensions(exts []*Extension, gracePeriod time.Duration) {
1238 continue1432 continue
1239 }1433 }
1240 if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {1434 if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {
1241- _, _ = ext.stdin.Write(frame)1435+ _, _ = ext.writeFrame(frame)
1242 }1436 }
1243 _ = ext.stdin.Close()1437 _ = ext.stdin.Close()
1244 }1438 }
M packages/agent/extensions/manager_test.go +60 -0
@@ -68,6 +68,9 @@ func (s *stubHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
68 }68 }
69 func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}69 func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
70 func (s *stubHooks) ClosePanel(string, string) {}70 func (s *stubHooks) ClosePanel(string, string) {}
71+func (s *stubHooks) SetStatus(string, string, string, string) {}
72+func (s *stubHooks) SetWidget(string, string, string, string, []string) {}
73+func (s *stubHooks) ClearWidget(string, string) {}
71 74
72 // writeMockExtension creates a minimal extension on disk that uses a75 // writeMockExtension creates a minimal extension on disk that uses a
73 // shell script (or batch file on windows) to drive the protocol. The76 // shell script (or batch file on windows) to drive the protocol. The
@@ -259,6 +262,63 @@ func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
259 }262 }
260 }263 }
261 264
265+func TestManifestBundlesSkillsWithSafePrecedence(t *testing.T) {
266+ tmp := t.TempDir()
267+ extRoot := filepath.Join(tmp, "extensions")
268+ extDir := filepath.Join(extRoot, "bundle")
269+ if err := os.MkdirAll(filepath.Join(extDir, "skills", "phases"), 0o755); err != nil {
270+ t.Fatal(err)
271+ }
272+ if err := os.WriteFile(filepath.Join(extDir, "skills", "phases", "SKILL.md"), []byte("---\nname: phases\ndescription: bundled phases\n---\nbody\n"), 0o644); err != nil {
273+ t.Fatal(err)
274+ }
275+ manifest := `{"name":"bundle","skills":["skills"],"description":"bundle"}`
276+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
277+ t.Fatal(err)
278+ }
279+ if err := os.WriteFile(filepath.Join(extDir, "theme.json"), []byte(`{"name":"bundle","colors":{"dark":{"accent":1}}}`), 0o644); err != nil {
280+ t.Fatal(err)
281+ }
282+
283+ mgr := New(tmp, "", "0.0.0-test", "", "", &stubHooks{})
284+ if errs := mgr.Discover(context.Background()); len(errs) > 0 {
285+ t.Fatalf("discover errors: %v", errs)
286+ }
287+ defer mgr.Stop(10 * time.Millisecond)
288+ bundled := mgr.Skills()
289+ if len(bundled) != 1 || bundled[0].Name != "phases" || bundled[0].Source != "extension bundle" {
290+ t.Fatalf("bundled skills = %#v", bundled)
291+ }
292+ if _, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{"../outside"}}); len(errs) == 0 {
293+ t.Fatal("path traversal manifest was accepted")
294+ }
295+ for _, path := range []string{"", filepath.Join(string(filepath.Separator), "outside")} {
296+ if _, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{path}}); len(errs) == 0 {
297+ t.Fatalf("unsafe skill path %q was accepted", path)
298+ }
299+ }
300+ if runtime.GOOS != "windows" {
301+ outside := filepath.Join(tmp, "outside", "SKILL.md")
302+ if err := os.MkdirAll(filepath.Dir(outside), 0o755); err != nil {
303+ t.Fatal(err)
304+ }
305+ if err := os.WriteFile(outside, []byte("---\nname: escape\ndescription: outside\n---\nbody\n"), 0o644); err != nil {
306+ t.Fatal(err)
307+ }
308+ unsafeDir := filepath.Join(extDir, "unsafe")
309+ if err := os.MkdirAll(unsafeDir, 0o755); err != nil {
310+ t.Fatal(err)
311+ }
312+ if err := os.Symlink(outside, filepath.Join(unsafeDir, "SKILL.md")); err != nil {
313+ t.Fatal(err)
314+ }
315+ loaded, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{"unsafe"}})
316+ if len(errs) == 0 || len(loaded) != 0 {
317+ t.Fatalf("symlinked skill outside extension was accepted: skills=%#v errors=%v", loaded, errs)
318+ }
319+ }
320+}
321+
262 func TestManagerSpawnAndInvoke(t *testing.T) {322 func TestManagerSpawnAndInvoke(t *testing.T) {
263 tmp := t.TempDir()323 tmp := t.TempDir()
264 extRoot := filepath.Join(tmp, "extensions")324 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 +47 -0
@@ -44,3 +44,50 @@ func TestClearNotesNoMatchKeepsNotes(t *testing.T) {
44 t.Fatalf("expected note to survive, got %d", len(i.extNotes))44 t.Fatalf("expected note to survive, got %d", len(i.extNotes))
45 }45 }
46 }46 }
47+
48+func TestPersistentExtensionWidgetRowsAreBounded(t *testing.T) {
49+ i := newNotesTestInteractive()
50+ lines := make([]string, 20)
51+ for idx := range lines {
52+ lines[idx] = "line"
53+ }
54+ i.SetWidget("extension", "widget", "above_input", "Title", lines)
55+
56+ i.mu.Lock()
57+ got := i.extensionChromeLinesLocked(80)
58+ i.mu.Unlock()
59+ if len(got) != maxExtensionWidgetRows {
60+ t.Fatalf("widget rows = %d, want %d: %v", len(got), maxExtensionWidgetRows, got)
61+ }
62+ if !strings.Contains(strings.Join(got, "\n"), "extension widgets truncated") {
63+ t.Fatalf("bounded widget rows omitted truncation marker: %v", got)
64+ }
65+}
66+
67+func TestPersistentExtensionStatusAndWidget(t *testing.T) {
68+ i := newNotesTestInteractive()
69+ i.SetStatus("tasked-phases", "progress", "success", "2/4 tasks checked")
70+ i.SetWidget("tasked-phases", "plan", "above_input", "Tasked phases", []string{"Current phase: parse", "[ ] read files"})
71+
72+ i.mu.Lock()
73+ lines := i.extensionChromeLinesLocked(80)
74+ statusKey := i.extensionStatusesKeyLocked()
75+ widgetKey := i.extensionWidgetsKeyLocked()
76+ i.mu.Unlock()
77+ joined := strings.Join(lines, "\n")
78+ if !strings.Contains(joined, "2/4 tasks checked") || !strings.Contains(joined, "Tasked phases") || !strings.Contains(joined, "[ ] read files") {
79+ t.Fatalf("persistent extension chrome = %q", joined)
80+ }
81+ if !strings.Contains(statusKey, "tasked-phases/progress/success/2/4 tasks checked") {
82+ t.Fatalf("status cache key = %q", statusKey)
83+ }
84+ if !strings.Contains(widgetKey, "tasked-phases/plan/above_input/Tasked phases") {
85+ t.Fatalf("widget cache key = %q", widgetKey)
86+ }
87+
88+ i.SetStatus("tasked-phases", "progress", "", "")
89+ i.ClearWidget("tasked-phases", "plan")
90+ if len(i.extStatuses) != 0 || len(i.extWidgets) != 0 {
91+ t.Fatalf("persistent extension chrome was not cleared: statuses=%v widgets=%v", i.extStatuses, i.extWidgets)
92+ }
93+}
M packages/agent/modes/interactive.go +202 -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,124 @@ 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+
1190+func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
1191+ var out []string
1192+ bodyWidth := cols - 2
1193+ if bodyWidth < 1 {
1194+ bodyWidth = 1
1195+ }
1196+ widgetRows := 0
1197+ widgetTruncated := false
1198+ appendWidgetRow := func(line string) bool {
1199+ if widgetRows >= maxExtensionWidgetRows-1 {
1200+ widgetTruncated = true
1201+ return false
1202+ }
1203+ out = append(out, line)
1204+ widgetRows++
1205+ return true
1206+ }
1207+
1208+ stopWidgets := false
1209+ for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
1210+ if stopWidgets {
1211+ break
1212+ }
1213+ for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1214+ widget := i.extWidgets[extName][id]
1215+ label := " [" + extName + "]"
1216+ if widget.Title != "" {
1217+ label += " " + widget.Title
1218+ }
1219+ if !appendWidgetRow(i.cfg.Theme.FG256(i.cfg.Theme.Accent, truncateLine(label, cols))) {
1220+ stopWidgets = true
1221+ break
1222+ }
1223+ for _, line := range widget.Lines {
1224+ if !appendWidgetRow(" " + truncateLine(line, bodyWidth)) {
1225+ stopWidgets = true
1226+ break
1227+ }
1228+ }
1229+ if stopWidgets {
1230+ break
1231+ }
1232+ }
1233+ }
1234+ if widgetTruncated {
1235+ out = append(out, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols)))
1236+ }
1237+
1238+ for _, extName := range sortedNestedOuterKeys(i.extStatuses) {
1239+ for _, key := range sortedNestedInnerKeys(i.extStatuses, extName) {
1240+ status := i.extStatuses[extName][key]
1241+ color := i.cfg.Theme.Muted
1242+ switch status.Level {
1243+ case "warn":
1244+ color = i.cfg.Theme.Warning
1245+ case "error":
1246+ color = i.cfg.Theme.Error
1247+ case "success":
1248+ color = i.cfg.Theme.Tool
1249+ }
1250+ label := " [" + extName + "] " + status.Text
1251+ out = append(out, i.cfg.Theme.FG256(color, truncateLine(label, cols)))
1252+ }
1253+ }
1254+ return out
1255+}
1256+
1119 func (i *Interactive) buildChatLocked(cols int) []string {1257 func (i *Interactive) buildChatLocked(cols int) []string {
1120 if i.agent != nil {1258 if i.agent != nil {
1121 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())1259 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
@@ -1599,13 +1737,15 @@ func (i *Interactive) redraw() {
1599 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))1737 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
1600 }1738 }
1601 1739
1740+ extensionLines := i.extensionChromeLinesLocked(cols)
1741+
1602 // Bottom-sticky sections (always visible, never scroll). Each1742 // Bottom-sticky sections (always visible, never scroll). Each
1603 // non-empty subsection (dialog, suggest popup, sliding-in queue)1743 // non-empty subsection (dialog, suggest popup, sliding-in queue)
1604 // is preceded by one blank row so it has air above the chat1744 // is preceded by one blank row so it has air above the chat
1605 // content. The status block and editor get their own dedicated1745 // content. The status block and editor get their own dedicated
1606 // blanks so spacing stays consistent whether or not a dialog or1746 // blanks so spacing stays consistent whether or not a dialog or
1607 // popup is showing.1747 // popup is showing.
1608- bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(statusLines)+len(edLines)+9)1748+ bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(edLines)+9)
1609 inputStartRow := -11749 inputStartRow := -1
1610 if len(dialog) > 0 {1750 if len(dialog) > 0 {
1611 bottom = append(bottom, "")1751 bottom = append(bottom, "")
@@ -1627,6 +1767,7 @@ func (i *Interactive) redraw() {
1627 workingBelow := workingPosition == tui.WorkingPositionBelowInput1767 workingBelow := workingPosition == tui.WorkingPositionBelowInput
1628 1768
1629 var aboveInput []string1769 var aboveInput []string
1770+ aboveInput = append(aboveInput, extensionLines...)
1630 if !statusBelow {1771 if !statusBelow {
1631 aboveInput = append(aboveInput, statusLines...)1772 aboveInput = append(aboveInput, statusLines...)
1632 }1773 }
@@ -3486,6 +3627,66 @@ func (i *Interactive) Display(extName, text string) {
3486 i.invalidate()3627 i.invalidate()
3487 }3628 }
3488 3629
3630+// SetStatus replaces one persistent status item owned by an extension.
3631+func (i *Interactive) SetStatus(extName, key, level, text string) {
3632+ if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {
3633+ return
3634+ }
3635+ i.mu.Lock()
3636+ if i.extStatuses == nil {
3637+ i.extStatuses = map[string]map[string]extensionStatus{}
3638+ }
3639+ if strings.TrimSpace(text) == "" {
3640+ if items := i.extStatuses[extName]; items != nil {
3641+ delete(items, key)
3642+ if len(items) == 0 {
3643+ delete(i.extStatuses, extName)
3644+ }
3645+ }
3646+ } else {
3647+ if i.extStatuses[extName] == nil {
3648+ i.extStatuses[extName] = map[string]extensionStatus{}
3649+ }
3650+ i.extStatuses[extName][key] = extensionStatus{Level: level, Text: text}
3651+ }
3652+ i.mu.Unlock()
3653+ i.invalidate()
3654+}
3655+
3656+// SetWidget replaces one persistent widget owned by an extension.
3657+func (i *Interactive) SetWidget(extName, id, position, title string, lines []string) {
3658+ if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {
3659+ return
3660+ }
3661+ i.mu.Lock()
3662+ if i.extWidgets == nil {
3663+ i.extWidgets = map[string]map[string]extensionWidget{}
3664+ }
3665+ if i.extWidgets[extName] == nil {
3666+ i.extWidgets[extName] = map[string]extensionWidget{}
3667+ }
3668+ i.extWidgets[extName][id] = extensionWidget{
3669+ Position: position,
3670+ Title: title,
3671+ Lines: append([]string(nil), lines...),
3672+ }
3673+ i.mu.Unlock()
3674+ i.invalidate()
3675+}
3676+
3677+// ClearWidget removes one persistent widget owned by an extension.
3678+func (i *Interactive) ClearWidget(extName, id string) {
3679+ i.mu.Lock()
3680+ if items := i.extWidgets[extName]; items != nil {
3681+ delete(items, id)
3682+ if len(items) == 0 {
3683+ delete(i.extWidgets, extName)
3684+ }
3685+ }
3686+ i.mu.Unlock()
3687+ i.invalidate()
3688+}
3689+
3489 func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {3690 func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
3490 i.mu.Lock()3691 i.mu.Lock()
3491 defer i.mu.Unlock()3692 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 +88 -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,70 @@ 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)
347+}
348+
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+ effectiveCount := 0
362+ err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
363+ var head sessionLineHead
364+ if err := json.Unmarshal(line, &head); err != nil {
365+ return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
366+ }
367+ switch head.Type {
368+ case "meta", "usage", "rename":
369+ return nil
370+ case "message":
371+ if _, err := hydrateMessage(line); err != nil {
372+ return fmt.Errorf("line %d: invalid message row: %w", lineNo, err)
373+ }
374+ effectiveCount++
375+ case "compaction":
376+ messages, err := hydrateCompaction(line)
377+ if err != nil {
378+ return fmt.Errorf("line %d: invalid compaction row: %w", lineNo, err)
379+ }
380+ effectiveCount = len(messages)
381+ case "extension_state":
382+ var row sessionLine
383+ if err := json.Unmarshal(line, &row); err != nil {
384+ return fmt.Errorf("line %d: invalid extension state row: %w", lineNo, err)
385+ }
386+ if effectiveCount > limit || row.Extension == "" || len(row.State) > maxExtensionStateBytes {
387+ return nil
388+ }
389+ if len(row.State) == 0 || strings.TrimSpace(string(row.State)) == "null" {
390+ delete(state, row.Extension)
391+ } else if json.Valid(row.State) {
392+ state[row.Extension] = append(json.RawMessage(nil), row.State...)
393+ }
394+ default:
395+ return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
396+ }
397+ return nil
398+ })
399+ if err != nil {
400+ return nil, fmt.Errorf("branch: read extension state: %w", err)
401+ }
402+ return state, nil
338 }403 }
339 404
340-func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool) (string, error) {405+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)406 dir := SessionsDir(root, cwd)
342 if err := os.MkdirAll(dir, 0o755); err != nil {407 if err := os.MkdirAll(dir, 0o755); err != nil {
343 return "", err408 return "", err
@@ -404,6 +469,26 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages
404 }469 }
405 }470 }
406 471
472+ // Extension snapshots are branch state, not provider-visible transcript
473+ // content. Write the latest snapshot that existed at the fork point so a
474+ // child branch starts from the same extension state as its copied prefix.
475+ extensionNames := make([]string, 0, len(extensionState))
476+ for name := range extensionState {
477+ extensionNames = append(extensionNames, name)
478+ }
479+ sort.Strings(extensionNames)
480+ for _, name := range extensionNames {
481+ line, err := json.Marshal(sessionLine{Type: "extension_state", Extension: name, State: extensionState[name]})
482+ if err != nil {
483+ return "", fmt.Errorf("branch: marshal extension state: %w", err)
484+ }
485+ if _, err := bw.Write(line); err != nil {
486+ return "", fmt.Errorf("branch: write extension state: %w", err)
487+ }
488+ if err := bw.WriteByte('\n'); err != nil {
489+ return "", fmt.Errorf("branch: terminate extension state row: %w", err)
490+ }
491+ }
407 // Preserve every cumulative checkpoint that belongs to this prefix. The492 // Preserve every cumulative checkpoint that belongs to this prefix. The
408 // final two rows are needed to reconstruct LastTurnUsage as a delta.493 // final two rows are needed to reconstruct LastTurnUsage as a delta.
409 for _, checkpoint := range checkpoints {494 for _, checkpoint := range checkpoints {
M packages/core/session_portable_test.go +37 -0
@@ -491,6 +491,43 @@ 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+
494 // TestBuildSessionTree verifies parent/child edges are rebuilt531 // TestBuildSessionTree verifies parent/child edges are rebuilt
495 // from meta + sibling-scan.532 // from meta + sibling-scan.
496 func TestBuildSessionTree(t *testing.T) {533 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