opencode-usage-coach 0.7.0 → 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 (3) hide show
  1. package/dist/index.js +186 -16
  2. package/dist/tui.js +10 -3
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -114,6 +114,8 @@ function saveInvestigationResult(keywords, result, source) {
114
114
  // src/index.ts
115
115
  var PLUGIN_NAME = "opencode-usage-coach";
116
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);
117
119
  var PIPE_LOG = join2(homedir(), ".cache", "opencode-usage-coach", "pipeline.log");
118
120
  function pipeLog(msg) {
119
121
  try {
@@ -205,6 +207,40 @@ function writeHarness(sessionID, h) {
205
207
  } catch {
206
208
  }
207
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);
218
+ } catch {
219
+ }
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
+ }
208
244
  function readHarnessCfg(dir) {
209
245
  const tryRead = (p) => {
210
246
  try {
@@ -218,8 +254,16 @@ function readHarnessCfg(dir) {
218
254
  ...tryRead(join2(dir, "harness.config.json"))
219
255
  };
220
256
  }
221
- async function runModel(client, model, prompt, directory) {
257
+ async function runModel(client, model, prompt, directory, track, maxSteps = DEFAULT_MAX_STEPS) {
222
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
+ });
223
267
  try {
224
268
  const slash = model.indexOf("/");
225
269
  const providerID = slash >= 0 ? model.slice(0, slash) : model;
@@ -227,12 +271,75 @@ async function runModel(client, model, prompt, directory) {
227
271
  const s = await client.session.create({ body: { title: "uc-harness-sub" }, query: { directory } });
228
272
  const id = s?.data?.info?.id ?? s?.data?.id ?? s?.id;
229
273
  if (!id) return `ERROR: session.create returned no id (response: ${JSON.stringify(s?.data ?? s).slice(0, 200)})`;
230
- log(`runModel(${model}): session ${id} created, sending prompt (${prompt.length} chars)`);
231
- 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({
232
319
  path: { id },
233
320
  body: { model: { providerID, modelID }, parts: [{ type: "text", text: prompt }] }
234
- });
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)]);
235
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
+ }
236
343
  const parts = resp?.data?.parts ?? resp?.parts ?? [];
237
344
  const text = parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("");
238
345
  try {
@@ -244,12 +351,27 @@ async function runModel(client, model, prompt, directory) {
244
351
  await client.session.delete?.({ path: { id } });
245
352
  } catch {
246
353
  }
354
+ subId = null;
247
355
  log(`runModel(${model}): done ${elapsed}s, ${text.length} chars`);
248
356
  return text.trim() || `ERROR: no assistant text in prompt response after ${elapsed}s (parts: ${parts.length}, types: ${parts.map((p) => p?.type).join(",")})`;
249
357
  } catch (e) {
250
358
  const elapsed = Math.round((Date.now() - t0) / 1e3);
251
359
  log(`runModel err (${model}, ${elapsed}s): ${String(e)}`);
252
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
+ }
253
375
  }
254
376
  }
255
377
  var HARNESS_AGENTS = (process.env.UC_HARNESS_AGENT ?? "Usage-Coach-Harness").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
@@ -549,12 +671,14 @@ async function UsageCoachPlugin(input) {
549
671
  // Custom tools for the harness agent mode — report status to the panel.
550
672
  tool: {
551
673
  harness_start: tool({
552
- 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.",
553
675
  args: { name: tool.schema.string(), total: tool.schema.number() },
554
676
  async execute(args, ctx) {
555
677
  writeHarness(ctx.sessionID, { name: args.name, total: args.total, current: 0, tasks: [], usage: {}, active: true, startedAt: (/* @__PURE__ */ new Date()).toISOString() });
556
678
  return `Harness '${args.name}' started (${args.total} tasks).
557
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
+
558
682
  DETERMINISTIC LOOP \u2014 first classify the tasks:
559
683
  INDEPENDENT = task B does NOT need task A's output -> use PATH A (parallel, faster)
560
684
  DEPENDENT = task B needs task A's output -> use PATH B (sequential)
@@ -669,7 +793,14 @@ Output a structured root cause:
669
793
  category: (one of: constraint-violation, missing-context, tool-misuse, model-limitation, other)
670
794
  explanation: <why it failed>
671
795
  evidence: <file/line or specific quote>`;
672
- 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
+ );
673
804
  if (domainEmpty && keywords.length) {
674
805
  try {
675
806
  saveInvestigationResult(keywords, out, "investigate");
@@ -697,7 +828,14 @@ Grade feedback: ${args.gradeResult}
697
828
  Diagnosis: ${args.diagnosis}
698
829
  Is the diagnosis CORRECT and ACTIONABLE (leads to a useful rule)?
699
830
  Output PASS (the diagnosis is right) or FAIL (re-investigate needed), then reason.`;
700
- 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
+ );
701
839
  let verdict = "FAIL";
702
840
  if (!out.startsWith("ERROR:")) {
703
841
  const f = (out.split("\n").find((l) => l.trim()) ?? "").trim();
@@ -724,7 +862,14 @@ Diagnosis: ${args.diagnosis}
724
862
  Failed task: ${args.task}
725
863
  Output a single rule in the form: 'For <task-type> tasks, always <check/do X> because <reason>.'
726
864
  Keep it concrete and actionable.`;
727
- 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
+ );
728
873
  const rule = out;
729
874
  try {
730
875
  const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
@@ -744,8 +889,8 @@ Origin: ${args.task}
744
889
  // Per-role model execution (config-driven, quota-aware, same server, no deadlock).
745
890
  // P1: quota decision drives model selection + concurrency.
746
891
  generate: tool({
747
- description: "Run the GENERATOR model on a prompt. Quota-aware: on THROTTLE, auto-switches to lighterModel if configured. Returns the model's text response.",
748
- 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.") },
749
894
  async execute(args, ctx) {
750
895
  const cfg = readHarnessCfg(ctx.directory);
751
896
  if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
@@ -781,22 +926,33 @@ ${rules}
781
926
  } catch (e) {
782
927
  log(`generate domain query err: ${String(e)}`);
783
928
  }
784
- const out = await runModel(input.client, model, prefix + args.prompt, ctx.directory);
785
- 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) {
786
941
  try {
787
942
  saveInvestigationResult(keywords, out, "generate");
788
943
  } catch (e) {
789
944
  log(`generate save err: ${String(e)}`);
790
945
  }
791
946
  }
947
+ if (out.startsWith("Task appears too large")) return out;
792
948
  return out + (throttle ? `
793
949
  [usage-coach] quota THROTTLE \u2014 used lighter model ${cfg.lighterModel}` : "") + `
794
950
  [usage-coach NEXT] call task_update(i, title, "grading"), then grade to evaluate this work.`;
795
951
  }
796
952
  }),
797
953
  generate_batch: tool({
798
- 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.",
799
- 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).") },
800
956
  async execute(args, ctx) {
801
957
  const cfg = readHarnessCfg(ctx.directory);
802
958
  if (!cfg.generator) return 'ERROR: no generator model configured. Set "generator" in harness.config.json (see harness.config.example.json).';
@@ -813,7 +969,14 @@ ${rules}
813
969
  for (let i = 0; i < args.tasks.length; i += limit) {
814
970
  const batch = args.tasks.slice(i, i + limit);
815
971
  const out = await Promise.all(batch.map(async (t) => {
816
- 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
+ );
817
980
  return `[task ${t.id}] ${r}`;
818
981
  }));
819
982
  results.push(...out);
@@ -830,7 +993,14 @@ ${rules}
830
993
  const cfg = readHarnessCfg(ctx.directory);
831
994
  const model = cfg.grader ?? cfg.generator;
832
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.";
833
- 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
+ );
834
1004
  let verdict = "FAIL";
835
1005
  if (!out.startsWith("ERROR:")) {
836
1006
  const f = (out.split("\n").find((l) => l.trim()) ?? "").trim();
package/dist/tui.js CHANGED
@@ -391,8 +391,14 @@ function initializeTui(api, disposeRoot) {
391
391
  const lbl = TLABEL[t.status] ?? t.status;
392
392
  const rev = t.revisions > 0 && t.status === "revising" ? `(${t.revisions})` : "";
393
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;
394
398
  const elapsed = t.startedAt ? Math.max(0, Math.round((Date.now() - new Date(t.startedAt).getTime()) / 1e3)) : 0;
395
- 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;
396
402
  nodes.push((() => {
397
403
  var _el$55 = _$createElement("text"), _el$56 = _$createTextNode(` \u25CF `), _el$57 = _$createTextNode(` `), _el$58 = _$createTextNode(` `);
398
404
  _$insertNode(_el$55, _el$56);
@@ -402,9 +408,10 @@ function initializeTui(api, disposeRoot) {
402
408
  _$insert(_el$55, mdl, _el$57);
403
409
  _$insert(_el$55, lbl, _el$58);
404
410
  _$insert(_el$55, rev, _el$58);
405
- _$insert(_el$55, elapsedStr, _el$58);
411
+ _$insert(_el$55, subStepStr, _el$58);
412
+ _$insert(_el$55, displayEl, _el$58);
406
413
  _$insert(_el$55, () => t.title, null);
407
- _$effect((_$p) => _$setProp(_el$55, "style", st(sKey), _$p));
414
+ _$effect((_$p) => _$setProp(_el$55, "style", st(lineKey), _$p));
408
415
  return _el$55;
409
416
  })());
410
417
  const pv = t.model ? (t.model.split("/")[0] ?? "").split("-")[0] : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-coach",
3
- "version": "0.7.0",
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",