opencode-usage-coach 0.3.5 → 0.5.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
@@ -152,6 +152,38 @@ Place in the **work directory**. Each role runs on its model, so per-model quota
152
152
  | `UC_PROVIDER` | (config `provider`) | codexbar provider for the guardian |
153
153
  | `UC_TTL_MS` | 60000 | quota cache TTL (ms) |
154
154
  | `UC_DEBUG` | 0 | set to `1` for a diagnostic log at `~/.cache/opencode-usage-coach/coach.log` |
155
+ | `UC_HARNESS_AGENT` | `Usage-Coach-Harness` | comma-separated agent modes allowed to use harness tools + receive quota coaching (case-insensitive; must match the agent id, e.g. `usage-coach-harness` from `agents/usage-coach-harness.md`) |
156
+ | `UC_WORM_MAX_AGE_DAYS` | 180 | domain DB worm (GC): drop nodes not accessed in N days (~6 months) |
157
+ | `UC_WORM_MAX_NODES` | 100000 | domain DB worm (GC): cap node count, evict oldest-accessed beyond this |
158
+
159
+ ## Agent-mode scoping
160
+
161
+ Harness tools (`generate`, `grade`, `harness_start`, …) and quota coaching are **scoped to
162
+ the `usage-coach-harness` agent mode**. Other modes (build, general, your custom agents) stay
163
+ completely clean — no harness tools in their tool list, no quota coaching injected into their
164
+ system prompt.
165
+
166
+ This is enforced on two independent layers (defense in depth):
167
+
168
+ 1. **Agent definition** (`agents/usage-coach-harness.md`) — its `permission` allowlist names the
169
+ harness tools, so they only appear in this mode. Other agents' permission lists don't name
170
+ them, so they're hidden from those modes automatically (this is the standard opencode
171
+ mechanism — tool visibility is the agent definition's responsibility).
172
+ 2. **Plugin runtime gate** (`tool.execute.before`) — even if a harness tool were somehow
173
+ invoked, the plugin resolves the current session's agent (`client.session.get` → `info.agent`,
174
+ 60s-cached) and throws unless it matches `UC_HARNESS_AGENT` (default `Usage-Coach-Harness`,
175
+ case-insensitive). The quota system-prompt injection is gated the same way.
176
+
177
+ **To use the harness tools**, switch to the `usage-coach-harness` agent mode.
178
+
179
+ **To allow additional modes**, set `UC_HARNESS_AGENT` to a comma-separated list:
180
+ ```bash
181
+ export UC_HARNESS_AGENT="usage-coach-harness,my-other-harness"
182
+ ```
183
+
184
+ > Why not the v2 plugin API? v2 has no `tool` registration domain, so a plugin that provides
185
+ > custom tools (like this one) cannot be fully rewritten in v2. Agent `permission` allowlists +
186
+ > the v1 runtime gate is the structurally correct way to scope tool visibility.
155
187
 
156
188
  ## Architecture
157
189
  - **Server module** (`src/index.ts`) — SENSE/DECIDE/ACT + custom harness tools. Loaded via `opencode.json`.
@@ -12,7 +12,12 @@ permission:
12
12
  grep: allow
13
13
  task: allow
14
14
  generate: allow
15
+ generate_batch: allow
15
16
  grade: allow
17
+ investigate: allow
18
+ verify_diagnosis: allow
19
+ generalize: allow
20
+ record_failure: allow
16
21
  harness_start: allow
17
22
  task_update: allow
18
23
  harness_done: allow
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import { mkdirSync as mkdirSync2, writeFileSync, appendFileSync as appendFileSync2, readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
2
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, appendFileSync as appendFileSync2, readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
3
3
  import { spawn } from "child_process";
4
4
  import { createHash } from "crypto";
5
5
  import { homedir } from "os";
@@ -7,7 +7,7 @@ import { join as join2, resolve, dirname } from "path";
7
7
  import { tool } from "@opencode-ai/plugin";
8
8
 
9
9
  // src/domain.ts
10
- import { mkdirSync, appendFileSync, readFileSync, existsSync } from "fs";
10
+ import { mkdirSync, appendFileSync, readFileSync, existsSync, writeFileSync } from "fs";
11
11
  import { join } from "path";
12
12
  var BASE_DIR = "";
13
13
  function initDomain(stateDir) {
@@ -41,6 +41,14 @@ function addDomainNode(node) {
41
41
  }
42
42
  return full.id;
43
43
  }
44
+ function writeNodes(nodes) {
45
+ try {
46
+ mkdirSync(BASE_DIR, { recursive: true });
47
+ const lines = nodes.map((n) => JSON.stringify(n));
48
+ writeFileSync(nodesFile(), lines.length ? lines.join("\n") + "\n" : "");
49
+ } catch {
50
+ }
51
+ }
44
52
  function queryDomain(keywords) {
45
53
  const lc = keywords.map((k) => k.toLowerCase());
46
54
  const nodes = readNodes();
@@ -48,10 +56,47 @@ function queryDomain(keywords) {
48
56
  const hay = (n.name + " " + JSON.stringify(n.props)).toLowerCase();
49
57
  return lc.some((k) => k && hay.includes(k));
50
58
  });
59
+ if (matched.length) touchNodes(new Set(matched.map((n) => n.id)));
51
60
  const ids = new Set(matched.map((n) => n.id));
52
61
  const edges = readEdges().filter((e) => ids.has(e.from) || ids.has(e.to));
53
62
  return { nodes: matched, edges };
54
63
  }
64
+ function touchNodes(ids) {
65
+ if (ids.size === 0) return;
66
+ try {
67
+ const nodes = readNodes();
68
+ let changed = false;
69
+ const now = (/* @__PURE__ */ new Date()).toISOString();
70
+ for (const n of nodes) {
71
+ if (ids.has(n.id)) {
72
+ n.lastAccessed = now;
73
+ n.accessCount = (n.accessCount ?? 0) + 1;
74
+ changed = true;
75
+ }
76
+ }
77
+ if (changed) writeNodes(nodes);
78
+ } catch {
79
+ }
80
+ }
81
+ function evictStale(maxAgeDays = 30, maxNodes = 1e3) {
82
+ try {
83
+ const nodes = readNodes();
84
+ if (nodes.length === 0) return { removed: 0, kept: 0 };
85
+ const now = Date.now();
86
+ const ageMs = maxAgeDays * 864e5;
87
+ const lastTs = (n) => new Date(n.lastAccessed ?? n.ts).getTime();
88
+ let kept = nodes.filter((n) => now - lastTs(n) < ageMs);
89
+ if (kept.length > maxNodes) {
90
+ kept.sort((a, b) => lastTs(b) - lastTs(a));
91
+ kept = kept.slice(0, maxNodes);
92
+ }
93
+ const removed = nodes.length - kept.length;
94
+ if (removed > 0) writeNodes(kept);
95
+ return { removed, kept: kept.length };
96
+ } catch {
97
+ return { removed: 0, kept: 0 };
98
+ }
99
+ }
55
100
  function saveInvestigationResult(keywords, result, source) {
56
101
  try {
57
102
  return addDomainNode({
@@ -68,11 +113,9 @@ function saveInvestigationResult(keywords, result, source) {
68
113
 
69
114
  // src/index.ts
70
115
  var PLUGIN_NAME = "opencode-usage-coach";
71
- var DEBUG = process.env.UC_DEBUG === "1";
72
116
  var TTL_MS = Number(process.env.UC_TTL_MS ?? 6e4);
73
117
  var STATE_DIR = join2(homedir(), ".cache", "opencode-usage-coach");
74
118
  var STATE_FILE = join2(STATE_DIR, "state.json");
75
- var HARNESS_FILE = join2(STATE_DIR, "harness.json");
76
119
  var LOG_FILE = join2(STATE_DIR, "coach.log");
77
120
  function projectStateDir(dir) {
78
121
  const abs = resolve(dir || ".");
@@ -82,7 +125,6 @@ function projectStateDir(dir) {
82
125
  function setStateDir(dir) {
83
126
  STATE_DIR = process.env.UC_STATE_DIR ?? projectStateDir(dir);
84
127
  STATE_FILE = join2(STATE_DIR, "state.json");
85
- HARNESS_FILE = join2(STATE_DIR, "harness.json");
86
128
  LOG_FILE = join2(STATE_DIR, "coach.log");
87
129
  }
88
130
  var NOOP_HOOKS = {};
@@ -96,7 +138,7 @@ function log(msg) {
96
138
  function writeState(c) {
97
139
  try {
98
140
  mkdirSync2(STATE_DIR, { recursive: true });
99
- writeFileSync(STATE_FILE, JSON.stringify({ ...c, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
141
+ writeFileSync2(STATE_FILE, JSON.stringify({ ...c, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
100
142
  } catch {
101
143
  }
102
144
  }
@@ -149,7 +191,7 @@ function writeHarness(sessionID, h) {
149
191
  const f = harnessFile(sessionID);
150
192
  mkdirSync2(dirname(f), { recursive: true });
151
193
  h.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
152
- writeFileSync(f, JSON.stringify(h, null, 2));
194
+ writeFileSync2(f, JSON.stringify(h, null, 2));
153
195
  } catch {
154
196
  }
155
197
  }
@@ -200,6 +242,7 @@ async function runModel(client, model, prompt, directory) {
200
242
  return `ERROR: runModel exception after ${elapsed}s: ${String(e)}`;
201
243
  }
202
244
  }
245
+ var HARNESS_AGENTS = (process.env.UC_HARNESS_AGENT ?? "Usage-Coach-Harness").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
203
246
  var num = (e, d) => {
204
247
  try {
205
248
  const v = Number(process.env[e]);
@@ -213,6 +256,8 @@ var THR_5H = num("UC_THROTTLE_5H", 70);
213
256
  var STOP_WK = num("UC_STOP_WEEKLY", 95);
214
257
  var THR_WK = num("UC_THROTTLE_WEEKLY", 85);
215
258
  var STOP_MO = num("UC_STOP_MONTHLY", 98);
259
+ var WORM_MAX_AGE_DAYS = num("UC_WORM_MAX_AGE_DAYS", 180);
260
+ var WORM_MAX_NODES = num("UC_WORM_MAX_NODES", 1e5);
216
261
  function humanRemaining(iso) {
217
262
  try {
218
263
  if (!iso) return "";
@@ -325,6 +370,25 @@ function coach(q, lighter) {
325
370
  if (wk >= THR_WK) return thr(`weekly ${wk}% (${wkR})`);
326
371
  return { decision: "GO", advice: `Comfortable \u2014 weekly ${wk}% \xB7 5h ${h5}% \xB7 monthly ${mo}%. proceed. 5h window ${h5R}.`, weekly: wk, monthly: mo, fiveHour: h5 };
327
372
  }
373
+ var agentCache = /* @__PURE__ */ new Map();
374
+ async function resolveAgent(client, sessionID) {
375
+ if (!sessionID) return "";
376
+ const hit = agentCache.get(sessionID);
377
+ if (hit && Date.now() - hit.ts < 6e4) return hit.agent;
378
+ try {
379
+ const s = await client.session.get({ path: { id: sessionID } });
380
+ const agent = String(s?.data?.info?.agent ?? s?.data?.agent ?? s?.info?.agent ?? "");
381
+ agentCache.set(sessionID, { agent, ts: Date.now() });
382
+ return agent;
383
+ } catch (e) {
384
+ log(`resolveAgent err: ${String(e)}`);
385
+ return "";
386
+ }
387
+ }
388
+ function isHarnessAgent(agent) {
389
+ if (!agent) return false;
390
+ return HARNESS_AGENTS.includes(agent.toLowerCase());
391
+ }
328
392
  var LOADING = { decision: "GO", advice: "quota loading\u2026", weekly: -1, monthly: -1, fiveHour: -1 };
329
393
  async function UsageCoachPlugin(input) {
330
394
  try {
@@ -380,30 +444,46 @@ async function UsageCoachPlugin(input) {
380
444
  event: async ({ event }) => {
381
445
  try {
382
446
  if (event.type === "session.created" || event.type === "session.idle") refreshBackground();
447
+ if (event.type === "session.idle") {
448
+ try {
449
+ const r = evictStale(WORM_MAX_AGE_DAYS, WORM_MAX_NODES);
450
+ if (r.removed) log(`evictStale: removed ${r.removed}, kept ${r.kept} (maxAge=${WORM_MAX_AGE_DAYS}d, maxNodes=${WORM_MAX_NODES})`);
451
+ } catch (e) {
452
+ log(`evictStale err: ${String(e)}`);
453
+ }
454
+ }
383
455
  } catch (e) {
384
456
  log(`event err: ${String(e)}`);
385
457
  }
386
458
  },
387
- // ACT(1) hard gate — ONLY for harness tools (generate/grade/etc.) that consume quota.
388
- // General tools (read/edit/bash/grep/task) are NEVER blocked — they don't consume model quota.
389
- // This ensures Agent-Factory-Coordinator and other modes work freely even at STOP.
459
+ // ACT(1) hard gate — harness tools are restricted to the configured harness
460
+ // agent mode AND gated by quota STOP. General tools (read/edit/bash/grep/task)
461
+ // are NEVER gated, in ANY mode they don't consume model quota.
390
462
  "tool.execute.before": async (_input) => {
391
- let decision = "GO";
463
+ const harnessTools = ["generate", "generate_batch", "grade", "investigate", "verify_diagnosis", "generalize", "harness_start", "task_update", "harness_done", "record_failure"];
464
+ if (!harnessTools.includes(_input.tool)) return;
465
+ const agent = await resolveAgent(input.client, _input.sessionID);
466
+ if (!isHarnessAgent(agent)) {
467
+ 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.`);
468
+ }
469
+ let decision;
392
470
  try {
393
471
  decision = current().decision;
394
472
  } catch {
395
473
  decision = "GO";
396
474
  }
397
475
  if (decision === "STOP") {
398
- const harnessTools = ["generate", "generate_batch", "grade", "investigate", "verify_diagnosis", "generalize"];
399
- if (harnessTools.includes(_input.tool)) {
400
- throw new Error(`[${PLUGIN_NAME}] blocked: quota limit exceeded. ${current().advice}`);
401
- }
476
+ throw new Error(`[${PLUGIN_NAME}] blocked: quota limit exceeded. ${current().advice}`);
402
477
  }
403
478
  },
404
- // ACT(2) inject coaching into system prompt (double defense). Silent on error.
479
+ // ACT(2) inject coaching into system prompt ONLY in the harness agent mode,
480
+ // so other modes' system prompts stay completely clean. Silent on error.
405
481
  "experimental.chat.system.transform": async (_input, output) => {
406
482
  try {
483
+ if (_input.sessionID) {
484
+ const agent = await resolveAgent(input.client, _input.sessionID);
485
+ if (!isHarnessAgent(agent)) return;
486
+ }
407
487
  const c = current();
408
488
  let instruction = "";
409
489
  if (c.decision === "STOP") instruction = `[${PLUGIN_NAME}] QUOTA limit exceeded. ${c.advice} Stop making further tool calls, finish the in-progress work, then report the quota status to the user.`;
package/dist/tui.js CHANGED
@@ -131,11 +131,9 @@ function initializeTui(api, disposeRoot) {
131
131
  tlog(`api probe err: ${String(e)}`);
132
132
  }
133
133
  const [getState, setState] = createSignal(readState());
134
- const [getHarness, setHarness] = createSignal(readHarness());
135
134
  const timer = setInterval(() => {
136
135
  try {
137
136
  setState(readState());
138
- setHarness(readHarness());
139
137
  } catch {
140
138
  }
141
139
  }, 3e3);
@@ -184,7 +182,7 @@ function initializeTui(api, disposeRoot) {
184
182
  return _el$;
185
183
  })();
186
184
  }
187
- let s = null;
185
+ let s;
188
186
  try {
189
187
  s = getState();
190
188
  } catch {
@@ -415,16 +413,18 @@ function initializeTui(api, disposeRoot) {
415
413
  slots: {
416
414
  sidebar_footer(ctx) {
417
415
  tlog("sidebar_footer slot called");
416
+ let result;
418
417
  try {
419
- return panel(ctx);
418
+ result = panel(ctx);
420
419
  } catch (e) {
421
420
  tlog(`sidebar_footer err: ${String(e)}`);
422
- return (() => {
421
+ result = (() => {
423
422
  var _el$61 = _$createElement("text");
424
423
  _$insertNode(_el$61, _$createTextNode(`usage-coach`));
425
424
  return _el$61;
426
425
  })();
427
426
  }
427
+ return result;
428
428
  }
429
429
  }
430
430
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.3.5",
4
- "description": "opencode closed-loop usage coach \u2014 quota SENSE -> coaching DECIDE -> loop ACT + TUI integration",
3
+ "version": "0.5.0",
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",
@@ -18,6 +18,8 @@
18
18
  "scripts": {
19
19
  "build": "tsup",
20
20
  "typecheck": "tsc --noEmit",
21
+ "lint": "eslint .",
22
+ "lint:fix": "eslint . --fix",
21
23
  "prepack": "tsup"
22
24
  },
23
25
  "files": [
@@ -44,12 +46,17 @@
44
46
  "solid-js": ">=1.9.12"
45
47
  },
46
48
  "devDependencies": {
49
+ "@eslint/js": "^10.0.1",
47
50
  "@opencode-ai/plugin": "*",
48
51
  "@opentui/core": ">=0.4.0",
49
52
  "@opentui/solid": ">=0.4.0",
50
53
  "esbuild-plugin-solid": "^0.6.0",
54
+ "eslint": "^10.6.0",
55
+ "eslint-plugin-solid": "^0.14.5",
56
+ "globals": "^17.7.0",
51
57
  "solid-js": "^1.9",
52
58
  "tsup": "^8.5",
53
- "typescript": "^5"
59
+ "typescript": "^5",
60
+ "typescript-eslint": "^8.63.0"
54
61
  }
55
62
  }