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 +21 -0
- package/README.md +125 -0
- package/agents/usage-coach-harness.md +61 -0
- package/dist/index.js +233 -0
- package/dist/tui.js +227 -0
- package/package.json +46 -0
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
|
+
}
|