opencode-goal-mode 0.3.11 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ARCHITECTURE.md CHANGED
@@ -66,7 +66,7 @@ Verified against `@opencode-ai/plugin@1.15.13` source.
66
66
  | `chat.message` | Capture the user's goal text (drives contextual review gates). |
67
67
  | `chat.params` | Track the current agent; activate goal sessions. |
68
68
  | `experimental.chat.system.transform` | Inject the live Goal Guard state block. |
69
- | `tool.execute.before` | Block destructive / remote-exec bash by throwing. |
69
+ | `tool.execute.before` | Block destructive / remote-exec bash, and block non-Goal agents from invoking `goal-*` subagents, by throwing. |
70
70
  | `tool.execute.after` | Record edits, verification, mutations, and review verdicts. |
71
71
  | `experimental.text.complete` | Rewrite premature `Goal Completed` claims. |
72
72
  | `experimental.session.compacting` | Preserve guard state across compaction. |
@@ -166,18 +166,23 @@ hooks still load.
166
166
  ## TUI companion (experimental)
167
167
 
168
168
  `plugins/goal-sidebar.tsx` is a TUI plugin module — default-exporting `{ id, tui }`
169
- — distinct from the server plugin. It waits until the persisted state contains an
170
- active Goal session, then registers a `sidebar_content` slot via
171
- `api.slots.register({ slots: { sidebar_content } })` and renders the short goal
172
- label, gate/status line, and structured Goal todos derived from acceptance
173
- criteria, evidence freshness, dirty state, and missing gates. It starts with a
174
- brief rainbow foreground effect, then returns to the configured running colour
175
- (`#FFD700` by default). When there is no active Goal session it does not register
176
- the slot, so Build and other modes keep OpenCode's native todo section in place.
169
+ — distinct from the server plugin. It registers a `sidebar_content` slot via
170
+ `api.slots.register({ slots: { sidebar_content } })` (matching the canonical
171
+ OpenCode TUI-plugin pattern), and the slot renders content **only** for the active
172
+ session, and **only** when that exact session is an active Goal session. It is
173
+ keyed strictly by `props.session_id`: there is no most-recently-touched global
174
+ fallback, so a Build (or any non-Goal) session in the same worktree never inherits
175
+ another session's goal it renders nothing and keeps OpenCode's native todo
176
+ section. When it does render, it shows the short goal label, gate/status line, and
177
+ structured Goal todos derived from acceptance criteria, evidence freshness, dirty
178
+ state, and missing gates, starting with a brief per-line rainbow foreground effect
179
+ and then settling to the configured running colour (`#FFD700` by default; red when
180
+ done).
177
181
 
178
182
  It is *paired* with the server plugin only through the persisted state file:
179
183
  `sidebar-data.js` recomputes the same `stateBaseDir`/`projectKey` path the guard
180
- writes to and projects the active session via `summary.sidebarView`. That keeps
184
+ writes to and projects the requested session via `summary.sidebarView` (the same
185
+ per-session rule, so the Node tests and the real component agree). That keeps
181
186
  the pure projection logic Node-testable (`tests/sidebar.test.mjs`) even though the
182
187
  JSX renderer itself can only run inside OpenCode's (Bun) TUI runtime. Everything
183
188
  in the `tui` entry is wrapped so a missing slot API, missing JSX runtime, or read
package/CHANGELOG.md CHANGED
@@ -1,5 +1,85 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.1
4
+
5
+ ### Restructured Goal sidebar todo section
6
+
7
+ - The sidebar Goal section is now a proper stacked, multi-colour layout instead of
8
+ one run of text: a bold **`GOAL`** label on its own line (yellow while running,
9
+ red when done), then the goal title, then a `passing/total gates · status` line —
10
+ each line in its own highlight colour (GOAL yellow, title white, status cyan) so
11
+ they never blend together. It opens with a first-display per-line rainbow, then
12
+ settles.
13
+ - Removed the noisy `· changes pending` suffix from the status line; pending work
14
+ now surfaces as a structured todo row instead.
15
+ - Better, more structured todos: one row per acceptance criterion (✓ when fresh
16
+ evidence covers it), a re-verify row when the tree changed, and one row per
17
+ still-missing review gate by friendly name (e.g. "Pass Security Reviewer").
18
+ - In a goal session the section takes over the sidebar `sidebar_content` slot;
19
+ because OpenCode renders the native todo list as that slot's fallback, it
20
+ replaces the native list on builds that use replace/single-winner slot mode and
21
+ sits alongside it otherwise. Non-goal and no-goal sessions render nothing, so
22
+ the native todo list stays in place.
23
+
24
+ ### Build mode is never treated as a goal
25
+
26
+ - Hardened the guard so a Build/Plan/custom session never accumulates goal state.
27
+ `tool.execute.after` bookkeeping (dirty flag, edits, verification, verdicts) now
28
+ runs only for active Goal sessions (and goal-namespace subagent sessions for
29
+ verdict capture). Destructive-command blocking still applies in every mode.
30
+
31
+ ### Installer
32
+
33
+ - `--global` now resolves the home directory via `$HOME` and falls back to the OS
34
+ home dir, so it works in shells/containers where `$HOME` is unset.
35
+ - Verified the full install → idempotent re-run → conflict-protection → uninstall
36
+ lifecycle end-to-end on macOS (Node 24) and in clean Linux containers (Apple
37
+ `container`, Node 20 and Node 24) using the real `npm install -g <tarball>` path.
38
+ - Moved **Install** to the top of the README and documented dry-run/uninstall.
39
+
40
+ ## v0.4.0
41
+
42
+ ### Goal-only subagents
43
+
44
+ - The `goal-*` specialist subagents are now mechanically locked to Goal Mode.
45
+ OpenCode resolves subagents globally, so a Build, Plan, or custom agent could
46
+ previously invoke a Goal reviewer directly. The guard now blocks any `task` call
47
+ targeting a `goal-*` subagent unless it comes from an active Goal session, and a
48
+ poach attempt never turns the calling session into a Goal. General-purpose
49
+ subagents (`explore`/`general`/`scout`) are unaffected. New `restrictSubagents` /
50
+ `GOAL_GUARD_RESTRICT_SUBAGENTS` option (default on) toggles it.
51
+
52
+ ### Per-session sidebar isolation (not global)
53
+
54
+ - The TUI Goal todo section is now strictly per-session. Both the live component
55
+ (`goal-sidebar.tsx`) and the Node-testable projection (`sidebar-data.js`) resolve
56
+ state by the exact `props.session_id` and only when that session is an active Goal
57
+ session — the "most-recently-touched active session" global fallback is gone in
58
+ both. A Build (or any non-Goal) session in the same worktree can no longer inherit
59
+ a sibling session's goal.
60
+ - The slot now always registers and decides per-session inside the render (matching
61
+ the canonical OpenCode TUI-plugin pattern) instead of conditionally registering.
62
+ - Added node + headless-visual coverage proving two active goals in one worktree
63
+ each render only their own goal.
64
+
65
+ ### Goal-mode-only tools
66
+
67
+ - Every `goal_*` tool is Goal-mode-only: non-Goal/Build sessions get a clear refusal
68
+ instead of any Goal status, evidence map, memory, contract, evidence, or reset.
69
+
70
+ ### Documentation & visuals (accuracy pass)
71
+
72
+ - Corrected the sidebar wording from "replaces the native todo area" to "adds a
73
+ Goal-owned todo section" (the slot contributes content; it does not replace native
74
+ todos), and removed the stale "waits to register the slot" description.
75
+ - Regenerated the README hero image (`docs/sidebar-demo.svg`) to match what actually
76
+ renders: a bold `Goal todos` label (no orb), the first-display per-line rainbow,
77
+ the running/done colour states, and the native-todo-stays behavior. The old image
78
+ showed a removed `◆ GOAL` orb and a removed grey "No goal" state.
79
+ - Made the benchmark "remaining misses" description accurate (all are plain `rm`
80
+ without `-r`/`-f`, intentionally permitted) and documented Goal-only subagents and
81
+ `restrictSubagents` in the README config table and ARCHITECTURE hook table.
82
+
3
83
  ## v0.3.11
4
84
 
5
85
  - Fixed Goal sidebar/status isolation so an explicit Build or other non-Goal
package/README.md CHANGED
@@ -1,40 +1,38 @@
1
1
  # OpenCode Goal Mode
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/opencode-goal-mode?color=2da44e&label=npm)](https://www.npmjs.com/package/opencode-goal-mode)
4
- [![npm downloads](https://img.shields.io/npm/dm/opencode-goal-mode?color=2da44e)](https://www.npmjs.com/package/opencode-goal-mode)
5
- [![CI](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/ci.yml/badge.svg)](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/ci.yml)
6
- [![Release](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/publish.yml/badge.svg)](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/publish.yml)
7
- [![license](https://img.shields.io/npm/l/opencode-goal-mode?color=2da44e)](LICENSE)
8
- [![node](https://img.shields.io/node/v/opencode-goal-mode?color=2da44e)](package.json)
9
-
10
3
  Strict Goal Mode for OpenCode: a primary `goal` agent, specialized review
11
4
  subagents, slash commands, a `goal-guard` plugin that enforces review discipline
12
- and blocks destructive shell commands, and a live Goal-owned todo section in the
13
- TUI sidebar.
5
+ and blocks destructive shell commands, and a structured Goal-owned todo section
6
+ in the TUI sidebar.
14
7
 
15
8
  ## Install
16
9
 
17
- **One command** (recommended; needs [Node](https://nodejs.org) 20.11+ and a working [OpenCode](https://opencode.ai) install):
10
+ **One command.** Needs [Node](https://nodejs.org) 20.11+ and a working
11
+ [OpenCode](https://opencode.ai). Works on macOS and Linux:
18
12
 
19
13
  ```bash
20
14
  npm install -g opencode-goal-mode && opencode-goal-mode --global
21
15
  ```
22
16
 
23
- Then **restart OpenCode**. That's the whole install: it copies the Goal agent,
17
+ Then **restart OpenCode**. That's the whole install it copies the Goal agent,
24
18
  review subagents, slash commands, and guard plugin into `~/.config/opencode`, and
25
19
  merge-safely registers the Goal todo sidebar in `~/.config/opencode/tui.json`.
26
20
  In the agent picker you'll see only the **`goal`** agent; reviewers are subagents
27
- it drives automatically. The global install keeps the TUI package resolvable on
28
- future OpenCode starts; Goal Mode inherits your existing OpenCode model/provider.
21
+ it drives automatically. The install is **idempotent** (re-run it to upgrade in
22
+ place), never touches files you've edited, and `--uninstall` removes exactly what
23
+ it added. Goal Mode inherits your existing OpenCode model/provider.
29
24
 
30
25
  <details>
31
26
  <summary>Other ways to install</summary>
32
27
 
33
28
  ```bash
34
- # Global npm install, then run the installer
29
+ # Global npm install, then run the installer separately
35
30
  npm install -g opencode-goal-mode
36
31
  opencode-goal-mode --global # alias of opencode-goal-mode-install
37
32
 
33
+ # Preview first, then install (no writes on --dry-run)
34
+ opencode-goal-mode --global --dry-run
35
+
38
36
  # Temporary npx install (server-side components work; for the TUI sidebar,
39
37
  # prefer the global install above so OpenCode can resolve the package later)
40
38
  npx opencode-goal-mode --global
@@ -42,6 +40,9 @@ npx opencode-goal-mode --global
42
40
  # Into a single project (writes ./.opencode, including ./.opencode/tui.json)
43
41
  npx opencode-goal-mode
44
42
 
43
+ # Clean removal of everything it installed (incl. its tui.json entry)
44
+ opencode-goal-mode --global --uninstall
45
+
45
46
  # From source
46
47
  git clone https://github.com/devinoldenburg/opencode-goal-mode
47
48
  cd opencode-goal-mode && npm ci && npm run install:global
@@ -49,16 +50,23 @@ cd opencode-goal-mode && npm ci && npm run install:global
49
50
 
50
51
  Use global install for normal daily use. Use project install only when you want
51
52
  Goal Mode scoped to one repo and your OpenCode build reads project `.opencode`
52
- config, including `.opencode/tui.json`. `--dry-run` previews changes;
53
- `--uninstall` removes only what it installed (and its `tui.json` entry), leaving
54
- your edits untouched. See [Installer options](#installer-options).
53
+ config, including `.opencode/tui.json`. See [Installer options](#installer-options).
55
54
  </details>
56
55
 
56
+ [![npm version](https://img.shields.io/npm/v/opencode-goal-mode?color=2da44e&label=npm)](https://www.npmjs.com/package/opencode-goal-mode)
57
+ [![npm downloads](https://img.shields.io/npm/dm/opencode-goal-mode?color=2da44e)](https://www.npmjs.com/package/opencode-goal-mode)
58
+ [![CI](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/ci.yml/badge.svg)](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/ci.yml)
59
+ [![Release](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/publish.yml/badge.svg)](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/publish.yml)
60
+ [![license](https://img.shields.io/npm/l/opencode-goal-mode?color=2da44e)](LICENSE)
61
+ [![node](https://img.shields.io/node/v/opencode-goal-mode?color=2da44e)](package.json)
62
+
57
63
  ![OpenCode Goal Mode sidebar todo section](docs/sidebar-demo.svg)
58
64
 
59
- <sub>↑ In Goal mode, the sidebar todo slot becomes a Goal-owned todo section with
60
- a first-display rainbow effect, then normal goal colours. Build and other modes
61
- keep OpenCode's native todo section see [TUI integration](#tui-integration).</sub>
65
+ <sub>↑ In goal mode, the Goal plugin takes over the sidebar todo section with a
66
+ structured, evidence-aware Goal todo list a bold `GOAL` label, then the goal
67
+ title, gate progress, and per-acceptance/gate todo rows, each on its own line in
68
+ its own colour, with a first-display rainbow. Build and every other mode keep
69
+ OpenCode's native todo section — see [TUI integration](#tui-integration).</sub>
62
70
 
63
71
  **[Quick start](#quick-start) · [Why it's different](#why-its-different) · [Benchmarks](#benchmarks-honest-edition) · [TUI integration](#tui-integration) · [Configuration](#configuration) · [Releasing](#releasing) · [Architecture](ARCHITECTURE.md)**
64
72
 
@@ -140,10 +148,13 @@ Reproduce with `npm run bench` or `node benchmarks/external.mjs`.
140
148
 
141
149
  Honest caveats, because the point of this rewrite was to stop overclaiming:
142
150
 
143
- - The ~7 remaining "misses" are almost all un-flagged single-target `rm <file>`,
144
- which the guard **intentionally permits** (plain `rm` is common and the guard
145
- blocks `rm -r`/`rm -f`, `$(rm …)`, `bash -c`, interpreters, etc.). Under a
146
- strict every-`rm`-is-destructive labeling those count against it.
151
+ - The 7 remaining "misses" are all plain `rm` invocations without `-r`/`-f`
152
+ (single- or multi-target, a few with `-i`/`-v`/`-d`), which the guard
153
+ **intentionally permits**: bare `rm` is extremely common, so the guard marks it
154
+ dirty but lets the host's own `rm *` permission decide, while still blocking the
155
+ irreversible forms (`rm -r`/`rm -f`, wildcard/root, `$(rm …)`, `bash -c`,
156
+ `/bin/rm`, interpreters, etc.). Under a strict every-`rm`-is-destructive
157
+ labeling those count against it.
147
158
  - The single counted false positive (`git filter-repo …`) actually *is* a
148
159
  history-rewriting command, so the real-world false-positive rate is effectively
149
160
  zero. `node benchmarks/external.mjs --json` lists every miss and false positive
@@ -182,7 +193,8 @@ second) — negligible for a per-tool-call guard:
182
193
  discovery, verification planning, and reviews to subagents. **`goal` is the only
183
194
  user-selectable agent** — every specialist (security, diff, verifier, …) is a
184
195
  `mode: subagent` that the Goal agent invokes via the task tool; the user never
185
- picks one directly. They surface with friendly names (e.g. "Security Reviewer",
196
+ picks one directly, and the guard blocks any other agent from invoking them (see
197
+ **Goal-only subagents** below). They surface with friendly names (e.g. "Security Reviewer",
186
198
  "API Reviewer") rather than raw ids.
187
199
  - Strict review gates for prompt compliance, diff review, verification, security,
188
200
  UX, operations, data, API, performance, tests, docs, quality, and final audit.
@@ -197,6 +209,12 @@ second) — negligible for a per-tool-call guard:
197
209
  `Goal Not Completed` with the exact missing review gates.
198
210
  - **Contextual gating**: the goal text and changed files determine which
199
211
  specialist reviewers are required.
212
+ - **Goal-only subagents**: the `goal-*` specialist subagents are mechanically
213
+ locked to Goal Mode. OpenCode resolves subagents globally, so the guard blocks
214
+ any Build, Plan, or custom agent that tries to invoke a `goal-*` reviewer via
215
+ the task tool — they run only under the Goal agent (toggle with
216
+ `restrictSubagents`). General-purpose subagents (`explore`/`general`/`scout`)
217
+ are never restricted.
200
218
  - **Reviewer Memory**: blocking reviewer findings are carried across cycles,
201
219
  surfaced in status/system context, and marked resolved by fresh PASS verdicts.
202
220
  - **Disk persistence**: review ledgers and Reviewer Memory survive OpenCode restarts.
@@ -208,9 +226,10 @@ second) — negligible for a per-tool-call guard:
208
226
  reviewer's friendly name, and a single "completion unlocked" toast the moment
209
227
  the last required gate clears.
210
228
  - An **experimental** companion TUI plugin (`plugins/goal-sidebar.tsx`) that, in
211
- Goal sessions only, replaces the native todo sidebar area with a Goal-owned,
212
- evidence-aware todo section. It shows a brief rainbow effect the first time it
213
- appears, then normal goal colours. See [TUI integration](#tui-integration).
229
+ Goal sessions only, takes over the sidebar todo area with a structured,
230
+ evidence-aware Goal todo list (`GOAL` label, goal title, gate progress, and
231
+ todo rows each on its own line in its own colour). It shows a first-display
232
+ rainbow, then normal goal colours. See [TUI integration](#tui-integration).
214
233
  - A test suite validating the analyzer, plugin hooks, state store, install
215
234
  safety, and config compatibility.
216
235
 
@@ -220,26 +239,40 @@ Goal Mode is a **plugin pair**: the server-side `goal-guard` plugin owns
220
239
  enforcement and writes its state to disk, and an experimental TUI plugin
221
240
  (`plugins/goal-sidebar.tsx`) reads that same state to render a live todo section.
222
241
 
223
- - **Goal-mode todo replacement.** In a `goal` session, the sidebar content/todo
224
- area is replaced by a Goal-owned todo section: short goal title, gate progress,
225
- lifecycle status, and structured todo rows derived from acceptance criteria,
226
- evidence freshness, dirty state, and missing review gates. It starts with a
227
- brief rainbow foreground effect (`sidebarRainbowMs`) so the replacement is
228
- visible, then returns to the normal lifecycle colours:
229
- - **yellow** a goal is set and running;
230
- - **red** the goal is done (all required gates pass and the tree is clean);
231
- - **no render** Build and every non-Goal mode keep OpenCode's native todo
232
- section in the same sidebar position instead of being classified as a goal.
242
+ - **Goal-owned todo section.** In a `goal` session with a goal set, the Goal
243
+ plugin renders its own structured todo section into the sidebar's `sidebar_content`
244
+ slot, stacked on separate lines, each in its own colour so it never reads as one
245
+ run of text:
246
+ - a bold **`GOAL`** label (yellow while running, red when done);
247
+ - the short goal title;
248
+ - a `passing/total gates · status` line (lifecycle only — no "changes pending"
249
+ noise; pending work shows as a todo row instead);
250
+ - structured todo rows derived from real guard state: one per acceptance
251
+ criterion (✓ when fresh evidence covers it), a re-verify row when the tree
252
+ changed, and one row per still-missing review gate by friendly name
253
+ (e.g. "Pass Security Reviewer").
254
+
255
+ It opens with a first-display rainbow (`sidebarRainbowMs`) so the takeover is
256
+ visible, then settles to the lifecycle colours (running → yellow label; done →
257
+ red). Because OpenCode renders the native todo list as that slot's *fallback*,
258
+ on builds that render `sidebar_content` in replace/single-winner mode the Goal
259
+ section **replaces** the native todo list while a goal is active; in append mode
260
+ it sits alongside it. In every case:
261
+ - **no render** — Build and every non-Goal mode (and a Goal session before a
262
+ goal is set) render nothing here, so OpenCode's native todo section stays in
263
+ the same position. The section is scoped to the session that owns the goal: a
264
+ Build session in the same worktree never inherits another session's goal.
233
265
 
234
266
  Toggle/recolour with `sidebarBanner`, `sidebarColor` (running), `sidebarDoneColor`
235
267
  (done), `sidebarMutedColor`, `sidebarRainbowMs`, or the `GOAL_GUARD_SIDEBAR_*`
236
268
  env vars.
237
269
 
238
270
  **How it loads — important.** TUI plugins are **not** loaded from the `plugins/`
239
- dir; OpenCode loads them from `tui.json`. The Goal sidebar waits to register its
240
- `sidebar_content` slot until a real Goal session exists, so non-Goal modes do not
241
- get a blank replacement slot. With `--global`, the installer writes
242
- `~/.config/opencode/tui.json` for you (merge-safe):
271
+ dir; OpenCode loads them from `tui.json`. The Goal sidebar registers a
272
+ `sidebar_content` slot that renders content **only** for the active session when
273
+ that session is a Goal session; for any other session it renders nothing, so
274
+ non-Goal modes keep their native todo section. With `--global`, the installer
275
+ writes `~/.config/opencode/tui.json` for you (merge-safe):
243
276
 
244
277
  ```json
245
278
  { "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-goal-mode"] }
@@ -299,6 +332,7 @@ Or via environment variables (`GOAL_GUARD_*`):
299
332
  | `injectSystemState` / `GOAL_GUARD_INJECT_SYSTEM_STATE` | `true` | Inject live state into the prompt. |
300
333
  | `persist` / `GOAL_GUARD_PERSIST` | `true` | Persist state under the XDG state dir. |
301
334
  | `contextualGates` / `GOAL_GUARD_CONTEXTUAL_GATES` | `true` | Require specialist gates by goal keywords. |
335
+ | `restrictSubagents` / `GOAL_GUARD_RESTRICT_SUBAGENTS` | `true` | Block non-Goal agents from invoking the `goal-*` subagents via the task tool. |
302
336
  | `maxSessions` / `GOAL_GUARD_MAX_SESSIONS` | `200` | Session cache size. |
303
337
  | `sessionTtlMs` / `GOAL_GUARD_SESSION_TTL_MS` | `86400000` | Idle session TTL. |
304
338
  | `toastOnBlock` / `GOAL_GUARD_TOAST_ON_BLOCK` | `true` | Toast when something is blocked. |
@@ -1,54 +1,78 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="760" height="280" viewBox="0 0 760 280" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" role="img" aria-label="OpenCode Goal Mode sidebar banner: the active goal in yellow with a gate-status line, and a grey No goal state.">
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1000" height="452" viewBox="0 0 1000 452" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" role="img" aria-label="OpenCode Goal Mode sidebar: in a goal session the plugin renders a stacked Goal todo section — a bold GOAL label, the goal title, a gates/status line, and per-gate todo rows — each on its own line in its own colour, opening with a first-display rainbow then settling (GOAL yellow, title white, status cyan) and turning red when done. Build and other modes render no Goal section and keep OpenCode's native todo list.">
2
2
  <defs>
3
3
  <style>
4
4
  .win { fill: #0d1117; stroke: #30363d; stroke-width: 1; }
5
5
  .bar { fill: #161b22; }
6
6
  .dot { stroke-width: 0; }
7
7
  .title { fill: #8b949e; font-size: 12px; }
8
- .label { fill: #8b949e; font-size: 12px; }
9
- .goal { fill: #FFD700; font-size: 13px; }
10
- .goalb { fill: #FFD700; font-size: 13px; font-weight: 700; }
11
- .status { fill: #FFD700; font-size: 12px; }
12
- .muted { fill: #808080; font-size: 13px; }
8
+ .hdr { fill: #8b949e; font-size: 11px; letter-spacing: 0.5px; }
9
+ .lbl { font-size: 13px; font-weight: 700; }
10
+ .ln { font-size: 13px; }
11
+ .sm { font-size: 12px; }
13
12
  .chat { fill: #c9d1d9; font-size: 12px; }
14
13
  .dim { fill: #6e7681; font-size: 12px; }
15
14
  .ok { fill: #2da44e; font-size: 12px; }
16
15
  .div { stroke: #30363d; stroke-width: 1; }
16
+ /* first-display rainbow — successive hues per line (RAINBOW[index]) */
17
+ .r0 { fill: #FF5555; } .r1 { fill: #FFAA00; } .r2 { fill: #FFFF55; } .r3 { fill: #55FF55; }
18
+ /* settled running palette */
19
+ .label { fill: #FFD700; } /* GOAL — yellow */
20
+ .gtitle { fill: #FFFFFF; } /* goal title — bright white */
21
+ .meta { fill: #8BE9FD; } /* gates · status — cyan */
22
+ .todo { fill: #808080; } /* pending todo rows — grey */
23
+ .done { fill: #FF5555; } /* done state — red */
24
+ .check { fill: #50FA7B; } /* ✓ done rows — green */
17
25
  </style>
18
26
  </defs>
19
27
 
20
- <!-- window -->
21
- <rect class="win" x="1" y="1" width="758" height="278" rx="8"/>
22
- <rect class="bar" x="1" y="1" width="758" height="30" rx="8"/>
23
- <rect class="bar" x="1" y="20" width="758" height="11"/>
28
+ <rect class="win" x="1" y="1" width="998" height="450" rx="8"/>
29
+ <rect class="bar" x="1" y="1" width="998" height="30" rx="8"/>
30
+ <rect class="bar" x="1" y="20" width="998" height="11"/>
24
31
  <circle class="dot" cx="20" cy="16" r="5" fill="#ff5f56"/>
25
32
  <circle class="dot" cx="38" cy="16" r="5" fill="#ffbd2e"/>
26
33
  <circle class="dot" cx="56" cy="16" r="5" fill="#27c93f"/>
27
34
  <text class="title" x="86" y="20">opencode — goal mode</text>
28
35
 
29
- <!-- vertical divider between chat and sidebar -->
30
- <line class="div" x1="470" y1="31" x2="470" y2="279"/>
36
+ <line class="div" x1="470" y1="31" x2="470" y2="451"/>
31
37
 
32
38
  <!-- chat pane (left) -->
33
- <text class="dim" x="20" y="58">▌ goal</text>
39
+ <text class="dim" x="20" y="58">▌ goal</text>
34
40
  <text class="chat" x="20" y="82">▸ Goal Contract recorded (4 acceptance criteria)</text>
35
41
  <text class="chat" x="20" y="104">▸ implementing… running verification</text>
36
42
  <text class="ok" x="20" y="126">✓ Security Reviewer → PASS</text>
37
43
  <text class="ok" x="20" y="148">✓ Verifier → PASS</text>
38
44
  <text class="dim" x="20" y="170">▸ Diff Reviewer running…</text>
45
+ <text class="dim" x="20" y="212">Build / Plan / custom agents cannot</text>
46
+ <text class="dim" x="20" y="230">invoke the goal-* reviewers, and never</text>
47
+ <text class="dim" x="20" y="248">get a Goal section in their sidebar.</text>
39
48
 
40
- <!-- sidebar pane (right) -->
41
- <text class="label" x="490" y="58">SESSION</text>
42
- <line class="div" x1="490" y1="68" x2="740" y2="68"/>
49
+ <!-- Panel 1: running, first-display rainbow -->
50
+ <text class="hdr" x="490" y="52">GOAL SESSION · running · first-display rainbow</text>
51
+ <line class="div" x1="490" y1="60" x2="980" y2="60"/>
52
+ <text class="lbl r0" x="490" y="80">GOAL</text>
53
+ <text class="ln r1" x="490" y="98">Ship the OAuth refactor</text>
54
+ <text class="sm r2" x="490" y="116">0/5 gates · in progress</text>
55
+ <text class="sm r3" x="490" y="134">□ Pass Prompt Auditor</text>
43
56
 
44
- <!-- goal banner (active) -->
45
- <text x="490" y="98"><tspan class="goal">◆ </tspan><tspan class="goalb">GOAL</tspan><tspan class="goal"> Ship the OAuth</tspan></text>
46
- <text class="goal" x="490" y="116">refactor</text>
47
- <text class="status" x="490" y="138">3/5 gates · dirty</text>
57
+ <!-- Panel 2: running, settled (distinct colours per line) -->
58
+ <text class="hdr" x="490" y="166">GOAL SESSION · running · settled colours</text>
59
+ <line class="div" x1="490" y1="174" x2="980" y2="174"/>
60
+ <text class="lbl label" x="490" y="194">GOAL</text>
61
+ <text class="ln gtitle" x="490" y="212">Ship the OAuth refactor</text>
62
+ <text class="sm meta" x="490" y="230">3/5 gates · in progress</text>
63
+ <text class="sm todo" x="490" y="248">□ Pass Security Reviewer</text>
48
64
 
49
- <line class="div" x1="490" y1="170" x2="740" y2="170"/>
65
+ <!-- Panel 3: done (red) -->
66
+ <text class="hdr" x="490" y="280">GOAL SESSION · done</text>
67
+ <line class="div" x1="490" y1="288" x2="980" y2="288"/>
68
+ <text class="lbl done" x="490" y="308">GOAL</text>
69
+ <text class="ln done" x="490" y="326">Fix the parser bug</text>
70
+ <text class="sm done" x="490" y="344">5/5 gates · completed · 2 review cycles</text>
71
+ <text class="sm check" x="490" y="362">✓ All acceptance criteria covered</text>
50
72
 
51
- <!-- no-goal state -->
52
- <text class="label" x="490" y="196">when no goal is set</text>
53
- <text class="muted" x="490" y="222">No goal</text>
73
+ <!-- Panel 4: build / other mode → native todos, no Goal section -->
74
+ <text class="hdr" x="490" y="394">BUILD / OTHER MODE</text>
75
+ <line class="div" x1="490" y1="402" x2="980" y2="402"/>
76
+ <text class="todo" x="490" y="422" font-size="12">▢ OpenCode's native todo section</text>
77
+ <text class="dim" x="490" y="440">(no Goal section is rendered here)</text>
54
78
  </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-mode",
3
- "version": "0.3.11",
3
+ "version": "0.4.1",
4
4
  "description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "plugins/goal-sidebar.tsx",
@@ -20,6 +20,8 @@ export const DEFAULT_CONFIG = Object.freeze({
20
20
  persist: true,
21
21
  /** Require the contextual specialist gates derived from goal text / changed files. */
22
22
  contextualGates: true,
23
+ /** Block non-Goal agents from invoking the goal-* subagents via the task tool. */
24
+ restrictSubagents: true,
23
25
  /** Maximum tracked sessions before LRU eviction. */
24
26
  maxSessions: 200,
25
27
  /** Idle TTL (ms) after which a session's state may be dropped. 0 disables TTL. */
@@ -68,6 +70,7 @@ function fromEnv(env) {
68
70
  GOAL_GUARD_INJECT_SYSTEM_STATE: ["injectSystemState", coerceBool],
69
71
  GOAL_GUARD_PERSIST: ["persist", coerceBool],
70
72
  GOAL_GUARD_CONTEXTUAL_GATES: ["contextualGates", coerceBool],
73
+ GOAL_GUARD_RESTRICT_SUBAGENTS: ["restrictSubagents", coerceBool],
71
74
  GOAL_GUARD_MAX_SESSIONS: ["maxSessions", coerceInt],
72
75
  GOAL_GUARD_SESSION_TTL_MS: ["sessionTtlMs", coerceInt],
73
76
  GOAL_GUARD_TOAST_ON_BLOCK: ["toastOnBlock", coerceBool],
@@ -23,7 +23,7 @@ import { createStore, createState } from "./state.js";
23
23
  import { createPersistence } from "./persistence.js";
24
24
  import { createLogger } from "./logger.js";
25
25
  import { analyzeCommand, looksLikeDestructiveBash, looksLikeMutatingBash, isVerification } from "./shell.js";
26
- import { isPrimaryAgent, isReviewAgent, CYCLE_CLOSING_AGENT, prettyAgentName } from "./agents.js";
26
+ import { isPrimaryAgent, isReviewAgent, isGoalAgent, CYCLE_CLOSING_AGENT, prettyAgentName } from "./agents.js";
27
27
  import { textOf, parseVerdict, recordVerdict } from "./verdicts.js";
28
28
  import { completionAllowed, missingGates, refreshStickyGates } from "./gates.js";
29
29
  import { evaluateCompletionClaim } from "./completion.js";
@@ -41,6 +41,11 @@ function commandOf(input, output) {
41
41
  return String(output?.args?.command ?? input?.args?.command ?? "");
42
42
  }
43
43
 
44
+ /** The subagent a `task` call targets (args live on the output in tool.execute.before). */
45
+ function taskTarget(input, output) {
46
+ return String(output?.args?.subagent_type ?? input?.args?.subagent_type ?? output?.args?.agent ?? input?.args?.agent ?? "").trim();
47
+ }
48
+
44
49
  function partsText(parts) {
45
50
  if (!Array.isArray(parts)) return "";
46
51
  return parts
@@ -130,6 +135,29 @@ export function createGuard(input = {}, options = {}, overrides = {}) {
130
135
 
131
136
  async "tool.execute.before"(inp, out) {
132
137
  const state = store.stateFor(inp?.sessionID);
138
+
139
+ // The goal-* subagents belong to Goal Mode. OpenCode resolves subagents
140
+ // globally, so without this a Build/Plan/custom agent could invoke a Goal
141
+ // reviewer directly. Only a Goal session (the `goal` primary, or a session
142
+ // the guard has already marked active) may spawn them. Non-goal targets
143
+ // (explore/general/scout) are never restricted.
144
+ if (inp?.tool === "task" && config.restrictSubagents) {
145
+ const target = taskTarget(inp, out);
146
+ if (target && isGoalAgent(target)) {
147
+ const caller = state.currentAgent;
148
+ const callerIsGoal = isPrimaryAgent(caller) || state.active;
149
+ if (!callerIsGoal) {
150
+ state.dirtyReasons.push(`blocked non-Goal invocation of subagent ${target}`);
151
+ if (config.toastOnBlock) logger.toast(`Goal Guard blocked ${prettyAgentName(target)} (Goal-only subagent)`, "error");
152
+ persist();
153
+ throw new Error(
154
+ `Goal Guard: "${target}" is a Goal Mode subagent and can only be invoked by the Goal agent. ` +
155
+ `The "${caller || "current"}" agent cannot call it — start with /goal or switch to the goal agent.`,
156
+ );
157
+ }
158
+ }
159
+ }
160
+
133
161
  if (inp?.tool === "bash") {
134
162
  const command = commandOf(inp, out);
135
163
  const analysis = analyzeCommand(command);
@@ -150,6 +178,13 @@ export function createGuard(input = {}, options = {}, overrides = {}) {
150
178
  async "tool.execute.after"(inp, out) {
151
179
  try {
152
180
  const state = store.stateFor(inp?.sessionID);
181
+ // Goal bookkeeping is GOAL-ONLY. A Build/Plan/custom session must never have
182
+ // its edits, mutations, verification, or verdicts recorded as goal state —
183
+ // otherwise the guard would treat a non-Goal message/task as if it were a
184
+ // goal. Only an active Goal session (or a goal-namespace subagent's own
185
+ // session, for verdict capture) is tracked. Destructive-command blocking is
186
+ // handled in tool.execute.before and still applies in every mode.
187
+ if (!state.active && !isGoalAgent(state.currentAgent)) return;
153
188
  const tool = inp?.tool;
154
189
  const isReviewing = isReviewAgent(state.currentAgent);
155
190
 
@@ -30,25 +30,20 @@ function normalize(record) {
30
30
  }
31
31
 
32
32
  /**
33
- * Choose which session's goal to show. When OpenCode gives us a concrete session
34
- * id, never fall back to another session: Build/non-Goal sessions must not show a
35
- * Goal from the same worktree. The most-recent active fallback is only for
36
- * no-session contexts such as initial sidebar registration polling.
33
+ * Resolve the guard state for EXACTLY this session id, and only when it is an
34
+ * active Goal session. There is deliberately NO "most-recently-touched" global
35
+ * fallback: a Build or other session in the same worktree must never inherit a
36
+ * Goal from a sibling session. This mirrors goal-sidebar.tsx's pickSession so the
37
+ * Node-testable projection and the real TUI component behave identically.
37
38
  */
38
39
  export function pickSession(snapshot, sessionId) {
39
- if (!snapshot || !Array.isArray(snapshot.sessions)) return null;
40
- const records = snapshot.sessions
41
- .filter((e) => Array.isArray(e) && e.length === 2)
42
- .map(([key, st]) => [key, normalize(st)]);
43
- if (sessionId) {
44
- const direct = records.find(([key, st]) => key === sessionId && st.active);
45
- if (direct) return direct[1];
46
- return null;
40
+ if (!snapshot || !Array.isArray(snapshot.sessions) || !sessionId) return null;
41
+ for (const entry of snapshot.sessions) {
42
+ if (!Array.isArray(entry) || entry.length !== 2) continue;
43
+ const [key, st] = entry;
44
+ if (key === sessionId && st && typeof st === "object" && st.active) return normalize(st);
47
45
  }
48
- const active = records.filter(([, st]) => st.active);
49
- if (active.length === 0) return null;
50
- active.sort((a, b) => (b[1].touchedAt || 0) - (a[1].touchedAt || 0));
51
- return active[0][1];
46
+ return null;
52
47
  }
53
48
 
54
49
  /**
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { requiredGates, missingGates, gatePassedFresh } from "./gates.js";
7
+ import { prettyAgentName } from "./agents.js";
7
8
 
8
9
  /**
9
10
  * A short, single-line label for the current goal.
@@ -35,33 +36,55 @@ function criterionEvidenceFresh(state, criterion) {
35
36
  return entries.some((entry) => evidenceMatchesCriterion(entry, criterion) && evidenceFresh(entry, state));
36
37
  }
37
38
 
39
+ function clip(text, max) {
40
+ const s = String(text || "").replace(/\s+/g, " ").trim();
41
+ return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`;
42
+ }
43
+
44
+ /**
45
+ * Structured, ordered todo rows for the sidebar. Each row is a concrete, checkable
46
+ * step toward Goal completion — acceptance criteria first (✓ when fresh evidence
47
+ * covers them), then a re-verify nudge if the tree changed, then ONE row per still-
48
+ * missing review gate (friendly name, e.g. "Pass Security Reviewer"). This is the
49
+ * Goal plugin's own todo system: it is derived from real guard state (contract,
50
+ * evidence freshness, dirty flag, required gates), not transcript memory, so it
51
+ * tracks completion truthfully and updates as gates clear.
52
+ */
38
53
  function sidebarTodos(state, required, missing) {
39
54
  const criteria = Array.isArray(state?.contract?.acceptanceCriteria) ? state.contract.acceptanceCriteria : [];
40
55
  const items = [];
41
- for (const criterion of criteria.slice(0, 5)) {
42
- const text = String(criterion || "").replace(/\s+/g, " ").trim();
56
+ for (const criterion of criteria.slice(0, 4)) {
57
+ const text = clip(criterion, 52);
43
58
  if (!text) continue;
44
- items.push({
45
- status: criterionEvidenceFresh(state, text) ? "done" : "todo",
46
- text: text.length <= 58 ? text : `${text.slice(0, 57).trimEnd()}…`,
47
- });
59
+ items.push({ status: criterionEvidenceFresh(state, text) ? "done" : "todo", text });
48
60
  }
49
- if (state?.dirty) items.push({ status: "todo", text: "Rerun verification and reviews after latest changes" });
50
- if (missing.length > 0) items.push({ status: "todo", text: `Clear review gates: ${missing.slice(0, 3).join(", ")}${missing.length > 3 ? "…" : ""}` });
51
- if (items.length === 0 && required.length > 0) items.push({ status: "todo", text: "Record Goal Contract acceptance criteria" });
52
- return items.slice(0, 7);
61
+ if (state?.dirty) items.push({ status: "todo", text: "Re-verify & re-review after recent edits" });
62
+ // One row per missing/stale review gate, by friendly name more scannable than a
63
+ // comma-joined id list and it shows exactly which reviewer is still owed.
64
+ for (const gate of missing.slice(0, 4)) {
65
+ items.push({ status: "todo", text: `Pass ${prettyAgentName(gate)}` });
66
+ }
67
+ if (items.length === 0 && required.length > 0) {
68
+ items.push({ status: "todo", text: "Record the Goal Contract & acceptance criteria" });
69
+ }
70
+ return items.slice(0, 8);
53
71
  }
54
72
 
55
73
  /**
56
74
  * Compact projection for the TUI sidebar todo section. ALWAYS returns an object with a
57
- * three-way `state`, plus three lines that stack vertically in the sidebar:
58
- * - `goal` → line 1: the short AI goal title.
59
- * - `gates` → line 2: the gate count, e.g. "0/7 gates".
60
- * - `status` → line 3: the lifecycle status, e.g. "in progress · changes pending"
61
- * or "completed · 2 review cycles".
75
+ * three-way `state`, plus lines that stack vertically in the sidebar — each rendered
76
+ * on its OWN line in a distinct colour so it never reads as one run of text:
77
+ * - `label` → line 1: the fixed "GOAL" header (yellow when running, red when done).
78
+ * - `goal` → line 2: the short AI goal title.
79
+ * - `gates` → line 3: gate count + lifecycle, e.g. "3/5 gates · in progress" or
80
+ * "5/5 gates · completed · 2 review cycles". No "changes pending"
81
+ * noise — pending work surfaces as a structured todo row instead.
82
+ * - `todos` → following lines: structured acceptance/verification/gate todos.
62
83
  * State drives colour: "running" = rainbow first, then yellow; "done" = red;
63
- * "none" = render nothing so non-Goal modes keep the native todo section.
84
+ * "none" = render nothing so non-Goal / no-goal sessions keep the native todo section.
64
85
  */
86
+ export const GOAL_LABEL = "GOAL";
87
+
65
88
  export function sidebarView(state, config) {
66
89
  if (!state || !state.active) return NO_GOAL;
67
90
  const goal = shortGoalLabel(state);
@@ -76,10 +99,11 @@ export function sidebarView(state, config) {
76
99
  if (done) {
77
100
  return {
78
101
  state: "done",
102
+ label: GOAL_LABEL,
79
103
  goal,
80
104
  gates,
81
105
  status: `completed · ${cycles} review cycle${cycles === 1 ? "" : "s"}`,
82
- todoTitle: "Goal todos",
106
+ todoTitle: GOAL_LABEL,
83
107
  todos: todos.length ? todos.map((item) => ({ ...item, status: "done" })) : [{ status: "done", text: "All Goal completion gates are clear" }],
84
108
  passing,
85
109
  required: required.length,
@@ -88,10 +112,11 @@ export function sidebarView(state, config) {
88
112
  }
89
113
  return {
90
114
  state: "running",
115
+ label: GOAL_LABEL,
91
116
  goal,
92
117
  gates,
93
- status: `in progress${state.dirty ? " · changes pending" : ""}`,
94
- todoTitle: "Goal todos",
118
+ status: "in progress",
119
+ todoTitle: GOAL_LABEL,
95
120
  todos,
96
121
  passing,
97
122
  required: required.length,
@@ -50,6 +50,7 @@ export function createGoalTools({ store, config, persist }) {
50
50
  args: {},
51
51
  async execute(_args, ctx) {
52
52
  const state = store.stateFor(ctx.sessionID);
53
+ if (!requireGoalMode(state)) return goalModeOnlyResult();
53
54
  const report = statusReport(state, config);
54
55
  const goal = report.goal ? `“${report.goal}” — ` : "";
55
56
  return {
@@ -73,6 +74,7 @@ export function createGoalTools({ store, config, persist }) {
73
74
  args: {},
74
75
  async execute(_args, ctx) {
75
76
  const state = store.stateFor(ctx.sessionID);
77
+ if (!requireGoalMode(state)) return goalModeOnlyResult();
76
78
  const report = evidenceMapReport(state, config);
77
79
  const covered = report.criteria.filter((item) => item.status === "covered").length;
78
80
  return {
@@ -90,6 +92,7 @@ export function createGoalTools({ store, config, persist }) {
90
92
  args: {},
91
93
  async execute(_args, ctx) {
92
94
  const state = store.stateFor(ctx.sessionID);
95
+ if (!requireGoalMode(state)) return goalModeOnlyResult();
93
96
  const report = reviewerMemoryReport(state);
94
97
  return {
95
98
  title: `Reviewer Memory: ${report.open.length} open findings`,
@@ -2,9 +2,14 @@
2
2
  /**
3
3
  * Goal Mode — TUI sidebar todo section.
4
4
  *
5
- * In Goal agent sessions this replaces the native-looking todo area with a
6
- * Goal-owned, evidence-aware todo section. Non-Goal sessions render nothing here
7
- * so Build and other modes keep OpenCode's normal todo section in the same slot.
5
+ * In Goal agent sessions this renders a Goal-owned, evidence-aware todo section
6
+ * into the sidebar_content slot (GOAL label, goal title, gate/status line, and
7
+ * structured todo rows). OpenCode renders the native todo list as that slot's
8
+ * fallback, so in replace/single-winner slot mode this REPLACES the native todos
9
+ * while a goal is active; rendering nothing (non-Goal or no-goal sessions) brings
10
+ * the native todos back. The section is strictly per-session: it is keyed by
11
+ * props.session_id, so a Build session in the same worktree never inherits
12
+ * another session's goal.
8
13
  *
9
14
  * How OpenCode loads this: TUI plugins are listed in `~/.config/opencode/tui.json`
10
15
  * (NOT the plugins/ dir) and resolved via the package's `exports["./tui"]`. The
@@ -22,9 +27,12 @@ import { createSignal, onCleanup, For, Show } from "solid-js";
22
27
  import { sidebarView, NO_GOAL } from "./goal-guard/summary.js";
23
28
  import { DEFAULT_CONFIG } from "./goal-guard/config.js";
24
29
 
25
- const DEFAULT_COLOR = "#FFD700"; // running — yellow
30
+ const DEFAULT_COLOR = "#FFD700"; // running — GOAL label, yellow
26
31
  const DEFAULT_DONE = "#FF5555"; // done — red
27
- const DEFAULT_MUTED = "#808080"; // no goal — grey
32
+ const DEFAULT_MUTED = "#808080"; // pending todo rows — grey
33
+ const TITLE_COLOR = "#FFFFFF"; // goal title line (running) — bright, distinct from the yellow GOAL label
34
+ const META_COLOR = "#8BE9FD"; // gates · status line (running) — cyan accent
35
+ const TODO_DONE_COLOR = "#50FA7B"; // ✓ done todo rows — green
28
36
  const POLL_MS = 1500;
29
37
  const RAINBOW = ["#FF5555", "#FFAA00", "#FFFF55", "#55FF55", "#55FFFF", "#5599FF", "#FF55FF"];
30
38
 
@@ -60,21 +68,21 @@ function readSnapshot(worktree) {
60
68
  }
61
69
  }
62
70
 
63
- /** Most-recently-touched active session, preferring an explicit active sessionId. */
71
+ /**
72
+ * Resolve the guard state for EXACTLY this session id, and only when it is an
73
+ * active Goal session. There is deliberately NO "most-recently-touched" global
74
+ * fallback: a Build or other session in the same worktree must never inherit a
75
+ * Goal from a sibling session. (Mirrors the reference OpenCode TUI plugin's
76
+ * explicit per-session rule — do not fall back to the latest state.)
77
+ */
64
78
  function pickSession(snapshot, sessionId) {
65
- if (!snapshot || !Array.isArray(snapshot.sessions)) return null;
66
- const records = snapshot.sessions
67
- .filter((e) => Array.isArray(e) && e.length === 2)
68
- .map(([key, st]) => [key, st && typeof st === "object" ? st : {}]);
69
- if (sessionId) {
70
- const direct = records.find(([key, st]) => key === sessionId && st.active);
71
- if (direct) return direct[1];
72
- return null;
79
+ if (!snapshot || !Array.isArray(snapshot.sessions) || !sessionId) return null;
80
+ for (const entry of snapshot.sessions) {
81
+ if (!Array.isArray(entry) || entry.length !== 2) continue;
82
+ const [key, st] = entry;
83
+ if (key === sessionId && st && typeof st === "object" && st.active) return st;
73
84
  }
74
- const active = records.filter(([, st]) => st.active);
75
- if (active.length === 0) return null;
76
- active.sort((a, b) => (b[1].touchedAt || 0) - (a[1].touchedAt || 0));
77
- return active[0][1];
85
+ return null;
78
86
  }
79
87
 
80
88
  function readModel(worktree, sessionId) {
@@ -94,17 +102,13 @@ const id = "goal-mode-sidebar";
94
102
  /** @type {import("@opencode-ai/plugin/tui").TuiPlugin} */
95
103
  const tui = async (api, options) => {
96
104
  try {
97
- const { enabled, color, doneColor, rainbowMs } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
105
+ const { enabled, color, doneColor, muted, rainbowMs } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
98
106
  if (!enabled) return;
99
107
  if (!api?.slots?.register) return; // runtime without the slot API → no-op.
100
108
 
101
109
  const worktree = api.state?.path?.worktree || api.state?.path?.directory;
102
110
 
103
- let registered = false;
104
- const register = () => {
105
- if (registered) return;
106
- registered = true;
107
- api.slots.register({
111
+ api.slots.register({
108
112
  order: 50,
109
113
  slots: {
110
114
  sidebar_content(_ctx, props) {
@@ -124,38 +128,40 @@ const tui = async (api, options) => {
124
128
  const rainbowTimer = setTimeout(() => setRainbow(false), Math.max(0, rainbowMs || 0));
125
129
  onCleanup(() => clearInterval(timer));
126
130
  onCleanup(() => clearTimeout(rainbowTimer));
127
- const fg = () => (model().state === "done" ? doneColor : color);
128
- const lineColor = (index = 0) => (rainbow() && model().state === "running" ? RAINBOW[index % RAINBOW.length] : fg());
129
- // Goal sessions render a Goal-owned todo section; non-Goal sessions return undefined so native todos remain.
131
+ const isRainbow = () => rainbow() && model().state === "running";
132
+ // Settled (post-rainbow) colour for each header line. When done, every
133
+ // line is red; while running each line gets its OWN highlight colour so
134
+ // the GOAL label, the goal title, and the status never read as one text.
135
+ const settled = (kind) => {
136
+ if (model().state === "done") return doneColor;
137
+ if (kind === "label") return color; // GOAL — yellow
138
+ if (kind === "title") return TITLE_COLOR; // goal title — bright white
139
+ return META_COLOR; // gates · status — cyan
140
+ };
141
+ const lineColor = (index, kind) => (isRainbow() ? RAINBOW[index % RAINBOW.length] : settled(kind));
142
+ const todoColor = (index, item) => {
143
+ if (isRainbow()) return RAINBOW[index % RAINBOW.length];
144
+ if (item.status === "done") return TODO_DONE_COLOR;
145
+ return model().state === "done" ? doneColor : muted;
146
+ };
147
+ // Goal sessions render a Goal-owned todo section (GOAL label, then the goal
148
+ // title, status, and structured todos — each on its own line). Non-Goal /
149
+ // no-goal sessions returned undefined above, so native todos remain.
130
150
  return (
131
151
  <Show when={model().state !== "none"}>
132
152
  <box flexDirection="column" paddingTop={1}>
133
- <text fg={lineColor(0)}>
134
- <b>{model().todoTitle || "Goal todos"}</b>
135
- {` ${model().goal}`}
136
- </text>
137
- <text fg={lineColor(1)}>{`${model().gates} · ${model().status}`}</text>
153
+ <text fg={lineColor(0, "label")}><b>{model().label || "GOAL"}</b></text>
154
+ <text fg={lineColor(1, "title")}>{model().goal}</text>
155
+ <text fg={lineColor(2, "meta")}>{`${model().gates} · ${model().status}`}</text>
138
156
  <For each={model().todos || []}>
139
- {(item, index) => <text fg={lineColor(index() + 2)}>{`${item.status === "done" ? "✓" : "□"} ${item.text}`}</text>}
157
+ {(item, index) => <text fg={todoColor(index() + 3, item)}>{`${item.status === "done" ? "✓" : "□"} ${item.text}`}</text>}
140
158
  </For>
141
159
  </box>
142
160
  </Show>
143
161
  );
144
162
  },
145
163
  },
146
- });
147
- };
148
-
149
- if (readModel(worktree).state !== "none") {
150
- register();
151
- } else {
152
- const registrationTimer = setInterval(() => {
153
- if (readModel(worktree).state !== "none") {
154
- clearInterval(registrationTimer);
155
- register();
156
- }
157
- }, POLL_MS);
158
- }
164
+ });
159
165
  } catch {
160
166
  /* TUI runtime missing or API drift — render nothing rather than crash. */
161
167
  }
@@ -15,6 +15,7 @@ import {
15
15
  import { join, resolve, relative, dirname } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { createHash } from "node:crypto";
18
+ import { homedir } from "node:os";
18
19
  import { parseArgs } from "node:util";
19
20
 
20
21
  const { values } = parseArgs({
@@ -67,8 +68,18 @@ if (values.global && values.target) {
67
68
  function resolveTarget() {
68
69
  if (values.target) return resolve(String(values.target));
69
70
  if (values.global) {
70
- const home = process.env.HOME;
71
- if (!home) throw new Error("Cannot resolve HOME for --global install");
71
+ // OpenCode reads global config from ~/.config/opencode. Resolve home from $HOME,
72
+ // falling back to the OS home dir (homedir() works where $HOME is unset, e.g.
73
+ // some CI/container shells) so --global doesn't fail in those environments.
74
+ let home = process.env.HOME;
75
+ if (!home) {
76
+ try {
77
+ home = homedir();
78
+ } catch {
79
+ home = "";
80
+ }
81
+ }
82
+ if (!home) throw new Error("Cannot resolve a home directory for --global install. Pass --target <dir> instead.");
72
83
  return join(home, ".config", "opencode");
73
84
  }
74
85
  return resolve(process.cwd(), ".opencode");