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 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 coding plans (e.g. z.ai / GLM, or any quota-metered provider) where USD cost
5
- is meaningless, so it tracks **quota windows (5h / weekly / monthly)** and turns them
6
- into coaching + loop control. Provider-agnostic — configure via `harness.config.json`.
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. GLM generation + free mimo grading without a second terminal. Do NOT use the `task` tool for harness work; use `generate`/`grade`.
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
- **Independent tasks → parallel:** if the decomposed tasks are INDEPENDENT, call multiple `generate` tools in the same turn (parallel sub-sessions). Cap by quota coaching (big-OK → 3-4; throttle → 1-2; STOP → none). DEPENDENT tasks (B needs A) → sequential.
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 does the work and returns a summary.
45
- c. `task_update(i, title, "grading")`.
46
- d. **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}" })`.
47
- e. Parse the verdict:
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 (c) re-grade.
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
- if (DEBUG) try {
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
- const id = s?.data?.info?.id;
83
- if (!id) return null;
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
- const st = await client.session.status({ path: { id } });
91
- const status = st?.data?.[id]?.status ?? st?.data?.status;
92
- if (status === "idle" || status === "completed" || !status) break;
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 msgs = await client.session.messages({ path: { id } });
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
- return text.trim() || null;
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
- log(`runModel err (${model}): ${String(e)}`);
106
- return null;
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 ?? `(generator ${cfg.generator} produced no text)`;
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 ?? "(no output)"}`;
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" (and "generator") in harness.config.json (see harness.config.example.json).';
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
- return out ?? `(grader ${model} produced no text)`;
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
- if (!existsSync(HARNESS_FILE)) return null;
33
- return JSON.parse(readFileSync(HARNESS_FILE, "utf8"));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "opencode closed-loop usage coach \u2014 quota SENSE -> coaching DECIDE -> loop ACT + TUI integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",