opencode-goal-mode 0.4.1 → 0.4.2
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 +32 -0
- package/README.md +12 -6
- package/agents/goal.md +2 -2
- package/package.json +1 -1
- package/plugins/goal-sidebar.tsx +61 -24
- package/scripts/install.mjs +35 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.4.2
|
|
4
|
+
|
|
5
|
+
### The sidebar Goal section now actually renders in the live TUI
|
|
6
|
+
|
|
7
|
+
- **Root-cause fix.** The `sidebar_content` slot bailed at mount with
|
|
8
|
+
`return undefined` whenever no goal existed *yet* — but the goal is normally set
|
|
9
|
+
*after* the sidebar mounts, so the polling component never started and the Goal
|
|
10
|
+
section never appeared. The slot now **always mounts a reactive, polling
|
|
11
|
+
component** and reveals the section (via `<Show>`) the moment the goal is
|
|
12
|
+
recorded. Verified by driving the real OpenCode TUI in a PTY.
|
|
13
|
+
- **Stale plugin cache.** OpenCode caches TUI plugins under
|
|
14
|
+
`~/.cache/opencode/packages/<name>@<spec>/` and never re-checks npm, so upgrades
|
|
15
|
+
kept loading the *old* sidebar build. The installer now clears that cache on
|
|
16
|
+
install and uninstall, so a restart picks up the installed version.
|
|
17
|
+
- The first-display rainbow now triggers when the goal first appears (not at
|
|
18
|
+
mount, when there may be no goal yet).
|
|
19
|
+
- The sidebar resolves state under both the worktree and directory path keys, so a
|
|
20
|
+
path-key mismatch can't hide an active goal.
|
|
21
|
+
|
|
22
|
+
### Sidebar layout
|
|
23
|
+
|
|
24
|
+
- The gate count and the lifecycle status are now on **separate lines**, each in
|
|
25
|
+
its own colour (GOAL = yellow, title = white, gates = cyan, status = orange).
|
|
26
|
+
|
|
27
|
+
### Native todos are replaced in goal mode
|
|
28
|
+
|
|
29
|
+
- The `goal` agent no longer uses the native `todowrite` tool (it is disabled in
|
|
30
|
+
Goal Mode). Because OpenCode renders native todos as their own sidebar slot, the
|
|
31
|
+
only way to replace them is to stop producing them — so in a goal session the
|
|
32
|
+
native todo list stays empty and the structured Goal-owned section is what shows.
|
|
33
|
+
Build and every other mode keep their native todos.
|
|
34
|
+
|
|
3
35
|
## v0.4.1
|
|
4
36
|
|
|
5
37
|
### Restructured Goal sidebar todo section
|
package/README.md
CHANGED
|
@@ -278,12 +278,18 @@ enforcement and writes its state to disk, and an experimental TUI plugin
|
|
|
278
278
|
{ "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-goal-mode"] }
|
|
279
279
|
```
|
|
280
280
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
281
|
+
OpenCode installs the referenced package into its own plugin cache
|
|
282
|
+
(`~/.cache/opencode/packages/`) and provides the `@opentui/solid` + `solid-js`
|
|
283
|
+
runtime to it. It does **not** re-check that cache for newer versions, so the
|
|
284
|
+
installer clears the cached copy on install/uninstall — that's why an upgrade
|
|
285
|
+
needs only a restart to load the new sidebar. Restart OpenCode after install. The
|
|
286
|
+
Goal todo section appears in a **Goal session** view (not the home screen and not
|
|
287
|
+
Build mode), and because the Goal agent does its own todo tracking (native
|
|
288
|
+
`todowrite` is disabled in Goal Mode), it replaces — rather than sits beside —
|
|
289
|
+
the native todo list while a goal is active. The visual harness renders the
|
|
290
|
+
component headlessly in [visual test](tools/visual-test/README.md)
|
|
291
|
+
(`npm run test:visual`); the enforcement core is a separate server plugin and
|
|
292
|
+
works regardless of the sidebar.
|
|
287
293
|
- **Toasts.** Review verdicts and completion-unlock events surface as toasts
|
|
288
294
|
(`toastOnReview`), and blocked destructive commands / premature completions
|
|
289
295
|
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:
|
|
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.
|
|
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
package/plugins/goal-sidebar.tsx
CHANGED
|
@@ -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,7 +31,8 @@ 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
|
|
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;
|
|
37
38
|
const RAINBOW = ["#FF5555", "#FFAA00", "#FFFF55", "#55FF55", "#55FFFF", "#5599FF", "#FF55FF"];
|
|
@@ -85,16 +86,27 @@ function pickSession(snapshot, sessionId) {
|
|
|
85
86
|
return null;
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the sidebar model for a session, trying each candidate worktree key in
|
|
91
|
+
* turn. The guard persists keyed by `worktree || directory`; the TUI may surface
|
|
92
|
+
* either path, so we try both (worktree first) rather than risk a key mismatch
|
|
93
|
+
* that would hide an active goal and leave the native todos showing.
|
|
94
|
+
*/
|
|
95
|
+
function readModel(worktrees, sessionId) {
|
|
96
|
+
const keys = (Array.isArray(worktrees) ? worktrees : [worktrees]).filter(Boolean);
|
|
97
|
+
for (const wt of keys) {
|
|
98
|
+
try {
|
|
99
|
+
const snapshot = readSnapshot(wt);
|
|
100
|
+
if (!snapshot) continue;
|
|
101
|
+
const record = pickSession(snapshot, sessionId);
|
|
102
|
+
if (!record) continue;
|
|
103
|
+
const view = sidebarView(record, DEFAULT_CONFIG);
|
|
104
|
+
if (view && view.state !== "none") return view;
|
|
105
|
+
} catch {
|
|
106
|
+
/* try the next candidate */
|
|
107
|
+
}
|
|
97
108
|
}
|
|
109
|
+
return NO_GOAL;
|
|
98
110
|
}
|
|
99
111
|
|
|
100
112
|
const id = "goal-mode-sidebar";
|
|
@@ -106,7 +118,9 @@ const tui = async (api, options) => {
|
|
|
106
118
|
if (!enabled) return;
|
|
107
119
|
if (!api?.slots?.register) return; // runtime without the slot API → no-op.
|
|
108
120
|
|
|
109
|
-
|
|
121
|
+
// The guard keys persisted state by worktree (falling back to directory).
|
|
122
|
+
// Surface both so a path-key mismatch can't hide an active goal.
|
|
123
|
+
const worktrees = [api.state?.path?.worktree, api.state?.path?.directory];
|
|
110
124
|
|
|
111
125
|
api.slots.register({
|
|
112
126
|
order: 50,
|
|
@@ -115,18 +129,38 @@ const tui = async (api, options) => {
|
|
|
115
129
|
if (!props?.session_id) return undefined;
|
|
116
130
|
const read = () => {
|
|
117
131
|
try {
|
|
118
|
-
return readModel(
|
|
132
|
+
return readModel(worktrees, props?.session_id) || NO_GOAL;
|
|
119
133
|
} catch {
|
|
120
134
|
return NO_GOAL;
|
|
121
135
|
}
|
|
122
136
|
};
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
137
|
+
// ALWAYS mount a reactive, polling component — do NOT bail when there is
|
|
138
|
+
// no goal yet. The goal is normally recorded AFTER the sidebar mounts
|
|
139
|
+
// (the user opens the session, then states the goal), so the slot must
|
|
140
|
+
// keep polling and let <Show> reveal the section when the goal appears.
|
|
141
|
+
// Returning undefined at mount (the old behavior) meant the poll never
|
|
142
|
+
// ran and the Goal section never showed even once a goal existed.
|
|
143
|
+
const first = read();
|
|
144
|
+
const [model, setModel] = createSignal(first);
|
|
127
145
|
const timer = setInterval(() => setModel(read()), POLL_MS);
|
|
128
|
-
const rainbowTimer = setTimeout(() => setRainbow(false), Math.max(0, rainbowMs || 0));
|
|
129
146
|
onCleanup(() => clearInterval(timer));
|
|
147
|
+
// First-display rainbow: starts the moment a goal FIRST appears. If a goal
|
|
148
|
+
// is already present at mount it starts immediately; otherwise the effect
|
|
149
|
+
// fires when the goal later appears (the common case — the goal is set
|
|
150
|
+
// after the sidebar mounts). Either way it settles after rainbowMs.
|
|
151
|
+
const [rainbow, setRainbow] = createSignal(false);
|
|
152
|
+
let rainbowStarted = false;
|
|
153
|
+
let rainbowTimer;
|
|
154
|
+
const startRainbow = () => {
|
|
155
|
+
if (rainbowStarted || (rainbowMs || 0) <= 0) return;
|
|
156
|
+
rainbowStarted = true;
|
|
157
|
+
setRainbow(true);
|
|
158
|
+
rainbowTimer = setTimeout(() => setRainbow(false), Math.max(0, rainbowMs));
|
|
159
|
+
};
|
|
160
|
+
if (first.state !== "none") startRainbow();
|
|
161
|
+
createEffect(() => {
|
|
162
|
+
if (model().state !== "none") startRainbow();
|
|
163
|
+
});
|
|
130
164
|
onCleanup(() => clearTimeout(rainbowTimer));
|
|
131
165
|
const isRainbow = () => rainbow() && model().state === "running";
|
|
132
166
|
// Settled (post-rainbow) colour for each header line. When done, every
|
|
@@ -136,7 +170,8 @@ const tui = async (api, options) => {
|
|
|
136
170
|
if (model().state === "done") return doneColor;
|
|
137
171
|
if (kind === "label") return color; // GOAL — yellow
|
|
138
172
|
if (kind === "title") return TITLE_COLOR; // goal title — bright white
|
|
139
|
-
return META_COLOR; //
|
|
173
|
+
if (kind === "gates") return META_COLOR; // gate count — cyan
|
|
174
|
+
return STATUS_COLOR; // lifecycle status — orange
|
|
140
175
|
};
|
|
141
176
|
const lineColor = (index, kind) => (isRainbow() ? RAINBOW[index % RAINBOW.length] : settled(kind));
|
|
142
177
|
const todoColor = (index, item) => {
|
|
@@ -144,17 +179,19 @@ const tui = async (api, options) => {
|
|
|
144
179
|
if (item.status === "done") return TODO_DONE_COLOR;
|
|
145
180
|
return model().state === "done" ? doneColor : muted;
|
|
146
181
|
};
|
|
147
|
-
// Goal sessions render a Goal-owned todo section
|
|
148
|
-
//
|
|
149
|
-
// no-goal sessions returned undefined
|
|
182
|
+
// Goal sessions render a Goal-owned todo section — GOAL label, goal title,
|
|
183
|
+
// gate count, lifecycle status, then structured todos — EACH on its own
|
|
184
|
+
// line in its own colour. Non-Goal / no-goal sessions returned undefined
|
|
185
|
+
// above, so the native todo section shows instead.
|
|
150
186
|
return (
|
|
151
187
|
<Show when={model().state !== "none"}>
|
|
152
188
|
<box flexDirection="column" paddingTop={1}>
|
|
153
189
|
<text fg={lineColor(0, "label")}><b>{model().label || "GOAL"}</b></text>
|
|
154
190
|
<text fg={lineColor(1, "title")}>{model().goal}</text>
|
|
155
|
-
<text fg={lineColor(2, "
|
|
191
|
+
<text fg={lineColor(2, "gates")}>{model().gates}</text>
|
|
192
|
+
<text fg={lineColor(3, "status")}>{model().status}</text>
|
|
156
193
|
<For each={model().todos || []}>
|
|
157
|
-
{(item, index) => <text fg={todoColor(index() +
|
|
194
|
+
{(item, index) => <text fg={todoColor(index() + 4, item)}>{`${item.status === "done" ? "✓" : "□"} ${item.text}`}</text>}
|
|
158
195
|
</For>
|
|
159
196
|
</box>
|
|
160
197
|
</Show>
|
package/scripts/install.mjs
CHANGED
|
@@ -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.");
|