opencode-usage-coach 0.8.5 → 0.9.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A closed-loop usage coach and harness for [OpenCode](https://opencode.ai). Built for flat-rate / quota-metered coding plans — it **senses quota → coaches → stops/advances the loop**. Provider-agnostic, configurable via `harness.config.json`.
4
4
 
5
- [![npm version](https://img.shields.io/npm/v/opencode-usage-coach)](https://www.npmjs.com/package/opencode-usage-coach) [![license](https://img.shields.io/npm/l/opencode-usage-coach)](./LICENSE) [![coverage](https://img.shields.io/badge/coverage-planned-yellow)](#) [![Ko-fi](https://img.shields.io/badge/Ko--fi-sponsor-FF5E5B)](https://ko-fi.com/lhjnano) [![GitHub Sponsors](https://img.shields.io/badge/GitHub-Sponsors-ea4aaa)](https://github.com/sponsors/lhjnano)
5
+ [![npm version](https://img.shields.io/npm/v/opencode-usage-coach)](https://www.npmjs.com/package/opencode-usage-coach) [![license](https://img.shields.io/npm/l/opencode-usage-coach)](./LICENSE) [![coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/lhjnano/opencode-usage-coach/main/coverage-badge.json)](./coverage-badge.json) [![Ko-fi](https://img.shields.io/badge/Ko--fi-sponsor-FF5E5B)](https://ko-fi.com/lhjnano) [![GitHub Sponsors](https://img.shields.io/badge/GitHub-Sponsors-ea4aaa)](https://github.com/sponsors/lhjnano)
6
6
 
7
7
  ## Features
8
8
 
package/dist/index.js CHANGED
@@ -184,6 +184,7 @@ var PLUGIN_NAME = "opencode-usage-coach";
184
184
  var TTL_MS = Number(process.env.UC_TTL_MS ?? 6e4);
185
185
  var DEFAULT_MAX_STEPS = Number(process.env.UC_MAX_STEPS ?? 30) || 30;
186
186
  var WATCHDOG_POLL_MS = Math.max(1e3, Number(process.env.UC_WATCHDOG_POLL_MS ?? 3e3) || 3e3);
187
+ var WALL_TIMEOUT_MS = Math.max(1, Number(process.env.UC_WALL_TIMEOUT_MIN ?? 30) || 30) * 60 * 1e3;
187
188
  var DEFAULT_MAX_QUESTIONS = Math.max(1, Math.round(Number(process.env.UC_MAX_QUESTIONS ?? 7)) || 7);
188
189
  var PIPE_LOG = join2(homedir(), ".cache", "opencode-usage-coach", "pipeline.log");
189
190
  function pipeLog(msg) {
@@ -827,7 +828,7 @@ Questions for the user (${r.questions.length}):`);
827
828
  if (r.rawAnalysis) L.push(`
828
829
  Raw analysis: ${r.rawAnalysis.slice(0, 200)}`);
829
830
  L.push("\n[usage-coach NEXT] unknowns reviewed:");
830
- L.push(" - If questions are flagged, ask the user first.");
831
+ L.push(" - If questions are flagged, call question() to present them to the user.");
831
832
  L.push(" - If task splits are suggested, adjust via task_update.");
832
833
  L.push(" - Then proceed to generate/generate_batch.");
833
834
  return L.join("\n");
@@ -870,11 +871,27 @@ function checkScanGate(sessionID) {
870
871
  try {
871
872
  const h = readHarness(sessionID);
872
873
  if (!h || !h.scanRequired) return { warning: null, summary: null };
873
- if (h.scanDone) return { warning: null, summary: h.scanSummary ?? null };
874
- return {
875
- warning: `\u26A0 DIAGNOSIS GATE: unknown_scan was NOT called before this generate. You are generating without pre-flight gap analysis. Blind spots (unknown unknowns) may cause wrong assumptions and waste steps. Call unknown_scan first, OR proceed consciously accepting the risk.`,
876
- summary: null
877
- };
874
+ if (!h.scanDone) {
875
+ return {
876
+ warning: `\u26A0 DIAGNOSIS GATE: unknown_scan was NOT called before this generate. You are generating without pre-flight gap analysis. Blind spots (unknown unknowns) may cause wrong assumptions and waste steps. Call unknown_scan first, OR proceed consciously accepting the risk.`,
877
+ summary: null
878
+ };
879
+ }
880
+ if (h.unknownScan?.questions?.length && !h.questionsResolved) {
881
+ return {
882
+ warning: `\u26A0 UNRESOLVED QUESTIONS: unknown_scan found ${h.unknownScan.questions.length} question(s) for the user, but the question tool was not called. You MUST call question() to present these to the user before generating. The answers materially affect the approach. Do NOT proceed without asking.`,
883
+ summary: h.scanSummary ?? null
884
+ };
885
+ }
886
+ let summary = h.scanSummary ?? null;
887
+ if (h.questionsResolved && h.questionAnswers) {
888
+ const answers = Object.entries(h.questionAnswers).map(([id, ans]) => ` ${id}: ${ans}`).join("\n");
889
+ summary = summary ? `${summary}
890
+ User answers to scan questions:
891
+ ${answers}` : `User answers to scan questions:
892
+ ${answers}`;
893
+ }
894
+ return { warning: null, summary };
878
895
  } catch {
879
896
  return { warning: null, summary: null };
880
897
  }
@@ -896,8 +913,10 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
896
913
  const t0 = Date.now();
897
914
  const subStart = Date.now();
898
915
  let poller = null;
916
+ let wallTimer = null;
899
917
  let subId = null;
900
918
  let timedOut = false;
919
+ let pollerDone = false;
901
920
  let signalTimeout;
902
921
  const timeoutSignal = new Promise((resolve2) => {
903
922
  signalTimeout = resolve2;
@@ -912,12 +931,13 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
912
931
  subId = id;
913
932
  log(`runModel(${model}): session ${id} created, sending prompt (${prompt.length} chars), max_steps=${maxSteps}`);
914
933
  poller = setInterval(async () => {
915
- if (timedOut) return;
934
+ if (timedOut || pollerDone) return;
916
935
  try {
917
936
  let step = 0;
918
937
  let lastTs = (/* @__PURE__ */ new Date()).toISOString();
919
938
  try {
920
939
  const msgs = await client.session.messages?.({ path: { id } });
940
+ if (pollerDone) return;
921
941
  const msgList = Array.isArray(msgs?.data) ? msgs.data : Array.isArray(msgs) ? msgs : [];
922
942
  if (msgList.length) {
923
943
  step = msgList.filter((m) => {
@@ -940,7 +960,7 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
940
960
  signalTimeout();
941
961
  return;
942
962
  }
943
- if (track) {
963
+ if (track && !pollerDone) {
944
964
  const elapsed2 = Math.round((Date.now() - subStart) / 1e3);
945
965
  updateSubSession(track.sessionID, track.taskId, {
946
966
  subSessionId: id,
@@ -953,6 +973,17 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
953
973
  log(`runModel poller err: ${String(e)}`);
954
974
  }
955
975
  }, WATCHDOG_POLL_MS);
976
+ wallTimer = setTimeout(() => {
977
+ if (!timedOut) {
978
+ timedOut = true;
979
+ log(`runModel(${model}): WALL-CLOCK timeout after ${WALL_TIMEOUT_MS / 1e3}s, aborting session ${id}`);
980
+ try {
981
+ client.session.abort?.({ path: { id } });
982
+ } catch {
983
+ }
984
+ signalTimeout();
985
+ }
986
+ }, WALL_TIMEOUT_MS);
956
987
  const promptP = client.session.prompt({
957
988
  path: { id },
958
989
  body: { model: { providerID, modelID }, parts: [{ type: "text", text: prompt }] }
@@ -997,7 +1028,9 @@ async function runModel(client, model, prompt, directory, track, maxSteps = DEFA
997
1028
  log(`runModel err (${model}, ${elapsed}s): ${String(e)}`);
998
1029
  return `ERROR: runModel exception after ${elapsed}s: ${String(e)}`;
999
1030
  } finally {
1031
+ pollerDone = true;
1000
1032
  if (poller) clearInterval(poller);
1033
+ if (wallTimer) clearTimeout(wallTimer);
1001
1034
  if (track) {
1002
1035
  try {
1003
1036
  clearSubSession(track.sessionID, track.taskId);
@@ -1031,7 +1064,9 @@ var WORM_MAX_NODES = num("UC_WORM_MAX_NODES", 1e5);
1031
1064
  function humanRemaining(iso) {
1032
1065
  try {
1033
1066
  if (!iso) return "";
1034
- const mins = Math.floor((new Date(iso).getTime() - Date.now()) / 6e4);
1067
+ const ms = new Date(iso).getTime();
1068
+ if (Number.isNaN(ms)) return "";
1069
+ const mins = Math.floor((ms - Date.now()) / 6e4);
1035
1070
  if (mins < 0) return "resets soon";
1036
1071
  if (mins < 60) return `resets in ${mins}m`;
1037
1072
  if (mins < 1440) return `resets in ${Math.floor(mins / 60)}h ${mins % 60}m`;
@@ -1098,6 +1133,21 @@ async function fetchProvidersCoach() {
1098
1133
  }));
1099
1134
  return results.filter(Boolean);
1100
1135
  }
1136
+ function parseQuotaResponse(rawText) {
1137
+ try {
1138
+ const text = (rawText || "").trim();
1139
+ if (!text || text === "[]") return null;
1140
+ const u = JSON.parse(text)[0]?.usage;
1141
+ if (!u) return null;
1142
+ return {
1143
+ weekly: u.primary ?? { usedPercent: 0 },
1144
+ monthly: u.secondary ?? { usedPercent: 0 },
1145
+ fiveHour: u.tertiary ?? { usedPercent: 0 }
1146
+ };
1147
+ } catch {
1148
+ return null;
1149
+ }
1150
+ }
1101
1151
  function fetchQuota(provider) {
1102
1152
  return new Promise((resolve2) => {
1103
1153
  let out = "";
@@ -1115,21 +1165,24 @@ function fetchQuota(provider) {
1115
1165
  });
1116
1166
  p.on("error", () => resolve2(null));
1117
1167
  p.on("close", () => {
1118
- try {
1119
- const text = out.trim();
1120
- if (!text || text === "[]") return resolve2(null);
1121
- const u = JSON.parse(text)[0]?.usage;
1122
- if (!u) return resolve2(null);
1123
- resolve2({ weekly: u.primary ?? { usedPercent: 0 }, monthly: u.secondary ?? { usedPercent: 0 }, fiveHour: u.tertiary ?? { usedPercent: 0 } });
1124
- } catch {
1125
- resolve2(null);
1126
- }
1168
+ resolve2(parseQuotaResponse(out));
1127
1169
  });
1128
1170
  });
1129
1171
  }
1172
+ async function fetchQuotaWithRetry(provider, maxRetries = 3) {
1173
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
1174
+ const q = await fetchQuota(provider);
1175
+ if (q) return q;
1176
+ if (attempt < maxRetries - 1) {
1177
+ await new Promise((r) => setTimeout(r, 1e3 * (attempt + 1)));
1178
+ }
1179
+ }
1180
+ return null;
1181
+ }
1130
1182
  function coach(q, lighter) {
1131
- if (!q) return { decision: "GO", advice: "quota unavailable \u2014 proceeding cautiously.", weekly: -1, monthly: -1, fiveHour: -1 };
1183
+ if (!q) return { decision: "GO", advice: "quota unavailable \u2014 retrying. proceeding cautiously.", weekly: -2, monthly: -2, fiveHour: -2 };
1132
1184
  const wk = Math.round(q.weekly?.usedPercent ?? 0), mo = Math.round(q.monthly?.usedPercent ?? 0), h5 = Math.round(q.fiveHour?.usedPercent ?? 0);
1185
+ if (Number.isNaN(wk) || Number.isNaN(mo) || Number.isNaN(h5)) return { decision: "THROTTLE", advice: "invalid quota data \u2014 proceeding with caution. switch to lighter model if available.", weekly: Number.isNaN(wk) ? 0 : wk, monthly: Number.isNaN(mo) ? 0 : mo, fiveHour: Number.isNaN(h5) ? 0 : h5 };
1133
1186
  const wkR = humanRemaining(q.weekly?.resetsAt), h5R = humanRemaining(q.fiveHour?.resetsAt);
1134
1187
  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 });
1135
1188
  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 });
@@ -1153,7 +1206,8 @@ function isFreeModel(model, provider) {
1153
1206
  }
1154
1207
  function providerToCodexbar(provider) {
1155
1208
  if (!provider) return "";
1156
- return provider.split("-")[0];
1209
+ const first = provider.split("-")[0];
1210
+ return first || provider;
1157
1211
  }
1158
1212
  async function resolveAgent(client, sessionID) {
1159
1213
  if (!sessionID) return "";
@@ -1198,6 +1252,7 @@ async function UsageCoachPlugin(input) {
1198
1252
  const PROVIDER = process.env.UC_PROVIDER ?? cfg0.provider ?? "";
1199
1253
  const LIGHTER = process.env.UC_LIGHTER_MODEL ?? cfg0.lighterModel ?? "a lighter model";
1200
1254
  let last = null;
1255
+ let lastKnownQuota = null;
1201
1256
  let lastFetchedAt = 0;
1202
1257
  let refreshing = false;
1203
1258
  const refreshBackground = () => {
@@ -1215,9 +1270,11 @@ async function UsageCoachPlugin(input) {
1215
1270
  return;
1216
1271
  }
1217
1272
  const activeProvider = providerToCodexbar(currentProvider) || PROVIDER;
1218
- fetchQuota(activeProvider).then(async (q) => {
1273
+ fetchQuotaWithRetry(activeProvider).then(async (q) => {
1219
1274
  try {
1220
- last = coach(q, LIGHTER);
1275
+ if (q) lastKnownQuota = q;
1276
+ const effectiveQ = q ?? lastKnownQuota;
1277
+ last = coach(effectiveQ, LIGHTER);
1221
1278
  lastFetchedAt = Date.now();
1222
1279
  let providers = [];
1223
1280
  try {
@@ -1234,7 +1291,7 @@ async function UsageCoachPlugin(input) {
1234
1291
  log(`refresh-in-then err: ${String(e)}`);
1235
1292
  }
1236
1293
  }).catch((e) => {
1237
- log(`fetchQuota err: ${String(e)}`);
1294
+ log(`fetchQuotaWithRetry err: ${String(e)}`);
1238
1295
  }).finally(() => {
1239
1296
  refreshing = false;
1240
1297
  });
@@ -1272,7 +1329,7 @@ async function UsageCoachPlugin(input) {
1272
1329
  const agent = await resolveAgent(input.client, _input.sessionID);
1273
1330
  currentAgent = agent;
1274
1331
  refreshBackground();
1275
- const harnessTools = ["unknown_scan", "generate", "generate_batch", "grade", "investigate", "verify_diagnosis", "generalize", "harness_start", "task_update", "harness_done", "record_failure", "reverse_interview"];
1332
+ const harnessTools = ["unknown_scan", "question", "generate", "generate_batch", "grade", "investigate", "verify_diagnosis", "generalize", "harness_start", "task_update", "harness_done", "record_failure", "reverse_interview"];
1276
1333
  if (!harnessTools.includes(_input.tool)) return;
1277
1334
  if (!isHarnessAgent(agent)) {
1278
1335
  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.`);
@@ -1318,9 +1375,11 @@ async function UsageCoachPlugin(input) {
1318
1375
  \u26A0 DIAGNOSIS GATE \u2014 unknown_scan is REQUIRED before generate/generate_batch.
1319
1376
  unknown_scan({ prompt: "<user request>", tasks: [{id:1, title:"..."}, ...] })
1320
1377
  If you skip it, generate will inject a \u26A0 warning into the sub-session prompt.
1321
- Review the report: if QUESTIONS are flagged \u2192 ask the user first. If TASK
1322
- REFINEMENTS are suggested \u2192 apply via task_update. Unknown unknowns found will
1323
- be automatically injected into generate prompts as context.
1378
+ Review the report: if QUESTIONS are flagged \u2192 call question() to present them
1379
+ to the user BEFORE generate (it is enforced \u2014 generate will block with a
1380
+ warning until answers are recorded). If TASK REFINEMENTS are suggested \u2192
1381
+ apply via task_update. Unknown unknowns found will be automatically injected
1382
+ into generate prompts as context.
1324
1383
 
1325
1384
  STEP LIMIT (default ${DEFAULT_MAX_STEPS}): each generate call creates a sub-session that is automatically aborted if it exceeds ${DEFAULT_MAX_STEPS} assistant steps. Before starting the loop, review each task: can it be completed in a focused, single-pass effort? If a task seems too broad (multiple files, multiple features, open-ended research), SPLIT it now into 2-3 smaller subtasks. A timeout wastes quota \u2014 split upfront.
1326
1385
 
@@ -1462,6 +1521,42 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
1462
1521
  return formatReport(result);
1463
1522
  }
1464
1523
  }),
1524
+ question: tool({
1525
+ description: "Present unknown_scan questions to the user. REQUIRED after unknown_scan finds questions \u2014 call BEFORE generate. First call (no answers) returns the questions formatted for presentation. Present them to the user, then call again with their answers.",
1526
+ args: {
1527
+ answers: tool.schema.record(tool.schema.string(), tool.schema.string()).optional().describe(`User's answers keyed by question ID (e.g. {"Q1": "yes", "Q2": "node"}). Omit on first call to get the questions.`)
1528
+ },
1529
+ async execute(args, ctx) {
1530
+ const h = readHarness(ctx.sessionID);
1531
+ if (!h || !h.unknownScan || !h.unknownScan.questions?.length) {
1532
+ return "No questions from unknown_scan. [usage-coach NEXT] proceed to generate.";
1533
+ }
1534
+ const questions = h.unknownScan.questions;
1535
+ if (!args.answers) {
1536
+ const lines2 = [`unknown_scan found ${questions.length} question(s) that need user input before proceeding:
1537
+ `];
1538
+ questions.forEach((q) => {
1539
+ lines2.push(`[${q.id}] ${q.question}`);
1540
+ });
1541
+ lines2.push('\nPresent ALL of these questions to the user. Collect their answers, then call question({ answers: { "' + questions[0].id + '": "..." } }) with ALL answers.');
1542
+ lines2.push("[usage-coach NEXT] Present the questions above to the user verbatim. After they respond, call question({answers:{...}}) to record answers, then proceed to generate.");
1543
+ return lines2.join("\n");
1544
+ }
1545
+ h.questionsResolved = true;
1546
+ h.questionAnswers = args.answers;
1547
+ writeHarness(ctx.sessionID, h);
1548
+ const answered = Object.keys(args.answers).length;
1549
+ const lines = [`Questions resolved (${answered}/${questions.length} answered).
1550
+ `];
1551
+ questions.forEach((q) => {
1552
+ const ans = args.answers[q.id];
1553
+ if (ans) lines.push(` ${q.id}: ${q.question}
1554
+ \u2192 ${ans}`);
1555
+ });
1556
+ lines.push("\n[usage-coach NEXT] Answers recorded. Proceed to generate \u2014 they will be injected into the sub-session prompt.");
1557
+ return lines.join("\n");
1558
+ }
1559
+ }),
1465
1560
  task_update: tool({
1466
1561
  description: "Update a harness task's status on the panel. Call whenever a task transitions to generating/grading/revising/completed/failed.",
1467
1562
  args: {
@@ -1655,7 +1750,14 @@ Origin: ${args.task}
1655
1750
  args: { prompt: tool.schema.string(), max_steps: tool.schema.number().optional().describe("Maximum sub-session steps before timeout (default 30). Increase for complex tasks, decrease to fail fast on scope creep.") },
1656
1751
  async execute(args, ctx) {
1657
1752
  const cfg = readHarnessCfg(ctx.directory);
1658
- if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
1753
+ if (!cfg.generator) {
1754
+ const h = readHarness(ctx.sessionID);
1755
+ if (h) {
1756
+ h.active = false;
1757
+ writeHarness(ctx.sessionID, h);
1758
+ }
1759
+ return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json). HARNESS TERMINATED \u2014 configure a generator model, then restart the harness.';
1760
+ }
1659
1761
  let decision = "GO";
1660
1762
  try {
1661
1763
  decision = current().decision;
@@ -1761,7 +1863,14 @@ ${gate.summary}
1761
1863
  args: { tasks: tool.schema.array(tool.schema.object({ id: tool.schema.number(), prompt: tool.schema.string() })), max_steps: tool.schema.number().optional().describe("Maximum sub-session steps per task before timeout (default 30).") },
1762
1864
  async execute(args, ctx) {
1763
1865
  const cfg = readHarnessCfg(ctx.directory);
1764
- if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
1866
+ if (!cfg.generator) {
1867
+ const h = readHarness(ctx.sessionID);
1868
+ if (h) {
1869
+ h.active = false;
1870
+ writeHarness(ctx.sessionID, h);
1871
+ }
1872
+ return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json). HARNESS TERMINATED \u2014 configure a generator model, then restart the harness.';
1873
+ }
1765
1874
  let decision = "GO";
1766
1875
  try {
1767
1876
  decision = current().decision;
@@ -2039,5 +2148,28 @@ If the task is already well-specified with no significant ambiguities, return {"
2039
2148
  }
2040
2149
  }
2041
2150
  export {
2042
- UsageCoachPlugin as default
2151
+ buildGapPrompt,
2152
+ buildScanSummary,
2153
+ checkScanGate,
2154
+ clearSubSession,
2155
+ coach,
2156
+ UsageCoachPlugin as default,
2157
+ detectLanguage,
2158
+ extractImplNotes,
2159
+ extractKeywords,
2160
+ findActiveTaskId,
2161
+ formatReport,
2162
+ humanRemaining,
2163
+ isFreeModel,
2164
+ isHarnessAgent,
2165
+ parseFileList,
2166
+ parseGapAnalysis,
2167
+ parseQuotaResponse,
2168
+ providerAdvice,
2169
+ providerToCodexbar,
2170
+ readHarness,
2171
+ readRules,
2172
+ setStateDir,
2173
+ updateSubSession,
2174
+ writeHarness
2043
2175
  };
package/dist/tui.js CHANGED
@@ -10,6 +10,101 @@ import { createHash } from "crypto";
10
10
  import { homedir } from "os";
11
11
  import { join, resolve } from "path";
12
12
  import { createRoot, createSignal, onCleanup } from "solid-js";
13
+
14
+ // src/tui-logic.ts
15
+ var STALE_MS = 5 * 6e4;
16
+ var HIDE_MS = 30 * 6e4;
17
+ var TAG = {
18
+ GO: "ok",
19
+ THROTTLE: "slow",
20
+ STOP: "STOP"
21
+ };
22
+ var TLABEL = {
23
+ generating: "gen",
24
+ grading: "grade",
25
+ revising: "revise",
26
+ completed: "done",
27
+ failed: "fail",
28
+ timed_out: "timeout",
29
+ halted_quota: "quota-halt",
30
+ stale: "STALE"
31
+ };
32
+ var STATUS_KEY = {
33
+ generating: "info",
34
+ grading: "accent",
35
+ revising: "warning",
36
+ completed: "success",
37
+ failed: "error",
38
+ timed_out: "error",
39
+ halted_quota: "error"
40
+ };
41
+ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
42
+ "completed",
43
+ "failed",
44
+ "timed_out",
45
+ "halted_quota"
46
+ ]);
47
+ function barFill(p) {
48
+ const n = !Number.isFinite(p) || p <= 0 ? 0 : Math.max(1, Math.min(10, Math.round(p / 10)));
49
+ return "\u2588".repeat(n);
50
+ }
51
+ function barEmpty(p) {
52
+ const n = !Number.isFinite(p) || p <= 0 ? 0 : Math.max(1, Math.min(10, Math.round(p / 10)));
53
+ return "\u2591".repeat(10 - n);
54
+ }
55
+ function computeStaleness(h, now = Date.now()) {
56
+ const hAge = h.updatedAt ? now - new Date(h.updatedAt).getTime() : 0;
57
+ const hasActiveSub = h.tasks.some((t) => !!t.subSessionId);
58
+ const isStale = !hasActiveSub && hAge > STALE_MS;
59
+ const shouldHide = hAge > HIDE_MS && !hasActiveSub;
60
+ return { hAge, hasActiveSub, isStale, shouldHide };
61
+ }
62
+ function isHarnessVisible(h, staleness) {
63
+ if (!h) return false;
64
+ if (h.tasks.length === 0) return false;
65
+ if (h.active !== true) return false;
66
+ if (staleness.shouldHide) return false;
67
+ return true;
68
+ }
69
+ function computeTaskDisplay(t, isStale, now = Date.now()) {
70
+ const displayStatus = isStale && !TERMINAL_STATUSES.has(t.status) ? "stale" : t.status;
71
+ const sKey = STATUS_KEY[displayStatus] ?? "text";
72
+ const lbl = TLABEL[displayStatus] ?? displayStatus;
73
+ const rev = (t.revisions ?? 0) > 0 && t.status === "revising" ? `(${t.revisions})` : "";
74
+ const mdl = t.model ? ` ${t.model.split("/").pop() ?? t.model}` : "";
75
+ const hasSub = !!t.subSessionId;
76
+ const subStepStr = hasSub && t.subStep !== void 0 && t.subStep > 0 ? ` step:${t.subStep}` : "";
77
+ const subEl = hasSub && t.subElapsed !== void 0 ? ` ${t.subElapsed}s` : "";
78
+ const subWarn = hasSub && (t.subElapsed ?? 0) > 300;
79
+ const elapsed = t.startedAt ? Math.max(0, Math.round((now - new Date(t.startedAt).getTime()) / 1e3)) : 0;
80
+ const taskEl = t.status === "completed" || t.status === "failed" ? "" : elapsed > 0 ? ` ${elapsed}s` : "";
81
+ const displayEl = hasSub ? subEl : taskEl;
82
+ const lineKey = subWarn ? "warning" : sKey;
83
+ return {
84
+ displayStatus,
85
+ themeKey: lineKey,
86
+ label: lbl,
87
+ revSuffix: rev,
88
+ modelStr: mdl,
89
+ stepStr: subStepStr,
90
+ elapsedStr: displayEl,
91
+ hasSub,
92
+ subWarn
93
+ };
94
+ }
95
+ function decisionThemeKey(decision) {
96
+ return decision === "GO" ? "success" : decision === "THROTTLE" ? "warning" : "error";
97
+ }
98
+ function taskQuotaPct(t, s) {
99
+ const pv = t.model ? (t.model.split("/")[0] ?? "").split("-")[0] : "";
100
+ const provCoach = pv ? s?.providers?.find((p) => p.id === pv || pv && p.id.startsWith(pv) || pv && pv.startsWith(p.id)) : s?.providers?.[0];
101
+ const rawPct = provCoach?.fiveHour ?? s?.fiveHour ?? -1;
102
+ const pct = rawPct < 0 ? 0 : rawPct;
103
+ const label = rawPct === -1 ? "\u2026" : rawPct < 0 ? "retry" : `${rawPct}%`;
104
+ return { pct, label };
105
+ }
106
+
107
+ // src/tui.tsx
13
108
  function projectStateDir(dir) {
14
109
  const abs = resolve(dir || ".");
15
110
  const h = createHash("sha1").update(abs).digest("hex").slice(0, 12);
@@ -71,31 +166,6 @@ function readHarness() {
71
166
  return null;
72
167
  }
73
168
  }
74
- var TAG = {
75
- GO: "ok",
76
- THROTTLE: "slow",
77
- STOP: "STOP"
78
- };
79
- var TLABEL = {
80
- generating: "gen",
81
- grading: "grade",
82
- revising: "revise",
83
- completed: "done",
84
- failed: "fail",
85
- timed_out: "timeout",
86
- halted_quota: "quota-halt",
87
- stale: "STALE"
88
- };
89
- var STALE_MS = 5 * 6e4;
90
- var HIDE_MS = 30 * 6e4;
91
- function barFill(p) {
92
- const n = p <= 0 ? 0 : Math.max(1, Math.min(10, Math.round(p / 10)));
93
- return "\u2588".repeat(n);
94
- }
95
- function barEmpty(p) {
96
- const n = p <= 0 ? 0 : Math.max(1, Math.min(10, Math.round(p / 10)));
97
- return "\u2591".repeat(10 - n);
98
- }
99
169
  function initializeTui(api, disposeRoot) {
100
170
  STATE_DIR = process.env.UC_STATE_DIR ?? projectStateDir(api.state.path.directory);
101
171
  STATE_FILE = join(STATE_DIR, "state.json");
@@ -162,15 +232,6 @@ function initializeTui(api, disposeRoot) {
162
232
  } catch {
163
233
  }
164
234
  });
165
- const statusKey = {
166
- generating: "info",
167
- grading: "accent",
168
- revising: "warning",
169
- completed: "success",
170
- failed: "error",
171
- timed_out: "error",
172
- halted_quota: "error"
173
- };
174
235
  const panel = (ctx) => {
175
236
  const th = ctx.theme?.current ?? {};
176
237
  const st = (k) => ({
@@ -206,7 +267,7 @@ function initializeTui(api, disposeRoot) {
206
267
  }
207
268
  const nodes = [];
208
269
  if (s) {
209
- const dKey = s.decision === "GO" ? "success" : s.decision === "THROTTLE" ? "warning" : "error";
270
+ const dKey = decisionThemeKey(s.decision);
210
271
  const modelShort = s.model ? s.model.split("/").pop() ?? s.model : "";
211
272
  if (s.isFree) {
212
273
  nodes.push((() => {
@@ -316,7 +377,7 @@ function initializeTui(api, disposeRoot) {
316
377
  }
317
378
  } else {
318
379
  nodes.push((() => {
319
- var _el$32 = _$createElement("box"), _el$33 = _$createElement("text"), _el$35 = _$createElement("text"), _el$36 = _$createElement("text"), _el$37 = _$createElement("text");
380
+ var _el$32 = _$createElement("box"), _el$33 = _$createElement("text"), _el$35 = _$createElement("text"), _el$36 = _$createElement("text"), _el$37 = _$createElement("text"), _el$38 = _$createTextNode(` `), _el$39 = _$createTextNode(`%`);
320
381
  _$insertNode(_el$32, _el$33);
321
382
  _$insertNode(_el$32, _el$35);
322
383
  _$insertNode(_el$32, _el$36);
@@ -325,7 +386,9 @@ function initializeTui(api, disposeRoot) {
325
386
  _$insertNode(_el$33, _$createTextNode(` 5h `));
326
387
  _$insert(_el$35, () => barFill(s.fiveHour));
327
388
  _$insert(_el$36, () => barEmpty(s.fiveHour));
328
- _$insertNode(_el$37, _$createTextNode(` 0%`));
389
+ _$insertNode(_el$37, _el$38);
390
+ _$insertNode(_el$37, _el$39);
391
+ _$insert(_el$37, () => s.fiveHour, _el$39);
329
392
  _$effect((_p$) => {
330
393
  var _v$9 = st("text"), _v$0 = st("text");
331
394
  _v$9 !== _p$.e && (_p$.e = _$setProp(_el$35, "style", _v$9, _p$.e));
@@ -338,126 +401,111 @@ function initializeTui(api, disposeRoot) {
338
401
  return _el$32;
339
402
  })());
340
403
  nodes.push((() => {
341
- var _el$39 = _$createElement("box"), _el$40 = _$createElement("text"), _el$42 = _$createElement("text"), _el$43 = _$createElement("text"), _el$44 = _$createElement("text");
342
- _$insertNode(_el$39, _el$40);
343
- _$insertNode(_el$39, _el$42);
344
- _$insertNode(_el$39, _el$43);
345
- _$insertNode(_el$39, _el$44);
346
- _$setProp(_el$39, "flexDirection", "row");
347
- _$insertNode(_el$40, _$createTextNode(` 1w `));
348
- _$insert(_el$42, () => barFill(s.weekly));
349
- _$insert(_el$43, () => barEmpty(s.weekly));
350
- _$insertNode(_el$44, _$createTextNode(` 0%`));
404
+ var _el$40 = _$createElement("box"), _el$41 = _$createElement("text"), _el$43 = _$createElement("text"), _el$44 = _$createElement("text"), _el$45 = _$createElement("text"), _el$46 = _$createTextNode(` `), _el$47 = _$createTextNode(`%`);
405
+ _$insertNode(_el$40, _el$41);
406
+ _$insertNode(_el$40, _el$43);
407
+ _$insertNode(_el$40, _el$44);
408
+ _$insertNode(_el$40, _el$45);
409
+ _$setProp(_el$40, "flexDirection", "row");
410
+ _$insertNode(_el$41, _$createTextNode(` 1w `));
411
+ _$insert(_el$43, () => barFill(s.weekly));
412
+ _$insert(_el$44, () => barEmpty(s.weekly));
413
+ _$insertNode(_el$45, _el$46);
414
+ _$insertNode(_el$45, _el$47);
415
+ _$insert(_el$45, () => s.weekly, _el$47);
351
416
  _$effect((_p$) => {
352
417
  var _v$1 = st("text"), _v$10 = st("text");
353
- _v$1 !== _p$.e && (_p$.e = _$setProp(_el$42, "style", _v$1, _p$.e));
354
- _v$10 !== _p$.t && (_p$.t = _$setProp(_el$43, "style", _v$10, _p$.t));
418
+ _v$1 !== _p$.e && (_p$.e = _$setProp(_el$43, "style", _v$1, _p$.e));
419
+ _v$10 !== _p$.t && (_p$.t = _$setProp(_el$44, "style", _v$10, _p$.t));
355
420
  return _p$;
356
421
  }, {
357
422
  e: void 0,
358
423
  t: void 0
359
424
  });
360
- return _el$39;
425
+ return _el$40;
361
426
  })());
362
427
  }
363
428
  }
364
429
  } else {
365
430
  nodes.push((() => {
366
- var _el$46 = _$createElement("text");
367
- _$insertNode(_el$46, _$createTextNode(`usage-coach: ...`));
368
- return _el$46;
431
+ var _el$48 = _$createElement("text");
432
+ _$insertNode(_el$48, _$createTextNode(`usage-coach: ...`));
433
+ return _el$48;
369
434
  })());
370
435
  }
371
- if (h && h.tasks.length > 0 && h.active !== false) {
372
- const hAge = h.updatedAt ? Date.now() - new Date(h.updatedAt).getTime() : 0;
373
- const hasActiveSub = h.tasks.some((t) => !!t.subSessionId);
374
- const isStale = !hasActiveSub && hAge > STALE_MS;
375
- const shouldHide = hAge > HIDE_MS && !hasActiveSub;
376
- if (shouldHide) {
377
- } else {
436
+ if (h) {
437
+ const staleness = computeStaleness(h);
438
+ if (isHarnessVisible(h, staleness)) {
439
+ const isStale = staleness.isStale;
378
440
  nodes.push((() => {
379
- var _el$48 = _$createElement("text");
380
- _$insertNode(_el$48, _$createTextNode(` `));
381
- return _el$48;
441
+ var _el$50 = _$createElement("text");
442
+ _$insertNode(_el$50, _$createTextNode(` `));
443
+ return _el$50;
382
444
  })());
383
445
  nodes.push((() => {
384
- var _el$50 = _$createElement("text"), _el$51 = _$createTextNode(`harness: `), _el$52 = _$createTextNode(` `), _el$53 = _$createTextNode(`/`);
385
- _$insertNode(_el$50, _el$51);
386
- _$insertNode(_el$50, _el$52);
387
- _$insertNode(_el$50, _el$53);
388
- _$insert(_el$50, () => h.name, _el$52);
389
- _$insert(_el$50, () => h.current, _el$53);
390
- _$insert(_el$50, () => h.total, null);
391
- _$insert(_el$50, isStale ? " (stale)" : "", null);
392
- _$effect((_$p) => _$setProp(_el$50, "style", st("textMuted"), _$p));
393
- return _el$50;
446
+ var _el$52 = _$createElement("text"), _el$53 = _$createTextNode(`harness: `), _el$54 = _$createTextNode(` `), _el$55 = _$createTextNode(`/`);
447
+ _$insertNode(_el$52, _el$53);
448
+ _$insertNode(_el$52, _el$54);
449
+ _$insertNode(_el$52, _el$55);
450
+ _$insert(_el$52, () => h.name, _el$54);
451
+ _$insert(_el$52, () => h.current, _el$55);
452
+ _$insert(_el$52, () => h.total, null);
453
+ _$insert(_el$52, isStale ? " (stale)" : "", null);
454
+ _$effect((_$p) => _$setProp(_el$52, "style", st("textMuted"), _$p));
455
+ return _el$52;
394
456
  })());
395
457
  for (const t of h.tasks) {
396
- const TERMINAL = /* @__PURE__ */ new Set(["completed", "failed", "timed_out", "halted_quota"]);
397
- const displayStatus = isStale && !TERMINAL.has(t.status) ? "stale" : t.status;
398
- const sKey = statusKey[displayStatus] ?? "text";
399
- const lbl = TLABEL[displayStatus] ?? displayStatus;
400
- const rev = t.revisions > 0 && t.status === "revising" ? `(${t.revisions})` : "";
401
- const mdl = t.model ? ` ${t.model.split("/").pop() ?? t.model}` : "";
402
- const hasSub = !!t.subSessionId;
403
- const subStepStr = hasSub && t.subStep !== void 0 && t.subStep > 0 ? ` step:${t.subStep}` : "";
404
- const subEl = hasSub && t.subElapsed !== void 0 ? ` ${t.subElapsed}s` : "";
405
- const subWarn = hasSub && (t.subElapsed ?? 0) > 300;
406
- const elapsed = t.startedAt ? Math.max(0, Math.round((Date.now() - new Date(t.startedAt).getTime()) / 1e3)) : 0;
407
- const taskEl = t.status === "completed" || t.status === "failed" ? "" : elapsed > 0 ? ` ${elapsed}s` : "";
408
- const displayEl = hasSub ? subEl : taskEl;
409
- const lineKey = subWarn ? "warning" : sKey;
458
+ const td = computeTaskDisplay(t, isStale);
410
459
  nodes.push((() => {
411
- var _el$54 = _$createElement("text"), _el$55 = _$createTextNode(` \u25CF `), _el$56 = _$createTextNode(` `), _el$57 = _$createTextNode(` `);
412
- _$insertNode(_el$54, _el$55);
413
- _$insertNode(_el$54, _el$56);
414
- _$insertNode(_el$54, _el$57);
415
- _$insert(_el$54, () => t.id, _el$56);
416
- _$insert(_el$54, mdl, _el$56);
417
- _$insert(_el$54, lbl, _el$57);
418
- _$insert(_el$54, rev, _el$57);
419
- _$insert(_el$54, subStepStr, _el$57);
420
- _$insert(_el$54, displayEl, _el$57);
421
- _$insert(_el$54, () => t.title, null);
422
- _$effect((_$p) => _$setProp(_el$54, "style", st(lineKey), _$p));
423
- return _el$54;
460
+ var _el$56 = _$createElement("text"), _el$57 = _$createTextNode(` \u25CF `), _el$58 = _$createTextNode(` `), _el$59 = _$createTextNode(` `);
461
+ _$insertNode(_el$56, _el$57);
462
+ _$insertNode(_el$56, _el$58);
463
+ _$insertNode(_el$56, _el$59);
464
+ _$insert(_el$56, () => t.id, _el$58);
465
+ _$insert(_el$56, () => td.modelStr, _el$58);
466
+ _$insert(_el$56, () => td.label, _el$59);
467
+ _$insert(_el$56, () => td.revSuffix, _el$59);
468
+ _$insert(_el$56, () => td.stepStr, _el$59);
469
+ _$insert(_el$56, () => td.elapsedStr, _el$59);
470
+ _$insert(_el$56, () => t.title, null);
471
+ _$effect((_$p) => _$setProp(_el$56, "style", st(td.themeKey), _$p));
472
+ return _el$56;
424
473
  })());
425
- const pv = t.model ? (t.model.split("/")[0] ?? "").split("-")[0] : "";
426
- const provCoach = pv ? s?.providers?.find((p) => p.id === pv || pv && p.id.startsWith(pv) || pv && pv.startsWith(p.id)) : s?.providers?.[0];
427
- const rawPct = provCoach?.fiveHour ?? s?.fiveHour ?? -1;
428
- const pct = rawPct < 0 ? 0 : rawPct;
429
- const pctLabel = rawPct < 0 ? "n/a" : `${rawPct}%`;
474
+ const {
475
+ pct,
476
+ label: pctLabel
477
+ } = taskQuotaPct(t, s);
430
478
  nodes.push((() => {
431
- var _el$58 = _$createElement("box"), _el$59 = _$createElement("text"), _el$61 = _$createElement("text"), _el$62 = _$createElement("text"), _el$63 = _$createElement("text"), _el$64 = _$createTextNode(` `);
432
- _$insertNode(_el$58, _el$59);
433
- _$insertNode(_el$58, _el$61);
434
- _$insertNode(_el$58, _el$62);
435
- _$insertNode(_el$58, _el$63);
436
- _$setProp(_el$58, "flexDirection", "row");
437
- _$insertNode(_el$59, _$createTextNode(` 5h `));
438
- _$insert(_el$61, () => barFill(pct));
439
- _$insert(_el$62, () => barEmpty(pct));
440
- _$insertNode(_el$63, _el$64);
441
- _$insert(_el$63, pctLabel, null);
479
+ var _el$60 = _$createElement("box"), _el$61 = _$createElement("text"), _el$63 = _$createElement("text"), _el$64 = _$createElement("text"), _el$65 = _$createElement("text"), _el$66 = _$createTextNode(` `);
480
+ _$insertNode(_el$60, _el$61);
481
+ _$insertNode(_el$60, _el$63);
482
+ _$insertNode(_el$60, _el$64);
483
+ _$insertNode(_el$60, _el$65);
484
+ _$setProp(_el$60, "flexDirection", "row");
485
+ _$insertNode(_el$61, _$createTextNode(` 5h `));
486
+ _$insert(_el$63, () => barFill(pct));
487
+ _$insert(_el$64, () => barEmpty(pct));
488
+ _$insertNode(_el$65, _el$66);
489
+ _$insert(_el$65, pctLabel, null);
442
490
  _$effect((_p$) => {
443
491
  var _v$11 = st("text"), _v$12 = st("text");
444
- _v$11 !== _p$.e && (_p$.e = _$setProp(_el$61, "style", _v$11, _p$.e));
445
- _v$12 !== _p$.t && (_p$.t = _$setProp(_el$62, "style", _v$12, _p$.t));
492
+ _v$11 !== _p$.e && (_p$.e = _$setProp(_el$63, "style", _v$11, _p$.e));
493
+ _v$12 !== _p$.t && (_p$.t = _$setProp(_el$64, "style", _v$12, _p$.t));
446
494
  return _p$;
447
495
  }, {
448
496
  e: void 0,
449
497
  t: void 0
450
498
  });
451
- return _el$58;
499
+ return _el$60;
452
500
  })());
453
501
  }
454
502
  }
455
503
  }
456
504
  return (() => {
457
- var _el$65 = _$createElement("box");
458
- _$setProp(_el$65, "flexDirection", "column");
459
- _$insert(_el$65, nodes);
460
- return _el$65;
505
+ var _el$67 = _$createElement("box");
506
+ _$setProp(_el$67, "flexDirection", "column");
507
+ _$insert(_el$67, nodes);
508
+ return _el$67;
461
509
  })();
462
510
  };
463
511
  tlog("registering slots");
@@ -472,9 +520,9 @@ function initializeTui(api, disposeRoot) {
472
520
  } catch (e) {
473
521
  tlog(`sidebar_footer err: ${String(e)}`);
474
522
  result = (() => {
475
- var _el$66 = _$createElement("text");
476
- _$insertNode(_el$66, _$createTextNode(`usage-coach`));
477
- return _el$66;
523
+ var _el$68 = _$createElement("text");
524
+ _$insertNode(_el$68, _$createTextNode(`usage-coach`));
525
+ return _el$68;
478
526
  })();
479
527
  }
480
528
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.8.5",
3
+ "version": "0.9.1",
4
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",
@@ -20,6 +20,9 @@
20
20
  "typecheck": "tsc --noEmit",
21
21
  "lint": "eslint .",
22
22
  "lint:fix": "eslint . --fix",
23
+ "test": "node --import tsx --test test/**/*.test.ts",
24
+ "test:coverage": "c8 --reporter=text --reporter=lcov --reporter=json-summary node --import tsx --test test/**/*.test.ts",
25
+ "coverage:badge": "node scripts/coverage-badge.mjs",
23
26
  "prepack": "tsup"
24
27
  },
25
28
  "files": [
@@ -50,12 +53,15 @@
50
53
  "@opencode-ai/plugin": "*",
51
54
  "@opentui/core": ">=0.4.0",
52
55
  "@opentui/solid": ">=0.4.0",
56
+ "@types/node": "^26.1.1",
57
+ "c8": "^11.0.0",
53
58
  "esbuild-plugin-solid": "^0.6.0",
54
59
  "eslint": "^10.6.0",
55
60
  "eslint-plugin-solid": "^0.14.5",
56
61
  "globals": "^17.7.0",
57
62
  "solid-js": "^1.9",
58
63
  "tsup": "^8.5",
64
+ "tsx": "^4.23.0",
59
65
  "typescript": "^5",
60
66
  "typescript-eslint": "^8.63.0"
61
67
  }