opencode-usage-coach 0.3.5 → 0.4.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,36 @@ 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
+
157
+ ## Agent-mode scoping
158
+
159
+ Harness tools (`generate`, `grade`, `harness_start`, …) and quota coaching are **scoped to
160
+ the `usage-coach-harness` agent mode**. Other modes (build, general, your custom agents) stay
161
+ completely clean — no harness tools in their tool list, no quota coaching injected into their
162
+ system prompt.
163
+
164
+ This is enforced on two independent layers (defense in depth):
165
+
166
+ 1. **Agent definition** (`agents/usage-coach-harness.md`) — its `permission` allowlist names the
167
+ harness tools, so they only appear in this mode. Other agents' permission lists don't name
168
+ them, so they're hidden from those modes automatically (this is the standard opencode
169
+ mechanism — tool visibility is the agent definition's responsibility).
170
+ 2. **Plugin runtime gate** (`tool.execute.before`) — even if a harness tool were somehow
171
+ invoked, the plugin resolves the current session's agent (`client.session.get` → `info.agent`,
172
+ 60s-cached) and throws unless it matches `UC_HARNESS_AGENT` (default `Usage-Coach-Harness`,
173
+ case-insensitive). The quota system-prompt injection is gated the same way.
174
+
175
+ **To use the harness tools**, switch to the `usage-coach-harness` agent mode.
176
+
177
+ **To allow additional modes**, set `UC_HARNESS_AGENT` to a comma-separated list:
178
+ ```bash
179
+ export UC_HARNESS_AGENT="usage-coach-harness,my-other-harness"
180
+ ```
181
+
182
+ > Why not the v2 plugin API? v2 has no `tool` registration domain, so a plugin that provides
183
+ > custom tools (like this one) cannot be fully rewritten in v2. Agent `permission` allowlists +
184
+ > the v1 runtime gate is the structurally correct way to scope tool visibility.
155
185
 
156
186
  ## Architecture
157
187
  - **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
@@ -68,11 +68,9 @@ function saveInvestigationResult(keywords, result, source) {
68
68
 
69
69
  // src/index.ts
70
70
  var PLUGIN_NAME = "opencode-usage-coach";
71
- var DEBUG = process.env.UC_DEBUG === "1";
72
71
  var TTL_MS = Number(process.env.UC_TTL_MS ?? 6e4);
73
72
  var STATE_DIR = join2(homedir(), ".cache", "opencode-usage-coach");
74
73
  var STATE_FILE = join2(STATE_DIR, "state.json");
75
- var HARNESS_FILE = join2(STATE_DIR, "harness.json");
76
74
  var LOG_FILE = join2(STATE_DIR, "coach.log");
77
75
  function projectStateDir(dir) {
78
76
  const abs = resolve(dir || ".");
@@ -82,7 +80,6 @@ function projectStateDir(dir) {
82
80
  function setStateDir(dir) {
83
81
  STATE_DIR = process.env.UC_STATE_DIR ?? projectStateDir(dir);
84
82
  STATE_FILE = join2(STATE_DIR, "state.json");
85
- HARNESS_FILE = join2(STATE_DIR, "harness.json");
86
83
  LOG_FILE = join2(STATE_DIR, "coach.log");
87
84
  }
88
85
  var NOOP_HOOKS = {};
@@ -200,6 +197,7 @@ async function runModel(client, model, prompt, directory) {
200
197
  return `ERROR: runModel exception after ${elapsed}s: ${String(e)}`;
201
198
  }
202
199
  }
200
+ var HARNESS_AGENTS = (process.env.UC_HARNESS_AGENT ?? "Usage-Coach-Harness").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
203
201
  var num = (e, d) => {
204
202
  try {
205
203
  const v = Number(process.env[e]);
@@ -325,6 +323,25 @@ function coach(q, lighter) {
325
323
  if (wk >= THR_WK) return thr(`weekly ${wk}% (${wkR})`);
326
324
  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
325
  }
326
+ var agentCache = /* @__PURE__ */ new Map();
327
+ async function resolveAgent(client, sessionID) {
328
+ if (!sessionID) return "";
329
+ const hit = agentCache.get(sessionID);
330
+ if (hit && Date.now() - hit.ts < 6e4) return hit.agent;
331
+ try {
332
+ const s = await client.session.get({ path: { id: sessionID } });
333
+ const agent = String(s?.data?.info?.agent ?? s?.data?.agent ?? s?.info?.agent ?? "");
334
+ agentCache.set(sessionID, { agent, ts: Date.now() });
335
+ return agent;
336
+ } catch (e) {
337
+ log(`resolveAgent err: ${String(e)}`);
338
+ return "";
339
+ }
340
+ }
341
+ function isHarnessAgent(agent) {
342
+ if (!agent) return false;
343
+ return HARNESS_AGENTS.includes(agent.toLowerCase());
344
+ }
328
345
  var LOADING = { decision: "GO", advice: "quota loading\u2026", weekly: -1, monthly: -1, fiveHour: -1 };
329
346
  async function UsageCoachPlugin(input) {
330
347
  try {
@@ -384,26 +401,34 @@ async function UsageCoachPlugin(input) {
384
401
  log(`event err: ${String(e)}`);
385
402
  }
386
403
  },
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.
404
+ // ACT(1) hard gate — harness tools are restricted to the configured harness
405
+ // agent mode AND gated by quota STOP. General tools (read/edit/bash/grep/task)
406
+ // are NEVER gated, in ANY mode they don't consume model quota.
390
407
  "tool.execute.before": async (_input) => {
391
- let decision = "GO";
408
+ const harnessTools = ["generate", "generate_batch", "grade", "investigate", "verify_diagnosis", "generalize", "harness_start", "task_update", "harness_done", "record_failure"];
409
+ if (!harnessTools.includes(_input.tool)) return;
410
+ const agent = await resolveAgent(input.client, _input.sessionID);
411
+ if (!isHarnessAgent(agent)) {
412
+ 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.`);
413
+ }
414
+ let decision;
392
415
  try {
393
416
  decision = current().decision;
394
417
  } catch {
395
418
  decision = "GO";
396
419
  }
397
420
  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
- }
421
+ throw new Error(`[${PLUGIN_NAME}] blocked: quota limit exceeded. ${current().advice}`);
402
422
  }
403
423
  },
404
- // ACT(2) inject coaching into system prompt (double defense). Silent on error.
424
+ // ACT(2) inject coaching into system prompt ONLY in the harness agent mode,
425
+ // so other modes' system prompts stay completely clean. Silent on error.
405
426
  "experimental.chat.system.transform": async (_input, output) => {
406
427
  try {
428
+ if (_input.sessionID) {
429
+ const agent = await resolveAgent(input.client, _input.sessionID);
430
+ if (!isHarnessAgent(agent)) return;
431
+ }
407
432
  const c = current();
408
433
  let instruction = "";
409
434
  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.4.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
  }