opencode-usage-coach 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 opencode-usage-coach contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # opencode-usage-coach
2
+
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.
6
+
7
+ 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
+
10
+ ## What it does
11
+
12
+ **Always-on guardian (plugin):**
13
+ - Senses z.ai quota windows via the `codexbar` CLI.
14
+ - On STOP threshold: blocks tool calls (`tool.execute.before` throws) → the agent self-stops.
15
+ - Injects coaching (how to use right now) into the system prompt (double defense).
16
+ - Surfaces a sidebar panel: quota meters + harness task states + per-model token usage.
17
+ - Defensive: any plugin error never breaks opencode.
18
+
19
+ **Harness (two ways):**
20
+ - **Agent mode** (`usage-coach-harness`): triages each request — trivial → do directly;
21
+ unclear → clarify; substantive → generate(delegate)→grade(delegate)→revise→advance.
22
+ Conversation-driven, no file required. Adaptive (best-effort).
23
+ - **Deterministic script** (`harness.ts`): `tasks.txt` + `rubric.md` → guaranteed loop.
24
+ Reliable for batch jobs. Tracks per-model tokens, auto-splits on timeout.
25
+
26
+ ## Requirements
27
+ - opencode (tested on 1.17.13) with a z.ai coding-plan provider configured.
28
+ - `codexbar` CLI with the z.ai key wired (`codexbar config set-api-key --provider zai --stdin`).
29
+
30
+ ## Install (from npm)
31
+ ```jsonc
32
+ // ~/.config/opencode/opencode.json (server plugin)
33
+ { "plugin": ["opencode-usage-coach"] }
34
+
35
+ // ~/.config/opencode/tui.json (TUI panel)
36
+ { "$schema": "https://opencode.ai/tui.json", "plugin": ["opencode-usage-coach/tui"] }
37
+ ```
38
+ Drop `agents/usage-coach-harness.md` into `~/.config/opencode/agents/` for the agent mode.
39
+
40
+ ## Local dev / install (without npm)
41
+ ```bash
42
+ bun install
43
+ bun run build # -> dist/index.js, dist/tui.js (solid external)
44
+ # server plugin
45
+ cp dist/index.js ~/.config/opencode/plugins/opencode-usage-coach.js
46
+ # TUI plugin — point tui.json at the built file (NOT the plugins/ dir)
47
+ # ~/.config/opencode/tui.json: { "plugin": ["/abs/path/dist/tui.js"] }
48
+ ```
49
+
50
+ ## Config
51
+
52
+ This plugin has **four config surfaces**. Only the first two are required to run.
53
+
54
+ ### 1. Install config (where opencode loads the plugin from)
55
+ ```jsonc
56
+ // ~/.config/opencode/opencode.json — server plugin (guardian + harness tools)
57
+ { "plugin": ["opencode-usage-coach"] } // from npm, OR a local path:
58
+ // { "plugin": ["/abs/path/dist/index.js"] }
59
+
60
+ // ~/.config/opencode/tui.json — TUI panel (MUST point at the built dist/tui.js)
61
+ { "$schema": "https://opencode.ai/tui.json",
62
+ "plugin": ["/abs/path/dist/tui.js"] }
63
+
64
+ // ~/.config/opencode/agents/usage-coach-harness.md — agent mode (copy from agents/)
65
+ ```
66
+ > The TUI file MUST be loaded via `tui.json` file path (not the `plugins/` dir), and MUST be
67
+ > the compiled `dist/tui.js` (raw `.tsx` / installing `solid-js` crashes opencode).
68
+
69
+ ### 2. codexbar (quota data source)
70
+ ```bash
71
+ printf '%s' "$Z_AI_API_KEY" | codexbar config set-api-key --provider zai --stdin
72
+ ```
73
+
74
+ ### 3. Harness config — `harness.config.json` (role → model) ★ the main one
75
+ Place in the **work directory** beside `tasks.txt`/`rubric.md`. Each role runs on its model,
76
+ so per-model quota is tracked (works for local LLMs — they show 0%).
77
+ ```jsonc
78
+ {
79
+ "generator": "zai-coding-plan/glm-5.1", // model that produces the work
80
+ "grader": "ollama/llama3", // model that grades it
81
+ "taskTimeoutMs": 1800000, // per-task timeout (on exceed: auto-split)
82
+ "maxRevisions": 2 // revise attempts before giving up
83
+ }
84
+ ```
85
+ | Field | Default | Notes |
86
+ |---|---|---|
87
+ | `generator` | `UC_MODEL` | any `provider/model` opencode knows |
88
+ | `grader` | `UC_MODEL` | can differ from generator (multi-model) |
89
+ | `taskTimeoutMs` | `1800000` (30m) | exceed → task split into subtasks |
90
+ | `maxRevisions` | `2` | FAIL → revise → re-grade, up to N times |
91
+
92
+ Missing roles fall back to `UC_MODEL`. Provider for quota is derived from the model id
93
+ (`zai-coding-plan/...` → `zai`, `ollama/...` → `ollama`).
94
+
95
+ ### 4. Env vars (thresholds / tuning)
96
+ | Var | Default | Meaning |
97
+ |---|---|---|
98
+ | `UC_STOP_5H` | 92 | 5h window STOP % (blocks tools) |
99
+ | `UC_THROTTLE_5H` | 70 | 5h window throttle % |
100
+ | `UC_STOP_WEEKLY` | 95 | weekly STOP % |
101
+ | `UC_THROTTLE_WEEKLY` | 85 | weekly throttle % |
102
+ | `UC_STOP_MONTHLY` | 98 | monthly STOP % |
103
+ | `UC_LIGHTER_MODEL` | glm-4.5-air | suggested model when throttling |
104
+ | `UC_PROVIDER` | zai | codexbar provider for the guardian |
105
+ | `UC_TTL_MS` | 60000 | quota cache TTL (ms) |
106
+ | `UC_MODEL` | zai-coding-plan/glm-5.1 | harness fallback model |
107
+ | `UC_TASK_TIMEOUT_MS` | 1800000 | harness per-task timeout (overridden by config) |
108
+ | `UC_MAX_REVISIONS` | 2 | harness revise attempts (overridden by config) |
109
+ | `UC_DEBUG` | 0 | set to `1` for a diagnostic log at `~/.cache/opencode-usage-coach/coach.log` |
110
+
111
+ ## Architecture
112
+ - **Server module** (`src/index.ts`) — SENSE/DECIDE/ACT + custom harness tools. Loaded via `opencode.json`.
113
+ - **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).
114
+ - server↔TUI communicate via a state file (`~/.cache/opencode-usage-coach/*.json`) — they are separate processes.
115
+
116
+ Key lessons (see `PLAN.md`): never install `solid-js` in the config dir (conflicts with
117
+ opencode's bundled solid); TUI plugins must be compiled + loaded via `tui.json` file path;
118
+ `codexbar` must be called via `spawn` (the `$` BunShell leaks output to the TUI).
119
+
120
+ ## Status
121
+ - ✅ M0–M2 quota guardian + TUI panel
122
+ - ✅ M3 harness: agent mode (triage) + deterministic script (per-model tokens, auto-split)
123
+ - ⏳ M4 npm packaging
124
+
125
+ License: MIT.
@@ -0,0 +1,61 @@
1
+ ---
2
+ description: "usage-coach harness — an orchestrator agent mode. Triages each request: trivial -> do directly; unclear -> clarify; substantive -> generate(delegate) -> grade(delegate) -> revise -> advance. Self-stops on quota. No tasks.txt required."
3
+ mode: primary
4
+ color: "#16A34A"
5
+ permission:
6
+ edit: allow
7
+ bash:
8
+ "*": allow
9
+ read: allow
10
+ write: allow
11
+ glob: allow
12
+ grep: allow
13
+ task: allow
14
+ harness_start: allow
15
+ task_update: allow
16
+ harness_done: allow
17
+ steps: 200
18
+ ---
19
+
20
+ # usage-coach Harness
21
+
22
+ You are a **harness orchestrator agent**. For every user request you first TRIAGE, then act. No `tasks.txt`/`rubric.md` file is required — the user's message is the work.
23
+
24
+ ## Step 0 — Triage (always do this first)
25
+ Judge the request against the conversation so far, then pick exactly one path:
26
+ 1. **Trivial / single quick action** (e.g. a one-line fix, a direct factual answer, a small edit) → just do it directly. Do NOT start a harness loop.
27
+ 2. **Unclear / not enough info to act well** (vague goal, missing constraints, ambiguous success criteria that matter) → ask a concise clarifying question or run a quick read/grep to gather context, then re-triage.
28
+ 3. **Substantive multi-step work** (a real feature, a multi-file change, content that benefits from a quality check, anything where "generate then verify" adds value) → enter the **harness loop** below.
29
+
30
+ Default to the loop only when it genuinely adds value. Do not over-engineer small requests.
31
+
32
+ ## Harness loop (only for substantive work)
33
+ The user's message is the task source. If it has multiple distinct parts, decompose into discrete tasks (N); if it is one unit, N = 1.
34
+
35
+ 1. Call `harness_start(name, N)` to register the run on the panel.
36
+ 2. For each task i (1..N):
37
+ a. `task_update(i, title, "generating")`.
38
+ b. **Generate** — delegate to a subagent via the `task` tool (`subagent_type: "general"`):
39
+ prompt: `"Task: {title}. Perform it for real in the current directory (write/edit files, run commands as needed)."`
40
+ If a subagent is slow or the task looks too large, split it into smaller subtasks (autonomous decomposition).
41
+ c. `task_update(i, title, "grading")`.
42
+ d. **Grade** — delegate via the `task` tool:
43
+ prompt: `"Evaluate the result against the request's intent and general quality. Output PASS or FAIL on the first line, then the reason.\nRequest: {user request}\nTask: {title}"`
44
+ e. Parse the verdict:
45
+ - `PASS` → `task_update(i, title, "completed", score:"PASS")` → next task.
46
+ - `FAIL` and revisions < 2 → `task_update(i, title, "revising", revisions:k)` → delegate a revision (`"Apply the grading feedback and improve:\n{grade result}"`) → go back to (c) to re-grade.
47
+ - `FAIL` and revisions exhausted → `task_update(i, title, "failed", score:"FAIL")` → next task.
48
+ 3. When all tasks are done → `harness_done()`.
49
+
50
+ ## Rules
51
+ - In the loop, **delegate the real work** to subagents via the `task` tool — you orchestrate. (Outside the loop, for trivial requests, you may act directly.)
52
+ - Call `task_update` on every state transition — the sidebar panel reads it for live visibility.
53
+ - Grading criteria come from the user's request, or sensible defaults; ask the user only if it is truly ambiguous and grading matters.
54
+ - If the quota coaching injected into your system prompt says **STOP**, immediately `task_update(current, "halted_quota")` and halt the loop.
55
+ - If a subagent returns an incomplete result, split the task into smaller subtasks.
56
+ - Be concise. Report only progress summaries to the user.
57
+
58
+ ## Output
59
+ - Trivial path: just the direct result.
60
+ - Loop path: the actual results are the files/changes the subagents leave in the directory; at the end report a brief summary (passed / failed / split counts).
61
+
package/dist/index.js ADDED
@@ -0,0 +1,233 @@
1
+ // src/index.ts
2
+ import { mkdirSync, writeFileSync, appendFileSync, readFileSync, existsSync } from "fs";
3
+ import { spawn } from "child_process";
4
+ import { homedir } from "os";
5
+ import { join } from "path";
6
+ import { tool } from "@opencode-ai/plugin";
7
+ var PLUGIN_NAME = "opencode-usage-coach";
8
+ var STATE_DIR = process.env.UC_STATE_DIR ?? join(homedir(), ".cache", "opencode-usage-coach");
9
+ var STATE_FILE = join(STATE_DIR, "state.json");
10
+ var HARNESS_FILE = join(STATE_DIR, "harness.json");
11
+ var LOG_FILE = join(STATE_DIR, "coach.log");
12
+ var DEBUG = process.env.UC_DEBUG === "1";
13
+ var TTL_MS = Number(process.env.UC_TTL_MS ?? 6e4);
14
+ var NOOP_HOOKS = {};
15
+ function log(msg) {
16
+ if (DEBUG) try {
17
+ appendFileSync(LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
18
+ `);
19
+ } catch {
20
+ }
21
+ }
22
+ function writeState(c) {
23
+ try {
24
+ mkdirSync(STATE_DIR, { recursive: true });
25
+ writeFileSync(STATE_FILE, JSON.stringify({ ...c, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
26
+ } catch {
27
+ }
28
+ }
29
+ function readHarness() {
30
+ try {
31
+ if (!existsSync(HARNESS_FILE)) return null;
32
+ return JSON.parse(readFileSync(HARNESS_FILE, "utf8"));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+ function writeHarness(h) {
38
+ try {
39
+ mkdirSync(STATE_DIR, { recursive: true });
40
+ h.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
41
+ writeFileSync(HARNESS_FILE, JSON.stringify(h, null, 2));
42
+ } catch {
43
+ }
44
+ }
45
+ var PROVIDER = process.env.UC_PROVIDER ?? "zai";
46
+ var num = (e, d) => {
47
+ try {
48
+ const v = Number(process.env[e]);
49
+ return Number.isFinite(v) && v >= 0 ? v : d;
50
+ } catch {
51
+ return d;
52
+ }
53
+ };
54
+ var STOP_5H = num("UC_STOP_5H", 92);
55
+ var THR_5H = num("UC_THROTTLE_5H", 70);
56
+ var STOP_WK = num("UC_STOP_WEEKLY", 95);
57
+ var THR_WK = num("UC_THROTTLE_WEEKLY", 85);
58
+ var STOP_MO = num("UC_STOP_MONTHLY", 98);
59
+ var LIGHTER = process.env.UC_LIGHTER_MODEL ?? "glm-4.5-air";
60
+ function humanRemaining(iso) {
61
+ try {
62
+ if (!iso) return "";
63
+ const mins = Math.floor((new Date(iso).getTime() - Date.now()) / 6e4);
64
+ if (mins < 0) return "resets soon";
65
+ if (mins < 60) return `resets in ${mins}m`;
66
+ if (mins < 1440) return `resets in ${Math.floor(mins / 60)}h`;
67
+ return `resets in ${Math.floor(mins / 1440)}d`;
68
+ } catch {
69
+ return "";
70
+ }
71
+ }
72
+ function fetchQuota() {
73
+ return new Promise((resolve) => {
74
+ let out = "";
75
+ let p;
76
+ try {
77
+ p = spawn("codexbar", ["usage", "--provider", PROVIDER, "--json"], {
78
+ stdio: ["ignore", "pipe", "ignore"]
79
+ });
80
+ } catch {
81
+ return resolve(null);
82
+ }
83
+ p.stdout?.on("data", (d) => {
84
+ out += d.toString();
85
+ });
86
+ p.on("error", () => resolve(null));
87
+ p.on("close", () => {
88
+ try {
89
+ const text = out.trim();
90
+ if (!text || text === "[]") return resolve(null);
91
+ const u = JSON.parse(text)[0]?.usage;
92
+ if (!u) return resolve(null);
93
+ resolve({ weekly: u.primary ?? { usedPercent: 0 }, monthly: u.secondary ?? { usedPercent: 0 }, fiveHour: u.tertiary ?? { usedPercent: 0 } });
94
+ } catch {
95
+ resolve(null);
96
+ }
97
+ });
98
+ });
99
+ }
100
+ function coach(q) {
101
+ if (!q) return { decision: "GO", advice: "quota unavailable \u2014 proceeding cautiously.", weekly: -1, monthly: -1, fiveHour: -1 };
102
+ const wk = Math.round(q.weekly?.usedPercent ?? 0), mo = Math.round(q.monthly?.usedPercent ?? 0), h5 = Math.round(q.fiveHour?.usedPercent ?? 0);
103
+ const wkR = humanRemaining(q.weekly?.resetsAt), h5R = humanRemaining(q.fiveHour?.resetsAt);
104
+ 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 });
105
+ 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 });
106
+ if (h5 >= STOP_5H) return stop(`5h window ${h5}% (${h5R})`);
107
+ if (wk >= STOP_WK) return stop(`weekly ${wk}% (${wkR})`);
108
+ if (mo >= STOP_MO) return stop(`monthly ${mo}%`);
109
+ if (h5 >= THR_5H) return thr(`5h window ${h5}% (${h5R})`);
110
+ if (wk >= THR_WK) return thr(`weekly ${wk}% (${wkR})`);
111
+ return { decision: "GO", advice: `Comfortable \u2014 weekly ${wk}% \xB7 5h ${h5}% \xB7 monthly ${mo}%. proceed. 5h window ${h5R}.`, weekly: wk, monthly: mo, fiveHour: h5 };
112
+ }
113
+ var LOADING = { decision: "GO", advice: "quota loading\u2026", weekly: -1, monthly: -1, fiveHour: -1 };
114
+ async function UsageCoachPlugin(input) {
115
+ try {
116
+ let last = null;
117
+ let lastFetchedAt = 0;
118
+ let refreshing = false;
119
+ const refreshBackground = () => {
120
+ try {
121
+ if (refreshing) return;
122
+ if (last && Date.now() - lastFetchedAt < TTL_MS) return;
123
+ refreshing = true;
124
+ fetchQuota().then((q) => {
125
+ try {
126
+ last = coach(q);
127
+ lastFetchedAt = Date.now();
128
+ writeState(last);
129
+ log(`${last.decision} | weekly=${last.weekly}% 5h=${last.fiveHour}%`);
130
+ } catch (e) {
131
+ log(`refresh-in-then err: ${String(e)}`);
132
+ }
133
+ }).catch((e) => {
134
+ log(`fetchQuota err: ${String(e)}`);
135
+ }).finally(() => {
136
+ refreshing = false;
137
+ });
138
+ } catch (e) {
139
+ log(`refreshBackground err: ${String(e)}`);
140
+ }
141
+ };
142
+ const current = () => {
143
+ try {
144
+ if (!last) refreshBackground();
145
+ return last ?? LOADING;
146
+ } catch {
147
+ return LOADING;
148
+ }
149
+ };
150
+ return {
151
+ event: async ({ event }) => {
152
+ try {
153
+ if (event.type === "session.created" || event.type === "session.idle") refreshBackground();
154
+ } catch (e) {
155
+ log(`event err: ${String(e)}`);
156
+ }
157
+ },
158
+ // ACT(1) hard gate: only intentional STOP throws. Our own bugs never block.
159
+ "tool.execute.before": async (_input) => {
160
+ let decision = "GO";
161
+ try {
162
+ decision = current().decision;
163
+ } catch {
164
+ decision = "GO";
165
+ }
166
+ if (decision === "STOP") {
167
+ throw new Error(`[${PLUGIN_NAME}] blocked: quota limit exceeded. ${current().advice}`);
168
+ }
169
+ },
170
+ // ACT(2) inject coaching into system prompt (double defense). Silent on error.
171
+ "experimental.chat.system.transform": async (_input, output) => {
172
+ try {
173
+ const c = current();
174
+ let instruction = "";
175
+ 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.`;
176
+ else if (c.decision === "THROTTLE") instruction = `[${PLUGIN_NAME}] ${c.advice} Hold off on long/heavy tasks.`;
177
+ else if (c.weekly >= 0) instruction = `[${PLUGIN_NAME}] quota ok \u2014 weekly ${c.weekly}% \xB7 5h ${c.fiveHour}% \xB7 monthly ${c.monthly}%.`;
178
+ if (instruction) output.system.push(instruction);
179
+ } catch (e) {
180
+ log(`system.transform err: ${String(e)}`);
181
+ }
182
+ },
183
+ // Custom tools for the harness agent mode — report status to the panel.
184
+ tool: {
185
+ harness_start: tool({
186
+ description: "Start the harness: register the total task count on the panel. Call once when the harness loop begins.",
187
+ args: { name: tool.schema.string(), total: tool.schema.number() },
188
+ async execute(args) {
189
+ writeHarness({ name: args.name, total: args.total, current: 0, tasks: [], usage: {}, startedAt: (/* @__PURE__ */ new Date()).toISOString() });
190
+ return `Harness '${args.name}' started (${args.total} tasks). Now report each task's status via task_update and run the loop.`;
191
+ }
192
+ }),
193
+ task_update: tool({
194
+ description: "Update a harness task's status on the panel. Call whenever a task transitions to generating/grading/revising/completed/failed.",
195
+ args: {
196
+ id: tool.schema.number(),
197
+ title: tool.schema.string(),
198
+ status: tool.schema.string().describe("generating | grading | revising | completed | failed | timed_out"),
199
+ revisions: tool.schema.number().optional(),
200
+ score: tool.schema.string().optional().describe("PASS | FAIL"),
201
+ model: tool.schema.string().optional()
202
+ },
203
+ async execute(args) {
204
+ const h = readHarness() ?? { name: "batch", total: 0, current: 0, tasks: [], usage: {} };
205
+ h.tasks = h.tasks.filter((x) => x.id !== args.id);
206
+ h.tasks.push({ id: args.id, title: args.title, status: args.status, model: args.model ?? "", revisions: args.revisions ?? 0, score: args.score ?? null });
207
+ if (args.id > h.current) h.current = args.id;
208
+ writeHarness(h);
209
+ return `task ${args.id} -> ${args.status}${args.score ? ` (${args.score})` : ""}`;
210
+ }
211
+ }),
212
+ harness_done: tool({
213
+ description: "Mark the harness as complete \u2014 call when the loop ends.",
214
+ args: {},
215
+ async execute() {
216
+ const h = readHarness();
217
+ if (h) {
218
+ h.current = h.total;
219
+ writeHarness(h);
220
+ }
221
+ return "Harness complete.";
222
+ }
223
+ })
224
+ }
225
+ };
226
+ } catch (e) {
227
+ log(`PLUGIN INIT FAILED (no-op): ${String(e)}`);
228
+ return NOOP_HOOKS;
229
+ }
230
+ }
231
+ export {
232
+ UsageCoachPlugin as default
233
+ };
package/dist/tui.js ADDED
@@ -0,0 +1,227 @@
1
+ // src/tui.tsx
2
+ import { setProp as _$setProp } from "@opentui/solid";
3
+ import { insert as _$insert } from "@opentui/solid";
4
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
5
+ import { insertNode as _$insertNode } from "@opentui/solid";
6
+ import { createElement as _$createElement } from "@opentui/solid";
7
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs";
8
+ import { homedir } from "os";
9
+ import { join } from "path";
10
+ import { createRoot, createSignal, onCleanup } from "solid-js";
11
+ var STATE_DIR = process.env.UC_STATE_DIR ?? join(homedir(), ".cache", "opencode-usage-coach");
12
+ var STATE_FILE = join(STATE_DIR, "state.json");
13
+ var HARNESS_FILE = join(STATE_DIR, "harness.json");
14
+ var MARKER = join(STATE_DIR, "tui-loaded.txt");
15
+ function readState() {
16
+ try {
17
+ if (!existsSync(STATE_FILE)) return null;
18
+ return JSON.parse(readFileSync(STATE_FILE, "utf8"));
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+ function readHarness() {
24
+ try {
25
+ if (!existsSync(HARNESS_FILE)) return null;
26
+ return JSON.parse(readFileSync(HARNESS_FILE, "utf8"));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ var TAG = {
32
+ GO: "ok",
33
+ THROTTLE: "slow",
34
+ STOP: "STOP"
35
+ };
36
+ var TLABEL = {
37
+ generating: "gen",
38
+ grading: "grade",
39
+ revising: "revise",
40
+ completed: "done",
41
+ failed: "fail",
42
+ timed_out: "timeout",
43
+ halted_quota: "quota-halt"
44
+ };
45
+ function bar(p) {
46
+ const n = Math.max(0, Math.min(10, Math.round(p / 10)));
47
+ return "\u2588".repeat(n) + "\u2591".repeat(10 - n);
48
+ }
49
+ function short(s, n) {
50
+ return s.length <= n ? s : s.slice(0, n - 1) + "\u2026";
51
+ }
52
+ function initializeTui(api, disposeRoot) {
53
+ try {
54
+ mkdirSync(STATE_DIR, {
55
+ recursive: true
56
+ });
57
+ writeFileSync(MARKER, `loaded ${(/* @__PURE__ */ new Date()).toISOString()}`);
58
+ } catch {
59
+ }
60
+ const [getState, setState] = createSignal(readState());
61
+ const [getHarness, setHarness] = createSignal(readHarness());
62
+ const timer = setInterval(() => {
63
+ try {
64
+ setState(readState());
65
+ setHarness(readHarness());
66
+ } catch {
67
+ }
68
+ }, 3e3);
69
+ onCleanup(() => clearInterval(timer));
70
+ onCleanup(() => disposeRoot());
71
+ const statusDot = {
72
+ generating: "\u25CF",
73
+ grading: "\u25CF",
74
+ revising: "\u25CF",
75
+ completed: "\u25CF",
76
+ failed: "\u25CF",
77
+ timed_out: "\u25CF",
78
+ halted_quota: "\u25CF"
79
+ };
80
+ const panel = (ctx) => {
81
+ let s = null;
82
+ try {
83
+ s = getState();
84
+ } catch {
85
+ s = null;
86
+ }
87
+ let h = null;
88
+ try {
89
+ h = getHarness();
90
+ } catch {
91
+ h = null;
92
+ }
93
+ const nodes = [];
94
+ if (s) {
95
+ nodes.push((() => {
96
+ var _el$ = _$createElement("text"), _el$2 = _$createTextNode(`usage-coach [`), _el$3 = _$createTextNode(`]`);
97
+ _$insertNode(_el$, _el$2);
98
+ _$insertNode(_el$, _el$3);
99
+ _$insert(_el$, () => TAG[s.decision], _el$3);
100
+ return _el$;
101
+ })());
102
+ nodes.push((() => {
103
+ var _el$4 = _$createElement("text"), _el$5 = _$createTextNode(` 5h `), _el$6 = _$createTextNode(` `), _el$7 = _$createTextNode(`%`);
104
+ _$insertNode(_el$4, _el$5);
105
+ _$insertNode(_el$4, _el$6);
106
+ _$insertNode(_el$4, _el$7);
107
+ _$insert(_el$4, () => bar(s.fiveHour), _el$6);
108
+ _$insert(_el$4, () => s.fiveHour, _el$7);
109
+ return _el$4;
110
+ })());
111
+ nodes.push((() => {
112
+ var _el$8 = _$createElement("text"), _el$9 = _$createTextNode(` wk `), _el$0 = _$createTextNode(` `), _el$1 = _$createTextNode(`%`);
113
+ _$insertNode(_el$8, _el$9);
114
+ _$insertNode(_el$8, _el$0);
115
+ _$insertNode(_el$8, _el$1);
116
+ _$insert(_el$8, () => bar(s.weekly), _el$0);
117
+ _$insert(_el$8, () => s.weekly, _el$1);
118
+ return _el$8;
119
+ })());
120
+ nodes.push((() => {
121
+ var _el$10 = _$createElement("text"), _el$11 = _$createTextNode(` mo `), _el$12 = _$createTextNode(` `), _el$13 = _$createTextNode(`%`);
122
+ _$insertNode(_el$10, _el$11);
123
+ _$insertNode(_el$10, _el$12);
124
+ _$insertNode(_el$10, _el$13);
125
+ _$insert(_el$10, () => bar(s.monthly), _el$12);
126
+ _$insert(_el$10, () => s.monthly, _el$13);
127
+ return _el$10;
128
+ })());
129
+ } else {
130
+ nodes.push((() => {
131
+ var _el$14 = _$createElement("text");
132
+ _$insertNode(_el$14, _$createTextNode(`usage-coach: ...`));
133
+ return _el$14;
134
+ })());
135
+ }
136
+ const hStatus = h && h.tasks.length > 0 ? `${h.name} ${h.current}/${h.total}` : "idle";
137
+ nodes.push((() => {
138
+ var _el$16 = _$createElement("text");
139
+ _$insertNode(_el$16, _$createTextNode(` `));
140
+ return _el$16;
141
+ })());
142
+ nodes.push((() => {
143
+ var _el$18 = _$createElement("text"), _el$19 = _$createTextNode(`harness: `);
144
+ _$insertNode(_el$18, _el$19);
145
+ _$insert(_el$18, hStatus, null);
146
+ return _el$18;
147
+ })());
148
+ if (h && h.tasks.length > 0) {
149
+ for (const t of h.tasks) {
150
+ const dot = statusDot[t.status] ?? "\xB7";
151
+ const lbl = TLABEL[t.status] ?? t.status;
152
+ const rev = t.revisions > 0 && t.status === "revising" ? `(${t.revisions})` : "";
153
+ const mdl = t.model ? ` ${t.model.split("/").pop() ?? t.model}` : "";
154
+ nodes.push((() => {
155
+ var _el$20 = _$createElement("text"), _el$21 = _$createTextNode(` `), _el$22 = _$createTextNode(` `), _el$23 = _$createTextNode(` `), _el$24 = _$createTextNode(` `);
156
+ _$insertNode(_el$20, _el$21);
157
+ _$insertNode(_el$20, _el$22);
158
+ _$insertNode(_el$20, _el$23);
159
+ _$insertNode(_el$20, _el$24);
160
+ _$insert(_el$20, dot, _el$22);
161
+ _$insert(_el$20, () => t.id, _el$23);
162
+ _$insert(_el$20, mdl, _el$23);
163
+ _$insert(_el$20, lbl, _el$24);
164
+ _$insert(_el$20, rev, _el$24);
165
+ _$insert(_el$20, () => short(t.title, 12), null);
166
+ return _el$20;
167
+ })());
168
+ const pv = t.model ? t.model.startsWith("zai") ? "zai" : (t.model.split("/")[0] ?? "").split("-")[0] : "";
169
+ const q = pv && h.quotas?.[pv] ? h.quotas[pv] : null;
170
+ const pct = q ? q.fiveHour : 0;
171
+ nodes.push((() => {
172
+ var _el$25 = _$createElement("text"), _el$26 = _$createTextNode(` 5h `), _el$27 = _$createTextNode(` `), _el$28 = _$createTextNode(`%`);
173
+ _$insertNode(_el$25, _el$26);
174
+ _$insertNode(_el$25, _el$27);
175
+ _$insertNode(_el$25, _el$28);
176
+ _$insert(_el$25, () => bar(pct), _el$27);
177
+ _$insert(_el$25, pct, _el$28);
178
+ return _el$25;
179
+ })());
180
+ }
181
+ }
182
+ return (() => {
183
+ var _el$29 = _$createElement("box");
184
+ _$setProp(_el$29, "flexDirection", "column");
185
+ _$insert(_el$29, nodes);
186
+ return _el$29;
187
+ })();
188
+ };
189
+ api.slots.register({
190
+ order: 80,
191
+ slots: {
192
+ sidebar_footer(ctx) {
193
+ try {
194
+ return panel(ctx);
195
+ } catch {
196
+ return (() => {
197
+ var _el$30 = _$createElement("text");
198
+ _$insertNode(_el$30, _$createTextNode(`usage-coach`));
199
+ return _el$30;
200
+ })();
201
+ }
202
+ },
203
+ home_footer(ctx) {
204
+ try {
205
+ return panel(ctx);
206
+ } catch {
207
+ return (() => {
208
+ var _el$32 = _$createElement("text");
209
+ _$insertNode(_el$32, _$createTextNode(`usage-coach`));
210
+ return _el$32;
211
+ })();
212
+ }
213
+ }
214
+ }
215
+ });
216
+ }
217
+ var tui = async (api) => {
218
+ createRoot((disposeRoot) => initializeTui(api, disposeRoot));
219
+ };
220
+ var plugin = {
221
+ id: "opencode-usage-coach-tui",
222
+ tui
223
+ };
224
+ var tui_default = plugin;
225
+ export {
226
+ tui_default as default
227
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "opencode-usage-coach",
3
+ "version": "0.1.1",
4
+ "description": "opencode closed-loop usage coach — quota SENSE -> coaching DECIDE -> loop ACT + TUI integration",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./tui": {
14
+ "types": "./dist/tui.d.ts",
15
+ "import": "./dist/tui.js"
16
+ }
17
+ },
18
+ "scripts": {
19
+ "build": "tsup",
20
+ "typecheck": "tsc --noEmit",
21
+ "prepack": "tsup"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "agents",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "keywords": ["opencode", "opencode-plugin", "quota", "usage", "coach", "loop", "zai", "glm"],
30
+ "license": "MIT",
31
+ "peerDependencies": {
32
+ "@opencode-ai/plugin": ">=1.14",
33
+ "@opentui/core": ">=0.4.0",
34
+ "@opentui/solid": ">=0.4.0",
35
+ "solid-js": ">=1.9.12"
36
+ },
37
+ "devDependencies": {
38
+ "@opencode-ai/plugin": "*",
39
+ "@opentui/core": ">=0.4.0",
40
+ "@opentui/solid": ">=0.4.0",
41
+ "esbuild-plugin-solid": "^0.6.0",
42
+ "solid-js": "^1.9",
43
+ "tsup": "^8.5",
44
+ "typescript": "^5"
45
+ }
46
+ }