opencode-usage-coach 0.2.4 → 0.3.0
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 +52 -3
- package/agents/usage-coach-harness.md +9 -7
- package/dist/index.js +52 -18
- package/dist/tui.js +40 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# opencode-usage-coach
|
|
2
2
|
|
|
3
3
|
A closed-loop usage coach and harness for [OpenCode](https://opencode.ai). Built for
|
|
4
|
-
flat-rate
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
flat-rate / quota-metered coding plans where USD cost is meaningless, so it tracks
|
|
5
|
+
**quota windows (5h / weekly / monthly)** and turns them into coaching + loop control.
|
|
6
|
+
Provider-agnostic — configure via `harness.config.json`.
|
|
7
7
|
|
|
8
8
|
Existing plugins only *display* usage. This one **senses quota → coaches → stops/advances
|
|
9
9
|
the loop** — and ships a harness agent mode.
|
|
@@ -23,6 +23,10 @@ the loop** — and ships a harness agent mode.
|
|
|
23
23
|
- Multi-model via plugin tools `generate`/`grade`: run the configured generator/grader model
|
|
24
24
|
(from `harness.config.json`) in a new session on the same server — no second terminal.
|
|
25
25
|
- Reports progress via `task_update` → the sidebar panel shows live task states.
|
|
26
|
+
- **Triggering the harness:** the agent auto-triages — trivial work is done directly,
|
|
27
|
+
substantive multi-step work enters the generate→grade loop. For reliable triggering,
|
|
28
|
+
explicitly ask "run this through the harness" or "use the harness for this". The harness
|
|
29
|
+
tools are only available when the harness agent mode is active (see Install).
|
|
26
30
|
|
|
27
31
|
## Requirements
|
|
28
32
|
- opencode (tested on 1.17.13) with a quota-metered provider configured.
|
|
@@ -115,6 +119,51 @@ Place in the **work directory**. Each role runs on its model, so per-model quota
|
|
|
115
119
|
opencode's bundled solid); TUI plugins must be compiled + loaded via `tui.json` file path;
|
|
116
120
|
`codexbar` must be called via `spawn` (the `$` BunShell leaks output to the TUI).
|
|
117
121
|
|
|
122
|
+
## Troubleshooting
|
|
123
|
+
|
|
124
|
+
Recurring issues and fixes — mostly learned the hard way during development.
|
|
125
|
+
|
|
126
|
+
### TUI panel missing or harness not shown
|
|
127
|
+
- **Check `tui.json` points at the `dist/tui.js` file path** — not the `plugins/` directory.
|
|
128
|
+
```jsonc
|
|
129
|
+
// ~/.config/opencode/tui.json
|
|
130
|
+
{ "$schema": "https://opencode.ai/tui.json", "plugin": ["/abs/path/dist/tui.js"] }
|
|
131
|
+
```
|
|
132
|
+
- **Do NOT install `solid-js` in the config dir** — conflicts with opencode's bundled solid, causes a crash. Keep it peer + external only.
|
|
133
|
+
- **Export must be `{ tui }`** (an object) — a bare function is misread as a server plugin.
|
|
134
|
+
- **Color prop is `fg`** (not `foreground`): `style={{ fg: theme.current.success }}`.
|
|
135
|
+
- **Call `codexbar` via `spawn`** — the `$` BunShell leaks command output into the TUI.
|
|
136
|
+
- **Harness not visible?** The TUI scans session subdirectories for the most recent `active:true` harness. A finished harness (`active:false`) is hidden.
|
|
137
|
+
|
|
138
|
+
### LLM model selection (generate/grade)
|
|
139
|
+
- **`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).
|
|
140
|
+
- Format: `"provider/model"` (e.g. `"opencode/deepseek-v4-flash-free"`, `"opencode/mimo-v2.5-free"` — see `harness.config.example.json`).
|
|
141
|
+
- **Config precedence**: workdir `harness.config.json` > global `~/.config/opencode-usage-coach/harness.config.json`.
|
|
142
|
+
- Omitting `grader` falls back to `generator`. If neither is set, the grade tool returns FAIL + guidance.
|
|
143
|
+
- `provider` (quota source) and `lighterModel` (suggested on throttle) are also configurable in the same file.
|
|
144
|
+
|
|
145
|
+
### Parallel harness execution (generate_batch)
|
|
146
|
+
- **Only INDEPENDENT tasks should be batched** — dependent tasks (B needs A) require sequential `generate` calls.
|
|
147
|
+
- `generate_batch` runs each task in a separate sub-session on the same server (shared model config).
|
|
148
|
+
- runModel polls the sub-session until `idle`/`completed` (max 10 min). Trace with `UC_DEBUG=1`:
|
|
149
|
+
```
|
|
150
|
+
runModel(<generator>): session xxx created, prompt 142 chars
|
|
151
|
+
runModel(<generator>): poll 3s status="running"
|
|
152
|
+
runModel(<generator>): done 48s, 1203 chars
|
|
153
|
+
```
|
|
154
|
+
- `(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
|
+
- **Note**: runModel only terminates on explicit `idle`/`completed` status. An earlier bug broke on `!status` (undefined) immediately — fixed.
|
|
156
|
+
|
|
157
|
+
### Multi-session (per-session state isolation)
|
|
158
|
+
- Each opencode session has a unique sessionID; harness state is isolated per-session:
|
|
159
|
+
```
|
|
160
|
+
~/.cache/opencode-usage-coach/projects/<dir-hash>/<sessionID>/harness.json
|
|
161
|
+
```
|
|
162
|
+
- Different working directories get separate project state (keyed by path hash).
|
|
163
|
+
- The TUI auto-discovers the most recent `active:true` harness — switching sessions shows that session's harness.
|
|
164
|
+
- Harness completion sets `active:false` → hidden from the TUI.
|
|
165
|
+
- Override the state path with `UC_STATE_DIR` (forces global state).
|
|
166
|
+
|
|
118
167
|
## Status
|
|
119
168
|
- ✅ Quota guardian + TUI panel (per-provider coach view, colors, collapsible Alt+H)
|
|
120
169
|
- ✅ Harness: agent mode (triage) with generate/grade model-specific tools (1 terminal, multi-model)
|
|
@@ -34,20 +34,22 @@ Default to the loop only when it genuinely adds value. Do not over-engineer smal
|
|
|
34
34
|
## Harness loop (only for substantive work)
|
|
35
35
|
The user's message is the task source. If it has multiple distinct parts, decompose into discrete tasks (N); if it is one unit, N = 1.
|
|
36
36
|
|
|
37
|
-
**Multi-model, 1 terminal:** the work runs via the plugin tools `generate` and `grade`, which use the configured models (generator/grader from harness.config.json) on the same server — so you get e.g.
|
|
37
|
+
**Multi-model, 1 terminal:** the work runs via the plugin tools `generate` and `grade`, which use the configured models (generator/grader from harness.config.json) on the same server — so you get e.g. a paid generator + a free grader without a second terminal. Do NOT use the `task` tool for harness work; use `generate`/`grade`.
|
|
38
38
|
|
|
39
|
-
**
|
|
39
|
+
**Parallel independent tasks:** if the decomposed tasks are INDEPENDENT, prefer `generate_batch` (one call, all results at once) over multiple sequential `generate` calls. Cap by quota coaching (big-OK → 3-4; throttle → 1-2; STOP → none). DEPENDENT tasks (B needs A) → sequential `generate` calls.
|
|
40
40
|
|
|
41
41
|
1. Call `harness_start(name, N)` to register the run on the panel.
|
|
42
42
|
2. For each task i (1..N):
|
|
43
43
|
a. `task_update(i, title, "generating")`.
|
|
44
|
-
b. **Generate** — call `generate({ prompt: "Task: {title}. Perform it for real in the current directory (write/edit files)." })`. The generator model
|
|
45
|
-
c.
|
|
46
|
-
d.
|
|
47
|
-
e.
|
|
44
|
+
b. **Generate** — call `generate({ prompt: "Task: {title}. Perform it for real in the current directory (write/edit files)." })`. The generator model runs in a sub-session and writes files directly — its return value is a summary, NOT the work itself.
|
|
45
|
+
c. **Verify the work** — after generate returns, read the files it should have produced (use `read`/`glob`) to confirm the work actually exists and is non-trivial. Do not trust the summary alone.
|
|
46
|
+
d. `task_update(i, title, "grading")`.
|
|
47
|
+
e. **Grade** — call `grade({ prompt: "Evaluate the result against the request's intent and general quality. Output PASS or FAIL on the first line, then the reason.\\nRequest: {user request}\\nTask: {title}" })`. The grade tool normalizes the verdict: PASS/FAIL is always on the first line.
|
|
48
|
+
f. Parse the verdict (first non-empty line):
|
|
48
49
|
- `PASS` → `task_update(i, title, "completed", score:"PASS")` → next.
|
|
49
|
-
- `FAIL` and revisions < 2 → `task_update(i, title, "revising", revisions:k)` → `generate({ prompt: "Apply the grading feedback and improve:\\n{grade result}\\nTask: {title}" })` → back to (
|
|
50
|
+
- `FAIL` and revisions < 2 → `task_update(i, title, "revising", revisions:k)` → `generate({ prompt: "Apply the grading feedback and improve:\\n{grade result}\\nTask: {title}" })` → back to (d) re-grade.
|
|
50
51
|
- `FAIL` and revisions exhausted → `task_update(i, title, "failed", score:"FAIL")` → next.
|
|
52
|
+
g. **Error handling:** if `generate` returns text starting with `ERROR:` or `[runModel TIMEOUT`, the sub-session failed. Log it via `task_update(i, title, "failed")` and continue to the next task — do not loop forever on a broken task.
|
|
51
53
|
3. When all tasks are done → `harness_done()`.
|
|
52
54
|
|
|
53
55
|
## Rules
|
package/dist/index.js
CHANGED
|
@@ -25,7 +25,7 @@ function setStateDir(dir) {
|
|
|
25
25
|
}
|
|
26
26
|
var NOOP_HOOKS = {};
|
|
27
27
|
function log(msg) {
|
|
28
|
-
|
|
28
|
+
try {
|
|
29
29
|
appendFileSync(LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
|
|
30
30
|
`);
|
|
31
31
|
} catch {
|
|
@@ -74,36 +74,59 @@ function readHarnessCfg(dir) {
|
|
|
74
74
|
}
|
|
75
75
|
async function runModel(client, model, prompt, directory) {
|
|
76
76
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
77
|
+
const t0 = Date.now();
|
|
77
78
|
try {
|
|
78
79
|
const slash = model.indexOf("/");
|
|
79
80
|
const providerID = slash >= 0 ? model.slice(0, slash) : model;
|
|
80
81
|
const modelID = slash >= 0 ? model.slice(slash + 1) : "";
|
|
81
82
|
const s = await client.session.create({ body: { title: "uc-harness-sub" }, query: { directory } });
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
log(`runModel(${model}): create resp = ${JSON.stringify(s?.data ?? s).slice(0, 500)}`);
|
|
84
|
+
const id = s?.data?.info?.id ?? s?.data?.id ?? s?.id;
|
|
85
|
+
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`);
|
|
84
87
|
await client.session.prompt({
|
|
85
88
|
path: { id },
|
|
86
89
|
body: { model: { providerID, modelID }, parts: [{ type: "text", text: prompt }] }
|
|
87
90
|
});
|
|
91
|
+
log(`runModel(${model}): prompt sent to ${id}`);
|
|
92
|
+
let lastMsgCount = -1;
|
|
93
|
+
let timedOut = false;
|
|
94
|
+
let text = "";
|
|
88
95
|
for (let i = 0; i < 600; i++) {
|
|
89
96
|
await sleep(1e3);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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;
|
|
93
116
|
}
|
|
94
|
-
const
|
|
95
|
-
const all = msgs?.data ?? [];
|
|
96
|
-
const lastAssistant = all.filter((m) => m?.info?.role === "assistant").pop();
|
|
97
|
-
const parts = lastAssistant?.parts ?? [];
|
|
98
|
-
const text = parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("");
|
|
117
|
+
const elapsed = Math.round((Date.now() - t0) / 1e3);
|
|
99
118
|
try {
|
|
100
119
|
await client.session.remove?.({ path: { id } });
|
|
101
120
|
} catch {
|
|
102
121
|
}
|
|
103
|
-
|
|
122
|
+
log(`runModel(${model}): done ${elapsed}s, ${text.length} chars${timedOut ? " [TIMEOUT]" : ""}`);
|
|
123
|
+
if (timedOut) return `[runModel TIMEOUT after ${elapsed}s \u2014 no assistant text appeared]
|
|
124
|
+
${text}`;
|
|
125
|
+
return text || `ERROR: no assistant text after ${elapsed}s (${lastMsgCount} messages seen)`;
|
|
104
126
|
} catch (e) {
|
|
105
|
-
|
|
106
|
-
|
|
127
|
+
const elapsed = Math.round((Date.now() - t0) / 1e3);
|
|
128
|
+
log(`runModel err (${model}, ${elapsed}s): ${String(e)}`);
|
|
129
|
+
return `ERROR: runModel exception after ${elapsed}s: ${String(e)}`;
|
|
107
130
|
}
|
|
108
131
|
}
|
|
109
132
|
var num = (e, d) => {
|
|
@@ -360,7 +383,7 @@ async function UsageCoachPlugin(input) {
|
|
|
360
383
|
const cfg = readHarnessCfg(ctx.directory);
|
|
361
384
|
if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
|
|
362
385
|
const out = await runModel(input.client, cfg.generator, args.prompt, ctx.directory);
|
|
363
|
-
return out
|
|
386
|
+
return out;
|
|
364
387
|
}
|
|
365
388
|
}),
|
|
366
389
|
generate_batch: tool({
|
|
@@ -371,7 +394,7 @@ async function UsageCoachPlugin(input) {
|
|
|
371
394
|
if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
|
|
372
395
|
const results = await Promise.all(args.tasks.map(async (t) => {
|
|
373
396
|
const out = await runModel(input.client, cfg.generator, t.prompt, ctx.directory);
|
|
374
|
-
return `[task ${t.id}] ${out
|
|
397
|
+
return `[task ${t.id}] ${out}`;
|
|
375
398
|
}));
|
|
376
399
|
return results.join("\n\n");
|
|
377
400
|
}
|
|
@@ -382,9 +405,20 @@ async function UsageCoachPlugin(input) {
|
|
|
382
405
|
async execute(args, ctx) {
|
|
383
406
|
const cfg = readHarnessCfg(ctx.directory);
|
|
384
407
|
const model = cfg.grader ?? cfg.generator;
|
|
385
|
-
if (!model) return 'ERROR: no grader/generator model configured. Set "grader"
|
|
408
|
+
if (!model) return 'FAIL\n(ERROR: no grader/generator model configured. Set "grader" and "generator" in harness.config.json.)';
|
|
386
409
|
const out = await runModel(input.client, model, args.prompt, ctx.directory);
|
|
387
|
-
|
|
410
|
+
if (out.startsWith("ERROR:")) return `FAIL
|
|
411
|
+
(${out})`;
|
|
412
|
+
const firstLine = out.split("\n").find((l) => l.trim()) ?? "";
|
|
413
|
+
const f = firstLine.trim();
|
|
414
|
+
const isPass = /^pass\b/i.test(f);
|
|
415
|
+
const isFail = /^fail\b/i.test(f);
|
|
416
|
+
if (!isPass && !isFail) {
|
|
417
|
+
return `FAIL
|
|
418
|
+
(grader did not output PASS/FAIL on the first line; defaulting to FAIL to trigger a revise)
|
|
419
|
+
${out}`;
|
|
420
|
+
}
|
|
421
|
+
return out;
|
|
388
422
|
}
|
|
389
423
|
})
|
|
390
424
|
}
|
package/dist/tui.js
CHANGED
|
@@ -5,7 +5,7 @@ import { effect as _$effect } from "@opentui/solid";
|
|
|
5
5
|
import { createTextNode as _$createTextNode } from "@opentui/solid";
|
|
6
6
|
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
7
7
|
import { createElement as _$createElement } from "@opentui/solid";
|
|
8
|
-
import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs";
|
|
8
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync, statSync } from "fs";
|
|
9
9
|
import { createHash } from "crypto";
|
|
10
10
|
import { homedir } from "os";
|
|
11
11
|
import { join, resolve } from "path";
|
|
@@ -29,8 +29,44 @@ function readState() {
|
|
|
29
29
|
}
|
|
30
30
|
function readHarness() {
|
|
31
31
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
let best = null;
|
|
33
|
+
let entries = [];
|
|
34
|
+
try {
|
|
35
|
+
entries = readdirSync(STATE_DIR);
|
|
36
|
+
} catch {
|
|
37
|
+
}
|
|
38
|
+
for (const d of entries) {
|
|
39
|
+
const sub = join(STATE_DIR, d);
|
|
40
|
+
let isDir = false;
|
|
41
|
+
try {
|
|
42
|
+
isDir = statSync(sub).isDirectory();
|
|
43
|
+
} catch {
|
|
44
|
+
}
|
|
45
|
+
if (!isDir) continue;
|
|
46
|
+
const f = join(sub, "harness.json");
|
|
47
|
+
if (!existsSync(f)) continue;
|
|
48
|
+
let st;
|
|
49
|
+
try {
|
|
50
|
+
st = statSync(f);
|
|
51
|
+
} catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
let active = false;
|
|
55
|
+
try {
|
|
56
|
+
active = !!JSON.parse(readFileSync(f, "utf8")).active;
|
|
57
|
+
} catch {
|
|
58
|
+
}
|
|
59
|
+
if (!best || active && !best.active || active === best.active && st.mtimeMs > best.mtime) {
|
|
60
|
+
best = {
|
|
61
|
+
file: f,
|
|
62
|
+
mtime: st.mtimeMs,
|
|
63
|
+
active
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (best) return JSON.parse(readFileSync(best.file, "utf8"));
|
|
68
|
+
if (existsSync(HARNESS_FILE)) return JSON.parse(readFileSync(HARNESS_FILE, "utf8"));
|
|
69
|
+
return null;
|
|
34
70
|
} catch {
|
|
35
71
|
return null;
|
|
36
72
|
}
|
|
@@ -137,6 +173,7 @@ function initializeTui(api, disposeRoot) {
|
|
|
137
173
|
const hf = join(STATE_DIR, sid, "harness.json");
|
|
138
174
|
if (existsSync(hf)) h = JSON.parse(readFileSync(hf, "utf8"));
|
|
139
175
|
}
|
|
176
|
+
if (!h || h.active === false) h = readHarness();
|
|
140
177
|
} catch {
|
|
141
178
|
h = null;
|
|
142
179
|
}
|
package/package.json
CHANGED