detached 2880a25

feat(extensions): add session-aware extension APIs

commit
2880a253af7d91f32f73a184c1bea9b47539dc2b
author
brice · · 2026-08-04 13:04 UTC
committer
brice · · 2026-08-04 14:19 UTC
parent
69e5a1b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
35 files · +3755 -106
M README.md +1 -1
@@ -954,7 +954,7 @@ An extension may ship only a theme: `extension.json` plus `theme.json` (or `them
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 +225 -0
@@ -0,0 +1,225 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "strings"
8+ "sync"
9+
10+ "github.com/patriceckhart/zot/packages/agent/ext"
11+)
12+
13+const (
14+ extensionName = "tasked-phases"
15+ extensionVersion = "1.0.0"
16+ toolName = "tasked_phases"
17+ panelID = "tasked-phases"
18+)
19+
20+const toolSchema = `{
21+ "type": "object",
22+ "additionalProperties": false,
23+ "properties": {
24+ "action": {
25+ "type": "string",
26+ "enum": ["get_status", "set_spec", "replace_plan", "add_phase", "update_phase", "remove_phase", "add_task", "update_task", "remove_task", "set_current_phase", "set_task_checked", "set_phase_checked", "clear"],
27+ "description": "State operation to perform. Closed plans must be restarted with clear, or set_spec immediately followed by replace_plan."
28+ },
29+ "spec": {"type": "string", "description": "Spec text used by set_spec"},
30+ "phaseId": {"type": "string", "description": "Target phase id, raw id without the brackets shown in summaries"},
31+ "phaseTitle": {"type": "string", "description": "Phase title for add_phase or update_phase"},
32+ "phaseGoal": {"type": "string", "description": "Phase goal for add_phase or update_phase"},
33+ "taskId": {"type": "string", "description": "Target task id"},
34+ "taskText": {"type": "string", "description": "Task text for add_task or update_task"},
35+ "checked": {"type": "boolean", "description": "Checked state for set_task_checked or set_phase_checked"},
36+ "phases": {
37+ "type": "array",
38+ "description": "Full plan replacement used by replace_plan",
39+ "items": {
40+ "type": "object",
41+ "additionalProperties": false,
42+ "properties": {
43+ "id": {"type": "string", "description": "Optional phase id. If omitted, one is generated."},
44+ "title": {"type": "string", "description": "Phase title"},
45+ "goal": {"type": "string", "description": "Optional short goal for the phase"},
46+ "tasks": {
47+ "type": "array",
48+ "description": "Checklist tasks for the phase",
49+ "items": {
50+ "type": "object",
51+ "additionalProperties": false,
52+ "properties": {
53+ "id": {"type": "string", "description": "Optional task id. If omitted, one is generated."},
54+ "text": {"type": "string", "description": "Checklist task text"},
55+ "checked": {"type": "boolean", "description": "Whether the task starts checked"}
56+ },
57+ "required": ["text"]
58+ }
59+ }
60+ },
61+ "required": ["title"]
62+ }
63+ }
64+ },
65+ "required": ["action"]
66+}`
67+
68+type app struct {
69+ ext *ext.Extension
70+ store *stateStore
71+
72+ panelMu sync.Mutex
73+ panelOpen bool
74+}
75+
76+func newApp(e *ext.Extension) *app {
77+ return &app{ext: e, store: newStateStore()}
78+}
79+
80+func main() {
81+ e := ext.New(extensionName, extensionVersion)
82+ a := newApp(e)
83+
84+ e.OnHello(func(host ext.HostInfo) {
85+ dataDir := host.DataDir
86+ if dataDir == "" {
87+ dataDir = host.ExtensionDir
88+ }
89+ if err := a.store.load(dataDir, host.CWD); err != nil {
90+ e.Logf("load project state: %v", err)
91+ return
92+ }
93+ if state, err := a.store.snapshot(); err == nil {
94+ a.publishChrome(state)
95+ }
96+ })
97+ restoreSession := func(event ext.Event) {
98+ if err := a.store.activateSession(event.State); err != nil {
99+ e.Logf("restore session state: %v", err)
100+ return
101+ }
102+ if state, err := a.store.snapshot(); err == nil {
103+ a.publishChrome(state)
104+ a.renderPanelIfOpen(state)
105+ }
106+ }
107+ e.On("session_opened", restoreSession)
108+ e.On("session_switched", restoreSession)
109+ e.On("session_forked", restoreSession)
110+ e.InterceptTurnStart(func(_ int) ext.TurnStartDecision {
111+ state, err := a.store.snapshot()
112+ if err != nil {
113+ e.Logf("load turn focus: %v", err)
114+ return ext.TurnStartDecision{}
115+ }
116+ return ext.TurnStartDecision{Context: buildTurnContext(state)}
117+ })
118+
119+ e.Tool(toolName,
120+ "Persist and update a structured spec, phased plan, current phase, and checklist tasks. Use it for spec-driven planning and progress tracking.",
121+ json.RawMessage(toolSchema), a.handleTool)
122+ e.Command("phases", "Show the current spec, phases, and checklist state", a.handleCommand)
123+ e.OnPanelKey(panelID, a.handlePanelKey, a.handlePanelClose)
124+
125+ if err := e.Run(); err != nil {
126+ fmt.Fprintln(os.Stderr, err)
127+ }
128+}
129+
130+func (a *app) handleCommand(_ string) ext.Response {
131+ state, err := a.store.snapshot()
132+ if err != nil {
133+ return ext.Errorf("load tasked phases: %v", err)
134+ }
135+ a.publishChrome(state)
136+ a.panelMu.Lock()
137+ a.panelOpen = true
138+ a.panelMu.Unlock()
139+ return ext.OpenPanel(panelID, "Tasked phases", buildViewLines(state), "Escape closes this panel")
140+}
141+
142+func (a *app) handlePanelKey(key, _ string) {
143+ if key == "esc" {
144+ a.ext.ClosePanel(panelID)
145+ a.setPanelOpen(false)
146+ }
147+}
148+
149+func (a *app) handlePanelClose() { a.setPanelOpen(false) }
150+
151+func (a *app) setPanelOpen(open bool) {
152+ a.panelMu.Lock()
153+ a.panelOpen = open
154+ a.panelMu.Unlock()
155+}
156+
157+func (a *app) renderPanelIfOpen(state PlanState) {
158+ a.panelMu.Lock()
159+ open := a.panelOpen
160+ a.panelMu.Unlock()
161+ if open {
162+ a.ext.RenderPanel(panelID, "Tasked phases", buildViewLines(state), "Escape closes this panel")
163+ }
164+}
165+
166+func (a *app) handleTool(args json.RawMessage) ext.ToolResult {
167+ var params toolArgs
168+ if err := json.Unmarshal(args, &params); err != nil {
169+ return ext.TextErrorResult("invalid args: " + err.Error())
170+ }
171+ if !isKnownAction(params.Action) {
172+ return ext.TextErrorResult("unsupported action: " + params.Action)
173+ }
174+
175+ if params.Action == actionGetStatus {
176+ state, err := a.store.snapshot()
177+ if err != nil {
178+ return toolResult(params.Action, PlanState{}, "", err)
179+ }
180+ a.publishChrome(state)
181+ a.renderPanelIfOpen(state)
182+ return toolResult(params.Action, state, "Current phased plan status", nil)
183+ }
184+
185+ state, headline, err := a.store.transact(func(current PlanState) (PlanState, string, error) {
186+ return applyAction(current, params)
187+ })
188+ a.publishChrome(state)
189+ if err == nil {
190+ a.renderPanelIfOpen(state)
191+ }
192+ return toolResult(params.Action, state, headline, err)
193+}
194+
195+func (a *app) publishChrome(state PlanState) {
196+ if isPlanClosed(state) {
197+ a.ext.SetStatus("progress", "closed: "+state.ClosedSummary)
198+ } else if done, total := getPlanProgress(state); total > 0 {
199+ a.ext.SetStatus("progress", fmt.Sprintf("%d/%d tasks checked", done, total))
200+ } else {
201+ a.ext.SetStatus("progress", "no active plan")
202+ }
203+ a.ext.SetWidget("plan", "above_input", "Tasked phases", strings.Split(buildCompactSummary(state), "\n"))
204+}
205+
206+func toolResult(action string, state PlanState, headline string, err error) ext.ToolResult {
207+ text := buildToolResultText(action, state, headline, err)
208+ var result ext.ToolResult
209+ if err != nil {
210+ result = ext.TextErrorResult(text)
211+ } else {
212+ result = ext.TextResult(text)
213+ }
214+ result.Details = ext.JSONDetails(state)
215+ return result
216+}
217+
218+func isKnownAction(action string) bool {
219+ for _, known := range toolActions {
220+ if action == known {
221+ return true
222+ }
223+ }
224+ return false
225+}
A examples/extensions/tasked-phases/skill_sync_test.go +29 -0
@@ -0,0 +1,29 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "testing"
9+)
10+
11+func TestBundledSkillMatchesStandaloneCopy(t *testing.T) {
12+ _, file, _, ok := runtime.Caller(0)
13+ if !ok {
14+ t.Fatal("runtime.Caller failed")
15+ }
16+ extensionSkill := filepath.Join(filepath.Dir(file), "skills", "tasked-phases", "SKILL.md")
17+ standaloneSkill := filepath.Join(filepath.Dir(file), "..", "..", "skills", "tasked-phases", "SKILL.md")
18+ extension, err := os.ReadFile(extensionSkill)
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ standalone, err := os.ReadFile(standaloneSkill)
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ if !bytes.Equal(extension, standalone) {
27+ t.Fatal("bundled and standalone tasked-phases skills have diverged")
28+ }
29+}
A examples/extensions/tasked-phases/skills/tasked-phases/SKILL.md +33 -0
@@ -0,0 +1,33 @@
1+---
2+name: tasked-phases
3+description: Use when the user wants a spec turned into phased work with checklist tasks tracked by the tasked_phases tool, or when ongoing work should be tracked phase by phase.
4+---
5+
6+# Tasked Phases
7+
8+Use the `tasked_phases` tool as the source of truth for the spec, phases, current focus, and checklist progress.
9+
10+## Workflow
11+
12+1. Clarify missing requirements when the spec is ambiguous.
13+2. Save the accepted spec with `tasked_phases` using `set_spec`.
14+3. Create or materially revise the plan with `replace_plan`.
15+4. Keep phases small and tasks concrete enough to check off.
16+5. Set the focus with `set_current_phase` and pass the target `phaseId`.
17+6. Call `set_task_checked` immediately after each checklist task is completed.
18+7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task.
19+8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` before creating a new plan.
20+
21+## Planning rules
22+
23+- Prefer 3–7 phases unless the task is tiny.
24+- Each task should describe an observable outcome.
25+- Avoid vague tasks such as “work on this” or “finish implementation”.
26+- Keep the stored spec and plan concise and operational.
27+
28+## Tool guidance
29+
30+- Use `get_status` before relying on remembered state or revising a long-running plan.
31+- Use `replace_plan` for a substantial restructuring; use `add_*`, `update_*`, and `remove_*` for small changes.
32+- Pass `phaseId` as the raw ID shown in brackets, without the brackets. The tool also accepts a complete phase label or a unique phase title when recovering from copied output.
33+- Do not rely on prose alone for completion state; update the tool after each completed task.
A examples/extensions/tasked-phases/state.go +1079 -0
@@ -0,0 +1,1079 @@
1+package main
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+ "regexp"
12+ "strings"
13+ "sync"
14+ "time"
15+)
16+
17+const (
18+ actionGetStatus = "get_status"
19+ actionSetSpec = "set_spec"
20+ actionReplacePlan = "replace_plan"
21+ actionAddPhase = "add_phase"
22+ actionUpdatePhase = "update_phase"
23+ actionRemovePhase = "remove_phase"
24+ actionAddTask = "add_task"
25+ actionUpdateTask = "update_task"
26+ actionRemoveTask = "remove_task"
27+ actionSetCurrentPhase = "set_current_phase"
28+ actionSetTaskChecked = "set_task_checked"
29+ actionSetPhaseChecked = "set_phase_checked"
30+ actionClear = "clear"
31+)
32+
33+var toolActions = []string{
34+ actionGetStatus,
35+ actionSetSpec,
36+ actionReplacePlan,
37+ actionAddPhase,
38+ actionUpdatePhase,
39+ actionRemovePhase,
40+ actionAddTask,
41+ actionUpdateTask,
42+ actionRemoveTask,
43+ actionSetCurrentPhase,
44+ actionSetTaskChecked,
45+ actionSetPhaseChecked,
46+ actionClear,
47+}
48+
49+var closedPlanBlockedActions = map[string]bool{
50+ actionReplacePlan: true,
51+ actionAddPhase: true,
52+ actionUpdatePhase: true,
53+ actionRemovePhase: true,
54+ actionAddTask: true,
55+ actionUpdateTask: true,
56+ actionRemoveTask: true,
57+ actionSetCurrentPhase: true,
58+ actionSetTaskChecked: true,
59+ actionSetPhaseChecked: true,
60+}
61+
62+type PhaseTask struct {
63+ ID string `json:"id"`
64+ Text string `json:"text"`
65+ Checked bool `json:"checked"`
66+}
67+
68+type Phase struct {
69+ ID string `json:"id"`
70+ Title string `json:"title"`
71+ Goal string `json:"goal,omitempty"`
72+ Tasks []PhaseTask `json:"tasks"`
73+}
74+
75+type PlanState struct {
76+ Version int `json:"version"`
77+ Spec string `json:"spec,omitempty"`
78+ Phases []Phase `json:"phases"`
79+ CurrentPhaseID string `json:"currentPhaseId,omitempty"`
80+ ClosedAt *int64 `json:"closedAt,omitempty"`
81+ ClosedSummary string `json:"closedSummary,omitempty"`
82+ NextPhaseNumber int `json:"nextPhaseNumber"`
83+ NextTaskNumber int `json:"nextTaskNumber"`
84+ UpdatedAt int64 `json:"updatedAt"`
85+}
86+
87+type TaskInput struct {
88+ ID *string `json:"id"`
89+ Text string `json:"text"`
90+ Checked *bool `json:"checked"`
91+}
92+
93+type PhaseInput struct {
94+ ID *string `json:"id"`
95+ Title string `json:"title"`
96+ Goal *string `json:"goal"`
97+ Tasks *[]TaskInput `json:"tasks"`
98+}
99+
100+type toolArgs struct {
101+ Action string `json:"action"`
102+ Spec *string `json:"spec"`
103+ PhaseID string `json:"phaseId"`
104+ PhaseTitle *string `json:"phaseTitle"`
105+ PhaseGoal *string `json:"phaseGoal"`
106+ TaskID string `json:"taskId"`
107+ TaskText *string `json:"taskText"`
108+ Checked *bool `json:"checked"`
109+ Phases *[]PhaseInput `json:"phases"`
110+}
111+
112+func nowMillis() int64 { return time.Now().UnixMilli() }
113+
114+func createEmptyState() PlanState {
115+ return PlanState{
116+ Version: 1,
117+ Phases: []Phase{},
118+ NextPhaseNumber: 1,
119+ NextTaskNumber: 1,
120+ UpdatedAt: nowMillis(),
121+ }
122+}
123+
124+func cloneState(state PlanState) PlanState {
125+ cloned := state
126+ cloned.Phases = make([]Phase, len(state.Phases))
127+ for i, phase := range state.Phases {
128+ cloned.Phases[i] = phase
129+ cloned.Phases[i].Tasks = append([]PhaseTask(nil), phase.Tasks...)
130+ }
131+ return cloned
132+}
133+
134+func normalizeOptionalText(value string) string {
135+ return strings.TrimSpace(value)
136+}
137+
138+func singleLine(text string) string {
139+ return strings.Join(strings.Fields(text), " ")
140+}
141+
142+func truncatePlain(text string, maxLength int) string {
143+ if maxLength < 1 {
144+ return ""
145+ }
146+ runes := []rune(text)
147+ if len(runes) <= maxLength {
148+ return text
149+ }
150+ return strings.TrimRight(string(runes[:maxLength-1]), " \t\r\n") + "…"
151+}
152+
153+func getPhaseProgress(phase Phase) (done, total int) {
154+ total = len(phase.Tasks)
155+ for _, task := range phase.Tasks {
156+ if task.Checked {
157+ done++
158+ }
159+ }
160+ return done, total
161+}
162+
163+func getPlanProgress(state PlanState) (done, total int) {
164+ for _, phase := range state.Phases {
165+ phaseDone, phaseTotal := getPhaseProgress(phase)
166+ done += phaseDone
167+ total += phaseTotal
168+ }
169+ return done, total
170+}
171+
172+func isPhaseDone(phase Phase) bool {
173+ done, total := getPhaseProgress(phase)
174+ return total > 0 && done == total
175+}
176+
177+func hasStoredPlan(state PlanState) bool {
178+ return state.Spec != "" || len(state.Phases) > 0
179+}
180+
181+func isPlanComplete(state PlanState) bool {
182+ if len(state.Phases) == 0 {
183+ return false
184+ }
185+ for _, phase := range state.Phases {
186+ if !isPhaseDone(phase) {
187+ return false
188+ }
189+ }
190+ return true
191+}
192+
193+func isPlanClosed(state PlanState) bool { return state.ClosedAt != nil }
194+
195+func closePlanIfComplete(state *PlanState) {
196+ if !isPlanComplete(*state) {
197+ return
198+ }
199+ done, total := getPlanProgress(*state)
200+ if state.ClosedAt == nil {
201+ closedAt := nowMillis()
202+ state.ClosedAt = &closedAt
203+ }
204+ state.ClosedSummary = fmt.Sprintf("Completed %d/%d tasks across %d phase(s).", done, total, len(state.Phases))
205+ state.CurrentPhaseID = ""
206+}
207+
208+func getCurrentPhase(state PlanState) *Phase {
209+ if state.CurrentPhaseID == "" {
210+ return nil
211+ }
212+ for i := range state.Phases {
213+ if state.Phases[i].ID == state.CurrentPhaseID {
214+ return &state.Phases[i]
215+ }
216+ }
217+ return nil
218+}
219+
220+func getActivePhase(state PlanState) *Phase {
221+ if current := getCurrentPhase(state); current != nil {
222+ return current
223+ }
224+ for i := range state.Phases {
225+ if !isPhaseDone(state.Phases[i]) {
226+ return &state.Phases[i]
227+ }
228+ }
229+ return nil
230+}
231+
232+func getSuggestedCurrentPhaseID(state PlanState) string {
233+ if state.CurrentPhaseID != "" {
234+ for _, phase := range state.Phases {
235+ if phase.ID == state.CurrentPhaseID {
236+ return state.CurrentPhaseID
237+ }
238+ }
239+ }
240+ for _, phase := range state.Phases {
241+ if !isPhaseDone(phase) {
242+ return phase.ID
243+ }
244+ }
245+ if len(state.Phases) > 0 {
246+ return state.Phases[len(state.Phases)-1].ID
247+ }
248+ return ""
249+}
250+
251+func extractHighestIDNumber(prefix string, ids []string) int {
252+ highest := 0
253+ pattern := regexp.MustCompile(`^` + regexp.QuoteMeta(prefix) + `-(\d+)$`)
254+ for _, id := range ids {
255+ match := pattern.FindStringSubmatch(id)
256+ if len(match) != 2 {
257+ continue
258+ }
259+ var number int
260+ if _, err := fmt.Sscanf(match[1], "%d", &number); err == nil && number > highest {
261+ highest = number
262+ }
263+ }
264+ return highest
265+}
266+
267+func ensureState(state PlanState) PlanState {
268+ normalized := cloneState(state)
269+ normalized.Version = 1
270+ normalized.Spec = normalizeOptionalText(normalized.Spec)
271+ if normalized.Phases == nil {
272+ normalized.Phases = []Phase{}
273+ }
274+ for i := range normalized.Phases {
275+ normalized.Phases[i].Goal = optionalNormalized(normalized.Phases[i].Goal)
276+ if normalized.Phases[i].Tasks == nil {
277+ normalized.Phases[i].Tasks = []PhaseTask{}
278+ }
279+ }
280+ normalized.ClosedSummary = optionalNormalized(normalized.ClosedSummary)
281+ if isPlanComplete(normalized) {
282+ closePlanIfComplete(&normalized)
283+ } else {
284+ normalized.ClosedAt = nil
285+ normalized.ClosedSummary = ""
286+ }
287+
288+ highestPhase := make([]string, 0, len(normalized.Phases))
289+ highestTask := make([]string, 0)
290+ for _, phase := range normalized.Phases {
291+ highestPhase = append(highestPhase, phase.ID)
292+ for _, task := range phase.Tasks {
293+ highestTask = append(highestTask, task.ID)
294+ }
295+ }
296+ if normalized.NextPhaseNumber < 1 {
297+ normalized.NextPhaseNumber = 1
298+ }
299+ if normalized.NextTaskNumber < 1 {
300+ normalized.NextTaskNumber = 1
301+ }
302+ normalized.NextPhaseNumber = maxInt(normalized.NextPhaseNumber, extractHighestIDNumber("phase", highestPhase)+1)
303+ normalized.NextTaskNumber = maxInt(normalized.NextTaskNumber, extractHighestIDNumber("task", highestTask)+1)
304+ if isPlanClosed(normalized) {
305+ normalized.CurrentPhaseID = ""
306+ } else {
307+ normalized.CurrentPhaseID = getSuggestedCurrentPhaseID(normalized)
308+ }
309+ normalized.UpdatedAt = nowMillis()
310+ return normalized
311+}
312+
313+func optionalNormalized(value string) string {
314+ return normalizeOptionalText(value)
315+}
316+
317+func maxInt(a, b int) int {
318+ if a > b {
319+ return a
320+ }
321+ return b
322+}
323+
324+func nextPhaseID(state *PlanState) string {
325+ id := fmt.Sprintf("phase-%d", state.NextPhaseNumber)
326+ state.NextPhaseNumber++
327+ return id
328+}
329+
330+func nextTaskID(state *PlanState) string {
331+ id := fmt.Sprintf("task-%d", state.NextTaskNumber)
332+ state.NextTaskNumber++
333+ return id
334+}
335+
336+func extractBracketedPhaseID(value string) string {
337+ matches := regexp.MustCompile(`\[([^\]]+)\]`).FindAllStringSubmatch(value, -1)
338+ candidate := ""
339+ for _, match := range matches {
340+ if len(match) == 2 {
341+ if normalized := normalizeOptionalText(match[1]); normalized != "" {
342+ candidate = normalized
343+ }
344+ }
345+ }
346+ return candidate
347+}
348+
349+func findUniquePhaseByTitle(state PlanState, phaseTitle string) *Phase {
350+ normalizedTitle := singleLine(phaseTitle)
351+ var match *Phase
352+ matches := 0
353+ for i := range state.Phases {
354+ if singleLine(state.Phases[i].Title) == normalizedTitle {
355+ match = &state.Phases[i]
356+ matches++
357+ }
358+ }
359+ if matches == 1 {
360+ return match
361+ }
362+ return nil
363+}
364+
365+func findPhase(state PlanState, phaseID string) *Phase {
366+ candidate := normalizeOptionalText(phaseID)
367+ if candidate == "" {
368+ return nil
369+ }
370+ for i := range state.Phases {
371+ if state.Phases[i].ID == candidate {
372+ return &state.Phases[i]
373+ }
374+ }
375+ if bracketedID := extractBracketedPhaseID(candidate); bracketedID != "" {
376+ for i := range state.Phases {
377+ if state.Phases[i].ID == bracketedID {
378+ return &state.Phases[i]
379+ }
380+ }
381+ }
382+ return findUniquePhaseByTitle(state, candidate)
383+}
384+
385+func formatValidPhaseIDs(state PlanState) string {
386+ if len(state.Phases) == 0 {
387+ return "none"
388+ }
389+ parts := make([]string, 0, len(state.Phases))
390+ for _, phase := range state.Phases {
391+ parts = append(parts, fmt.Sprintf("%s (%s)", phase.ID, singleLine(phase.Title)))
392+ }
393+ return strings.Join(parts, ", ")
394+}
395+
396+func phaseIDError(state PlanState, action, phaseID string) string {
397+ candidate := normalizeOptionalText(phaseID)
398+ validIDs := formatValidPhaseIDs(state)
399+ if candidate == "" {
400+ return fmt.Sprintf("phaseId is required for %s. Use a valid phase id. Valid phase ids: %s.", action, validIDs)
401+ }
402+ return fmt.Sprintf("phaseId %q was not found for %s. Use the raw id from brackets without brackets. Valid phase ids: %s.", singleLine(candidate), action, validIDs)
403+}
404+
405+func findTask(state PlanState, taskID, phaseID string) (phase *Phase, task *PhaseTask) {
406+ if taskID == "" {
407+ return nil, nil
408+ }
409+ if phaseID != "" {
410+ phase = findPhase(state, phaseID)
411+ if phase == nil {
412+ return nil, nil
413+ }
414+ for i := range phase.Tasks {
415+ if phase.Tasks[i].ID == taskID {
416+ return phase, &phase.Tasks[i]
417+ }
418+ }
419+ return nil, nil
420+ }
421+ for i := range state.Phases {
422+ for j := range state.Phases[i].Tasks {
423+ if state.Phases[i].Tasks[j].ID == taskID {
424+ return &state.Phases[i], &state.Phases[i].Tasks[j]
425+ }
426+ }
427+ }
428+ return nil, nil
429+}
430+
431+func buildPhaseFromInput(state *PlanState, input PhaseInput, phaseIDs, taskIDs map[string]struct{}) (Phase, error) {
432+ id := ""
433+ if input.ID != nil {
434+ id = normalizeOptionalText(*input.ID)
435+ }
436+ if id == "" {
437+ id = nextPhaseID(state)
438+ }
439+ if _, exists := phaseIDs[id]; exists {
440+ return Phase{}, fmt.Errorf("duplicate phase id %q", id)
441+ }
442+ phaseIDs[id] = struct{}{}
443+ phase := Phase{
444+ ID: id,
445+ Title: strings.TrimSpace(input.Title),
446+ Goal: optionalInputText(input.Goal),
447+ Tasks: []PhaseTask{},
448+ }
449+ if input.Tasks != nil {
450+ phase.Tasks = make([]PhaseTask, 0, len(*input.Tasks))
451+ for _, inputTask := range *input.Tasks {
452+ taskID := ""
453+ if inputTask.ID != nil {
454+ taskID = normalizeOptionalText(*inputTask.ID)
455+ }
456+ if taskID == "" {
457+ taskID = nextTaskID(state)
458+ }
459+ if _, exists := taskIDs[taskID]; exists {
460+ return Phase{}, fmt.Errorf("duplicate task id %q", taskID)
461+ }
462+ taskIDs[taskID] = struct{}{}
463+ checked := false
464+ if inputTask.Checked != nil {
465+ checked = *inputTask.Checked
466+ }
467+ phase.Tasks = append(phase.Tasks, PhaseTask{ID: taskID, Text: strings.TrimSpace(inputTask.Text), Checked: checked})
468+ }
469+ }
470+ return phase, nil
471+}
472+
473+func optionalInputText(value *string) string {
474+ if value == nil {
475+ return ""
476+ }
477+ return normalizeOptionalText(*value)
478+}
479+
480+// applyAction is the state transition used by the protocol handler. It does
481+// not persist the result; stateStore performs that step while holding its
482+// mutex, so a failed write never publishes an unpersisted state.
483+func applyAction(state PlanState, params toolArgs) (PlanState, string, error) {
484+ state = cloneState(state)
485+ nextState := cloneState(state)
486+ if isPlanClosed(state) && closedPlanBlockedActions[params.Action] {
487+ reopen := (params.Action == actionSetTaskChecked || params.Action == actionSetPhaseChecked) && params.Checked != nil && !*params.Checked
488+ if !reopen {
489+ return state, "Plan is closed", errors.New("this plan is already complete and closed; call clear before starting new work, or call set_spec immediately followed by replace_plan")
490+ }
491+ }
492+
493+ switch params.Action {
494+ case actionGetStatus:
495+ return state, "Current phased plan status", nil
496+ case actionSetSpec:
497+ spec := ""
498+ if params.Spec != nil {
499+ spec = normalizeOptionalText(*params.Spec)
500+ }
501+ if spec == "" {
502+ return state, "Spec not updated", errors.New("spec is required for set_spec")
503+ }
504+ nextState.Spec = spec
505+ if isPlanClosed(state) {
506+ nextState.Phases = []Phase{}
507+ nextState.CurrentPhaseID = ""
508+ nextState.NextPhaseNumber = 1
509+ nextState.NextTaskNumber = 1
510+ }
511+ nextState.ClosedAt = nil
512+ nextState.ClosedSummary = ""
513+ return nextState, "Saved spec", nil
514+ case actionReplacePlan:
515+ if params.Phases == nil {
516+ return state, "Plan not replaced", errors.New("phases is required for replace_plan")
517+ }
518+ nextState.Phases = []Phase{}
519+ nextState.CurrentPhaseID = ""
520+ nextState.NextPhaseNumber = 1
521+ nextState.NextTaskNumber = 1
522+ phaseIDs := map[string]struct{}{}
523+ taskIDs := map[string]struct{}{}
524+ for _, phaseInput := range *params.Phases {
525+ phase, err := buildPhaseFromInput(&nextState, phaseInput, phaseIDs, taskIDs)
526+ if err != nil {
527+ return state, "Plan not replaced", err
528+ }
529+ nextState.Phases = append(nextState.Phases, phase)
530+ }
531+ nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState)
532+ return nextState, fmt.Sprintf("Replaced plan with %d phase(s)", len(nextState.Phases)), nil
533+ case actionAddPhase:
534+ phaseTitle := ""
535+ if params.PhaseTitle != nil {
536+ phaseTitle = normalizeOptionalText(*params.PhaseTitle)
537+ }
538+ if phaseTitle == "" {
539+ return state, "Phase not added", errors.New("phaseTitle is required for add_phase")
540+ }
541+ nextState.Phases = append(nextState.Phases, Phase{ID: nextPhaseID(&nextState), Title: phaseTitle, Tasks: []PhaseTask{}})
542+ if params.PhaseGoal != nil {
543+ nextState.Phases[len(nextState.Phases)-1].Goal = normalizeOptionalText(*params.PhaseGoal)
544+ }
545+ if nextState.CurrentPhaseID == "" {
546+ nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState)
547+ }
548+ return nextState, "Added phase " + phaseTitle, nil
549+ case actionUpdatePhase:
550+ phase := findPhase(nextState, params.PhaseID)
551+ if phase == nil {
552+ return state, "Phase not updated", errors.New(phaseIDError(nextState, actionUpdatePhase, params.PhaseID))
553+ }
554+ if params.PhaseTitle != nil {
555+ if phaseTitle := normalizeOptionalText(*params.PhaseTitle); phaseTitle != "" {
556+ phase.Title = phaseTitle
557+ }
558+ }
559+ if params.PhaseGoal != nil {
560+ phase.Goal = normalizeOptionalText(*params.PhaseGoal)
561+ }
562+ return nextState, "Updated phase " + phase.Title, nil
563+ case actionRemovePhase:
564+ phase := findPhase(nextState, params.PhaseID)
565+ if phase == nil {
566+ return state, "Phase not removed", errors.New(phaseIDError(nextState, actionRemovePhase, params.PhaseID))
567+ }
568+ removedID := phase.ID
569+ filtered := make([]Phase, 0, len(nextState.Phases)-1)
570+ for _, entry := range nextState.Phases {
571+ if entry.ID != removedID {
572+ filtered = append(filtered, entry)
573+ }
574+ }
575+ nextState.Phases = filtered
576+ nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState)
577+ return nextState, "Removed phase " + removedID, nil
578+ case actionAddTask:
579+ phase := findPhase(nextState, params.PhaseID)
580+ taskText := ""
581+ if params.TaskText != nil {
582+ taskText = normalizeOptionalText(*params.TaskText)
583+ }
584+ if phase == nil {
585+ return state, "Task not added", errors.New(phaseIDError(nextState, actionAddTask, params.PhaseID))
586+ }
587+ if taskText == "" {
588+ return state, "Task not added", errors.New("taskText is required for add_task")
589+ }
590+ phase.Tasks = append(phase.Tasks, PhaseTask{ID: nextTaskID(&nextState), Text: taskText})
591+ return nextState, "Added task to " + phase.Title, nil
592+ case actionUpdateTask:
593+ _, task := findTask(nextState, params.TaskID, params.PhaseID)
594+ taskText := ""
595+ if params.TaskText != nil {
596+ taskText = normalizeOptionalText(*params.TaskText)
597+ }
598+ if task == nil {
599+ return state, "Task not updated", errors.New("taskId was not found")
600+ }
601+ if taskText == "" {
602+ return state, "Task not updated", errors.New("taskText is required for update_task")
603+ }
604+ task.Text = taskText
605+ return nextState, "Updated task " + task.ID, nil
606+ case actionRemoveTask:
607+ if params.TaskID == "" {
608+ return state, "Task not removed", errors.New("taskId is required")
609+ }
610+ phase, task := findTask(nextState, params.TaskID, params.PhaseID)
611+ if task == nil {
612+ return state, "Task not removed", errors.New("taskId was not found")
613+ }
614+ filtered := make([]PhaseTask, 0, len(phase.Tasks)-1)
615+ for _, entry := range phase.Tasks {
616+ if entry.ID != params.TaskID {
617+ filtered = append(filtered, entry)
618+ }
619+ }
620+ phase.Tasks = filtered
621+ return nextState, "Removed task " + params.TaskID, nil
622+ case actionSetCurrentPhase:
623+ phase := findPhase(nextState, params.PhaseID)
624+ if phase == nil {
625+ return state, "Current phase not updated", errors.New(phaseIDError(nextState, actionSetCurrentPhase, params.PhaseID))
626+ }
627+ nextState.CurrentPhaseID = phase.ID
628+ return nextState, "Current phase set to " + phase.Title, nil
629+ case actionSetTaskChecked:
630+ phase, task := findTask(nextState, params.TaskID, params.PhaseID)
631+ if task == nil {
632+ return state, "Task not updated", errors.New("taskId was not found")
633+ }
634+ if params.Checked == nil {
635+ return state, "Task not updated", errors.New("checked must be provided for set_task_checked")
636+ }
637+ task.Checked = *params.Checked
638+ if *params.Checked && nextState.CurrentPhaseID == phase.ID && isPhaseDone(*phase) {
639+ nextState.CurrentPhaseID = ""
640+ }
641+ return nextState, fmt.Sprintf("%s task %s", checkedWord(*params.Checked), task.ID), nil
642+ case actionSetPhaseChecked:
643+ phase := findPhase(nextState, params.PhaseID)
644+ if phase == nil {
645+ return state, "Phase not updated", errors.New(phaseIDError(nextState, actionSetPhaseChecked, params.PhaseID))
646+ }
647+ if params.Checked == nil {
648+ return state, "Phase not updated", errors.New("checked must be provided for set_phase_checked")
649+ }
650+ if len(phase.Tasks) == 0 {
651+ return state, "Phase not updated", errors.New("set_phase_checked requires the phase to have at least one task")
652+ }
653+ for i := range phase.Tasks {
654+ phase.Tasks[i].Checked = *params.Checked
655+ }
656+ if *params.Checked && nextState.CurrentPhaseID == phase.ID {
657+ nextState.CurrentPhaseID = ""
658+ }
659+ if !*params.Checked {
660+ nextState.CurrentPhaseID = phase.ID
661+ }
662+ return nextState, fmt.Sprintf("%s phase %s", checkedWord(*params.Checked), phase.Title), nil
663+ case actionClear:
664+ return createEmptyState(), "Cleared stored spec and phased checklist", nil
665+ default:
666+ return state, "Unsupported action", errors.New("unknown action")
667+ }
668+}
669+
670+func checkedWord(checked bool) string {
671+ if checked {
672+ return "Checked"
673+ }
674+ return "Unchecked"
675+}
676+
677+const (
678+ compactVisibleIncompleteTasks = 4
679+ turnContextVisibleIncompleteTasks = 3
680+ maxTurnContextChars = 2048
681+)
682+
683+func getIncompleteTasks(phase Phase) []PhaseTask {
684+ incomplete := make([]PhaseTask, 0)
685+ for _, task := range phase.Tasks {
686+ if !task.Checked {
687+ incomplete = append(incomplete, task)
688+ }
689+ }
690+ return incomplete
691+}
692+
693+func formatRemainingTaskCount(count int) string {
694+ if count == 1 {
695+ return "1 remaining"
696+ }
697+ return fmt.Sprintf("%d remaining", count)
698+}
699+
700+func formatPhaseTitle(phase Phase) string { return truncatePlain(singleLine(phase.Title), 120) }
701+func formatTaskText(task PhaseTask) string { return truncatePlain(singleLine(task.Text), 180) }
702+
703+func formatTurnContextID(id string) string { return truncatePlain(singleLine(id), 96) }
704+
705+func appendIncompleteTaskLines(lines *[]string, tasks []PhaseTask, visibleCount int, indent string) {
706+ visible := tasks
707+ if len(visible) > visibleCount {
708+ visible = visible[:visibleCount]
709+ }
710+ for _, task := range visible {
711+ *lines = append(*lines, fmt.Sprintf("%s[ ] %s [%s]", indent, formatTaskText(task), task.ID))
712+ }
713+ if omitted := len(tasks) - len(visible); omitted > 0 {
714+ *lines = append(*lines, fmt.Sprintf("%s... %d more incomplete task(s) omitted; call tasked_phases get_status for the full checklist.", indent, omitted))
715+ }
716+}
717+
718+func buildSummary(state PlanState) string {
719+ if !hasStoredPlan(state) {
720+ return "No spec or phased checklist has been stored yet."
721+ }
722+ lines := make([]string, 0)
723+ if state.Spec != "" {
724+ lines = append(lines, "Spec:", state.Spec, "")
725+ }
726+ if len(state.Phases) == 0 {
727+ return strings.Join(append(lines, "Phases: (none)"), "\n")
728+ }
729+ done, total := getPlanProgress(state)
730+ lines = append(lines, fmt.Sprintf("Plan progress: %d/%d tasks checked", done, total))
731+ if isPlanClosed(state) {
732+ closedSummary := state.ClosedSummary
733+ if closedSummary == "" {
734+ closedSummary = "all phases complete"
735+ }
736+ lines = append(lines, "Plan closed: "+closedSummary)
737+ }
738+ lines = append(lines, "Phases:")
739+ for _, phase := range state.Phases {
740+ phaseDone, phaseTotal := getPhaseProgress(phase)
741+ currentMarker := " "
742+ if phase.ID == state.CurrentPhaseID {
743+ currentMarker = ">"
744+ }
745+ phaseMarker := "[ ]"
746+ if isPhaseDone(phase) {
747+ phaseMarker = "[x]"
748+ }
749+ goalSuffix := ""
750+ if phase.Goal != "" {
751+ goalSuffix = " - " + phase.Goal
752+ }
753+ lines = append(lines, fmt.Sprintf("%s %s %s [%s] (%d/%d)%s", currentMarker, phaseMarker, phase.Title, phase.ID, phaseDone, phaseTotal, goalSuffix))
754+ if len(phase.Tasks) == 0 {
755+ lines = append(lines, " - No tasks yet")
756+ continue
757+ }
758+ for _, task := range phase.Tasks {
759+ marker := "[ ]"
760+ if task.Checked {
761+ marker = "[x]"
762+ }
763+ lines = append(lines, fmt.Sprintf(" %s %s [%s]", marker, task.Text, task.ID))
764+ }
765+ }
766+ return strings.Join(lines, "\n")
767+}
768+
769+func buildCompactSummary(state PlanState) string {
770+ if !hasStoredPlan(state) {
771+ return "No spec or phased checklist has been stored yet."
772+ }
773+ lines := make([]string, 0)
774+ if state.Spec != "" {
775+ lines = append(lines, "Spec: "+truncatePlain(singleLine(state.Spec), 220))
776+ }
777+ done, total := getPlanProgress(state)
778+ lines = append(lines, fmt.Sprintf("Progress: %d/%d tasks checked", done, total))
779+ if isPlanClosed(state) {
780+ closedSummary := state.ClosedSummary
781+ if closedSummary == "" {
782+ closedSummary = "all phases complete"
783+ }
784+ lines = append(lines, "Plan closed: "+closedSummary)
785+ return strings.Join(lines, "\n")
786+ }
787+ if currentPhase := getActivePhase(state); currentPhase != nil {
788+ incompleteTasks := getIncompleteTasks(*currentPhase)
789+ goalSuffix := ""
790+ if currentPhase.Goal != "" {
791+ goalSuffix = " - " + truncatePlain(singleLine(currentPhase.Goal), 120)
792+ }
793+ lines = append(lines, fmt.Sprintf("Current phase: %s [%s] (%s)%s", formatPhaseTitle(*currentPhase), currentPhase.ID, formatRemainingTaskCount(len(incompleteTasks)), goalSuffix))
794+ if len(incompleteTasks) > 0 {
795+ lines = append(lines, "Incomplete tasks:")
796+ appendIncompleteTaskLines(&lines, incompleteTasks, compactVisibleIncompleteTasks, " ")
797+ }
798+ } else if len(state.Phases) > 0 {
799+ lines = append(lines, "Current phase: none")
800+ }
801+ return strings.Join(lines, "\n")
802+}
803+
804+// buildTurnContext returns the small amount of plan state the model needs at
805+// the start of a turn. It deliberately excludes the spec, completed phases,
806+// and future phases; get_status and /phases remain the full-state views.
807+func buildTurnContext(state PlanState) string {
808+ if !hasStoredPlan(state) {
809+ return ""
810+ }
811+ if isPlanClosed(state) {
812+ return truncatePlain("Tasked phases focus: the current plan is complete and closed. Use clear before creating a plan for unrelated work.", maxTurnContextChars)
813+ }
814+ phase := getActivePhase(state)
815+ if phase == nil {
816+ return ""
817+ }
818+ lines := []string{
819+ "Tasked phases focus:",
820+ fmt.Sprintf("Current phase: %s [%s]", formatPhaseTitle(*phase), formatTurnContextID(phase.ID)),
821+ }
822+ if phase.Goal != "" {
823+ lines = append(lines, "Goal: "+truncatePlain(singleLine(phase.Goal), 160))
824+ }
825+ incompleteTasks := getIncompleteTasks(*phase)
826+ if len(incompleteTasks) == 0 {
827+ lines = append(lines, "No incomplete tasks remain in this phase; update the plan or advance the phase as appropriate.")
828+ return truncatePlain(strings.Join(lines, "\n"), maxTurnContextChars)
829+ }
830+ lines = append(lines, "Incomplete tasks:")
831+ visible := incompleteTasks
832+ if len(visible) > turnContextVisibleIncompleteTasks {
833+ visible = visible[:turnContextVisibleIncompleteTasks]
834+ }
835+ for _, task := range visible {
836+ lines = append(lines, fmt.Sprintf(" [ ] %s [%s]", formatTaskText(task), formatTurnContextID(task.ID)))
837+ }
838+ if omitted := len(incompleteTasks) - len(visible); omitted > 0 {
839+ lines = append(lines, fmt.Sprintf(" ... %d more incomplete task(s) omitted; call tasked_phases get_status for the full checklist.", omitted))
840+ }
841+ return truncatePlain(strings.Join(lines, "\n"), maxTurnContextChars)
842+}
843+
844+func shouldReturnFullSummary(action string, err error) bool {
845+ return err != nil || action == actionGetStatus || action == actionReplacePlan || action == actionClear
846+}
847+
848+func buildToolResultText(action string, state PlanState, headline string, err error) string {
849+ summary := buildCompactSummary(state)
850+ if shouldReturnFullSummary(action, err) {
851+ summary = buildSummary(state)
852+ }
853+ if err != nil {
854+ return "Error: " + err.Error() + "\n\n" + summary
855+ }
856+ return headline + "\n\n" + summary
857+}
858+
859+func buildViewLines(state PlanState) []string {
860+ lines := []string{"", " Tasked phases ", ""}
861+ if !hasStoredPlan(state) {
862+ return append(lines,
863+ " No spec or phases stored yet.",
864+ "",
865+ " Ask the agent to create a spec and phased checklist.",
866+ "",
867+ " Press Escape to close",
868+ )
869+ }
870+ if state.Spec != "" {
871+ lines = append(lines, " Spec")
872+ for _, specLine := range strings.Split(state.Spec, "\n") {
873+ lines = append(lines, " "+specLine)
874+ }
875+ lines = append(lines, "")
876+ }
877+ done, total := getPlanProgress(state)
878+ lines = append(lines, fmt.Sprintf(" Progress: %d/%d tasks checked", done, total))
879+ if isPlanClosed(state) {
880+ closedSummary := state.ClosedSummary
881+ if closedSummary == "" {
882+ closedSummary = "all phases complete"
883+ }
884+ lines = append(lines,
885+ " Closed: "+closedSummary,
886+ " New work should create a fresh plan instead of extending this one.",
887+ )
888+ }
889+ lines = append(lines, "")
890+ for _, phase := range state.Phases {
891+ phaseDone, phaseTotal := getPhaseProgress(phase)
892+ marker := "[ ]"
893+ if isPhaseDone(phase) {
894+ marker = "[x]"
895+ }
896+ currentTitle := phase.Title
897+ if phase.ID == state.CurrentPhaseID {
898+ currentTitle = "> " + currentTitle
899+ }
900+ lines = append(lines, fmt.Sprintf(" %s %s [%s] (%d/%d)", marker, currentTitle, phase.ID, phaseDone, phaseTotal))
901+ if phase.Goal != "" {
902+ lines = append(lines, " "+phase.Goal)
903+ }
904+ if len(phase.Tasks) == 0 {
905+ lines = append(lines, " - No tasks yet")
906+ continue
907+ }
908+ for _, task := range phase.Tasks {
909+ taskMarker := "[ ]"
910+ if task.Checked {
911+ taskMarker = "[x]"
912+ }
913+ lines = append(lines, fmt.Sprintf(" %s %s [%s]", taskMarker, task.Text, task.ID))
914+ }
915+ lines = append(lines, "")
916+ }
917+ return append(lines, " Press Escape to close")
918+}
919+
920+type stateStore struct {
921+ mu sync.Mutex
922+ state PlanState
923+ path string
924+ loaded bool
925+ loadErr error
926+}
927+
928+func newStateStore() *stateStore {
929+ return &stateStore{state: createEmptyState()}
930+}
931+
932+func projectStatePath(dataDir, cwd string) string {
933+ key := sha256.Sum256([]byte(cwd))
934+ return filepath.Join(dataDir, "projects", hex.EncodeToString(key[:])+".json")
935+}
936+
937+func (s *stateStore) load(dataDir, cwd string) error {
938+ s.mu.Lock()
939+ defer s.mu.Unlock()
940+ if s.loaded {
941+ return s.loadErr
942+ }
943+ s.loaded = true
944+ s.state = createEmptyState()
945+ if strings.TrimSpace(dataDir) == "" {
946+ s.loadErr = errors.New("host did not provide extension data_dir")
947+ return s.loadErr
948+ }
949+ s.path = projectStatePath(dataDir, cwd)
950+ if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
951+ s.loadErr = fmt.Errorf("create state directory: %w", err)
952+ return s.loadErr
953+ }
954+ if err := os.Chmod(filepath.Dir(s.path), 0o700); err != nil {
955+ s.loadErr = fmt.Errorf("restrict state directory: %w", err)
956+ return s.loadErr
957+ }
958+ data, err := os.ReadFile(s.path)
959+ if os.IsNotExist(err) {
960+ return nil
961+ }
962+ if err != nil {
963+ s.loadErr = fmt.Errorf("read state: %w", err)
964+ return s.loadErr
965+ }
966+ if err := os.Chmod(s.path, 0o600); err != nil {
967+ s.loadErr = fmt.Errorf("restrict state file: %w", err)
968+ return s.loadErr
969+ }
970+ if len(data) == 0 {
971+ return nil
972+ }
973+ var state PlanState
974+ if err := json.Unmarshal(data, &state); err != nil {
975+ s.loadErr = fmt.Errorf("parse state: %w", err)
976+ return s.loadErr
977+ }
978+ s.state = ensureState(state)
979+ return nil
980+}
981+
982+// activateSession replaces the project fallback with the opaque snapshot
983+// supplied by zot for the active session branch. An empty snapshot means the
984+// branch has no extension state yet and therefore starts clean.
985+func (s *stateStore) activateSession(raw json.RawMessage) error {
986+ s.mu.Lock()
987+ defer s.mu.Unlock()
988+ if !s.loaded {
989+ return errors.New("state has not been loaded from hello")
990+ }
991+ if s.path == "" && s.loadErr != nil {
992+ return s.loadErr
993+ }
994+ if len(raw) == 0 || strings.TrimSpace(string(raw)) == "null" {
995+ s.state = createEmptyState()
996+ s.loadErr = nil
997+ return nil
998+ }
999+ var state PlanState
1000+ if err := json.Unmarshal(raw, &state); err != nil {
1001+ return fmt.Errorf("parse session state: %w", err)
1002+ }
1003+ s.state = ensureState(state)
1004+ s.loadErr = nil
1005+ return nil
1006+}
1007+
1008+func (s *stateStore) snapshot() (PlanState, error) {
1009+ s.mu.Lock()
1010+ defer s.mu.Unlock()
1011+ if !s.loaded {
1012+ return PlanState{}, errors.New("state has not been loaded from hello")
1013+ }
1014+ if s.loadErr != nil {
1015+ return cloneState(s.state), s.loadErr
1016+ }
1017+ return cloneState(s.state), nil
1018+}
1019+
1020+func (s *stateStore) transact(fn func(PlanState) (PlanState, string, error)) (PlanState, string, error) {
1021+ s.mu.Lock()
1022+ defer s.mu.Unlock()
1023+ if !s.loaded {
1024+ return PlanState{}, "", errors.New("state has not been loaded from hello")
1025+ }
1026+ if s.loadErr != nil {
1027+ return cloneState(s.state), "", s.loadErr
1028+ }
1029+ current := cloneState(s.state)
1030+ next, headline, err := fn(current)
1031+ if err != nil {
1032+ return current, headline, err
1033+ }
1034+ next = ensureState(next)
1035+ if err := writeJSONAtomic(s.path, next); err != nil {
1036+ return current, headline, fmt.Errorf("persist state: %w", err)
1037+ }
1038+ s.state = next
1039+ return cloneState(next), headline, nil
1040+}
1041+
1042+func writeJSONAtomic(path string, value any) error {
1043+ if strings.TrimSpace(path) == "" {
1044+ return errors.New("state path is empty")
1045+ }
1046+ data, err := json.MarshalIndent(value, "", " ")
1047+ if err != nil {
1048+ return err
1049+ }
1050+ data = append(data, '\n')
1051+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
1052+ return err
1053+ }
1054+ tmp, err := os.CreateTemp(filepath.Dir(path), ".tasked-phases-*.tmp")
1055+ if err != nil {
1056+ return err
1057+ }
1058+ tmpName := tmp.Name()
1059+ defer os.Remove(tmpName)
1060+ if err := tmp.Chmod(0o600); err != nil {
1061+ _ = tmp.Close()
1062+ return err
1063+ }
1064+ if _, err := tmp.Write(data); err != nil {
1065+ _ = tmp.Close()
1066+ return err
1067+ }
1068+ if err := tmp.Sync(); err != nil {
1069+ _ = tmp.Close()
1070+ return err
1071+ }
1072+ if err := tmp.Close(); err != nil {
1073+ return err
1074+ }
1075+ if err := os.Rename(tmpName, path); err != nil {
1076+ return err
1077+ }
1078+ return nil
1079+}
A examples/extensions/tasked-phases/state_test.go +390 -0
@@ -0,0 +1,390 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "strings"
9+ "sync"
10+ "testing"
11+)
12+
13+func stringPtr(value string) *string { return &value }
14+func boolPtr(value bool) *bool { return &value }
15+
16+func transition(t *testing.T, state PlanState, params toolArgs) PlanState {
17+ t.Helper()
18+ next, _, err := applyAction(ensureState(state), params)
19+ if err != nil {
20+ t.Fatalf("apply %s: %v", params.Action, err)
21+ }
22+ return ensureState(next)
23+}
24+
25+func sampleState() PlanState {
26+ return ensureState(PlanState{
27+ Version: 1,
28+ Spec: "Ship compact tasked phases output without losing source-of-truth state.",
29+ Phases: []Phase{
30+ {
31+ ID: "phase-1",
32+ Title: "Finished discovery",
33+ Goal: "Understand the old behavior",
34+ Tasks: []PhaseTask{
35+ {ID: "task-1", Text: "Completed discovery task", Checked: true},
36+ {ID: "task-2", Text: "Completed contract task", Checked: true},
37+ },
38+ },
39+ {
40+ ID: "phase-2",
41+ Title: "Implement compact output",
42+ Goal: "Reduce repeated context",
43+ Tasks: []PhaseTask{
44+ {ID: "task-3", Text: "Completed implementation task", Checked: true},
45+ {ID: "task-4", Text: "Remaining implementation task", Checked: false},
46+ },
47+ },
48+ },
49+ CurrentPhaseID: "phase-2",
50+ NextPhaseNumber: 3,
51+ NextTaskNumber: 5,
52+ })
53+}
54+
55+func TestTransitionsGenerateIDsAndSelectCurrentPhase(t *testing.T) {
56+ state := transition(t, createEmptyState(), toolArgs{
57+ Action: actionReplacePlan,
58+ Phases: &[]PhaseInput{
59+ {Title: "First", Tasks: &[]TaskInput{{Text: "one"}}},
60+ {Title: "Second", Tasks: &[]TaskInput{{Text: "two"}}},
61+ },
62+ })
63+ if got := state.Phases[0].ID; got != "phase-1" {
64+ t.Fatalf("first generated phase id = %q", got)
65+ }
66+ if got := state.Phases[1].ID; got != "phase-2" {
67+ t.Fatalf("second generated phase id = %q", got)
68+ }
69+ if got := state.Phases[0].Tasks[0].ID; got != "task-1" {
70+ t.Fatalf("first generated task id = %q", got)
71+ }
72+ if state.CurrentPhaseID != "phase-1" || state.NextPhaseNumber != 3 || state.NextTaskNumber != 3 {
73+ t.Fatalf("generated counters/current phase = %q, %d, %d", state.CurrentPhaseID, state.NextPhaseNumber, state.NextTaskNumber)
74+ }
75+
76+ state = transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(true)})
77+ if state.CurrentPhaseID != "phase-2" {
78+ t.Fatalf("current phase did not advance to the first incomplete phase: %q", state.CurrentPhaseID)
79+ }
80+ state = transition(t, state, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr("Third")})
81+ if got := state.Phases[2].ID; got != "phase-3" {
82+ t.Fatalf("added phase id = %q", got)
83+ }
84+ state = transition(t, state, toolArgs{Action: actionAddTask, PhaseID: "phase-3", TaskText: stringPtr("three")})
85+ if got := state.Phases[2].Tasks[0].ID; got != "task-3" {
86+ t.Fatalf("added task id = %q", got)
87+ }
88+}
89+
90+func TestReplacePlanRejectsDuplicateIDs(t *testing.T) {
91+ state := createEmptyState()
92+ _, _, err := applyAction(state, toolArgs{
93+ Action: actionReplacePlan,
94+ Phases: &[]PhaseInput{
95+ {ID: stringPtr("phase-1"), Title: "First"},
96+ {ID: stringPtr("phase-1"), Title: "Duplicate"},
97+ },
98+ })
99+ if err == nil || !strings.Contains(err.Error(), "duplicate phase id") {
100+ t.Fatalf("duplicate phase id error = %v", err)
101+ }
102+
103+ _, _, err = applyAction(state, toolArgs{
104+ Action: actionReplacePlan,
105+ Phases: &[]PhaseInput{
106+ {Title: "First", Tasks: &[]TaskInput{{ID: stringPtr("task-1"), Text: "one"}}},
107+ {Title: "Second", Tasks: &[]TaskInput{{ID: stringPtr("task-1"), Text: "duplicate"}}},
108+ },
109+ })
110+ if err == nil || !strings.Contains(err.Error(), "duplicate task id") {
111+ t.Fatalf("duplicate task id error = %v", err)
112+ }
113+}
114+
115+func TestClosedPlanRulesAndReopen(t *testing.T) {
116+ state := transition(t, createEmptyState(), toolArgs{
117+ Action: actionReplacePlan,
118+ Phases: &[]PhaseInput{{Title: "Done", Tasks: &[]TaskInput{{Text: "finished", Checked: boolPtr(true)}}}},
119+ })
120+ if !isPlanClosed(state) || state.CurrentPhaseID != "" {
121+ t.Fatalf("complete plan was not closed: %+v", state)
122+ }
123+ if !strings.Contains(buildSummary(state), "Plan closed: Completed 1/1 tasks across 1 phase(s).") {
124+ t.Fatalf("closed summary missing: %s", buildSummary(state))
125+ }
126+
127+ if _, _, err := applyAction(state, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr("new")}); err == nil || !strings.Contains(err.Error(), "already complete and closed") {
128+ t.Fatalf("add to closed plan error = %v", err)
129+ }
130+ if _, _, err := applyAction(state, toolArgs{Action: actionReplacePlan, Phases: &[]PhaseInput{}}); err == nil {
131+ t.Fatal("replace_plan unexpectedly bypassed closed-plan guard")
132+ }
133+
134+ state = transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(false)})
135+ if isPlanClosed(state) || state.CurrentPhaseID != "phase-1" || state.Phases[0].Tasks[0].Checked {
136+ t.Fatalf("unchecked reopen produced %+v", state)
137+ }
138+
139+ state = transition(t, state, toolArgs{Action: actionSetSpec, Spec: stringPtr("new work")})
140+ if len(state.Phases) != 1 {
141+ t.Fatalf("set_spec on reopened plan unexpectedly cleared phases")
142+ }
143+ closed := transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(true)})
144+ if !isPlanClosed(closed) {
145+ t.Fatal("plan did not close again")
146+ }
147+ state = transition(t, closed, toolArgs{Action: actionSetSpec, Spec: stringPtr("fresh work")})
148+ if len(state.Phases) != 0 || state.Spec != "fresh work" || state.NextPhaseNumber != 1 || state.NextTaskNumber != 1 {
149+ t.Fatalf("set_spec did not restart a closed plan: %+v", state)
150+ }
151+ state = transition(t, state, toolArgs{Action: actionReplacePlan, Phases: &[]PhaseInput{{Title: "Fresh"}}})
152+ if len(state.Phases) != 1 || isPlanClosed(state) {
153+ t.Fatalf("replace after set_spec failed: %+v", state)
154+ }
155+}
156+
157+func TestPhaseLookupAcceptsRawBracketedSummaryAndUniqueTitle(t *testing.T) {
158+ state := sampleState()
159+ for _, input := range []string{
160+ "phase-2",
161+ "[phase-2]",
162+ "Implement compact output [phase-2]",
163+ "Implement compact output [phase-2] (1 remaining) - Reduce repeated context",
164+ "Implement compact output",
165+ } {
166+ if phase := findPhase(state, input); phase == nil || phase.ID != "phase-2" {
167+ t.Fatalf("findPhase(%q) = %#v", input, phase)
168+ }
169+ }
170+ if findPhase(state, "missing") != nil || findPhase(state, "") != nil {
171+ t.Fatal("missing phase lookup unexpectedly matched")
172+ }
173+ duplicate := cloneState(state)
174+ duplicate.Phases[0].Title = "Duplicate"
175+ duplicate.Phases[1].Title = "Duplicate"
176+ if findPhase(duplicate, "Duplicate") != nil {
177+ t.Fatal("ambiguous title unexpectedly matched")
178+ }
179+ if phase := findPhase(duplicate, "phase-2"); phase == nil || phase.ID != "phase-2" {
180+ t.Fatal("raw id did not win over duplicate title")
181+ }
182+}
183+
184+func TestSummariesAreFullOrCompactAsAppropriate(t *testing.T) {
185+ state := sampleState()
186+ compact := buildToolResultText(actionSetTaskChecked, state, "Checked task task-3", nil)
187+ if !strings.Contains(compact, "Progress: 3/4 tasks checked") || !strings.Contains(compact, "[ ] Remaining implementation task [task-4]") {
188+ t.Fatalf("compact summary omitted useful active state: %s", compact)
189+ }
190+ if strings.Contains(compact, "Phases:") || strings.Contains(compact, "Completed discovery task") {
191+ t.Fatalf("compact summary included full checklist: %s", compact)
192+ }
193+ full := buildToolResultText(actionGetStatus, state, "Current phased plan status", nil)
194+ if !strings.Contains(full, "Phases:") || !strings.Contains(full, "Completed discovery task [task-1]") {
195+ t.Fatalf("full summary omitted checklist: %s", full)
196+ }
197+ if !strings.Contains(buildSummary(state), "[phase-2] (1/2) - Reduce repeated context") {
198+ t.Fatal("phase goal/progress missing from full summary")
199+ }
200+}
201+
202+func TestTurnContextContainsOnlyCurrentFocus(t *testing.T) {
203+ state := sampleState()
204+ state.Phases[1].Tasks = append(state.Phases[1].Tasks,
205+ PhaseTask{ID: "task-5", Text: "Next focused task"},
206+ PhaseTask{ID: "task-6", Text: "Another focused task"},
207+ PhaseTask{ID: "task-7", Text: "Deferred focused task"},
208+ )
209+ state.Phases = append(state.Phases, Phase{
210+ ID: "phase-3",
211+ Title: "Future phase",
212+ Tasks: []PhaseTask{{ID: "task-8", Text: "Future task"}},
213+ })
214+ context := buildTurnContext(state)
215+ for _, want := range []string{
216+ "Tasked phases focus:",
217+ "Current phase: Implement compact output [phase-2]",
218+ "Goal: Reduce repeated context",
219+ "[ ] Remaining implementation task [task-4]",
220+ "[ ] Next focused task [task-5]",
221+ "[ ] Another focused task [task-6]",
222+ } {
223+ if !strings.Contains(context, want) {
224+ t.Errorf("turn context missing %q: %s", want, context)
225+ }
226+ }
227+ for _, unwanted := range []string{
228+ "Ship compact tasked phases output",
229+ "Finished discovery",
230+ "Completed discovery task",
231+ "Future phase",
232+ "Future task",
233+ "Deferred focused task [task-7]",
234+ } {
235+ if strings.Contains(context, unwanted) {
236+ t.Errorf("turn context included %q: %s", unwanted, context)
237+ }
238+ }
239+}
240+
241+func TestTurnContextIsBoundedForUntrustedIdentifiers(t *testing.T) {
242+ state := sampleState()
243+ state.Phases[1].ID = strings.Repeat("phase-id-", 1000)
244+ state.CurrentPhaseID = state.Phases[1].ID
245+ state.Phases[1].Tasks[0].ID = strings.Repeat("task-id-", 1000)
246+
247+ context := buildTurnContext(state)
248+ if got := len([]rune(context)); got > maxTurnContextChars {
249+ t.Fatalf("turn context length = %d; want at most %d", got, maxTurnContextChars)
250+ }
251+ if !strings.Contains(context, "Tasked phases focus:") || !strings.Contains(context, "Current phase:") || !strings.Contains(context, "Goal: Reduce repeated context") {
252+ t.Fatalf("bounded turn context lost its useful focus: %q", context)
253+ }
254+}
255+
256+func TestPanelRendersSpecGoalsTasksAndEmptyState(t *testing.T) {
257+ empty := strings.Join(buildViewLines(createEmptyState()), "\n")
258+ if !strings.Contains(empty, "No spec or phases stored yet.") || !strings.Contains(empty, "Press Escape to close") {
259+ t.Fatalf("empty panel = %s", empty)
260+ }
261+ panel := strings.Join(buildViewLines(sampleState()), "\n")
262+ for _, want := range []string{
263+ "Tasked phases",
264+ "Spec",
265+ "Progress: 3/4 tasks checked",
266+ "[x] Completed discovery task [task-1]",
267+ "[ ] Remaining implementation task [task-4]",
268+ "Understand the old behavior",
269+ "Press Escape to close",
270+ } {
271+ if !strings.Contains(panel, want) {
272+ t.Errorf("panel missing %q: %s", want, panel)
273+ }
274+ }
275+}
276+
277+func TestStatePersistenceIsProjectScopedAtomicAndRestrictive(t *testing.T) {
278+ dataDir := t.TempDir()
279+ first := newStateStore()
280+ if err := first.load(dataDir, "/projects/one"); err != nil {
281+ t.Fatal(err)
282+ }
283+ state, _, err := first.transact(func(current PlanState) (PlanState, string, error) {
284+ return applyAction(current, toolArgs{
285+ Action: actionReplacePlan,
286+ Phases: &[]PhaseInput{{Title: "Persisted", Tasks: &[]TaskInput{{Text: "remember me"}}}},
287+ })
288+ })
289+ if err != nil {
290+ t.Fatal(err)
291+ }
292+ if state.Phases[0].ID != "phase-1" {
293+ t.Fatalf("persisted state = %+v", state)
294+ }
295+ if runtime.GOOS != "windows" {
296+ info, err := os.Stat(first.path)
297+ if err != nil {
298+ t.Fatal(err)
299+ }
300+ if got := info.Mode().Perm(); got != 0o600 {
301+ t.Fatalf("state file mode = %o, want 600", got)
302+ }
303+ if got := (mustStat(t, filepath.Dir(first.path))).Mode().Perm(); got != 0o700 {
304+ t.Fatalf("state directory mode = %o, want 700", got)
305+ }
306+ }
307+
308+ second := newStateStore()
309+ if err := second.load(dataDir, "/projects/one"); err != nil {
310+ t.Fatal(err)
311+ }
312+ loaded, err := second.snapshot()
313+ if err != nil {
314+ t.Fatal(err)
315+ }
316+ if loaded.Phases[0].Tasks[0].Text != "remember me" {
317+ t.Fatalf("loaded state = %+v", loaded)
318+ }
319+ otherProject := newStateStore()
320+ if err := otherProject.load(dataDir, "/projects/two"); err != nil {
321+ t.Fatal(err)
322+ }
323+ other, err := otherProject.snapshot()
324+ if err != nil {
325+ t.Fatal(err)
326+ }
327+ if hasStoredPlan(other) {
328+ t.Fatal("state leaked between project CWDs")
329+ }
330+ files, err := os.ReadDir(filepath.Dir(first.path))
331+ if err != nil {
332+ t.Fatal(err)
333+ }
334+ for _, file := range files {
335+ if strings.HasSuffix(file.Name(), ".tmp") {
336+ t.Fatalf("temporary file left behind: %s", file.Name())
337+ }
338+ }
339+
340+ var decoded PlanState
341+ data, err := os.ReadFile(first.path)
342+ if err != nil {
343+ t.Fatal(err)
344+ }
345+ if err := json.Unmarshal(data, &decoded); err != nil {
346+ t.Fatal(err)
347+ }
348+ if decoded.Phases[0].Tasks[0].ID != "task-1" {
349+ t.Fatalf("on-disk state = %+v", decoded)
350+ }
351+}
352+
353+func TestStateStoreSerializesConcurrentTransitions(t *testing.T) {
354+ store := newStateStore()
355+ if err := store.load(t.TempDir(), "/projects/concurrent"); err != nil {
356+ t.Fatal(err)
357+ }
358+ const count = 12
359+ var wg sync.WaitGroup
360+ for i := 0; i < count; i++ {
361+ wg.Add(1)
362+ go func(i int) {
363+ defer wg.Done()
364+ title := "phase " + string(rune('a'+i))
365+ _, _, err := store.transact(func(current PlanState) (PlanState, string, error) {
366+ return applyAction(current, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr(title)})
367+ })
368+ if err != nil {
369+ t.Errorf("concurrent transition: %v", err)
370+ }
371+ }(i)
372+ }
373+ wg.Wait()
374+ state, err := store.snapshot()
375+ if err != nil {
376+ t.Fatal(err)
377+ }
378+ if len(state.Phases) != count {
379+ t.Fatalf("serialized phase count = %d, want %d", len(state.Phases), count)
380+ }
381+}
382+
383+func mustStat(t *testing.T, path string) os.FileInfo {
384+ t.Helper()
385+ info, err := os.Stat(path)
386+ if err != nil {
387+ t.Fatal(err)
388+ }
389+ return info
390+}
A examples/skills/tasked-phases/SKILL.md +33 -0
@@ -0,0 +1,33 @@
1+---
2+name: tasked-phases
3+description: Use when the user wants a spec turned into phased work with checklist tasks tracked by the tasked_phases tool, or when ongoing work should be tracked phase by phase.
4+---
5+
6+# Tasked Phases
7+
8+Use the `tasked_phases` tool as the source of truth for the spec, phases, current focus, and checklist progress.
9+
10+## Workflow
11+
12+1. Clarify missing requirements when the spec is ambiguous.
13+2. Save the accepted spec with `tasked_phases` using `set_spec`.
14+3. Create or materially revise the plan with `replace_plan`.
15+4. Keep phases small and tasks concrete enough to check off.
16+5. Set the focus with `set_current_phase` and pass the target `phaseId`.
17+6. Call `set_task_checked` immediately after each checklist task is completed.
18+7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task.
19+8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` before creating a new plan.
20+
21+## Planning rules
22+
23+- Prefer 3–7 phases unless the task is tiny.
24+- Each task should describe an observable outcome.
25+- Avoid vague tasks such as “work on this” or “finish implementation”.
26+- Keep the stored spec and plan concise and operational.
27+
28+## Tool guidance
29+
30+- Use `get_status` before relying on remembered state or revising a long-running plan.
31+- Use `replace_plan` for a substantial restructuring; use `add_*`, `update_*`, and `remove_*` for small changes.
32+- Pass `phaseId` as the raw ID shown in brackets, without the brackets. The tool also accepts a complete phase label or a unique phase title when recovering from copied output.
33+- Do not rely on prose alone for completion state; update the tool after each completed task.
M packages/agent/build.go +72 -10
@@ -66,6 +66,7 @@ type Resolved struct {
6666 // resolve.
6767 systemAppend []string
6868 systemCustom string
69+ skillAddendum string
6970 toolDescriptions map[string]string
7071 }
7172
@@ -83,33 +84,87 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
8384 if mgr == nil {
8485 return
8586 }
86- infos := mgr.Tools()
87- if len(infos) == 0 {
88- return
89- }
9087 changed := false
91- for _, info := range infos {
88+ for _, info := range mgr.Tools() {
9289 if _, exists := r.ToolRegistry[info.Name]; exists {
9390 continue
9491 }
9592 r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
9693 changed = true
9794 }
95+
96+ // A nil SkillTool means skill discovery was explicitly disabled (for
97+ // example --no-skill or a profile with inheritSkills=false). Bundled
98+ // extension skills must honor that same clean-room switch.
99+ if source, ok := mgr.(ExtensionSkillSource); ok && r.SkillTool != nil {
100+ if bundled := source.Skills(); len(bundled) > 0 {
101+ merged := mergeExtensionSkills(r.SkillTool, bundled)
102+ r.SkillTool.SetSkills(merged)
103+ append_ := append([]string(nil), r.systemAppend...)
104+ if r.skillAddendum != "" {
105+ filtered := append_[:0]
106+ for _, item := range append_ {
107+ if item != r.skillAddendum {
108+ filtered = append(filtered, item)
109+ }
110+ }
111+ append_ = filtered
112+ }
113+ r.skillAddendum = skills.SystemPromptAddendum(merged)
114+ if r.skillAddendum != "" {
115+ append_ = append(append_, r.skillAddendum)
116+ }
117+ r.systemAppend = append_
118+ changed = true
119+ }
120+ }
98121 if !changed {
99122 return
100123 }
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
124+ // Re-render the system prompt with the merged tool and skill lists.
125+ r.ToolSummary = toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions)
104126 r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
105127 CWD: r.CWD,
106- Tools: toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
128+ Tools: r.ToolSummary,
107129 Custom: r.systemCustom,
108- Append: append_,
130+ Append: r.systemAppend,
109131 ZotDocsDir: filepath.Join(ZotHome(), "docs"),
110132 })
111133 }
112134
135+func mergeExtensionSkills(tool *skills.Tool, bundled []*skills.Skill) []*skills.Skill {
136+ var existing []*skills.Skill
137+ if tool != nil {
138+ existing = tool.Skills()
139+ }
140+ merged := make([]*skills.Skill, 0, len(existing)+len(bundled))
141+ seen := map[string]bool{}
142+ add := func(list []*skills.Skill) {
143+ for _, skill := range list {
144+ if skill == nil || skill.Name == "" || seen[skill.Name] {
145+ continue
146+ }
147+ seen[skill.Name] = true
148+ merged = append(merged, skill)
149+ }
150+ }
151+ var user, builtins []*skills.Skill
152+ for _, skill := range existing {
153+ if skill == nil || strings.HasPrefix(skill.Source, "extension ") {
154+ continue
155+ }
156+ if skill.Builtin {
157+ builtins = append(builtins, skill)
158+ } else {
159+ user = append(user, skill)
160+ }
161+ }
162+ add(user)
163+ add(bundled)
164+ add(builtins)
165+ return merged
166+}
167+
113168 // ExtensionToolSource is the slice of the extension manager that
114169 // MergeExtensionTools needs. Lives here as an interface so the
115170 // build package doesn't import packages/agent/extensions (which
@@ -119,6 +174,12 @@ type ExtensionToolSource interface {
119174 NewExtensionTool(info ExtensionToolInfo) core.Tool
120175 }
121176
177+// ExtensionSkillSource exposes the optional bundled-skill surface without
178+// forcing older ExtensionToolSource implementations to add it.
179+type ExtensionSkillSource interface {
180+ Skills() []*skills.Skill
181+}
182+
122183 // ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
123184 // ExtensionToolSource here without importing the extensions
124185 // package. The cli wires a tiny adapter to bridge them.
@@ -731,6 +792,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
731792 ContextFiles: contextFiles,
732793 systemAppend: append_,
733794 systemCustom: custom,
795+ skillAddendum: skillAddendum,
734796 toolDescriptions: descMapFromSummaries(summaries),
735797 }, nil
736798 }
M packages/agent/cli.go +127 -16
@@ -131,6 +131,21 @@ func (h *interactiveExtHooks) ClosePanel(extName, panelID string) {
131131 iv.ClosePanel(extName, panelID)
132132 }
133133 }
134+func (h *interactiveExtHooks) SetStatus(extName, key, level, text string) {
135+ if iv := h.iv(); iv != nil {
136+ iv.SetStatus(extName, key, level, text)
137+ }
138+}
139+func (h *interactiveExtHooks) SetWidget(extName, id, position, title string, lines []string) {
140+ if iv := h.iv(); iv != nil {
141+ iv.SetWidget(extName, id, position, title, lines)
142+ }
143+}
144+func (h *interactiveExtHooks) ClearWidget(extName, id string) {
145+ if iv := h.iv(); iv != nil {
146+ iv.ClearWidget(extName, id)
147+ }
148+}
134149
135150 // extToolAdapter bridges *extensions.Manager to the
136151 // ExtensionToolSource interface declared in build.go (kept narrow to
@@ -141,6 +156,16 @@ type extToolAdapter struct {
141156 mgr *extensions.Manager
142157 }
143158
159+var _ ExtensionToolSource = (*extToolAdapter)(nil)
160+var _ ExtensionSkillSource = (*extToolAdapter)(nil)
161+
162+func (a *extToolAdapter) Skills() []*skills.Skill {
163+ if a == nil || a.mgr == nil {
164+ return nil
165+ }
166+ return a.mgr.Skills()
167+}
168+
144169 func (a *extToolAdapter) Tools() []ExtensionToolInfo {
145170 infos := a.mgr.Tools()
146171 out := make([]ExtensionToolInfo, len(infos))
@@ -436,6 +461,61 @@ func applyInitialSessionResume(ctx context.Context, args Args, base Resolved, ex
436461 // the events that have a clear extension-facing meaning are
437462 // forwarded; internal-only ones (text_delta, tool_progress) are
438463 // dropped to keep the per-extension stream sane.
464+func sessionContext(sess *core.Session) *extproto.SessionContext {
465+ if sess == nil {
466+ return nil
467+ }
468+ return &extproto.SessionContext{
469+ ID: sess.Meta.ID,
470+ ParentID: sess.Meta.Parent,
471+ Path: sess.Path,
472+ CWD: sess.Meta.CWD,
473+ ForkPoint: sess.Meta.ForkPoint,
474+ }
475+}
476+
477+func announceSession(mgr *extensions.Manager, sess *core.Session) {
478+ if mgr == nil {
479+ return
480+ }
481+ var states map[string]json.RawMessage
482+ if sess != nil {
483+ states = copyExtensionStates(sess.ExtensionState)
484+ }
485+ mgr.EmitSessionEvent("session_start", sessionContext(sess), states)
486+ if sess != nil {
487+ mgr.EmitSessionEvent("session_opened", sessionContext(sess), states)
488+ }
489+}
490+
491+func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawMessage {
492+ if len(states) == 0 {
493+ return nil
494+ }
495+ out := make(map[string]json.RawMessage, len(states))
496+ for name, state := range states {
497+ out[name] = append(json.RawMessage(nil), state...)
498+ }
499+ return out
500+}
501+
502+func persistExtensionToolResult(mgr *extensions.Manager, sess *core.Session, result core.ToolResult) {
503+ if sess == nil {
504+ return
505+ }
506+ details, ok := result.Details.(extensions.ToolResultDetails)
507+ if !ok || details.Extension == "" || len(details.State) == 0 {
508+ return
509+ }
510+ if err := sess.AppendExtensionState(details.Extension, details.State); err != nil {
511+ fmt.Fprintf(os.Stderr, "extension state: %v\n", err)
512+ return
513+ }
514+ if mgr != nil {
515+ mgr.UpdateSessionState(details.Extension, details.State)
516+ }
517+}
518+
439519 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
440520 if mgr == nil {
441521 return
@@ -557,6 +637,9 @@ func (nonInteractiveExtHooks) ClearNotes(string)
557637 func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
558638 func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
559639 func (nonInteractiveExtHooks) ClosePanel(string, string) {}
640+func (nonInteractiveExtHooks) SetStatus(string, string, string, string) {}
641+func (nonInteractiveExtHooks) SetWidget(string, string, string, string, []string) {}
642+func (nonInteractiveExtHooks) ClearWidget(string, string) {}
560643
561644 // setupNonInteractiveExtensions loads --ext paths and (unless
562645 // --no-ext) runs discovery. Returns the manager so the caller can
@@ -574,7 +657,6 @@ func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved,
574657 }
575658 extMgr.WaitForReady(3 * time.Second)
576659 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
577- extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
578660 return extMgr, func() { extMgr.Stop(2 * time.Second) }
579661 }
580662
@@ -593,9 +675,9 @@ func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr
593675 }
594676 return true, "", res.ModifiedArgs
595677 }
596- ag.BeforeTurn = func(step int) (bool, string) {
597- res := extMgr.InterceptTurnStart(ctx, step)
598- return !res.Block, res.Reason
678+ ag.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
679+ res := extMgr.InterceptTurnStart(turnCtx, step)
680+ return !res.Block, res.Reason, res.Context
599681 }
600682 ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
601683 res := extMgr.InterceptAssistantMessage(ctx, text)
@@ -677,8 +759,10 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
677759 return err
678760 }
679761 r.Provider, r.Model = providerName, model
762+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
680763 defer sess.Close()
681764 }
765+ announceSession(extMgr, sess)
682766
683767 prompt := args.Prompt
684768 if prompt == "" {
@@ -738,8 +822,10 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
738822 return err
739823 }
740824 r.Provider, r.Model = providerName, model
825+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
741826 defer sess.Close()
742827 }
828+ announceSession(extMgr, sess)
743829
744830 prompt := args.Prompt
745831 if prompt == "" {
@@ -838,8 +924,10 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
838924 return err
839925 }
840926 r.Provider, r.Model = providerName, model
927+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
841928 defer sess.Close()
842929 }
930+ announceSession(extMgr, sess)
843931
844932 prompt := args.Prompt
845933 if prompt == "" {
@@ -1170,9 +1258,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11701258 }
11711259 return true, "", r.ModifiedArgs
11721260 }
1173- a.BeforeTurn = func(step int) (bool, string) {
1174- r := extMgr.InterceptTurnStart(ctx, step)
1175- return !r.Block, r.Reason
1261+ a.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
1262+ r := extMgr.InterceptTurnStart(turnCtx, step)
1263+ return !r.Block, r.Reason, r.Context
11761264 }
11771265 a.BeforeAssistantMessage = func(text string) (bool, string, string) {
11781266 r := extMgr.InterceptAssistantMessage(ctx, text)
@@ -1261,9 +1349,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12611349 refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
12621350 })
12631351
1264- // Fire session_start once we know the manager's running.
1265- extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
1266-
12671352 var sess *core.Session
12681353 var sessBaselineMsgs int // messages already on disk when current session opened
12691354 var sessionTitlePending bool
@@ -1296,6 +1381,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12961381 sessionTitlePending = sess.Meta.Parent != "" && sess.Title == "" && len(ag.Messages()) <= sess.Meta.ForkPoint
12971382 }
12981383 }
1384+ announceSession(extMgr, sess)
12991385 defer func() {
13001386 persistMu.Lock()
13011387 defer persistMu.Unlock()
@@ -1334,16 +1420,27 @@ func runInteractive(ctx context.Context, args Args, version string) error {
13341420 }
13351421 persistCompaction := func(messages []provider.Message) {
13361422 sessionTransitionMu.RLock()
1337- defer sessionTransitionMu.RUnlock()
1423+ var session *extproto.SessionContext
1424+ var states map[string]json.RawMessage
13381425 persistMu.Lock()
1339- defer persistMu.Unlock()
1340- if sess == nil {
1341- return
1426+ if sess != nil {
1427+ if err := sess.AppendCompaction(messages); err == nil {
1428+ sessBaselineMsgs = len(messages)
1429+ session = sessionContext(sess)
1430+ states = copyExtensionStates(sess.ExtensionState)
1431+ }
13421432 }
1343- if err := sess.AppendCompaction(messages); err == nil {
1344- sessBaselineMsgs = len(messages)
1433+ persistMu.Unlock()
1434+ sessionTransitionMu.RUnlock()
1435+ if session != nil {
1436+ extMgr.EmitSessionEvent("session_compacted", session, states)
13451437 }
13461438 }
1439+ persistToolResult := func(_ string, result core.ToolResult) {
1440+ persistMu.Lock()
1441+ defer persistMu.Unlock()
1442+ persistExtensionToolResult(extMgr, sess, result)
1443+ }
13471444 wireAgentPersist := func(a *core.Agent) *core.Agent {
13481445 if a == nil {
13491446 return a
@@ -1351,6 +1448,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
13511448 a.OnMessageAppended = persistMessage
13521449 a.OnUsage = persistUsage
13531450 a.OnTranscriptCompacted = persistCompaction
1451+ a.OnToolResult = persistToolResult
13541452 return a
13551453 }
13561454 wireAgentPersist(ag)
@@ -1443,6 +1541,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14431541 persistMu.Unlock()
14441542 return fmt.Errorf("session changed while loading; please try again")
14451543 }
1544+ newStates := copyExtensionStates(candidate.session.ExtensionState)
14461545 if oldSess != nil {
14471546 _ = oldSess.Close()
14481547 }
@@ -1477,6 +1576,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14771576 }
14781577 committed = true
14791578 persistMu.Unlock()
1579+ if candidate.session.Meta.Parent != "" {
1580+ extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
1581+ }
1582+ extMgr.EmitSessionEvent("session_switched", sessionContext(candidate.session), newStates)
14801583 return nil
14811584 }
14821585
@@ -1626,12 +1729,16 @@ func runInteractive(ctx context.Context, args Args, version string) error {
16261729
16271730 // Commit only after every fallible operation has succeeded. Close
16281731 // the old session last so rollback never has to resurrect it.
1732+ var newStates map[string]json.RawMessage
16291733 persistMu.Lock()
16301734 if sess != nil {
16311735 writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
16321736 _ = sess.Close()
16331737 }
16341738 sess = newSess
1739+ if newSess != nil {
1740+ newStates = copyExtensionStates(newSess.ExtensionState)
1741+ }
16351742 sessBaselineMsgs = 0
16361743 sessionTitlePending = false
16371744 activeProvider = newProvider
@@ -1650,6 +1757,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
16501757 if swarmMgr != nil && newSess != nil {
16511758 swarmMgr.SetActiveSession(newSess.ID)
16521759 }
1760+ if newSess != nil {
1761+ extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
1762+ }
16531763 return nil
16541764 }
16551765
@@ -1906,6 +2016,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
19062016 // manifest and the skill tool.
19072017 userHome, _ := os.UserHomeDir()
19082018 list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)
2019+ list = mergeExtensionSkills(skills.NewTool(list), extMgr.Skills())
19092020 return skills.VisibleSkills(list)
19102021 },
19112022 NoYolo: args.NoYolo,
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 +97 -0
@@ -220,6 +220,103 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
220220 h.hostW.Close()
221221 }
222222
223+func TestPersistentChromeFrames(t *testing.T) {
224+ h := newHarness("chrome-ext")
225+ go h.ext.Run()
226+ h.handshake(t)
227+
228+ h.ext.SetStatus("progress", "2/4 tasks")
229+ status := h.drainUntil(t, "status")
230+ var sf extproto.StatusFromExt
231+ if err := json.Unmarshal(status.raw, &sf); err != nil {
232+ t.Fatal(err)
233+ }
234+ if sf.Key != "progress" || sf.Text != "2/4 tasks" {
235+ t.Fatalf("status = %+v", sf)
236+ }
237+
238+ h.ext.SetWidget("plan", "above_input", "Plan", []string{"one"})
239+ widget := h.drainUntil(t, "widget")
240+ var wf extproto.WidgetFromExt
241+ if err := json.Unmarshal(widget.raw, &wf); err != nil {
242+ t.Fatal(err)
243+ }
244+ if wf.ID != "plan" || wf.Position != "above_input" || wf.Title != "Plan" || len(wf.Lines) != 1 || wf.Lines[0] != "one" {
245+ t.Fatalf("widget = %+v", wf)
246+ }
247+
248+ h.ext.ClearWidget("plan")
249+ clear := h.drainUntil(t, "widget_clear")
250+ var cf extproto.ClearWidgetFromExt
251+ if err := json.Unmarshal(clear.raw, &cf); err != nil {
252+ t.Fatal(err)
253+ }
254+ if cf.ID != "plan" {
255+ t.Fatalf("widget clear = %+v", cf)
256+ }
257+ h.hostW.Close()
258+}
259+
260+func TestToolResultDetailsEmitsOpaqueMetadata(t *testing.T) {
261+ h := newHarness("details-ext")
262+ h.ext.Tool("details", "returns metadata", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult {
263+ return ToolResult{Content: []ToolContent{Text("ok")}, Details: JSONDetails(map[string]any{"state": 1})}
264+ })
265+ go h.ext.Run()
266+ h.handshake(t)
267+ h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "call-1", Name: "details", Args: json.RawMessage(`{}`)})
268+ frame := h.drainUntil(t, "tool_result")
269+ var result extproto.ToolResultFromExt
270+ if err := json.Unmarshal(frame.raw, &result); err != nil {
271+ t.Fatal(err)
272+ }
273+ if string(result.Details) != `{"state":1}` {
274+ t.Fatalf("details = %s", result.Details)
275+ }
276+ h.hostW.Close()
277+}
278+
279+func TestSessionEventCarriesIdentityAndState(t *testing.T) {
280+ h := newHarness("session-ext")
281+ received := make(chan Event, 1)
282+ h.ext.On("session_opened", func(event Event) { received <- event })
283+ go h.ext.Run()
284+ h.handshake(t)
285+ h.sendToExt(t, extproto.EventFromHost{
286+ Type: "event", Event: "session_opened",
287+ Session: &extproto.SessionContext{ID: "branch-1", ParentID: "root-1", Path: "/tmp/session.jsonl", ForkPoint: 4},
288+ State: json.RawMessage(`{"version":1}`),
289+ })
290+ select {
291+ case event := <-received:
292+ if event.Session == nil || event.Session.ID != "branch-1" || event.Session.ParentID != "root-1" || event.Session.Path != "/tmp/session.jsonl" || event.Session.ForkPoint != 4 || string(event.State) != `{"version":1}` {
293+ t.Fatalf("session event = %+v state=%s", event.Session, event.State)
294+ }
295+ case <-time.After(2 * time.Second):
296+ t.Fatal("timeout waiting for session event")
297+ }
298+ h.hostW.Close()
299+}
300+
301+func TestTurnStartContextIsReturned(t *testing.T) {
302+ h := newHarness("context-ext")
303+ h.ext.InterceptTurnStart(func(int) TurnStartDecision {
304+ return TurnStartDecision{Context: "current phase: parse"}
305+ })
306+ go h.ext.Run()
307+ h.handshake(t)
308+ h.sendToExt(t, extproto.EventInterceptFromHost{Type: "event_intercept", ID: "intercept-1", Event: "turn_start", Step: 2})
309+ frame := h.drainUntil(t, "event_intercept_response")
310+ var response extproto.EventInterceptResponseFromExt
311+ if err := json.Unmarshal(frame.raw, &response); err != nil {
312+ t.Fatal(err)
313+ }
314+ if response.ID != "intercept-1" || response.Context != "current phase: parse" || response.Block {
315+ t.Fatalf("intercept response = %+v", response)
316+ }
317+ h.hostW.Close()
318+}
319+
223320 func TestAlertEmitsStructuredFrame(t *testing.T) {
224321 h := newHarness("alert-ext")
225322 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 +208 -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,78 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
9231027 }
9241028 }
9251029
1030+func (ext *Extension) writeFrame(frame []byte) (int, error) {
1031+ if ext == nil || ext.stdin == nil {
1032+ return 0, fmt.Errorf("extension stdin is closed")
1033+ }
1034+ ext.writeMu.Lock()
1035+ defer ext.writeMu.Unlock()
1036+ return ext.stdin.Write(frame)
1037+}
1038+
1039+func (ext *Extension) startLifecycleWriter() {
1040+ if ext.lifecycleFrames == nil {
1041+ ext.lifecycleFrames = make(chan []byte, 64)
1042+ }
1043+ if ext.lifecycleStop == nil {
1044+ ext.lifecycleStop = make(chan struct{})
1045+ }
1046+ go ext.lifecycleWriter()
1047+}
1048+
1049+func (ext *Extension) stopLifecycleWriter() {
1050+ if ext == nil || ext.lifecycleStop == nil {
1051+ return
1052+ }
1053+ ext.lifecycleStopOnce.Do(func() { close(ext.lifecycleStop) })
1054+}
1055+
1056+func (ext *Extension) enqueueLifecycleFrame(frame []byte) bool {
1057+ if ext == nil {
1058+ return false
1059+ }
1060+ if ext.lifecycleFrames == nil || ext.lifecycleStop == nil {
1061+ _, err := ext.writeFrame(frame)
1062+ return err == nil
1063+ }
1064+ copyFrame := append([]byte(nil), frame...)
1065+ select {
1066+ case <-ext.lifecycleStop:
1067+ return false
1068+ default:
1069+ }
1070+ select {
1071+ case ext.lifecycleFrames <- copyFrame:
1072+ return true
1073+ default:
1074+ // Prefer the newest lifecycle snapshot when a broken extension stops
1075+ // draining its pipe. The latest event still carries the current state.
1076+ select {
1077+ case <-ext.lifecycleFrames:
1078+ default:
1079+ }
1080+ select {
1081+ case ext.lifecycleFrames <- copyFrame:
1082+ return true
1083+ default:
1084+ return false
1085+ }
1086+ }
1087+}
1088+
1089+func (ext *Extension) lifecycleWriter() {
1090+ for {
1091+ select {
1092+ case <-ext.lifecycleStop:
1093+ return
1094+ case frame := <-ext.lifecycleFrames:
1095+ if _, err := ext.writeFrame(frame); err != nil {
1096+ return
1097+ }
1098+ }
1099+ }
1100+}
1101+
9261102 func (ext *Extension) recordDiagnostic(msg string) {
9271103 ext.mu.Lock()
9281104 defer ext.mu.Unlock()
@@ -1075,6 +1251,24 @@ func (m *Manager) Tools() []ToolInfo {
10751251 return out
10761252 }
10771253
1254+// Skills returns extension-bundled skills in deterministic extension-name
1255+// order. The build layer merges them after user skills and before built-ins.
1256+func (m *Manager) Skills() []*skills.Skill {
1257+ m.mu.RLock()
1258+ names := make([]string, 0, len(m.ext))
1259+ for name := range m.ext {
1260+ names = append(names, name)
1261+ }
1262+ sort.Strings(names)
1263+ var out []*skills.Skill
1264+ for _, name := range names {
1265+ ext := m.ext[name]
1266+ out = append(out, ext.skills...)
1267+ }
1268+ m.mu.RUnlock()
1269+ return out
1270+}
1271+
10781272 // HasTool reports whether name is registered by any extension.
10791273 func (m *Manager) HasTool(name string) bool {
10801274 m.mu.RLock()
@@ -1106,7 +1300,7 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess
11061300 Name: name,
11071301 Args: args,
11081302 })
1109- if _, err := ext.stdin.Write(frame); err != nil {
1303+ if _, err := ext.writeFrame(frame); err != nil {
11101304 ext.mu.Lock()
11111305 delete(ext.pendingTool, id)
11121306 ext.mu.Unlock()
@@ -1172,7 +1366,7 @@ func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Du
11721366 Name: name,
11731367 Args: args,
11741368 })
1175- if _, err := ext.stdin.Write(frame); err != nil {
1369+ if _, err := ext.writeFrame(frame); err != nil {
11761370 ext.mu.Lock()
11771371 delete(ext.pending, id)
11781372 ext.mu.Unlock()
@@ -1206,7 +1400,7 @@ func (m *Manager) SendPanelKey(extName, panelID, key, text string) error {
12061400 return fmt.Errorf("no extension %q", extName)
12071401 }
12081402 frame, _ := extproto.Encode(extproto.PanelKeyFromHost{Type: "panel_key", PanelID: panelID, Key: key, Text: text})
1209- _, err := ext.stdin.Write(frame)
1403+ _, err := ext.writeFrame(frame)
12101404 return err
12111405 }
12121406
@@ -1218,7 +1412,7 @@ func (m *Manager) SendPanelClose(extName, panelID string) error {
12181412 return fmt.Errorf("no extension %q", extName)
12191413 }
12201414 frame, _ := extproto.Encode(extproto.PanelCloseFromHost{Type: "panel_close", PanelID: panelID})
1221- _, err := ext.stdin.Write(frame)
1415+ _, err := ext.writeFrame(frame)
12221416 return err
12231417 }
12241418
@@ -1238,7 +1432,7 @@ func stopExtensions(exts []*Extension, gracePeriod time.Duration) {
12381432 continue
12391433 }
12401434 if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {
1241- _, _ = ext.stdin.Write(frame)
1435+ _, _ = ext.writeFrame(frame)
12421436 }
12431437 _ = ext.stdin.Close()
12441438 }
M packages/agent/extensions/manager_test.go +60 -0
@@ -68,6 +68,9 @@ func (s *stubHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
6868 }
6969 func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
7070 func (s *stubHooks) ClosePanel(string, string) {}
71+func (s *stubHooks) SetStatus(string, string, string, string) {}
72+func (s *stubHooks) SetWidget(string, string, string, string, []string) {}
73+func (s *stubHooks) ClearWidget(string, string) {}
7174
7275 // writeMockExtension creates a minimal extension on disk that uses a
7376 // shell script (or batch file on windows) to drive the protocol. The
@@ -259,6 +262,63 @@ func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
259262 }
260263 }
261264
265+func TestManifestBundlesSkillsWithSafePrecedence(t *testing.T) {
266+ tmp := t.TempDir()
267+ extRoot := filepath.Join(tmp, "extensions")
268+ extDir := filepath.Join(extRoot, "bundle")
269+ if err := os.MkdirAll(filepath.Join(extDir, "skills", "phases"), 0o755); err != nil {
270+ t.Fatal(err)
271+ }
272+ if err := os.WriteFile(filepath.Join(extDir, "skills", "phases", "SKILL.md"), []byte("---\nname: phases\ndescription: bundled phases\n---\nbody\n"), 0o644); err != nil {
273+ t.Fatal(err)
274+ }
275+ manifest := `{"name":"bundle","skills":["skills"],"description":"bundle"}`
276+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
277+ t.Fatal(err)
278+ }
279+ if err := os.WriteFile(filepath.Join(extDir, "theme.json"), []byte(`{"name":"bundle","colors":{"dark":{"accent":1}}}`), 0o644); err != nil {
280+ t.Fatal(err)
281+ }
282+
283+ mgr := New(tmp, "", "0.0.0-test", "", "", &stubHooks{})
284+ if errs := mgr.Discover(context.Background()); len(errs) > 0 {
285+ t.Fatalf("discover errors: %v", errs)
286+ }
287+ defer mgr.Stop(10 * time.Millisecond)
288+ bundled := mgr.Skills()
289+ if len(bundled) != 1 || bundled[0].Name != "phases" || bundled[0].Source != "extension bundle" {
290+ t.Fatalf("bundled skills = %#v", bundled)
291+ }
292+ if _, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{"../outside"}}); len(errs) == 0 {
293+ t.Fatal("path traversal manifest was accepted")
294+ }
295+ for _, path := range []string{"", filepath.Join(string(filepath.Separator), "outside")} {
296+ if _, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{path}}); len(errs) == 0 {
297+ t.Fatalf("unsafe skill path %q was accepted", path)
298+ }
299+ }
300+ if runtime.GOOS != "windows" {
301+ outside := filepath.Join(tmp, "outside", "SKILL.md")
302+ if err := os.MkdirAll(filepath.Dir(outside), 0o755); err != nil {
303+ t.Fatal(err)
304+ }
305+ if err := os.WriteFile(outside, []byte("---\nname: escape\ndescription: outside\n---\nbody\n"), 0o644); err != nil {
306+ t.Fatal(err)
307+ }
308+ unsafeDir := filepath.Join(extDir, "unsafe")
309+ if err := os.MkdirAll(unsafeDir, 0o755); err != nil {
310+ t.Fatal(err)
311+ }
312+ if err := os.Symlink(outside, filepath.Join(unsafeDir, "SKILL.md")); err != nil {
313+ t.Fatal(err)
314+ }
315+ loaded, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{"unsafe"}})
316+ if len(errs) == 0 || len(loaded) != 0 {
317+ t.Fatalf("symlinked skill outside extension was accepted: skills=%#v errors=%v", loaded, errs)
318+ }
319+ }
320+}
321+
262322 func TestManagerSpawnAndInvoke(t *testing.T) {
263323 tmp := t.TempDir()
264324 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 +47 -0
@@ -44,3 +44,50 @@ func TestClearNotesNoMatchKeepsNotes(t *testing.T) {
4444 t.Fatalf("expected note to survive, got %d", len(i.extNotes))
4545 }
4646 }
47+
48+func TestPersistentExtensionWidgetRowsAreBounded(t *testing.T) {
49+ i := newNotesTestInteractive()
50+ lines := make([]string, 20)
51+ for idx := range lines {
52+ lines[idx] = "line"
53+ }
54+ i.SetWidget("extension", "widget", "above_input", "Title", lines)
55+
56+ i.mu.Lock()
57+ got := i.extensionChromeLinesLocked(80)
58+ i.mu.Unlock()
59+ if len(got) != maxExtensionWidgetRows {
60+ t.Fatalf("widget rows = %d, want %d: %v", len(got), maxExtensionWidgetRows, got)
61+ }
62+ if !strings.Contains(strings.Join(got, "\n"), "extension widgets truncated") {
63+ t.Fatalf("bounded widget rows omitted truncation marker: %v", got)
64+ }
65+}
66+
67+func TestPersistentExtensionStatusAndWidget(t *testing.T) {
68+ i := newNotesTestInteractive()
69+ i.SetStatus("tasked-phases", "progress", "success", "2/4 tasks checked")
70+ i.SetWidget("tasked-phases", "plan", "above_input", "Tasked phases", []string{"Current phase: parse", "[ ] read files"})
71+
72+ i.mu.Lock()
73+ lines := i.extensionChromeLinesLocked(80)
74+ statusKey := i.extensionStatusesKeyLocked()
75+ widgetKey := i.extensionWidgetsKeyLocked()
76+ i.mu.Unlock()
77+ joined := strings.Join(lines, "\n")
78+ if !strings.Contains(joined, "2/4 tasks checked") || !strings.Contains(joined, "Tasked phases") || !strings.Contains(joined, "[ ] read files") {
79+ t.Fatalf("persistent extension chrome = %q", joined)
80+ }
81+ if !strings.Contains(statusKey, "tasked-phases/progress/success/2/4 tasks checked") {
82+ t.Fatalf("status cache key = %q", statusKey)
83+ }
84+ if !strings.Contains(widgetKey, "tasked-phases/plan/above_input/Tasked phases") {
85+ t.Fatalf("widget cache key = %q", widgetKey)
86+ }
87+
88+ i.SetStatus("tasked-phases", "progress", "", "")
89+ i.ClearWidget("tasked-phases", "plan")
90+ if len(i.extStatuses) != 0 || len(i.extWidgets) != 0 {
91+ t.Fatalf("persistent extension chrome was not cleared: statuses=%v widgets=%v", i.extStatuses, i.extWidgets)
92+ }
93+}
M packages/agent/modes/interactive.go +202 -1
@@ -7,6 +7,7 @@ import (
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,124 @@ 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+
1190+func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
1191+ var out []string
1192+ bodyWidth := cols - 2
1193+ if bodyWidth < 1 {
1194+ bodyWidth = 1
1195+ }
1196+ widgetRows := 0
1197+ widgetTruncated := false
1198+ appendWidgetRow := func(line string) bool {
1199+ if widgetRows >= maxExtensionWidgetRows-1 {
1200+ widgetTruncated = true
1201+ return false
1202+ }
1203+ out = append(out, line)
1204+ widgetRows++
1205+ return true
1206+ }
1207+
1208+ stopWidgets := false
1209+ for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
1210+ if stopWidgets {
1211+ break
1212+ }
1213+ for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1214+ widget := i.extWidgets[extName][id]
1215+ label := " [" + extName + "]"
1216+ if widget.Title != "" {
1217+ label += " " + widget.Title
1218+ }
1219+ if !appendWidgetRow(i.cfg.Theme.FG256(i.cfg.Theme.Accent, truncateLine(label, cols))) {
1220+ stopWidgets = true
1221+ break
1222+ }
1223+ for _, line := range widget.Lines {
1224+ if !appendWidgetRow(" " + truncateLine(line, bodyWidth)) {
1225+ stopWidgets = true
1226+ break
1227+ }
1228+ }
1229+ if stopWidgets {
1230+ break
1231+ }
1232+ }
1233+ }
1234+ if widgetTruncated {
1235+ out = append(out, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols)))
1236+ }
1237+
1238+ for _, extName := range sortedNestedOuterKeys(i.extStatuses) {
1239+ for _, key := range sortedNestedInnerKeys(i.extStatuses, extName) {
1240+ status := i.extStatuses[extName][key]
1241+ color := i.cfg.Theme.Muted
1242+ switch status.Level {
1243+ case "warn":
1244+ color = i.cfg.Theme.Warning
1245+ case "error":
1246+ color = i.cfg.Theme.Error
1247+ case "success":
1248+ color = i.cfg.Theme.Tool
1249+ }
1250+ label := " [" + extName + "] " + status.Text
1251+ out = append(out, i.cfg.Theme.FG256(color, truncateLine(label, cols)))
1252+ }
1253+ }
1254+ return out
1255+}
1256+
11191257 func (i *Interactive) buildChatLocked(cols int) []string {
11201258 if i.agent != nil {
11211259 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
@@ -1599,13 +1737,15 @@ func (i *Interactive) redraw() {
15991737 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
16001738 }
16011739
1740+ extensionLines := i.extensionChromeLinesLocked(cols)
1741+
16021742 // Bottom-sticky sections (always visible, never scroll). Each
16031743 // non-empty subsection (dialog, suggest popup, sliding-in queue)
16041744 // is preceded by one blank row so it has air above the chat
16051745 // content. The status block and editor get their own dedicated
16061746 // blanks so spacing stays consistent whether or not a dialog or
16071747 // popup is showing.
1608- bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(statusLines)+len(edLines)+9)
1748+ bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(edLines)+9)
16091749 inputStartRow := -1
16101750 if len(dialog) > 0 {
16111751 bottom = append(bottom, "")
@@ -1627,6 +1767,7 @@ func (i *Interactive) redraw() {
16271767 workingBelow := workingPosition == tui.WorkingPositionBelowInput
16281768
16291769 var aboveInput []string
1770+ aboveInput = append(aboveInput, extensionLines...)
16301771 if !statusBelow {
16311772 aboveInput = append(aboveInput, statusLines...)
16321773 }
@@ -3486,6 +3627,66 @@ func (i *Interactive) Display(extName, text string) {
34863627 i.invalidate()
34873628 }
34883629
3630+// SetStatus replaces one persistent status item owned by an extension.
3631+func (i *Interactive) SetStatus(extName, key, level, text string) {
3632+ if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {
3633+ return
3634+ }
3635+ i.mu.Lock()
3636+ if i.extStatuses == nil {
3637+ i.extStatuses = map[string]map[string]extensionStatus{}
3638+ }
3639+ if strings.TrimSpace(text) == "" {
3640+ if items := i.extStatuses[extName]; items != nil {
3641+ delete(items, key)
3642+ if len(items) == 0 {
3643+ delete(i.extStatuses, extName)
3644+ }
3645+ }
3646+ } else {
3647+ if i.extStatuses[extName] == nil {
3648+ i.extStatuses[extName] = map[string]extensionStatus{}
3649+ }
3650+ i.extStatuses[extName][key] = extensionStatus{Level: level, Text: text}
3651+ }
3652+ i.mu.Unlock()
3653+ i.invalidate()
3654+}
3655+
3656+// SetWidget replaces one persistent widget owned by an extension.
3657+func (i *Interactive) SetWidget(extName, id, position, title string, lines []string) {
3658+ if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {
3659+ return
3660+ }
3661+ i.mu.Lock()
3662+ if i.extWidgets == nil {
3663+ i.extWidgets = map[string]map[string]extensionWidget{}
3664+ }
3665+ if i.extWidgets[extName] == nil {
3666+ i.extWidgets[extName] = map[string]extensionWidget{}
3667+ }
3668+ i.extWidgets[extName][id] = extensionWidget{
3669+ Position: position,
3670+ Title: title,
3671+ Lines: append([]string(nil), lines...),
3672+ }
3673+ i.mu.Unlock()
3674+ i.invalidate()
3675+}
3676+
3677+// ClearWidget removes one persistent widget owned by an extension.
3678+func (i *Interactive) ClearWidget(extName, id string) {
3679+ i.mu.Lock()
3680+ if items := i.extWidgets[extName]; items != nil {
3681+ delete(items, id)
3682+ if len(items) == 0 {
3683+ delete(i.extWidgets, extName)
3684+ }
3685+ }
3686+ i.mu.Unlock()
3687+ i.invalidate()
3688+}
3689+
34893690 func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
34903691 i.mu.Lock()
34913692 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 +88 -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,70 @@ 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)
347+}
348+
349+// readExtensionStateAtFork reconstructs the latest extension snapshot that was
350+// persisted at or before a branch boundary. Extension state is independent of
351+// provider messages, but its timeline still follows the effective message
352+// stream across compaction rows.
353+func readExtensionStateAtFork(path string, limit int) (map[string]json.RawMessage, error) {
354+ f, err := os.Open(path)
355+ if err != nil {
356+ return nil, fmt.Errorf("branch: open parent for extension state: %w", err)
357+ }
358+ defer f.Close()
359+
360+ state := make(map[string]json.RawMessage)
361+ effectiveCount := 0
362+ err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
363+ var head sessionLineHead
364+ if err := json.Unmarshal(line, &head); err != nil {
365+ return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
366+ }
367+ switch head.Type {
368+ case "meta", "usage", "rename":
369+ return nil
370+ case "message":
371+ if _, err := hydrateMessage(line); err != nil {
372+ return fmt.Errorf("line %d: invalid message row: %w", lineNo, err)
373+ }
374+ effectiveCount++
375+ case "compaction":
376+ messages, err := hydrateCompaction(line)
377+ if err != nil {
378+ return fmt.Errorf("line %d: invalid compaction row: %w", lineNo, err)
379+ }
380+ effectiveCount = len(messages)
381+ case "extension_state":
382+ var row sessionLine
383+ if err := json.Unmarshal(line, &row); err != nil {
384+ return fmt.Errorf("line %d: invalid extension state row: %w", lineNo, err)
385+ }
386+ if effectiveCount > limit || row.Extension == "" || len(row.State) > maxExtensionStateBytes {
387+ return nil
388+ }
389+ if len(row.State) == 0 || strings.TrimSpace(string(row.State)) == "null" {
390+ delete(state, row.Extension)
391+ } else if json.Valid(row.State) {
392+ state[row.Extension] = append(json.RawMessage(nil), row.State...)
393+ }
394+ default:
395+ return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
396+ }
397+ return nil
398+ })
399+ if err != nil {
400+ return nil, fmt.Errorf("branch: read extension state: %w", err)
401+ }
402+ return state, nil
338403 }
339404
340-func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool) (string, error) {
405+func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage) (string, error) {
341406 dir := SessionsDir(root, cwd)
342407 if err := os.MkdirAll(dir, 0o755); err != nil {
343408 return "", err
@@ -404,6 +469,26 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages
404469 }
405470 }
406471
472+ // Extension snapshots are branch state, not provider-visible transcript
473+ // content. Write the latest snapshot that existed at the fork point so a
474+ // child branch starts from the same extension state as its copied prefix.
475+ extensionNames := make([]string, 0, len(extensionState))
476+ for name := range extensionState {
477+ extensionNames = append(extensionNames, name)
478+ }
479+ sort.Strings(extensionNames)
480+ for _, name := range extensionNames {
481+ line, err := json.Marshal(sessionLine{Type: "extension_state", Extension: name, State: extensionState[name]})
482+ if err != nil {
483+ return "", fmt.Errorf("branch: marshal extension state: %w", err)
484+ }
485+ if _, err := bw.Write(line); err != nil {
486+ return "", fmt.Errorf("branch: write extension state: %w", err)
487+ }
488+ if err := bw.WriteByte('\n'); err != nil {
489+ return "", fmt.Errorf("branch: terminate extension state row: %w", err)
490+ }
491+ }
407492 // Preserve every cumulative checkpoint that belongs to this prefix. The
408493 // final two rows are needed to reconstruct LastTurnUsage as a delta.
409494 for _, checkpoint := range checkpoints {
M packages/core/session_portable_test.go +37 -0
@@ -491,6 +491,43 @@ 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+
494531 // TestBuildSessionTree verifies parent/child edges are rebuilt
495532 // from meta + sibling-scan.
496533 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