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