opencode-usage-coach 0.13.1 → 0.13.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
@@ -25,32 +25,52 @@ See **[docs/architecture.md](docs/architecture.md)** for the full design.
25
25
 
26
26
  ## Quick Start
27
27
 
28
+ ### 3 steps to get running
29
+
30
+ **Step 1 — Install globally (provides the `usage-coach` CLI):**
31
+
32
+ ```bash
33
+ npm install -g opencode-usage-coach
34
+ ```
35
+
36
+ **Step 2 — Add the server plugin to opencode:**
37
+
28
38
  ```jsonc
29
- // ~/.config/opencode/opencode.json — server plugin
39
+ // ~/.config/opencode/opencode.json
30
40
  { "plugin": ["opencode-usage-coach"] }
31
-
32
- // ~/.config/opencode/tui.json — sidebar panel (point at built dist/tui.js)
33
- { "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-usage-coach/tui"] }
34
41
  ```
35
42
 
36
- Then run setup:
43
+ **Step 3 — Run setup (auto-configures everything else):**
37
44
 
38
45
  ```bash
39
- usage-coach setup # auto-generates harness.config.json + copies agent file
40
- usage-coach setup --json # machine-readable output (for scripts/CI)
46
+ usage-coach setup
41
47
  ```
42
48
 
43
- This creates `~/.config/opencode-usage-coach/harness.config.json` (edit `generator`/`grader` or use `/coach-config` at runtime) and copies `agents/usage-coach-harness.md` to `~/.config/opencode/agents/`.
49
+ This single command:
50
+ - Creates `~/.config/opencode-usage-coach/harness.config.json` (model config)
51
+ - Copies the harness agent file to `~/.config/opencode/agents/`
52
+ - **Auto-configures `~/.config/opencode/tui.json`** with the correct TUI plugin path
53
+ - Detects whether `codexbar` is installed
44
54
 
45
- If you use `codexbar` for quota sensing:
55
+ Restart opencode — you're done. The sidebar panel appears (toggle with `Alt+H`).
56
+
57
+ > **Without `npm install -g`:** The server plugin still works (opencode auto-installs it from npm), but `usage-coach setup` and the `usage-coach` CLI won't be available. You'd need to manually create `harness.config.json` and configure `tui.json` yourself.
58
+
59
+ ### Quota sensing (optional but recommended)
60
+
61
+ The plugin works in **GO-only mode** out of the box (no quota sensing). To enable real-time quota monitoring, install [codexbar](https://github.com/nicepkg/codexbar):
46
62
 
47
63
  ```bash
64
+ # macOS / Linux
65
+ curl -fsSL https://raw.githubusercontent.com/nicepkg/codexbar/main/install.sh | bash
66
+
67
+ # Then configure your provider API key:
48
68
  printf '%s' "$YOUR_PROVIDER_API_KEY" | codexbar config set-api-key --provider <id> --stdin
49
69
  ```
50
70
 
51
- Without codexbar, the plugin runs in GO-only mode (no quota sensing).
71
+ codexbar supports any provider with a usage API (OpenAI, Anthropic, Google, z.ai, etc.). Check `codexbar providers` for the full list.
52
72
 
53
- For local dev without npm: `bun install && bun run build`, then point both configs at the `dist/` files.
73
+ Without codexbar, the plugin simply doesn't sense quota all other features (harness loop, learning, domain knowledge) work normally.
54
74
 
55
75
  ## Configuration
56
76
 
@@ -89,7 +109,13 @@ See **[docs/architecture.md](docs/architecture.md)** for details on the loop, NE
89
109
 
90
110
  ## Troubleshooting
91
111
 
92
- Common issues: TUI panel missing (check `tui.json` points at `dist/tui.js`, never install `solid-js` in the config dir), model selection errors (`generator` is required), and tool-abort timeouts (platform limit split large tasks).
112
+ **opencode crashes on startup after adding the plugin** Make sure you're on v0.13.1 or later. Earlier versions had a bug where opencode would crash due to named exports. Run `npm install -g opencode-usage-coach@latest`.
113
+
114
+ **TUI sidebar not showing** — Run `usage-coach setup` again. It auto-detects the TUI path and writes `tui.json`. If you installed via `npm install -g`, the path is `$(npm root -g)/opencode-usage-coach/dist/tui.js`. Never install `solid-js` in the opencode config directory.
115
+
116
+ **`usage-coach: command not found`** — You need `npm install -g opencode-usage-coach` for the CLI. The server plugin works without it, but setup and status commands require the global install.
117
+
118
+ **Model selection errors** — `generator` is required in `harness.config.json`. Run `usage-coach setup` to create it, then edit the model or use `/coach-config` at runtime.
93
119
 
94
120
  See **[docs/troubleshooting.md](docs/troubleshooting.md)** for full diagnoses and fixes.
95
121
 
package/dist/cli.js CHANGED
@@ -345,6 +345,7 @@ function formatDomain(r) {
345
345
  }
346
346
  var GLOBAL_CONFIG_DIR = join(homedir(), ".config", "opencode-usage-coach");
347
347
  var OPENCODE_AGENTS_DIR = join(homedir(), ".config", "opencode", "agents");
348
+ var OPENCODE_TUI_CONFIG = join(homedir(), ".config", "opencode", "tui.json");
348
349
  var DEFAULT_HARNESS_CONFIG = {
349
350
  generator: "opencode/deepseek-v4-flash-free",
350
351
  grader: "opencode/mimo-v2.5-free",
@@ -355,6 +356,42 @@ function resolveAgentSourceFile() {
355
356
  const scriptDir = dirname(fileURLToPath(import.meta.url));
356
357
  return join(scriptDir, "..", "agents", "usage-coach-harness.md");
357
358
  }
359
+ function resolveTuiPath(scriptDir) {
360
+ const dir = scriptDir ?? dirname(fileURLToPath(import.meta.url));
361
+ return join(dir, "tui.js");
362
+ }
363
+ function configureTui(tuiPath, tuiConfigPath) {
364
+ if (!existsSync(tuiPath)) {
365
+ return { action: "not-found", path: tuiPath };
366
+ }
367
+ let config = {};
368
+ if (existsSync(tuiConfigPath)) {
369
+ try {
370
+ config = JSON.parse(readFileSync(tuiConfigPath, "utf8"));
371
+ } catch {
372
+ config = {};
373
+ }
374
+ }
375
+ const plugins = Array.isArray(config["plugin"]) ? config["plugin"] : [];
376
+ if (plugins.includes(tuiPath)) {
377
+ return { action: "exists", path: tuiConfigPath };
378
+ }
379
+ const filtered = plugins.filter(
380
+ (p) => !p.includes("opencode-usage-coach")
381
+ );
382
+ filtered.push(tuiPath);
383
+ config["plugin"] = filtered;
384
+ if (!config["$schema"]) {
385
+ config["$schema"] = "https://opencode.ai/tui.json";
386
+ }
387
+ try {
388
+ mkdirSync(dirname(tuiConfigPath), { recursive: true });
389
+ writeFileSync(tuiConfigPath, JSON.stringify(config, null, 2) + "\n");
390
+ return { action: "configured", path: tuiConfigPath };
391
+ } catch {
392
+ return { action: "write-error", path: tuiConfigPath };
393
+ }
394
+ }
358
395
  function detectCodexbar() {
359
396
  try {
360
397
  const r = spawnSync("codexbar", ["--version"], { timeout: 5e3 });
@@ -370,6 +407,7 @@ function doSetup(opts = {}) {
370
407
  const configDir = opts.configDir ?? GLOBAL_CONFIG_DIR;
371
408
  const agentsDir = opts.agentsDir ?? OPENCODE_AGENTS_DIR;
372
409
  const agentSource = opts.agentSourceFile ?? resolveAgentSourceFile();
410
+ const tuiConfigPath = opts.tuiConfigPath ?? OPENCODE_TUI_CONFIG;
373
411
  const configPath = join(configDir, "harness.config.json");
374
412
  let configAction;
375
413
  if (existsSync(configPath)) {
@@ -394,10 +432,13 @@ function doSetup(opts = {}) {
394
432
  copyFileSync(agentSource, agentDestPath);
395
433
  agentAction = "copied";
396
434
  }
435
+ const tuiPath = resolveTuiPath(opts.scriptDir);
436
+ const tuiResult = configureTui(tuiPath, tuiConfigPath);
397
437
  return {
398
438
  harnessConfig: { action: configAction, path: configPath },
399
439
  codexbar,
400
- agentFile: { action: agentAction, path: agentDestPath }
440
+ agentFile: { action: agentAction, path: agentDestPath },
441
+ tuiConfig: tuiResult
401
442
  };
402
443
  }
403
444
  function formatSetup(r) {
@@ -433,6 +474,20 @@ function formatSetup(r) {
433
474
  lines.push(` Expected: ${r.agentFile.path}`);
434
475
  }
435
476
  lines.push("");
477
+ if (r.tuiConfig.action === "configured") {
478
+ lines.push(` \u2705 TUI plugin configured`);
479
+ lines.push(` ${r.tuiConfig.path}`);
480
+ } else if (r.tuiConfig.action === "exists") {
481
+ lines.push(` \u2705 TUI plugin already configured`);
482
+ lines.push(` ${r.tuiConfig.path}`);
483
+ } else if (r.tuiConfig.action === "not-found") {
484
+ lines.push(` \u26A0\uFE0F TUI plugin (dist/tui.js) not found`);
485
+ lines.push(` Run: npm install -g opencode-usage-coach`);
486
+ } else {
487
+ lines.push(` \u26A0\uFE0F Could not write TUI config`);
488
+ lines.push(` Manually add to ${r.tuiConfig.path}`);
489
+ }
490
+ lines.push("");
436
491
  lines.push("Setup complete. Restart opencode to apply changes.");
437
492
  return lines.join("\n");
438
493
  }
@@ -551,6 +606,7 @@ if (isDirectRun) {
551
606
  main();
552
607
  }
553
608
  export {
609
+ configureTui,
554
610
  doSetup,
555
611
  parseArgs,
556
612
  projectStateDir,
@@ -560,5 +616,6 @@ export {
560
616
  readRules,
561
617
  readStatus,
562
618
  resolveAgentSourceFile,
563
- resolveStateDir
619
+ resolveStateDir,
620
+ resolveTuiPath
564
621
  };
package/dist/index.js CHANGED
@@ -1821,7 +1821,7 @@ async function UsageCoachPlugin(input) {
1821
1821
  const agent = await resolveAgent(input.client, _input.sessionID);
1822
1822
  currentAgent = agent;
1823
1823
  refreshBackground();
1824
- const harnessTools = ["unknown_scan", "question", "generate", "generate_batch", "grade", "investigate", "verify_diagnosis", "generalize", "harness_start", "task_update", "harness_done", "record_failure", "reverse_interview"];
1824
+ const harnessTools = ["unknown_scan", "question", "generate", "generate_batch", "grade", "grade_batch", "investigate", "verify_diagnosis", "generalize", "harness_start", "task_update", "harness_done", "record_failure", "reverse_interview"];
1825
1825
  if (!harnessTools.includes(_input.tool)) return;
1826
1826
  if (!isHarnessAgent(agent)) {
1827
1827
  throw new Error(`[${PLUGIN_NAME}] '${_input.tool}' is restricted to agent mode ${JSON.stringify(HARNESS_AGENTS)} (current: ${JSON.stringify(agent || "unknown")}). Switch to that agent mode to use it.`);
@@ -1885,13 +1885,16 @@ DETERMINISTIC LOOP \u2014 first classify the tasks:
1885
1885
  INDEPENDENT = task B does NOT need task A's output -> use PATH A (parallel, faster)
1886
1886
  DEPENDENT = task B needs task A's output -> use PATH B (sequential)
1887
1887
 
1888
- PATH A \u2014 INDEPENDENT (parallel via generate_batch):
1888
+ PATH A \u2014 INDEPENDENT (parallel via generate_batch + grade_batch):
1889
1889
  1. task_update(1..${args.total}, title, "generating")
1890
1890
  2. generate_batch({tasks: [{id:1, prompt:"Task: <title1>. Perform it."}, ...]}) -> all results + NEXT
1891
- 3. for each i: task_update(i, title, "grading") + grade({prompt:"Evaluate... PASS/FAIL first line. Task: <title>"}) -> verdict + NEXT
1892
- 4. for each i: PASS -> task_update(i, title, "completed", "PASS"); FAIL -> revise (up to 2x) or task_update(i, title, "failed", "FAIL")
1891
+ 3. task_update(1..${args.total}, title, "grading")
1892
+ 4. grade_batch({tasks: [{id:1, prompt:"Evaluate... PASS/FAIL first line. Task: <title1>"}, ...]}) -> all verdicts + per-task NEXT
1893
+ 5. for each i: PASS -> task_update(i, title, "completed", "PASS"); FAIL -> revise (up to 2x) or task_update(i, title, "failed", "FAIL")
1894
+ Note: grade_batch runs all grades IN PARALLEL \u2014 much faster than grading one-by-one.
1895
+ After revisions, you can grade_batch the revised tasks together too.
1893
1896
 
1894
- PATH B \u2014 DEPENDENT (sequential):
1897
+ PATH B \u2014 DEPENDENT (sequential \u2014 one task at a time):
1895
1898
  Optional: task_update(1..N, title, "pending") \u2190 pre-register all tasks first
1896
1899
  for i in 1..${args.total}:
1897
1900
  1. task_update(i, title, "generating")
@@ -1899,6 +1902,7 @@ PATH B \u2014 DEPENDENT (sequential):
1899
1902
  3. task_update(i, title, "grading")
1900
1903
  4. grade(...) -> verdict + NEXT
1901
1904
  5. PASS -> task_update(i, title, "completed", "PASS"); FAIL -> revise (up to 2x) or failed
1905
+ Note: use single grade() here \u2014 tasks are dependent, grading is one at a time.
1902
1906
 
1903
1907
  Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns. Do NOT improvise the sequence.`;
1904
1908
  }
@@ -2521,6 +2525,89 @@ The next generate call will automatically include the new rule.`;
2521
2525
  return out + "\n" + next;
2522
2526
  }
2523
2527
  }),
2528
+ grade_batch: tool({
2529
+ description: "Run the GRADER model on MULTIPLE tasks IN PARALLEL. Quota-aware: GO = full parallel; THROTTLE = concurrency capped at 2; STOP = refused. Use after generate_batch returns results for all INDEPENDENT tasks \u2014 grade them all at once instead of one-by-one. Each result includes a per-task PASS/FAIL verdict + NEXT directive. Resilient: failed grades are retried sequentially (once).",
2530
+ args: {
2531
+ tasks: tool.schema.array(tool.schema.object({
2532
+ id: tool.schema.number().describe("Task ID (must match the task_update ID)."),
2533
+ prompt: tool.schema.string().describe("The grading prompt for this task (same format as grade()).")
2534
+ })).describe("One entry per task to grade. All run in parallel on GO.")
2535
+ },
2536
+ async execute(args, ctx) {
2537
+ const cfg = readHarnessCfg(ctx.directory);
2538
+ const model = cfg.grader ?? cfg.generator;
2539
+ if (!model) {
2540
+ return 'ERROR: no grader/generator model configured. Set "grader" or "generator" in harness.config.json.';
2541
+ }
2542
+ let decision = "GO";
2543
+ try {
2544
+ decision = current().decision;
2545
+ } catch {
2546
+ }
2547
+ if (decision === "STOP") {
2548
+ return 'ERROR: quota STOP \u2014 halt the harness loop now. Call task_update(current, "halted_quota") and stop.';
2549
+ }
2550
+ const limit = decision === "THROTTLE" ? 2 : args.tasks.length;
2551
+ const gradeOne = async (t) => {
2552
+ const gradeTaskId = findActiveTaskId(ctx.sessionID, "grading");
2553
+ const out = await runModel(
2554
+ input.client,
2555
+ model,
2556
+ t.prompt,
2557
+ ctx.directory,
2558
+ gradeTaskId ? { sessionID: ctx.sessionID, taskId: gradeTaskId } : void 0
2559
+ );
2560
+ let verdict = "FAIL";
2561
+ if (!out.startsWith("ERROR:")) {
2562
+ const f = (out.split("\n").find((l) => l.trim()) ?? "").trim();
2563
+ if (/^pass\b/i.test(f)) verdict = "PASS";
2564
+ else if (/^fail\b/i.test(f)) verdict = "FAIL";
2565
+ }
2566
+ const next = verdict === "PASS" ? `[usage-coach NEXT] task ${t.id}: PASS -> task_update(${t.id}, title, "completed", "PASS"), then proceed.` : `[usage-coach NEXT] task ${t.id}: FAIL -> if revisions < 2: task_update(${t.id}, title, "revising") + generate({prompt:"Apply feedback:\\n${out.slice(0, 500)}\\nTask: {title}"}); else: record_failure + investigate + verify + generalize, then task_update(${t.id}, title, "failed", "FAIL").`;
2567
+ return { id: t.id, result: `[task ${t.id}] ${verdict}
2568
+ ${out}
2569
+ ${next}` };
2570
+ };
2571
+ const results = [];
2572
+ const failed = [];
2573
+ for (let i = 0; i < args.tasks.length; i += limit) {
2574
+ const batch = args.tasks.slice(i, i + limit);
2575
+ const settled = await Promise.allSettled(batch.map((t) => gradeOne(t)));
2576
+ for (let j = 0; j < settled.length; j++) {
2577
+ const s = settled[j];
2578
+ const t = batch[j];
2579
+ if (s.status === "fulfilled") {
2580
+ results.push(s.value.result);
2581
+ } else {
2582
+ const err = String(s.reason ?? "unknown rejection");
2583
+ log(`grade_batch task ${t.id} REJECTED: ${err}`);
2584
+ failed.push({ id: t.id, prompt: t.prompt, error: err });
2585
+ }
2586
+ }
2587
+ }
2588
+ if (failed.length > 0) {
2589
+ log(`grade_batch: retrying ${failed.length} failed grade(s) sequentially`);
2590
+ for (const f of failed) {
2591
+ try {
2592
+ const r = await gradeOne(f);
2593
+ results.push(r.result.replace(`[task ${r.id}]`, `[task ${r.id}] (retry)`));
2594
+ } catch (e2) {
2595
+ const err2 = String(e2 ?? "unknown rejection on retry");
2596
+ log(`grade_batch task ${f.id} retry ALSO FAILED: ${err2}`);
2597
+ results.push(`[task ${f.id}] FAIL
2598
+ (ERROR: grade failed on both batch and retry: ${err2})
2599
+ [usage-coach NEXT] task ${f.id}: grade error \u2014 re-run grade() individually for this task.`);
2600
+ }
2601
+ }
2602
+ }
2603
+ const throttleNote = decision === "THROTTLE" ? `
2604
+ [usage-coach] quota THROTTLE \u2014 grader concurrency capped at ${limit}.` : "";
2605
+ const passCount = results.filter((r) => /\[task \d+\] PASS/.test(r)).length;
2606
+ const summary = `
2607
+ [usage-coach] Graded ${results.length} task(s): ${passCount} PASS, ${results.length - passCount} FAIL.`;
2608
+ return results.join("\n\n") + summary + throttleNote;
2609
+ }
2610
+ }),
2524
2611
  reverse_interview: tool({
2525
2612
  description: "Reverse interview: identify ambiguities in the task and ask the user one question at a time, highest design-impact first. Call WITHOUT answer to start or get the next question. Call WITH answer to record the user's response and advance. Returns the next question or a completion summary. ALWAYS present the question to the user verbatim \u2014 do NOT answer it yourself.",
2526
2613
  args: {
package/package.json CHANGED
@@ -1,29 +1,21 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "opencode closed-loop usage coach — quota SENSE -> coaching DECIDE -> loop ACT + TUI integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
8
8
  "exports": {
9
- ".": {
10
- "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
12
- },
13
- "./tui": {
14
- "types": "./dist/tui.d.ts",
15
- "import": "./dist/tui.js"
16
- },
17
- "./cli": {
18
- "types": "./dist/cli.d.ts",
19
- "import": "./dist/cli.js"
20
- }
9
+ ".": "./dist/index.js",
10
+ "./tui": "./dist/tui.js",
11
+ "./cli": "./dist/cli.js"
21
12
  },
22
13
  "bin": {
23
14
  "usage-coach": "./dist/cli.js"
24
15
  },
25
16
  "scripts": {
26
17
  "build": "tsup",
18
+ "prepublishOnly": "npm run lint && npm run typecheck && npm test",
27
19
  "typecheck": "tsc --noEmit",
28
20
  "lint": "eslint .",
29
21
  "lint:fix": "eslint . --fix",