opencode-usage-coach 0.6.2 → 0.7.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.
Files changed (4) hide show
  1. package/README.md +0 -1
  2. package/dist/index.js +341 -199
  3. package/dist/tui.js +194 -150
  4. package/package.json +2 -8
package/README.md CHANGED
@@ -155,7 +155,6 @@ Place in the **work directory**. Each role runs on its model, so per-model quota
155
155
  | `UC_HARNESS_AGENT` | `Usage-Coach-Harness` | comma-separated agent modes allowed to use harness tools + receive quota coaching (case-insensitive; must match the agent id, e.g. `usage-coach-harness` from `agents/usage-coach-harness.md`) |
156
156
  | `UC_WORM_MAX_AGE_DAYS` | 180 | domain DB worm (GC): drop nodes not accessed in N days (~6 months) |
157
157
  | `UC_WORM_MAX_NODES` | 100000 | domain DB worm (GC): cap node count, evict oldest-accessed beyond this |
158
- | `UC_DOMAIN_TIMEOUT_MS` | 5000 | domain DB query timeout (ms) — a slow/hung query resolves to a safe fallback instead of blocking the plugin |
159
158
 
160
159
  ## Agent-mode scoping
161
160
 
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import { mkdirSync, writeFileSync, appendFileSync, readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
2
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, appendFileSync as appendFileSync2, readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
3
3
  import { spawn } from "child_process";
4
4
  import { createHash } from "crypto";
5
5
  import { homedir } from "os";
@@ -7,193 +7,125 @@ import { join as join2, resolve, dirname } from "path";
7
7
  import { tool } from "@opencode-ai/plugin";
8
8
 
9
9
  // src/domain.ts
10
- import { Database, Connection } from "@ladybugdb/core";
10
+ import { mkdirSync, appendFileSync, readFileSync, existsSync, writeFileSync } from "fs";
11
11
  import { join } from "path";
12
- import { existsSync, readFileSync } from "fs";
13
- var QUERY_TIMEOUT_MS = (() => {
14
- try {
15
- const v = Number(process.env.UC_DOMAIN_TIMEOUT_MS);
16
- return Number.isFinite(v) && v > 0 ? v : 5e3;
17
- } catch {
18
- return 5e3;
19
- }
20
- })();
21
12
  var BASE_DIR = "";
22
- var DB_PATH = "";
23
- var db = null;
24
- var conn = null;
25
- var schemaReady = false;
26
- var migrated = false;
27
13
  function initDomain(stateDir) {
28
14
  BASE_DIR = stateDir;
29
- DB_PATH = join(BASE_DIR, "domain.ladybug");
30
- db = null;
31
- conn = null;
32
- schemaReady = false;
33
- migrated = false;
34
15
  }
35
- function uid(prefix) {
36
- return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
16
+ var nodesFile = () => join(BASE_DIR, "nodes.ndjson");
17
+ var edgesFile = () => join(BASE_DIR, "edges.ndjson");
18
+ function readNdjson(path) {
19
+ try {
20
+ if (!existsSync(path)) return [];
21
+ return readFileSync(path, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
22
+ } catch {
23
+ return [];
24
+ }
37
25
  }
38
- function esc(s) {
39
- return String(s ?? "").replace(/\\/g, "\\\\").replace(/'/g, "\\'");
26
+ function readNodes() {
27
+ return readNdjson(nodesFile());
40
28
  }
41
- function timed(op, fallback) {
42
- let timer;
43
- const timeout = new Promise((resolve2) => {
44
- timer = setTimeout(() => resolve2(fallback), QUERY_TIMEOUT_MS);
45
- });
46
- return Promise.race([op().catch(() => fallback), timeout]).finally(() => {
47
- if (timer) clearTimeout(timer);
48
- });
29
+ function readEdges() {
30
+ return readNdjson(edgesFile());
31
+ }
32
+ function uid(prefix) {
33
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
49
34
  }
50
- function parseNode(r) {
51
- let props = {};
35
+ function addDomainNode(node) {
36
+ const full = { ...node, id: uid("node"), ts: (/* @__PURE__ */ new Date()).toISOString() };
52
37
  try {
53
- if (r.props) props = JSON.parse(r.props);
38
+ mkdirSync(BASE_DIR, { recursive: true });
39
+ appendFileSync(nodesFile(), JSON.stringify(full) + "\n");
54
40
  } catch {
55
41
  }
56
- return {
57
- id: r.id,
58
- type: r.type,
59
- name: r.name,
60
- props,
61
- source: r.source ?? "",
62
- confidence: r.confidence ?? 0,
63
- ts: r.ts,
64
- lastAccessed: r.lastAccessed || void 0,
65
- accessCount: r.accessCount ?? void 0
66
- };
42
+ return full.id;
67
43
  }
68
- var NODE_COLS = "n.id AS id, n.type AS type, n.name AS name, n.props AS props, n.source AS source, n.confidence AS confidence, n.ts AS ts, n.lastAccessed AS lastAccessed, n.accessCount AS accessCount";
69
- async function ready() {
70
- if (!conn) {
71
- db = new Database(DB_PATH);
72
- conn = new Connection(db);
73
- }
74
- if (!schemaReady) {
75
- try {
76
- await conn.query("CREATE NODE TABLE DomainNode(id STRING, type STRING, name STRING, props STRING, source STRING, confidence DOUBLE, lastAccessed STRING, accessCount INT64 DEFAULT 0, ts STRING, PRIMARY KEY(id))");
77
- } catch {
78
- }
79
- try {
80
- await conn.query("CREATE REL TABLE Related(FROM DomainNode TO DomainNode, rel STRING, note STRING, ts STRING)");
81
- } catch {
82
- }
83
- schemaReady = true;
84
- }
85
- if (!migrated) {
86
- migrated = true;
87
- await migrateFromNdjson();
88
- }
89
- return conn;
90
- }
91
- async function migrateFromNdjson() {
92
- if (!conn) return;
93
- const nf = join(BASE_DIR, "nodes.ndjson");
94
- const ef = join(BASE_DIR, "edges.ndjson");
95
- if (!existsSync(nf)) return;
44
+ function writeNodes(nodes) {
96
45
  try {
97
- const existing = await conn.query("MATCH (n:DomainNode) RETURN count(n) AS c");
98
- const cnt = (await existing.getAll())[0]?.c ?? 0;
99
- if (cnt > 0) return;
100
- const readNdjson = (p) => {
101
- try {
102
- return readFileSync(p, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
103
- } catch {
104
- return [];
105
- }
106
- };
107
- for (const n of readNdjson(nf)) {
108
- await conn.query(`CREATE (n:DomainNode {id:'${esc(n.id)}',type:'${esc(n.type)}',name:'${esc(n.name)}',props:'${esc(JSON.stringify(n.props ?? {}))}',source:'${esc(n.source ?? "")}',confidence:${Number(n.confidence ?? 0)},ts:'${esc(n.ts ?? "")}',lastAccessed:'${esc(n.lastAccessed ?? "")}',accessCount:${Number(n.accessCount ?? 0)}})`);
109
- }
110
- if (existsSync(ef)) {
111
- for (const e of readNdjson(ef)) {
112
- await conn.query(`MATCH (a:DomainNode {id:'${esc(e.from)}'}), (b:DomainNode {id:'${esc(e.to)}'}) CREATE (a)-[:Related {rel:'${esc(e.rel)}',note:'${esc(e.note ?? "")}',ts:'${esc(e.ts ?? "")}'}]->(b)`);
113
- }
114
- }
46
+ mkdirSync(BASE_DIR, { recursive: true });
47
+ const lines = nodes.map((n) => JSON.stringify(n));
48
+ writeFileSync(nodesFile(), lines.length ? lines.join("\n") + "\n" : "");
115
49
  } catch {
116
50
  }
117
51
  }
118
- async function _readNodes() {
119
- const c = await ready();
120
- const r = await c.query(`MATCH (n:DomainNode) RETURN ${NODE_COLS}`);
121
- return (await r.getAll()).map(parseNode);
122
- }
123
- async function _addDomainNode(node) {
124
- const c = await ready();
125
- const id = uid("node");
126
- const now = (/* @__PURE__ */ new Date()).toISOString();
127
- await c.query(`CREATE (n:DomainNode {id:'${esc(id)}',type:'${esc(node.type)}',name:'${esc(node.name)}',props:'${esc(JSON.stringify(node.props ?? {}))}',source:'${esc(node.source ?? "")}',confidence:${Number(node.confidence ?? 0)},ts:'${esc(now)}',lastAccessed:'',accessCount:0})`);
128
- return id;
129
- }
130
52
  function queryDomain(keywords) {
131
- return timed(() => _queryDomain(keywords), { nodes: [], edges: [] });
132
- }
133
- async function _queryDomain(keywords) {
134
- const lc = keywords.map((k) => k.toLowerCase()).filter(Boolean);
135
- if (lc.length === 0) return { nodes: [], edges: [] };
136
- const c = await ready();
137
- const conds = lc.map((kw) => `(lower(n.name) CONTAINS '${esc(kw)}' OR lower(n.props) CONTAINS '${esc(kw)}')`).join(" OR ");
138
- const r = await c.query(`MATCH (n:DomainNode) WHERE ${conds} RETURN ${NODE_COLS}`);
139
- const matched = (await r.getAll()).map(parseNode);
140
- if (matched.length) await _touchNodes(new Set(matched.map((n) => n.id)));
141
- let edges = [];
142
- if (matched.length) {
143
- const ids = matched.map((n) => `'${esc(n.id)}'`).join(",");
144
- const er = await c.query(`MATCH (a:DomainNode)-[r:Related]->(b:DomainNode) WHERE a.id IN [${ids}] OR b.id IN [${ids}] RETURN a.id AS from, b.id AS to, r.rel AS rel, r.note AS note, r.ts AS ts`);
145
- edges = await er.getAll();
146
- }
53
+ const lc = keywords.map((k) => k.toLowerCase());
54
+ const nodes = readNodes();
55
+ const matched = nodes.filter((n) => {
56
+ const hay = (n.name + " " + JSON.stringify(n.props)).toLowerCase();
57
+ return lc.some((k) => k && hay.includes(k));
58
+ });
59
+ if (matched.length) touchNodes(new Set(matched.map((n) => n.id)));
60
+ const ids = new Set(matched.map((n) => n.id));
61
+ const edges = readEdges().filter((e) => ids.has(e.from) || ids.has(e.to));
147
62
  return { nodes: matched, edges };
148
63
  }
149
- async function _touchNodes(ids) {
64
+ function touchNodes(ids) {
150
65
  if (ids.size === 0) return;
151
- const c = await ready();
152
- const now = (/* @__PURE__ */ new Date()).toISOString();
153
- const idList = [...ids].map((id) => `'${esc(id)}'`).join(",");
154
- await c.query(`MATCH (n:DomainNode) WHERE n.id IN [${idList}] SET n.lastAccessed = '${esc(now)}', n.accessCount = COALESCE(n.accessCount, 0) + 1`);
155
- }
156
- function evictStale(maxAgeDays = 30, maxNodes = 1e5) {
157
- return timed(() => _evictStale(maxAgeDays, maxNodes), { removed: 0, kept: 0 });
158
- }
159
- async function _evictStale(maxAgeDays, maxNodes) {
160
- const c = await ready();
161
- const all = await _readNodes();
162
- if (all.length === 0) return { removed: 0, kept: 0 };
163
- const now = Date.now();
164
- const ageMs = maxAgeDays * 864e5;
165
- const lastTs = (n) => {
166
- const la = n.lastAccessed ? new Date(n.lastAccessed).getTime() : 0;
167
- return la || new Date(n.ts).getTime();
168
- };
169
- let kept = all.filter((n) => now - lastTs(n) < ageMs);
170
- if (kept.length > maxNodes) {
171
- kept.sort((a, b) => lastTs(b) - lastTs(a));
172
- kept = kept.slice(0, maxNodes);
66
+ try {
67
+ const nodes = readNodes();
68
+ let changed = false;
69
+ const now = (/* @__PURE__ */ new Date()).toISOString();
70
+ for (const n of nodes) {
71
+ if (ids.has(n.id)) {
72
+ n.lastAccessed = now;
73
+ n.accessCount = (n.accessCount ?? 0) + 1;
74
+ changed = true;
75
+ }
76
+ }
77
+ if (changed) writeNodes(nodes);
78
+ } catch {
173
79
  }
174
- const keepIds = new Set(kept.map((n) => n.id));
175
- const toRemove = all.filter((n) => !keepIds.has(n.id));
176
- for (const n of toRemove) {
177
- await c.query(`MATCH (n:DomainNode {id:'${esc(n.id)}'}) DETACH DELETE n`);
80
+ }
81
+ function evictStale(maxAgeDays = 30, maxNodes = 1e3) {
82
+ try {
83
+ const nodes = readNodes();
84
+ if (nodes.length === 0) return { removed: 0, kept: 0 };
85
+ const now = Date.now();
86
+ const ageMs = maxAgeDays * 864e5;
87
+ const lastTs = (n) => new Date(n.lastAccessed ?? n.ts).getTime();
88
+ let kept = nodes.filter((n) => now - lastTs(n) < ageMs);
89
+ if (kept.length > maxNodes) {
90
+ kept.sort((a, b) => lastTs(b) - lastTs(a));
91
+ kept = kept.slice(0, maxNodes);
92
+ }
93
+ const removed = nodes.length - kept.length;
94
+ if (removed > 0) writeNodes(kept);
95
+ return { removed, kept: kept.length };
96
+ } catch {
97
+ return { removed: 0, kept: 0 };
178
98
  }
179
- return { removed: toRemove.length, kept: kept.length };
180
99
  }
181
100
  function saveInvestigationResult(keywords, result, source) {
182
- return timed(() => _saveInvestigationResult(keywords, result, source), "");
183
- }
184
- async function _saveInvestigationResult(keywords, result, source) {
185
- return _addDomainNode({
186
- type: "fact",
187
- name: keywords.join(" "),
188
- props: { result },
189
- source: source || "investigation",
190
- confidence: 0.7
191
- });
101
+ try {
102
+ return addDomainNode({
103
+ type: "fact",
104
+ name: keywords.join(" "),
105
+ props: { result },
106
+ source: source || "investigation",
107
+ confidence: 0.7
108
+ });
109
+ } catch {
110
+ return "";
111
+ }
192
112
  }
193
113
 
194
114
  // src/index.ts
195
115
  var PLUGIN_NAME = "opencode-usage-coach";
196
116
  var TTL_MS = Number(process.env.UC_TTL_MS ?? 6e4);
117
+ var DEFAULT_MAX_STEPS = Number(process.env.UC_MAX_STEPS ?? 30) || 30;
118
+ var WATCHDOG_POLL_MS = Math.max(1e3, Number(process.env.UC_WATCHDOG_POLL_MS ?? 3e3) || 3e3);
119
+ var PIPE_LOG = join2(homedir(), ".cache", "opencode-usage-coach", "pipeline.log");
120
+ function pipeLog(msg) {
121
+ try {
122
+ mkdirSync2(dirname(PIPE_LOG), { recursive: true });
123
+ appendFileSync2(PIPE_LOG, `[SERVER] ${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
124
+ `);
125
+ } catch {
126
+ }
127
+ }
128
+ pipeLog(`MODULE LOADED | node=${process.version} | pid=${process.pid}`);
197
129
  var STATE_DIR = join2(homedir(), ".cache", "opencode-usage-coach");
198
130
  var STATE_FILE = join2(STATE_DIR, "state.json");
199
131
  var LOG_FILE = join2(STATE_DIR, "coach.log");
@@ -210,15 +142,15 @@ function setStateDir(dir) {
210
142
  var NOOP_HOOKS = {};
211
143
  function log(msg) {
212
144
  try {
213
- appendFileSync(LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
145
+ appendFileSync2(LOG_FILE, `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
214
146
  `);
215
147
  } catch {
216
148
  }
217
149
  }
218
150
  function writeState(c) {
219
151
  try {
220
- mkdirSync(STATE_DIR, { recursive: true });
221
- writeFileSync(STATE_FILE, JSON.stringify({ ...c, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
152
+ mkdirSync2(STATE_DIR, { recursive: true });
153
+ writeFileSync2(STATE_FILE, JSON.stringify({ ...c, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
222
154
  } catch {
223
155
  }
224
156
  }
@@ -269,12 +201,46 @@ function readHarness(sessionID) {
269
201
  function writeHarness(sessionID, h) {
270
202
  try {
271
203
  const f = harnessFile(sessionID);
272
- mkdirSync(dirname(f), { recursive: true });
204
+ mkdirSync2(dirname(f), { recursive: true });
273
205
  h.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
274
- writeFileSync(f, JSON.stringify(h, null, 2));
206
+ writeFileSync2(f, JSON.stringify(h, null, 2));
207
+ } catch {
208
+ }
209
+ }
210
+ function updateSubSession(sessionID, taskId, fields) {
211
+ try {
212
+ const h = readHarness(sessionID);
213
+ if (!h) return;
214
+ const t = h.tasks.find((x) => x.id === taskId);
215
+ if (!t) return;
216
+ Object.assign(t, fields);
217
+ writeHarness(sessionID, h);
275
218
  } catch {
276
219
  }
277
220
  }
221
+ function clearSubSession(sessionID, taskId) {
222
+ try {
223
+ const h = readHarness(sessionID);
224
+ if (!h) return;
225
+ const t = h.tasks.find((x) => x.id === taskId);
226
+ if (!t) return;
227
+ t.subSessionId = void 0;
228
+ t.subStep = void 0;
229
+ t.lastActivity = void 0;
230
+ t.subElapsed = void 0;
231
+ writeHarness(sessionID, h);
232
+ } catch {
233
+ }
234
+ }
235
+ function findActiveTaskId(sessionID, status) {
236
+ try {
237
+ const h = readHarness(sessionID);
238
+ if (!h) return void 0;
239
+ return h.tasks.find((x) => x.status === status)?.id;
240
+ } catch {
241
+ return void 0;
242
+ }
243
+ }
278
244
  function readHarnessCfg(dir) {
279
245
  const tryRead = (p) => {
280
246
  try {
@@ -288,8 +254,16 @@ function readHarnessCfg(dir) {
288
254
  ...tryRead(join2(dir, "harness.config.json"))
289
255
  };
290
256
  }
291
- async function runModel(client, model, prompt, directory) {
257
+ async function runModel(client, model, prompt, directory, track, maxSteps = DEFAULT_MAX_STEPS) {
292
258
  const t0 = Date.now();
259
+ const subStart = Date.now();
260
+ let poller = null;
261
+ let subId = null;
262
+ let timedOut = false;
263
+ let signalTimeout;
264
+ const timeoutSignal = new Promise((resolve2) => {
265
+ signalTimeout = resolve2;
266
+ });
293
267
  try {
294
268
  const slash = model.indexOf("/");
295
269
  const providerID = slash >= 0 ? model.slice(0, slash) : model;
@@ -297,12 +271,75 @@ async function runModel(client, model, prompt, directory) {
297
271
  const s = await client.session.create({ body: { title: "uc-harness-sub" }, query: { directory } });
298
272
  const id = s?.data?.info?.id ?? s?.data?.id ?? s?.id;
299
273
  if (!id) return `ERROR: session.create returned no id (response: ${JSON.stringify(s?.data ?? s).slice(0, 200)})`;
300
- log(`runModel(${model}): session ${id} created, sending prompt (${prompt.length} chars)`);
301
- const resp = await client.session.prompt({
274
+ subId = id;
275
+ log(`runModel(${model}): session ${id} created, sending prompt (${prompt.length} chars), max_steps=${maxSteps}`);
276
+ poller = setInterval(async () => {
277
+ if (timedOut) return;
278
+ try {
279
+ let step = 0;
280
+ let lastTs = (/* @__PURE__ */ new Date()).toISOString();
281
+ try {
282
+ const msgs = await client.session.messages?.({ path: { id } });
283
+ const msgList = Array.isArray(msgs?.data) ? msgs.data : Array.isArray(msgs) ? msgs : [];
284
+ if (msgList.length) {
285
+ step = msgList.filter((m) => {
286
+ const role = m?.role ?? m?.info?.role;
287
+ return role === "assistant";
288
+ }).length;
289
+ const last = msgList[msgList.length - 1];
290
+ const ts = last?.ts ?? last?.info?.updatedAt ?? last?.info?.completedAt ?? last?.updatedAt;
291
+ if (ts) lastTs = String(ts);
292
+ }
293
+ } catch {
294
+ }
295
+ if (step > maxSteps) {
296
+ log(`runModel(${model}): STEP LIMIT exceeded (${step} > ${maxSteps}), aborting session ${id}`);
297
+ timedOut = true;
298
+ try {
299
+ await client.session.abort?.({ path: { id } });
300
+ } catch {
301
+ }
302
+ signalTimeout();
303
+ return;
304
+ }
305
+ if (track) {
306
+ const elapsed2 = Math.round((Date.now() - subStart) / 1e3);
307
+ updateSubSession(track.sessionID, track.taskId, {
308
+ subSessionId: id,
309
+ subStep: step,
310
+ lastActivity: lastTs,
311
+ subElapsed: elapsed2
312
+ });
313
+ }
314
+ } catch (e) {
315
+ log(`runModel poller err: ${String(e)}`);
316
+ }
317
+ }, WATCHDOG_POLL_MS);
318
+ const promptP = client.session.prompt({
302
319
  path: { id },
303
320
  body: { model: { providerID, modelID }, parts: [{ type: "text", text: prompt }] }
304
- });
321
+ }).then(
322
+ (r) => r,
323
+ () => null
324
+ // abort causes rejection -> return null (handled via timedOut flag)
325
+ );
326
+ const resp = await Promise.race([promptP, timeoutSignal.then(() => null)]);
305
327
  const elapsed = Math.round((Date.now() - t0) / 1e3);
328
+ if (timedOut) {
329
+ try {
330
+ const summary = await client.session.summarize?.({ path: { id } });
331
+ log(`runModel(${model}): TIMED OUT summary: ${JSON.stringify(summary?.data ?? summary).slice(0, 300)}`);
332
+ } catch {
333
+ }
334
+ try {
335
+ await client.session.delete?.({ path: { id } });
336
+ } catch {
337
+ }
338
+ subId = null;
339
+ log(`runModel(${model}): TIMED OUT after ${elapsed}s (${maxSteps} steps exceeded)`);
340
+ return `Task appears too large (exceeded ${maxSteps} steps). Consider splitting into smaller subtasks.
341
+ [usage-coach NEXT] split the original task into smaller subtasks (each should complete within ${maxSteps} steps), then re-run generate for each subtask.`;
342
+ }
306
343
  const parts = resp?.data?.parts ?? resp?.parts ?? [];
307
344
  const text = parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("");
308
345
  try {
@@ -314,12 +351,27 @@ async function runModel(client, model, prompt, directory) {
314
351
  await client.session.delete?.({ path: { id } });
315
352
  } catch {
316
353
  }
354
+ subId = null;
317
355
  log(`runModel(${model}): done ${elapsed}s, ${text.length} chars`);
318
356
  return text.trim() || `ERROR: no assistant text in prompt response after ${elapsed}s (parts: ${parts.length}, types: ${parts.map((p) => p?.type).join(",")})`;
319
357
  } catch (e) {
320
358
  const elapsed = Math.round((Date.now() - t0) / 1e3);
321
359
  log(`runModel err (${model}, ${elapsed}s): ${String(e)}`);
322
360
  return `ERROR: runModel exception after ${elapsed}s: ${String(e)}`;
361
+ } finally {
362
+ if (poller) clearInterval(poller);
363
+ if (track) {
364
+ try {
365
+ clearSubSession(track.sessionID, track.taskId);
366
+ } catch {
367
+ }
368
+ }
369
+ if (subId) {
370
+ try {
371
+ await client.session.delete?.({ path: { id: subId } });
372
+ } catch {
373
+ }
374
+ }
323
375
  }
324
376
  }
325
377
  var HARNESS_AGENTS = (process.env.UC_HARNESS_AGENT ?? "Usage-Coach-Harness").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
@@ -451,14 +503,44 @@ function coach(q, lighter) {
451
503
  return { decision: "GO", advice: `Comfortable \u2014 weekly ${wk}% \xB7 5h ${h5}% \xB7 monthly ${mo}%. proceed. 5h window ${h5R}.`, weekly: wk, monthly: mo, fiveHour: h5 };
452
504
  }
453
505
  var agentCache = /* @__PURE__ */ new Map();
506
+ var currentModel = "";
507
+ var currentProvider = "";
508
+ var currentAgent = "";
509
+ var modelChanged = false;
510
+ function isFreeModel(model, provider) {
511
+ if (!model && !provider) return false;
512
+ if (provider === "opencode") return true;
513
+ if (model.toLowerCase().includes("free")) return true;
514
+ return false;
515
+ }
516
+ function providerToCodexbar(provider) {
517
+ if (!provider) return "";
518
+ return provider.split("-")[0];
519
+ }
454
520
  async function resolveAgent(client, sessionID) {
455
521
  if (!sessionID) return "";
456
522
  const hit = agentCache.get(sessionID);
457
- if (hit && Date.now() - hit.ts < 6e4) return hit.agent;
523
+ if (hit && Date.now() - hit.ts < 6e4) {
524
+ currentModel = hit.model;
525
+ currentProvider = hit.provider;
526
+ return hit.agent;
527
+ }
458
528
  try {
459
529
  const s = await client.session.get({ path: { id: sessionID } });
460
- const agent = String(s?.data?.info?.agent ?? s?.data?.agent ?? s?.info?.agent ?? "");
461
- agentCache.set(sessionID, { agent, ts: Date.now() });
530
+ log(`resolveAgent raw session: ${JSON.stringify(s?.data?.info ?? s?.data ?? s?.info ?? s).slice(0, 500)}`);
531
+ const info = s?.data?.info ?? s?.data ?? s?.info ?? s;
532
+ const agent = String(info?.agent ?? "");
533
+ const rawModel = info?.model;
534
+ const model = typeof rawModel === "string" ? rawModel : rawModel?.id ?? rawModel?.modelID ?? rawModel?.name ?? "";
535
+ const rawProvider = info?.providerID ?? info?.provider ?? (typeof rawModel === "object" ? rawModel?.providerID ?? rawModel?.provider : "");
536
+ const provider = typeof rawProvider === "string" ? rawProvider : rawProvider?.id ?? "";
537
+ if (currentModel && model && currentModel !== model) {
538
+ log(`MODEL CHANGED: ${currentModel} \u2192 ${model} (provider: ${currentProvider} \u2192 ${provider})`);
539
+ modelChanged = true;
540
+ }
541
+ agentCache.set(sessionID, { agent, model, provider, ts: Date.now() });
542
+ currentModel = model;
543
+ currentProvider = provider;
462
544
  return agent;
463
545
  } catch (e) {
464
546
  log(`resolveAgent err: ${String(e)}`);
@@ -483,9 +565,19 @@ async function UsageCoachPlugin(input) {
483
565
  const refreshBackground = () => {
484
566
  try {
485
567
  if (refreshing) return;
486
- if (last && Date.now() - lastFetchedAt < TTL_MS) return;
568
+ if (last && !modelChanged && Date.now() - lastFetchedAt < TTL_MS) return;
487
569
  refreshing = true;
488
- fetchQuota(PROVIDER).then(async (q) => {
570
+ modelChanged = false;
571
+ if (isFreeModel(currentModel, currentProvider)) {
572
+ last = { decision: "GO", advice: `${currentModel || currentProvider || "free model"} \u2014 no quota limit.`, weekly: -1, monthly: -1, fiveHour: -1, model: currentModel, provider: currentProvider, isFree: true };
573
+ lastFetchedAt = Date.now();
574
+ writeState({ ...last, providers: [], model: currentModel, provider: currentProvider, isFree: true, agent: currentAgent, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
575
+ log(`FREE | model=${currentModel} provider=${currentProvider}`);
576
+ refreshing = false;
577
+ return;
578
+ }
579
+ const activeProvider = providerToCodexbar(currentProvider) || PROVIDER;
580
+ fetchQuota(activeProvider).then(async (q) => {
489
581
  try {
490
582
  last = coach(q, LIGHTER);
491
583
  lastFetchedAt = Date.now();
@@ -498,7 +590,7 @@ async function UsageCoachPlugin(input) {
498
590
  const p0 = providers[0];
499
591
  last = { ...last, weekly: p0.weekly, fiveHour: p0.fiveHour, monthly: p0.weekly >= 0 ? 0 : -1, advice: p0.advice, decision: p0.weekly >= STOP_WK ? "STOP" : p0.weekly >= THR_WK ? "THROTTLE" : "GO" };
500
592
  }
501
- writeState({ ...last, providers, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
593
+ writeState({ ...last, providers, model: currentModel, provider: currentProvider, isFree: false, agent: currentAgent, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
502
594
  log(`${last.decision} | weekly=${last.weekly}% 5h=${last.fiveHour}% | providers=${providers.length}`);
503
595
  } catch (e) {
504
596
  log(`refresh-in-then err: ${String(e)}`);
@@ -526,7 +618,7 @@ async function UsageCoachPlugin(input) {
526
618
  if (event.type === "session.created" || event.type === "session.idle") refreshBackground();
527
619
  if (event.type === "session.idle") {
528
620
  try {
529
- const r = await evictStale(WORM_MAX_AGE_DAYS, WORM_MAX_NODES);
621
+ const r = evictStale(WORM_MAX_AGE_DAYS, WORM_MAX_NODES);
530
622
  if (r.removed) log(`evictStale: removed ${r.removed}, kept ${r.kept} (maxAge=${WORM_MAX_AGE_DAYS}d, maxNodes=${WORM_MAX_NODES})`);
531
623
  } catch (e) {
532
624
  log(`evictStale err: ${String(e)}`);
@@ -536,13 +628,14 @@ async function UsageCoachPlugin(input) {
536
628
  log(`event err: ${String(e)}`);
537
629
  }
538
630
  },
539
- // ACT(1) hard gate harness tools are restricted to the configured harness
540
- // agent mode AND gated by quota STOP. General tools (read/edit/bash/grep/task)
541
- // are NEVER gated, in ANY mode — they don't consume model quota.
631
+ // ACT(1) model/agent detection on EVERY tool call (cached 60s, negligible overhead).
632
+ // Then hard-gate harness tools by agent mode + quota STOP.
542
633
  "tool.execute.before": async (_input) => {
634
+ const agent = await resolveAgent(input.client, _input.sessionID);
635
+ currentAgent = agent;
636
+ refreshBackground();
543
637
  const harnessTools = ["generate", "generate_batch", "grade", "investigate", "verify_diagnosis", "generalize", "harness_start", "task_update", "harness_done", "record_failure"];
544
638
  if (!harnessTools.includes(_input.tool)) return;
545
- const agent = await resolveAgent(input.client, _input.sessionID);
546
639
  if (!isHarnessAgent(agent)) {
547
640
  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.`);
548
641
  }
@@ -562,6 +655,7 @@ async function UsageCoachPlugin(input) {
562
655
  try {
563
656
  if (_input.sessionID) {
564
657
  const agent = await resolveAgent(input.client, _input.sessionID);
658
+ refreshBackground();
565
659
  if (!isHarnessAgent(agent)) return;
566
660
  }
567
661
  const c = current();
@@ -577,12 +671,14 @@ async function UsageCoachPlugin(input) {
577
671
  // Custom tools for the harness agent mode — report status to the panel.
578
672
  tool: {
579
673
  harness_start: tool({
580
- description: "Start the harness: register the total task count on the panel. Call once when the harness loop begins.",
674
+ description: "Start the harness: register the total task count on the panel. Call once when the harness loop begins. IMPORTANT: each generate/generate_batch sub-session is step-limited (default 30). If any task seems too large, split it into smaller subtasks BEFORE starting \u2014 oversized tasks will timeout.",
581
675
  args: { name: tool.schema.string(), total: tool.schema.number() },
582
676
  async execute(args, ctx) {
583
677
  writeHarness(ctx.sessionID, { name: args.name, total: args.total, current: 0, tasks: [], usage: {}, active: true, startedAt: (/* @__PURE__ */ new Date()).toISOString() });
584
678
  return `Harness '${args.name}' started (${args.total} tasks).
585
679
 
680
+ 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.
681
+
586
682
  DETERMINISTIC LOOP \u2014 first classify the tasks:
587
683
  INDEPENDENT = task B does NOT need task A's output -> use PATH A (parallel, faster)
588
684
  DEPENDENT = task B needs task A's output -> use PATH B (sequential)
@@ -651,8 +747,8 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
651
747
  async execute(args, _ctx) {
652
748
  const rec = { ts: (/* @__PURE__ */ new Date()).toISOString(), task: args.task, prompt: args.prompt, gradeResult: args.gradeResult, model: args.model, revisions: args.revisions };
653
749
  try {
654
- mkdirSync(STATE_DIR, { recursive: true });
655
- appendFileSync(failuresFile(), JSON.stringify(rec) + "\n");
750
+ mkdirSync2(STATE_DIR, { recursive: true });
751
+ appendFileSync2(failuresFile(), JSON.stringify(rec) + "\n");
656
752
  } catch (e) {
657
753
  log(`record_failure err: ${String(e)}`);
658
754
  }
@@ -676,7 +772,7 @@ Then: harness_done(). Follow the [usage-coach NEXT] directive each tool returns.
676
772
  try {
677
773
  keywords = extractKeywords(`${args.task} ${args.gradeResult}`);
678
774
  if (keywords.length) {
679
- const { nodes, edges } = await queryDomain(keywords);
775
+ const { nodes, edges } = queryDomain(keywords);
680
776
  if (nodes && nodes.length || edges && edges.length) {
681
777
  domainEmpty = false;
682
778
  domainPrefix = `Known facts from domain DB: ${JSON.stringify({ nodes, edges })}. Use these if relevant.
@@ -697,10 +793,17 @@ Output a structured root cause:
697
793
  category: (one of: constraint-violation, missing-context, tool-misuse, model-limitation, other)
698
794
  explanation: <why it failed>
699
795
  evidence: <file/line or specific quote>`;
700
- const out = await runModel(input.client, cfg.generator, domainPrefix + rcaPrompt, ctx.directory);
796
+ const invTaskId = findActiveTaskId(ctx.sessionID, "revising");
797
+ const out = await runModel(
798
+ input.client,
799
+ cfg.generator,
800
+ domainPrefix + rcaPrompt,
801
+ ctx.directory,
802
+ invTaskId ? { sessionID: ctx.sessionID, taskId: invTaskId } : void 0
803
+ );
701
804
  if (domainEmpty && keywords.length) {
702
805
  try {
703
- await saveInvestigationResult(keywords, out, "investigate");
806
+ saveInvestigationResult(keywords, out, "investigate");
704
807
  } catch (e) {
705
808
  log(`investigate save err: ${String(e)}`);
706
809
  }
@@ -725,7 +828,14 @@ Grade feedback: ${args.gradeResult}
725
828
  Diagnosis: ${args.diagnosis}
726
829
  Is the diagnosis CORRECT and ACTIONABLE (leads to a useful rule)?
727
830
  Output PASS (the diagnosis is right) or FAIL (re-investigate needed), then reason.`;
728
- const out = await runModel(input.client, model, verifyPrompt, ctx.directory);
831
+ const verTaskId = findActiveTaskId(ctx.sessionID, "revising");
832
+ const out = await runModel(
833
+ input.client,
834
+ model,
835
+ verifyPrompt,
836
+ ctx.directory,
837
+ verTaskId ? { sessionID: ctx.sessionID, taskId: verTaskId } : void 0
838
+ );
729
839
  let verdict = "FAIL";
730
840
  if (!out.startsWith("ERROR:")) {
731
841
  const f = (out.split("\n").find((l) => l.trim()) ?? "").trim();
@@ -752,12 +862,19 @@ Diagnosis: ${args.diagnosis}
752
862
  Failed task: ${args.task}
753
863
  Output a single rule in the form: 'For <task-type> tasks, always <check/do X> because <reason>.'
754
864
  Keep it concrete and actionable.`;
755
- const out = await runModel(input.client, cfg.generator, genPrompt, ctx.directory);
865
+ const genRuleTaskId = findActiveTaskId(ctx.sessionID, "revising");
866
+ const out = await runModel(
867
+ input.client,
868
+ cfg.generator,
869
+ genPrompt,
870
+ ctx.directory,
871
+ genRuleTaskId ? { sessionID: ctx.sessionID, taskId: genRuleTaskId } : void 0
872
+ );
756
873
  const rule = out;
757
874
  try {
758
875
  const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
759
- mkdirSync(STATE_DIR, { recursive: true });
760
- appendFileSync(rulesFile(), `## Rule (${date})
876
+ mkdirSync2(STATE_DIR, { recursive: true });
877
+ appendFileSync2(rulesFile(), `## Rule (${date})
761
878
  ${rule}
762
879
  Origin: ${args.task}
763
880
 
@@ -772,8 +889,8 @@ Origin: ${args.task}
772
889
  // Per-role model execution (config-driven, quota-aware, same server, no deadlock).
773
890
  // P1: quota decision drives model selection + concurrency.
774
891
  generate: tool({
775
- description: "Run the GENERATOR model on a prompt. Quota-aware: on THROTTLE, auto-switches to lighterModel if configured. Returns the model's text response.",
776
- args: { prompt: tool.schema.string() },
892
+ description: "Run the GENERATOR model on a prompt. Quota-aware: on THROTTLE, auto-switches to lighterModel if configured. Returns the model's text response. Step-limited: aborts after max_steps (default 30) to prevent runaway tasks.",
893
+ 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.") },
777
894
  async execute(args, ctx) {
778
895
  const cfg = readHarnessCfg(ctx.directory);
779
896
  if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
@@ -796,7 +913,7 @@ ${rules}
796
913
  try {
797
914
  keywords = extractKeywords(args.prompt);
798
915
  if (keywords.length) {
799
- const { nodes, edges } = await queryDomain(keywords);
916
+ const { nodes, edges } = queryDomain(keywords);
800
917
  if (nodes && nodes.length || edges && edges.length) {
801
918
  domainEmpty = false;
802
919
  prefix = `Known facts from domain DB: ${JSON.stringify({ nodes, edges })}. Use these if relevant.
@@ -809,22 +926,33 @@ ${rules}
809
926
  } catch (e) {
810
927
  log(`generate domain query err: ${String(e)}`);
811
928
  }
812
- const out = await runModel(input.client, model, prefix + args.prompt, ctx.directory);
813
- if (domainEmpty && keywords.length) {
929
+ const genTaskId = findActiveTaskId(ctx.sessionID, "generating");
930
+ const maxSteps = args.max_steps ?? DEFAULT_MAX_STEPS;
931
+ const out = await runModel(
932
+ input.client,
933
+ model,
934
+ prefix + args.prompt,
935
+ ctx.directory,
936
+ genTaskId ? { sessionID: ctx.sessionID, taskId: genTaskId } : void 0,
937
+ maxSteps
938
+ );
939
+ const isTimeoutOrError = out.startsWith("Task appears too large") || out.startsWith("ERROR:");
940
+ if (domainEmpty && keywords.length && !isTimeoutOrError) {
814
941
  try {
815
- await saveInvestigationResult(keywords, out, "generate");
942
+ saveInvestigationResult(keywords, out, "generate");
816
943
  } catch (e) {
817
944
  log(`generate save err: ${String(e)}`);
818
945
  }
819
946
  }
947
+ if (out.startsWith("Task appears too large")) return out;
820
948
  return out + (throttle ? `
821
949
  [usage-coach] quota THROTTLE \u2014 used lighter model ${cfg.lighterModel}` : "") + `
822
950
  [usage-coach NEXT] call task_update(i, title, "grading"), then grade to evaluate this work.`;
823
951
  }
824
952
  }),
825
953
  generate_batch: tool({
826
- description: "Run the GENERATOR model on MULTIPLE tasks. Quota-aware: GO = full parallel; THROTTLE = lighter model + concurrency capped at 2; STOP = refused. Use for INDEPENDENT tasks.",
827
- args: { tasks: tool.schema.array(tool.schema.object({ id: tool.schema.number(), prompt: tool.schema.string() })) },
954
+ description: "Run the GENERATOR model on MULTIPLE tasks. Quota-aware: GO = full parallel; THROTTLE = lighter model + concurrency capped at 2; STOP = refused. Use for INDEPENDENT tasks. Step-limited: each sub-session aborts after max_steps (default 30).",
955
+ 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).") },
828
956
  async execute(args, ctx) {
829
957
  const cfg = readHarnessCfg(ctx.directory);
830
958
  if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
@@ -841,7 +969,14 @@ ${rules}
841
969
  for (let i = 0; i < args.tasks.length; i += limit) {
842
970
  const batch = args.tasks.slice(i, i + limit);
843
971
  const out = await Promise.all(batch.map(async (t) => {
844
- const r = await runModel(input.client, model, t.prompt, ctx.directory);
972
+ const r = await runModel(
973
+ input.client,
974
+ model,
975
+ t.prompt,
976
+ ctx.directory,
977
+ { sessionID: ctx.sessionID, taskId: t.id },
978
+ args.max_steps ?? DEFAULT_MAX_STEPS
979
+ );
845
980
  return `[task ${t.id}] ${r}`;
846
981
  }));
847
982
  results.push(...out);
@@ -858,7 +993,14 @@ ${rules}
858
993
  const cfg = readHarnessCfg(ctx.directory);
859
994
  const model = cfg.grader ?? cfg.generator;
860
995
  if (!model) return "FAIL\n(ERROR: no grader/generator model configured.)\n[usage-coach NEXT] configure grader in harness.config.json, then retry grade.";
861
- const out = await runModel(input.client, model, args.prompt, ctx.directory);
996
+ const gradeTaskId = findActiveTaskId(ctx.sessionID, "grading");
997
+ const out = await runModel(
998
+ input.client,
999
+ model,
1000
+ args.prompt,
1001
+ ctx.directory,
1002
+ gradeTaskId ? { sessionID: ctx.sessionID, taskId: gradeTaskId } : void 0
1003
+ );
862
1004
  let verdict = "FAIL";
863
1005
  if (!out.startsWith("ERROR:")) {
864
1006
  const f = (out.split("\n").find((l) => l.trim()) ?? "").trim();
package/dist/tui.js CHANGED
@@ -202,173 +202,217 @@ function initializeTui(api, disposeRoot) {
202
202
  h = null;
203
203
  }
204
204
  const nodes = [];
205
+ const HARNESS_AGENTS = ["usage-coach-harness"];
206
+ if (s && s.agent && !HARNESS_AGENTS.includes(s.agent)) {
207
+ return _$createElement("box");
208
+ }
205
209
  if (s) {
206
210
  const dKey = s.decision === "GO" ? "success" : s.decision === "THROTTLE" ? "warning" : "error";
207
- nodes.push((() => {
208
- var _el$4 = _$createElement("text"), _el$5 = _$createTextNode(`usage-coach [`), _el$6 = _$createTextNode(`]`);
209
- _$insertNode(_el$4, _el$5);
210
- _$insertNode(_el$4, _el$6);
211
- _$insert(_el$4, () => TAG[s.decision], _el$6);
212
- _$effect((_$p) => _$setProp(_el$4, "style", st(dKey), _$p));
213
- return _el$4;
214
- })());
215
- if (s.providers && s.providers.length > 0) {
216
- for (const p of s.providers) {
217
- nodes.push((() => {
218
- var _el$7 = _$createElement("text"), _el$8 = _$createTextNode(` `);
219
- _$insertNode(_el$7, _el$8);
220
- _$insert(_el$7, () => p.name, null);
221
- _$effect((_$p) => _$setProp(_el$7, "style", st("textMuted"), _$p));
222
- return _el$7;
223
- })());
211
+ const modelShort = s.model ? s.model.split("/").pop() ?? s.model : "";
212
+ if (s.isFree) {
213
+ nodes.push((() => {
214
+ var _el$5 = _$createElement("box"), _el$6 = _$createElement("text"), _el$8 = _$createElement("text"), _el$9 = _$createTextNode(` `);
215
+ _$insertNode(_el$5, _el$6);
216
+ _$insertNode(_el$5, _el$8);
217
+ _$setProp(_el$5, "flexDirection", "row");
218
+ _$insertNode(_el$6, _$createTextNode(`usage-coach [free]`));
219
+ _$insertNode(_el$8, _el$9);
220
+ _$insert(_el$8, modelShort, null);
221
+ _$effect((_p$) => {
222
+ var _v$ = st(dKey), _v$2 = st("textMuted");
223
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$6, "style", _v$, _p$.e));
224
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$8, "style", _v$2, _p$.t));
225
+ return _p$;
226
+ }, {
227
+ e: void 0,
228
+ t: void 0
229
+ });
230
+ return _el$5;
231
+ })());
232
+ } else {
233
+ if (modelShort) {
224
234
  nodes.push((() => {
225
- var _el$9 = _$createElement("box"), _el$0 = _$createElement("text"), _el$10 = _$createElement("text"), _el$11 = _$createElement("text"), _el$12 = _$createElement("text"), _el$13 = _$createTextNode(` `), _el$14 = _$createTextNode(`% `);
226
- _$insertNode(_el$9, _el$0);
227
- _$insertNode(_el$9, _el$10);
228
- _$insertNode(_el$9, _el$11);
229
- _$insertNode(_el$9, _el$12);
230
- _$setProp(_el$9, "flexDirection", "row");
231
- _$insertNode(_el$0, _$createTextNode(` 5h `));
232
- _$insert(_el$10, () => barFill(p.fiveHour));
233
- _$insert(_el$11, () => barEmpty(p.fiveHour));
235
+ var _el$0 = _$createElement("box"), _el$1 = _$createElement("text"), _el$10 = _$createTextNode(`usage-coach [`), _el$11 = _$createTextNode(`]`), _el$12 = _$createElement("text"), _el$13 = _$createTextNode(` `);
236
+ _$insertNode(_el$0, _el$1);
237
+ _$insertNode(_el$0, _el$12);
238
+ _$setProp(_el$0, "flexDirection", "row");
239
+ _$insertNode(_el$1, _el$10);
240
+ _$insertNode(_el$1, _el$11);
241
+ _$insert(_el$1, () => TAG[s.decision], _el$11);
234
242
  _$insertNode(_el$12, _el$13);
235
- _$insertNode(_el$12, _el$14);
236
- _$insert(_el$12, () => p.fiveHour, _el$14);
237
- _$insert(_el$12, () => p.fiveHourReset, null);
243
+ _$insert(_el$12, modelShort, null);
238
244
  _$effect((_p$) => {
239
- var _v$ = st("text"), _v$2 = st("text");
240
- _v$ !== _p$.e && (_p$.e = _$setProp(_el$10, "style", _v$, _p$.e));
241
- _v$2 !== _p$.t && (_p$.t = _$setProp(_el$11, "style", _v$2, _p$.t));
245
+ var _v$3 = st(dKey), _v$4 = st("textMuted");
246
+ _v$3 !== _p$.e && (_p$.e = _$setProp(_el$1, "style", _v$3, _p$.e));
247
+ _v$4 !== _p$.t && (_p$.t = _$setProp(_el$12, "style", _v$4, _p$.t));
242
248
  return _p$;
243
249
  }, {
244
250
  e: void 0,
245
251
  t: void 0
246
252
  });
247
- return _el$9;
253
+ return _el$0;
254
+ })());
255
+ } else {
256
+ nodes.push((() => {
257
+ var _el$14 = _$createElement("text"), _el$15 = _$createTextNode(`usage-coach [`), _el$16 = _$createTextNode(`]`);
258
+ _$insertNode(_el$14, _el$15);
259
+ _$insertNode(_el$14, _el$16);
260
+ _$insert(_el$14, () => TAG[s.decision], _el$16);
261
+ _$effect((_$p) => _$setProp(_el$14, "style", st(dKey), _$p));
262
+ return _el$14;
248
263
  })());
264
+ }
265
+ if (s.providers && s.providers.length > 0) {
266
+ for (const p of s.providers) {
267
+ nodes.push((() => {
268
+ var _el$17 = _$createElement("box"), _el$18 = _$createElement("text"), _el$20 = _$createElement("text"), _el$21 = _$createElement("text"), _el$22 = _$createElement("text"), _el$23 = _$createTextNode(` `), _el$24 = _$createTextNode(`% `);
269
+ _$insertNode(_el$17, _el$18);
270
+ _$insertNode(_el$17, _el$20);
271
+ _$insertNode(_el$17, _el$21);
272
+ _$insertNode(_el$17, _el$22);
273
+ _$setProp(_el$17, "flexDirection", "row");
274
+ _$insertNode(_el$18, _$createTextNode(` 5h `));
275
+ _$insert(_el$20, () => barFill(p.fiveHour));
276
+ _$insert(_el$21, () => barEmpty(p.fiveHour));
277
+ _$insertNode(_el$22, _el$23);
278
+ _$insertNode(_el$22, _el$24);
279
+ _$insert(_el$22, () => p.fiveHour, _el$24);
280
+ _$insert(_el$22, () => p.fiveHourReset, null);
281
+ _$effect((_p$) => {
282
+ var _v$5 = st("text"), _v$6 = st("text");
283
+ _v$5 !== _p$.e && (_p$.e = _$setProp(_el$20, "style", _v$5, _p$.e));
284
+ _v$6 !== _p$.t && (_p$.t = _$setProp(_el$21, "style", _v$6, _p$.t));
285
+ return _p$;
286
+ }, {
287
+ e: void 0,
288
+ t: void 0
289
+ });
290
+ return _el$17;
291
+ })());
292
+ nodes.push((() => {
293
+ var _el$25 = _$createElement("box"), _el$26 = _$createElement("text"), _el$28 = _$createElement("text"), _el$29 = _$createElement("text"), _el$30 = _$createElement("text"), _el$31 = _$createTextNode(` `), _el$32 = _$createTextNode(`% `);
294
+ _$insertNode(_el$25, _el$26);
295
+ _$insertNode(_el$25, _el$28);
296
+ _$insertNode(_el$25, _el$29);
297
+ _$insertNode(_el$25, _el$30);
298
+ _$setProp(_el$25, "flexDirection", "row");
299
+ _$insertNode(_el$26, _$createTextNode(` 1w `));
300
+ _$insert(_el$28, () => barFill(p.weekly));
301
+ _$insert(_el$29, () => barEmpty(p.weekly));
302
+ _$insertNode(_el$30, _el$31);
303
+ _$insertNode(_el$30, _el$32);
304
+ _$insert(_el$30, () => p.weekly, _el$32);
305
+ _$insert(_el$30, () => p.weeklyReset, null);
306
+ _$effect((_p$) => {
307
+ var _v$7 = st("text"), _v$8 = st("text");
308
+ _v$7 !== _p$.e && (_p$.e = _$setProp(_el$28, "style", _v$7, _p$.e));
309
+ _v$8 !== _p$.t && (_p$.t = _$setProp(_el$29, "style", _v$8, _p$.t));
310
+ return _p$;
311
+ }, {
312
+ e: void 0,
313
+ t: void 0
314
+ });
315
+ return _el$25;
316
+ })());
317
+ }
318
+ } else {
249
319
  nodes.push((() => {
250
- var _el$15 = _$createElement("box"), _el$16 = _$createElement("text"), _el$18 = _$createElement("text"), _el$19 = _$createElement("text"), _el$20 = _$createElement("text"), _el$21 = _$createTextNode(` `), _el$22 = _$createTextNode(`% `);
251
- _$insertNode(_el$15, _el$16);
252
- _$insertNode(_el$15, _el$18);
253
- _$insertNode(_el$15, _el$19);
254
- _$insertNode(_el$15, _el$20);
255
- _$setProp(_el$15, "flexDirection", "row");
256
- _$insertNode(_el$16, _$createTextNode(` 1w `));
257
- _$insert(_el$18, () => barFill(p.weekly));
258
- _$insert(_el$19, () => barEmpty(p.weekly));
259
- _$insertNode(_el$20, _el$21);
260
- _$insertNode(_el$20, _el$22);
261
- _$insert(_el$20, () => p.weekly, _el$22);
262
- _$insert(_el$20, () => p.weeklyReset, null);
320
+ var _el$33 = _$createElement("box"), _el$34 = _$createElement("text"), _el$36 = _$createElement("text"), _el$37 = _$createElement("text"), _el$38 = _$createElement("text");
321
+ _$insertNode(_el$33, _el$34);
322
+ _$insertNode(_el$33, _el$36);
323
+ _$insertNode(_el$33, _el$37);
324
+ _$insertNode(_el$33, _el$38);
325
+ _$setProp(_el$33, "flexDirection", "row");
326
+ _$insertNode(_el$34, _$createTextNode(` 5h `));
327
+ _$insert(_el$36, () => barFill(s.fiveHour));
328
+ _$insert(_el$37, () => barEmpty(s.fiveHour));
329
+ _$insertNode(_el$38, _$createTextNode(` 0%`));
263
330
  _$effect((_p$) => {
264
- var _v$3 = st("text"), _v$4 = st("text");
265
- _v$3 !== _p$.e && (_p$.e = _$setProp(_el$18, "style", _v$3, _p$.e));
266
- _v$4 !== _p$.t && (_p$.t = _$setProp(_el$19, "style", _v$4, _p$.t));
331
+ var _v$9 = st("text"), _v$0 = st("text");
332
+ _v$9 !== _p$.e && (_p$.e = _$setProp(_el$36, "style", _v$9, _p$.e));
333
+ _v$0 !== _p$.t && (_p$.t = _$setProp(_el$37, "style", _v$0, _p$.t));
267
334
  return _p$;
268
335
  }, {
269
336
  e: void 0,
270
337
  t: void 0
271
338
  });
272
- return _el$15;
339
+ return _el$33;
273
340
  })());
274
341
  nodes.push((() => {
275
- var _el$23 = _$createElement("text"), _el$24 = _$createTextNode(` -> `);
276
- _$insertNode(_el$23, _el$24);
277
- _$insert(_el$23, () => p.advice, null);
278
- _$effect((_$p) => _$setProp(_el$23, "style", st(dKey), _$p));
279
- return _el$23;
342
+ var _el$40 = _$createElement("box"), _el$41 = _$createElement("text"), _el$43 = _$createElement("text"), _el$44 = _$createElement("text"), _el$45 = _$createElement("text");
343
+ _$insertNode(_el$40, _el$41);
344
+ _$insertNode(_el$40, _el$43);
345
+ _$insertNode(_el$40, _el$44);
346
+ _$insertNode(_el$40, _el$45);
347
+ _$setProp(_el$40, "flexDirection", "row");
348
+ _$insertNode(_el$41, _$createTextNode(` 1w `));
349
+ _$insert(_el$43, () => barFill(s.weekly));
350
+ _$insert(_el$44, () => barEmpty(s.weekly));
351
+ _$insertNode(_el$45, _$createTextNode(` 0%`));
352
+ _$effect((_p$) => {
353
+ var _v$1 = st("text"), _v$10 = st("text");
354
+ _v$1 !== _p$.e && (_p$.e = _$setProp(_el$43, "style", _v$1, _p$.e));
355
+ _v$10 !== _p$.t && (_p$.t = _$setProp(_el$44, "style", _v$10, _p$.t));
356
+ return _p$;
357
+ }, {
358
+ e: void 0,
359
+ t: void 0
360
+ });
361
+ return _el$40;
280
362
  })());
281
363
  }
282
- } else {
283
- nodes.push((() => {
284
- var _el$27 = _$createElement("box"), _el$28 = _$createElement("text"), _el$30 = _$createElement("text"), _el$31 = _$createElement("text"), _el$32 = _$createElement("text");
285
- _$insertNode(_el$27, _el$28);
286
- _$insertNode(_el$27, _el$30);
287
- _$insertNode(_el$27, _el$31);
288
- _$insertNode(_el$27, _el$32);
289
- _$setProp(_el$27, "flexDirection", "row");
290
- _$insertNode(_el$28, _$createTextNode(` 5h `));
291
- _$insert(_el$30, () => barFill(s.fiveHour));
292
- _$insert(_el$31, () => barEmpty(s.fiveHour));
293
- _$insertNode(_el$32, _$createTextNode(` 0%`));
294
- _$effect((_p$) => {
295
- var _v$5 = st("text"), _v$6 = st("text");
296
- _v$5 !== _p$.e && (_p$.e = _$setProp(_el$30, "style", _v$5, _p$.e));
297
- _v$6 !== _p$.t && (_p$.t = _$setProp(_el$31, "style", _v$6, _p$.t));
298
- return _p$;
299
- }, {
300
- e: void 0,
301
- t: void 0
302
- });
303
- return _el$27;
304
- })());
305
- nodes.push((() => {
306
- var _el$34 = _$createElement("box"), _el$35 = _$createElement("text"), _el$37 = _$createElement("text"), _el$38 = _$createElement("text"), _el$39 = _$createElement("text");
307
- _$insertNode(_el$34, _el$35);
308
- _$insertNode(_el$34, _el$37);
309
- _$insertNode(_el$34, _el$38);
310
- _$insertNode(_el$34, _el$39);
311
- _$setProp(_el$34, "flexDirection", "row");
312
- _$insertNode(_el$35, _$createTextNode(` 1w `));
313
- _$insert(_el$37, () => barFill(s.weekly));
314
- _$insert(_el$38, () => barEmpty(s.weekly));
315
- _$insertNode(_el$39, _$createTextNode(` 0%`));
316
- _$effect((_p$) => {
317
- var _v$7 = st("text"), _v$8 = st("text");
318
- _v$7 !== _p$.e && (_p$.e = _$setProp(_el$37, "style", _v$7, _p$.e));
319
- _v$8 !== _p$.t && (_p$.t = _$setProp(_el$38, "style", _v$8, _p$.t));
320
- return _p$;
321
- }, {
322
- e: void 0,
323
- t: void 0
324
- });
325
- return _el$34;
326
- })());
327
364
  }
328
365
  } else {
329
366
  nodes.push((() => {
330
- var _el$41 = _$createElement("text");
331
- _$insertNode(_el$41, _$createTextNode(`usage-coach: ...`));
332
- return _el$41;
367
+ var _el$47 = _$createElement("text");
368
+ _$insertNode(_el$47, _$createTextNode(`usage-coach: ...`));
369
+ return _el$47;
333
370
  })());
334
371
  }
335
372
  if (h && h.active !== false && h.tasks.length > 0) {
336
373
  nodes.push((() => {
337
- var _el$43 = _$createElement("text");
338
- _$insertNode(_el$43, _$createTextNode(` `));
339
- return _el$43;
374
+ var _el$49 = _$createElement("text");
375
+ _$insertNode(_el$49, _$createTextNode(` `));
376
+ return _el$49;
340
377
  })());
341
378
  nodes.push((() => {
342
- var _el$45 = _$createElement("text"), _el$46 = _$createTextNode(`harness: `), _el$47 = _$createTextNode(` `), _el$48 = _$createTextNode(`/`);
343
- _$insertNode(_el$45, _el$46);
344
- _$insertNode(_el$45, _el$47);
345
- _$insertNode(_el$45, _el$48);
346
- _$insert(_el$45, () => h.name, _el$47);
347
- _$insert(_el$45, () => h.current, _el$48);
348
- _$insert(_el$45, () => h.total, null);
349
- _$effect((_$p) => _$setProp(_el$45, "style", st("textMuted"), _$p));
350
- return _el$45;
379
+ var _el$51 = _$createElement("text"), _el$52 = _$createTextNode(`harness: `), _el$53 = _$createTextNode(` `), _el$54 = _$createTextNode(`/`);
380
+ _$insertNode(_el$51, _el$52);
381
+ _$insertNode(_el$51, _el$53);
382
+ _$insertNode(_el$51, _el$54);
383
+ _$insert(_el$51, () => h.name, _el$53);
384
+ _$insert(_el$51, () => h.current, _el$54);
385
+ _$insert(_el$51, () => h.total, null);
386
+ _$effect((_$p) => _$setProp(_el$51, "style", st("textMuted"), _$p));
387
+ return _el$51;
351
388
  })());
352
389
  for (const t of h.tasks) {
353
390
  const sKey = statusKey[t.status] ?? "text";
354
391
  const lbl = TLABEL[t.status] ?? t.status;
355
392
  const rev = t.revisions > 0 && t.status === "revising" ? `(${t.revisions})` : "";
356
393
  const mdl = t.model ? ` ${t.model.split("/").pop() ?? t.model}` : "";
394
+ const hasSub = !!t.subSessionId;
395
+ const subStepStr = hasSub && t.subStep !== void 0 && t.subStep > 0 ? ` step:${t.subStep}` : "";
396
+ const subEl = hasSub && t.subElapsed !== void 0 ? ` ${t.subElapsed}s` : "";
397
+ const subWarn = hasSub && (t.subElapsed ?? 0) > 300;
357
398
  const elapsed = t.startedAt ? Math.max(0, Math.round((Date.now() - new Date(t.startedAt).getTime()) / 1e3)) : 0;
358
- const elapsedStr = t.status === "completed" || t.status === "failed" ? "" : elapsed > 0 ? ` ${elapsed}s` : "";
399
+ const taskEl = t.status === "completed" || t.status === "failed" ? "" : elapsed > 0 ? ` ${elapsed}s` : "";
400
+ const displayEl = hasSub ? subEl : taskEl;
401
+ const lineKey = subWarn ? "warning" : sKey;
359
402
  nodes.push((() => {
360
- var _el$49 = _$createElement("text"), _el$50 = _$createTextNode(` \u25CF `), _el$51 = _$createTextNode(` `), _el$52 = _$createTextNode(` `);
361
- _$insertNode(_el$49, _el$50);
362
- _$insertNode(_el$49, _el$51);
363
- _$insertNode(_el$49, _el$52);
364
- _$insert(_el$49, () => t.id, _el$51);
365
- _$insert(_el$49, mdl, _el$51);
366
- _$insert(_el$49, lbl, _el$52);
367
- _$insert(_el$49, rev, _el$52);
368
- _$insert(_el$49, elapsedStr, _el$52);
369
- _$insert(_el$49, () => t.title, null);
370
- _$effect((_$p) => _$setProp(_el$49, "style", st(sKey), _$p));
371
- return _el$49;
403
+ var _el$55 = _$createElement("text"), _el$56 = _$createTextNode(` \u25CF `), _el$57 = _$createTextNode(` `), _el$58 = _$createTextNode(` `);
404
+ _$insertNode(_el$55, _el$56);
405
+ _$insertNode(_el$55, _el$57);
406
+ _$insertNode(_el$55, _el$58);
407
+ _$insert(_el$55, () => t.id, _el$57);
408
+ _$insert(_el$55, mdl, _el$57);
409
+ _$insert(_el$55, lbl, _el$58);
410
+ _$insert(_el$55, rev, _el$58);
411
+ _$insert(_el$55, subStepStr, _el$58);
412
+ _$insert(_el$55, displayEl, _el$58);
413
+ _$insert(_el$55, () => t.title, null);
414
+ _$effect((_$p) => _$setProp(_el$55, "style", st(lineKey), _$p));
415
+ return _el$55;
372
416
  })());
373
417
  const pv = t.model ? (t.model.split("/")[0] ?? "").split("-")[0] : "";
374
418
  const provCoach = pv ? s?.providers?.find((p) => p.id === pv || pv && p.id.startsWith(pv) || pv && pv.startsWith(p.id)) : s?.providers?.[0];
@@ -376,35 +420,35 @@ function initializeTui(api, disposeRoot) {
376
420
  const pct = rawPct < 0 ? 0 : rawPct;
377
421
  const pctLabel = rawPct < 0 ? "n/a" : `${rawPct}%`;
378
422
  nodes.push((() => {
379
- var _el$53 = _$createElement("box"), _el$54 = _$createElement("text"), _el$56 = _$createElement("text"), _el$57 = _$createElement("text"), _el$58 = _$createElement("text"), _el$59 = _$createTextNode(` `);
380
- _$insertNode(_el$53, _el$54);
381
- _$insertNode(_el$53, _el$56);
382
- _$insertNode(_el$53, _el$57);
383
- _$insertNode(_el$53, _el$58);
384
- _$setProp(_el$53, "flexDirection", "row");
385
- _$insertNode(_el$54, _$createTextNode(` 5h `));
386
- _$insert(_el$56, () => barFill(pct));
387
- _$insert(_el$57, () => barEmpty(pct));
388
- _$insertNode(_el$58, _el$59);
389
- _$insert(_el$58, pctLabel, null);
423
+ var _el$59 = _$createElement("box"), _el$60 = _$createElement("text"), _el$62 = _$createElement("text"), _el$63 = _$createElement("text"), _el$64 = _$createElement("text"), _el$65 = _$createTextNode(` `);
424
+ _$insertNode(_el$59, _el$60);
425
+ _$insertNode(_el$59, _el$62);
426
+ _$insertNode(_el$59, _el$63);
427
+ _$insertNode(_el$59, _el$64);
428
+ _$setProp(_el$59, "flexDirection", "row");
429
+ _$insertNode(_el$60, _$createTextNode(` 5h `));
430
+ _$insert(_el$62, () => barFill(pct));
431
+ _$insert(_el$63, () => barEmpty(pct));
432
+ _$insertNode(_el$64, _el$65);
433
+ _$insert(_el$64, pctLabel, null);
390
434
  _$effect((_p$) => {
391
- var _v$9 = st("text"), _v$0 = st("text");
392
- _v$9 !== _p$.e && (_p$.e = _$setProp(_el$56, "style", _v$9, _p$.e));
393
- _v$0 !== _p$.t && (_p$.t = _$setProp(_el$57, "style", _v$0, _p$.t));
435
+ var _v$11 = st("text"), _v$12 = st("text");
436
+ _v$11 !== _p$.e && (_p$.e = _$setProp(_el$62, "style", _v$11, _p$.e));
437
+ _v$12 !== _p$.t && (_p$.t = _$setProp(_el$63, "style", _v$12, _p$.t));
394
438
  return _p$;
395
439
  }, {
396
440
  e: void 0,
397
441
  t: void 0
398
442
  });
399
- return _el$53;
443
+ return _el$59;
400
444
  })());
401
445
  }
402
446
  }
403
447
  return (() => {
404
- var _el$60 = _$createElement("box");
405
- _$setProp(_el$60, "flexDirection", "column");
406
- _$insert(_el$60, nodes);
407
- return _el$60;
448
+ var _el$66 = _$createElement("box");
449
+ _$setProp(_el$66, "flexDirection", "column");
450
+ _$insert(_el$66, nodes);
451
+ return _el$66;
408
452
  })();
409
453
  };
410
454
  tlog("registering slots");
@@ -419,9 +463,9 @@ function initializeTui(api, disposeRoot) {
419
463
  } catch (e) {
420
464
  tlog(`sidebar_footer err: ${String(e)}`);
421
465
  result = (() => {
422
- var _el$61 = _$createElement("text");
423
- _$insertNode(_el$61, _$createTextNode(`usage-coach`));
424
- return _el$61;
466
+ var _el$67 = _$createElement("text");
467
+ _$insertNode(_el$67, _$createTextNode(`usage-coach`));
468
+ return _el$67;
425
469
  })();
426
470
  }
427
471
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.6.2",
3
+ "version": "0.7.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",
@@ -58,11 +58,5 @@
58
58
  "tsup": "^8.5",
59
59
  "typescript": "^5",
60
60
  "typescript-eslint": "^8.63.0"
61
- },
62
- "dependencies": {
63
- "@ladybugdb/core": "^0.18.0"
64
- },
65
- "trustedDependencies": [
66
- "@ladybugdb/core"
67
- ]
61
+ }
68
62
  }