opencode-usage-coach 0.12.1 → 0.13.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
@@ -33,16 +33,23 @@ See **[docs/architecture.md](docs/architecture.md)** for the full design.
33
33
  { "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-usage-coach/tui"] }
34
34
  ```
35
35
 
36
- Then wire `codexbar` and drop `agents/usage-coach-harness.md` into `~/.config/opencode/agents/` for agent mode:
36
+ Then run setup:
37
37
 
38
38
  ```bash
39
- # provider quota data source
40
- printf '%s' "$YOUR_PROVIDER_API_KEY" | codexbar config set-api-key --provider <id> --stdin
39
+ usage-coach setup # auto-generates harness.config.json + copies agent file
40
+ usage-coach setup --json # machine-readable output (for scripts/CI)
41
+ ```
42
+
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/`.
44
+
45
+ If you use `codexbar` for quota sensing:
41
46
 
42
- # harness role → model mapping (place in your work directory)
43
- cp harness.config.example.json harness.config.json # edit generator/grader
47
+ ```bash
48
+ printf '%s' "$YOUR_PROVIDER_API_KEY" | codexbar config set-api-key --provider <id> --stdin
44
49
  ```
45
50
 
51
+ Without codexbar, the plugin runs in GO-only mode (no quota sensing).
52
+
46
53
  For local dev without npm: `bun install && bun run build`, then point both configs at the `dist/` files.
47
54
 
48
55
  ## Configuration
package/dist/cli.js ADDED
@@ -0,0 +1,564 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import {
5
+ readFileSync,
6
+ writeFileSync,
7
+ existsSync,
8
+ readdirSync,
9
+ statSync,
10
+ mkdirSync,
11
+ copyFileSync
12
+ } from "fs";
13
+ import { createHash } from "crypto";
14
+ import { homedir } from "os";
15
+ import { join, resolve, dirname } from "path";
16
+ import { fileURLToPath } from "url";
17
+ import { spawnSync } from "child_process";
18
+ function projectStateDir(dir) {
19
+ const abs = resolve(dir || ".");
20
+ const h = createHash("sha1").update(abs).digest("hex").slice(0, 12);
21
+ return join(homedir(), ".cache", "opencode-usage-coach", "projects", h);
22
+ }
23
+ function resolveStateDir(dir) {
24
+ return process.env.UC_STATE_DIR ?? projectStateDir(dir ?? process.cwd());
25
+ }
26
+ var CACHE_ROOT = join(homedir(), ".cache", "opencode-usage-coach");
27
+ function readJson(path) {
28
+ try {
29
+ if (!existsSync(path)) return null;
30
+ return JSON.parse(readFileSync(path, "utf8"));
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+ function readLines(path) {
36
+ try {
37
+ if (!existsSync(path)) return [];
38
+ return readFileSync(path, "utf8").split("\n").filter(Boolean);
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+ function readNdjson(path) {
44
+ return readLines(path).map((l) => {
45
+ try {
46
+ return JSON.parse(l);
47
+ } catch {
48
+ return null;
49
+ }
50
+ }).filter((x) => x !== null);
51
+ }
52
+ function readText(path) {
53
+ try {
54
+ if (!existsSync(path)) return "";
55
+ return readFileSync(path, "utf8");
56
+ } catch {
57
+ return "";
58
+ }
59
+ }
60
+ function countFileLines(path) {
61
+ return readLines(path).length;
62
+ }
63
+ function findHarness(stateDir) {
64
+ let best = null;
65
+ let entries = [];
66
+ try {
67
+ entries = readdirSync(stateDir);
68
+ } catch {
69
+ }
70
+ for (const d of entries) {
71
+ const sub = join(stateDir, d);
72
+ let isDir = false;
73
+ try {
74
+ isDir = statSync(sub).isDirectory();
75
+ } catch {
76
+ }
77
+ if (!isDir) continue;
78
+ const f = join(sub, "harness.json");
79
+ if (!existsSync(f)) continue;
80
+ let st;
81
+ try {
82
+ st = statSync(f);
83
+ } catch {
84
+ continue;
85
+ }
86
+ let active = false;
87
+ try {
88
+ active = !!JSON.parse(readFileSync(f, "utf8")).active;
89
+ } catch {
90
+ }
91
+ if (!best || active && !best.active || active === best.active && st.mtimeMs > best.mtime) {
92
+ best = { file: f, mtime: st.mtimeMs, active };
93
+ }
94
+ }
95
+ if (best) return readJson(best.file);
96
+ return readJson(join(stateDir, "harness.json"));
97
+ }
98
+ function readStatus(dir) {
99
+ const stateDir = resolveStateDir(dir);
100
+ const s = readJson(join(stateDir, "state.json"));
101
+ const h = findHarness(stateDir);
102
+ const rulesCount = parseRules(readText(join(stateDir, "rules.md"))).length;
103
+ const failuresCount = countFileLines(join(stateDir, "failures.ndjson"));
104
+ const domainNodes = countFileLines(join(stateDir, "nodes.ndjson"));
105
+ const domainEdges = countFileLines(join(stateDir, "edges.ndjson"));
106
+ return {
107
+ directory: resolve(dir ?? process.cwd()),
108
+ stateDir,
109
+ quota: s ? {
110
+ decision: s.decision,
111
+ fiveHour: s.fiveHour,
112
+ weekly: s.weekly,
113
+ monthly: s.monthly,
114
+ model: s.model,
115
+ provider: s.provider,
116
+ isFree: s.isFree,
117
+ advice: s.advice
118
+ } : null,
119
+ providers: s?.providers ?? null,
120
+ harness: h ? {
121
+ active: h.active ?? false,
122
+ name: h.name,
123
+ total: h.total,
124
+ current: h.current,
125
+ tasks: h.tasks.map((t) => ({
126
+ id: t.id,
127
+ title: t.title,
128
+ status: t.status,
129
+ score: t.score ?? void 0,
130
+ model: t.model,
131
+ steps: t.subStep
132
+ }))
133
+ } : null,
134
+ learning: { rulesCount, failuresCount, domainNodes, domainEdges },
135
+ updatedAt: s?.updatedAt
136
+ };
137
+ }
138
+ function readAggregateStatus() {
139
+ const projectsDir = join(CACHE_ROOT, "projects");
140
+ let dirs = [];
141
+ try {
142
+ dirs = readdirSync(projectsDir).map((d) => join(projectsDir, d));
143
+ } catch {
144
+ }
145
+ const instances = [];
146
+ const decCount = {};
147
+ let max5h = 0, maxWk = 0, maxMo = 0, activeHarnesses = 0, totalTasks = 0, totalRules = 0, totalFailures = 0, totalDomainNodes = 0, totalDomainEdges = 0;
148
+ for (const d of dirs) {
149
+ let isDir = false;
150
+ try {
151
+ isDir = statSync(d).isDirectory();
152
+ } catch {
153
+ continue;
154
+ }
155
+ if (!isDir) continue;
156
+ const s = readJson(join(d, "state.json"));
157
+ const decision = s?.decision ?? "unknown";
158
+ const fiveHour = s?.fiveHour ?? 0;
159
+ const weekly = s?.weekly ?? 0;
160
+ instances.push({
161
+ directory: d,
162
+ stateDir: d,
163
+ decision,
164
+ fiveHour,
165
+ weekly,
166
+ model: s?.model
167
+ });
168
+ decCount[decision] = (decCount[decision] ?? 0) + 1;
169
+ max5h = Math.max(max5h, fiveHour);
170
+ maxWk = Math.max(maxWk, weekly);
171
+ maxMo = Math.max(maxMo, s?.monthly ?? 0);
172
+ const h = findHarness(d);
173
+ if (h?.active) {
174
+ activeHarnesses++;
175
+ totalTasks += h.tasks.length;
176
+ }
177
+ totalRules += parseRules(readText(join(d, "rules.md"))).length;
178
+ totalFailures += countFileLines(join(d, "failures.ndjson"));
179
+ totalDomainNodes += countFileLines(join(d, "nodes.ndjson"));
180
+ totalDomainEdges += countFileLines(join(d, "edges.ndjson"));
181
+ }
182
+ return {
183
+ instanceCount: instances.length,
184
+ instances,
185
+ aggregate: {
186
+ maxFiveHour: max5h,
187
+ maxWeekly: maxWk,
188
+ maxMonthly: maxMo,
189
+ decisions: decCount,
190
+ activeHarnesses,
191
+ totalTasks,
192
+ totalRules,
193
+ totalFailures,
194
+ totalDomainNodes,
195
+ totalDomainEdges
196
+ }
197
+ };
198
+ }
199
+ function readRules(dir) {
200
+ const stateDir = resolveStateDir(dir);
201
+ const content = readText(join(stateDir, "rules.md"));
202
+ const rules = parseRules(content);
203
+ return { count: rules.length, rules };
204
+ }
205
+ function parseRules(content) {
206
+ if (!content.trim()) return [];
207
+ const blocks = content.split(/^## /m).filter((b) => b.startsWith("Rule"));
208
+ return blocks.map((block) => {
209
+ const headerMatch = block.match(
210
+ /^Rule\s+(\d+)\s*\(([^,]+),\s*category:\s*([^)]+)\)/
211
+ );
212
+ const number = headerMatch ? parseInt(headerMatch[1], 10) : 0;
213
+ const date = headerMatch ? headerMatch[2].trim() : "";
214
+ const category = headerMatch ? headerMatch[3].trim() : "";
215
+ const body = block.slice(headerMatch?.[0]?.length ?? 0).trim();
216
+ const originMatch = body.match(/^Origin:\s*(.+)$/m);
217
+ const text = body.split("\n").filter((l) => !l.startsWith("Origin:")).join(" ").trim();
218
+ return {
219
+ number,
220
+ category,
221
+ date,
222
+ text,
223
+ origin: originMatch ? originMatch[1].trim() : ""
224
+ };
225
+ });
226
+ }
227
+ function readDecisions(dir, limit = 20) {
228
+ const stateDir = resolveStateDir(dir);
229
+ const lines = readLines(join(stateDir, "coach.log"));
230
+ const decisions = [];
231
+ for (let i = lines.length - 1; i >= 0 && decisions.length < limit; i--) {
232
+ const line = lines[i];
233
+ const m = line.match(
234
+ /^(\S+)\s+DECIDE\s+(GO|THROTTLE|STOP)\s+(.*)$/
235
+ );
236
+ if (m) {
237
+ decisions.push({
238
+ ts: m[1],
239
+ decision: m[2],
240
+ detail: m[3]
241
+ });
242
+ }
243
+ }
244
+ return { count: decisions.length, decisions };
245
+ }
246
+ function readDomainStats(dir) {
247
+ const stateDir = resolveStateDir(dir);
248
+ const nodes = readNdjson(join(stateDir, "nodes.ndjson"));
249
+ const edges = readNdjson(join(stateDir, "edges.ndjson"));
250
+ const nodeTypes = {};
251
+ for (const n of nodes) {
252
+ const t = n.type ?? "unknown";
253
+ nodeTypes[t] = (nodeTypes[t] ?? 0) + 1;
254
+ }
255
+ const edgeTypes = {};
256
+ for (const e of edges) {
257
+ const r = e.rel ?? "unknown";
258
+ edgeTypes[r] = (edgeTypes[r] ?? 0) + 1;
259
+ }
260
+ return {
261
+ nodes: nodes.length,
262
+ edges: edges.length,
263
+ nodeTypes,
264
+ edgeTypes
265
+ };
266
+ }
267
+ function bar(pct) {
268
+ const n = pct <= 0 ? 0 : Math.max(1, Math.min(10, Math.round(pct / 10)));
269
+ return "\u2588".repeat(n) + "\u2591".repeat(10 - n);
270
+ }
271
+ function formatStatus(r) {
272
+ const lines = [];
273
+ if (!r.quota) {
274
+ lines.push("usage-coach: no state (is the plugin running?)");
275
+ return lines.join("\n");
276
+ }
277
+ const q = r.quota;
278
+ const tag = q.isFree ? "free" : q.decision;
279
+ const model = q.model ? ` ${q.model.split("/").pop()}` : "";
280
+ lines.push(`usage-coach [${tag}]${model}`);
281
+ if (!q.isFree) {
282
+ lines.push(` 5h ${bar(q.fiveHour)} ${q.fiveHour}%`);
283
+ lines.push(` 1w ${bar(q.weekly)} ${q.weekly}%`);
284
+ }
285
+ if (q.advice) lines.push(` ${q.advice}`);
286
+ if (r.harness?.active) {
287
+ lines.push("");
288
+ lines.push(`harness: ${r.harness.name} ${r.harness.current}/${r.harness.total}`);
289
+ for (const t of r.harness.tasks) {
290
+ const score = t.score ? ` [${t.score}]` : "";
291
+ lines.push(` ${t.id} [${t.status}]${score} ${t.title}`);
292
+ }
293
+ }
294
+ if (r.learning.rulesCount > 0) {
295
+ lines.push("");
296
+ lines.push(
297
+ `learning: ${r.learning.rulesCount} rules, ${r.learning.failuresCount} failures, ${r.learning.domainNodes} domain nodes`
298
+ );
299
+ }
300
+ return lines.join("\n");
301
+ }
302
+ function formatAggregate(r) {
303
+ const lines = [];
304
+ const a = r.aggregate;
305
+ lines.push(`usage-coach aggregate \u2014 ${r.instanceCount} instances`);
306
+ lines.push(` max 5h ${bar(a.maxFiveHour)} ${a.maxFiveHour}%`);
307
+ lines.push(` max 1w ${bar(a.maxWeekly)} ${a.maxWeekly}%`);
308
+ const decs = Object.entries(a.decisions).map(([k, v]) => `${k}:${v}`).join(" ");
309
+ lines.push(` decisions: ${decs}`);
310
+ lines.push(
311
+ ` harnesses: ${a.activeHarnesses} active, ${a.totalTasks} tasks`
312
+ );
313
+ lines.push(
314
+ ` learning: ${a.totalRules} rules, ${a.totalFailures} failures, ${a.totalDomainNodes} domain nodes`
315
+ );
316
+ return lines.join("\n");
317
+ }
318
+ function formatRules(r) {
319
+ if (r.count === 0) return "No rules accumulated yet.";
320
+ const lines = [`${r.count} rules:`];
321
+ for (const rule of r.rules) {
322
+ lines.push(
323
+ ` #${rule.number} (${rule.date}, ${rule.category}): ${rule.text.slice(0, 80)}${rule.text.length > 80 ? "..." : ""}`
324
+ );
325
+ }
326
+ return lines.join("\n");
327
+ }
328
+ function formatDecisions(r) {
329
+ if (r.count === 0) return "No decisions logged.";
330
+ const lines = [`${r.count} recent decisions:`];
331
+ for (const d of r.decisions) {
332
+ lines.push(` ${d.ts} ${d.decision} ${d.detail}`);
333
+ }
334
+ return lines.join("\n");
335
+ }
336
+ function formatDomain(r) {
337
+ if (r.nodes === 0 && r.edges === 0) return "No domain knowledge stored.";
338
+ const nt = Object.entries(r.nodeTypes).map(([k, v]) => `${k}:${v}`).join(" ");
339
+ const et = Object.entries(r.edgeTypes).map(([k, v]) => `${k}:${v}`).join(" ");
340
+ return [
341
+ `domain: ${r.nodes} nodes, ${r.edges} edges`,
342
+ ` node types: ${nt}`,
343
+ ` edge types: ${et}`
344
+ ].join("\n");
345
+ }
346
+ var GLOBAL_CONFIG_DIR = join(homedir(), ".config", "opencode-usage-coach");
347
+ var OPENCODE_AGENTS_DIR = join(homedir(), ".config", "opencode", "agents");
348
+ var DEFAULT_HARNESS_CONFIG = {
349
+ generator: "opencode/deepseek-v4-flash-free",
350
+ grader: "opencode/mimo-v2.5-free",
351
+ provider: "",
352
+ lighterModel: ""
353
+ };
354
+ function resolveAgentSourceFile() {
355
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
356
+ return join(scriptDir, "..", "agents", "usage-coach-harness.md");
357
+ }
358
+ function detectCodexbar() {
359
+ try {
360
+ const r = spawnSync("codexbar", ["--version"], { timeout: 5e3 });
361
+ if (r.status === 0 || r.stdout && r.stdout.toString().trim().length > 0) {
362
+ return { found: true, version: (r.stdout?.toString().trim() ?? "") || "unknown" };
363
+ }
364
+ return { found: false, version: "" };
365
+ } catch {
366
+ return { found: false, version: "" };
367
+ }
368
+ }
369
+ function doSetup(opts = {}) {
370
+ const configDir = opts.configDir ?? GLOBAL_CONFIG_DIR;
371
+ const agentsDir = opts.agentsDir ?? OPENCODE_AGENTS_DIR;
372
+ const agentSource = opts.agentSourceFile ?? resolveAgentSourceFile();
373
+ const configPath = join(configDir, "harness.config.json");
374
+ let configAction;
375
+ if (existsSync(configPath)) {
376
+ configAction = "exists";
377
+ } else {
378
+ mkdirSync(configDir, { recursive: true });
379
+ writeFileSync(
380
+ configPath,
381
+ JSON.stringify(DEFAULT_HARNESS_CONFIG, null, 2) + "\n"
382
+ );
383
+ configAction = "created";
384
+ }
385
+ const codexbar = detectCodexbar();
386
+ const agentDestPath = join(agentsDir, "usage-coach-harness.md");
387
+ let agentAction;
388
+ if (!existsSync(agentSource)) {
389
+ agentAction = "source-not-found";
390
+ } else if (existsSync(agentDestPath)) {
391
+ agentAction = "exists";
392
+ } else {
393
+ mkdirSync(agentsDir, { recursive: true });
394
+ copyFileSync(agentSource, agentDestPath);
395
+ agentAction = "copied";
396
+ }
397
+ return {
398
+ harnessConfig: { action: configAction, path: configPath },
399
+ codexbar,
400
+ agentFile: { action: agentAction, path: agentDestPath }
401
+ };
402
+ }
403
+ function formatSetup(r) {
404
+ const lines = ["usage-coach setup", ""];
405
+ const configCheck = r.harnessConfig.action === "created" ? "\u2705" : "\u2705";
406
+ lines.push(
407
+ ` ${configCheck} harness.config.json ${r.harnessConfig.action === "created" ? "created" : "already exists"}`
408
+ );
409
+ lines.push(` ${r.harnessConfig.path}`);
410
+ if (r.harnessConfig.action === "created") {
411
+ lines.push(
412
+ ` generator: ${DEFAULT_HARNESS_CONFIG.generator} (edit or use /coach-config)`
413
+ );
414
+ }
415
+ if (r.codexbar.found) {
416
+ lines.push("");
417
+ lines.push(` \u2705 codexbar found`);
418
+ lines.push(` ${r.codexbar.version}`);
419
+ } else {
420
+ lines.push("");
421
+ lines.push(` \u26A0\uFE0F codexbar not found`);
422
+ lines.push(` Plugin runs in GO-only mode (no quota sensing)`);
423
+ }
424
+ lines.push("");
425
+ if (r.agentFile.action === "copied") {
426
+ lines.push(` \u2705 Agent file copied`);
427
+ lines.push(` ${r.agentFile.path}`);
428
+ } else if (r.agentFile.action === "exists") {
429
+ lines.push(` \u2705 Agent file already exists`);
430
+ lines.push(` ${r.agentFile.path}`);
431
+ } else {
432
+ lines.push(` \u26A0\uFE0F Agent source not found`);
433
+ lines.push(` Expected: ${r.agentFile.path}`);
434
+ }
435
+ lines.push("");
436
+ lines.push("Setup complete. Restart opencode to apply changes.");
437
+ return lines.join("\n");
438
+ }
439
+ function parseArgs(argv) {
440
+ const args = argv.slice(2);
441
+ let command = "status";
442
+ let json = false;
443
+ let dir;
444
+ let aggregate = false;
445
+ let limit = 20;
446
+ for (let i = 0; i < args.length; i++) {
447
+ const a = args[i];
448
+ if (a === "--json" || a === "-j") json = true;
449
+ else if (a === "--aggregate" || a === "-a") aggregate = true;
450
+ else if (a === "--dir" || a === "-d") {
451
+ dir = args[++i];
452
+ } else if (a === "--limit" || a === "-l") {
453
+ limit = parseInt(args[++i], 10) || 20;
454
+ } else if (a === "--help" || a === "-h") {
455
+ command = "help";
456
+ } else if (a === "--version" || a === "-v") {
457
+ command = "version";
458
+ } else if (!a.startsWith("-")) {
459
+ command = a;
460
+ }
461
+ }
462
+ return { command, json, dir, aggregate, limit };
463
+ }
464
+ var HELP = `usage-coach \u2014 quota intelligence CLI for opencode
465
+
466
+ Commands:
467
+ status Show quota + harness + learning state (default)
468
+ rules List accumulated learning rules
469
+ decisions Show recent GO/THROTTLE/STOP decision history
470
+ domain Show domain knowledge graph stats
471
+ setup Auto-generate harness.config.json, detect codexbar, copy agent file
472
+
473
+ Flags:
474
+ --json, -j Output as JSON (default: human-readable)
475
+ --dir <path> Project directory to query (default: cwd)
476
+ --aggregate, -a Scan all project instances (status only)
477
+ --limit <n> Number of decisions to show (default: 20)
478
+ --help, -h Show this help
479
+ --version, -v Show version
480
+
481
+ Examples:
482
+ usage-coach status --json
483
+ usage-coach status --aggregate --json
484
+ usage-coach rules --json --dir /path/to/project`;
485
+ function getVersion() {
486
+ try {
487
+ const pkgPath = join(
488
+ dirname(fileURLToPath(import.meta.url)),
489
+ "..",
490
+ "package.json"
491
+ );
492
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
493
+ return pkg.version ?? "unknown";
494
+ } catch {
495
+ return "unknown";
496
+ }
497
+ }
498
+ function main() {
499
+ const args = parseArgs(process.argv);
500
+ switch (args.command) {
501
+ case "help": {
502
+ console.log(HELP);
503
+ break;
504
+ }
505
+ case "version": {
506
+ console.log(getVersion());
507
+ break;
508
+ }
509
+ case "status": {
510
+ if (args.aggregate) {
511
+ const r = readAggregateStatus();
512
+ console.log(args.json ? JSON.stringify(r, null, 2) : formatAggregate(r));
513
+ } else {
514
+ const r = readStatus(args.dir);
515
+ console.log(args.json ? JSON.stringify(r, null, 2) : formatStatus(r));
516
+ }
517
+ break;
518
+ }
519
+ case "rules": {
520
+ const r = readRules(args.dir);
521
+ console.log(args.json ? JSON.stringify(r, null, 2) : formatRules(r));
522
+ break;
523
+ }
524
+ case "decisions": {
525
+ const r = readDecisions(args.dir, args.limit);
526
+ console.log(
527
+ args.json ? JSON.stringify(r, null, 2) : formatDecisions(r)
528
+ );
529
+ break;
530
+ }
531
+ case "domain": {
532
+ const r = readDomainStats(args.dir);
533
+ console.log(args.json ? JSON.stringify(r, null, 2) : formatDomain(r));
534
+ break;
535
+ }
536
+ case "setup": {
537
+ const r = doSetup();
538
+ console.log(args.json ? JSON.stringify(r, null, 2) : formatSetup(r));
539
+ break;
540
+ }
541
+ default: {
542
+ console.error(`Unknown command: ${args.command}
543
+
544
+ ${HELP}`);
545
+ process.exit(1);
546
+ }
547
+ }
548
+ }
549
+ var isDirectRun = process.argv[1] && (process.argv[1].endsWith("cli.js") || process.argv[1].endsWith("cli.ts") || process.argv[1].endsWith("usage-coach"));
550
+ if (isDirectRun) {
551
+ main();
552
+ }
553
+ export {
554
+ doSetup,
555
+ parseArgs,
556
+ projectStateDir,
557
+ readAggregateStatus,
558
+ readDecisions,
559
+ readDomainStats,
560
+ readRules,
561
+ readStatus,
562
+ resolveAgentSourceFile,
563
+ resolveStateDir
564
+ };
package/dist/index.js CHANGED
@@ -1349,6 +1349,7 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
1349
1349
  const providerID = slash >= 0 ? model.slice(0, slash) : model;
1350
1350
  const modelID = slash >= 0 ? model.slice(slash + 1) : "";
1351
1351
  const s = await client.session.create({ body: { title: "uc-harness-sub" }, query: { directory } });
1352
+ log(`runModel(${model}): session.create ${Date.now() - t0}ms`);
1352
1353
  const id = s?.data?.info?.id ?? s?.data?.id ?? s?.id;
1353
1354
  if (!id) return `ERROR: session.create returned no id (response: ${JSON.stringify(s?.data ?? s).slice(0, 200)})`;
1354
1355
  subId = id;
@@ -1417,6 +1418,7 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
1417
1418
  );
1418
1419
  const resp = await Promise.race([promptP, timeoutSignal.then(() => null)]);
1419
1420
  const elapsed = Math.round((Date.now() - t0) / 1e3);
1421
+ log(`runModel(${model}): prompt resolved ${elapsed}s, timedOut=${timedOut}`);
1420
1422
  if (timedOut) {
1421
1423
  try {
1422
1424
  const summary = await client.session.summarize?.({ path: { id } });
@@ -1424,7 +1426,7 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
1424
1426
  } catch {
1425
1427
  }
1426
1428
  try {
1427
- await client.session.delete?.({ path: { id } });
1429
+ client.session.delete?.({ path: { id } });
1428
1430
  } catch {
1429
1431
  }
1430
1432
  subId = null;
@@ -1434,17 +1436,19 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
1434
1436
  }
1435
1437
  const parts = resp?.data?.parts ?? resp?.parts ?? [];
1436
1438
  const text = parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("");
1439
+ const cleanupStart = Date.now();
1437
1440
  try {
1438
- const summary = await client.session.summarize?.({ path: { id } });
1439
- log(`runModel(${model}): sub-session summary: ${JSON.stringify(summary?.data ?? summary).slice(0, 300)}`);
1441
+ client.session.summarize?.({ path: { id } }).then((summary) => log(`runModel(${model}): summary ${Date.now() - cleanupStart}ms: ${JSON.stringify(summary?.data ?? summary).slice(0, 300)}`)).catch(() => {
1442
+ });
1440
1443
  } catch {
1441
1444
  }
1442
1445
  try {
1443
- await client.session.delete?.({ path: { id } });
1446
+ client.session.delete?.({ path: { id } }).catch(() => {
1447
+ });
1444
1448
  } catch {
1445
1449
  }
1446
1450
  subId = null;
1447
- log(`runModel(${model}): done ${elapsed}s, ${text.length} chars`);
1451
+ log(`runModel(${model}): done ${elapsed}s, ${text.length} chars (cleanup dispatched)`);
1448
1452
  return text.trim() || `ERROR: no assistant text in prompt response after ${elapsed}s (parts: ${parts.length}, types: ${parts.map((p) => p?.type).join(",")})`;
1449
1453
  } catch (e) {
1450
1454
  const elapsed = Math.round((Date.now() - t0) / 1e3);
@@ -1462,7 +1466,8 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
1462
1466
  }
1463
1467
  if (subId) {
1464
1468
  try {
1465
- await client.session.delete?.({ path: { id: subId } });
1469
+ client.session.delete?.({ path: { id: subId } }).catch(() => {
1470
+ });
1466
1471
  } catch {
1467
1472
  }
1468
1473
  }
@@ -1510,11 +1515,15 @@ function captureStdout(args) {
1510
1515
  p.stdout?.on("data", (d) => {
1511
1516
  out += d.toString();
1512
1517
  });
1513
- p.on("error", () => resolve2(""));
1518
+ p.on("error", (err) => {
1519
+ if (err.code === "ENOENT") codexbarMissing = true;
1520
+ resolve2("");
1521
+ });
1514
1522
  p.on("close", () => resolve2(out));
1515
1523
  });
1516
1524
  }
1517
1525
  async function fetchEnabledProviders() {
1526
+ if (codexbarMissing) return [];
1518
1527
  const out = await captureStdout(["config", "providers"]);
1519
1528
  const ids = [];
1520
1529
  for (const line of out.split("\n")) {
@@ -1586,13 +1595,17 @@ function fetchQuota(provider) {
1586
1595
  p.stdout?.on("data", (d) => {
1587
1596
  out += d.toString();
1588
1597
  });
1589
- p.on("error", () => resolve2(null));
1598
+ p.on("error", (err) => {
1599
+ if (err.code === "ENOENT") codexbarMissing = true;
1600
+ resolve2(null);
1601
+ });
1590
1602
  p.on("close", () => {
1591
1603
  resolve2(parseQuotaResponse(out));
1592
1604
  });
1593
1605
  });
1594
1606
  }
1595
1607
  async function fetchQuotaWithRetry(provider, maxRetries = 3) {
1608
+ if (codexbarMissing) return null;
1596
1609
  for (let attempt = 0; attempt < maxRetries; attempt++) {
1597
1610
  const q = await fetchQuota(provider);
1598
1611
  if (q) return q;
@@ -1621,6 +1634,13 @@ var currentModel = "";
1621
1634
  var currentProvider = "";
1622
1635
  var currentAgent = "";
1623
1636
  var modelChanged = false;
1637
+ var codexbarMissing = false;
1638
+ function isCodexbarMissing() {
1639
+ return codexbarMissing;
1640
+ }
1641
+ function __resetCodexbarMissing() {
1642
+ codexbarMissing = false;
1643
+ }
1624
1644
  function isFreeModel(model, provider) {
1625
1645
  if (!model && !provider) return false;
1626
1646
  if (provider === "opencode") return true;
@@ -1682,6 +1702,12 @@ async function UsageCoachPlugin(input) {
1682
1702
  const refreshBackground = () => {
1683
1703
  try {
1684
1704
  if (refreshing) return;
1705
+ if (codexbarMissing) {
1706
+ last = { decision: "GO", advice: "codexbar not installed \u2014 running in GO-only mode (no quota sensing).", weekly: -3, monthly: -3, fiveHour: -3 };
1707
+ lastFetchedAt = Date.now();
1708
+ refreshing = false;
1709
+ return;
1710
+ }
1685
1711
  if (last && !modelChanged && Date.now() - lastFetchedAt < TTL_MS) return;
1686
1712
  refreshing = true;
1687
1713
  modelChanged = false;
@@ -2646,6 +2672,7 @@ Note: Changes take effect immediately for new generate/grade calls.`,
2646
2672
  }
2647
2673
  }
2648
2674
  export {
2675
+ __resetCodexbarMissing,
2649
2676
  buildGapPrompt,
2650
2677
  buildScanSummary,
2651
2678
  checkScanGate,
@@ -2655,9 +2682,11 @@ export {
2655
2682
  detectLanguage,
2656
2683
  extractImplNotes,
2657
2684
  extractKeywords,
2685
+ fetchQuotaWithRetry,
2658
2686
  findActiveTaskId,
2659
2687
  formatReport,
2660
2688
  humanRemaining,
2689
+ isCodexbarMissing,
2661
2690
  isFreeModel,
2662
2691
  isHarnessAgent,
2663
2692
  parseFileList,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
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",
@@ -13,8 +13,15 @@
13
13
  "./tui": {
14
14
  "types": "./dist/tui.d.ts",
15
15
  "import": "./dist/tui.js"
16
+ },
17
+ "./cli": {
18
+ "types": "./dist/cli.d.ts",
19
+ "import": "./dist/cli.js"
16
20
  }
17
21
  },
22
+ "bin": {
23
+ "usage-coach": "./dist/cli.js"
24
+ },
18
25
  "scripts": {
19
26
  "build": "tsup",
20
27
  "typecheck": "tsc --noEmit",