opencode-usage-coach 0.3.2 → 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 CHANGED
@@ -166,7 +166,7 @@ Recurring issues and fixes — mostly learned the hard way during development.
166
166
  - **Export must be `{ tui }`** (an object) — a bare function is misread as a server plugin.
167
167
  - **Color prop is `fg`** (not `foreground`): `style={{ fg: theme.current.success }}`.
168
168
  - **Call `codexbar` via `spawn`** — the `$` BunShell leaks command output into the TUI.
169
- - **Harness not visible?** The TUI scans session subdirectories for the most recent `active:true` harness. A finished harness (`active:false`) is hidden.
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.
170
170
 
171
171
  ### LLM model selection (generate/grade)
172
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).
@@ -187,13 +187,19 @@ Recurring issues and fixes — mostly learned the hard way during development.
187
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`).
188
188
  - **Note**: runModel only terminates on explicit `idle`/`completed` status. An earlier bug broke on `!status` (undefined) immediately — fixed.
189
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
+
190
196
  ### Multi-session (per-session state isolation)
191
197
  - Each opencode session has a unique sessionID; harness state is isolated per-session:
192
198
  ```
193
199
  ~/.cache/opencode-usage-coach/projects/<dir-hash>/<sessionID>/harness.json
194
200
  ```
195
201
  - Different working directories get separate project state (keyed by path hash).
196
- - The TUI auto-discovers the most recent `active:true` harness — switching sessions shows that session's harness.
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.
197
203
  - Harness completion sets `active:false` → hidden from the TUI.
198
204
  - Override the state path with `UC_STATE_DIR` (forces global state).
199
205
 
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${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)`;
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)}`);
@@ -376,7 +360,7 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
376
360
  async execute(args, ctx) {
377
361
  const h = readHarness(ctx.sessionID) ?? { name: "batch", total: 0, current: 0, tasks: [], usage: {}, active: true };
378
362
  h.tasks = h.tasks.filter((x) => x.id !== args.id);
379
- 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() });
380
364
  if (args.id > h.current) h.current = args.id;
381
365
  writeHarness(ctx.sessionID, h);
382
366
  return `task ${args.id} -> ${args.status}${args.score ? ` (${args.score})` : ""}`;
@@ -410,7 +394,14 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
410
394
  }
411
395
  const throttle = decision === "THROTTLE" && cfg.lighterModel;
412
396
  const model = throttle ? cfg.lighterModel : cfg.generator;
413
- const out = await runModel(input.client, model, args.prompt, ctx.directory);
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);
414
405
  return out + (throttle ? `
415
406
  [usage-coach] quota THROTTLE \u2014 used lighter model ${cfg.lighterModel}` : "") + `
416
407
  [usage-coach NEXT] call task_update(i, title, "grading"), then grade to evaluate this work.`;
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 q = pv && h.quotas?.[pv] ? h.quotas[pv] : null;
376
- const pct = q ? q.fiveHour : 0;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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",