opencode-usage-coach 0.2.2 → 0.2.4

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
5
- **quota windows (5h / weekly / monthly)** and turns them into coaching + loop control.
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`.
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,11 @@ 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
26
 
27
27
  ## 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`).
28
+ - opencode (tested on 1.17.13) with a quota-metered provider configured.
29
+ - `codexbar` CLI with your provider key wired (e.g. `codexbar config set-api-key --provider zai --stdin`).
30
30
 
31
31
  ## Install (from npm)
32
32
  ```jsonc
@@ -69,29 +69,29 @@ This plugin has **four config surfaces**. Only the first two are required to run
69
69
 
70
70
  ### 2. codexbar (quota data source)
71
71
  ```bash
72
- printf '%s' "$Z_AI_API_KEY" | codexbar config set-api-key --provider zai --stdin
72
+ printf '%s' "$YOUR_PROVIDER_API_KEY" | codexbar config set-api-key --provider <id> --stdin
73
73
  ```
74
74
 
75
75
  ### 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%).
76
+ Place in the **work directory**. Each role runs on its model, so per-model quota is tracked
77
+ (works for local LLMs — they show 0%).
78
78
  ```jsonc
79
79
  {
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
80
+ "generator": "opencode/deepseek-v4-flash-free", // model that produces the work
81
+ "grader": "opencode/mimo-v2.5-free", // model that grades it
82
+ "provider": "", // codexbar quota provider ("" = default)
83
+ "lighterModel": "" // suggested when throttling
84
84
  }
85
85
  ```
86
86
  | Field | Default | Notes |
87
87
  |---|---|---|
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 |
88
+ | `generator` | **required** | any `provider/model` opencode knows |
89
+ | `grader` | falls back to `generator` | can differ (multi-model) |
90
+ | `provider` | `""` (codexbar default) | which provider's quota the guardian watches |
91
+ | `lighterModel` | `""` | shown in throttle advice; env `UC_LIGHTER_MODEL` overrides |
92
92
 
93
- Missing roles fall back to `UC_MODEL`. Provider for quota is derived from the model id
94
- (`zai-coding-plan/...` `zai`, `ollama/...``ollama`).
93
+ `generator` is **required** the tools return a clear error if missing. Copy
94
+ `harness.config.example.json` to get started (both example models are free never gated).
95
95
 
96
96
  ### 4. Env vars (thresholds / tuning)
97
97
  | Var | Default | Meaning |
@@ -101,12 +101,9 @@ Missing roles fall back to `UC_MODEL`. Provider for quota is derived from the mo
101
101
  | `UC_STOP_WEEKLY` | 95 | weekly STOP % |
102
102
  | `UC_THROTTLE_WEEKLY` | 85 | weekly throttle % |
103
103
  | `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 |
104
+ | `UC_LIGHTER_MODEL` | (config `lighterModel`) | suggested model when throttling |
105
+ | `UC_PROVIDER` | (config `provider`) | codexbar provider for the guardian |
106
106
  | `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
107
  | `UC_DEBUG` | 0 | set to `1` for a diagnostic log at `~/.cache/opencode-usage-coach/coach.log` |
111
108
 
112
109
  ## Architecture
@@ -114,13 +111,13 @@ Missing roles fall back to `UC_MODEL`. Provider for quota is derived from the mo
114
111
  - **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
112
  - server↔TUI communicate via a state file (`~/.cache/opencode-usage-coach/*.json`) — they are separate processes.
116
113
 
117
- Key lessons (see `PLAN.md`): never install `solid-js` in the config dir (conflicts with
114
+ **Key lessons (hard-won):** never install `solid-js` in the config dir (conflicts with
118
115
  opencode's bundled solid); TUI plugins must be compiled + loaded via `tui.json` file path;
119
116
  `codexbar` must be called via `spawn` (the `$` BunShell leaks output to the TUI).
120
117
 
121
118
  ## 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`)
119
+ - ✅ Quota guardian + TUI panel (per-provider coach view, colors, collapsible Alt+H)
120
+ - ✅ Harness: agent mode (triage) with generate/grade model-specific tools (1 terminal, multi-model)
121
+ - ✅ npm packaging (`opencode plugin install opencode-usage-coach`)
125
122
 
126
123
  License: MIT.
package/dist/index.js CHANGED
@@ -73,6 +73,7 @@ function readHarnessCfg(dir) {
73
73
  };
74
74
  }
75
75
  async function runModel(client, model, prompt, directory) {
76
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
76
77
  try {
77
78
  const slash = model.indexOf("/");
78
79
  const providerID = slash >= 0 ? model.slice(0, slash) : model;
@@ -80,11 +81,20 @@ async function runModel(client, model, prompt, directory) {
80
81
  const s = await client.session.create({ body: { title: "uc-harness-sub" }, query: { directory } });
81
82
  const id = s?.data?.info?.id;
82
83
  if (!id) return null;
83
- const r = await client.session.prompt({
84
+ await client.session.prompt({
84
85
  path: { id },
85
86
  body: { model: { providerID, modelID }, parts: [{ type: "text", text: prompt }] }
86
87
  });
87
- const parts = r?.data?.parts ?? [];
88
+ for (let i = 0; i < 600; i++) {
89
+ 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;
93
+ }
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 ?? [];
88
98
  const text = parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("");
89
99
  try {
90
100
  await client.session.remove?.({ path: { id } });
@@ -96,7 +106,6 @@ async function runModel(client, model, prompt, directory) {
96
106
  return null;
97
107
  }
98
108
  }
99
- var PROVIDER = process.env.UC_PROVIDER ?? "zai";
100
109
  var num = (e, d) => {
101
110
  try {
102
111
  const v = Number(process.env[e]);
@@ -110,7 +119,6 @@ var THR_5H = num("UC_THROTTLE_5H", 70);
110
119
  var STOP_WK = num("UC_STOP_WEEKLY", 95);
111
120
  var THR_WK = num("UC_THROTTLE_WEEKLY", 85);
112
121
  var STOP_MO = num("UC_STOP_MONTHLY", 98);
113
- var LIGHTER = process.env.UC_LIGHTER_MODEL ?? "glm-4.5-air";
114
122
  function humanRemaining(iso) {
115
123
  try {
116
124
  if (!iso) return "";
@@ -181,12 +189,13 @@ async function fetchProvidersCoach() {
181
189
  }));
182
190
  return results.filter(Boolean);
183
191
  }
184
- function fetchQuota() {
192
+ function fetchQuota(provider) {
185
193
  return new Promise((resolve2) => {
186
194
  let out = "";
187
195
  let p;
188
196
  try {
189
- p = spawn("codexbar", ["usage", "--provider", PROVIDER, "--json"], {
197
+ const args = provider ? ["usage", "--provider", provider, "--json"] : ["usage", "--json"];
198
+ p = spawn("codexbar", args, {
190
199
  stdio: ["ignore", "pipe", "ignore"]
191
200
  });
192
201
  } catch {
@@ -209,12 +218,12 @@ function fetchQuota() {
209
218
  });
210
219
  });
211
220
  }
212
- function coach(q) {
221
+ function coach(q, lighter) {
213
222
  if (!q) return { decision: "GO", advice: "quota unavailable \u2014 proceeding cautiously.", weekly: -1, monthly: -1, fiveHour: -1 };
214
223
  const wk = Math.round(q.weekly?.usedPercent ?? 0), mo = Math.round(q.monthly?.usedPercent ?? 0), h5 = Math.round(q.fiveHour?.usedPercent ?? 0);
215
224
  const wkR = humanRemaining(q.weekly?.resetsAt), h5R = humanRemaining(q.fiveHour?.resetsAt);
216
225
  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 });
217
- 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 });
226
+ 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 });
218
227
  if (h5 >= STOP_5H) return stop(`5h window ${h5}% (${h5R})`);
219
228
  if (wk >= STOP_WK) return stop(`weekly ${wk}% (${wkR})`);
220
229
  if (mo >= STOP_MO) return stop(`monthly ${mo}%`);
@@ -226,6 +235,9 @@ var LOADING = { decision: "GO", advice: "quota loading\u2026", weekly: -1, month
226
235
  async function UsageCoachPlugin(input) {
227
236
  try {
228
237
  setStateDir(input.directory);
238
+ const cfg0 = readHarnessCfg(input.directory);
239
+ const PROVIDER = process.env.UC_PROVIDER ?? cfg0.provider ?? "";
240
+ const LIGHTER = process.env.UC_LIGHTER_MODEL ?? cfg0.lighterModel ?? "a lighter model";
229
241
  let last = null;
230
242
  let lastFetchedAt = 0;
231
243
  let refreshing = false;
@@ -234,9 +246,9 @@ async function UsageCoachPlugin(input) {
234
246
  if (refreshing) return;
235
247
  if (last && Date.now() - lastFetchedAt < TTL_MS) return;
236
248
  refreshing = true;
237
- fetchQuota().then(async (q) => {
249
+ fetchQuota(PROVIDER).then(async (q) => {
238
250
  try {
239
- last = coach(q);
251
+ last = coach(q, LIGHTER);
240
252
  lastFetchedAt = Date.now();
241
253
  let providers = [];
242
254
  try {
@@ -346,9 +358,9 @@ async function UsageCoachPlugin(input) {
346
358
  args: { prompt: tool.schema.string() },
347
359
  async execute(args, ctx) {
348
360
  const cfg = readHarnessCfg(ctx.directory);
349
- const model = cfg.generator ?? "zai-coding-plan/glm-5.1";
350
- const out = await runModel(input.client, model, args.prompt, ctx.directory);
351
- return out ?? `(generator ${model} produced no text)`;
361
+ if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
362
+ const out = await runModel(input.client, cfg.generator, args.prompt, ctx.directory);
363
+ return out ?? `(generator ${cfg.generator} produced no text)`;
352
364
  }
353
365
  }),
354
366
  generate_batch: tool({
@@ -356,9 +368,9 @@ async function UsageCoachPlugin(input) {
356
368
  args: { tasks: tool.schema.array(tool.schema.object({ id: tool.schema.number(), prompt: tool.schema.string() })) },
357
369
  async execute(args, ctx) {
358
370
  const cfg = readHarnessCfg(ctx.directory);
359
- const model = cfg.generator ?? "zai-coding-plan/glm-5.1";
371
+ if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
360
372
  const results = await Promise.all(args.tasks.map(async (t) => {
361
- const out = await runModel(input.client, model, t.prompt, ctx.directory);
373
+ const out = await runModel(input.client, cfg.generator, t.prompt, ctx.directory);
362
374
  return `[task ${t.id}] ${out ?? "(no output)"}`;
363
375
  }));
364
376
  return results.join("\n\n");
@@ -369,7 +381,8 @@ async function UsageCoachPlugin(input) {
369
381
  args: { prompt: tool.schema.string() },
370
382
  async execute(args, ctx) {
371
383
  const cfg = readHarnessCfg(ctx.directory);
372
- const model = cfg.grader ?? cfg.generator ?? "opencode/mimo-v2.5-free";
384
+ 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).';
373
386
  const out = await runModel(input.client, model, args.prompt, ctx.directory);
374
387
  return out ?? `(grader ${model} produced no text)`;
375
388
  }
package/dist/tui.js CHANGED
@@ -334,7 +334,7 @@ function initializeTui(api, disposeRoot) {
334
334
  _$effect((_$p) => _$setProp(_el$59, "style", st(sKey), _$p));
335
335
  return _el$59;
336
336
  })());
337
- const pv = t.model ? t.model.startsWith("zai") ? "zai" : (t.model.split("/")[0] ?? "").split("-")[0] : "";
337
+ const pv = t.model ? (t.model.split("/")[0] ?? "").split("-")[0] : "";
338
338
  const q = pv && h.quotas?.[pv] ? h.quotas[pv] : null;
339
339
  const pct = q ? q.fiveHour : 0;
340
340
  nodes.push((() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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": {