opencode-goal-mode 0.4.1 → 0.4.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.3
4
+
5
+ ### Build mode no longer behaves like a goal
6
+
7
+ - Switching a session from the `goal` agent to Build (or any non-goal agent) now
8
+ **deactivates** it: `state.active` tracks the current agent (`isPrimaryAgent`),
9
+ instead of latching `true` forever. The sidebar also gates on the session's live
10
+ current agent (its latest message), so when you switch to Build the Goal section
11
+ disappears and OpenCode's native todos return — and a Build session can no longer
12
+ invoke the `goal-*` subagents or have its completion claims policed.
13
+ - Goal **worker** subagents (e.g. `goal-implementer`) are no longer activated by
14
+ their own edits, so Goal completion enforcement is never injected into a worker's
15
+ prompt. Bookkeeping runs only for active goal sessions and review subagents.
16
+
17
+ ### `npm install -g` now updates everything, automatically
18
+
19
+ - A global-install `postinstall` runs the installer for you — it copies the
20
+ components into `~/.config/opencode`, registers the Goal sidebar, and clears
21
+ OpenCode's stale plugin cache — so `npm install -g opencode-goal-mode` **alone**
22
+ fully installs or upgrades Goal Mode and the new version actually loads on the
23
+ next restart. It runs only for global installs, never for repo/dev/dependency
24
+ installs, and never fails the npm install (it prints a hint if it can't finish).
25
+
26
+ ### Fixes
27
+
28
+ - **CI green again:** the headless visual test no longer requires
29
+ `@opentui/solid/jsx-dev-runtime` (the CI visual job was failing).
30
+ - Hardened `gatePassedFresh` against partial/legacy snapshots so the TUI never
31
+ silently fails to render an active goal.
32
+ - Compaction context is only added for active goal sessions (no stray Goal state in
33
+ a Build session's compaction summary).
34
+ - Docs: corrected the sidebar description (separate gate/status lines; the label is
35
+ `GOAL`, not "Goal todos").
36
+
37
+ ## v0.4.2
38
+
39
+ ### The sidebar Goal section now actually renders in the live TUI
40
+
41
+ - **Root-cause fix.** The `sidebar_content` slot bailed at mount with
42
+ `return undefined` whenever no goal existed *yet* — but the goal is normally set
43
+ *after* the sidebar mounts, so the polling component never started and the Goal
44
+ section never appeared. The slot now **always mounts a reactive, polling
45
+ component** and reveals the section (via `<Show>`) the moment the goal is
46
+ recorded. Verified by driving the real OpenCode TUI in a PTY.
47
+ - **Stale plugin cache.** OpenCode caches TUI plugins under
48
+ `~/.cache/opencode/packages/<name>@<spec>/` and never re-checks npm, so upgrades
49
+ kept loading the *old* sidebar build. The installer now clears that cache on
50
+ install and uninstall, so a restart picks up the installed version.
51
+ - The first-display rainbow now triggers when the goal first appears (not at
52
+ mount, when there may be no goal yet).
53
+ - The sidebar resolves state under both the worktree and directory path keys, so a
54
+ path-key mismatch can't hide an active goal.
55
+
56
+ ### Sidebar layout
57
+
58
+ - The gate count and the lifecycle status are now on **separate lines**, each in
59
+ its own colour (GOAL = yellow, title = white, gates = cyan, status = orange).
60
+
61
+ ### Native todos are replaced in goal mode
62
+
63
+ - The `goal` agent no longer uses the native `todowrite` tool (it is disabled in
64
+ Goal Mode). Because OpenCode renders native todos as their own sidebar slot, the
65
+ only way to replace them is to stop producing them — so in a goal session the
66
+ native todo list stays empty and the structured Goal-owned section is what shows.
67
+ Build and every other mode keep their native todos.
68
+
3
69
  ## v0.4.1
4
70
 
5
71
  ### Restructured Goal sidebar todo section
package/README.md CHANGED
@@ -244,9 +244,11 @@ enforcement and writes its state to disk, and an experimental TUI plugin
244
244
  slot, stacked on separate lines, each in its own colour so it never reads as one
245
245
  run of text:
246
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);
247
+ - the short goal title (white);
248
+ - the gate count `passing/total gates` (cyan), on its own line;
249
+ - the lifecycle status (orange) on its own line — `in progress`, or
250
+ `completed · N review cycles`. No "changes pending" noise; pending work shows
251
+ as a todo row instead;
250
252
  - structured todo rows derived from real guard state: one per acceptance
251
253
  criterion (✓ when fresh evidence covers it), a re-verify row when the tree
252
254
  changed, and one row per still-missing review gate by friendly name
@@ -278,12 +280,18 @@ enforcement and writes its state to disk, and an experimental TUI plugin
278
280
  { "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-goal-mode"] }
279
281
  ```
280
282
 
281
- Restart OpenCode after install so it picks up the TUI plugin (it resolves the
282
- package and provides the `@opentui/solid` runtime). The Goal todo section appears
283
- in a **Goal session** view (not the home screen and not Build mode). The visual
284
- harness renders it with a headless OpenTUI renderer in
285
- [visual test](tools/visual-test/README.md) (`npm run test:visual`). The
286
- enforcement core is a separate server plugin and works regardless of the sidebar.
283
+ OpenCode installs the referenced package into its own plugin cache
284
+ (`~/.cache/opencode/packages/`) and provides the `@opentui/solid` + `solid-js`
285
+ runtime to it. It does **not** re-check that cache for newer versions, so the
286
+ installer clears the cached copy on install/uninstall that's why an upgrade
287
+ needs only a restart to load the new sidebar. Restart OpenCode after install. The
288
+ Goal todo section appears in a **Goal session** view (not the home screen and not
289
+ Build mode), and because the Goal agent does its own todo tracking (native
290
+ `todowrite` is disabled in Goal Mode), it replaces — rather than sits beside —
291
+ the native todo list while a goal is active. The visual harness renders the
292
+ component headlessly in [visual test](tools/visual-test/README.md)
293
+ (`npm run test:visual`); the enforcement core is a separate server plugin and
294
+ works regardless of the sidebar.
287
295
  - **Toasts.** Review verdicts and completion-unlock events surface as toasts
288
296
  (`toastOnReview`), and blocked destructive commands / premature completions
289
297
  toast as before (`toastOnBlock`).
package/agents/goal.md CHANGED
@@ -28,7 +28,7 @@ permission:
28
28
  "/projects/**": allow
29
29
  "~/.config/opencode/**": allow
30
30
  "~/.local/share/opencode/tool-output/**": allow
31
- todowrite: allow
31
+ todowrite: deny
32
32
  question: allow
33
33
  webfetch: allow
34
34
  websearch: allow
@@ -103,7 +103,7 @@ Operating loop:
103
103
  1. Establish the Goal Contract, constraints, current state, and acceptance criteria.
104
104
  2. If essential information is missing, ask all necessary clarifying questions immediately at the beginning. Do not defer avoidable questions into the build phase.
105
105
  3. Delegate research and discovery before editing. Use subagents to inspect local files, map structures, trace code paths, research docs, identify verification commands, and gather external web evidence.
106
- 4. Create and maintain a todo list for any non-trivial goal. Keep exactly one active item while working.
106
+ 4. Track progress through the Goal Contract acceptance criteria and the guard's evidence/gate state, not the native todo tool. Goal Mode owns the sidebar todo section: it derives a live, structured todo list from the acceptance criteria (checked off as you record evidence), dirty state, and outstanding review gates. Do not use `todowrite` (it is disabled in Goal Mode so the native todo list never competes with the Goal-owned section); call `goal_status`/`goal_evidence_map` when you need the current checklist.
107
107
  5. Implement the goal yourself in the main agent unless a bounded implementation subtask is explicitly safer to delegate.
108
108
  6. Run or delegate relevant checks, tests, builds, linters, typechecks, previews, or manual verification planning.
109
109
  7. When you believe the goal is finished, immediately run a strict review cycle before telling the user. The review must compare the original prompt and Goal Contract against the actual result.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-mode",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "plugins/goal-sidebar.tsx",
@@ -25,6 +25,7 @@
25
25
  "plugins/",
26
26
  "research/",
27
27
  "scripts/install.mjs",
28
+ "scripts/postinstall.mjs",
28
29
  "ARCHITECTURE.md",
29
30
  "CHANGELOG.md",
30
31
  "LICENSE",
@@ -51,7 +52,8 @@
51
52
  "ci": "npm run validate && npm run audit",
52
53
  "prepublishOnly": "npm run ci && npm run publish:check",
53
54
  "install:local": "node scripts/install.mjs",
54
- "install:global": "node scripts/install.mjs --global"
55
+ "install:global": "node scripts/install.mjs --global",
56
+ "postinstall": "node scripts/postinstall.mjs"
55
57
  },
56
58
  "keywords": [
57
59
  "opencode",
@@ -71,7 +71,7 @@ export function requiredGates(state, config) {
71
71
 
72
72
  /** A gate is satisfied when its latest verdict is PASS and newer than the last edit. */
73
73
  export function gatePassedFresh(state, agent) {
74
- const v = state.latestVerdict[agent];
74
+ const v = state.latestVerdict?.[agent];
75
75
  if (!v || v.verdict !== "PASS") return false;
76
76
  return v.seq > (state.lastEditSeq || 0);
77
77
  }
@@ -93,7 +93,10 @@ export function createGuard(input = {}, options = {}, overrides = {}) {
93
93
  try {
94
94
  if (!inp?.sessionID) return;
95
95
  const state = store.stateFor(inp.sessionID);
96
- if (isPrimaryAgent(inp.agent)) state.active = true;
96
+ // `active` reflects whether this session is CURRENTLY a Goal session. Switching
97
+ // the session's agent to Build (or anything non-goal) must deactivate it, or
98
+ // the sidebar/guard would keep treating an explicit Build session as a goal.
99
+ if (inp.agent) state.active = isPrimaryAgent(inp.agent);
97
100
  const text = partsText(out?.parts);
98
101
  if (text && state.active) {
99
102
  // Accumulate goal text (bounded) so contextual gates can be derived.
@@ -115,7 +118,10 @@ export function createGuard(input = {}, options = {}, overrides = {}) {
115
118
  if (!normalized) return;
116
119
  const state = store.stateFor(normalized);
117
120
  state.currentAgent = inp.agent;
118
- if (isPrimaryAgent(inp.agent)) state.active = true;
121
+ // Track the current mode: a session is active (a goal) only while its agent
122
+ // is the goal primary. Switching to Build/Plan/etc. deactivates it so the
123
+ // Goal sidebar and enforcement stop treating it as a goal.
124
+ if (inp.agent) state.active = isPrimaryAgent(inp.agent);
119
125
  } catch {
120
126
  /* ignore */
121
127
  }
@@ -178,13 +184,14 @@ export function createGuard(input = {}, options = {}, overrides = {}) {
178
184
  async "tool.execute.after"(inp, out) {
179
185
  try {
180
186
  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;
187
+ // Goal bookkeeping runs only for an active Goal session, or for a REVIEW
188
+ // subagent's own child session (so the agent-path can capture its verdict).
189
+ // It must NOT run for a Build/Plan/custom session, nor for a non-review goal
190
+ // WORKER child session (e.g. goal-implementer/goal-explorer) otherwise that
191
+ // worker's edits would mark it dirty and activate it, leaking goal completion
192
+ // enforcement into a worker's prompt. Destructive-command blocking lives in
193
+ // tool.execute.before and still applies in every mode.
194
+ if (!state.active && !isReviewAgent(state.currentAgent)) return;
188
195
  const tool = inp?.tool;
189
196
  const isReviewing = isReviewAgent(state.currentAgent);
190
197
 
@@ -278,6 +285,7 @@ export function createGuard(input = {}, options = {}, overrides = {}) {
278
285
  try {
279
286
  if (!inp?.sessionID || !out || !Array.isArray(out.context)) return;
280
287
  const state = store.stateFor(inp.sessionID);
288
+ if (!state.active) return; // only preserve goal state for active Goal sessions
281
289
  out.context.push(
282
290
  `Goal Guard state: ${summarizeState(state, config)}. Preserve Goal Contract, Verification Ledger, ` +
283
291
  `Review Ledger, Reviewer Memory, review cycle count, dirty state, and open findings across compaction.`,
@@ -23,7 +23,7 @@
23
23
  * the Node test suite.
24
24
  */
25
25
 
26
- import { createSignal, onCleanup, For, Show } from "solid-js";
26
+ import { createSignal, createEffect, onCleanup, For, Show } from "solid-js";
27
27
  import { sidebarView, NO_GOAL } from "./goal-guard/summary.js";
28
28
  import { DEFAULT_CONFIG } from "./goal-guard/config.js";
29
29
 
@@ -31,9 +31,11 @@ const DEFAULT_COLOR = "#FFD700"; // running — GOAL label, yellow
31
31
  const DEFAULT_DONE = "#FF5555"; // done — red
32
32
  const DEFAULT_MUTED = "#808080"; // pending todo rows — grey
33
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
34
+ const META_COLOR = "#8BE9FD"; // gates line (running) — cyan accent
35
+ const STATUS_COLOR = "#FFB86C"; // status line (running) — orange, distinct from the cyan gates line
35
36
  const TODO_DONE_COLOR = "#50FA7B"; // ✓ done todo rows — green
36
37
  const POLL_MS = 1500;
38
+ const GOAL_AGENT = "goal"; // the primary Goal agent id (mirrors agents.js PRIMARY_AGENT)
37
39
  const RAINBOW = ["#FF5555", "#FFAA00", "#FFFF55", "#55FF55", "#55FFFF", "#5599FF", "#FF55FF"];
38
40
 
39
41
  function resolveOptions(options, env) {
@@ -85,16 +87,27 @@ function pickSession(snapshot, sessionId) {
85
87
  return null;
86
88
  }
87
89
 
88
- function readModel(worktree, sessionId) {
89
- try {
90
- const snapshot = readSnapshot(worktree);
91
- if (!snapshot) return NO_GOAL;
92
- const record = pickSession(snapshot, sessionId);
93
- if (!record) return NO_GOAL;
94
- return sidebarView(record, DEFAULT_CONFIG);
95
- } catch {
96
- return NO_GOAL;
90
+ /**
91
+ * Resolve the sidebar model for a session, trying each candidate worktree key in
92
+ * turn. The guard persists keyed by `worktree || directory`; the TUI may surface
93
+ * either path, so we try both (worktree first) rather than risk a key mismatch
94
+ * that would hide an active goal and leave the native todos showing.
95
+ */
96
+ function readModel(worktrees, sessionId) {
97
+ const keys = (Array.isArray(worktrees) ? worktrees : [worktrees]).filter(Boolean);
98
+ for (const wt of keys) {
99
+ try {
100
+ const snapshot = readSnapshot(wt);
101
+ if (!snapshot) continue;
102
+ const record = pickSession(snapshot, sessionId);
103
+ if (!record) continue;
104
+ const view = sidebarView(record, DEFAULT_CONFIG);
105
+ if (view && view.state !== "none") return view;
106
+ } catch {
107
+ /* try the next candidate */
108
+ }
97
109
  }
110
+ return NO_GOAL;
98
111
  }
99
112
 
100
113
  const id = "goal-mode-sidebar";
@@ -106,27 +119,74 @@ const tui = async (api, options) => {
106
119
  if (!enabled) return;
107
120
  if (!api?.slots?.register) return; // runtime without the slot API → no-op.
108
121
 
109
- const worktree = api.state?.path?.worktree || api.state?.path?.directory;
122
+ // The guard keys persisted state by worktree (falling back to directory).
123
+ // Surface both so a path-key mismatch can't hide an active goal.
124
+ const worktrees = [api.state?.path?.worktree, api.state?.path?.directory];
110
125
 
111
126
  api.slots.register({
112
127
  order: 50,
113
128
  slots: {
114
129
  sidebar_content(_ctx, props) {
115
130
  if (!props?.session_id) return undefined;
131
+ // The session's CURRENT agent, from its latest message (mirrors the
132
+ // reference OpenCode TUI plugin). This is the authoritative, immediate
133
+ // signal of whether the session is in Goal mode right now — so when the
134
+ // user switches to Build (or any non-goal agent) the Goal section vanishes
135
+ // and OpenCode's native todos return, without waiting for persisted state.
136
+ const currentAgent = () => {
137
+ try {
138
+ const msgs = api?.state?.session?.messages?.(props.session_id);
139
+ if (Array.isArray(msgs)) {
140
+ for (let i = msgs.length - 1; i >= 0; i--) {
141
+ const a = msgs[i] && msgs[i].agent;
142
+ if (a) return String(a).toLowerCase();
143
+ }
144
+ }
145
+ } catch {
146
+ /* messages API unavailable — fall back to persisted goal state */
147
+ }
148
+ return undefined;
149
+ };
116
150
  const read = () => {
117
151
  try {
118
- return readModel(worktree, props?.session_id) || NO_GOAL;
152
+ const agent = currentAgent();
153
+ // Render only in Goal mode. "goal" and its `goal-*` subagents count as
154
+ // Goal mode (so the section doesn't flicker out while reviewers run);
155
+ // any other primary agent (build/plan/custom) renders nothing here.
156
+ const goalMode = !agent || agent === GOAL_AGENT || agent.startsWith(`${GOAL_AGENT}-`);
157
+ if (!goalMode) return NO_GOAL;
158
+ return readModel(worktrees, props?.session_id) || NO_GOAL;
119
159
  } catch {
120
160
  return NO_GOAL;
121
161
  }
122
162
  };
123
- const initial = read();
124
- if (initial.state === "none") return undefined;
125
- const [model, setModel] = createSignal(initial);
126
- const [rainbow, setRainbow] = createSignal((rainbowMs || 0) > 0);
163
+ // ALWAYS mount a reactive, polling component — do NOT bail when there is
164
+ // no goal yet. The goal is normally recorded AFTER the sidebar mounts
165
+ // (the user opens the session, then states the goal), so the slot must
166
+ // keep polling and let <Show> reveal the section when the goal appears.
167
+ // Returning undefined at mount (the old behavior) meant the poll never
168
+ // ran and the Goal section never showed even once a goal existed.
169
+ const first = read();
170
+ const [model, setModel] = createSignal(first);
127
171
  const timer = setInterval(() => setModel(read()), POLL_MS);
128
- const rainbowTimer = setTimeout(() => setRainbow(false), Math.max(0, rainbowMs || 0));
129
172
  onCleanup(() => clearInterval(timer));
173
+ // First-display rainbow: starts the moment a goal FIRST appears. If a goal
174
+ // is already present at mount it starts immediately; otherwise the effect
175
+ // fires when the goal later appears (the common case — the goal is set
176
+ // after the sidebar mounts). Either way it settles after rainbowMs.
177
+ const [rainbow, setRainbow] = createSignal(false);
178
+ let rainbowStarted = false;
179
+ let rainbowTimer;
180
+ const startRainbow = () => {
181
+ if (rainbowStarted || (rainbowMs || 0) <= 0) return;
182
+ rainbowStarted = true;
183
+ setRainbow(true);
184
+ rainbowTimer = setTimeout(() => setRainbow(false), Math.max(0, rainbowMs));
185
+ };
186
+ if (first.state !== "none") startRainbow();
187
+ createEffect(() => {
188
+ if (model().state !== "none") startRainbow();
189
+ });
130
190
  onCleanup(() => clearTimeout(rainbowTimer));
131
191
  const isRainbow = () => rainbow() && model().state === "running";
132
192
  // Settled (post-rainbow) colour for each header line. When done, every
@@ -136,7 +196,8 @@ const tui = async (api, options) => {
136
196
  if (model().state === "done") return doneColor;
137
197
  if (kind === "label") return color; // GOAL — yellow
138
198
  if (kind === "title") return TITLE_COLOR; // goal title — bright white
139
- return META_COLOR; // gates · status — cyan
199
+ if (kind === "gates") return META_COLOR; // gate count — cyan
200
+ return STATUS_COLOR; // lifecycle status — orange
140
201
  };
141
202
  const lineColor = (index, kind) => (isRainbow() ? RAINBOW[index % RAINBOW.length] : settled(kind));
142
203
  const todoColor = (index, item) => {
@@ -144,17 +205,19 @@ const tui = async (api, options) => {
144
205
  if (item.status === "done") return TODO_DONE_COLOR;
145
206
  return model().state === "done" ? doneColor : muted;
146
207
  };
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.
208
+ // Goal sessions render a Goal-owned todo section GOAL label, goal title,
209
+ // gate count, lifecycle status, then structured todos — EACH on its own
210
+ // line in its own colour. Non-Goal / no-goal sessions returned undefined
211
+ // above, so the native todo section shows instead.
150
212
  return (
151
213
  <Show when={model().state !== "none"}>
152
214
  <box flexDirection="column" paddingTop={1}>
153
215
  <text fg={lineColor(0, "label")}><b>{model().label || "GOAL"}</b></text>
154
216
  <text fg={lineColor(1, "title")}>{model().goal}</text>
155
- <text fg={lineColor(2, "meta")}>{`${model().gates} · ${model().status}`}</text>
217
+ <text fg={lineColor(2, "gates")}>{model().gates}</text>
218
+ <text fg={lineColor(3, "status")}>{model().status}</text>
156
219
  <For each={model().todos || []}>
157
- {(item, index) => <text fg={todoColor(index() + 3, item)}>{`${item.status === "done" ? "✓" : "□"} ${item.text}`}</text>}
220
+ {(item, index) => <text fg={todoColor(index() + 4, item)}>{`${item.status === "done" ? "✓" : "□"} ${item.text}`}</text>}
158
221
  </For>
159
222
  </box>
160
223
  </Show>
@@ -183,6 +183,35 @@ function ensureTuiPlugin(remove = false) {
183
183
  return true;
184
184
  }
185
185
 
186
+ /**
187
+ * OpenCode caches TUI plugins under `~/.cache/opencode/packages/<name>@<spec>/`
188
+ * and does NOT re-check npm for a newer version, so after an upgrade it keeps
189
+ * loading the OLD sidebar build. Removing our cache entries forces OpenCode to
190
+ * re-fetch the just-installed version on its next start. Returns the dirs cleared.
191
+ */
192
+ function refreshTuiPluginCache() {
193
+ const base = (process.env.XDG_CACHE_HOME && process.env.XDG_CACHE_HOME.trim()) || join(homedir() || "", ".cache");
194
+ const pkgRoot = join(base, "opencode", "packages");
195
+ if (!existsSync(pkgRoot)) return [];
196
+ let entries;
197
+ try {
198
+ entries = readdirSync(pkgRoot);
199
+ } catch {
200
+ return [];
201
+ }
202
+ const ours = entries
203
+ .filter((name) => name === pkg.name || name.startsWith(`${pkg.name}@`))
204
+ .map((name) => join(pkgRoot, name));
205
+ for (const dir of ours) {
206
+ try {
207
+ if (!values["dry-run"]) rmSync(dir, { recursive: true, force: true });
208
+ } catch {
209
+ /* best-effort */
210
+ }
211
+ }
212
+ return ours;
213
+ }
214
+
186
215
  // ---------------------------------------------------------------------------
187
216
  // Uninstall
188
217
  // ---------------------------------------------------------------------------
@@ -205,6 +234,8 @@ if (values.uninstall) {
205
234
  const tuiRemoved = ensureTuiPlugin(true);
206
235
  if (!values["dry-run"]) pruneEmptyDirs(target, Object.keys(manifest.files));
207
236
  if (tuiRemoved) console.log(`${values["dry-run"] ? "Would remove" : "Removed"} the sidebar entry from ${join(target, "tui.json")}`);
237
+ const cachedCleared = refreshTuiPluginCache();
238
+ if (cachedCleared.length) console.log(`${values["dry-run"] ? "Would clear" : "Cleared"} OpenCode's cached TUI plugin (${cachedCleared.length}).`);
208
239
  const verb = values["dry-run"] ? "Would remove" : "Removed";
209
240
  console.log(`${verb} ${removed.length} Goal Mode files from ${target}.`);
210
241
  if (kept.length) {
@@ -302,4 +333,8 @@ console.log(
302
333
  `Files copied: ${summary.copied.length}; unchanged: ${summary.unchanged.length}; pruned: ${summary.pruned.length}`,
303
334
  );
304
335
  if (tuiAdded) console.log(`Registered the experimental sidebar in ${join(target, "tui.json")}`);
336
+ const cacheCleared = refreshTuiPluginCache();
337
+ if (cacheCleared.length) {
338
+ console.log(`${values["dry-run"] ? "Would clear" : "Cleared"} OpenCode's stale TUI plugin cache so the sidebar reloads at the installed version.`);
339
+ }
305
340
  console.log("Restart OpenCode for agents, commands, and plugins to load.");
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Auto-setup on a GLOBAL install.
4
+ *
5
+ * When a user runs `npm install -g opencode-goal-mode` (a fresh install OR an
6
+ * upgrade), this runs the installer for them so that, with a single command, the
7
+ * package's components are (re)copied into `~/.config/opencode`, the Goal sidebar
8
+ * is registered in `tui.json`, and OpenCode's stale plugin cache is cleared so the
9
+ * just-installed version actually loads. That makes `npm install -g` "really
10
+ * update" — no separate `opencode-goal-mode --global` step required.
11
+ *
12
+ * Safety:
13
+ * - Runs ONLY for global installs (npm sets `npm_config_global`). Repo development
14
+ * (`npm ci` / `npm install`) and installs as a project dependency are no-ops, so
15
+ * this never writes to a user's config when it shouldn't.
16
+ * - Best-effort: it NEVER fails the npm install. Any problem prints a one-line
17
+ * hint to run `opencode-goal-mode --global` manually and exits 0.
18
+ */
19
+ import { spawnSync } from "node:child_process";
20
+ import { fileURLToPath } from "node:url";
21
+ import { join, dirname } from "node:path";
22
+
23
+ function isGlobalInstall() {
24
+ const g = process.env.npm_config_global;
25
+ return g === "true" || g === "1" || g === true;
26
+ }
27
+
28
+ if (!isGlobalInstall()) {
29
+ // Local/dev/dependency install — do not touch the user's OpenCode config.
30
+ process.exit(0);
31
+ }
32
+
33
+ try {
34
+ const installer = join(dirname(fileURLToPath(import.meta.url)), "install.mjs");
35
+ console.log("opencode-goal-mode: global install detected — updating ~/.config/opencode…");
36
+ const res = spawnSync(process.execPath, [installer, "--global"], { stdio: "inherit" });
37
+ // spawnSync does not throw on a non-zero exit (e.g. the installer refused to
38
+ // overwrite a file you edited). Surface a clear, actionable hint instead of
39
+ // leaving only the child's raw error.
40
+ if (res.error || res.status !== 0) {
41
+ console.warn(
42
+ "opencode-goal-mode: auto-setup didn't fully complete. " +
43
+ "Run `opencode-goal-mode --global` manually (add --force to replace files you've edited), then restart OpenCode.",
44
+ );
45
+ }
46
+ } catch (err) {
47
+ console.warn(
48
+ `opencode-goal-mode: auto-setup skipped (${(err && err.message) || err}). ` +
49
+ "Run `opencode-goal-mode --global` to finish, then restart OpenCode.",
50
+ );
51
+ }
52
+ // Never fail `npm install`, regardless of the installer's outcome.
53
+ process.exit(0);