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 +30 -0
- package/agents/usage-coach-harness.md +5 -0
- package/dist/index.js +37 -12
- package/dist/tui.js +5 -5
- package/package.json +10 -3
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 —
|
|
388
|
-
// General tools (read/edit/bash/grep/task)
|
|
389
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
418
|
+
result = panel(ctx);
|
|
420
419
|
} catch (e) {
|
|
421
420
|
tlog(`sidebar_footer err: ${String(e)}`);
|
|
422
|
-
|
|
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.
|
|
4
|
-
"description": "opencode closed-loop usage coach
|
|
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
|
}
|