opencode-usage-coach 0.3.1 → 0.3.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/README.md +41 -2
- package/agents/usage-coach-harness.md +1 -0
- package/dist/index.js +62 -51
- package/dist/tui.js +7 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,6 +52,39 @@ cp dist/index.js ~/.config/opencode/plugins/opencode-usage-coach.js
|
|
|
52
52
|
# ~/.config/opencode/tui.json: { "plugin": ["/abs/path/dist/tui.js"] }
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
## Prompting the harness
|
|
56
|
+
|
|
57
|
+
How to trigger and use the harness loop for substantive work.
|
|
58
|
+
|
|
59
|
+
### Triggering
|
|
60
|
+
With the harness agent mode active (`agents/usage-coach-harness.md` installed), ask for substantive work — explicitly or just describe a multi-step task:
|
|
61
|
+
- `"run this through the harness: write CONTRIBUTING.md from git log"`
|
|
62
|
+
- `"harness: add TypeScript strict mode across the repo"`
|
|
63
|
+
- Or just describe the work; the agent triages and enters the loop.
|
|
64
|
+
|
|
65
|
+
Trivial requests (a one-line fix, a direct answer) are done directly — the loop only runs when generate→grade adds value.
|
|
66
|
+
|
|
67
|
+
### Independent vs dependent tasks
|
|
68
|
+
The harness picks the loop path based on task dependency:
|
|
69
|
+
- **Independent** (task B doesn't need A's output) → `generate_batch` runs all tasks in **parallel** (faster). Example: `"CONTRIBUTING.md, PR template, issue template"` — three separate docs.
|
|
70
|
+
- **Dependent** (B needs A) → **sequential** `generate` calls. Example: `"1) schema, 2) migration, 3) API"` — each needs the prior.
|
|
71
|
+
|
|
72
|
+
### Deterministic NEXT directives
|
|
73
|
+
Each tool appends a `[usage-coach NEXT]` line to its return value, telling the agent exactly what to call next:
|
|
74
|
+
- `generate` → NEXT: grade the work
|
|
75
|
+
- `grade` PASS → NEXT: mark completed, proceed
|
|
76
|
+
- `grade` FAIL → NEXT: revise (up to 2x) or mark failed
|
|
77
|
+
|
|
78
|
+
The agent follows NEXT — you just describe the work, the loop runs itself.
|
|
79
|
+
|
|
80
|
+
### Quota-aware (automatic)
|
|
81
|
+
You don't manage quota — the tools adapt:
|
|
82
|
+
- **GO** + headroom → strong generator, full parallel
|
|
83
|
+
- **THROTTLE** → auto-switch to `lighterModel`, concurrency capped at 2
|
|
84
|
+
- **STOP** → loop halts
|
|
85
|
+
|
|
86
|
+
Set `lighterModel` in `harness.config.json` to enable THROTTLE switching.
|
|
87
|
+
|
|
55
88
|
## Config
|
|
56
89
|
|
|
57
90
|
This plugin has **four config surfaces**. Only the first two are required to run.
|
|
@@ -133,7 +166,7 @@ Recurring issues and fixes — mostly learned the hard way during development.
|
|
|
133
166
|
- **Export must be `{ tui }`** (an object) — a bare function is misread as a server plugin.
|
|
134
167
|
- **Color prop is `fg`** (not `foreground`): `style={{ fg: theme.current.success }}`.
|
|
135
168
|
- **Call `codexbar` via `spawn`** — the `$` BunShell leaks command output into the TUI.
|
|
136
|
-
- **Harness not visible?** The TUI
|
|
169
|
+
- **Harness not visible?** The TUI shows the **current session's** harness only. A finished harness (`active:false`) is hidden. A harness started in another session won't appear here — switch to that session to see it.
|
|
137
170
|
|
|
138
171
|
### LLM model selection (generate/grade)
|
|
139
172
|
- **`generator` is required in `harness.config.json`** — the tools return a clear error if missing (the old z.ai fallback was removed in v0.2.4).
|
|
@@ -154,13 +187,19 @@ Recurring issues and fixes — mostly learned the hard way during development.
|
|
|
154
187
|
- `(no output)` means the sub-session returned no text. Check `~/.cache/opencode-usage-coach/coach.log` for the runModel trace (requires `UC_DEBUG=1`).
|
|
155
188
|
- **Note**: runModel only terminates on explicit `idle`/`completed` status. An earlier bug broke on `!status` (undefined) immediately — fixed.
|
|
156
189
|
|
|
190
|
+
### generate / generate_batch returns "Tool execution aborted"
|
|
191
|
+
- **Cause:** opencode imposes a tool execution timeout (~60–120s, not configurable). `runModel` polls the sub-session for up to 10 min; if the generator model takes longer than the tool timeout, opencode aborts the call.
|
|
192
|
+
- **This is a platform limit, not a plugin bug** — there is no config key to extend it.
|
|
193
|
+
- **Mitigation:** split large tasks into smaller ones that finish within the timeout. The NEXT directives + revise loop help — a FAIL triggers a focused revise rather than a monolithic retry.
|
|
194
|
+
- The sub-session keeps running in the background after abort; its file writes are preserved. Only the tool's return value is lost.
|
|
195
|
+
|
|
157
196
|
### Multi-session (per-session state isolation)
|
|
158
197
|
- Each opencode session has a unique sessionID; harness state is isolated per-session:
|
|
159
198
|
```
|
|
160
199
|
~/.cache/opencode-usage-coach/projects/<dir-hash>/<sessionID>/harness.json
|
|
161
200
|
```
|
|
162
201
|
- Different working directories get separate project state (keyed by path hash).
|
|
163
|
-
- The TUI
|
|
202
|
+
- The TUI shows the **current session's** harness only (per-session isolation) — no cross-session leakage. A harness running in session B does not appear in session A's panel.
|
|
164
203
|
- Harness completion sets `active:false` → hidden from the TUI.
|
|
165
204
|
- Override the state path with `UC_STATE_DIR` (forces global state).
|
|
166
205
|
|
|
@@ -58,6 +58,7 @@ DEPENDENT tasks (B needs A) → always sequential `generate` calls, regardless o
|
|
|
58
58
|
3. When all tasks are done → `harness_done()`.
|
|
59
59
|
|
|
60
60
|
## Rules
|
|
61
|
+
- **Follow the [usage-coach NEXT] directive each tool returns.** `harness_start`, `generate`, and `grade` all append a `NEXT` line telling you exactly what to call next. This makes the loop deterministic — do not improvise the sequence, follow `NEXT`.
|
|
61
62
|
- In the loop, do NOT do the work yourself — call `generate`/`grade` (they run the configured models). You orchestrate. (Outside the loop, for trivial requests, act directly.)
|
|
62
63
|
- Call `task_update` on every state transition — the sidebar panel reads it for live visibility.
|
|
63
64
|
- Grading criteria come from the user's request, or sensible defaults; ask the user only if it is truly ambiguous and grading matters.
|
package/dist/index.js
CHANGED
|
@@ -38,6 +38,18 @@ function writeState(c) {
|
|
|
38
38
|
} catch {
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
function rulesFile() {
|
|
42
|
+
return join(STATE_DIR, "rules.md");
|
|
43
|
+
}
|
|
44
|
+
function readRules() {
|
|
45
|
+
try {
|
|
46
|
+
const f = rulesFile();
|
|
47
|
+
if (!existsSync(f)) return "";
|
|
48
|
+
return readFileSync(f, "utf8").trim();
|
|
49
|
+
} catch {
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
41
53
|
function harnessFile(sessionID) {
|
|
42
54
|
return join(STATE_DIR, sessionID || "_default", "harness.json");
|
|
43
55
|
}
|
|
@@ -73,56 +85,28 @@ function readHarnessCfg(dir) {
|
|
|
73
85
|
};
|
|
74
86
|
}
|
|
75
87
|
async function runModel(client, model, prompt, directory) {
|
|
76
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
77
88
|
const t0 = Date.now();
|
|
78
89
|
try {
|
|
79
90
|
const slash = model.indexOf("/");
|
|
80
91
|
const providerID = slash >= 0 ? model.slice(0, slash) : model;
|
|
81
92
|
const modelID = slash >= 0 ? model.slice(slash + 1) : "";
|
|
82
93
|
const s = await client.session.create({ body: { title: "uc-harness-sub" }, query: { directory } });
|
|
83
|
-
log(`runModel(${model}): create resp = ${JSON.stringify(s?.data ?? s).slice(0, 500)}`);
|
|
84
94
|
const id = s?.data?.info?.id ?? s?.data?.id ?? s?.id;
|
|
85
95
|
if (!id) return `ERROR: session.create returned no id (response: ${JSON.stringify(s?.data ?? s).slice(0, 200)})`;
|
|
86
|
-
log(`runModel(${model}): session ${id} created, prompt ${prompt.length} chars`);
|
|
87
|
-
await client.session.prompt({
|
|
96
|
+
log(`runModel(${model}): session ${id} created, sending prompt (${prompt.length} chars)`);
|
|
97
|
+
const resp = await client.session.prompt({
|
|
88
98
|
path: { id },
|
|
89
99
|
body: { model: { providerID, modelID }, parts: [{ type: "text", text: prompt }] }
|
|
90
100
|
});
|
|
91
|
-
log(`runModel(${model}): prompt sent to ${id}`);
|
|
92
|
-
let lastMsgCount = -1;
|
|
93
|
-
let timedOut = false;
|
|
94
|
-
let text = "";
|
|
95
|
-
for (let i = 0; i < 600; i++) {
|
|
96
|
-
await sleep(1e3);
|
|
97
|
-
try {
|
|
98
|
-
const msgs = await client.session.messages({ path: { id } });
|
|
99
|
-
const all = msgs?.data ?? msgs ?? [];
|
|
100
|
-
if (all.length !== lastMsgCount) {
|
|
101
|
-
log(`runModel(${model}): poll[${i}] msgs=${all.length}`);
|
|
102
|
-
lastMsgCount = all.length;
|
|
103
|
-
}
|
|
104
|
-
const lastAssistant = all.filter((m) => (m?.info?.role ?? m?.role) === "assistant").pop();
|
|
105
|
-
const parts = lastAssistant?.parts ?? lastAssistant?.info?.parts ?? [];
|
|
106
|
-
const t = parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("").trim();
|
|
107
|
-
if (t) {
|
|
108
|
-
text = t;
|
|
109
|
-
log(`runModel(${model}): got assistant text at poll[${i}] (${t.length} chars)`);
|
|
110
|
-
break;
|
|
111
|
-
}
|
|
112
|
-
} catch (e) {
|
|
113
|
-
log(`runModel(${model}): poll[${i}] messages err: ${String(e).slice(0, 120)}`);
|
|
114
|
-
}
|
|
115
|
-
if (i === 599) timedOut = true;
|
|
116
|
-
}
|
|
117
101
|
const elapsed = Math.round((Date.now() - t0) / 1e3);
|
|
102
|
+
const parts = resp?.data?.parts ?? resp?.parts ?? [];
|
|
103
|
+
const text = parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("");
|
|
118
104
|
try {
|
|
119
105
|
await client.session.remove?.({ path: { id } });
|
|
120
106
|
} catch {
|
|
121
107
|
}
|
|
122
|
-
log(`runModel(${model}): done ${elapsed}s, ${text.length} chars
|
|
123
|
-
|
|
124
|
-
${text}`;
|
|
125
|
-
return text || `ERROR: no assistant text after ${elapsed}s (${lastMsgCount} messages seen)`;
|
|
108
|
+
log(`runModel(${model}): done ${elapsed}s, ${text.length} chars`);
|
|
109
|
+
return text.trim() || `ERROR: no assistant text in prompt response after ${elapsed}s (parts: ${parts.length}, types: ${parts.map((p) => p?.type).join(",")})`;
|
|
126
110
|
} catch (e) {
|
|
127
111
|
const elapsed = Math.round((Date.now() - t0) / 1e3);
|
|
128
112
|
log(`runModel err (${model}, ${elapsed}s): ${String(e)}`);
|
|
@@ -340,7 +324,27 @@ async function UsageCoachPlugin(input) {
|
|
|
340
324
|
args: { name: tool.schema.string(), total: tool.schema.number() },
|
|
341
325
|
async execute(args, ctx) {
|
|
342
326
|
writeHarness(ctx.sessionID, { name: args.name, total: args.total, current: 0, tasks: [], usage: {}, active: true, startedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
343
|
-
return `Harness '${args.name}' started (${args.total} tasks).
|
|
327
|
+
return `Harness '${args.name}' started (${args.total} tasks).
|
|
328
|
+
|
|
329
|
+
DETERMINISTIC LOOP \u2014 first classify the tasks:
|
|
330
|
+
INDEPENDENT = task B does NOT need task A's output -> use PATH A (parallel, faster)
|
|
331
|
+
DEPENDENT = task B needs task A's output -> use PATH B (sequential)
|
|
332
|
+
|
|
333
|
+
PATH A \u2014 INDEPENDENT (parallel via generate_batch):
|
|
334
|
+
1. task_update(1..${args.total}, title, "generating")
|
|
335
|
+
2. generate_batch({tasks: [{id:1, prompt:"Task: <title1>. Perform it."}, ...]}) -> all results + NEXT
|
|
336
|
+
3. for each i: task_update(i, title, "grading") + grade({prompt:"Evaluate... PASS/FAIL first line. Task: <title>"}) -> verdict + NEXT
|
|
337
|
+
4. for each i: PASS -> task_update(i, title, "completed", "PASS"); FAIL -> revise (up to 2x) or task_update(i, title, "failed", "FAIL")
|
|
338
|
+
|
|
339
|
+
PATH B \u2014 DEPENDENT (sequential):
|
|
340
|
+
for i in 1..${args.total}:
|
|
341
|
+
1. task_update(i, title, "generating")
|
|
342
|
+
2. generate({prompt:"Task: <title>. Perform it."}) -> work + NEXT
|
|
343
|
+
3. task_update(i, title, "grading")
|
|
344
|
+
4. grade(...) -> verdict + NEXT
|
|
345
|
+
5. PASS -> task_update(i, title, "completed", "PASS"); FAIL -> revise (up to 2x) or failed
|
|
346
|
+
|
|
347
|
+
Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns. Do NOT improvise the sequence.`;
|
|
344
348
|
}
|
|
345
349
|
}),
|
|
346
350
|
task_update: tool({
|
|
@@ -356,7 +360,7 @@ async function UsageCoachPlugin(input) {
|
|
|
356
360
|
async execute(args, ctx) {
|
|
357
361
|
const h = readHarness(ctx.sessionID) ?? { name: "batch", total: 0, current: 0, tasks: [], usage: {}, active: true };
|
|
358
362
|
h.tasks = h.tasks.filter((x) => x.id !== args.id);
|
|
359
|
-
h.tasks.push({ id: args.id, title: args.title, status: args.status, model: args.model ?? "", revisions: args.revisions ?? 0, score: args.score ?? null });
|
|
363
|
+
h.tasks.push({ id: args.id, title: args.title, status: args.status, model: args.model ?? "", revisions: args.revisions ?? 0, score: args.score ?? null, startedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
360
364
|
if (args.id > h.current) h.current = args.id;
|
|
361
365
|
writeHarness(ctx.sessionID, h);
|
|
362
366
|
return `task ${args.id} -> ${args.status}${args.score ? ` (${args.score})` : ""}`;
|
|
@@ -390,9 +394,17 @@ async function UsageCoachPlugin(input) {
|
|
|
390
394
|
}
|
|
391
395
|
const throttle = decision === "THROTTLE" && cfg.lighterModel;
|
|
392
396
|
const model = throttle ? cfg.lighterModel : cfg.generator;
|
|
393
|
-
const
|
|
397
|
+
const rules = readRules();
|
|
398
|
+
const prefix = rules ? `Lessons learned from previous failures (apply where relevant):
|
|
399
|
+
${rules}
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
` : "";
|
|
404
|
+
const out = await runModel(input.client, model, prefix + args.prompt, ctx.directory);
|
|
394
405
|
return out + (throttle ? `
|
|
395
|
-
[usage-coach] quota THROTTLE \u2014 used lighter model ${cfg.lighterModel}` : "")
|
|
406
|
+
[usage-coach] quota THROTTLE \u2014 used lighter model ${cfg.lighterModel}` : "") + `
|
|
407
|
+
[usage-coach NEXT] call task_update(i, title, "grading"), then grade to evaluate this work.`;
|
|
396
408
|
}
|
|
397
409
|
}),
|
|
398
410
|
generate_batch: tool({
|
|
@@ -425,25 +437,24 @@ async function UsageCoachPlugin(input) {
|
|
|
425
437
|
}
|
|
426
438
|
}),
|
|
427
439
|
grade: tool({
|
|
428
|
-
description: "Run the GRADER model on a prompt. Returns PASS/FAIL on the first line. Falls back to generator if grader quota is out.",
|
|
440
|
+
description: "Run the GRADER model on a prompt. Returns PASS/FAIL on the first line + a [usage-coach NEXT] directive. Falls back to generator if grader quota is out.",
|
|
429
441
|
args: { prompt: tool.schema.string() },
|
|
430
442
|
async execute(args, ctx) {
|
|
431
443
|
const cfg = readHarnessCfg(ctx.directory);
|
|
432
444
|
const model = cfg.grader ?? cfg.generator;
|
|
433
|
-
if (!model) return
|
|
445
|
+
if (!model) return "FAIL\n(ERROR: no grader/generator model configured.)\n[usage-coach NEXT] configure grader in harness.config.json, then retry grade.";
|
|
434
446
|
const out = await runModel(input.client, model, args.prompt, ctx.directory);
|
|
435
|
-
|
|
436
|
-
(
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (!isPass && !isFail) {
|
|
442
|
-
return `FAIL
|
|
443
|
-
(grader did not output PASS/FAIL on the first line; defaulting to FAIL to trigger a revise)
|
|
444
|
-
${out}`;
|
|
447
|
+
let verdict = "FAIL";
|
|
448
|
+
if (!out.startsWith("ERROR:")) {
|
|
449
|
+
const f = (out.split("\n").find((l) => l.trim()) ?? "").trim();
|
|
450
|
+
if (/^pass\b/i.test(f)) verdict = "PASS";
|
|
451
|
+
else if (/^fail\b/i.test(f)) verdict = "FAIL";
|
|
452
|
+
else verdict = "FAIL";
|
|
445
453
|
}
|
|
446
|
-
|
|
454
|
+
const next = verdict === "PASS" ? `
|
|
455
|
+
[usage-coach NEXT] PASS -> call task_update(i, title, "completed", "PASS"), then proceed to next task (or harness_done if last).` : `
|
|
456
|
+
[usage-coach NEXT] FAIL -> if revisions < 2: task_update(i, title, "revising", revisions+1) + generate({prompt: "Apply feedback:\\n{grade result}\\nTask: {title}"}); else: task_update(i, title, "failed", "FAIL") -> next task.`;
|
|
457
|
+
return out + "\n" + next;
|
|
447
458
|
}
|
|
448
459
|
})
|
|
449
460
|
}
|
package/dist/tui.js
CHANGED
|
@@ -172,8 +172,9 @@ function initializeTui(api, disposeRoot) {
|
|
|
172
172
|
if (sid) {
|
|
173
173
|
const hf = join(STATE_DIR, sid, "harness.json");
|
|
174
174
|
if (existsSync(hf)) h = JSON.parse(readFileSync(hf, "utf8"));
|
|
175
|
+
} else {
|
|
176
|
+
h = readHarness();
|
|
175
177
|
}
|
|
176
|
-
if (!h || h.active === false) h = readHarness();
|
|
177
178
|
} catch {
|
|
178
179
|
h = null;
|
|
179
180
|
}
|
|
@@ -358,6 +359,8 @@ function initializeTui(api, disposeRoot) {
|
|
|
358
359
|
const lbl = TLABEL[t.status] ?? t.status;
|
|
359
360
|
const rev = t.revisions > 0 && t.status === "revising" ? `(${t.revisions})` : "";
|
|
360
361
|
const mdl = t.model ? ` ${t.model.split("/").pop() ?? t.model}` : "";
|
|
362
|
+
const elapsed = t.startedAt ? Math.max(0, Math.round((Date.now() - new Date(t.startedAt).getTime()) / 1e3)) : 0;
|
|
363
|
+
const elapsedStr = t.status === "completed" || t.status === "failed" ? "" : elapsed > 0 ? ` ${elapsed}s` : "";
|
|
361
364
|
nodes.push((() => {
|
|
362
365
|
var _el$59 = _$createElement("text"), _el$60 = _$createTextNode(` \u25CF `), _el$61 = _$createTextNode(` `), _el$62 = _$createTextNode(` `);
|
|
363
366
|
_$insertNode(_el$59, _el$60);
|
|
@@ -367,13 +370,14 @@ function initializeTui(api, disposeRoot) {
|
|
|
367
370
|
_$insert(_el$59, mdl, _el$61);
|
|
368
371
|
_$insert(_el$59, lbl, _el$62);
|
|
369
372
|
_$insert(_el$59, rev, _el$62);
|
|
373
|
+
_$insert(_el$59, elapsedStr, _el$62);
|
|
370
374
|
_$insert(_el$59, () => short(t.title, 12), null);
|
|
371
375
|
_$effect((_$p) => _$setProp(_el$59, "style", st(sKey), _$p));
|
|
372
376
|
return _el$59;
|
|
373
377
|
})());
|
|
374
378
|
const pv = t.model ? (t.model.split("/")[0] ?? "").split("-")[0] : "";
|
|
375
|
-
const
|
|
376
|
-
const pct =
|
|
379
|
+
const provCoach = s?.providers?.find((p) => p.id === pv || pv && p.id.startsWith(pv) || pv && pv.startsWith(p.id));
|
|
380
|
+
const pct = provCoach ? provCoach.fiveHour : s?.fiveHour ?? 0;
|
|
377
381
|
nodes.push((() => {
|
|
378
382
|
var _el$63 = _$createElement("box"), _el$64 = _$createElement("text"), _el$66 = _$createElement("text"), _el$67 = _$createElement("text"), _el$68 = _$createElement("text"), _el$69 = _$createTextNode(` `), _el$70 = _$createTextNode(`%`);
|
|
379
383
|
_$insertNode(_el$63, _el$64);
|
package/package.json
CHANGED