opencode-usage-coach 0.2.3 → 0.2.5

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,16 +1,17 @@
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) where USD cost is meaningless, so it tracks
4
+ flat-rate / quota-metered coding plans where USD cost is meaningless, so it tracks
5
5
  **quota windows (5h / weekly / monthly)** and turns them into coaching + loop control.
6
+ Provider-agnostic — configure via `harness.config.json`.
6
7
 
7
8
  Existing plugins only *display* usage. This one **senses quota → coaches → stops/advances
8
- the loop** — and ships a harness agent mode + a deterministic orchestrator.
9
+ the loop** — and ships a harness agent mode.
9
10
 
10
11
  ## What it does
11
12
 
12
13
  **Always-on guardian (plugin):**
13
- - Senses z.ai quota windows via the `codexbar` CLI.
14
+ - Senses provider quota windows via the `codexbar` CLI (config-driven — any provider codexbar knows).
14
15
  - On STOP threshold: blocks tool calls (`tool.execute.before` throws) → the agent self-stops.
15
16
  - Injects coaching (how to use right now) into the system prompt (double defense).
16
17
  - Surfaces a sidebar panel: per-provider quota meters + harness task states + coaching.
@@ -21,12 +22,15 @@ the loop** — and ships a harness agent mode + a deterministic orchestrator.
21
22
  unclear → clarify; substantive → generate→grade→revise→advance.
22
23
  - Multi-model via plugin tools `generate`/`grade`: run the configured generator/grader model
23
24
  (from `harness.config.json`) in a new session on the same server — no second terminal.
24
- e.g. GLM generation + free mimo grading in one opencode session.
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
- - opencode (tested on 1.17.13) with a z.ai coding-plan provider configured.
29
- - `codexbar` CLI with the z.ai key wired (`codexbar config set-api-key --provider zai --stdin`).
32
+ - opencode (tested on 1.17.13) with a quota-metered provider configured.
33
+ - `codexbar` CLI with your provider key wired (e.g. `codexbar config set-api-key --provider zai --stdin`).
30
34
 
31
35
  ## Install (from npm)
32
36
  ```jsonc
@@ -69,29 +73,29 @@ This plugin has **four config surfaces**. Only the first two are required to run
69
73
 
70
74
  ### 2. codexbar (quota data source)
71
75
  ```bash
72
- printf '%s' "$Z_AI_API_KEY" | codexbar config set-api-key --provider zai --stdin
76
+ printf '%s' "$YOUR_PROVIDER_API_KEY" | codexbar config set-api-key --provider <id> --stdin
73
77
  ```
74
78
 
75
79
  ### 3. Harness config — `harness.config.json` (role → model) ★ the main one
76
- Place in the **work directory** beside `tasks.txt`/`rubric.md`. Each role runs on its model,
77
- so per-model quota is tracked (works for local LLMs — they show 0%).
80
+ Place in the **work directory**. Each role runs on its model, so per-model quota is tracked
81
+ (works for local LLMs — they show 0%).
78
82
  ```jsonc
79
83
  {
80
- "generator": "zai-coding-plan/glm-5.1", // model that produces the work
81
- "grader": "ollama/llama3", // model that grades it
82
- "taskTimeoutMs": 1800000, // per-task timeout (on exceed: auto-split)
83
- "maxRevisions": 2 // revise attempts before giving up
84
+ "generator": "opencode/deepseek-v4-flash-free", // model that produces the work
85
+ "grader": "opencode/mimo-v2.5-free", // model that grades it
86
+ "provider": "", // codexbar quota provider ("" = default)
87
+ "lighterModel": "" // suggested when throttling
84
88
  }
85
89
  ```
86
90
  | Field | Default | Notes |
87
91
  |---|---|---|
88
- | `generator` | `UC_MODEL` | any `provider/model` opencode knows |
89
- | `grader` | `UC_MODEL` | can differ from generator (multi-model) |
90
- | `taskTimeoutMs` | `1800000` (30m) | exceed task split into subtasks |
91
- | `maxRevisions` | `2` | FAIL revise re-grade, up to N times |
92
+ | `generator` | **required** | any `provider/model` opencode knows |
93
+ | `grader` | falls back to `generator` | can differ (multi-model) |
94
+ | `provider` | `""` (codexbar default) | which provider's quota the guardian watches |
95
+ | `lighterModel` | `""` | shown in throttle advice; env `UC_LIGHTER_MODEL` overrides |
92
96
 
93
- Missing roles fall back to `UC_MODEL`. Provider for quota is derived from the model id
94
- (`zai-coding-plan/...` `zai`, `ollama/...``ollama`).
97
+ `generator` is **required** the tools return a clear error if missing. Copy
98
+ `harness.config.example.json` to get started (both example models are free never gated).
95
99
 
96
100
  ### 4. Env vars (thresholds / tuning)
97
101
  | Var | Default | Meaning |
@@ -101,12 +105,9 @@ Missing roles fall back to `UC_MODEL`. Provider for quota is derived from the mo
101
105
  | `UC_STOP_WEEKLY` | 95 | weekly STOP % |
102
106
  | `UC_THROTTLE_WEEKLY` | 85 | weekly throttle % |
103
107
  | `UC_STOP_MONTHLY` | 98 | monthly STOP % |
104
- | `UC_LIGHTER_MODEL` | glm-4.5-air | suggested model when throttling |
105
- | `UC_PROVIDER` | zai | codexbar provider for the guardian |
108
+ | `UC_LIGHTER_MODEL` | (config `lighterModel`) | suggested model when throttling |
109
+ | `UC_PROVIDER` | (config `provider`) | codexbar provider for the guardian |
106
110
  | `UC_TTL_MS` | 60000 | quota cache TTL (ms) |
107
- | `UC_MODEL` | zai-coding-plan/glm-5.1 | harness fallback model |
108
- | `UC_TASK_TIMEOUT_MS` | 1800000 | harness per-task timeout (overridden by config) |
109
- | `UC_MAX_REVISIONS` | 2 | harness revise attempts (overridden by config) |
110
111
  | `UC_DEBUG` | 0 | set to `1` for a diagnostic log at `~/.cache/opencode-usage-coach/coach.log` |
111
112
 
112
113
  ## Architecture
@@ -114,13 +115,58 @@ Missing roles fall back to `UC_MODEL`. Provider for quota is derived from the mo
114
115
  - **TUI module** (`src/tui.tsx`) — SolidJS, reads a state file, renders into `sidebar_footer`/`home_footer`. Loaded via `tui.json`. Bundled with `tsup` + `esbuild-plugin-solid`, solid kept **external** (resolves to opencode's bundle — avoids the duplicate-instance crash). Exports `{ tui }` (a bare function is misread as a server plugin).
115
116
  - server↔TUI communicate via a state file (`~/.cache/opencode-usage-coach/*.json`) — they are separate processes.
116
117
 
117
- Key lessons (see `PLAN.md`): never install `solid-js` in the config dir (conflicts with
118
+ **Key lessons (hard-won):** never install `solid-js` in the config dir (conflicts with
118
119
  opencode's bundled solid); TUI plugins must be compiled + loaded via `tui.json` file path;
119
120
  `codexbar` must be called via `spawn` (the `$` BunShell leaks output to the TUI).
120
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
+
121
167
  ## Status
122
- - ✅ M0–M2 quota guardian + TUI panel (per-provider coach view, colors, collapsible Alt+H)
123
- - ✅ M3 harness: agent mode (triage) with generate/grade model-specific tools (1 terminal, multi-model)
124
- - ✅ M4 npm packaging (`opencode plugin install opencode-usage-coach`)
168
+ - ✅ Quota guardian + TUI panel (per-provider coach view, colors, collapsible Alt+H)
169
+ - ✅ Harness: agent mode (triage) with generate/grade model-specific tools (1 terminal, multi-model)
170
+ - ✅ npm packaging (`opencode plugin install opencode-usage-coach`)
125
171
 
126
172
  License: MIT.
@@ -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
@@ -74,23 +74,40 @@ 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
  const id = s?.data?.info?.id;
83
- if (!id) return null;
84
+ if (!id) {
85
+ log(`runModel(${model}): no session id returned`);
86
+ return null;
87
+ }
88
+ log(`runModel(${model}): session ${id} created, prompt ${prompt.length} chars`);
84
89
  await client.session.prompt({
85
90
  path: { id },
86
91
  body: { model: { providerID, modelID }, parts: [{ type: "text", text: prompt }] }
87
92
  });
93
+ let lastStatus = "";
94
+ let timedOut = false;
88
95
  for (let i = 0; i < 600; i++) {
89
96
  await sleep(1e3);
90
97
  const st = await client.session.status({ path: { id } });
91
98
  const status = st?.data?.[id]?.status ?? st?.data?.status;
92
- if (status === "idle" || status === "completed" || !status) break;
99
+ if (status !== lastStatus) {
100
+ log(`runModel(${model}): poll ${i}s status="${status}"`);
101
+ lastStatus = String(status);
102
+ }
103
+ if (status === "idle" || status === "completed") break;
104
+ if (status === "error" || status === "failed") {
105
+ log(`runModel(${model}): sub-session ended with "${status}"`);
106
+ break;
107
+ }
108
+ if (i === 599) timedOut = true;
93
109
  }
110
+ const elapsed = Math.round((Date.now() - t0) / 1e3);
94
111
  const msgs = await client.session.messages({ path: { id } });
95
112
  const all = msgs?.data ?? [];
96
113
  const lastAssistant = all.filter((m) => m?.info?.role === "assistant").pop();
@@ -100,13 +117,15 @@ async function runModel(client, model, prompt, directory) {
100
117
  await client.session.remove?.({ path: { id } });
101
118
  } catch {
102
119
  }
120
+ log(`runModel(${model}): done ${elapsed}s, ${text.length} chars${timedOut ? " [TIMEOUT]" : ""}`);
121
+ if (timedOut) return `[runModel TIMEOUT after ${elapsed}s \u2014 result may be incomplete]
122
+ ${text.trim()}`;
103
123
  return text.trim() || null;
104
124
  } catch (e) {
105
- log(`runModel err (${model}): ${String(e)}`);
125
+ log(`runModel err (${model}, ${Math.round((Date.now() - t0) / 1e3)}s): ${String(e)}`);
106
126
  return null;
107
127
  }
108
128
  }
109
- var PROVIDER = process.env.UC_PROVIDER ?? "zai";
110
129
  var num = (e, d) => {
111
130
  try {
112
131
  const v = Number(process.env[e]);
@@ -120,7 +139,6 @@ var THR_5H = num("UC_THROTTLE_5H", 70);
120
139
  var STOP_WK = num("UC_STOP_WEEKLY", 95);
121
140
  var THR_WK = num("UC_THROTTLE_WEEKLY", 85);
122
141
  var STOP_MO = num("UC_STOP_MONTHLY", 98);
123
- var LIGHTER = process.env.UC_LIGHTER_MODEL ?? "glm-4.5-air";
124
142
  function humanRemaining(iso) {
125
143
  try {
126
144
  if (!iso) return "";
@@ -191,12 +209,13 @@ async function fetchProvidersCoach() {
191
209
  }));
192
210
  return results.filter(Boolean);
193
211
  }
194
- function fetchQuota() {
212
+ function fetchQuota(provider) {
195
213
  return new Promise((resolve2) => {
196
214
  let out = "";
197
215
  let p;
198
216
  try {
199
- p = spawn("codexbar", ["usage", "--provider", PROVIDER, "--json"], {
217
+ const args = provider ? ["usage", "--provider", provider, "--json"] : ["usage", "--json"];
218
+ p = spawn("codexbar", args, {
200
219
  stdio: ["ignore", "pipe", "ignore"]
201
220
  });
202
221
  } catch {
@@ -219,12 +238,12 @@ function fetchQuota() {
219
238
  });
220
239
  });
221
240
  }
222
- function coach(q) {
241
+ function coach(q, lighter) {
223
242
  if (!q) return { decision: "GO", advice: "quota unavailable \u2014 proceeding cautiously.", weekly: -1, monthly: -1, fiveHour: -1 };
224
243
  const wk = Math.round(q.weekly?.usedPercent ?? 0), mo = Math.round(q.monthly?.usedPercent ?? 0), h5 = Math.round(q.fiveHour?.usedPercent ?? 0);
225
244
  const wkR = humanRemaining(q.weekly?.resetsAt), h5R = humanRemaining(q.fiveHour?.resetsAt);
226
245
  const stop = (r) => ({ decision: "STOP", advice: `STOP recommend \u2014 ${r}. window nearly exhausted. stop now or it will be force-blocked.`, weekly: wk, monthly: mo, fiveHour: h5 });
227
- const thr = (r) => ({ decision: "THROTTLE", advice: `Throttle recommend \u2014 ${r}. switch to lighter model (${LIGHTER}) or wait for window reset.`, weekly: wk, monthly: mo, fiveHour: h5 });
246
+ const thr = (r) => ({ decision: "THROTTLE", advice: `Throttle recommend \u2014 ${r}. switch to lighter model (${lighter}) or wait for window reset.`, weekly: wk, monthly: mo, fiveHour: h5 });
228
247
  if (h5 >= STOP_5H) return stop(`5h window ${h5}% (${h5R})`);
229
248
  if (wk >= STOP_WK) return stop(`weekly ${wk}% (${wkR})`);
230
249
  if (mo >= STOP_MO) return stop(`monthly ${mo}%`);
@@ -236,6 +255,9 @@ var LOADING = { decision: "GO", advice: "quota loading\u2026", weekly: -1, month
236
255
  async function UsageCoachPlugin(input) {
237
256
  try {
238
257
  setStateDir(input.directory);
258
+ const cfg0 = readHarnessCfg(input.directory);
259
+ const PROVIDER = process.env.UC_PROVIDER ?? cfg0.provider ?? "";
260
+ const LIGHTER = process.env.UC_LIGHTER_MODEL ?? cfg0.lighterModel ?? "a lighter model";
239
261
  let last = null;
240
262
  let lastFetchedAt = 0;
241
263
  let refreshing = false;
@@ -244,9 +266,9 @@ async function UsageCoachPlugin(input) {
244
266
  if (refreshing) return;
245
267
  if (last && Date.now() - lastFetchedAt < TTL_MS) return;
246
268
  refreshing = true;
247
- fetchQuota().then(async (q) => {
269
+ fetchQuota(PROVIDER).then(async (q) => {
248
270
  try {
249
- last = coach(q);
271
+ last = coach(q, LIGHTER);
250
272
  lastFetchedAt = Date.now();
251
273
  let providers = [];
252
274
  try {
@@ -356,9 +378,9 @@ async function UsageCoachPlugin(input) {
356
378
  args: { prompt: tool.schema.string() },
357
379
  async execute(args, ctx) {
358
380
  const cfg = readHarnessCfg(ctx.directory);
359
- const model = cfg.generator ?? "zai-coding-plan/glm-5.1";
360
- const out = await runModel(input.client, model, args.prompt, ctx.directory);
361
- return out ?? `(generator ${model} produced no text)`;
381
+ if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
382
+ const out = await runModel(input.client, cfg.generator, args.prompt, ctx.directory);
383
+ return out ?? `(generator ${cfg.generator} produced no text)`;
362
384
  }
363
385
  }),
364
386
  generate_batch: tool({
@@ -366,9 +388,9 @@ async function UsageCoachPlugin(input) {
366
388
  args: { tasks: tool.schema.array(tool.schema.object({ id: tool.schema.number(), prompt: tool.schema.string() })) },
367
389
  async execute(args, ctx) {
368
390
  const cfg = readHarnessCfg(ctx.directory);
369
- const model = cfg.generator ?? "zai-coding-plan/glm-5.1";
391
+ if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
370
392
  const results = await Promise.all(args.tasks.map(async (t) => {
371
- const out = await runModel(input.client, model, t.prompt, ctx.directory);
393
+ const out = await runModel(input.client, cfg.generator, t.prompt, ctx.directory);
372
394
  return `[task ${t.id}] ${out ?? "(no output)"}`;
373
395
  }));
374
396
  return results.join("\n\n");
@@ -379,9 +401,20 @@ async function UsageCoachPlugin(input) {
379
401
  args: { prompt: tool.schema.string() },
380
402
  async execute(args, ctx) {
381
403
  const cfg = readHarnessCfg(ctx.directory);
382
- const model = cfg.grader ?? cfg.generator ?? "opencode/mimo-v2.5-free";
404
+ const model = cfg.grader ?? cfg.generator;
405
+ if (!model) return 'FAIL\n(ERROR: no grader/generator model configured. Set "grader" and "generator" in harness.config.json.)';
383
406
  const out = await runModel(input.client, model, args.prompt, ctx.directory);
384
- return out ?? `(grader ${model} produced no text)`;
407
+ if (!out) return "FAIL\n(grader produced no output \u2014 treating as FAIL to trigger a revise)";
408
+ const firstLine = out.split("\n").find((l) => l.trim()) ?? "";
409
+ const f = firstLine.trim();
410
+ const isPass = /^pass\b/i.test(f);
411
+ const isFail = /^fail\b/i.test(f);
412
+ if (!isPass && !isFail) {
413
+ return `FAIL
414
+ (grader did not output PASS/FAIL on the first line; defaulting to FAIL to trigger a revise)
415
+ ${out}`;
416
+ }
417
+ return out;
385
418
  }
386
419
  })
387
420
  }
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
  }
@@ -334,7 +370,7 @@ function initializeTui(api, disposeRoot) {
334
370
  _$effect((_$p) => _$setProp(_el$59, "style", st(sKey), _$p));
335
371
  return _el$59;
336
372
  })());
337
- const pv = t.model ? t.model.startsWith("zai") ? "zai" : (t.model.split("/")[0] ?? "").split("-")[0] : "";
373
+ const pv = t.model ? (t.model.split("/")[0] ?? "").split("-")[0] : "";
338
374
  const q = pv && h.quotas?.[pv] ? h.quotas[pv] : null;
339
375
  const pct = q ? q.fiveHour : 0;
340
376
  nodes.push((() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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",
@@ -33,8 +33,8 @@
33
33
  "usage",
34
34
  "coach",
35
35
  "loop",
36
- "zai",
37
- "glm"
36
+ "llm",
37
+ "harness"
38
38
  ],
39
39
  "license": "MIT",
40
40
  "peerDependencies": {