detached bce908a

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

feat(extensions): add session-aware extension APIs
commit
bce908a66c9acaefe547ab228d74877d9f37bf18
author
bnema · · 2026-08-04 14:56 UTC
committer
GitHub · · 2026-08-04 14:56 UTC
merge of
69e5a1b + e7a0d16 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
38 files · +4315 -111
M README.md +1 -1
@@ -954,7 +954,7 @@ An extension may ship only a theme: `extension.json` plus `theme.json` (or `them
954954
955955 ### Reference
956956
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.
958958
959959 ## Skills
960960
M docs/extensions.md +99 -11
@@ -6,13 +6,17 @@ its stdin/stdout. Extensions can be written in **any language** that
66 can read and write JSON lines from stdio — Go, TypeScript, Python,
77 Rust, shell with `jq`, anything.
88
9-Four phases shipped so far:
9+Six phases shipped so far:
1010
1111 - **Phase 1**: slash commands, chat notifications, and host alerts.
1212 - **Phase 2**: tools the LLM can call.
1313 - **Phase 3**: lifecycle event subscriptions + tool-call interception
1414 for guardrail extensions.
1515 - **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.
1620 - **Theme-only extensions**: ship `theme.json` without launching a
1721 subprocess. See [themes.md](themes.md).
1822
@@ -118,6 +122,7 @@ manifest tells zot how to launch it:
118122 "args": ["--mode", "daemon"],
119123 "language": "go",
120124 "description": "current weather for any city",
125+ "skills": ["skills"],
121126 "enabled": true
122127 }
123128 ```
@@ -130,6 +135,7 @@ manifest tells zot how to launch it:
130135 | `args` | optional. extra argv passed to `exec`. |
131136 | `language` | optional. informational only (`go`, `python`, `typescript`, ...). |
132137 | `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. |
133139 | `enabled` | optional, defaults to `true`. set to `false` to disable without removing. |
134140
135141 ## Lifecycle
@@ -149,10 +155,12 @@ manifest tells zot how to launch it:
149155 (logged in the extension's own log file).
150156 5. **Runtime**: after `ready`, zot dispatches `command_invoked` frames
151157 when the user runs a registered command; the extension responds
152- with `command_response`. Extensions can also push `notify` and
153- structured `alert` frames during runtime. Panel-capable extensions
154- may open an interactive panel, receive key events, and push redraws
155- while the panel is focused.
158+ with `command_response`. Extensions can also push `notify`, persistent
159+ status/widget, and structured `alert` frames during runtime. Extensions
160+ subscribed to session lifecycle events receive the active branch identity
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.
156164 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to
157165 2s for the extension to send `shutdown_ack`. Holdouts are
158166 SIGTERM'd, then SIGKILL'd.
@@ -236,9 +244,17 @@ registered deferred tools that become available after this result.
236244
237245 ```json
238246 {"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}}}
240249 ```
241250
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+
242258 #### `subscribe`
243259
244260 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`.
250266 "intercept":["tool_call","turn_start","assistant_message"]}
251267 ```
252268
253-Recognised event names: `session_start`, `turn_start`, `turn_end`,
254-`tool_call`, `tool_confirmation_requested`, `assistant_message`.
269+Recognised event names include `session_start`, `session_opened`,
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.
255277
256278 `tool_confirmation_requested` fires only when zot is about to wait for
257279 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
282304 | `reason` | refusal text (on block) or pass-through note. |
283305 | `modified_args` | for `tool_call`: rewritten JSON args the tool will actually see. Must be a valid JSON object. Ignored when `block` is true. |
284306 | `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. |
285308
286309 Missing the response within 5s is treated as "allow" (i.e. an
287310 unresponsive extension never stalls the agent). When multiple
@@ -298,6 +321,9 @@ subsequent interceptor sees the previous one's output.
298321
299322 {"type":"event_intercept_response","id":"...",
300323 "replace_text":"[redacted]"}
324+
325+{"type":"event_intercept_response","id":"...",
326+ "context":"Current phase: parse files\nRemaining: validate inputs"}
301327 ```
302328
303329 #### `command_response` (reply to `command_invoked`)
@@ -369,6 +395,32 @@ Closes a previously-open panel.
369395 {"type":"panel_close","panel_id":"todos-main"}
370396 ```
371397
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+
372424 #### `notify` (one-way, any time)
373425
374426 ```json
@@ -429,7 +481,10 @@ Sent in response to `shutdown`. Extension should exit promptly after.
429481 "zot_version":"0.0.7","provider":"anthropic",
430482 "model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot",
431483 "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}}
433488 ```
434489
435490 Sent immediately after `hello`. Wait for this frame before sending
@@ -473,6 +528,9 @@ Lifecycle notification for events the extension subscribed to via
473528 `subscribe`. One-way — no response expected.
474529
475530 ```json
531+{"type":"event","event":"session_opened",
532+ "session":{"id":"branch-1","parent_id":"root-1","fork_point":4},
533+ "state":{"version":1}}
476534 {"type":"event","event":"turn_start","step":1}
477535 {"type":"event","event":"tool_call",
478536 "tool_id":"...","tool_name":"read","tool_args":{"path":"foo.go"}}
@@ -622,6 +680,16 @@ host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`,
622680 `ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for
623681 `hello_ack`, runs `OnHello`, announces registrations, then sends `ready`.
624682
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+
625693 The SDK has four interceptor hooks, all optional:
626694
627695 ```go
@@ -662,8 +730,17 @@ See:
662730 - `examples/extensions/guard/` — event subscriptions + tool-call
663731 interception (refuses dangerous bash patterns)
664732 - `examples/extensions/todo/` — interactive persistent panel + tool
733+- `examples/extensions/tasked-phases/` — spec, phased checklist, persistent tool, and `/phases` panel
665734 - `examples/extensions/scratchpad/` — source-run TypeScript commands + tool
666735
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+
667744 ### Hot reload
668745
669746 Type `/reload-ext` in the TUI to tear down every running extension
@@ -713,8 +790,9 @@ Phase 2 (shipped):
713790 - [x] tool result attribution surfaces extension name in details
714791
715792 Phase 3 (shipped):
716-- [x] event subscriptions (`session_start`, `turn_start`, `turn_end`,
717- `tool_call`, `assistant_message`)
793+- [x] event subscriptions (`session_start`, `session_opened`,
794+ `session_switched`, `session_forked`, `session_compacted`, `turn_start`,
795+ `turn_end`, `tool_call`, `assistant_message`)
718796 - [x] tool-call interception (block before execution)
719797
720798 Phase 4 (shipped):
@@ -724,6 +802,16 @@ Phase 4 (shipped):
724802 - [x] rewrite user-visible assistant text via `replace_text`
725803 - [x] `/reload-ext` slash command (hot-reload without restarting zot)
726804
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+
727815 Future (no firm timeline):
728816 - [ ] TypeScript and Python SDK packages (currently the wire format
729817 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
6666 `.claude/skills/` or `.agents/skills/` directories into a project and
6767 zot will pick them up.
6868
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+
6980 When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to
7081 `$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`
7182 on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows.
@@ -113,9 +124,10 @@ are omitted from the model's startup context.
113124
114125 ## Examples
115126
116-See `examples/skills/` for two starter skills:
127+See `examples/skills/` for starter skills:
117128
118129 - `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
119131 - `test-fix/` — diagnose + minimally fix a failing test
120132
121133 ## Comparison to other discovery layouts
A examples/extensions/tasked-phases/README.md +57 -0
@@ -0,0 +1,57 @@
1+# tasked-phases
2+
3+A zot extension for keeping a spec, phased plan, and checklist in one place.
4+It provides:
5+
6+- the `tasked_phases` tool for persistent spec/phase/task state;
7+- `/phases` for a full interactive panel;
8+- compact mutation results and full `get_status` output;
9+- project-scoped JSON persistence under the extension data directory.
10+
11+The tool actions are:
12+
13+```text
14+get_status set_spec replace_plan
15+add_phase update_phase remove_phase
16+add_task update_task remove_task
17+set_current_phase set_task_checked set_phase_checked
18+clear
19+```
20+
21+## Build and run
22+
23+From the zot repository:
24+
25+```bash
26+cd examples/extensions/tasked-phases
27+go build -o tasked-phases .
28+
29+# One-run development load, launched from the project you want to track:
30+cd /path/to/project
31+zot --no-ext --ext /path/to/zot/examples/extensions/tasked-phases
32+
33+# Or install it globally (run this from the extension directory):
34+cd /path/to/zot/examples/extensions/tasked-phases
35+zot ext install .
36+```
37+
38+Then use `/phases` or ask the model to create a spec and phased checklist with
39+`tasked_phases`. The extension keys its durable state by the host-reported
40+project CWD.
41+
42+The extension bundles its workflow skill under `skills/tasked-phases/`, so
43+`zot ext install` makes the guidance available automatically. The standalone
44+copy at `examples/skills/tasked-phases/SKILL.md` remains useful when you want
45+the same workflow without installing the extension.
46+
47+## Persistence and turn focus
48+
49+The extension stores one restrictive state file per project CWD as a fallback
50+for hosts that do not persist sessions. When zot session persistence is
51+available, the plan is also stored as extension-owned state on each session
52+branch and restored when sessions are opened, switched, or forked.
53+
54+Before each model turn, the extension supplies only the active phase, its goal,
55+and a bounded set of incomplete tasks as hidden context. Completed phases and
56+future phases are not repeated in every provider request; use `get_status` or
57+`/phases` when the full checklist is needed.
A examples/extensions/tasked-phases/extension.json +10 -0
@@ -0,0 +1,10 @@
1+{
2+ "name": "tasked-phases",
3+ "version": "1.0.0",
4+ "exec": "./tasked-phases",
5+ "args": [],
6+ "language": "go",
7+ "description": "persistent phased spec and checklist tracking",
8+ "skills": ["skills/tasked-phases"],
9+ "enabled": true
10+}
A examples/extensions/tasked-phases/main.go +244 -0
@@ -0,0 +1,244 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "strings"
8+ "sync"
9+
10+ "github.com/patriceckhart/zot/packages/agent/ext"
11+)
12+
13+const (
14+ extensionName = "tasked-phases"
15+ extensionVersion = "1.0.0"
16+ toolName = "tasked_phases"
17+ panelID = "tasked-phases"
18+)
19+
20+const toolSchema = `{
21+ "type": "object",
22+ "additionalProperties": false,
23+ "properties": {
24+ "action": {
25+ "type": "string",
26+ "enum": ["get_status", "set_spec", "replace_plan", "add_phase", "update_phase", "remove_phase", "add_task", "update_task", "remove_task", "set_current_phase", "set_task_checked", "set_phase_checked", "clear"],
27+ "description": "State operation to perform. Closed plans must be restarted with clear, or set_spec immediately followed by replace_plan."
28+ },
29+ "spec": {"type": "string", "description": "Spec text used by set_spec"},
30+ "phaseId": {"type": "string", "description": "Target phase id, raw id without the brackets shown in summaries"},
31+ "phaseTitle": {"type": "string", "description": "Phase title for add_phase or update_phase"},
32+ "phaseGoal": {"type": "string", "description": "Phase goal for add_phase or update_phase"},
33+ "taskId": {"type": "string", "description": "Target task id"},
34+ "taskText": {"type": "string", "description": "Task text for add_task or update_task"},
35+ "checked": {"type": "boolean", "description": "Checked state for set_task_checked or set_phase_checked"},
36+ "phases": {
37+ "type": "array",
38+ "description": "Full plan replacement used by replace_plan",
39+ "items": {
40+ "type": "object",
41+ "additionalProperties": false,
42+ "properties": {
43+ "id": {"type": "string", "description": "Optional phase id. If omitted, one is generated."},
44+ "title": {"type": "string", "description": "Phase title"},
45+ "goal": {"type": "string", "description": "Optional short goal for the phase"},
46+ "tasks": {
47+ "type": "array",
48+ "description": "Checklist tasks for the phase",
49+ "items": {
50+ "type": "object",
51+ "additionalProperties": false,
52+ "properties": {
53+ "id": {"type": "string", "description": "Optional task id. If omitted, one is generated."},
54+ "text": {"type": "string", "description": "Checklist task text"},
55+ "checked": {"type": "boolean", "description": "Whether the task starts checked"}
56+ },
57+ "required": ["text"]
58+ }
59+ }
60+ },
61+ "required": ["title"]
62+ }
63+ }
64+ },
65+ "required": ["action"]
66+}`
67+
68+type app struct {
69+ ext *ext.Extension
70+ store *stateStore
71+
72+ panelMu sync.Mutex
73+ panelOpen bool
74+}
75+
76+func newApp(e *ext.Extension) *app {
77+ return &app{ext: e, store: newStateStore()}
78+}
79+
80+func main() {
81+ e := ext.New(extensionName, extensionVersion)
82+ a := newApp(e)
83+
84+ e.OnHello(func(host ext.HostInfo) {
85+ dataDir := host.DataDir
86+ if dataDir == "" {
87+ dataDir = host.ExtensionDir
88+ }
89+ if err := a.store.load(dataDir, host.CWD); err != nil {
90+ e.Logf("load project state: %v", err)
91+ return
92+ }
93+ if state, err := a.store.snapshot(); err == nil {
94+ a.publishChrome(state)
95+ }
96+ })
97+ restoreSession := func(event ext.Event) {
98+ if err := a.store.activateSession(event.State); err != nil {
99+ e.Logf("restore session state: %v", err)
100+ a.publishUnavailableChrome()
101+ return
102+ }
103+ if state, err := a.store.snapshot(); err == nil {
104+ a.publishChrome(state)
105+ a.renderPanelIfOpen(state)
106+ }
107+ }
108+ e.On("session_opened", restoreSession)
109+ e.On("session_switched", restoreSession)
110+ e.On("session_forked", restoreSession)
111+ e.InterceptTurnStart(func(_ int) ext.TurnStartDecision {
112+ state, err := a.store.snapshot()
113+ if err != nil {
114+ e.Logf("load turn focus: %v", err)
115+ return ext.TurnStartDecision{}
116+ }
117+ return ext.TurnStartDecision{Context: buildTurnContext(state)}
118+ })
119+
120+ e.Tool(toolName,
121+ "Persist and update a structured spec, phased plan, current phase, and checklist tasks. Use it for spec-driven planning and progress tracking.",
122+ json.RawMessage(toolSchema), a.handleTool)
123+ e.Command("phases", "Show the current spec, phases, and checklist state", a.handleCommand)
124+ e.OnPanelKey(panelID, a.handlePanelKey, a.handlePanelClose)
125+
126+ if err := e.Run(); err != nil {
127+ fmt.Fprintln(os.Stderr, err)
128+ os.Exit(1)
129+ }
130+}
131+
132+func (a *app) handleCommand(_ string) ext.Response {
133+ state, err := a.store.snapshot()
134+ if err != nil {
135+ return ext.Errorf("load tasked phases: %v", err)
136+ }
137+ a.publishChrome(state)
138+ a.panelMu.Lock()
139+ a.panelOpen = true
140+ a.panelMu.Unlock()
141+ return ext.OpenPanel(panelID, "Tasked phases", buildViewLines(state), "Escape closes this panel")
142+}
143+
144+func (a *app) handlePanelKey(key, _ string) {
145+ if key == "esc" {
146+ a.ext.ClosePanel(panelID)
147+ a.setPanelOpen(false)
148+ }
149+}
150+
151+func (a *app) handlePanelClose() { a.setPanelOpen(false) }
152+
153+func (a *app) setPanelOpen(open bool) {
154+ a.panelMu.Lock()
155+ a.panelOpen = open
156+ a.panelMu.Unlock()
157+}
158+
159+func (a *app) renderPanelIfOpen(state PlanState) {
160+ a.panelMu.Lock()
161+ open := a.panelOpen
162+ a.panelMu.Unlock()
163+ if open {
164+ a.ext.RenderPanel(panelID, "Tasked phases", buildViewLines(state), "Escape closes this panel")
165+ }
166+}
167+
168+func (a *app) handleTool(args json.RawMessage) ext.ToolResult {
169+ var params toolArgs
170+ if err := json.Unmarshal(args, &params); err != nil {
171+ return ext.TextErrorResult("invalid args: " + err.Error())
172+ }
173+ if !isKnownAction(params.Action) {
174+ return ext.TextErrorResult("unsupported action: " + params.Action)
175+ }
176+
177+ if params.Action == actionGetStatus {
178+ state, err := a.store.snapshot()
179+ if err != nil {
180+ return toolResult(params.Action, PlanState{}, "", err)
181+ }
182+ a.publishChrome(state)
183+ a.renderPanelIfOpen(state)
184+ return toolResult(params.Action, state, "Current phased plan status", nil)
185+ }
186+
187+ state, headline, err := a.store.transact(func(current PlanState) (PlanState, string, error) {
188+ return applyAction(current, params)
189+ })
190+ // A zero state means transact could not obtain a loaded snapshot. Do not
191+ // replace persistent chrome with an invented empty plan. For action and
192+ // persistence errors, transact returns the unchanged usable state, which
193+ // should remain visible to help the user recover.
194+ if err == nil || state.Version != 0 {
195+ a.publishChrome(state)
196+ }
197+ if err == nil {
198+ a.renderPanelIfOpen(state)
199+ }
200+ return toolResult(params.Action, state, headline, err)
201+}
202+
203+func (a *app) publishUnavailableChrome() {
204+ a.ext.SetStatus("progress", "plan unavailable")
205+ a.ext.SetWidget("plan", "above_input", "Tasked phases", []string{"Plan state could not be restored for this session."})
206+ a.panelMu.Lock()
207+ open := a.panelOpen
208+ a.panelMu.Unlock()
209+ if open {
210+ a.ext.RenderPanel(panelID, "Tasked phases", []string{"Plan state could not be restored for this session."}, "Escape closes this panel")
211+ }
212+}
213+
214+func (a *app) publishChrome(state PlanState) {
215+ if isPlanClosed(state) {
216+ a.ext.SetStatus("progress", "closed: "+state.ClosedSummary)
217+ } else if done, total := getPlanProgress(state); total > 0 {
218+ a.ext.SetStatus("progress", fmt.Sprintf("%d/%d tasks checked", done, total))
219+ } else {
220+ a.ext.SetStatus("progress", "no active plan")
221+ }
222+ a.ext.SetWidget("plan", "above_input", "Tasked phases", strings.Split(buildCompactSummary(state), "\n"))
223+}
224+
225+func toolResult(action string, state PlanState, headline string, err error) ext.ToolResult {
226+ text := buildToolResultText(action, state, headline, err)
227+ var result ext.ToolResult
228+ if err != nil {
229+ result = ext.TextErrorResult(text)
230+ } else {
231+ result = ext.TextResult(text)
232+ }
233+ result.Details = ext.JSONDetails(state)
234+ return result
235+}
236+
237+func isKnownAction(action string) bool {
238+ for _, known := range toolActions {
239+ if action == known {
240+ return true
241+ }
242+ }
243+ return false
244+}
A examples/extensions/tasked-phases/manifest_test.go +48 -0
@@ -0,0 +1,48 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "slices"
8+ "testing"
9+)
10+
11+func TestManifestMatchesExtensionConstants(t *testing.T) {
12+ data, err := os.ReadFile("extension.json")
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ var manifest struct {
17+ Name string `json:"name"`
18+ Version string `json:"version"`
19+ Skills []string `json:"skills"`
20+ }
21+ if err := json.Unmarshal(data, &manifest); err != nil {
22+ t.Fatalf("extension.json is not valid JSON: %v", err)
23+ }
24+ if manifest.Name != extensionName || manifest.Version != extensionVersion {
25+ t.Fatalf("manifest = %s/%s, constants = %s/%s", manifest.Name, manifest.Version, extensionName, extensionVersion)
26+ }
27+ for _, skill := range manifest.Skills {
28+ if _, err := os.Stat(filepath.Join(skill, "SKILL.md")); err != nil {
29+ t.Errorf("declared skill %q has no SKILL.md: %v", skill, err)
30+ }
31+ }
32+}
33+
34+func TestToolSchemaEnumMatchesToolActions(t *testing.T) {
35+ var schema struct {
36+ Properties struct {
37+ Action struct {
38+ Enum []string `json:"enum"`
39+ } `json:"action"`
40+ } `json:"properties"`
41+ }
42+ if err := json.Unmarshal([]byte(toolSchema), &schema); err != nil {
43+ t.Fatalf("tool schema is not valid JSON: %v", err)
44+ }
45+ if !slices.Equal(schema.Properties.Action.Enum, toolActions) {
46+ t.Fatalf("schema enum = %v, toolActions = %v", schema.Properties.Action.Enum, toolActions)
47+ }
48+}
A examples/extensions/tasked-phases/skill_sync_test.go +24 -0
@@ -0,0 +1,24 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestBundledSkillMatchesStandaloneCopy(t *testing.T) {
11+ extensionSkill := filepath.Join("skills", "tasked-phases", "SKILL.md")
12+ standaloneSkill := filepath.Join("..", "..", "skills", "tasked-phases", "SKILL.md")
13+ extension, err := os.ReadFile(extensionSkill)
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ standalone, err := os.ReadFile(standaloneSkill)
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ if !bytes.Equal(extension, standalone) {
22+ t.Fatal("bundled and standalone tasked-phases skills have diverged")
23+ }
24+}
A examples/extensions/tasked-phases/skills/tasked-phases/SKILL.md +33 -0
@@ -0,0 +1,33 @@
1+---
2+name: tasked-phases
3+description: Use when the user wants a spec turned into phased work with checklist tasks tracked by the tasked_phases tool, or when ongoing work should be tracked phase by phase.
4+---
5+
6+# Tasked Phases
7+
8+Use the `tasked_phases` tool as the source of truth for the spec, phases, current focus, and checklist progress.
9+
10+## Workflow
11+
12+1. Clarify missing requirements when the spec is ambiguous.
13+2. Save the accepted spec with `tasked_phases` using `set_spec`.
14+3. Create or materially revise the plan with `replace_plan`.
15+4. Keep phases small and tasks concrete enough to check off.
16+5. Set the focus with `set_current_phase` and pass the target `phaseId`.
17+6. Call `set_task_checked` immediately after each checklist task is completed.
18+7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task.
19+8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` to discard the plan, or call `set_spec` immediately followed by `replace_plan` to record a new spec and a new set of phases.
20+
21+## Planning rules
22+
23+- Prefer 3–7 phases unless the task is tiny.
24+- Each task should describe an observable outcome.
25+- Avoid vague tasks such as “work on this” or “finish implementation”.
26+- Keep the stored spec and plan concise and operational.
27+
28+## Tool guidance
29+
30+- Use `get_status` before relying on remembered state or revising a long-running plan.
31+- Use `replace_plan` for a substantial restructuring; use `add_*`, `update_*`, and `remove_*` for small changes.
32+- Pass `phaseId` as the raw ID shown in brackets, without the brackets. The tool also accepts a complete phase label or a unique phase title when recovering from copied output.
33+- Do not rely on prose alone for completion state; update the tool after each completed task.
A examples/extensions/tasked-phases/state.go +1182 -0
@@ -0,0 +1,1182 @@
1+package main
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+ "regexp"
12+ "strconv"
13+ "strings"
14+ "sync"
15+ "time"
16+ "unicode"
17+ "unicode/utf8"
18+)
19+
20+const (
21+ actionGetStatus = "get_status"
22+ actionSetSpec = "set_spec"
23+ actionReplacePlan = "replace_plan"
24+ actionAddPhase = "add_phase"
25+ actionUpdatePhase = "update_phase"
26+ actionRemovePhase = "remove_phase"
27+ actionAddTask = "add_task"
28+ actionUpdateTask = "update_task"
29+ actionRemoveTask = "remove_task"
30+ actionSetCurrentPhase = "set_current_phase"
31+ actionSetTaskChecked = "set_task_checked"
32+ actionSetPhaseChecked = "set_phase_checked"
33+ actionClear = "clear"
34+)
35+
36+var (
37+ phaseIDPattern = regexp.MustCompile(`^phase-(\d+)$`)
38+ taskIDPattern = regexp.MustCompile(`^task-(\d+)$`)
39+ bracketedIDPattern = regexp.MustCompile(`\[([^\]]+)\]`)
40+)
41+
42+var toolActions = []string{
43+ actionGetStatus,
44+ actionSetSpec,
45+ actionReplacePlan,
46+ actionAddPhase,
47+ actionUpdatePhase,
48+ actionRemovePhase,
49+ actionAddTask,
50+ actionUpdateTask,
51+ actionRemoveTask,
52+ actionSetCurrentPhase,
53+ actionSetTaskChecked,
54+ actionSetPhaseChecked,
55+ actionClear,
56+}
57+
58+var closedPlanBlockedActions = map[string]bool{
59+ actionReplacePlan: true,
60+ actionAddPhase: true,
61+ actionUpdatePhase: true,
62+ actionRemovePhase: true,
63+ actionAddTask: true,
64+ actionUpdateTask: true,
65+ actionRemoveTask: true,
66+ actionSetCurrentPhase: true,
67+ actionSetTaskChecked: true,
68+ actionSetPhaseChecked: true,
69+}
70+
71+type PhaseTask struct {
72+ ID string `json:"id"`
73+ Text string `json:"text"`
74+ Checked bool `json:"checked"`
75+}
76+
77+type Phase struct {
78+ ID string `json:"id"`
79+ Title string `json:"title"`
80+ Goal string `json:"goal,omitempty"`
81+ Tasks []PhaseTask `json:"tasks"`
82+}
83+
84+type PlanState struct {
85+ Version int `json:"version"`
86+ Spec string `json:"spec,omitempty"`
87+ Phases []Phase `json:"phases"`
88+ CurrentPhaseID string `json:"currentPhaseId,omitempty"`
89+ ClosedAt *int64 `json:"closedAt,omitempty"`
90+ ClosedSummary string `json:"closedSummary,omitempty"`
91+ NextPhaseNumber int `json:"nextPhaseNumber"`
92+ NextTaskNumber int `json:"nextTaskNumber"`
93+ UpdatedAt int64 `json:"updatedAt"`
94+}
95+
96+type TaskInput struct {
97+ ID *string `json:"id"`
98+ Text string `json:"text"`
99+ Checked *bool `json:"checked"`
100+}
101+
102+type PhaseInput struct {
103+ ID *string `json:"id"`
104+ Title string `json:"title"`
105+ Goal *string `json:"goal"`
106+ Tasks *[]TaskInput `json:"tasks"`
107+}
108+
109+type toolArgs struct {
110+ Action string `json:"action"`
111+ Spec *string `json:"spec"`
112+ PhaseID string `json:"phaseId"`
113+ PhaseTitle *string `json:"phaseTitle"`
114+ PhaseGoal *string `json:"phaseGoal"`
115+ TaskID string `json:"taskId"`
116+ TaskText *string `json:"taskText"`
117+ Checked *bool `json:"checked"`
118+ Phases *[]PhaseInput `json:"phases"`
119+}
120+
121+func nowMillis() int64 { return time.Now().UnixMilli() }
122+
123+func createEmptyState() PlanState {
124+ return PlanState{
125+ Version: 1,
126+ Phases: []Phase{},
127+ NextPhaseNumber: 1,
128+ NextTaskNumber: 1,
129+ UpdatedAt: nowMillis(),
130+ }
131+}
132+
133+func cloneState(state PlanState) PlanState {
134+ cloned := state
135+ cloned.Phases = make([]Phase, len(state.Phases))
136+ for i, phase := range state.Phases {
137+ cloned.Phases[i] = phase
138+ cloned.Phases[i].Tasks = append([]PhaseTask(nil), phase.Tasks...)
139+ }
140+ return cloned
141+}
142+
143+func normalizeOptionalText(value string) string {
144+ return strings.TrimSpace(value)
145+}
146+
147+// sanitizeDisplayText removes terminal escape sequences and non-printing
148+// control characters at the rendering boundary. Persisted state is never
149+// modified, so the original extension-supplied text remains recoverable.
150+func sanitizeDisplayText(text string) string {
151+ var out strings.Builder
152+ for index := 0; index < len(text); {
153+ if text[index] == 0x1b {
154+ index++
155+ if index >= len(text) {
156+ break
157+ }
158+ switch text[index] {
159+ case '[': // CSI sequence.
160+ index++
161+ for index < len(text) {
162+ final := text[index]
163+ index++
164+ if final >= 0x40 && final <= 0x7e {
165+ break
166+ }
167+ }
168+ case ']': // OSC sequence, terminated by BEL or ST.
169+ index++
170+ for index < len(text) {
171+ if text[index] == 0x07 {
172+ index++
173+ break
174+ }
175+ if text[index] == 0x1b && index+1 < len(text) && text[index+1] == '\\' {
176+ index += 2
177+ break
178+ }
179+ index++
180+ }
181+ default:
182+ index++
183+ }
184+ continue
185+ }
186+ r, size := utf8.DecodeRuneInString(text[index:])
187+ index += size
188+ if r != '\n' && (unicode.IsControl(r) || r == 0x7f) {
189+ continue
190+ }
191+ out.WriteRune(r)
192+ }
193+ return out.String()
194+}
195+
196+func singleLine(text string) string {
197+ return strings.Join(strings.Fields(text), " ")
198+}
199+
200+func displaySingleLine(text string) string {
201+ return singleLine(sanitizeDisplayText(text))
202+}
203+
204+func truncatePlain(text string, maxLength int) string {
205+ if maxLength < 1 {
206+ return ""
207+ }
208+ runes := []rune(text)
209+ if len(runes) <= maxLength {
210+ return text
211+ }
212+ return strings.TrimRight(string(runes[:maxLength-1]), " \t\r\n") + "…"
213+}
214+
215+func getPhaseProgress(phase Phase) (done, total int) {
216+ total = len(phase.Tasks)
217+ for _, task := range phase.Tasks {
218+ if task.Checked {
219+ done++
220+ }
221+ }
222+ return done, total
223+}
224+
225+func getPlanProgress(state PlanState) (done, total int) {
226+ for _, phase := range state.Phases {
227+ phaseDone, phaseTotal := getPhaseProgress(phase)
228+ done += phaseDone
229+ total += phaseTotal
230+ }
231+ return done, total
232+}
233+
234+func isPhaseDone(phase Phase) bool {
235+ done, total := getPhaseProgress(phase)
236+ return total > 0 && done == total
237+}
238+
239+func hasStoredPlan(state PlanState) bool {
240+ return state.Spec != "" || len(state.Phases) > 0
241+}
242+
243+func isPlanComplete(state PlanState) bool {
244+ if len(state.Phases) == 0 {
245+ return false
246+ }
247+ for _, phase := range state.Phases {
248+ if !isPhaseDone(phase) {
249+ return false
250+ }
251+ }
252+ return true
253+}
254+
255+func isPlanClosed(state PlanState) bool { return state.ClosedAt != nil }
256+
257+func closePlanIfComplete(state *PlanState) {
258+ if !isPlanComplete(*state) {
259+ return
260+ }
261+ done, total := getPlanProgress(*state)
262+ if state.ClosedAt == nil {
263+ closedAt := nowMillis()
264+ state.ClosedAt = &closedAt
265+ }
266+ state.ClosedSummary = fmt.Sprintf("Completed %d/%d tasks across %d phase(s).", done, total, len(state.Phases))
267+ state.CurrentPhaseID = ""
268+}
269+
270+func getCurrentPhase(state PlanState) *Phase {
271+ if state.CurrentPhaseID == "" {
272+ return nil
273+ }
274+ for i := range state.Phases {
275+ if state.Phases[i].ID == state.CurrentPhaseID {
276+ return &state.Phases[i]
277+ }
278+ }
279+ return nil
280+}
281+
282+func getActivePhase(state PlanState) *Phase {
283+ if current := getCurrentPhase(state); current != nil {
284+ return current
285+ }
286+ for i := range state.Phases {
287+ if !isPhaseDone(state.Phases[i]) {
288+ return &state.Phases[i]
289+ }
290+ }
291+ return nil
292+}
293+
294+func getSuggestedCurrentPhaseID(state PlanState) string {
295+ if state.CurrentPhaseID != "" {
296+ for _, phase := range state.Phases {
297+ if phase.ID == state.CurrentPhaseID {
298+ return state.CurrentPhaseID
299+ }
300+ }
301+ }
302+ for _, phase := range state.Phases {
303+ if !isPhaseDone(phase) {
304+ return phase.ID
305+ }
306+ }
307+ if len(state.Phases) > 0 {
308+ return state.Phases[len(state.Phases)-1].ID
309+ }
310+ return ""
311+}
312+
313+func extractHighestIDNumber(prefix string, ids []string) int {
314+ highest := 0
315+ pattern := taskIDPattern
316+ if prefix == "phase" {
317+ pattern = phaseIDPattern
318+ }
319+ for _, id := range ids {
320+ match := pattern.FindStringSubmatch(id)
321+ if len(match) != 2 {
322+ continue
323+ }
324+ number, err := strconv.Atoi(match[1])
325+ if err == nil && number > highest {
326+ highest = number
327+ }
328+ }
329+ return highest
330+}
331+
332+func ensureState(state PlanState) PlanState {
333+ normalized := cloneState(state)
334+ normalized.Version = 1
335+ normalized.Spec = normalizeOptionalText(normalized.Spec)
336+ if normalized.Phases == nil {
337+ normalized.Phases = []Phase{}
338+ }
339+ for i := range normalized.Phases {
340+ normalized.Phases[i].Goal = optionalNormalized(normalized.Phases[i].Goal)
341+ if normalized.Phases[i].Tasks == nil {
342+ normalized.Phases[i].Tasks = []PhaseTask{}
343+ }
344+ }
345+ normalized.ClosedSummary = optionalNormalized(normalized.ClosedSummary)
346+ if isPlanComplete(normalized) {
347+ closePlanIfComplete(&normalized)
348+ } else {
349+ normalized.ClosedAt = nil
350+ normalized.ClosedSummary = ""
351+ }
352+
353+ highestPhase := make([]string, 0, len(normalized.Phases))
354+ highestTask := make([]string, 0)
355+ for _, phase := range normalized.Phases {
356+ highestPhase = append(highestPhase, phase.ID)
357+ for _, task := range phase.Tasks {
358+ highestTask = append(highestTask, task.ID)
359+ }
360+ }
361+ if normalized.NextPhaseNumber < 1 {
362+ normalized.NextPhaseNumber = 1
363+ }
364+ if normalized.NextTaskNumber < 1 {
365+ normalized.NextTaskNumber = 1
366+ }
367+ normalized.NextPhaseNumber = maxInt(normalized.NextPhaseNumber, extractHighestIDNumber("phase", highestPhase)+1)
368+ normalized.NextTaskNumber = maxInt(normalized.NextTaskNumber, extractHighestIDNumber("task", highestTask)+1)
369+ if isPlanClosed(normalized) {
370+ normalized.CurrentPhaseID = ""
371+ } else {
372+ normalized.CurrentPhaseID = getSuggestedCurrentPhaseID(normalized)
373+ }
374+ normalized.UpdatedAt = nowMillis()
375+ return normalized
376+}
377+
378+func optionalNormalized(value string) string {
379+ return normalizeOptionalText(value)
380+}
381+
382+func maxInt(a, b int) int {
383+ if a > b {
384+ return a
385+ }
386+ return b
387+}
388+
389+func nextPhaseID(state *PlanState) string {
390+ id := fmt.Sprintf("phase-%d", state.NextPhaseNumber)
391+ state.NextPhaseNumber++
392+ return id
393+}
394+
395+func nextTaskID(state *PlanState) string {
396+ id := fmt.Sprintf("task-%d", state.NextTaskNumber)
397+ state.NextTaskNumber++
398+ return id
399+}
400+
401+func extractBracketedPhaseID(value string) string {
402+ matches := bracketedIDPattern.FindAllStringSubmatch(value, -1)
403+ candidate := ""
404+ for _, match := range matches {
405+ if len(match) == 2 {
406+ if normalized := normalizeOptionalText(match[1]); normalized != "" {
407+ candidate = normalized
408+ }
409+ }
410+ }
411+ return candidate
412+}
413+
414+func findUniquePhaseByTitle(state PlanState, phaseTitle string) *Phase {
415+ normalizedTitle := singleLine(phaseTitle)
416+ var match *Phase
417+ matches := 0
418+ for i := range state.Phases {
419+ if singleLine(state.Phases[i].Title) == normalizedTitle {
420+ match = &state.Phases[i]
421+ matches++
422+ }
423+ }
424+ if matches == 1 {
425+ return match
426+ }
427+ return nil
428+}
429+
430+// findPhase returns a pointer into state.Phases, aliasing the caller's slice
431+// backing array so mutations are applied to the caller's state copy.
432+func findPhase(state PlanState, phaseID string) *Phase {
433+ candidate := normalizeOptionalText(phaseID)
434+ if candidate == "" {
435+ return nil
436+ }
437+ for i := range state.Phases {
438+ if state.Phases[i].ID == candidate {
439+ return &state.Phases[i]
440+ }
441+ }
442+ if bracketedID := extractBracketedPhaseID(candidate); bracketedID != "" {
443+ for i := range state.Phases {
444+ if state.Phases[i].ID == bracketedID {
445+ return &state.Phases[i]
446+ }
447+ }
448+ }
449+ return findUniquePhaseByTitle(state, candidate)
450+}
451+
452+func formatValidPhaseIDs(state PlanState) string {
453+ if len(state.Phases) == 0 {
454+ return "none"
455+ }
456+ parts := make([]string, 0, len(state.Phases))
457+ for _, phase := range state.Phases {
458+ parts = append(parts, fmt.Sprintf("%s (%s)", phase.ID, singleLine(phase.Title)))
459+ }
460+ return strings.Join(parts, ", ")
461+}
462+
463+func phaseIDError(state PlanState, action, phaseID string) string {
464+ candidate := normalizeOptionalText(phaseID)
465+ validIDs := formatValidPhaseIDs(state)
466+ if candidate == "" {
467+ return fmt.Sprintf("phaseId is required for %s. Use a valid phase id. Valid phase ids: %s.", action, validIDs)
468+ }
469+ return fmt.Sprintf("phaseId %q was not found for %s. Use the raw id from brackets without brackets. Valid phase ids: %s.", singleLine(candidate), action, validIDs)
470+}
471+
472+func formatValidTaskIDs(state PlanState) string {
473+ parts := make([]string, 0)
474+ for _, phase := range state.Phases {
475+ for _, task := range phase.Tasks {
476+ if !task.Checked {
477+ parts = append(parts, fmt.Sprintf("%s (%s)", task.ID, displaySingleLine(phase.Title)))
478+ }
479+ }
480+ }
481+ if len(parts) == 0 {
482+ return "none"
483+ }
484+ return strings.Join(parts, ", ")
485+}
486+
487+func taskIDError(state PlanState, action, taskID string) string {
488+ candidate := normalizeOptionalText(taskID)
489+ validIDs := formatValidTaskIDs(state)
490+ if candidate == "" {
491+ return fmt.Sprintf("taskId is required for %s. Use a valid task id. Valid incomplete task ids: %s.", action, validIDs)
492+ }
493+ return fmt.Sprintf("taskId %q was not found for %s. Use the task id from brackets. Valid incomplete task ids: %s.", displaySingleLine(candidate), action, validIDs)
494+}
495+
496+// findTask returns pointers into state.Phases and their Tasks, aliasing the
497+// caller's slice backing arrays so mutations are applied to the caller's state.
498+func findTask(state PlanState, taskID, phaseID string) (phase *Phase, task *PhaseTask) {
499+ if taskID == "" {
500+ return nil, nil
501+ }
502+ if phaseID != "" {
503+ phase = findPhase(state, phaseID)
504+ if phase == nil {
505+ return nil, nil
506+ }
507+ for i := range phase.Tasks {
508+ if phase.Tasks[i].ID == taskID {
509+ return phase, &phase.Tasks[i]
510+ }
511+ }
512+ return nil, nil
513+ }
514+ for i := range state.Phases {
515+ for j := range state.Phases[i].Tasks {
516+ if state.Phases[i].Tasks[j].ID == taskID {
517+ return &state.Phases[i], &state.Phases[i].Tasks[j]
518+ }
519+ }
520+ }
521+ return nil, nil
522+}
523+
524+func buildPhaseFromInput(state *PlanState, input PhaseInput, phaseIDs, taskIDs map[string]struct{}) (Phase, error) {
525+ id := ""
526+ if input.ID != nil {
527+ id = normalizeOptionalText(*input.ID)
528+ }
529+ if id == "" {
530+ id = nextPhaseID(state)
531+ }
532+ if _, exists := phaseIDs[id]; exists {
533+ return Phase{}, fmt.Errorf("duplicate phase id %q", id)
534+ }
535+ phaseIDs[id] = struct{}{}
536+ phase := Phase{
537+ ID: id,
538+ Title: strings.TrimSpace(input.Title),
539+ Goal: optionalInputText(input.Goal),
540+ Tasks: []PhaseTask{},
541+ }
542+ if input.Tasks != nil {
543+ phase.Tasks = make([]PhaseTask, 0, len(*input.Tasks))
544+ for _, inputTask := range *input.Tasks {
545+ taskID := ""
546+ if inputTask.ID != nil {
547+ taskID = normalizeOptionalText(*inputTask.ID)
548+ }
549+ if taskID == "" {
550+ taskID = nextTaskID(state)
551+ }
552+ if _, exists := taskIDs[taskID]; exists {
553+ return Phase{}, fmt.Errorf("duplicate task id %q", taskID)
554+ }
555+ taskIDs[taskID] = struct{}{}
556+ checked := false
557+ if inputTask.Checked != nil {
558+ checked = *inputTask.Checked
559+ }
560+ phase.Tasks = append(phase.Tasks, PhaseTask{ID: taskID, Text: strings.TrimSpace(inputTask.Text), Checked: checked})
561+ }
562+ }
563+ return phase, nil
564+}
565+
566+func optionalInputText(value *string) string {
567+ if value == nil {
568+ return ""
569+ }
570+ return normalizeOptionalText(*value)
571+}
572+
573+// applyAction is the state transition used by the protocol handler. It does
574+// not persist the result; stateStore performs that step while holding its
575+// mutex, so a failed write never publishes an unpersisted state.
576+func applyAction(state PlanState, params toolArgs) (PlanState, string, error) {
577+ state = cloneState(state)
578+ nextState := cloneState(state)
579+ if isPlanClosed(state) && closedPlanBlockedActions[params.Action] {
580+ reopen := (params.Action == actionSetTaskChecked || params.Action == actionSetPhaseChecked) && params.Checked != nil && !*params.Checked
581+ if !reopen {
582+ return state, "Plan is closed", errors.New("this plan is already complete and closed; call clear before starting new work, or call set_spec immediately followed by replace_plan")
583+ }
584+ }
585+
586+ switch params.Action {
587+ case actionGetStatus:
588+ return state, "Current phased plan status", nil
589+ case actionSetSpec:
590+ spec := ""
591+ if params.Spec != nil {
592+ spec = normalizeOptionalText(*params.Spec)
593+ }
594+ if spec == "" {
595+ return state, "Spec not updated", errors.New("spec is required for set_spec")
596+ }
597+ nextState.Spec = spec
598+ if isPlanClosed(state) {
599+ nextState.Phases = []Phase{}
600+ nextState.CurrentPhaseID = ""
601+ nextState.NextPhaseNumber = 1
602+ nextState.NextTaskNumber = 1
603+ }
604+ nextState.ClosedAt = nil
605+ nextState.ClosedSummary = ""
606+ return nextState, "Saved spec", nil
607+ case actionReplacePlan:
608+ if params.Phases == nil {
609+ return state, "Plan not replaced", errors.New("phases is required for replace_plan")
610+ }
611+ nextState.Phases = []Phase{}
612+ nextState.CurrentPhaseID = ""
613+ nextState.NextPhaseNumber = 1
614+ nextState.NextTaskNumber = 1
615+ phaseIDs := map[string]struct{}{}
616+ taskIDs := map[string]struct{}{}
617+ for _, phaseInput := range *params.Phases {
618+ phase, err := buildPhaseFromInput(&nextState, phaseInput, phaseIDs, taskIDs)
619+ if err != nil {
620+ return state, "Plan not replaced", err
621+ }
622+ nextState.Phases = append(nextState.Phases, phase)
623+ }
624+ nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState)
625+ return nextState, fmt.Sprintf("Replaced plan with %d phase(s)", len(nextState.Phases)), nil
626+ case actionAddPhase:
627+ phaseTitle := ""
628+ if params.PhaseTitle != nil {
629+ phaseTitle = normalizeOptionalText(*params.PhaseTitle)
630+ }
631+ if phaseTitle == "" {
632+ return state, "Phase not added", errors.New("phaseTitle is required for add_phase")
633+ }
634+ nextState.Phases = append(nextState.Phases, Phase{ID: nextPhaseID(&nextState), Title: phaseTitle, Tasks: []PhaseTask{}})
635+ if params.PhaseGoal != nil {
636+ nextState.Phases[len(nextState.Phases)-1].Goal = normalizeOptionalText(*params.PhaseGoal)
637+ }
638+ if nextState.CurrentPhaseID == "" {
639+ nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState)
640+ }
641+ return nextState, "Added phase " + phaseTitle, nil
642+ case actionUpdatePhase:
643+ phase := findPhase(nextState, params.PhaseID)
644+ if phase == nil {
645+ return state, "Phase not updated", errors.New(phaseIDError(nextState, actionUpdatePhase, params.PhaseID))
646+ }
647+ if params.PhaseTitle != nil {
648+ if phaseTitle := normalizeOptionalText(*params.PhaseTitle); phaseTitle != "" {
649+ phase.Title = phaseTitle
650+ }
651+ }
652+ if params.PhaseGoal != nil {
653+ phase.Goal = normalizeOptionalText(*params.PhaseGoal)
654+ }
655+ return nextState, "Updated phase " + phase.Title, nil
656+ case actionRemovePhase:
657+ phase := findPhase(nextState, params.PhaseID)
658+ if phase == nil {
659+ return state, "Phase not removed", errors.New(phaseIDError(nextState, actionRemovePhase, params.PhaseID))
660+ }
661+ removedID := phase.ID
662+ filtered := make([]Phase, 0, len(nextState.Phases)-1)
663+ for _, entry := range nextState.Phases {
664+ if entry.ID != removedID {
665+ filtered = append(filtered, entry)
666+ }
667+ }
668+ nextState.Phases = filtered
669+ nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState)
670+ return nextState, "Removed phase " + removedID, nil
671+ case actionAddTask:
672+ phase := findPhase(nextState, params.PhaseID)
673+ taskText := ""
674+ if params.TaskText != nil {
675+ taskText = normalizeOptionalText(*params.TaskText)
676+ }
677+ if phase == nil {
678+ return state, "Task not added", errors.New(phaseIDError(nextState, actionAddTask, params.PhaseID))
679+ }
680+ if taskText == "" {
681+ return state, "Task not added", errors.New("taskText is required for add_task")
682+ }
683+ phase.Tasks = append(phase.Tasks, PhaseTask{ID: nextTaskID(&nextState), Text: taskText})
684+ return nextState, "Added task to " + phase.Title, nil
685+ case actionUpdateTask:
686+ if params.TaskID == "" {
687+ return state, "Task not updated", errors.New("taskId is required for update_task")
688+ }
689+ _, task := findTask(nextState, params.TaskID, params.PhaseID)
690+ taskText := ""
691+ if params.TaskText != nil {
692+ taskText = normalizeOptionalText(*params.TaskText)
693+ }
694+ if task == nil {
695+ return state, "Task not updated", errors.New(taskIDError(nextState, actionUpdateTask, params.TaskID))
696+ }
697+ if taskText == "" {
698+ return state, "Task not updated", errors.New("taskText is required for update_task")
699+ }
700+ task.Text = taskText
701+ return nextState, "Updated task " + task.ID, nil
702+ case actionRemoveTask:
703+ if params.TaskID == "" {
704+ return state, "Task not removed", errors.New("taskId is required")
705+ }
706+ phase, task := findTask(nextState, params.TaskID, params.PhaseID)
707+ if task == nil {
708+ return state, "Task not removed", errors.New("taskId was not found")
709+ }
710+ filtered := make([]PhaseTask, 0, len(phase.Tasks)-1)
711+ for _, entry := range phase.Tasks {
712+ if entry.ID != params.TaskID {
713+ filtered = append(filtered, entry)
714+ }
715+ }
716+ phase.Tasks = filtered
717+ return nextState, "Removed task " + params.TaskID, nil
718+ case actionSetCurrentPhase:
719+ phase := findPhase(nextState, params.PhaseID)
720+ if phase == nil {
721+ return state, "Current phase not updated", errors.New(phaseIDError(nextState, actionSetCurrentPhase, params.PhaseID))
722+ }
723+ nextState.CurrentPhaseID = phase.ID
724+ return nextState, "Current phase set to " + phase.Title, nil
725+ case actionSetTaskChecked:
726+ if params.TaskID == "" {
727+ return state, "Task not updated", errors.New("taskId is required for set_task_checked")
728+ }
729+ phase, task := findTask(nextState, params.TaskID, params.PhaseID)
730+ if task == nil {
731+ return state, "Task not updated", errors.New(taskIDError(nextState, actionSetTaskChecked, params.TaskID))
732+ }
733+ if params.Checked == nil {
734+ return state, "Task not updated", errors.New("checked must be provided for set_task_checked")
735+ }
736+ task.Checked = *params.Checked
737+ if *params.Checked && nextState.CurrentPhaseID == phase.ID && isPhaseDone(*phase) {
738+ nextState.CurrentPhaseID = ""
739+ }
740+ return nextState, fmt.Sprintf("%s task %s", checkedWord(*params.Checked), task.ID), nil
741+ case actionSetPhaseChecked:
742+ phase := findPhase(nextState, params.PhaseID)
743+ if phase == nil {
744+ return state, "Phase not updated", errors.New(phaseIDError(nextState, actionSetPhaseChecked, params.PhaseID))
745+ }
746+ if params.Checked == nil {
747+ return state, "Phase not updated", errors.New("checked must be provided for set_phase_checked")
748+ }
749+ if len(phase.Tasks) == 0 {
750+ return state, "Phase not updated", errors.New("set_phase_checked requires the phase to have at least one task")
751+ }
752+ for i := range phase.Tasks {
753+ phase.Tasks[i].Checked = *params.Checked
754+ }
755+ if *params.Checked && nextState.CurrentPhaseID == phase.ID {
756+ nextState.CurrentPhaseID = ""
757+ }
758+ if !*params.Checked {
759+ nextState.CurrentPhaseID = phase.ID
760+ }
761+ return nextState, fmt.Sprintf("%s phase %s", checkedWord(*params.Checked), phase.Title), nil
762+ case actionClear:
763+ return createEmptyState(), "Cleared stored spec and phased checklist", nil
764+ default:
765+ return state, "Unsupported action", errors.New("unknown action")
766+ }
767+}
768+
769+func checkedWord(checked bool) string {
770+ if checked {
771+ return "Checked"
772+ }
773+ return "Unchecked"
774+}
775+
776+const (
777+ compactVisibleIncompleteTasks = 4
778+ turnContextVisibleIncompleteTasks = 3
779+ maxTurnContextChars = 2048
780+)
781+
782+func getIncompleteTasks(phase Phase) []PhaseTask {
783+ incomplete := make([]PhaseTask, 0)
784+ for _, task := range phase.Tasks {
785+ if !task.Checked {
786+ incomplete = append(incomplete, task)
787+ }
788+ }
789+ return incomplete
790+}
791+
792+func formatRemainingTaskCount(count int) string {
793+ if count == 1 {
794+ return "1 remaining"
795+ }
796+ return fmt.Sprintf("%d remaining", count)
797+}
798+
799+func formatPhaseTitle(phase Phase) string { return truncatePlain(displaySingleLine(phase.Title), 120) }
800+func formatTaskText(task PhaseTask) string { return truncatePlain(displaySingleLine(task.Text), 180) }
801+
802+func formatTurnContextID(id string) string { return truncatePlain(singleLine(id), 96) }
803+
804+func appendIncompleteTaskLines(lines *[]string, tasks []PhaseTask, visibleCount int, indent string) {
805+ visible := tasks
806+ if len(visible) > visibleCount {
807+ visible = visible[:visibleCount]
808+ }
809+ for _, task := range visible {
810+ *lines = append(*lines, fmt.Sprintf("%s[ ] %s [%s]", indent, formatTaskText(task), formatTurnContextID(task.ID)))
811+ }
812+ if omitted := len(tasks) - len(visible); omitted > 0 {
813+ *lines = append(*lines, fmt.Sprintf("%s... %d more incomplete task(s) omitted; call tasked_phases get_status for the full checklist.", indent, omitted))
814+ }
815+}
816+
817+func buildSummary(state PlanState) string {
818+ if !hasStoredPlan(state) {
819+ return "No spec or phased checklist has been stored yet."
820+ }
821+ lines := make([]string, 0)
822+ if state.Spec != "" {
823+ lines = append(lines, "Spec:", sanitizeDisplayText(state.Spec), "")
824+ }
825+ if len(state.Phases) == 0 {
826+ return strings.Join(append(lines, "Phases: (none)"), "\n")
827+ }
828+ done, total := getPlanProgress(state)
829+ lines = append(lines, fmt.Sprintf("Plan progress: %d/%d tasks checked", done, total))
830+ if isPlanClosed(state) {
831+ closedSummary := state.ClosedSummary
832+ if closedSummary == "" {
833+ closedSummary = "all phases complete"
834+ }
835+ lines = append(lines, "Plan closed: "+displaySingleLine(closedSummary))
836+ }
837+ lines = append(lines, "Phases:")
838+ for _, phase := range state.Phases {
839+ phaseDone, phaseTotal := getPhaseProgress(phase)
840+ currentMarker := " "
841+ if phase.ID == state.CurrentPhaseID {
842+ currentMarker = ">"
843+ }
844+ phaseMarker := "[ ]"
845+ if isPhaseDone(phase) {
846+ phaseMarker = "[x]"
847+ }
848+ goalSuffix := ""
849+ if phase.Goal != "" {
850+ goalSuffix = " - " + displaySingleLine(phase.Goal)
851+ }
852+ lines = append(lines, fmt.Sprintf("%s %s %s [%s] (%d/%d)%s", currentMarker, phaseMarker, formatPhaseTitle(phase), formatTurnContextID(phase.ID), phaseDone, phaseTotal, goalSuffix))
853+ if len(phase.Tasks) == 0 {
854+ lines = append(lines, " - No tasks yet")
855+ continue
856+ }
857+ for _, task := range phase.Tasks {
858+ marker := "[ ]"
859+ if task.Checked {
860+ marker = "[x]"
861+ }
862+ lines = append(lines, fmt.Sprintf(" %s %s [%s]", marker, formatTaskText(task), formatTurnContextID(task.ID)))
863+ }
864+ }
865+ return strings.Join(lines, "\n")
866+}
867+
868+func buildCompactSummary(state PlanState) string {
869+ if !hasStoredPlan(state) {
870+ return "No spec or phased checklist has been stored yet."
871+ }
872+ lines := make([]string, 0)
873+ if state.Spec != "" {
874+ lines = append(lines, "Spec: "+truncatePlain(displaySingleLine(state.Spec), 220))
875+ }
876+ done, total := getPlanProgress(state)
877+ lines = append(lines, fmt.Sprintf("Progress: %d/%d tasks checked", done, total))
878+ if isPlanClosed(state) {
879+ closedSummary := state.ClosedSummary
880+ if closedSummary == "" {
881+ closedSummary = "all phases complete"
882+ }
883+ lines = append(lines, "Plan closed: "+displaySingleLine(closedSummary))
884+ return strings.Join(lines, "\n")
885+ }
886+ if currentPhase := getActivePhase(state); currentPhase != nil {
887+ incompleteTasks := getIncompleteTasks(*currentPhase)
888+ goalSuffix := ""
889+ if currentPhase.Goal != "" {
890+ goalSuffix = " - " + truncatePlain(displaySingleLine(currentPhase.Goal), 120)
891+ }
892+ lines = append(lines, fmt.Sprintf("Current phase: %s [%s] (%s)%s", formatPhaseTitle(*currentPhase), formatTurnContextID(currentPhase.ID), formatRemainingTaskCount(len(incompleteTasks)), goalSuffix))
893+ if len(incompleteTasks) > 0 {
894+ lines = append(lines, "Incomplete tasks:")
895+ appendIncompleteTaskLines(&lines, incompleteTasks, compactVisibleIncompleteTasks, " ")
896+ }
897+ } else if len(state.Phases) > 0 {
898+ lines = append(lines, "Current phase: none")
899+ }
900+ return strings.Join(lines, "\n")
901+}
902+
903+// buildTurnContext returns the small amount of plan state the model needs at
904+// the start of a turn. It deliberately excludes the spec, completed phases,
905+// and future phases; get_status and /phases remain the full-state views.
906+func buildTurnContext(state PlanState) string {
907+ if !hasStoredPlan(state) {
908+ return ""
909+ }
910+ if isPlanClosed(state) {
911+ return truncatePlain("Tasked phases focus: the current plan is complete and closed. Use clear before creating a plan for unrelated work.", maxTurnContextChars)
912+ }
913+ phase := getActivePhase(state)
914+ if phase == nil {
915+ return ""
916+ }
917+ lines := []string{
918+ "Tasked phases focus:",
919+ fmt.Sprintf("Current phase: %s [%s]", formatPhaseTitle(*phase), formatTurnContextID(phase.ID)),
920+ }
921+ if phase.Goal != "" {
922+ lines = append(lines, "Goal: "+truncatePlain(displaySingleLine(phase.Goal), 160))
923+ }
924+ incompleteTasks := getIncompleteTasks(*phase)
925+ if len(incompleteTasks) == 0 {
926+ lines = append(lines, "No incomplete tasks remain in this phase; update the plan or advance the phase as appropriate.")
927+ return truncatePlain(strings.Join(lines, "\n"), maxTurnContextChars)
928+ }
929+ lines = append(lines, "Incomplete tasks:")
930+ appendIncompleteTaskLines(&lines, incompleteTasks, turnContextVisibleIncompleteTasks, " ")
931+ return truncatePlain(strings.Join(lines, "\n"), maxTurnContextChars)
932+}
933+
934+func shouldReturnFullSummary(action string, err error) bool {
935+ return err != nil || action == actionGetStatus || action == actionReplacePlan || action == actionClear
936+}
937+
938+func buildToolResultText(action string, state PlanState, headline string, err error) string {
939+ summary := buildCompactSummary(state)
940+ if shouldReturnFullSummary(action, err) {
941+ summary = buildSummary(state)
942+ }
943+ if err != nil {
944+ return "Error: " + err.Error() + "\n\n" + summary
945+ }
946+ return headline + "\n\n" + summary
947+}
948+
949+func buildViewLines(state PlanState) []string {
950+ lines := []string{"", " Tasked phases ", ""}
951+ if !hasStoredPlan(state) {
952+ return append(lines,
953+ " No spec or phases stored yet.",
954+ "",
955+ " Ask the agent to create a spec and phased checklist.",
956+ "",
957+ " Press Escape to close",
958+ )
959+ }
960+ if state.Spec != "" {
961+ lines = append(lines, " Spec")
962+ for _, specLine := range strings.Split(sanitizeDisplayText(state.Spec), "\n") {
963+ lines = append(lines, " "+specLine)
964+ }
965+ lines = append(lines, "")
966+ }
967+ done, total := getPlanProgress(state)
968+ lines = append(lines, fmt.Sprintf(" Progress: %d/%d tasks checked", done, total))
969+ if isPlanClosed(state) {
970+ closedSummary := state.ClosedSummary
971+ if closedSummary == "" {
972+ closedSummary = "all phases complete"
973+ }
974+ lines = append(lines,
975+ " Closed: "+displaySingleLine(closedSummary),
976+ " New work should create a fresh plan instead of extending this one.",
977+ )
978+ }
979+ lines = append(lines, "")
980+ for _, phase := range state.Phases {
981+ phaseDone, phaseTotal := getPhaseProgress(phase)
982+ marker := "[ ]"
983+ if isPhaseDone(phase) {
984+ marker = "[x]"
985+ }
986+ currentTitle := phase.Title
987+ if phase.ID == state.CurrentPhaseID {
988+ currentTitle = "> " + currentTitle
989+ }
990+ lines = append(lines, fmt.Sprintf(" %s %s [%s] (%d/%d)", marker, displaySingleLine(currentTitle), formatTurnContextID(phase.ID), phaseDone, phaseTotal))
991+ if phase.Goal != "" {
992+ lines = append(lines, " "+displaySingleLine(phase.Goal))
993+ }
994+ if len(phase.Tasks) == 0 {
995+ lines = append(lines, " - No tasks yet")
996+ continue
997+ }
998+ for _, task := range phase.Tasks {
999+ taskMarker := "[ ]"
1000+ if task.Checked {
1001+ taskMarker = "[x]"
1002+ }
1003+ lines = append(lines, fmt.Sprintf(" %s %s [%s]", taskMarker, formatTaskText(task), formatTurnContextID(task.ID)))
1004+ }
1005+ lines = append(lines, "")
1006+ }
1007+ return append(lines, " Press Escape to close")
1008+}
1009+
1010+type stateStore struct {
1011+ mu sync.Mutex
1012+ state PlanState
1013+ path string
1014+ loaded bool
1015+ loadErr error
1016+ // pathErr records a persistence fault independently of the active
1017+ // session state. Activating a session must never clear this fault.
1018+ pathErr error
1019+}
1020+
1021+func newStateStore() *stateStore {
1022+ return &stateStore{state: createEmptyState()}
1023+}
1024+
1025+func projectStatePath(dataDir, cwd string) string {
1026+ key := sha256.Sum256([]byte(cwd))
1027+ return filepath.Join(dataDir, "projects", hex.EncodeToString(key[:])+".json")
1028+}
1029+
1030+func (s *stateStore) load(dataDir, cwd string) error {
1031+ s.mu.Lock()
1032+ defer s.mu.Unlock()
1033+ if s.loaded {
1034+ return s.loadErr
1035+ }
1036+ s.loaded = true
1037+ s.state = createEmptyState()
1038+ if strings.TrimSpace(dataDir) == "" {
1039+ s.loadErr = errors.New("host did not provide extension data_dir")
1040+ s.pathErr = s.loadErr
1041+ return s.loadErr
1042+ }
1043+ s.path = projectStatePath(dataDir, cwd)
1044+ if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
1045+ s.loadErr = fmt.Errorf("create state directory: %w", err)
1046+ s.pathErr = s.loadErr
1047+ return s.loadErr
1048+ }
1049+ // #nosec G302 -- this is a directory; 0700 is the minimum mode that permits traversal.
1050+ if err := os.Chmod(filepath.Dir(s.path), 0o700); err != nil {
1051+ s.loadErr = fmt.Errorf("restrict state directory: %w", err)
1052+ s.pathErr = s.loadErr
1053+ return s.loadErr
1054+ }
1055+ data, err := os.ReadFile(s.path)
1056+ if os.IsNotExist(err) {
1057+ return nil
1058+ }
1059+ if err != nil {
1060+ s.loadErr = fmt.Errorf("read state: %w", err)
1061+ s.pathErr = s.loadErr
1062+ return s.loadErr
1063+ }
1064+ if err := os.Chmod(s.path, 0o600); err != nil {
1065+ s.loadErr = fmt.Errorf("restrict state file: %w", err)
1066+ s.pathErr = s.loadErr
1067+ return s.loadErr
1068+ }
1069+ if len(data) == 0 {
1070+ return nil
1071+ }
1072+ var state PlanState
1073+ if err := json.Unmarshal(data, &state); err != nil {
1074+ s.loadErr = fmt.Errorf("parse state: %w", err)
1075+ s.pathErr = s.loadErr
1076+ return s.loadErr
1077+ }
1078+ s.state = ensureState(state)
1079+ return nil
1080+}
1081+
1082+// activateSession replaces the project fallback with the opaque snapshot
1083+// supplied by zot for the active session branch. An empty snapshot means the
1084+// branch has no extension state yet and therefore starts clean.
1085+func (s *stateStore) activateSession(raw json.RawMessage) error {
1086+ s.mu.Lock()
1087+ defer s.mu.Unlock()
1088+ if !s.loaded {
1089+ return errors.New("state has not been loaded from hello")
1090+ }
1091+ if s.pathErr != nil {
1092+ return s.pathErr
1093+ }
1094+ if len(raw) == 0 || strings.TrimSpace(string(raw)) == "null" {
1095+ s.state = createEmptyState()
1096+ s.loadErr = nil
1097+ return nil
1098+ }
1099+ var state PlanState
1100+ if err := json.Unmarshal(raw, &state); err != nil {
1101+ return fmt.Errorf("parse session state: %w", err)
1102+ }
1103+ s.state = ensureState(state)
1104+ s.loadErr = nil
1105+ return nil
1106+}
1107+
1108+func (s *stateStore) snapshot() (PlanState, error) {
1109+ s.mu.Lock()
1110+ defer s.mu.Unlock()
1111+ if !s.loaded {
1112+ return PlanState{}, errors.New("state has not been loaded from hello")
1113+ }
1114+ if s.loadErr != nil {
1115+ return cloneState(s.state), s.loadErr
1116+ }
1117+ return cloneState(s.state), nil
1118+}
1119+
1120+func (s *stateStore) transact(fn func(PlanState) (PlanState, string, error)) (PlanState, string, error) {
1121+ s.mu.Lock()
1122+ defer s.mu.Unlock()
1123+ if !s.loaded {
1124+ return PlanState{}, "", errors.New("state has not been loaded from hello")
1125+ }
1126+ if s.pathErr != nil {
1127+ return cloneState(s.state), "", s.pathErr
1128+ }
1129+ if s.loadErr != nil {
1130+ return cloneState(s.state), "", s.loadErr
1131+ }
1132+ current := cloneState(s.state)
1133+ next, headline, err := fn(current)
1134+ if err != nil {
1135+ return current, headline, err
1136+ }
1137+ next = ensureState(next)
1138+ if err := writeJSONAtomic(s.path, next); err != nil {
1139+ return current, headline, fmt.Errorf("persist state: %w", err)
1140+ }
1141+ s.state = next
1142+ return cloneState(next), headline, nil
1143+}
1144+
1145+func writeJSONAtomic(path string, value any) error {
1146+ if strings.TrimSpace(path) == "" {
1147+ return errors.New("state path is empty")
1148+ }
1149+ data, err := json.MarshalIndent(value, "", " ")
1150+ if err != nil {
1151+ return err
1152+ }
1153+ data = append(data, '\n')
1154+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
1155+ return err
1156+ }
1157+ tmp, err := os.CreateTemp(filepath.Dir(path), ".tasked-phases-*.tmp")
1158+ if err != nil {
1159+ return err
1160+ }
1161+ tmpName := tmp.Name()
1162+ defer os.Remove(tmpName)
1163+ if err := tmp.Chmod(0o600); err != nil {
1164+ _ = tmp.Close()
1165+ return err
1166+ }
1167+ if _, err := tmp.Write(data); err != nil {
1168+ _ = tmp.Close()
1169+ return err
1170+ }
1171+ if err := tmp.Sync(); err != nil {
1172+ _ = tmp.Close()
1173+ return err
1174+ }
1175+ if err := tmp.Close(); err != nil {
1176+ return err
1177+ }
1178+ if err := os.Rename(tmpName, path); err != nil {
1179+ return err
1180+ }
1181+ return nil
1182+}
A examples/extensions/tasked-phases/state_test.go +463 -0
@@ -0,0 +1,463 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "strings"
9+ "sync"
10+ "testing"
11+)
12+
13+func stringPtr(value string) *string { return &value }
14+func boolPtr(value bool) *bool { return &value }
15+
16+func transition(t *testing.T, state PlanState, params toolArgs) PlanState {
17+ t.Helper()
18+ next, _, err := applyAction(ensureState(state), params)
19+ if err != nil {
20+ t.Fatalf("apply %s: %v", params.Action, err)
21+ }
22+ return ensureState(next)
23+}
24+
25+func sampleState() PlanState {
26+ return ensureState(PlanState{
27+ Version: 1,
28+ Spec: "Ship compact tasked phases output without losing source-of-truth state.",
29+ Phases: []Phase{
30+ {
31+ ID: "phase-1",
32+ Title: "Finished discovery",
33+ Goal: "Understand the old behavior",
34+ Tasks: []PhaseTask{
35+ {ID: "task-1", Text: "Completed discovery task", Checked: true},
36+ {ID: "task-2", Text: "Completed contract task", Checked: true},
37+ },
38+ },
39+ {
40+ ID: "phase-2",
41+ Title: "Implement compact output",
42+ Goal: "Reduce repeated context",
43+ Tasks: []PhaseTask{
44+ {ID: "task-3", Text: "Completed implementation task", Checked: true},
45+ {ID: "task-4", Text: "Remaining implementation task", Checked: false},
46+ },
47+ },
48+ },
49+ CurrentPhaseID: "phase-2",
50+ NextPhaseNumber: 3,
51+ NextTaskNumber: 5,
52+ })
53+}
54+
55+func TestTransitionsGenerateIDsAndSelectCurrentPhase(t *testing.T) {
56+ state := transition(t, createEmptyState(), toolArgs{
57+ Action: actionReplacePlan,
58+ Phases: &[]PhaseInput{
59+ {Title: "First", Tasks: &[]TaskInput{{Text: "one"}}},
60+ {Title: "Second", Tasks: &[]TaskInput{{Text: "two"}}},
61+ },
62+ })
63+ if got := state.Phases[0].ID; got != "phase-1" {
64+ t.Fatalf("first generated phase id = %q", got)
65+ }
66+ if got := state.Phases[1].ID; got != "phase-2" {
67+ t.Fatalf("second generated phase id = %q", got)
68+ }
69+ if got := state.Phases[0].Tasks[0].ID; got != "task-1" {
70+ t.Fatalf("first generated task id = %q", got)
71+ }
72+ if state.CurrentPhaseID != "phase-1" || state.NextPhaseNumber != 3 || state.NextTaskNumber != 3 {
73+ t.Fatalf("generated counters/current phase = %q, %d, %d", state.CurrentPhaseID, state.NextPhaseNumber, state.NextTaskNumber)
74+ }
75+
76+ state = transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(true)})
77+ if state.CurrentPhaseID != "phase-2" {
78+ t.Fatalf("current phase did not advance to the first incomplete phase: %q", state.CurrentPhaseID)
79+ }
80+ state = transition(t, state, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr("Third")})
81+ if got := state.Phases[2].ID; got != "phase-3" {
82+ t.Fatalf("added phase id = %q", got)
83+ }
84+ state = transition(t, state, toolArgs{Action: actionAddTask, PhaseID: "phase-3", TaskText: stringPtr("three")})
85+ if got := state.Phases[2].Tasks[0].ID; got != "task-3" {
86+ t.Fatalf("added task id = %q", got)
87+ }
88+}
89+
90+func TestReplacePlanRejectsDuplicateIDs(t *testing.T) {
91+ state := createEmptyState()
92+ _, _, err := applyAction(state, toolArgs{
93+ Action: actionReplacePlan,
94+ Phases: &[]PhaseInput{
95+ {ID: stringPtr("phase-1"), Title: "First"},
96+ {ID: stringPtr("phase-1"), Title: "Duplicate"},
97+ },
98+ })
99+ if err == nil || !strings.Contains(err.Error(), "duplicate phase id") {
100+ t.Fatalf("duplicate phase id error = %v", err)
101+ }
102+
103+ _, _, err = applyAction(state, toolArgs{
104+ Action: actionReplacePlan,
105+ Phases: &[]PhaseInput{
106+ {Title: "First", Tasks: &[]TaskInput{{ID: stringPtr("task-1"), Text: "one"}}},
107+ {Title: "Second", Tasks: &[]TaskInput{{ID: stringPtr("task-1"), Text: "duplicate"}}},
108+ },
109+ })
110+ if err == nil || !strings.Contains(err.Error(), "duplicate task id") {
111+ t.Fatalf("duplicate task id error = %v", err)
112+ }
113+}
114+
115+func TestClosedPlanRulesAndReopen(t *testing.T) {
116+ state := transition(t, createEmptyState(), toolArgs{
117+ Action: actionReplacePlan,
118+ Phases: &[]PhaseInput{{Title: "Done", Tasks: &[]TaskInput{{Text: "finished", Checked: boolPtr(true)}}}},
119+ })
120+ if !isPlanClosed(state) || state.CurrentPhaseID != "" {
121+ t.Fatalf("complete plan was not closed: %+v", state)
122+ }
123+ if !strings.Contains(buildSummary(state), "Plan closed: Completed 1/1 tasks across 1 phase(s).") {
124+ t.Fatalf("closed summary missing: %s", buildSummary(state))
125+ }
126+
127+ if _, _, err := applyAction(state, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr("new")}); err == nil || !strings.Contains(err.Error(), "already complete and closed") {
128+ t.Fatalf("add to closed plan error = %v", err)
129+ }
130+ if _, _, err := applyAction(state, toolArgs{Action: actionReplacePlan, Phases: &[]PhaseInput{}}); err == nil {
131+ t.Fatal("replace_plan unexpectedly bypassed closed-plan guard")
132+ }
133+
134+ state = transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(false)})
135+ if isPlanClosed(state) || state.CurrentPhaseID != "phase-1" || state.Phases[0].Tasks[0].Checked {
136+ t.Fatalf("unchecked reopen produced %+v", state)
137+ }
138+
139+ state = transition(t, state, toolArgs{Action: actionSetSpec, Spec: stringPtr("new work")})
140+ if len(state.Phases) != 1 {
141+ t.Fatalf("set_spec on reopened plan unexpectedly cleared phases")
142+ }
143+ closed := transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(true)})
144+ if !isPlanClosed(closed) {
145+ t.Fatal("plan did not close again")
146+ }
147+ state = transition(t, closed, toolArgs{Action: actionSetSpec, Spec: stringPtr("fresh work")})
148+ if len(state.Phases) != 0 || state.Spec != "fresh work" || state.NextPhaseNumber != 1 || state.NextTaskNumber != 1 {
149+ t.Fatalf("set_spec did not restart a closed plan: %+v", state)
150+ }
151+ state = transition(t, state, toolArgs{Action: actionReplacePlan, Phases: &[]PhaseInput{{Title: "Fresh"}}})
152+ if len(state.Phases) != 1 || isPlanClosed(state) {
153+ t.Fatalf("replace after set_spec failed: %+v", state)
154+ }
155+}
156+
157+func TestPhaseLookupAcceptsRawBracketedSummaryAndUniqueTitle(t *testing.T) {
158+ state := sampleState()
159+ for _, input := range []string{
160+ "phase-2",
161+ "[phase-2]",
162+ "Implement compact output [phase-2]",
163+ "Implement compact output [phase-2] (1 remaining) - Reduce repeated context",
164+ "Implement compact output",
165+ } {
166+ if phase := findPhase(state, input); phase == nil || phase.ID != "phase-2" {
167+ t.Fatalf("findPhase(%q) = %#v", input, phase)
168+ }
169+ }
170+ if findPhase(state, "missing") != nil || findPhase(state, "") != nil {
171+ t.Fatal("missing phase lookup unexpectedly matched")
172+ }
173+ duplicate := cloneState(state)
174+ duplicate.Phases[0].Title = "Duplicate"
175+ duplicate.Phases[1].Title = "Duplicate"
176+ if findPhase(duplicate, "Duplicate") != nil {
177+ t.Fatal("ambiguous title unexpectedly matched")
178+ }
179+ if phase := findPhase(duplicate, "phase-2"); phase == nil || phase.ID != "phase-2" {
180+ t.Fatal("raw id did not win over duplicate title")
181+ }
182+}
183+
184+func TestSummariesAreFullOrCompactAsAppropriate(t *testing.T) {
185+ state := sampleState()
186+ compact := buildToolResultText(actionSetTaskChecked, state, "Checked task task-3", nil)
187+ if !strings.Contains(compact, "Progress: 3/4 tasks checked") || !strings.Contains(compact, "[ ] Remaining implementation task [task-4]") {
188+ t.Fatalf("compact summary omitted useful active state: %s", compact)
189+ }
190+ if strings.Contains(compact, "Phases:") || strings.Contains(compact, "Completed discovery task") {
191+ t.Fatalf("compact summary included full checklist: %s", compact)
192+ }
193+ full := buildToolResultText(actionGetStatus, state, "Current phased plan status", nil)
194+ if !strings.Contains(full, "Phases:") || !strings.Contains(full, "Completed discovery task [task-1]") {
195+ t.Fatalf("full summary omitted checklist: %s", full)
196+ }
197+ if !strings.Contains(buildSummary(state), "[phase-2] (1/2) - Reduce repeated context") {
198+ t.Fatal("phase goal/progress missing from full summary")
199+ }
200+}
201+
202+func TestTurnContextContainsOnlyCurrentFocus(t *testing.T) {
203+ state := sampleState()
204+ state.Phases[1].Tasks = append(state.Phases[1].Tasks,
205+ PhaseTask{ID: "task-5", Text: "Next focused task"},
206+ PhaseTask{ID: "task-6", Text: "Another focused task"},
207+ PhaseTask{ID: "task-7", Text: "Deferred focused task"},
208+ )
209+ state.Phases = append(state.Phases, Phase{
210+ ID: "phase-3",
211+ Title: "Future phase",
212+ Tasks: []PhaseTask{{ID: "task-8", Text: "Future task"}},
213+ })
214+ context := buildTurnContext(state)
215+ for _, want := range []string{
216+ "Tasked phases focus:",
217+ "Current phase: Implement compact output [phase-2]",
218+ "Goal: Reduce repeated context",
219+ "[ ] Remaining implementation task [task-4]",
220+ "[ ] Next focused task [task-5]",
221+ "[ ] Another focused task [task-6]",
222+ } {
223+ if !strings.Contains(context, want) {
224+ t.Errorf("turn context missing %q: %s", want, context)
225+ }
226+ }
227+ for _, unwanted := range []string{
228+ "Ship compact tasked phases output",
229+ "Finished discovery",
230+ "Completed discovery task",
231+ "Future phase",
232+ "Future task",
233+ "Deferred focused task [task-7]",
234+ } {
235+ if strings.Contains(context, unwanted) {
236+ t.Errorf("turn context included %q: %s", unwanted, context)
237+ }
238+ }
239+}
240+
241+func TestTurnContextIsBoundedForUntrustedIdentifiers(t *testing.T) {
242+ state := sampleState()
243+ state.Phases[1].ID = strings.Repeat("phase-id-", 1000)
244+ state.CurrentPhaseID = state.Phases[1].ID
245+ state.Phases[1].Tasks[0].ID = strings.Repeat("task-id-", 1000)
246+
247+ context := buildTurnContext(state)
248+ if got := len([]rune(context)); got > maxTurnContextChars {
249+ t.Fatalf("turn context length = %d; want at most %d", got, maxTurnContextChars)
250+ }
251+ if !strings.Contains(context, "Tasked phases focus:") || !strings.Contains(context, "Current phase:") || !strings.Contains(context, "Goal: Reduce repeated context") {
252+ t.Fatalf("bounded turn context lost its useful focus: %q", context)
253+ }
254+ if !strings.Contains(context, "Remaining implementation task") {
255+ t.Fatalf("per-field id bounding did not leave room for task lines: %q", context)
256+ }
257+ for _, line := range strings.Split(context, "\n") {
258+ if got := len([]rune(line)); got > 400 {
259+ t.Fatalf("unbounded field produced a %d-rune line: %q", got, line)
260+ }
261+ }
262+}
263+
264+func TestDisplayRenderingSanitizesUntrustedTextWithoutMutatingState(t *testing.T) {
265+ state := sampleState()
266+ state.Spec = "spec \x1b[31mred\x1b[0m\x00"
267+ state.Phases[1].Title = "title \x1b]0;evil\a"
268+ state.Phases[1].Goal = "goal\r\t\x1b[2J"
269+ state.Phases[1].Tasks[0].Text = "task \x1b[?25l\x7f"
270+ original := state
271+
272+ for _, rendered := range []string{
273+ buildSummary(state), buildCompactSummary(state), buildTurnContext(state), strings.Join(buildViewLines(state), "\n"),
274+ } {
275+ if strings.Contains(rendered, "\x1b") || strings.ContainsRune(rendered, '\x00') || strings.ContainsRune(rendered, '\x7f') || strings.ContainsRune(rendered, '\r') || strings.ContainsRune(rendered, '\t') {
276+ t.Fatalf("rendered text contains terminal/control injection: %q", rendered)
277+ }
278+ }
279+ if state.Spec != original.Spec || state.Phases[1].Title != original.Phases[1].Title || state.Phases[1].Tasks[0].Text != original.Phases[1].Tasks[0].Text {
280+ t.Fatal("render sanitization mutated persisted plan state")
281+ }
282+}
283+
284+func TestTaskErrorsDistinguishMissingAndUnknownIDs(t *testing.T) {
285+ state := sampleState()
286+ for _, action := range []string{actionUpdateTask, actionSetTaskChecked} {
287+ if _, _, err := applyAction(state, toolArgs{Action: action}); err == nil || !strings.Contains(err.Error(), "taskId is required") {
288+ t.Fatalf("%s missing task id error = %v", action, err)
289+ }
290+ if _, _, err := applyAction(state, toolArgs{Action: action, TaskID: "missing"}); err == nil || !strings.Contains(err.Error(), "Valid incomplete task ids: task-4") {
291+ t.Fatalf("%s unknown task id error = %v", action, err)
292+ }
293+ }
294+}
295+
296+func TestPanelRendersSpecGoalsTasksAndEmptyState(t *testing.T) {
297+ empty := strings.Join(buildViewLines(createEmptyState()), "\n")
298+ if !strings.Contains(empty, "No spec or phases stored yet.") || !strings.Contains(empty, "Press Escape to close") {
299+ t.Fatalf("empty panel = %s", empty)
300+ }
301+ panel := strings.Join(buildViewLines(sampleState()), "\n")
302+ for _, want := range []string{
303+ "Tasked phases",
304+ "Spec",
305+ "Progress: 3/4 tasks checked",
306+ "[x] Completed discovery task [task-1]",
307+ "[ ] Remaining implementation task [task-4]",
308+ "Understand the old behavior",
309+ "Press Escape to close",
310+ } {
311+ if !strings.Contains(panel, want) {
312+ t.Errorf("panel missing %q: %s", want, panel)
313+ }
314+ }
315+}
316+
317+func TestStatePersistenceIsProjectScopedAtomicAndRestrictive(t *testing.T) {
318+ dataDir := t.TempDir()
319+ first := newStateStore()
320+ if err := first.load(dataDir, "/projects/one"); err != nil {
321+ t.Fatal(err)
322+ }
323+ state, _, err := first.transact(func(current PlanState) (PlanState, string, error) {
324+ return applyAction(current, toolArgs{
325+ Action: actionReplacePlan,
326+ Phases: &[]PhaseInput{{Title: "Persisted", Tasks: &[]TaskInput{{Text: "remember me"}}}},
327+ })
328+ })
329+ if err != nil {
330+ t.Fatal(err)
331+ }
332+ if state.Phases[0].ID != "phase-1" {
333+ t.Fatalf("persisted state = %+v", state)
334+ }
335+ if runtime.GOOS != "windows" {
336+ info, err := os.Stat(first.path)
337+ if err != nil {
338+ t.Fatal(err)
339+ }
340+ if got := info.Mode().Perm(); got != 0o600 {
341+ t.Fatalf("state file mode = %o, want 600", got)
342+ }
343+ if got := (mustStat(t, filepath.Dir(first.path))).Mode().Perm(); got != 0o700 {
344+ t.Fatalf("state directory mode = %o, want 700", got)
345+ }
346+ }
347+
348+ second := newStateStore()
349+ if err := second.load(dataDir, "/projects/one"); err != nil {
350+ t.Fatal(err)
351+ }
352+ loaded, err := second.snapshot()
353+ if err != nil {
354+ t.Fatal(err)
355+ }
356+ if loaded.Phases[0].Tasks[0].Text != "remember me" {
357+ t.Fatalf("loaded state = %+v", loaded)
358+ }
359+ otherProject := newStateStore()
360+ if err := otherProject.load(dataDir, "/projects/two"); err != nil {
361+ t.Fatal(err)
362+ }
363+ other, err := otherProject.snapshot()
364+ if err != nil {
365+ t.Fatal(err)
366+ }
367+ if hasStoredPlan(other) {
368+ t.Fatal("state leaked between project CWDs")
369+ }
370+ files, err := os.ReadDir(filepath.Dir(first.path))
371+ if err != nil {
372+ t.Fatal(err)
373+ }
374+ for _, file := range files {
375+ if strings.HasSuffix(file.Name(), ".tmp") {
376+ t.Fatalf("temporary file left behind: %s", file.Name())
377+ }
378+ }
379+
380+ var decoded PlanState
381+ data, err := os.ReadFile(first.path)
382+ if err != nil {
383+ t.Fatal(err)
384+ }
385+ if err := json.Unmarshal(data, &decoded); err != nil {
386+ t.Fatal(err)
387+ }
388+ if decoded.Phases[0].Tasks[0].ID != "task-1" {
389+ t.Fatalf("on-disk state = %+v", decoded)
390+ }
391+}
392+
393+func TestStateStoreDoesNotOverwriteCorruptStateAfterSessionActivation(t *testing.T) {
394+ dataDir := t.TempDir()
395+ cwd := "/projects/corrupt"
396+ path := projectStatePath(dataDir, cwd)
397+ original := []byte("not valid plan json")
398+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
399+ t.Fatal(err)
400+ }
401+ if err := os.WriteFile(path, original, 0o600); err != nil {
402+ t.Fatal(err)
403+ }
404+
405+ store := newStateStore()
406+ if err := store.load(dataDir, cwd); err == nil {
407+ t.Fatal("corrupt state unexpectedly loaded")
408+ }
409+ if err := store.activateSession(nil); err == nil {
410+ t.Fatal("session activation cleared the corrupt-state fault")
411+ }
412+ if _, _, err := store.transact(func(current PlanState) (PlanState, string, error) {
413+ return applyAction(current, toolArgs{Action: actionClear})
414+ }); err == nil {
415+ t.Fatal("transaction unexpectedly wrote through a corrupt-state fault")
416+ }
417+ got, err := os.ReadFile(path)
418+ if err != nil {
419+ t.Fatal(err)
420+ }
421+ if string(got) != string(original) {
422+ t.Fatalf("corrupt state bytes changed from %q to %q", original, got)
423+ }
424+}
425+
426+func TestStateStoreSerializesConcurrentTransitions(t *testing.T) {
427+ store := newStateStore()
428+ if err := store.load(t.TempDir(), "/projects/concurrent"); err != nil {
429+ t.Fatal(err)
430+ }
431+ const count = 12
432+ var wg sync.WaitGroup
433+ for i := 0; i < count; i++ {
434+ wg.Add(1)
435+ go func(i int) {
436+ defer wg.Done()
437+ title := "phase " + string(rune('a'+i))
438+ _, _, err := store.transact(func(current PlanState) (PlanState, string, error) {
439+ return applyAction(current, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr(title)})
440+ })
441+ if err != nil {
442+ t.Errorf("concurrent transition: %v", err)
443+ }
444+ }(i)
445+ }
446+ wg.Wait()
447+ state, err := store.snapshot()
448+ if err != nil {
449+ t.Fatal(err)
450+ }
451+ if len(state.Phases) != count {
452+ t.Fatalf("serialized phase count = %d, want %d", len(state.Phases), count)
453+ }
454+}
455+
456+func mustStat(t *testing.T, path string) os.FileInfo {
457+ t.Helper()
458+ info, err := os.Stat(path)
459+ if err != nil {
460+ t.Fatal(err)
461+ }
462+ return info
463+}
A examples/skills/tasked-phases/SKILL.md +33 -0
@@ -0,0 +1,33 @@
1+---
2+name: tasked-phases
3+description: Use when the user wants a spec turned into phased work with checklist tasks tracked by the tasked_phases tool, or when ongoing work should be tracked phase by phase.
4+---
5+
6+# Tasked Phases
7+
8+Use the `tasked_phases` tool as the source of truth for the spec, phases, current focus, and checklist progress.
9+
10+## Workflow
11+
12+1. Clarify missing requirements when the spec is ambiguous.
13+2. Save the accepted spec with `tasked_phases` using `set_spec`.
14+3. Create or materially revise the plan with `replace_plan`.
15+4. Keep phases small and tasks concrete enough to check off.
16+5. Set the focus with `set_current_phase` and pass the target `phaseId`.
17+6. Call `set_task_checked` immediately after each checklist task is completed.
18+7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task.
19+8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` to discard the plan, or call `set_spec` immediately followed by `replace_plan` to record a new spec and a new set of phases.
20+
21+## Planning rules
22+
23+- Prefer 3–7 phases unless the task is tiny.
24+- Each task should describe an observable outcome.
25+- Avoid vague tasks such as “work on this” or “finish implementation”.
26+- Keep the stored spec and plan concise and operational.
27+
28+## Tool guidance
29+
30+- Use `get_status` before relying on remembered state or revising a long-running plan.
31+- Use `replace_plan` for a substantial restructuring; use `add_*`, `update_*`, and `remove_*` for small changes.
32+- Pass `phaseId` as the raw ID shown in brackets, without the brackets. The tool also accepts a complete phase label or a unique phase title when recovering from copied output.
33+- Do not rely on prose alone for completion state; update the tool after each completed task.
M packages/agent/build.go +83 -10
@@ -61,11 +61,16 @@ type Resolved struct {
6161 // messages to the provider transcript.
6262 ContextFiles []ContextFile
6363
64+ // skillsEnabled is true when discovery ran, even when it found no
65+ // skills. It is false only for an explicit clean-room disable.
66+ skillsEnabled bool
67+
6468 // Bookkeeping for MergeExtensionTools. Captured at Resolve time
6569 // so the system prompt can be rebuilt later without re-running
6670 // resolve.
6771 systemAppend []string
6872 systemCustom string
73+ skillAddendum string
6974 toolDescriptions map[string]string
7075 }
7176
@@ -83,33 +88,91 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
8388 if mgr == nil {
8489 return
8590 }
86- infos := mgr.Tools()
87- if len(infos) == 0 {
88- return
89- }
9091 changed := false
91- for _, info := range infos {
92+ for _, info := range mgr.Tools() {
9293 if _, exists := r.ToolRegistry[info.Name]; exists {
9394 continue
9495 }
9596 r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
9697 changed = true
9798 }
99+
100+ // A nil SkillTool can mean discovery ran and found nothing, so use the
101+ // explicit disable flag rather than the discovery result as the gate.
102+ if source, ok := mgr.(ExtensionSkillSource); ok && r.skillsEnabled {
103+ if bundled := source.Skills(); len(bundled) > 0 {
104+ merged := mergeExtensionSkills(r.SkillTool, bundled)
105+ if r.SkillTool == nil {
106+ r.SkillTool = skills.NewTool(merged)
107+ r.ToolRegistry[r.SkillTool.Name()] = r.SkillTool
108+ } else {
109+ r.SkillTool.SetSkills(merged)
110+ }
111+ append_ := append([]string(nil), r.systemAppend...)
112+ if r.skillAddendum != "" {
113+ filtered := append_[:0]
114+ for _, item := range append_ {
115+ if item != r.skillAddendum {
116+ filtered = append(filtered, item)
117+ }
118+ }
119+ append_ = filtered
120+ }
121+ r.skillAddendum = skills.SystemPromptAddendum(merged)
122+ if r.skillAddendum != "" {
123+ append_ = append(append_, r.skillAddendum)
124+ }
125+ r.systemAppend = append_
126+ changed = true
127+ }
128+ }
98129 if !changed {
99130 return
100131 }
101- // Re-render the system prompt with the merged tool list. Skill
102- // addendum is preserved by walking the existing append slice.
103- append_ := r.systemAppend
132+ // Re-render the system prompt with the merged tool and skill lists.
133+ r.ToolSummary = toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions)
104134 r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
105135 CWD: r.CWD,
106- Tools: toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
136+ Tools: r.ToolSummary,
107137 Custom: r.systemCustom,
108- Append: append_,
138+ Append: r.systemAppend,
109139 ZotDocsDir: filepath.Join(ZotHome(), "docs"),
110140 })
111141 }
112142
143+func mergeExtensionSkills(tool *skills.Tool, bundled []*skills.Skill) []*skills.Skill {
144+ var existing []*skills.Skill
145+ if tool != nil {
146+ existing = tool.Skills()
147+ }
148+ merged := make([]*skills.Skill, 0, len(existing)+len(bundled))
149+ seen := map[string]bool{}
150+ add := func(list []*skills.Skill) {
151+ for _, skill := range list {
152+ if skill == nil || skill.Name == "" || seen[skill.Name] {
153+ continue
154+ }
155+ seen[skill.Name] = true
156+ merged = append(merged, skill)
157+ }
158+ }
159+ var user, builtins []*skills.Skill
160+ for _, skill := range existing {
161+ if skill == nil || strings.HasPrefix(skill.Source, "extension ") {
162+ continue
163+ }
164+ if skill.Builtin {
165+ builtins = append(builtins, skill)
166+ } else {
167+ user = append(user, skill)
168+ }
169+ }
170+ add(user)
171+ add(bundled)
172+ add(builtins)
173+ return merged
174+}
175+
113176 // ExtensionToolSource is the slice of the extension manager that
114177 // MergeExtensionTools needs. Lives here as an interface so the
115178 // build package doesn't import packages/agent/extensions (which
@@ -119,6 +182,12 @@ type ExtensionToolSource interface {
119182 NewExtensionTool(info ExtensionToolInfo) core.Tool
120183 }
121184
185+// ExtensionSkillSource exposes the optional bundled-skill surface without
186+// forcing older ExtensionToolSource implementations to add it.
187+type ExtensionSkillSource interface {
188+ Skills() []*skills.Skill
189+}
190+
122191 // ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
123192 // ExtensionToolSource here without importing the extensions
124193 // package. The cli wires a tiny adapter to bridge them.
@@ -640,8 +709,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
640709 discovered []*skills.Skill
641710 skillTool *skills.Tool
642711 skillAddendum string
712+ skillsEnabled bool
643713 )
644714 if !args.NoSkill && (selectedProfile == nil || selectedProfile.InheritSkills == nil || *selectedProfile.InheritSkills) {
715+ skillsEnabled = true
645716 homeDir, _ := os.UserHomeDir()
646717 discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
647718 if len(discovered) > 0 {
@@ -728,9 +799,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
728799 MaxOutput: resolvedModel.MaxOutput,
729800 Sandbox: sandbox,
730801 SkillTool: skillTool,
802+ skillsEnabled: skillsEnabled,
731803 ContextFiles: contextFiles,
732804 systemAppend: append_,
733805 systemCustom: custom,
806+ skillAddendum: skillAddendum,
734807 toolDescriptions: descMapFromSummaries(summaries),
735808 }, nil
736809 }
M packages/agent/build_test.go +48 -0
@@ -9,10 +9,58 @@ import (
99 "strings"
1010 "testing"
1111
12+ "github.com/patriceckhart/zot/packages/agent/skills"
1213 "github.com/patriceckhart/zot/packages/agent/tools"
14+ "github.com/patriceckhart/zot/packages/core"
1315 "github.com/patriceckhart/zot/packages/provider"
1416 )
1517
18+type bundledSkillSource struct {
19+ bundled []*skills.Skill
20+}
21+
22+func (s bundledSkillSource) Tools() []ExtensionToolInfo { return nil }
23+func (s bundledSkillSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
24+ return nil
25+}
26+func (s bundledSkillSource) Skills() []*skills.Skill { return s.bundled }
27+
28+func TestMergeExtensionToolsCreatesSkillToolWhenDiscoveryFoundNoSkills(t *testing.T) {
29+ r := Resolved{
30+ CWD: t.TempDir(),
31+ ToolRegistry: make(core.Registry),
32+ skillsEnabled: true,
33+ }
34+ bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
35+
36+ r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
37+
38+ if r.SkillTool == nil {
39+ t.Fatal("MergeExtensionTools did not create a skill tool for bundled skills")
40+ }
41+ if _, ok := r.ToolRegistry[r.SkillTool.Name()]; !ok {
42+ t.Fatalf("skill tool %q was not registered", r.SkillTool.Name())
43+ }
44+ if got := r.SkillTool.Skills(); len(got) != 1 || got[0].Name != bundled.Name {
45+ t.Fatalf("merged bundled skills = %#v", got)
46+ }
47+}
48+
49+func TestMergeExtensionToolsRespectsDisabledSkillDiscovery(t *testing.T) {
50+ r := Resolved{
51+ CWD: t.TempDir(),
52+ ToolRegistry: make(core.Registry),
53+ skillsEnabled: false,
54+ }
55+ bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
56+
57+ r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
58+
59+ if r.SkillTool != nil {
60+ t.Fatal("disabled skill discovery created a skill tool")
61+ }
62+}
63+
1664 func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
1765 root := t.TempDir()
1866 registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false)
M packages/agent/cli.go +203 -21
@@ -37,9 +37,11 @@ import (
3737 const maxBufferedInteractiveAlerts = 64
3838
3939 type interactiveExtHooks struct {
40- mu sync.Mutex
41- interactive *modes.Interactive
42- pendingAlerts []interactiveAlert
40+ mu sync.Mutex
41+ interactive *modes.Interactive
42+ pendingAlerts []interactiveAlert
43+ pendingStatuses map[string]interactiveStatus
44+ pendingWidgets map[string]interactiveWidget
4345 }
4446
4547 type interactiveAlert struct {
@@ -47,6 +49,21 @@ type interactiveAlert struct {
4749 alert extproto.AlertRequest
4850 }
4951
52+type interactiveStatus struct {
53+ extName string
54+ key string
55+ level string
56+ text string
57+}
58+
59+type interactiveWidget struct {
60+ extName string
61+ id string
62+ position string
63+ title string
64+ lines []string
65+}
66+
5067 func (h *interactiveExtHooks) iv() *modes.Interactive {
5168 if h == nil {
5269 return nil
@@ -62,12 +79,22 @@ func (h *interactiveExtHooks) attachInteractive(iv *modes.Interactive) {
6279 }
6380 h.mu.Lock()
6481 h.interactive = iv
65- pending := h.pendingAlerts
82+ pendingAlerts := h.pendingAlerts
83+ pendingStatuses := h.pendingStatuses
84+ pendingWidgets := h.pendingWidgets
6685 h.pendingAlerts = nil
86+ h.pendingStatuses = nil
87+ h.pendingWidgets = nil
6788 h.mu.Unlock()
68- for _, item := range pending {
89+ for _, item := range pendingAlerts {
6990 iv.Alert(item.extName, item.alert)
7091 }
92+ for _, item := range pendingStatuses {
93+ iv.SetStatus(item.extName, item.key, item.level, item.text)
94+ }
95+ for _, item := range pendingWidgets {
96+ iv.SetWidget(item.extName, item.id, item.position, item.title, item.lines)
97+ }
7198 }
7299
73100 func (h *interactiveExtHooks) Notify(extName, level, message string) {
@@ -131,6 +158,65 @@ func (h *interactiveExtHooks) ClosePanel(extName, panelID string) {
131158 iv.ClosePanel(extName, panelID)
132159 }
133160 }
161+func pendingChromeKey(extName, key string) string { return extName + "\x00" + key }
162+
163+func (h *interactiveExtHooks) SetStatus(extName, key, level, text string) {
164+ if h == nil {
165+ return
166+ }
167+ h.mu.Lock()
168+ if h.interactive == nil {
169+ if h.pendingStatuses == nil {
170+ h.pendingStatuses = map[string]interactiveStatus{}
171+ }
172+ pendingKey := pendingChromeKey(extName, key)
173+ if strings.TrimSpace(text) == "" {
174+ delete(h.pendingStatuses, pendingKey)
175+ } else {
176+ h.pendingStatuses[pendingKey] = interactiveStatus{extName: extName, key: key, level: level, text: text}
177+ }
178+ h.mu.Unlock()
179+ return
180+ }
181+ iv := h.interactive
182+ h.mu.Unlock()
183+ iv.SetStatus(extName, key, level, text)
184+}
185+func (h *interactiveExtHooks) SetWidget(extName, id, position, title string, lines []string) {
186+ if h == nil {
187+ return
188+ }
189+ h.mu.Lock()
190+ if h.interactive == nil {
191+ if h.pendingWidgets == nil {
192+ h.pendingWidgets = map[string]interactiveWidget{}
193+ }
194+ h.pendingWidgets[pendingChromeKey(extName, id)] = interactiveWidget{
195+ extName: extName, id: id, position: position, title: title, lines: append([]string(nil), lines...),
196+ }
197+ h.mu.Unlock()
198+ return
199+ }
200+ iv := h.interactive
201+ h.mu.Unlock()
202+ iv.SetWidget(extName, id, position, title, lines)
203+}
204+func (h *interactiveExtHooks) ClearWidget(extName, id string) {
205+ if h == nil {
206+ return
207+ }
208+ h.mu.Lock()
209+ if h.interactive == nil {
210+ if h.pendingWidgets != nil {
211+ delete(h.pendingWidgets, pendingChromeKey(extName, id))
212+ }
213+ h.mu.Unlock()
214+ return
215+ }
216+ iv := h.interactive
217+ h.mu.Unlock()
218+ iv.ClearWidget(extName, id)
219+}
134220
135221 // extToolAdapter bridges *extensions.Manager to the
136222 // ExtensionToolSource interface declared in build.go (kept narrow to
@@ -141,6 +227,16 @@ type extToolAdapter struct {
141227 mgr *extensions.Manager
142228 }
143229
230+var _ ExtensionToolSource = (*extToolAdapter)(nil)
231+var _ ExtensionSkillSource = (*extToolAdapter)(nil)
232+
233+func (a *extToolAdapter) Skills() []*skills.Skill {
234+ if a == nil || a.mgr == nil {
235+ return nil
236+ }
237+ return a.mgr.Skills()
238+}
239+
144240 func (a *extToolAdapter) Tools() []ExtensionToolInfo {
145241 infos := a.mgr.Tools()
146242 out := make([]ExtensionToolInfo, len(infos))
@@ -436,6 +532,61 @@ func applyInitialSessionResume(ctx context.Context, args Args, base Resolved, ex
436532 // the events that have a clear extension-facing meaning are
437533 // forwarded; internal-only ones (text_delta, tool_progress) are
438534 // dropped to keep the per-extension stream sane.
535+func sessionContext(sess *core.Session) *extproto.SessionContext {
536+ if sess == nil {
537+ return nil
538+ }
539+ return &extproto.SessionContext{
540+ ID: sess.Meta.ID,
541+ ParentID: sess.Meta.Parent,
542+ Path: sess.Path,
543+ CWD: sess.Meta.CWD,
544+ ForkPoint: sess.Meta.ForkPoint,
545+ }
546+}
547+
548+func announceSession(mgr *extensions.Manager, sess *core.Session) {
549+ if mgr == nil {
550+ return
551+ }
552+ var states map[string]json.RawMessage
553+ if sess != nil {
554+ states = copyExtensionStates(sess.ExtensionState)
555+ }
556+ mgr.EmitSessionEvent("session_start", sessionContext(sess), states)
557+ if sess != nil {
558+ mgr.EmitSessionEvent("session_opened", sessionContext(sess), states)
559+ }
560+}
561+
562+func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawMessage {
563+ if len(states) == 0 {
564+ return nil
565+ }
566+ out := make(map[string]json.RawMessage, len(states))
567+ for name, state := range states {
568+ out[name] = append(json.RawMessage(nil), state...)
569+ }
570+ return out
571+}
572+
573+func persistExtensionToolResult(mgr *extensions.Manager, sess *core.Session, result core.ToolResult) {
574+ if sess == nil {
575+ return
576+ }
577+ details, ok := result.Details.(extensions.ToolResultDetails)
578+ if !ok || details.Extension == "" || len(details.State) == 0 {
579+ return
580+ }
581+ if err := sess.AppendExtensionState(details.Extension, details.State); err != nil {
582+ fmt.Fprintf(os.Stderr, "extension state: %v\n", err)
583+ return
584+ }
585+ if mgr != nil {
586+ mgr.UpdateSessionState(details.Extension, details.State)
587+ }
588+}
589+
439590 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
440591 if mgr == nil {
441592 return
@@ -557,6 +708,9 @@ func (nonInteractiveExtHooks) ClearNotes(string)
557708 func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
558709 func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
559710 func (nonInteractiveExtHooks) ClosePanel(string, string) {}
711+func (nonInteractiveExtHooks) SetStatus(string, string, string, string) {}
712+func (nonInteractiveExtHooks) SetWidget(string, string, string, string, []string) {}
713+func (nonInteractiveExtHooks) ClearWidget(string, string) {}
560714
561715 // setupNonInteractiveExtensions loads --ext paths and (unless
562716 // --no-ext) runs discovery. Returns the manager so the caller can
@@ -574,7 +728,6 @@ func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved,
574728 }
575729 extMgr.WaitForReady(3 * time.Second)
576730 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
577- extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
578731 return extMgr, func() { extMgr.Stop(2 * time.Second) }
579732 }
580733
@@ -593,9 +746,9 @@ func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr
593746 }
594747 return true, "", res.ModifiedArgs
595748 }
596- ag.BeforeTurn = func(step int) (bool, string) {
597- res := extMgr.InterceptTurnStart(ctx, step)
598- return !res.Block, res.Reason
749+ ag.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
750+ res := extMgr.InterceptTurnStart(turnCtx, step)
751+ return !res.Block, res.Reason, res.Context
599752 }
600753 ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
601754 res := extMgr.InterceptAssistantMessage(ctx, text)
@@ -677,8 +830,10 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
677830 return err
678831 }
679832 r.Provider, r.Model = providerName, model
833+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
680834 defer sess.Close()
681835 }
836+ announceSession(extMgr, sess)
682837
683838 prompt := args.Prompt
684839 if prompt == "" {
@@ -738,8 +893,10 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
738893 return err
739894 }
740895 r.Provider, r.Model = providerName, model
896+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
741897 defer sess.Close()
742898 }
899+ announceSession(extMgr, sess)
743900
744901 prompt := args.Prompt
745902 if prompt == "" {
@@ -838,8 +995,10 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
838995 return err
839996 }
840997 r.Provider, r.Model = providerName, model
998+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
841999 defer sess.Close()
8421000 }
1001+ announceSession(extMgr, sess)
8431002
8441003 prompt := args.Prompt
8451004 if prompt == "" {
@@ -1170,9 +1329,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11701329 }
11711330 return true, "", r.ModifiedArgs
11721331 }
1173- a.BeforeTurn = func(step int) (bool, string) {
1174- r := extMgr.InterceptTurnStart(ctx, step)
1175- return !r.Block, r.Reason
1332+ a.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
1333+ r := extMgr.InterceptTurnStart(turnCtx, step)
1334+ return !r.Block, r.Reason, r.Context
11761335 }
11771336 a.BeforeAssistantMessage = func(text string) (bool, string, string) {
11781337 r := extMgr.InterceptAssistantMessage(ctx, text)
@@ -1261,9 +1420,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12611420 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
12621421 })
12631422
1264- // Fire session_start once we know the manager's running.
1265- extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
1266-
12671423 var sess *core.Session
12681424 var sessBaselineMsgs int // messages already on disk when current session opened
12691425 var sessionTitlePending bool
@@ -1296,6 +1452,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12961452 sessionTitlePending = sess.Meta.Parent != "" && sess.Title == "" && len(ag.Messages()) <= sess.Meta.ForkPoint
12971453 }
12981454 }
1455+ announceSession(extMgr, sess)
12991456 defer func() {
13001457 persistMu.Lock()
13011458 defer persistMu.Unlock()
@@ -1334,16 +1491,27 @@ func runInteractive(ctx context.Context, args Args, version string) error {
13341491 }
13351492 persistCompaction := func(messages []provider.Message) {
13361493 sessionTransitionMu.RLock()
1337- defer sessionTransitionMu.RUnlock()
1494+ var session *extproto.SessionContext
1495+ var states map[string]json.RawMessage
13381496 persistMu.Lock()
1339- defer persistMu.Unlock()
1340- if sess == nil {
1341- return
1497+ if sess != nil {
1498+ if err := sess.AppendCompaction(messages); err == nil {
1499+ sessBaselineMsgs = len(messages)
1500+ session = sessionContext(sess)
1501+ states = copyExtensionStates(sess.ExtensionState)
1502+ }
13421503 }
1343- if err := sess.AppendCompaction(messages); err == nil {
1344- sessBaselineMsgs = len(messages)
1504+ persistMu.Unlock()
1505+ sessionTransitionMu.RUnlock()
1506+ if session != nil {
1507+ extMgr.EmitSessionEvent("session_compacted", session, states)
13451508 }
13461509 }
1510+ persistToolResult := func(_ string, result core.ToolResult) {
1511+ persistMu.Lock()
1512+ defer persistMu.Unlock()
1513+ persistExtensionToolResult(extMgr, sess, result)
1514+ }
13471515 wireAgentPersist := func(a *core.Agent) *core.Agent {
13481516 if a == nil {
13491517 return a
@@ -1351,6 +1519,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
13511519 a.OnMessageAppended = persistMessage
13521520 a.OnUsage = persistUsage
13531521 a.OnTranscriptCompacted = persistCompaction
1522+ a.OnToolResult = persistToolResult
13541523 return a
13551524 }
13561525 wireAgentPersist(ag)
@@ -1443,6 +1612,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14431612 persistMu.Unlock()
14441613 return fmt.Errorf("session changed while loading; please try again")
14451614 }
1615+ newStates := copyExtensionStates(candidate.session.ExtensionState)
14461616 if oldSess != nil {
14471617 _ = oldSess.Close()
14481618 }
@@ -1477,6 +1647,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14771647 }
14781648 committed = true
14791649 persistMu.Unlock()
1650+ if candidate.session.Meta.Parent != "" {
1651+ extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
1652+ }
1653+ extMgr.EmitSessionEvent("session_switched", sessionContext(candidate.session), newStates)
14801654 return nil
14811655 }
14821656
@@ -1626,12 +1800,16 @@ func runInteractive(ctx context.Context, args Args, version string) error {
16261800
16271801 // Commit only after every fallible operation has succeeded. Close
16281802 // the old session last so rollback never has to resurrect it.
1803+ var newStates map[string]json.RawMessage
16291804 persistMu.Lock()
16301805 if sess != nil {
16311806 writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
16321807 _ = sess.Close()
16331808 }
16341809 sess = newSess
1810+ if newSess != nil {
1811+ newStates = copyExtensionStates(newSess.ExtensionState)
1812+ }
16351813 sessBaselineMsgs = 0
16361814 sessionTitlePending = false
16371815 activeProvider = newProvider
@@ -1650,6 +1828,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
16501828 if swarmMgr != nil && newSess != nil {
16511829 swarmMgr.SetActiveSession(newSess.ID)
16521830 }
1831+ if newSess != nil {
1832+ extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
1833+ }
16531834 return nil
16541835 }
16551836
@@ -1906,6 +2087,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
19062087 // manifest and the skill tool.
19072088 userHome, _ := os.UserHomeDir()
19082089 list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)
2090+ list = mergeExtensionSkills(skills.NewTool(list), extMgr.Skills())
19092091 return skills.VisibleSkills(list)
19102092 },
19112093 NoYolo: args.NoYolo,
M packages/agent/cli_alert_test.go +23 -0
@@ -7,6 +7,29 @@ import (
77 "github.com/patriceckhart/zot/packages/agent/modes"
88 )
99
10+func TestInteractiveExtHooksBufferStartupChrome(t *testing.T) {
11+ hooks := &interactiveExtHooks{}
12+ hooks.SetStatus("tasked-phases", "progress", "success", "2/4")
13+ hooks.SetWidget("tasked-phases", "plan", "above_input", "Plan", []string{"one"})
14+ hooks.SetWidget("tasked-phases", "cleared", "above_input", "old", []string{"old"})
15+ hooks.ClearWidget("tasked-phases", "cleared")
16+ hooks.SetStatus("tasked-phases", "removed", "info", "old")
17+ hooks.SetStatus("tasked-phases", "removed", "info", "")
18+
19+ hooks.mu.Lock()
20+ if len(hooks.pendingStatuses) != 1 || len(hooks.pendingWidgets) != 1 {
21+ t.Fatalf("pending chrome = statuses %d widgets %d, want one each", len(hooks.pendingStatuses), len(hooks.pendingWidgets))
22+ }
23+ hooks.mu.Unlock()
24+
25+ hooks.attachInteractive(&modes.Interactive{})
26+ hooks.mu.Lock()
27+ defer hooks.mu.Unlock()
28+ if len(hooks.pendingStatuses) != 0 || len(hooks.pendingWidgets) != 0 {
29+ t.Fatalf("pending chrome was not flushed: statuses=%v widgets=%v", hooks.pendingStatuses, hooks.pendingWidgets)
30+ }
31+}
32+
1033 func TestInteractiveExtHooksBufferStartupAlerts(t *testing.T) {
1134 hooks := &interactiveExtHooks{}
1235 hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
M packages/agent/ext/ext.go +56 -5
@@ -66,7 +66,11 @@ type EventHandler func(ev Event)
6666 // Event is a lifecycle notification from zot. The fields populated
6767 // depend on Name (the host's event_name string):
6868 //
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
7074 // turn_start : Step
7175 // turn_end : Stop, optional Error
7276 // tool_call : ToolID, ToolName, ToolArgs
@@ -75,6 +79,9 @@ type EventHandler func(ev Event)
7579 type Event struct {
7680 Name string
7781
82+ Session *extproto.SessionContext
83+ State json.RawMessage
84+
7885 Step int
7986 Stop string
8087 Error string
@@ -122,6 +129,9 @@ type ToolCallHandler func(toolName string, args json.RawMessage) ToolCallDecisio
122129 type TurnStartDecision struct {
123130 Block bool
124131 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
125135 }
126136
127137 // TurnStartHandler is called before every turn's model call. Return
@@ -156,6 +166,20 @@ type ToolResult struct {
156166 Content []ToolContent
157167 IsError bool
158168 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
159183 }
160184
161185 // AlertRequest asks the host to surface a structured alert. The host owns
@@ -301,6 +325,7 @@ type HostInfo struct {
301325 CWD string
302326 ExtensionDir string
303327 DataDir string
328+ Session *extproto.SessionContext
304329 }
305330
306331 // New constructs an Extension with the given identifier. name should
@@ -317,7 +342,7 @@ func New(name, version string) *Extension {
317342 eventHandlers: map[string]EventHandler{},
318343 panelKeys: map[string]func(key, text string){},
319344 panelCloses: map[string]func(){},
320- caps: []string{"commands", "tools", "events", "alerts", "panels"},
345+ caps: []string{"commands", "tools", "events", "alerts", "panels", "session_state", "status", "widgets"},
321346 }
322347 }
323348
@@ -416,8 +441,9 @@ func (e *Extension) registerTool(name, description string, schema json.RawMessag
416441
417442 // On subscribes to a lifecycle event. fn is called for each
418443 // notification; the same name can only have one handler (later
419-// registrations replace earlier ones). Recognised names:
420-// session_start, turn_start, turn_end, tool_call,
444+// registrations replace earlier ones). Recognised names include:
445+// session_start, session_opened, session_switched, session_forked,
446+// session_compacted, turn_start, turn_end, tool_call,
421447 // tool_confirmation_requested, assistant_message.
422448 func (e *Extension) On(name string, fn EventHandler) {
423449 e.mu.Lock()
@@ -485,6 +511,25 @@ func (e *Extension) Notify(level, message string) {
485511 })
486512 }
487513
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+
488533 // Alert asks the host to surface a structured alert. It is fire-and-forget;
489534 // hosts that do not support the requested kind may ignore it.
490535 func (e *Extension) Alert(alert AlertRequest) {
@@ -529,6 +574,7 @@ func (e *Extension) Run() error {
529574 CWD: ack.CWD,
530575 ExtensionDir: ack.ExtensionDir,
531576 DataDir: ack.DataDir,
577+ Session: ack.Session,
532578 }
533579 e.mu.Lock()
534580 onHello := e.onHello
@@ -657,7 +703,8 @@ func (e *Extension) Run() error {
657703 }
658704 }()
659705 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,
661708 Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName,
662709 ToolArgs: ef.ToolArgs, ToolPreview: ef.ToolPreview, Text: ef.Text,
663710 })
@@ -739,6 +786,7 @@ func (e *Extension) respondTool(id string, r ToolResult) {
739786 Content: blocks,
740787 IsError: r.IsError,
741788 ActivateTools: r.ActivateTools,
789+ Details: append(json.RawMessage(nil), r.Details...),
742790 })
743791 }
744792
@@ -802,6 +850,9 @@ func (e *Extension) dispatchIntercept(ei extproto.EventInterceptFromHost) {
802850 d := fn(ei.Step)
803851 resp.Block = d.Block
804852 resp.Reason = d.Reason
853+ if !d.Block {
854+ resp.Context = d.Context
855+ }
805856 }
806857 case "assistant_message":
807858 e.mu.Lock()
M packages/agent/ext/ext_test.go +113 -0
@@ -3,6 +3,7 @@ package ext
33 import (
44 "bufio"
55 "encoding/json"
6+ "errors"
67 "io"
78 "strings"
89 "testing"
@@ -64,6 +65,25 @@ func newHarness(name string) *extHarness {
6465 return h
6566 }
6667
68+func (h *extHarness) startRun(t *testing.T) {
69+ t.Helper()
70+ runDone := make(chan error, 1)
71+ go func() { runDone <- h.ext.Run() }()
72+ t.Cleanup(func() {
73+ if err := h.hostW.Close(); err != nil && !errors.Is(err, io.ErrClosedPipe) {
74+ t.Errorf("close extension input: %v", err)
75+ }
76+ select {
77+ case err := <-runDone:
78+ if err != nil {
79+ t.Errorf("extension Run: %v", err)
80+ }
81+ case <-time.After(2 * time.Second):
82+ t.Error("extension Run did not exit after closing host input")
83+ }
84+ })
85+}
86+
6787 // next returns the next frame, timing out after 2 s.
6888 func (h *extHarness) next(t *testing.T) rawFrame {
6989 t.Helper()
@@ -220,6 +240,99 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
220240 h.hostW.Close()
221241 }
222242
243+func TestPersistentChromeFrames(t *testing.T) {
244+ h := newHarness("chrome-ext")
245+ h.startRun(t)
246+ h.handshake(t)
247+
248+ h.ext.SetStatus("progress", "2/4 tasks")
249+ status := h.drainUntil(t, "status")
250+ var sf extproto.StatusFromExt
251+ if err := json.Unmarshal(status.raw, &sf); err != nil {
252+ t.Fatal(err)
253+ }
254+ if sf.Key != "progress" || sf.Text != "2/4 tasks" {
255+ t.Fatalf("status = %+v", sf)
256+ }
257+
258+ h.ext.SetWidget("plan", "above_input", "Plan", []string{"one"})
259+ widget := h.drainUntil(t, "widget")
260+ var wf extproto.WidgetFromExt
261+ if err := json.Unmarshal(widget.raw, &wf); err != nil {
262+ t.Fatal(err)
263+ }
264+ if wf.ID != "plan" || wf.Position != "above_input" || wf.Title != "Plan" || len(wf.Lines) != 1 || wf.Lines[0] != "one" {
265+ t.Fatalf("widget = %+v", wf)
266+ }
267+
268+ h.ext.ClearWidget("plan")
269+ clear := h.drainUntil(t, "widget_clear")
270+ var cf extproto.ClearWidgetFromExt
271+ if err := json.Unmarshal(clear.raw, &cf); err != nil {
272+ t.Fatal(err)
273+ }
274+ if cf.ID != "plan" {
275+ t.Fatalf("widget clear = %+v", cf)
276+ }
277+}
278+
279+func TestToolResultDetailsEmitsOpaqueMetadata(t *testing.T) {
280+ h := newHarness("details-ext")
281+ h.ext.Tool("details", "returns metadata", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult {
282+ return ToolResult{Content: []ToolContent{Text("ok")}, Details: JSONDetails(map[string]any{"state": 1})}
283+ })
284+ h.startRun(t)
285+ h.handshake(t)
286+ h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "call-1", Name: "details", Args: json.RawMessage(`{}`)})
287+ frame := h.drainUntil(t, "tool_result")
288+ var result extproto.ToolResultFromExt
289+ if err := json.Unmarshal(frame.raw, &result); err != nil {
290+ t.Fatal(err)
291+ }
292+ if string(result.Details) != `{"state":1}` {
293+ t.Fatalf("details = %s", result.Details)
294+ }
295+}
296+
297+func TestSessionEventCarriesIdentityAndState(t *testing.T) {
298+ h := newHarness("session-ext")
299+ received := make(chan Event, 1)
300+ h.ext.On("session_opened", func(event Event) { received <- event })
301+ h.startRun(t)
302+ h.handshake(t)
303+ h.sendToExt(t, extproto.EventFromHost{
304+ Type: "event", Event: "session_opened",
305+ Session: &extproto.SessionContext{ID: "branch-1", ParentID: "root-1", Path: "/tmp/session.jsonl", ForkPoint: 4},
306+ State: json.RawMessage(`{"version":1}`),
307+ })
308+ select {
309+ case event := <-received:
310+ if event.Session == nil || event.Session.ID != "branch-1" || event.Session.ParentID != "root-1" || event.Session.Path != "/tmp/session.jsonl" || event.Session.ForkPoint != 4 || string(event.State) != `{"version":1}` {
311+ t.Fatalf("session event = %+v state=%s", event.Session, event.State)
312+ }
313+ case <-time.After(2 * time.Second):
314+ t.Fatal("timeout waiting for session event")
315+ }
316+}
317+
318+func TestTurnStartContextIsReturned(t *testing.T) {
319+ h := newHarness("context-ext")
320+ h.ext.InterceptTurnStart(func(int) TurnStartDecision {
321+ return TurnStartDecision{Context: "current phase: parse"}
322+ })
323+ h.startRun(t)
324+ h.handshake(t)
325+ h.sendToExt(t, extproto.EventInterceptFromHost{Type: "event_intercept", ID: "intercept-1", Event: "turn_start", Step: 2})
326+ frame := h.drainUntil(t, "event_intercept_response")
327+ var response extproto.EventInterceptResponseFromExt
328+ if err := json.Unmarshal(frame.raw, &response); err != nil {
329+ t.Fatal(err)
330+ }
331+ if response.ID != "intercept-1" || response.Context != "current phase: parse" || response.Block {
332+ t.Fatalf("intercept response = %+v", response)
333+ }
334+}
335+
223336 func TestAlertEmitsStructuredFrame(t *testing.T) {
224337 h := newHarness("alert-ext")
225338 runDone := make(chan error, 1)
M packages/agent/extcmd.go +3 -0
@@ -142,6 +142,9 @@ func (extDoctorHooks) ClearNotes(string) {}
142142 func (extDoctorHooks) OpenPanel(string, extproto.PanelSpec) {}
143143 func (extDoctorHooks) UpdatePanel(string, string, string, []string, string) {}
144144 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) {}
145148
146149 type extDoctorStaticRow struct {
147150 Scope string
M packages/agent/extensions/events.go +131 -9
@@ -4,7 +4,9 @@ import (
44 "context"
55 "encoding/json"
66 "fmt"
7+ "strings"
78 "time"
9+ "unicode/utf8"
810
911 "github.com/patriceckhart/zot/packages/agent/extproto"
1012 )
@@ -36,16 +38,111 @@ func (m *Manager) EmitEvent(ev extproto.EventFromHost) {
3638
3739 for _, ext := range subs {
3840 go func(ext *Extension) {
39- defer func() {
40- // A panicking write to a closed pipe shouldn't kill
41- // the calling goroutine.
42- _ = recover()
43- }()
44- _, _ = ext.stdin.Write(frame)
41+ defer func() { _ = recover() }()
42+ _, _ = ext.writeFrame(frame)
4543 }(ext)
4644 }
4745 }
4846
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+
49146 // InterceptResult aggregates the outcome of walking every subscribed
50147 // interceptor for one event. The zero value means "allow, no
51148 // modification". Callers check Block first; if allowed, use the
@@ -56,9 +153,29 @@ type InterceptResult struct {
56153 Reason string
57154 ModifiedArgs json.RawMessage
58155 ReplaceText string
156+ Context string
59157 }
60158
61-const interceptTimeout = 5 * time.Second
159+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+}
62179
63180 // InterceptToolCall is the typed entry point for tool_call
64181 // interception. Subscribers are invoked serially; the first to return
@@ -100,6 +217,7 @@ func (m *Manager) InterceptTurnStart(ctx context.Context, step int) InterceptRes
100217 if len(subs) == 0 {
101218 return InterceptResult{}
102219 }
220+ var contexts []string
103221 for _, ext := range subs {
104222 r := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{
105223 Event: "turn_start",
@@ -108,8 +226,11 @@ func (m *Manager) InterceptTurnStart(ctx context.Context, step int) InterceptRes
108226 if r.Block {
109227 return r
110228 }
229+ if text := strings.TrimSpace(r.Context); text != "" {
230+ contexts = append(contexts, "["+ext.Manifest.Name+"]\n"+text)
231+ }
111232 }
112- return InterceptResult{}
233+ return InterceptResult{Context: boundInterceptContext(strings.Join(contexts, "\n\n"))}
113234 }
114235
115236 // InterceptAssistantMessage asks every subscriber to approve, block,
@@ -179,7 +300,7 @@ func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extp
179300 ext.mu.Unlock()
180301 return InterceptResult{}
181302 }
182- if _, err := ext.stdin.Write(frame); err != nil {
303+ if _, err := ext.writeFrame(frame); err != nil {
183304 ext.mu.Lock()
184305 delete(ext.pendingIntercept, id)
185306 ext.mu.Unlock()
@@ -194,6 +315,7 @@ func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extp
194315 Reason: resp.Reason,
195316 ModifiedArgs: resp.ModifiedArgs,
196317 ReplaceText: resp.ReplaceText,
318+ Context: resp.Context,
197319 }
198320 case <-time.After(interceptTimeout):
199321 ext.mu.Lock()
M packages/agent/extensions/intercept_test.go +14 -0
@@ -8,8 +8,22 @@ import (
88 "strings"
99 "testing"
1010 "time"
11+ "unicode/utf8"
1112 )
1213
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+
1327 // TestInterceptAllThreeEvents exercises tool_call / turn_start /
1428 // assistant_message interception end-to-end via a bash extension that
1529 // blocks `rm -rf`, rewrites any other bash command to `echo GUARDED`,
M packages/agent/extensions/manager.go +239 -14
@@ -29,6 +29,7 @@ import (
2929 "time"
3030
3131 "github.com/patriceckhart/zot/packages/agent/extproto"
32+ "github.com/patriceckhart/zot/packages/agent/skills"
3233 "github.com/patriceckhart/zot/packages/tui"
3334 )
3435
@@ -43,6 +44,7 @@ type Manifest struct {
4344 Language string `json:"language,omitempty"` // informational ("go", "python", "typescript", ...)
4445 Enabled *bool `json:"enabled,omitempty"` // nil = enabled
4546 Description string `json:"description,omitempty"`
47+ Skills []string `json:"skills,omitempty"` // bundled skill directories
4648 }
4749
4850 // IsEnabled returns the manifest's effective enabled state. Default
@@ -52,6 +54,48 @@ func (m Manifest) IsEnabled() bool {
5254 return m.Enabled == nil || *m.Enabled
5355 }
5456
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+
5599 // Extension is a running extension subprocess and the metadata zot
56100 // tracks about it.
57101 type Extension struct {
@@ -66,6 +110,16 @@ type Extension struct {
66110 helloAck bool
67111 commands []extproto.RegisterCommandFromExt
68112 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
69123
70124 // readyCh is closed when the extension sends a ReadyFromExt
71125 // frame, or when the host gives up waiting (registrationGrace).
@@ -130,6 +184,12 @@ type HostHooks interface {
130184 OpenPanel(extName string, spec extproto.PanelSpec)
131185 UpdatePanel(extName, panelID, title string, lines []string, footer string)
132186 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)
133193 }
134194
135195 // AlertHostHooks is an optional extension to HostHooks. Hosts that support
@@ -173,6 +233,16 @@ type Manager struct {
173233 // by the host so it can rebuild the agent's tool registry with
174234 // the freshly-registered extension tools.
175235 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
176246 }
177247
178248 // 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 {
287357 return nil
288358 }
289359
360+ bundledSkills, skillErrs := loadManifestSkills(dir, mf)
290361 ext := &Extension{
291362 Manifest: mf,
292363 Dir: dir,
@@ -296,6 +367,12 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
296367 pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{},
297368 eventSubs: map[string]struct{}{},
298369 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())
299376 }
300377 if mf.Exec != "" {
301378 if err := m.spawn(ctx, ext); err != nil {
@@ -627,6 +704,12 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
627704 // Trust the manifest's name; ignore mismatch from the hello.
628705 ext.helloAck = true
629706
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()
630713 ack, _ := extproto.Encode(extproto.HelloAckFromHost{
631714 Type: "hello_ack",
632715 ProtocolVersion: extproto.ProtocolVersion,
@@ -636,6 +719,7 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
636719 CWD: m.cwd,
637720 ExtensionDir: ext.Dir,
638721 DataDir: ext.Dir,
722+ Session: activeSession,
639723 })
640724 if _, err := stdin.Write(ack); err != nil {
641725 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 {
643727
644728 cleanup = false
645729
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()
647732 go m.readLoop(ext, scanner)
648733
649734 // Compatibility shim: extensions built against the phase-1 SDK
@@ -737,6 +822,7 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
737822 }
738823 }
739824 m.mu.Unlock()
825+ ext.stopLifecycleWriter()
740826 ext.readyOnce.Do(func() { close(ext.readyCh) })
741827 fmt.Fprintf(ext.logFile, "[zot] extension %s read loop exited at %s\n", ext.Manifest.Name, time.Now().Format(time.RFC3339))
742828 }()
@@ -800,6 +886,7 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
800886 }
801887 case "ready":
802888 ext.readyOnce.Do(func() { close(ext.readyCh) })
889+ m.replaySessionEvent(ext)
803890 case "subscribe":
804891 var sub extproto.SubscribeFromExt
805892 if err := json.Unmarshal(line, &sub); err == nil {
@@ -849,21 +936,38 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
849936 }
850937 case "notify":
851938 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 {
853940 m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)
854941 }
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+ }
855957 case "alert":
856958 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 {
858960 if hooks, ok := m.hooks.(AlertHostHooks); ok {
859961 hooks.Alert(ext.Manifest.Name, a.AlertRequest)
860962 }
861963 }
862964 case "clear_notes":
863- m.hooks.ClearNotes(ext.Manifest.Name)
965+ if m.hooks != nil {
966+ m.hooks.ClearNotes(ext.Manifest.Name)
967+ }
864968 case "submit":
865969 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 {
867971 text := strings.TrimSpace(s.Text)
868972 if text != "" {
869973 m.hooks.Submit(text)
@@ -877,7 +981,7 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
877981 var s extproto.SubmitSlashFromExt
878982 if err := json.Unmarshal(line, &s); err == nil {
879983 text := strings.TrimSpace(s.Text)
880- if strings.HasPrefix(text, "/") {
984+ if strings.HasPrefix(text, "/") && m.hooks != nil {
881985 m.hooks.SubmitSlash(text)
882986 } else {
883987 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) {
9011005 }
9021006 case "open_panel":
9031007 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 {
9051009 m.hooks.OpenPanel(ext.Manifest.Name, op.Panel)
9061010 }
9071011 case "panel_render":
9081012 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 {
9101014 m.hooks.UpdatePanel(ext.Manifest.Name, pr.PanelID, pr.Title, pr.Lines, pr.Footer)
9111015 }
9121016 case "panel_close":
9131017 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 {
9151019 m.hooks.ClosePanel(ext.Manifest.Name, pc.PanelID)
9161020 }
9171021 case "shutdown_ack":
@@ -923,6 +1027,109 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
9231027 }
9241028 }
9251029
1030+const lifecycleWriteGrace = 250 * time.Millisecond
1031+
1032+func (ext *Extension) writeFrame(frame []byte) (int, error) {
1033+ if ext == nil || ext.stdin == nil {
1034+ return 0, fmt.Errorf("extension stdin is closed")
1035+ }
1036+ ext.writeMu.Lock()
1037+ defer ext.writeMu.Unlock()
1038+ return ext.stdin.Write(frame)
1039+}
1040+
1041+// tryWriteLifecycleFrame bounds only a lifecycle writer's wait for the shared
1042+// writer lock. Synchronous host frames retain their existing write behavior;
1043+// a stalled lifecycle snapshot is dropped rather than delaying them.
1044+func (ext *Extension) tryWriteLifecycleFrame(frame []byte) error {
1045+ if ext == nil || ext.stdin == nil {
1046+ return fmt.Errorf("extension stdin is closed")
1047+ }
1048+ if !ext.writeMu.TryLock() {
1049+ timer := time.NewTimer(lifecycleWriteGrace)
1050+ defer timer.Stop()
1051+ ticker := time.NewTicker(5 * time.Millisecond)
1052+ defer ticker.Stop()
1053+ for {
1054+ select {
1055+ case <-timer.C:
1056+ return fmt.Errorf("extension lifecycle write is blocked")
1057+ case <-ticker.C:
1058+ if ext.writeMu.TryLock() {
1059+ goto locked
1060+ }
1061+ }
1062+ }
1063+ }
1064+locked:
1065+ defer ext.writeMu.Unlock()
1066+ _, err := ext.stdin.Write(frame)
1067+ return err
1068+}
1069+
1070+func (ext *Extension) startLifecycleWriter() {
1071+ if ext.lifecycleFrames == nil {
1072+ ext.lifecycleFrames = make(chan []byte, 64)
1073+ }
1074+ if ext.lifecycleStop == nil {
1075+ ext.lifecycleStop = make(chan struct{})
1076+ }
1077+ go ext.lifecycleWriter()
1078+}
1079+
1080+func (ext *Extension) stopLifecycleWriter() {
1081+ if ext == nil || ext.lifecycleStop == nil {
1082+ return
1083+ }
1084+ ext.lifecycleStopOnce.Do(func() { close(ext.lifecycleStop) })
1085+}
1086+
1087+func (ext *Extension) enqueueLifecycleFrame(frame []byte) bool {
1088+ if ext == nil {
1089+ return false
1090+ }
1091+ if ext.lifecycleFrames == nil || ext.lifecycleStop == nil {
1092+ _, err := ext.writeFrame(frame)
1093+ return err == nil
1094+ }
1095+ copyFrame := append([]byte(nil), frame...)
1096+ select {
1097+ case <-ext.lifecycleStop:
1098+ return false
1099+ default:
1100+ }
1101+ select {
1102+ case ext.lifecycleFrames <- copyFrame:
1103+ return true
1104+ default:
1105+ // Prefer the newest lifecycle snapshot when a broken extension stops
1106+ // draining its pipe. The latest event still carries the current state.
1107+ select {
1108+ case <-ext.lifecycleFrames:
1109+ default:
1110+ }
1111+ select {
1112+ case ext.lifecycleFrames <- copyFrame:
1113+ return true
1114+ default:
1115+ return false
1116+ }
1117+ }
1118+}
1119+
1120+func (ext *Extension) lifecycleWriter() {
1121+ for {
1122+ select {
1123+ case <-ext.lifecycleStop:
1124+ return
1125+ case frame := <-ext.lifecycleFrames:
1126+ if err := ext.tryWriteLifecycleFrame(frame); err != nil {
1127+ return
1128+ }
1129+ }
1130+ }
1131+}
1132+
9261133 func (ext *Extension) recordDiagnostic(msg string) {
9271134 ext.mu.Lock()
9281135 defer ext.mu.Unlock()
@@ -1075,6 +1282,24 @@ func (m *Manager) Tools() []ToolInfo {
10751282 return out
10761283 }
10771284
1285+// Skills returns extension-bundled skills in deterministic extension-name
1286+// order. The build layer merges them after user skills and before built-ins.
1287+func (m *Manager) Skills() []*skills.Skill {
1288+ m.mu.RLock()
1289+ names := make([]string, 0, len(m.ext))
1290+ for name := range m.ext {
1291+ names = append(names, name)
1292+ }
1293+ sort.Strings(names)
1294+ var out []*skills.Skill
1295+ for _, name := range names {
1296+ ext := m.ext[name]
1297+ out = append(out, ext.skills...)
1298+ }
1299+ m.mu.RUnlock()
1300+ return out
1301+}
1302+
10781303 // HasTool reports whether name is registered by any extension.
10791304 func (m *Manager) HasTool(name string) bool {
10801305 m.mu.RLock()
@@ -1106,7 +1331,7 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess
11061331 Name: name,
11071332 Args: args,
11081333 })
1109- if _, err := ext.stdin.Write(frame); err != nil {
1334+ if _, err := ext.writeFrame(frame); err != nil {
11101335 ext.mu.Lock()
11111336 delete(ext.pendingTool, id)
11121337 ext.mu.Unlock()
@@ -1172,7 +1397,7 @@ func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Du
11721397 Name: name,
11731398 Args: args,
11741399 })
1175- if _, err := ext.stdin.Write(frame); err != nil {
1400+ if _, err := ext.writeFrame(frame); err != nil {
11761401 ext.mu.Lock()
11771402 delete(ext.pending, id)
11781403 ext.mu.Unlock()
@@ -1206,7 +1431,7 @@ func (m *Manager) SendPanelKey(extName, panelID, key, text string) error {
12061431 return fmt.Errorf("no extension %q", extName)
12071432 }
12081433 frame, _ := extproto.Encode(extproto.PanelKeyFromHost{Type: "panel_key", PanelID: panelID, Key: key, Text: text})
1209- _, err := ext.stdin.Write(frame)
1434+ _, err := ext.writeFrame(frame)
12101435 return err
12111436 }
12121437
@@ -1218,7 +1443,7 @@ func (m *Manager) SendPanelClose(extName, panelID string) error {
12181443 return fmt.Errorf("no extension %q", extName)
12191444 }
12201445 frame, _ := extproto.Encode(extproto.PanelCloseFromHost{Type: "panel_close", PanelID: panelID})
1221- _, err := ext.stdin.Write(frame)
1446+ _, err := ext.writeFrame(frame)
12221447 return err
12231448 }
12241449
@@ -1238,7 +1463,7 @@ func stopExtensions(exts []*Extension, gracePeriod time.Duration) {
12381463 continue
12391464 }
12401465 if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {
1241- _, _ = ext.stdin.Write(frame)
1466+ _, _ = ext.writeFrame(frame)
12421467 }
12431468 _ = ext.stdin.Close()
12441469 }
M packages/agent/extensions/manager_test.go +85 -0
@@ -3,6 +3,7 @@ package extensions
33 import (
44 "context"
55 "encoding/json"
6+ "io"
67 "os"
78
89 "github.com/patriceckhart/zot/packages/agent/extproto"
@@ -14,6 +15,30 @@ import (
1415 "time"
1516 )
1617
18+func TestLifecycleWriteDropsWhenSynchronousWriterOwnsLock(t *testing.T) {
19+ ext := &Extension{stdin: &nopWriteCloser{}}
20+ ext.writeMu.Lock()
21+ defer ext.writeMu.Unlock()
22+
23+ done := make(chan error, 1)
24+ go func() { done <- ext.tryWriteLifecycleFrame([]byte("lifecycle")) }()
25+ select {
26+ case err := <-done:
27+ if err == nil {
28+ t.Fatal("lifecycle write unexpectedly acquired the synchronous writer lock")
29+ }
30+ case <-time.After(lifecycleWriteGrace + time.Second):
31+ t.Fatal("lifecycle write remained blocked on the synchronous writer lock")
32+ }
33+}
34+
35+type nopWriteCloser struct{}
36+
37+func (*nopWriteCloser) Write(data []byte) (int, error) { return len(data), nil }
38+func (*nopWriteCloser) Close() error { return nil }
39+
40+var _ io.WriteCloser = (*nopWriteCloser)(nil)
41+
1742 // stubHooks records every callback so the test can assert on them.
1843 type stubHooks struct {
1944 mu sync.Mutex
@@ -68,6 +93,9 @@ func (s *stubHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
6893 }
6994 func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
7095 func (s *stubHooks) ClosePanel(string, string) {}
96+func (s *stubHooks) SetStatus(string, string, string, string) {}
97+func (s *stubHooks) SetWidget(string, string, string, string, []string) {}
98+func (s *stubHooks) ClearWidget(string, string) {}
7199
72100 // writeMockExtension creates a minimal extension on disk that uses a
73101 // shell script (or batch file on windows) to drive the protocol. The
@@ -259,6 +287,63 @@ func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
259287 }
260288 }
261289
290+func TestManifestBundlesSkillsWithSafePrecedence(t *testing.T) {
291+ tmp := t.TempDir()
292+ extRoot := filepath.Join(tmp, "extensions")
293+ extDir := filepath.Join(extRoot, "bundle")
294+ if err := os.MkdirAll(filepath.Join(extDir, "skills", "phases"), 0o755); err != nil {
295+ t.Fatal(err)
296+ }
297+ if err := os.WriteFile(filepath.Join(extDir, "skills", "phases", "SKILL.md"), []byte("---\nname: phases\ndescription: bundled phases\n---\nbody\n"), 0o644); err != nil {
298+ t.Fatal(err)
299+ }
300+ manifest := `{"name":"bundle","skills":["skills"],"description":"bundle"}`
301+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
302+ t.Fatal(err)
303+ }
304+ if err := os.WriteFile(filepath.Join(extDir, "theme.json"), []byte(`{"name":"bundle","colors":{"dark":{"accent":1}}}`), 0o644); err != nil {
305+ t.Fatal(err)
306+ }
307+
308+ mgr := New(tmp, "", "0.0.0-test", "", "", &stubHooks{})
309+ if errs := mgr.Discover(context.Background()); len(errs) > 0 {
310+ t.Fatalf("discover errors: %v", errs)
311+ }
312+ defer mgr.Stop(10 * time.Millisecond)
313+ bundled := mgr.Skills()
314+ if len(bundled) != 1 || bundled[0].Name != "phases" || bundled[0].Source != "extension bundle" {
315+ t.Fatalf("bundled skills = %#v", bundled)
316+ }
317+ if _, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{"../outside"}}); len(errs) == 0 {
318+ t.Fatal("path traversal manifest was accepted")
319+ }
320+ for _, path := range []string{"", filepath.Join(string(filepath.Separator), "outside")} {
321+ if _, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{path}}); len(errs) == 0 {
322+ t.Fatalf("unsafe skill path %q was accepted", path)
323+ }
324+ }
325+ if runtime.GOOS != "windows" {
326+ outside := filepath.Join(tmp, "outside", "SKILL.md")
327+ if err := os.MkdirAll(filepath.Dir(outside), 0o755); err != nil {
328+ t.Fatal(err)
329+ }
330+ if err := os.WriteFile(outside, []byte("---\nname: escape\ndescription: outside\n---\nbody\n"), 0o644); err != nil {
331+ t.Fatal(err)
332+ }
333+ unsafeDir := filepath.Join(extDir, "unsafe")
334+ if err := os.MkdirAll(unsafeDir, 0o755); err != nil {
335+ t.Fatal(err)
336+ }
337+ if err := os.Symlink(outside, filepath.Join(unsafeDir, "SKILL.md")); err != nil {
338+ t.Fatal(err)
339+ }
340+ loaded, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{"unsafe"}})
341+ if len(errs) == 0 || len(loaded) != 0 {
342+ t.Fatalf("symlinked skill outside extension was accepted: skills=%#v errors=%v", loaded, errs)
343+ }
344+ }
345+}
346+
262347 func TestManagerSpawnAndInvoke(t *testing.T) {
263348 tmp := t.TempDir()
264349 extRoot := filepath.Join(tmp, "extensions")
M packages/agent/extensions/tool.go +14 -1
@@ -18,6 +18,15 @@ import (
1818 // One concrete type instead of a closure-driven anonymous tool
1919 // keeps the schema, name, and ownership inspectable for logs and
2020 // 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+
2130 type extensionTool struct {
2231 name string
2332 description string
@@ -90,7 +99,11 @@ func (t *extensionTool) Execute(ctx context.Context, args json.RawMessage, _ fun
9099 // Defensive: an empty content slice would confuse the model.
91100 out.Content = []provider.Content{provider.TextBlock{Text: "(extension returned no content)"}}
92101 }
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+ }
94107 return out, nil
95108 }
96109
M packages/agent/extproto/extproto.go +54 -8
@@ -64,6 +64,9 @@ type EventInterceptResponseFromExt struct {
6464 Reason string `json:"reason,omitempty"`
6565 ModifiedArgs json.RawMessage `json:"modified_args,omitempty"`
6666 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"`
6770 }
6871
6972 type ToolResultFromExt struct {
@@ -72,6 +75,9 @@ type ToolResultFromExt struct {
7275 Content []ContentBlock `json:"content"`
7376 IsError bool `json:"is_error,omitempty"`
7477 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"`
7581 }
7682
7783 type ContentBlock struct {
@@ -121,6 +127,31 @@ type PanelCloseFromExt struct {
121127 PanelID string `json:"panel_id"`
122128 }
123129
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+
124155 const AlertKindBell = "bell"
125156
126157 // AlertRequest is a host-owned alert request. Extensions provide semantic
@@ -174,14 +205,25 @@ type ShutdownAckFromExt struct {
174205 }
175206
176207 type HelloAckFromHost struct {
177- Type string `json:"type"`
178- ProtocolVersion int `json:"protocol_version"`
179- ZotVersion string `json:"zot_version"`
180- Provider string `json:"provider"`
181- Model string `json:"model"`
182- CWD string `json:"cwd"`
183- ExtensionDir string `json:"extension_dir,omitempty"`
184- DataDir string `json:"data_dir,omitempty"`
208+ Type string `json:"type"`
209+ ProtocolVersion int `json:"protocol_version"`
210+ ZotVersion string `json:"zot_version"`
211+ Provider string `json:"provider"`
212+ Model string `json:"model"`
213+ CWD string `json:"cwd"`
214+ ExtensionDir string `json:"extension_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"`
185227 }
186228
187229 type CommandInvokedFromHost struct {
@@ -209,6 +251,10 @@ type EventFromHost struct {
209251 ToolArgs json.RawMessage `json:"tool_args,omitempty"`
210252 ToolPreview string `json:"tool_preview,omitempty"`
211253 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"`
212258 }
213259
214260 type EventInterceptFromHost struct {
M packages/agent/modes/ext_notes_test.go +65 -0
@@ -1,6 +1,7 @@
11 package modes
22
33 import (
4+ "fmt"
45 "strings"
56 "testing"
67
@@ -44,3 +45,67 @@ func TestClearNotesNoMatchKeepsNotes(t *testing.T) {
4445 t.Fatalf("expected note to survive, got %d", len(i.extNotes))
4546 }
4647 }
48+
49+func TestPersistentExtensionWidgetRowsAreBounded(t *testing.T) {
50+ i := newNotesTestInteractive()
51+ lines := make([]string, 20)
52+ for idx := range lines {
53+ lines[idx] = "line"
54+ }
55+ i.SetWidget("extension", "widget", "above_input", "Title", lines)
56+
57+ i.mu.Lock()
58+ got := i.extensionChromeLinesLocked(80)
59+ i.mu.Unlock()
60+ if len(got) != maxExtensionWidgetRows {
61+ t.Fatalf("widget rows = %d, want %d: %v", len(got), maxExtensionWidgetRows, got)
62+ }
63+ if !strings.Contains(strings.Join(got, "\n"), "extension widgets truncated") {
64+ t.Fatalf("bounded widget rows omitted truncation marker: %v", got)
65+ }
66+}
67+
68+func TestPersistentExtensionStatusRowsAreBounded(t *testing.T) {
69+ i := newNotesTestInteractive()
70+ for n := 0; n < maxExtensionStatusRows+4; n++ {
71+ i.SetStatus("extension", fmt.Sprintf("status-%d", n), "info", "line")
72+ }
73+
74+ i.mu.Lock()
75+ got := i.extensionChromeLinesLocked(80)
76+ i.mu.Unlock()
77+ if len(got) != maxExtensionStatusRows {
78+ t.Fatalf("status rows = %d, want %d: %v", len(got), maxExtensionStatusRows, got)
79+ }
80+ if !strings.Contains(strings.Join(got, "\n"), "extension statuses truncated") {
81+ t.Fatalf("bounded status rows omitted truncation marker: %v", got)
82+ }
83+}
84+
85+func TestPersistentExtensionStatusAndWidget(t *testing.T) {
86+ i := newNotesTestInteractive()
87+ i.SetStatus("tasked-phases", "progress", "success", "2/4 tasks checked")
88+ i.SetWidget("tasked-phases", "plan", "above_input", "Tasked phases", []string{"Current phase: parse", "[ ] read files"})
89+
90+ i.mu.Lock()
91+ lines := i.extensionChromeLinesLocked(80)
92+ statusKey := i.extensionStatusesKeyLocked()
93+ widgetKey := i.extensionWidgetsKeyLocked()
94+ i.mu.Unlock()
95+ joined := strings.Join(lines, "\n")
96+ if !strings.Contains(joined, "2/4 tasks checked") || !strings.Contains(joined, "Tasked phases") || !strings.Contains(joined, "[ ] read files") {
97+ t.Fatalf("persistent extension chrome = %q", joined)
98+ }
99+ if !strings.Contains(statusKey, "tasked-phases/progress/success/2/4 tasks checked") {
100+ t.Fatalf("status cache key = %q", statusKey)
101+ }
102+ if !strings.Contains(widgetKey, "tasked-phases/plan/above_input/Tasked phases") {
103+ t.Fatalf("widget cache key = %q", widgetKey)
104+ }
105+
106+ i.SetStatus("tasked-phases", "progress", "", "")
107+ i.ClearWidget("tasked-phases", "plan")
108+ if len(i.extStatuses) != 0 || len(i.extWidgets) != 0 {
109+ t.Fatalf("persistent extension chrome was not cleared: statuses=%v widgets=%v", i.extStatuses, i.extWidgets)
110+ }
111+}
M packages/agent/modes/interactive.go +216 -1
@@ -7,6 +7,7 @@ import (
77 "os"
88 "path/filepath"
99 "runtime"
10+ "sort"
1011 "strconv"
1112 "strings"
1213 "sync"
@@ -376,6 +377,8 @@ type chatCacheKey struct {
376377 statusErr string
377378 help string
378379 extNotes string
380+ extStatuses string
381+ extWidgets string
379382 reloadErrors string
380383 updateAvailable bool
381384 updateCurrent string
@@ -392,6 +395,17 @@ type QuickModelShortcut struct {
392395 Model string
393396 }
394397
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+
395409 // SettingsStore persists user-toggleable settings surfaced by /settings.
396410 type SettingsStore interface {
397411 SetQuickModelShortcut(slot int, providerName, model string) error
@@ -480,6 +494,8 @@ type Interactive struct {
480494 statusErr string
481495 statusOK string
482496 reloadStatusSeq uint64
497+ extStatuses map[string]map[string]extensionStatus
498+ extWidgets map[string]map[string]extensionWidget
483499 liveBlock []string // live streaming/tool progress rendered outside scrollback
484500 helpBlock []string // rendered above the chat when /help was typed
485501 cumUsage provider.Usage
@@ -748,6 +764,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
748764 sessionOpsDialog: newSessionOpsDialog(),
749765 sessionTreeDialog: newSessionTreeDialog(),
750766 extPanel: newExtPanelDialog(),
767+ extStatuses: map[string]map[string]extensionStatus{},
768+ extWidgets: map[string]map[string]extensionWidget{},
751769 suggest: newSlashSuggester(),
752770 fileSuggest: newFileSuggester(),
753771 spin: newSpinner(cfg.Theme),
@@ -1105,6 +1123,8 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
11051123 statusErr: i.statusErr,
11061124 help: strings.Join(i.helpBlock, "\n"),
11071125 extNotes: strings.Join(i.extNotes, "\n"),
1126+ extStatuses: i.extensionStatusesKeyLocked(),
1127+ extWidgets: i.extensionWidgetsKeyLocked(),
11081128 reloadErrors: strings.Join(i.reloadErrors, "\n"),
11091129 updateAvailable: i.updateInfo.Available,
11101130 updateCurrent: i.updateInfo.Current,
@@ -1116,6 +1136,138 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
11161136 }, true
11171137 }
11181138
1139+func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string {
1140+ keys := make([]string, 0, len(groups))
1141+ for key := range groups {
1142+ keys = append(keys, key)
1143+ }
1144+ sort.Strings(keys)
1145+ return keys
1146+}
1147+
1148+func sortedNestedInnerKeys[T any](groups map[string]map[string]T, outer string) []string {
1149+ items := groups[outer]
1150+ keys := make([]string, 0, len(items))
1151+ for key := range items {
1152+ keys = append(keys, key)
1153+ }
1154+ sort.Strings(keys)
1155+ return keys
1156+}
1157+
1158+func (i *Interactive) extensionStatusesKeyLocked() string {
1159+ var sb strings.Builder
1160+ extNames := sortedNestedOuterKeys(i.extStatuses)
1161+ for _, extName := range extNames {
1162+ keys := sortedNestedInnerKeys(i.extStatuses, extName)
1163+ for _, key := range keys {
1164+ status := i.extStatuses[extName][key]
1165+ fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, key, status.Level, status.Text)
1166+ }
1167+ }
1168+ return sb.String()
1169+}
1170+
1171+func (i *Interactive) extensionWidgetsKeyLocked() string {
1172+ var sb strings.Builder
1173+ extNames := sortedNestedOuterKeys(i.extWidgets)
1174+ for _, extName := range extNames {
1175+ ids := sortedNestedInnerKeys(i.extWidgets, extName)
1176+ for _, id := range ids {
1177+ widget := i.extWidgets[extName][id]
1178+ fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, id, widget.Position, widget.Title)
1179+ for _, line := range widget.Lines {
1180+ sb.WriteString(line)
1181+ sb.WriteByte('\n')
1182+ }
1183+ }
1184+ }
1185+ return sb.String()
1186+}
1187+
1188+const maxExtensionWidgetRows = 12
1189+const maxExtensionStatusRows = 6
1190+
1191+func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
1192+ var out []string
1193+ bodyWidth := cols - 2
1194+ if bodyWidth < 1 {
1195+ bodyWidth = 1
1196+ }
1197+ widgetRows := 0
1198+ widgetTruncated := false
1199+ appendWidgetRow := func(line string) bool {
1200+ if widgetRows >= maxExtensionWidgetRows-1 {
1201+ widgetTruncated = true
1202+ return false
1203+ }
1204+ out = append(out, line)
1205+ widgetRows++
1206+ return true
1207+ }
1208+
1209+ stopWidgets := false
1210+ for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
1211+ if stopWidgets {
1212+ break
1213+ }
1214+ for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1215+ widget := i.extWidgets[extName][id]
1216+ label := " [" + extName + "]"
1217+ if widget.Title != "" {
1218+ label += " " + widget.Title
1219+ }
1220+ if !appendWidgetRow(i.cfg.Theme.FG256(i.cfg.Theme.Accent, truncateLine(label, cols))) {
1221+ stopWidgets = true
1222+ break
1223+ }
1224+ for _, line := range widget.Lines {
1225+ if !appendWidgetRow(" " + truncateLine(line, bodyWidth)) {
1226+ stopWidgets = true
1227+ break
1228+ }
1229+ }
1230+ if stopWidgets {
1231+ break
1232+ }
1233+ }
1234+ }
1235+ if widgetTruncated {
1236+ out = append(out, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols)))
1237+ }
1238+
1239+ statusRows := 0
1240+ statusTruncated := false
1241+ for _, extName := range sortedNestedOuterKeys(i.extStatuses) {
1242+ for _, key := range sortedNestedInnerKeys(i.extStatuses, extName) {
1243+ if statusRows >= maxExtensionStatusRows-1 {
1244+ statusTruncated = true
1245+ break
1246+ }
1247+ status := i.extStatuses[extName][key]
1248+ color := i.cfg.Theme.Muted
1249+ switch status.Level {
1250+ case "warn":
1251+ color = i.cfg.Theme.Warning
1252+ case "error":
1253+ color = i.cfg.Theme.Error
1254+ case "success":
1255+ color = i.cfg.Theme.Tool
1256+ }
1257+ label := " [" + extName + "] " + status.Text
1258+ out = append(out, i.cfg.Theme.FG256(color, truncateLine(label, cols)))
1259+ statusRows++
1260+ }
1261+ if statusTruncated {
1262+ break
1263+ }
1264+ }
1265+ if statusTruncated {
1266+ out = append(out, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension statuses truncated ...", cols)))
1267+ }
1268+ return out
1269+}
1270+
11191271 func (i *Interactive) buildChatLocked(cols int) []string {
11201272 if i.agent != nil {
11211273 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
@@ -1599,13 +1751,15 @@ func (i *Interactive) redraw() {
15991751 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
16001752 }
16011753
1754+ extensionLines := i.extensionChromeLinesLocked(cols)
1755+
16021756 // Bottom-sticky sections (always visible, never scroll). Each
16031757 // non-empty subsection (dialog, suggest popup, sliding-in queue)
16041758 // is preceded by one blank row so it has air above the chat
16051759 // content. The status block and editor get their own dedicated
16061760 // blanks so spacing stays consistent whether or not a dialog or
16071761 // popup is showing.
1608- bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(statusLines)+len(edLines)+9)
1762+ bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(edLines)+9)
16091763 inputStartRow := -1
16101764 if len(dialog) > 0 {
16111765 bottom = append(bottom, "")
@@ -1627,6 +1781,7 @@ func (i *Interactive) redraw() {
16271781 workingBelow := workingPosition == tui.WorkingPositionBelowInput
16281782
16291783 var aboveInput []string
1784+ aboveInput = append(aboveInput, extensionLines...)
16301785 if !statusBelow {
16311786 aboveInput = append(aboveInput, statusLines...)
16321787 }
@@ -3486,6 +3641,66 @@ func (i *Interactive) Display(extName, text string) {
34863641 i.invalidate()
34873642 }
34883643
3644+// SetStatus replaces one persistent status item owned by an extension.
3645+func (i *Interactive) SetStatus(extName, key, level, text string) {
3646+ if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {
3647+ return
3648+ }
3649+ i.mu.Lock()
3650+ if i.extStatuses == nil {
3651+ i.extStatuses = map[string]map[string]extensionStatus{}
3652+ }
3653+ if strings.TrimSpace(text) == "" {
3654+ if items := i.extStatuses[extName]; items != nil {
3655+ delete(items, key)
3656+ if len(items) == 0 {
3657+ delete(i.extStatuses, extName)
3658+ }
3659+ }
3660+ } else {
3661+ if i.extStatuses[extName] == nil {
3662+ i.extStatuses[extName] = map[string]extensionStatus{}
3663+ }
3664+ i.extStatuses[extName][key] = extensionStatus{Level: level, Text: text}
3665+ }
3666+ i.mu.Unlock()
3667+ i.invalidate()
3668+}
3669+
3670+// SetWidget replaces one persistent widget owned by an extension.
3671+func (i *Interactive) SetWidget(extName, id, position, title string, lines []string) {
3672+ if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {
3673+ return
3674+ }
3675+ i.mu.Lock()
3676+ if i.extWidgets == nil {
3677+ i.extWidgets = map[string]map[string]extensionWidget{}
3678+ }
3679+ if i.extWidgets[extName] == nil {
3680+ i.extWidgets[extName] = map[string]extensionWidget{}
3681+ }
3682+ i.extWidgets[extName][id] = extensionWidget{
3683+ Position: position,
3684+ Title: title,
3685+ Lines: append([]string(nil), lines...),
3686+ }
3687+ i.mu.Unlock()
3688+ i.invalidate()
3689+}
3690+
3691+// ClearWidget removes one persistent widget owned by an extension.
3692+func (i *Interactive) ClearWidget(extName, id string) {
3693+ i.mu.Lock()
3694+ if items := i.extWidgets[extName]; items != nil {
3695+ delete(items, id)
3696+ if len(items) == 0 {
3697+ delete(i.extWidgets, extName)
3698+ }
3699+ }
3700+ i.mu.Unlock()
3701+ i.invalidate()
3702+}
3703+
34893704 func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
34903705 i.mu.Lock()
34913706 defer i.mu.Unlock()
M packages/agent/rpc.go +28 -4
@@ -38,6 +38,9 @@ import (
3838 //
3939 // Responses (stdout): {"type":"response","id":"1","command":"prompt","success":true}
4040 // 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.
4144 //
4245 // Auth: if $ZOTCORE_RPC_TOKEN is set, the first command must be
4346 // {"type":"hello","token":"..."} or the connection is closed.
@@ -89,9 +92,9 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
8992 }
9093 return true, "", r.ModifiedArgs
9194 }
92- ag.BeforeTurn = func(step int) (bool, string) {
93- r := extMgr.InterceptTurnStart(ctx, step)
94- return !r.Block, r.Reason
95+ ag.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
96+ r := extMgr.InterceptTurnStart(turnCtx, step)
97+ return !r.Block, r.Reason, r.Context
9598 }
9699 ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
97100 r := extMgr.InterceptAssistantMessage(ctx, text)
@@ -120,7 +123,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error {
120123 }
121124 })
122125
123- extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
126+ extMgr.EmitSessionEvent("session_start", nil, nil)
124127
125128 return server.run(os.Stdin)
126129 }
@@ -176,6 +179,27 @@ func (h *rpcExtHooks) Insert(string) {} /
176179 func (h *rpcExtHooks) OpenPanel(string, extproto.PanelSpec) {}
177180 func (h *rpcExtHooks) UpdatePanel(string, string, string, []string, string) {}
178181 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+}
179203
180204 const maxPendingRPCExtEvents = 64
181205
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 {
7373 Permissions map[string][]string
7474 }
7575
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+
7687 // VisibleSkills returns the subset of skills users should see in
7788 // pickers, /skills, and other interactive surfaces. Built-ins are
7889 // hidden because they're implementation detail; the model still
@@ -102,13 +113,22 @@ func VisibleSkills(in []*Skill) []*Skill {
102113 // Errors per skill are returned alongside the partial result so a
103114 // single broken file doesn't suppress the rest.
104115 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) {
105123 var errs []error
106124 seen := map[string]*Skill{}
107125 if includeUser {
108126 errs = append(errs, scanUserSkills(zotHome, cwd, userHome, seen)...)
109127 }
110- // Built-ins fill in any name the user didn't already provide
111- // (or every name, when includeUser is false).
128+ for _, dir := range bundled {
129+ errs = append(errs, scanBundledSkills(dir, seen)...)
130+ }
131+ // Built-ins fill in any name the user or an extension didn't provide.
112132 for _, s := range loadBuiltins() {
113133 if _, dup := seen[s.Name]; dup {
114134 continue
@@ -123,6 +143,111 @@ func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []erro
123143 return out, errs
124144 }
125145
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+
126251 // scanUserSkills walks the user-skill search dirs and populates
127252 // `seen` with first-match-wins per name. Split out so Discover's
128253 // 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) {
9494 }
9595 }
9696
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+
97150 func TestVisibleSkillsHidesBuiltins(t *testing.T) {
98151 in := []*Skill{
99152 {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 {
7878 return err
7979 }
8080 r.Provider, r.Model = providerName, model
81+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
8182 defer sess.Close()
8283 }
84+ announceSession(extMgr, sess)
8385
8486 // Open the inbox listener BEFORE emitting agent_ready so the
8587 // supervisor can dial through on the very first send. The
M packages/core/agent.go +60 -4
@@ -8,6 +8,7 @@ import (
88 "strings"
99 "sync"
1010 "time"
11+ "unicode/utf8"
1112
1213 "github.com/patriceckhart/zot/packages/provider"
1314 )
@@ -49,6 +50,20 @@ type Agent struct {
4950 // limiting, business-hour gates, and deny-by-default setups.
5051 BeforeTurn func(step int) (allowed bool, reason string)
5152
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+
5267 // BeforeAssistantMessage, if set, is called after the model's
5368 // final assistant message is assembled but before it's appended
5469 // to the transcript. Returning (allowed=false) suppresses both
@@ -384,7 +399,20 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
384399 }
385400
386401 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 {
388416 if allowed, reason := a.BeforeTurn(step); !allowed {
389417 if reason == "" {
390418 reason = "turn blocked by extension guard"
@@ -401,7 +429,7 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
401429 err error
402430 )
403431 for attempt := 0; ; attempt++ {
404- stop, assistantMsg, err = a.oneTurn(ctx, sink)
432+ stop, assistantMsg, err = a.oneTurn(ctx, sink, turnContext)
405433 sink(EvTurnEnd{Stop: stop, Err: err})
406434 if err == nil || !a.canRetryError(err, attempt) {
407435 break
@@ -561,14 +589,21 @@ func (a *Agent) dropLastAssistantMessage() {
561589
562590 // oneTurn calls the LLM once, forwards events, returns the stop reason
563591 // 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) {
565593 fastMode := a.FastModeEnabled()
566594 if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil {
567595 return provider.StopError, provider.Message{}, err
568596 }
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+ }
569604 req := provider.Request{
570605 Model: a.Model,
571- System: a.System,
606+ System: system,
572607 // Repair any dangling tool_use blocks before sending. A turn
573608 // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a
574609 // 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
708743 addedTools = append(addedTools, name)
709744 }
710745 }
746+ if a.OnToolResult != nil {
747+ a.OnToolResult(tc.ID, res)
748+ }
711749 sink(EvToolResult{ID: tc.ID, Result: res})
712750 }
713751
@@ -821,6 +859,24 @@ func mirrorToolImagesAsUser(msg provider.Message) provider.Message {
821859 return provider.Message{Role: provider.RoleUser, Content: content, Time: time.Now()}
822860 }
823861
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+
824880 func containsString(values []string, want string) bool {
825881 for _, value := range values {
826882 if value == want {
M packages/core/agent_retry_test.go +41 -0
@@ -8,6 +8,7 @@ import (
88 "sync/atomic"
99 "testing"
1010 "time"
11+ "unicode/utf8"
1112
1213 "github.com/patriceckhart/zot/packages/provider"
1314 )
@@ -221,6 +222,46 @@ func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-cha
221222 return out, nil
222223 }
223224
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+
224265 func TestAgentPropagatesMaxTokens(t *testing.T) {
225266 client := &captureClient{}
226267 a := NewAgent(client, "fake-model", "system", Registry{})
M packages/core/core_test.go +73 -0
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "context"
5+ "encoding/json"
56 "os"
67 "testing"
78 "time"
@@ -163,6 +164,78 @@ func TestNewSessionAtPathCreatesAtExplicitPath(t *testing.T) {
163164 }
164165 }
165166
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+
166239 func TestCostAdd(t *testing.T) {
167240 var c CostTracker
168241 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 {
2626 Meta SessionMeta
2727 // Title is the effective title after applying append-only rename rows.
2828 // Meta.Title remains the original meta-line value for compatibility.
29- Title string
30- writer *os.File
31- buf *bufio.Writer
29+ Title string
30+ // ExtensionState contains the latest opaque state snapshot recorded by
31+ // 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
3235
3336 // freshFile is true when the file was created by NewSession (this
3437 // process owns it) and false when OpenSession reopened an existing
@@ -81,6 +84,8 @@ type sessionLine struct {
8184 Messages *[]provider.Message `json:"messages,omitempty"`
8285 Usage *provider.Usage `json:"usage,omitempty"`
8386 Cumulative *provider.Usage `json:"cumulative,omitempty"`
87+ Extension string `json:"extension,omitempty"`
88+ State json.RawMessage `json:"state,omitempty"`
8489 }
8590
8691 type sessionLineHead struct {
@@ -104,6 +109,7 @@ type SessionSnapshot struct {
104109 Meta SessionMeta
105110 Title string
106111 Messages []provider.Message
112+ ExtensionState map[string]json.RawMessage
107113 UsageCheckpoints []SessionUsageCheckpoint
108114 CompactionGeneration int
109115 }
@@ -168,12 +174,13 @@ func newSessionAt(p, cwd, providerName, model, version string) (*Session, error)
168174 return nil, err
169175 }
170176 s := &Session{
171- ID: id,
172- Path: p,
173- Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version},
174- writer: f,
175- buf: bufio.NewWriter(f),
176- freshFile: true,
177+ ID: id,
178+ Path: p,
179+ Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version},
180+ writer: f,
181+ buf: bufio.NewWriter(f),
182+ freshFile: true,
183+ ExtensionState: map[string]json.RawMessage{},
177184 }
178185 if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
179186 f.Close()
@@ -277,6 +284,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
277284 }
278285 var rawCheckpoints []rawCheckpoint
279286 titleFromRename := false
287+ extensionState := map[string]json.RawMessage{}
280288
281289 err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
282290 var head sessionLineHead
@@ -350,6 +358,19 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
350358 titleFromRename = true
351359 snapshot.Title = *row.Title
352360
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+
353374 default:
354375 return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
355376 }
@@ -363,6 +384,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
363384 }
364385
365386 snapshot.CompactionGeneration = generation
387+ snapshot.ExtensionState = extensionState
366388
367389 // Repair only after compaction replacement. This makes synthetic tool
368390 // results and the removal of orphaned results part of the same indexed
@@ -703,12 +725,13 @@ func OpenSession(path string) (*Session, []provider.Message, error) {
703725 return nil, nil, err
704726 }
705727 s := &Session{
706- ID: snapshot.Meta.ID,
707- Path: path,
708- Meta: snapshot.Meta,
709- Title: NormalizeSessionTitle(snapshot.Title),
710- writer: out,
711- buf: bufio.NewWriter(out),
728+ ID: snapshot.Meta.ID,
729+ Path: path,
730+ Meta: snapshot.Meta,
731+ Title: NormalizeSessionTitle(snapshot.Title),
732+ ExtensionState: snapshot.ExtensionState,
733+ writer: out,
734+ buf: bufio.NewWriter(out),
712735 }
713736 return s, snapshot.Messages, nil
714737 }
@@ -1110,6 +1133,40 @@ func (s *Session) AppendUsage(u, cum provider.Usage) error {
11101133 return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum})
11111134 }
11121135
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+
11131170 // Close flushes and closes the session file. If the session was
11141171 // freshly created in this process and never had any messages
11151172 // appended (the user opened zot, looked around, and exited without
@@ -1121,7 +1178,7 @@ func (s *Session) Close() error {
11211178 }
11221179 flushErr := s.buf.Flush()
11231180 closeErr := s.writer.Close()
1124- if s.freshFile && s.messagesAppended == 0 {
1181+ if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 {
11251182 // Best-effort cleanup. We deliberately don't propagate the
11261183 // remove error: if it fails (file already gone, perms changed)
11271184 // the worst case is one stale empty file in the listing.
M packages/core/session_portable.go +109 -3
@@ -9,6 +9,7 @@ import (
99 "io"
1010 "os"
1111 "path/filepath"
12+ "sort"
1213 "strings"
1314 "time"
1415
@@ -308,7 +309,11 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
308309 limit++
309310 }
310311
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)
312317 }
313318
314319 // BranchSessionHiddenFromHistory creates a hidden tree branch from a
@@ -334,10 +339,91 @@ func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segme
334339 if limit > 0 && limit < len(segment.Messages) && messageHasToolCalls(segment.Messages[limit-1]) && segment.Messages[limit].Role == provider.RoleTool {
335340 limit++
336341 }
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)
338347 }
339348
340-func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool) (string, error) {
349+// readExtensionStateAtFork reconstructs the latest extension snapshot that was
350+// persisted at or before a branch boundary. Extension state is independent of
351+// provider messages, but its timeline still follows the effective message
352+// stream across compaction rows.
353+func readExtensionStateAtFork(path string, limit int) (map[string]json.RawMessage, error) {
354+ f, err := os.Open(path)
355+ if err != nil {
356+ return nil, fmt.Errorf("branch: open parent for extension state: %w", err)
357+ }
358+ defer f.Close()
359+
360+ state := make(map[string]json.RawMessage)
361+ forkState := make(map[string]json.RawMessage)
362+ cloneState := func(source map[string]json.RawMessage) map[string]json.RawMessage {
363+ clone := make(map[string]json.RawMessage, len(source))
364+ for name, raw := range source {
365+ clone[name] = append(json.RawMessage(nil), raw...)
366+ }
367+ return clone
368+ }
369+ applyState := func(target map[string]json.RawMessage, row sessionLine) {
370+ if len(row.State) == 0 || strings.TrimSpace(string(row.State)) == "null" {
371+ delete(target, row.Extension)
372+ return
373+ }
374+ if json.Valid(row.State) {
375+ target[row.Extension] = append(json.RawMessage(nil), row.State...)
376+ }
377+ }
378+ effectiveCount := 0
379+ err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
380+ var head sessionLineHead
381+ if err := json.Unmarshal(line, &head); err != nil {
382+ return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
383+ }
384+ switch head.Type {
385+ case "meta", "usage", "rename":
386+ return nil
387+ case "message":
388+ if _, err := hydrateMessage(line); err != nil {
389+ return fmt.Errorf("line %d: invalid message row: %w", lineNo, err)
390+ }
391+ effectiveCount++
392+ case "compaction":
393+ messages, err := hydrateCompaction(line)
394+ if err != nil {
395+ return fmt.Errorf("line %d: invalid compaction row: %w", lineNo, err)
396+ }
397+ effectiveCount = len(messages)
398+ // A compaction replaces the effective transcript. State that was
399+ // recorded anywhere before this boundary belongs to every fork
400+ // inside the replacement transcript, even when its old message
401+ // index was after the requested fork point.
402+ forkState = cloneState(state)
403+ case "extension_state":
404+ var row sessionLine
405+ if err := json.Unmarshal(line, &row); err != nil {
406+ return fmt.Errorf("line %d: invalid extension state row: %w", lineNo, err)
407+ }
408+ if row.Extension == "" || len(row.State) > maxExtensionStateBytes {
409+ return nil
410+ }
411+ applyState(state, row)
412+ if effectiveCount <= limit {
413+ applyState(forkState, row)
414+ }
415+ default:
416+ return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
417+ }
418+ return nil
419+ })
420+ if err != nil {
421+ return nil, fmt.Errorf("branch: read extension state: %w", err)
422+ }
423+ return forkState, nil
424+}
425+
426+func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage) (string, error) {
341427 dir := SessionsDir(root, cwd)
342428 if err := os.MkdirAll(dir, 0o755); err != nil {
343429 return "", err
@@ -404,6 +490,26 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages
404490 }
405491 }
406492
493+ // Extension snapshots are branch state, not provider-visible transcript
494+ // content. Write the latest snapshot that existed at the fork point so a
495+ // child branch starts from the same extension state as its copied prefix.
496+ extensionNames := make([]string, 0, len(extensionState))
497+ for name := range extensionState {
498+ extensionNames = append(extensionNames, name)
499+ }
500+ sort.Strings(extensionNames)
501+ for _, name := range extensionNames {
502+ line, err := json.Marshal(sessionLine{Type: "extension_state", Extension: name, State: extensionState[name]})
503+ if err != nil {
504+ return "", fmt.Errorf("branch: marshal extension state: %w", err)
505+ }
506+ if _, err := bw.Write(line); err != nil {
507+ return "", fmt.Errorf("branch: write extension state: %w", err)
508+ }
509+ if err := bw.WriteByte('\n'); err != nil {
510+ return "", fmt.Errorf("branch: terminate extension state row: %w", err)
511+ }
512+ }
407513 // Preserve every cumulative checkpoint that belongs to this prefix. The
408514 // final two rows are needed to reconstruct LastTurnUsage as a delta.
409515 for _, checkpoint := range checkpoints {
M packages/core/session_portable_test.go +76 -0
@@ -491,6 +491,82 @@ func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) {
491491 }
492492 }
493493
494+func TestBranchSessionCopiesExtensionStateAtFork(t *testing.T) {
495+ root := t.TempDir()
496+ cwd := "/project"
497+ parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
498+ if err != nil {
499+ t.Fatal(err)
500+ }
501+ if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}}); err != nil {
502+ t.Fatal(err)
503+ }
504+ if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
505+ t.Fatal(err)
506+ }
507+ if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second"}}}); err != nil {
508+ t.Fatal(err)
509+ }
510+ if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":2}`)); err != nil {
511+ t.Fatal(err)
512+ }
513+ if err := parent.Close(); err != nil {
514+ t.Fatal(err)
515+ }
516+
517+ branchPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
518+ if err != nil {
519+ t.Fatal(err)
520+ }
521+ branch, _, err := OpenSession(branchPath)
522+ if err != nil {
523+ t.Fatal(err)
524+ }
525+ t.Cleanup(func() { _ = branch.Close() })
526+ if got := string(branch.ExtensionState["tasked-phases"]); got != `{"version":1}` {
527+ t.Fatalf("branch extension state = %q, want version 1", got)
528+ }
529+}
530+
531+func TestBranchSessionCarriesStateAcrossCompactionBoundary(t *testing.T) {
532+ root := t.TempDir()
533+ cwd := "/project"
534+ parent, err := NewSession(root, cwd, "anthropic", "claude", "test")
535+ if err != nil {
536+ t.Fatal(err)
537+ }
538+ for _, text := range []string{"before-a", "before-b"} {
539+ if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}); err != nil {
540+ t.Fatal(err)
541+ }
542+ }
543+ if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil {
544+ t.Fatal(err)
545+ }
546+ if err := parent.AppendCompaction([]provider.Message{
547+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "summary"}}},
548+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "tail"}}},
549+ }); err != nil {
550+ t.Fatal(err)
551+ }
552+ if err := parent.Close(); err != nil {
553+ t.Fatal(err)
554+ }
555+
556+ branchPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
557+ if err != nil {
558+ t.Fatal(err)
559+ }
560+ branch, _, err := OpenSession(branchPath)
561+ if err != nil {
562+ t.Fatal(err)
563+ }
564+ t.Cleanup(func() { _ = branch.Close() })
565+ if got := string(branch.ExtensionState["tasked-phases"]); got != `{"version":1}` {
566+ t.Fatalf("compacted branch extension state = %q, want version 1", got)
567+ }
568+}
569+
494570 // TestBuildSessionTree verifies parent/child edges are rebuilt
495571 // from meta + sibling-scan.
496572 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