@bojackduy/opencode-loopd 1.8.3 → 1.9.2

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/dist/server.js CHANGED
@@ -11,6 +11,7 @@ function createRuntimeState(goalID) {
11
11
  noProgressCount: 0,
12
12
  progressDuringTurn: false,
13
13
  unknownStatusCount: 0,
14
+ accountedMessageIDs: [],
14
15
  runGeneration: 0,
15
16
  createdAt: now,
16
17
  updatedAt: now
@@ -30,6 +31,10 @@ function acquireLease(rt, timeoutMs) {
30
31
  lastActivityAt: new Date(now).toISOString(),
31
32
  idleCandidateAt: undefined,
32
33
  idleCandidateGeneration: undefined,
34
+ idleConfirmFailedAt: undefined,
35
+ idleConfirmFailedGeneration: undefined,
36
+ idleStuckNotifiedGeneration: undefined,
37
+ workerAbortedAt: undefined,
33
38
  activePromptObservedAt: undefined,
34
39
  activeAssistantMessageID: undefined,
35
40
  activeAssistantCompletedAt: undefined,
@@ -479,6 +484,16 @@ function resolveGoalCreationConfig(input) {
479
484
  const explicitAgent = cleanText(requested.agent);
480
485
  const defaultAgent = cleanText(defaults.defaultAgent);
481
486
  const agent = explicitAgent || defaultAgent || undefined;
487
+ const explicitModel = cleanText(requested.model);
488
+ const defaultModel = cleanText(defaults.defaultModel);
489
+ const model = explicitModel || defaultModel || undefined;
490
+ if (model && !isValidModelRef(model)) {
491
+ return {
492
+ ok: false,
493
+ errorCode: "invalid_model",
494
+ message: `Invalid model "${model}". Use "providerID/modelID" (e.g. "openai/gpt-5.6-sol", "ollama/qwen3.8:27b"). Discover with \`opencode models\`.`
495
+ };
496
+ }
482
497
  const workspaceWrite = requested.workspaceWrite ?? true;
483
498
  const explicitChecks = cleanList(requested.checks);
484
499
  const defaultChecks = workspaceWrite ? cleanList(defaults.defaultChecks || ["bun test"]) : [];
@@ -495,16 +510,30 @@ function resolveGoalCreationConfig(input) {
495
510
  config: {
496
511
  ...requested,
497
512
  agent,
513
+ model,
498
514
  workspaceWrite,
499
515
  checks: checks.length > 0 ? checks : undefined,
500
516
  checkCwd: requested.checkCwd || (workspaceWrite ? input.directory : undefined)
501
517
  },
502
518
  defaultsApplied: {
503
519
  agent: !explicitAgent && Boolean(defaultAgent),
520
+ model: !explicitModel && Boolean(defaultModel),
504
521
  checks: explicitChecks.length === 0 && defaultChecks.length > 0
505
522
  }
506
523
  };
507
524
  }
525
+ function isValidModelRef(value) {
526
+ const slash = value.indexOf("/");
527
+ if (slash <= 0 || slash >= value.length - 1)
528
+ return false;
529
+ const providerID = value.slice(0, slash).trim();
530
+ const modelID = value.slice(slash + 1).trim();
531
+ if (!providerID || !modelID)
532
+ return false;
533
+ if (/\s/.test(providerID) || /\s/.test(modelID))
534
+ return false;
535
+ return true;
536
+ }
508
537
  function cleanText(value) {
509
538
  if (typeof value !== "string")
510
539
  return;
@@ -567,12 +596,17 @@ function createControlWorker(options) {
567
596
  if (running)
568
597
  return;
569
598
  running = true;
570
- processPending();
599
+ processPending().catch((error) => {
600
+ logServerEvent(directory, "control.worker.error", { detail: describeError(error) }).catch(() => {});
601
+ });
571
602
  pollTimer = setInterval(() => {
572
603
  if (running && lastProcessDone) {
573
604
  lastProcessDone = false;
574
605
  processPending().then(() => {
575
606
  lastProcessDone = true;
607
+ }, (error) => {
608
+ lastProcessDone = true;
609
+ logServerEvent(directory, "control.worker.error", { detail: describeError(error) }).catch(() => {});
576
610
  });
577
611
  }
578
612
  }, pollMs);
@@ -721,6 +755,21 @@ function createControlWorker(options) {
721
755
  };
722
756
  break;
723
757
  }
758
+ case "nudge": {
759
+ if (!request.goalID) {
760
+ response = { ...base, ok: false, message: "goalID is required", errorCode: "bad_request" };
761
+ break;
762
+ }
763
+ const result = await goalSvc.nudge(directory, request.goalID);
764
+ const state = await readState(directory);
765
+ response = {
766
+ ...base,
767
+ ok: result.ok,
768
+ message: result.message,
769
+ stateRevision: state.revision
770
+ };
771
+ break;
772
+ }
724
773
  case "clear": {
725
774
  await goalSvc.clear(directory, request.goalID);
726
775
  const state = await readState(directory);
@@ -742,12 +791,27 @@ function createControlWorker(options) {
742
791
  response = { ...base, ok: false, message: "goalID is required", errorCode: "bad_request" };
743
792
  break;
744
793
  }
745
- await appendGoalInbox(directory, request.goalID, "user", text);
794
+ const sent = await goalSvc.sendUserMessage(directory, request.goalID, text);
795
+ const state = await readState(directory);
796
+ response = {
797
+ ...base,
798
+ ok: sent.ok,
799
+ message: sent.message,
800
+ stateRevision: state.revision
801
+ };
802
+ break;
803
+ }
804
+ case "abort_worker": {
805
+ if (!request.goalID) {
806
+ response = { ...base, ok: false, message: "goalID is required", errorCode: "bad_request" };
807
+ break;
808
+ }
809
+ const result = await goalSvc.abortWorker(directory, request.goalID);
746
810
  const state = await readState(directory);
747
- const goal = state.goals.find((g) => g.id === request.goalID);
748
811
  response = {
749
812
  ...base,
750
- message: `sent to "${goal?.name || request.goalID}"`,
813
+ ok: result.ok,
814
+ message: result.message,
751
815
  stateRevision: state.revision
752
816
  };
753
817
  break;
@@ -904,7 +968,7 @@ function isTerminal(status) {
904
968
  }
905
969
  function createGoal(input) {
906
970
  const now = new Date().toISOString();
907
- return { ...input, tokensUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
971
+ return { ...input, tokensUsed: 0, costUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
908
972
  }
909
973
  // src/application/loop-engine.ts
910
974
  var CONFIRM_IDLE_DURATION_MS = 2000;
@@ -921,6 +985,9 @@ function createLoopEngine(options) {
921
985
  const maintenanceMs = options.pollIntervalMs ?? 30000;
922
986
  const confirmIdleMs = options.confirmIdleMs ?? CONFIRM_IDLE_DURATION_MS;
923
987
  const unknownStatusThreshold = Math.max(1, options.unknownStatusThreshold ?? 3);
988
+ const stuckRunningMs = options.stuckRunningMs ?? 10 * 60000;
989
+ const idleUnconfirmedMs = options.idleUnconfirmedMs ?? 5 * 60000;
990
+ const idleRecoverMs = options.idleRecoverMs ?? 3 * 60000;
924
991
  let running = false;
925
992
  let maintenanceTimer;
926
993
  let knownWorkerSessions = new Set;
@@ -1044,6 +1111,8 @@ function createLoopEngine(options) {
1044
1111
  } else {
1045
1112
  const part = event.properties?.part;
1046
1113
  if (part?.messageID && part.messageID === rt.activeAssistantMessageID) {
1114
+ if (rt.activeAssistantCompletedAt)
1115
+ return s;
1047
1116
  Object.assign(rt, recordActivity(rt));
1048
1117
  matched = true;
1049
1118
  }
@@ -1083,7 +1152,9 @@ function createLoopEngine(options) {
1083
1152
  const elapsed = now - Date.parse(rt.idleCandidateAt);
1084
1153
  if (elapsed < confirmIdleMs)
1085
1154
  return s;
1086
- if (rt.lastActivityAt && rt.lastActivityAt > rt.idleCandidateAt) {
1155
+ const anchoredHere = Boolean(rt.activeAssistantCompletedAt);
1156
+ const quietSince = anchoredHere && rt.activeAssistantCompletedAt ? rt.activeAssistantCompletedAt : rt.idleCandidateAt;
1157
+ if (rt.lastActivityAt && rt.lastActivityAt > rt.idleCandidateAt && rt.lastActivityAt > quietSince) {
1087
1158
  rt.idleCandidateAt = undefined;
1088
1159
  rt.idleCandidateGeneration = undefined;
1089
1160
  return s;
@@ -1105,8 +1176,35 @@ function createLoopEngine(options) {
1105
1176
  });
1106
1177
  if (confirmation) {
1107
1178
  const candidate = confirmation;
1179
+ const stagedRt = afterIdle.runtimes.find((r) => r.goalID === goalID);
1180
+ const eventAnchored = stagedRt?.runGeneration === candidate.generation && Boolean(stagedRt?.activeAssistantCompletedAt);
1108
1181
  const transcript = await inspectPromptTurn(goal.workerSessionID, candidate.promptMessageID);
1109
- if (!transcript.latestUserPrompt || !candidate.assistantCompleted && !transcript.assistantCompleted) {
1182
+ const failureReason = !transcript.latestUserPrompt && !eventAnchored ? "prompt-outside-window" : !eventAnchored && !candidate.assistantCompleted && !transcript.assistantCompleted ? "assistant-incomplete" : undefined;
1183
+ if (failureReason) {
1184
+ let newlyStamped = false;
1185
+ await mutateState(directory, `idle.confirm-stamp:${goalID}`, async (s) => {
1186
+ const rt = s.runtimes.find((r) => r.goalID === goalID);
1187
+ if (!rt || rt.runGeneration !== candidate.generation)
1188
+ return s;
1189
+ if (rt.idleConfirmFailedGeneration !== candidate.generation) {
1190
+ rt.idleConfirmFailedAt = new Date().toISOString();
1191
+ rt.idleConfirmFailedGeneration = candidate.generation;
1192
+ newlyStamped = true;
1193
+ }
1194
+ return s;
1195
+ });
1196
+ if (newlyStamped) {
1197
+ await appendEvent(directory, {
1198
+ version: 1,
1199
+ eventID: randomUUID2(),
1200
+ goalID,
1201
+ type: "idle.confirm-failed",
1202
+ reason: failureReason,
1203
+ runGeneration: candidate.generation,
1204
+ timestamp: new Date().toISOString(),
1205
+ revision: afterIdle.revision
1206
+ });
1207
+ }
1110
1208
  return true;
1111
1209
  }
1112
1210
  afterIdle = await mutateState(directory, `idle.confirm:${goalID}`, async (s) => {
@@ -1124,14 +1222,19 @@ function createLoopEngine(options) {
1124
1222
  return s;
1125
1223
  if (rt.idleCandidateAt !== candidate.candidateAt)
1126
1224
  return s;
1127
- if (rt.lastActivityAt && rt.lastActivityAt > candidate.candidateAt)
1128
- return s;
1225
+ if (rt.lastActivityAt && rt.lastActivityAt > candidate.candidateAt) {
1226
+ const quiet = rt.runGeneration === candidate.generation && rt.activeAssistantCompletedAt ? rt.activeAssistantCompletedAt : candidate.candidateAt;
1227
+ if (rt.lastActivityAt > quiet)
1228
+ return s;
1229
+ }
1129
1230
  if ((rt.activeToolCallIDs?.length ?? 0) > 0)
1130
1231
  return s;
1131
1232
  completedRunID = rt.activeRunID;
1132
1233
  Object.assign(rt, releaseLease(rt));
1133
1234
  rt.activeRunID = undefined;
1134
1235
  rt.lastWorkerStatus = "idle";
1236
+ rt.idleConfirmFailedAt = undefined;
1237
+ rt.idleConfirmFailedGeneration = undefined;
1135
1238
  return s;
1136
1239
  });
1137
1240
  }
@@ -1174,6 +1277,12 @@ function createLoopEngine(options) {
1174
1277
  return true;
1175
1278
  recentForceFinishBlocked.set(blockedKey, nowBlocked);
1176
1279
  let shouldNotifyBlocked = false;
1280
+ await goalService.accountUsage(directory, goalID).catch(() => ({
1281
+ tokenDelta: 0,
1282
+ costDelta: 0,
1283
+ timeDeltaSeconds: 0,
1284
+ counted: []
1285
+ }));
1177
1286
  const blockedState = await mutateState(directory, `idle.blocked:${goalID}`, async (s) => {
1178
1287
  const g = s.goals.find((item) => item.id === goalID);
1179
1288
  if (!g)
@@ -1244,7 +1353,7 @@ function createLoopEngine(options) {
1244
1353
  return noMatch;
1245
1354
  let messages;
1246
1355
  try {
1247
- messages = await host.readMessages(workerSessionID, 50);
1356
+ messages = await host.readMessages(workerSessionID, 200);
1248
1357
  } catch {
1249
1358
  return noMatch;
1250
1359
  }
@@ -1402,6 +1511,16 @@ function createLoopEngine(options) {
1402
1511
  reason: `Token budget exhausted (${goal.tokensUsed}/${goal.tokenBudget})`
1403
1512
  };
1404
1513
  }
1514
+ if (typeof goal.costBudget === "number" && (goal.costUsed ?? 0) >= goal.costBudget) {
1515
+ goal.status = "budget_limited";
1516
+ goal.updatedAt = new Date().toISOString();
1517
+ return {
1518
+ stop: "budget",
1519
+ blocked: true,
1520
+ event: "goal.status_changed",
1521
+ reason: `Cost budget exhausted ($${(goal.costUsed ?? 0).toFixed(4)}/$${goal.costBudget})`
1522
+ };
1523
+ }
1405
1524
  return noResult;
1406
1525
  }
1407
1526
  function shouldCompact(goal, runtime) {
@@ -1440,6 +1559,67 @@ function createLoopEngine(options) {
1440
1559
  return s;
1441
1560
  });
1442
1561
  }
1562
+ async function accountAndEnforceBudget(goal) {
1563
+ try {
1564
+ await goalService.accountUsage(directory, goal.id);
1565
+ } catch {}
1566
+ const fresh = await readState(directory);
1567
+ const g = fresh.goals.find((item) => item.id === goal.id);
1568
+ if (!g || g.status !== "active")
1569
+ return false;
1570
+ const overTokens = typeof g.tokenBudget === "number" && g.tokensUsed >= g.tokenBudget;
1571
+ const overCost = typeof g.costBudget === "number" && (g.costUsed ?? 0) >= g.costBudget;
1572
+ if (!overTokens && !overCost)
1573
+ return false;
1574
+ const reason = overCost ? `Cost budget exhausted ($${(g.costUsed ?? 0).toFixed(4)}/$${g.costBudget})` : `Token budget exhausted (${g.tokensUsed}/${g.tokenBudget})`;
1575
+ if (g.workerSessionID) {
1576
+ try {
1577
+ await host.abortSession(g.workerSessionID);
1578
+ } catch {}
1579
+ }
1580
+ let shouldNotify = false;
1581
+ const stoppedState = await mutateState(directory, `maintenance.budget:${goal.id}`, async (s) => {
1582
+ const target = s.goals.find((item) => item.id === goal.id);
1583
+ if (!target || target.status !== "active")
1584
+ return s;
1585
+ target.status = "budget_limited";
1586
+ target.updatedAt = new Date().toISOString();
1587
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1588
+ if (rt) {
1589
+ Object.assign(rt, releaseLease(rt));
1590
+ rt.activeRunID = undefined;
1591
+ rt.updatedAt = new Date().toISOString();
1592
+ if (shouldNotifyParent(rt, "stopped")) {
1593
+ markParentNotified(rt, "stopped");
1594
+ shouldNotify = true;
1595
+ }
1596
+ }
1597
+ return s;
1598
+ });
1599
+ const stoppedGoal = stoppedState.goals.find((item) => item.id === goal.id);
1600
+ if (stoppedGoal?.status !== "budget_limited")
1601
+ return false;
1602
+ await appendEvent(directory, {
1603
+ version: 1,
1604
+ eventID: randomUUID2(),
1605
+ goalID: goal.id,
1606
+ type: "goal.status_changed",
1607
+ from: "active",
1608
+ to: "budget_limited",
1609
+ timestamp: new Date().toISOString(),
1610
+ revision: stoppedState.revision
1611
+ });
1612
+ await logServerEvent(directory, "maintenance.budget-exhausted", {
1613
+ goalID: goal.id,
1614
+ reason,
1615
+ tokensUsed: stoppedGoal.tokensUsed,
1616
+ costUsed: stoppedGoal.costUsed
1617
+ });
1618
+ if (shouldNotify) {
1619
+ await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" stopped: ${reason}. Worker aborted, status: budget_limited. Resume with resume_goal to continue spending.`);
1620
+ }
1621
+ return true;
1622
+ }
1443
1623
  async function maintenance() {
1444
1624
  syncWorkerSessionsFromService();
1445
1625
  if (knownWorkerSessions.size === 0)
@@ -1476,6 +1656,11 @@ function createLoopEngine(options) {
1476
1656
  const runtime = state.runtimes.find((r) => r.goalID === goal.id);
1477
1657
  if (!runtime)
1478
1658
  continue;
1659
+ if (goal.workerSessionID) {
1660
+ const stopped = await accountAndEnforceBudget(goal);
1661
+ if (stopped)
1662
+ continue;
1663
+ }
1479
1664
  if (runtime.phase === "waiting_retry" && runtime.retryAfter) {
1480
1665
  if (Date.now() >= Date.parse(runtime.retryAfter)) {
1481
1666
  await mutateState(directory, `retry-ready:${goal.id}`, async (s) => {
@@ -1558,6 +1743,87 @@ function createLoopEngine(options) {
1558
1743
  });
1559
1744
  await logServerEvent(directory, "maintenance.worker-recovered", { goalID: goal.id });
1560
1745
  }
1746
+ if (status === "idle" && runtime.phase === "running") {
1747
+ const lastSignal = Math.max(runtime.lastActivityAt ? Date.parse(runtime.lastActivityAt) : 0, runtime.lastRunAt ? Date.parse(runtime.lastRunAt) : 0, runtime.turnStartedAt ? Date.parse(runtime.turnStartedAt) : 0);
1748
+ const quietMs = Date.now() - lastSignal;
1749
+ if (lastSignal > 0 && quietMs > idleRecoverMs) {
1750
+ const generation = runtime.runGeneration;
1751
+ const stalledRunID = runtime.activeRunID;
1752
+ let clearedToolCalls = 0;
1753
+ let recovered = false;
1754
+ const recoveredState = await mutateState(directory, `maintenance.idle-recover:${goal.id}`, async (s) => {
1755
+ const g = s.goals.find((item) => item.id === goal.id);
1756
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1757
+ if (!g || !rt || g.status !== "active")
1758
+ return s;
1759
+ if (rt.phase !== "running")
1760
+ return s;
1761
+ if (rt.runGeneration !== generation)
1762
+ return s;
1763
+ clearedToolCalls = rt.activeToolCallIDs?.length ?? 0;
1764
+ Object.assign(rt, releaseLease(rt));
1765
+ rt.activeRunID = undefined;
1766
+ rt.lastWorkerStatus = "idle";
1767
+ rt.idleConfirmFailedAt = undefined;
1768
+ rt.idleConfirmFailedGeneration = undefined;
1769
+ rt.idleStuckNotifiedGeneration = undefined;
1770
+ recovered = true;
1771
+ return s;
1772
+ });
1773
+ if (recovered) {
1774
+ await appendEvent(directory, {
1775
+ version: 1,
1776
+ eventID: randomUUID2(),
1777
+ goalID: goal.id,
1778
+ type: "run.recovered",
1779
+ runID: stalledRunID ?? "unknown",
1780
+ quietSeconds: Math.floor(quietMs / 1000),
1781
+ clearedToolCalls,
1782
+ timestamp: new Date().toISOString(),
1783
+ revision: recoveredState.revision
1784
+ });
1785
+ await logServerEvent(directory, "maintenance.idle-recovered", {
1786
+ goalID: goal.id,
1787
+ generation,
1788
+ quietSeconds: Math.floor(quietMs / 1000),
1789
+ clearedToolCalls
1790
+ });
1791
+ await continueGoal(goal.id);
1792
+ continue;
1793
+ }
1794
+ }
1795
+ }
1796
+ if (status === "idle" && runtime.phase === "running" && runtime.idleConfirmFailedGeneration === runtime.runGeneration && runtime.idleConfirmFailedAt && runtime.idleStuckNotifiedGeneration !== runtime.runGeneration && Date.now() - Date.parse(runtime.idleConfirmFailedAt) > idleUnconfirmedMs) {
1797
+ const failedAt = runtime.idleConfirmFailedAt;
1798
+ const generation = runtime.runGeneration;
1799
+ const stuckState = await mutateState(directory, `maintenance.idle-stuck:${goal.id}`, async (s) => {
1800
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1801
+ if (!rt || rt.runGeneration !== generation || rt.phase !== "running")
1802
+ return s;
1803
+ rt.idleStuckNotifiedGeneration = generation;
1804
+ rt.updatedAt = new Date().toISOString();
1805
+ return s;
1806
+ });
1807
+ const stuckSeconds = Math.floor((Date.now() - Date.parse(failedAt)) / 1000);
1808
+ await appendEvent(directory, {
1809
+ version: 1,
1810
+ eventID: randomUUID2(),
1811
+ goalID: goal.id,
1812
+ type: "run.stuck",
1813
+ runID: runtime.activeRunID ?? "unknown",
1814
+ stuckSeconds,
1815
+ timestamp: new Date().toISOString(),
1816
+ revision: stuckState.revision
1817
+ });
1818
+ await logServerEvent(directory, "maintenance.idle-stuck", {
1819
+ goalID: goal.id,
1820
+ generation,
1821
+ stuckSeconds
1822
+ });
1823
+ const quietMinutes = Math.max(1, Math.floor(stuckSeconds / 60));
1824
+ await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" worker is idle but its turn will not confirm (unconfirmed for ${quietMinutes}m, no activity). The goal remains active; use inspect_background_goal to look, nudge_goal to re-prompt, or pause_goal to stop it.`);
1825
+ continue;
1826
+ }
1561
1827
  if (status === "idle") {
1562
1828
  if (runtime.phase === "idle") {
1563
1829
  await continueGoal(goal.id);
@@ -1566,6 +1832,37 @@ function createLoopEngine(options) {
1566
1832
  }
1567
1833
  continue;
1568
1834
  }
1835
+ if ((status === "busy" || status === "retry") && runtime.phase === "running" && runtime.activeRunID && runtime.stuckNotifiedRunID !== runtime.activeRunID) {
1836
+ const leaseExpired = !runtime.leaseExpiresAt || Date.now() >= Date.parse(runtime.leaseExpiresAt);
1837
+ const lastActive = runtime.lastActivityAt ? Date.parse(runtime.lastActivityAt) : 0;
1838
+ if (leaseExpired && Date.now() - lastActive > stuckRunningMs) {
1839
+ const stuckSeconds = Math.floor((Date.now() - lastActive) / 1000);
1840
+ const stuckState = await mutateState(directory, `maintenance.run-stuck:${goal.id}`, async (s) => {
1841
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1842
+ if (!rt || rt.activeRunID !== runtime.activeRunID)
1843
+ return s;
1844
+ rt.stuckNotifiedRunID = rt.activeRunID;
1845
+ rt.updatedAt = new Date().toISOString();
1846
+ return s;
1847
+ });
1848
+ await appendEvent(directory, {
1849
+ version: 1,
1850
+ eventID: randomUUID2(),
1851
+ goalID: goal.id,
1852
+ type: "run.stuck",
1853
+ runID: runtime.activeRunID,
1854
+ stuckSeconds,
1855
+ timestamp: new Date().toISOString(),
1856
+ revision: stuckState.revision
1857
+ });
1858
+ await logServerEvent(directory, "maintenance.run-stuck", {
1859
+ goalID: goal.id,
1860
+ runID: runtime.activeRunID,
1861
+ stuckSeconds
1862
+ });
1863
+ await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" worker may be stuck: no activity for ${Math.floor(stuckSeconds / 60)}m while reporting ${status}, lease expired. The goal remains active; use inspect_background_goal to look, nudge_goal to re-prompt, or pause_goal to stop it.`);
1864
+ }
1865
+ }
1569
1866
  }
1570
1867
  }
1571
1868
  }
@@ -1577,6 +1874,194 @@ import { randomUUID as randomUUID3 } from "crypto";
1577
1874
  import * as path2 from "path";
1578
1875
  import { promises as fs2 } from "fs";
1579
1876
 
1877
+ // src/server/host-adapter.ts
1878
+ function parseModelRef(value) {
1879
+ if (value === undefined)
1880
+ return;
1881
+ const trimmed = value.trim();
1882
+ if (!trimmed)
1883
+ return;
1884
+ const slash = trimmed.indexOf("/");
1885
+ if (slash <= 0 || slash >= trimmed.length - 1) {
1886
+ throw new Error(`Invalid model "${value}". Use "providerID/modelID" (e.g. "openai/gpt-5.6-sol").`);
1887
+ }
1888
+ const providerID = trimmed.slice(0, slash).trim();
1889
+ const modelID = trimmed.slice(slash + 1).trim();
1890
+ if (!providerID || !modelID || /\s/.test(providerID) || /\s/.test(modelID)) {
1891
+ throw new Error(`Invalid model "${value}". Use "providerID/modelID" (e.g. "openai/gpt-5.6-sol").`);
1892
+ }
1893
+ return { providerID, modelID };
1894
+ }
1895
+ var recentParentNotifies = new Map;
1896
+ function shouldDedupParentNotify(ownerSessionID, message) {
1897
+ const key = `${ownerSessionID}:${message.slice(0, 200)}`;
1898
+ const now = Date.now();
1899
+ const last = recentParentNotifies.get(key);
1900
+ if (last !== undefined && now - last < 60000)
1901
+ return true;
1902
+ recentParentNotifies.set(key, now);
1903
+ if (recentParentNotifies.size > 200) {
1904
+ for (const [k, t] of recentParentNotifies.entries())
1905
+ if (now - t > 60000)
1906
+ recentParentNotifies.delete(k);
1907
+ }
1908
+ return false;
1909
+ }
1910
+ function createRealHost(client, directory) {
1911
+ return {
1912
+ async createWorker({ parentID, title, agent, model }) {
1913
+ try {
1914
+ const body = { parentID, title };
1915
+ if (agent)
1916
+ body.agent = agent;
1917
+ if (model)
1918
+ body.model = { id: model.modelID, providerID: model.providerID };
1919
+ const result = await withTimeout(client.session.create({ body }), 1e4, "OpenCode session.create");
1920
+ const data = result?.data;
1921
+ if (result?.error || !data?.id) {
1922
+ const detail = describeError(result?.error || "response contained no session ID");
1923
+ await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
1924
+ throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
1925
+ }
1926
+ await logServerEvent(directory, "worker.created", { parentID, workerSessionID: data.id, title });
1927
+ return data.id;
1928
+ } catch (error) {
1929
+ if (error instanceof Error && error.message.startsWith("OpenCode session.create failed"))
1930
+ throw error;
1931
+ const detail = describeError(error);
1932
+ await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
1933
+ throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
1934
+ }
1935
+ },
1936
+ async promptWorker({ sessionID, prompt, messageID, model, agent }) {
1937
+ const body = {
1938
+ parts: [{ type: "text", text: prompt }]
1939
+ };
1940
+ if (messageID) {
1941
+ const collapsed = messageID.replace(/^(msg-)+/, "msg-");
1942
+ body.messageID = collapsed.startsWith("msg-") ? collapsed : `msg-${messageID}`;
1943
+ }
1944
+ if (model)
1945
+ body.model = model;
1946
+ if (agent)
1947
+ body.agent = agent;
1948
+ const result = await withTimeout(client.session.promptAsync({
1949
+ path: { id: sessionID },
1950
+ body
1951
+ }), 1e4, "OpenCode session.promptAsync");
1952
+ if (result?.error) {
1953
+ const detail = describeError(result.error);
1954
+ await logServerEvent(directory, "worker.prompt.failed", { sessionID, detail });
1955
+ throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`);
1956
+ }
1957
+ await logServerEvent(directory, "worker.prompted", { sessionID });
1958
+ return { messageID: result?.data?.messageID };
1959
+ },
1960
+ async sessionStatus(sessionID) {
1961
+ try {
1962
+ const result = await client.session.status({});
1963
+ if (result?.error)
1964
+ return "unknown";
1965
+ const data = result?.data;
1966
+ if (!data || typeof data !== "object" || Array.isArray(data))
1967
+ return "unknown";
1968
+ const status = data[sessionID];
1969
+ if (status === undefined || status === null)
1970
+ return "idle";
1971
+ if (typeof status !== "object" || Array.isArray(status))
1972
+ return "unknown";
1973
+ const type = status.type;
1974
+ if (type === "busy" || type === "retry")
1975
+ return type;
1976
+ if (type === "idle")
1977
+ return "idle";
1978
+ return "unknown";
1979
+ } catch {
1980
+ return "unknown";
1981
+ }
1982
+ },
1983
+ async abortSession(sessionID) {
1984
+ try {
1985
+ await client.session.abort({ path: { id: sessionID } });
1986
+ } catch {}
1987
+ },
1988
+ async readMessages(sessionID, limit = 10) {
1989
+ try {
1990
+ const result = await client.session.messages({
1991
+ path: { id: sessionID },
1992
+ query: { limit }
1993
+ });
1994
+ const data = result?.data;
1995
+ if (!Array.isArray(data))
1996
+ return [];
1997
+ return data.map((m) => {
1998
+ const createdMs = m.info?.time?.created;
1999
+ const completedMs = m.info?.time?.completed;
2000
+ const tokens = m.info?.tokens;
2001
+ return {
2002
+ role: m.info?.role || "assistant",
2003
+ content: m.parts?.filter((p) => p.type === "text").map((p) => p.text).join(`
2004
+ `) || "",
2005
+ timestamp: completedMs || createdMs ? new Date(completedMs || createdMs).toISOString() : undefined,
2006
+ messageID: m.info?.id || m.id,
2007
+ parentMessageID: m.info?.parentID,
2008
+ completedAt: completedMs ? new Date(completedMs).toISOString() : undefined,
2009
+ tokens: tokens && typeof tokens.input === "number" ? {
2010
+ input: tokens.input || 0,
2011
+ output: tokens.output || 0,
2012
+ reasoning: tokens.reasoning || 0,
2013
+ cacheRead: tokens.cache?.read || 0,
2014
+ cacheWrite: tokens.cache?.write || 0
2015
+ } : undefined,
2016
+ cost: typeof m.info?.cost === "number" ? m.info.cost : undefined,
2017
+ durationMs: typeof createdMs === "number" && typeof completedMs === "number" && completedMs >= createdMs ? completedMs - createdMs : undefined
2018
+ };
2019
+ });
2020
+ } catch {
2021
+ return [];
2022
+ }
2023
+ },
2024
+ async compactSession(sessionID) {
2025
+ try {
2026
+ await client.session.compact({ sessionID });
2027
+ } catch {}
2028
+ },
2029
+ async notifyOwner(ownerSessionID, message) {
2030
+ if (shouldDedupParentNotify(ownerSessionID, message)) {
2031
+ await logServerEvent(directory, "parent.notify.deduped", { ownerSessionID, preview: message.slice(0, 160) });
2032
+ return;
2033
+ }
2034
+ try {
2035
+ const result = await withTimeout(client.session.promptAsync({
2036
+ path: { id: ownerSessionID },
2037
+ body: { parts: [{ type: "text", text: message }] }
2038
+ }), 1e4, "OpenCode parent notify");
2039
+ if (result?.error) {
2040
+ await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(result.error) });
2041
+ } else {
2042
+ await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
2043
+ }
2044
+ } catch (error) {
2045
+ await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
2046
+ }
2047
+ }
2048
+ };
2049
+ }
2050
+ async function withTimeout(promise, timeoutMs, operation) {
2051
+ let timer;
2052
+ try {
2053
+ return await Promise.race([
2054
+ promise,
2055
+ new Promise((_, reject) => {
2056
+ timer = setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs);
2057
+ })
2058
+ ]);
2059
+ } finally {
2060
+ if (timer)
2061
+ clearTimeout(timer);
2062
+ }
2063
+ }
2064
+
1580
2065
  // src/server/worker-session.ts
1581
2066
  function createWorkerManager(host) {
1582
2067
  return {
@@ -1584,7 +2069,8 @@ function createWorkerManager(host) {
1584
2069
  const workerSessionID = await host.createWorker({
1585
2070
  parentID: goal.ownerSessionID,
1586
2071
  title: `loopd: ${goal.name}`,
1587
- agent: goal.config.agent
2072
+ agent: goal.config.agent,
2073
+ model: parseModelRef(goal.config.model)
1588
2074
  });
1589
2075
  return {
1590
2076
  goalID: goal.id,
@@ -1598,7 +2084,18 @@ function createWorkerManager(host) {
1598
2084
  sessionID: worker.workerSessionID,
1599
2085
  prompt,
1600
2086
  messageID: runtime.activePromptMessageID,
1601
- agent: goal.config.agent
2087
+ agent: goal.config.agent,
2088
+ model: parseModelRef(goal.config.model)
2089
+ });
2090
+ return result;
2091
+ },
2092
+ async sendBare(worker, goal, runtime, text) {
2093
+ const result = await host.promptWorker({
2094
+ sessionID: worker.workerSessionID,
2095
+ prompt: text,
2096
+ messageID: runtime.activePromptMessageID,
2097
+ agent: goal.config.agent,
2098
+ model: parseModelRef(goal.config.model)
1602
2099
  });
1603
2100
  return result;
1604
2101
  },
@@ -1832,6 +2329,8 @@ function createGoalService(host) {
1832
2329
  ...input.config
1833
2330
  }
1834
2331
  });
2332
+ if (typeof input.costBudget === "number")
2333
+ goal.costBudget = input.costBudget;
1835
2334
  const artifactDir = goalArtifactDir(directory, id);
1836
2335
  goal.config.artifactDir = artifactDir;
1837
2336
  if (!goal.config.progressFile)
@@ -1940,6 +2439,63 @@ function createGoalService(host) {
1940
2439
  }
1941
2440
  return { goal, worker };
1942
2441
  }
2442
+ async function accountTailUsage(directory, goalID, runtime, tail) {
2443
+ const seenIDs = new Set(runtime.accountedMessageIDs ?? []);
2444
+ let tokenDelta = 0;
2445
+ let costDelta = 0;
2446
+ let timeDeltaSeconds = 0;
2447
+ const counted = [];
2448
+ for (const m of tail) {
2449
+ if (m.role !== "assistant" || !m.messageID || !m.completedAt)
2450
+ continue;
2451
+ if (seenIDs.has(m.messageID))
2452
+ continue;
2453
+ seenIDs.add(m.messageID);
2454
+ counted.push(m.messageID);
2455
+ if (m.tokens) {
2456
+ tokenDelta += (m.tokens.input || 0) + (m.tokens.output || 0) + (m.tokens.reasoning || 0) + (m.tokens.cacheRead || 0) + (m.tokens.cacheWrite || 0);
2457
+ }
2458
+ if (typeof m.cost === "number")
2459
+ costDelta += m.cost;
2460
+ if (typeof m.durationMs === "number")
2461
+ timeDeltaSeconds += m.durationMs / 1000;
2462
+ }
2463
+ if (counted.length === 0)
2464
+ return { tokenDelta: 0, costDelta: 0, timeDeltaSeconds: 0, counted };
2465
+ const mergedWatermark = [...runtime.accountedMessageIDs ?? [], ...counted].slice(-200);
2466
+ await mutateState(directory, `turn.account-usage:${goalID}`, async (s) => {
2467
+ const g = s.goals.find((item) => item.id === goalID);
2468
+ if (g) {
2469
+ g.tokensUsed += tokenDelta;
2470
+ g.costUsed = (g.costUsed ?? 0) + costDelta;
2471
+ g.timeUsedSeconds += timeDeltaSeconds;
2472
+ g.updatedAt = new Date().toISOString();
2473
+ }
2474
+ const rt = s.runtimes.find((item) => item.goalID === goalID);
2475
+ if (rt) {
2476
+ rt.turnTokensUsed = (rt.turnTokensUsed ?? 0) + tokenDelta;
2477
+ rt.accountedMessageIDs = mergedWatermark;
2478
+ rt.updatedAt = new Date().toISOString();
2479
+ }
2480
+ return s;
2481
+ });
2482
+ return { tokenDelta, costDelta, timeDeltaSeconds, counted };
2483
+ }
2484
+ async function accountUsageUnlocked(directory, goalID) {
2485
+ const preState = await readState(directory);
2486
+ const goal = preState.goals.find((g) => g.id === goalID);
2487
+ const runtime = preState.runtimes.find((r) => r.goalID === goalID);
2488
+ if (!goal?.workerSessionID || !runtime) {
2489
+ return { tokenDelta: 0, costDelta: 0, timeDeltaSeconds: 0, counted: [] };
2490
+ }
2491
+ let tail = [];
2492
+ try {
2493
+ tail = await host.readMessages(goal.workerSessionID, 50);
2494
+ } catch {
2495
+ return { tokenDelta: 0, costDelta: 0, timeDeltaSeconds: 0, counted: [] };
2496
+ }
2497
+ return accountTailUsage(directory, goalID, runtime, tail);
2498
+ }
1943
2499
  async function continueTurnUnlocked(directory, goalID, opts) {
1944
2500
  const preState = await readState(directory);
1945
2501
  const goal = preState.goals.find((g) => g.id === goalID);
@@ -2001,6 +2557,20 @@ function createGoalService(host) {
2001
2557
  timestamp: new Date().toISOString(),
2002
2558
  revision: state.revision
2003
2559
  });
2560
+ if (opts?.bare) {
2561
+ const bareWords = await drainGoalInbox(directory, goalID);
2562
+ const bareText = bareWords.join(`
2563
+ `).trim();
2564
+ if (bareText) {
2565
+ try {
2566
+ await workers.sendBare(session, freshGoal, freshRuntime, bareText);
2567
+ } catch (error) {
2568
+ await recordPromptFailure(directory, goalID, error);
2569
+ throw error;
2570
+ }
2571
+ return;
2572
+ }
2573
+ }
2004
2574
  const inboxMessages = await drainGoalInbox(directory, goalID);
2005
2575
  const allEvents = await readEvents(directory, 200);
2006
2576
  const progressHistory = allEvents.filter((e) => e.goalID === goalID && e.type === "goal.progress").map((e) => ({
@@ -2014,6 +2584,7 @@ function createGoalService(host) {
2014
2584
  } catch {
2015
2585
  transcriptTail = [];
2016
2586
  }
2587
+ await accountTailUsage(directory, goalID, freshRuntime, transcriptTail ?? []);
2017
2588
  let verification;
2018
2589
  try {
2019
2590
  const artifactDir = freshGoal.config.artifactDir;
@@ -2307,6 +2878,40 @@ function createGoalService(host) {
2307
2878
  await continueTurnUnlocked(directory, goalID, { force: true });
2308
2879
  return { ok: true, message: `Re-prompted worker for "${freshGoal.name}".` };
2309
2880
  }
2881
+ async function sendUnlocked(directory, goalID, text) {
2882
+ const trimmed = text.trim();
2883
+ if (!trimmed)
2884
+ return { ok: false, message: "Nothing to send." };
2885
+ const preState = await readState(directory);
2886
+ const goal = preState.goals.find((g) => g.id === goalID);
2887
+ if (!goal)
2888
+ return { ok: false, message: "Goal not found." };
2889
+ await appendGoalInbox(directory, goalID, "user", trimmed);
2890
+ if (goal.status !== "active") {
2891
+ return { ok: true, message: `Queued for "${goal.name}" (goal is ${goal.status}; delivers on the next active turn).` };
2892
+ }
2893
+ const cleared = await mutateState(directory, `goal.send:${goalID}`, async (s) => {
2894
+ const rt = s.runtimes.find((r) => r.goalID === goalID);
2895
+ if (!rt)
2896
+ return s;
2897
+ rt.phase = "idle";
2898
+ rt.activeRunID = undefined;
2899
+ rt.idleCandidateAt = undefined;
2900
+ rt.activePromptMessageID = undefined;
2901
+ rt.activeToolCallIDs = [];
2902
+ rt.updatedAt = new Date().toISOString();
2903
+ return s;
2904
+ });
2905
+ const freshGoal = cleared.goals.find((g) => g.id === goalID);
2906
+ if (!freshGoal || !freshGoal.workerSessionID) {
2907
+ return { ok: true, message: `Queued for "${goal.name}" (no worker session yet; delivers on the next turn).` };
2908
+ }
2909
+ await continueTurnUnlocked(directory, goalID, { force: true, bare: true });
2910
+ return { ok: true, message: `Sent to "${freshGoal.name}" as its own turn.` };
2911
+ }
2912
+ function sendUserMessage(directory, goalID, text) {
2913
+ return withGoalOperation(goalID, () => sendUnlocked(directory, goalID, text));
2914
+ }
2310
2915
  function continueTurn(directory, goalID, opts) {
2311
2916
  return withGoalOperation(goalID, () => continueTurnUnlocked(directory, goalID, opts));
2312
2917
  }
@@ -2325,7 +2930,48 @@ function createGoalService(host) {
2325
2930
  function nudge(directory, goalID) {
2326
2931
  return withGoalOperation(goalID, () => nudgeUnlocked(directory, goalID));
2327
2932
  }
2328
- return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile };
2933
+ async function abortWorkerUnlocked(directory, goalID) {
2934
+ const preState = await readState(directory);
2935
+ const goal = preState.goals.find((g) => g.id === goalID);
2936
+ if (!goal)
2937
+ return { ok: false, message: "Goal not found." };
2938
+ const workerID = sessions.get(goalID)?.workerSessionID || goal.workerSessionID;
2939
+ if (!workerID)
2940
+ return { ok: false, message: `Goal "${goal.name}" has no worker session to abort.` };
2941
+ try {
2942
+ await workers.abortWorker(workerID);
2943
+ } catch {}
2944
+ sessions.delete(goalID);
2945
+ await mutateState(directory, `goal.abort-worker:${goalID}`, async (s) => {
2946
+ const rt = s.runtimes.find((r) => r.goalID === goalID);
2947
+ if (rt) {
2948
+ Object.assign(rt, releaseLease(rt));
2949
+ rt.activeRunID = undefined;
2950
+ rt.activePromptMessageID = undefined;
2951
+ rt.activeToolCallIDs = [];
2952
+ rt.idleCandidateAt = undefined;
2953
+ rt.idleCandidateGeneration = undefined;
2954
+ rt.workerAbortedAt = new Date().toISOString();
2955
+ rt.updatedAt = new Date().toISOString();
2956
+ }
2957
+ const g = s.goals.find((item) => item.id === goalID);
2958
+ if (g)
2959
+ g.updatedAt = new Date().toISOString();
2960
+ return s;
2961
+ });
2962
+ await logServerEvent(directory, "worker.aborted-manual", { goalID, workerSessionID: workerID });
2963
+ return {
2964
+ ok: true,
2965
+ message: `Worker run for "${goal.name}" aborted, session kept for inspection (status unchanged: ${goal.status}).` + (goal.status === "active" ? " Engine continues the same session next turn." : "")
2966
+ };
2967
+ }
2968
+ function abortWorker(directory, goalID) {
2969
+ return withGoalOperation(goalID, () => abortWorkerUnlocked(directory, goalID));
2970
+ }
2971
+ function accountUsage(directory, goalID) {
2972
+ return withGoalOperation(goalID, () => accountUsageUnlocked(directory, goalID));
2973
+ }
2974
+ return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage };
2329
2975
  }
2330
2976
 
2331
2977
  // src/application/schedule-worker.ts
@@ -2442,161 +3088,6 @@ function createScheduleWorker(options) {
2442
3088
  return { start, stop, isRunning, tick };
2443
3089
  }
2444
3090
 
2445
- // src/server/host-adapter.ts
2446
- var recentParentNotifies = new Map;
2447
- function shouldDedupParentNotify(ownerSessionID, message) {
2448
- const key = `${ownerSessionID}:${message.slice(0, 200)}`;
2449
- const now = Date.now();
2450
- const last = recentParentNotifies.get(key);
2451
- if (last !== undefined && now - last < 60000)
2452
- return true;
2453
- recentParentNotifies.set(key, now);
2454
- if (recentParentNotifies.size > 200) {
2455
- for (const [k, t] of recentParentNotifies.entries())
2456
- if (now - t > 60000)
2457
- recentParentNotifies.delete(k);
2458
- }
2459
- return false;
2460
- }
2461
- function createRealHost(client, directory) {
2462
- return {
2463
- async createWorker({ parentID, title, agent }) {
2464
- try {
2465
- const body = { parentID, title };
2466
- if (agent)
2467
- body.agent = agent;
2468
- const result = await withTimeout(client.session.create({ body }), 1e4, "OpenCode session.create");
2469
- const data = result?.data;
2470
- if (result?.error || !data?.id) {
2471
- const detail = describeError(result?.error || "response contained no session ID");
2472
- await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
2473
- throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
2474
- }
2475
- await logServerEvent(directory, "worker.created", { parentID, workerSessionID: data.id, title });
2476
- return data.id;
2477
- } catch (error) {
2478
- if (error instanceof Error && error.message.startsWith("OpenCode session.create failed"))
2479
- throw error;
2480
- const detail = describeError(error);
2481
- await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
2482
- throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
2483
- }
2484
- },
2485
- async promptWorker({ sessionID, prompt, messageID, model, agent }) {
2486
- const body = {
2487
- parts: [{ type: "text", text: prompt }]
2488
- };
2489
- if (messageID) {
2490
- const collapsed = messageID.replace(/^(msg-)+/, "msg-");
2491
- body.messageID = collapsed.startsWith("msg-") ? collapsed : `msg-${messageID}`;
2492
- }
2493
- if (model)
2494
- body.model = model;
2495
- if (agent)
2496
- body.agent = agent;
2497
- const result = await withTimeout(client.session.promptAsync({
2498
- path: { id: sessionID },
2499
- body
2500
- }), 1e4, "OpenCode session.promptAsync");
2501
- if (result?.error) {
2502
- const detail = describeError(result.error);
2503
- await logServerEvent(directory, "worker.prompt.failed", { sessionID, detail });
2504
- throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`);
2505
- }
2506
- await logServerEvent(directory, "worker.prompted", { sessionID });
2507
- return { messageID: result?.data?.messageID };
2508
- },
2509
- async sessionStatus(sessionID) {
2510
- try {
2511
- const result = await client.session.status({});
2512
- if (result?.error)
2513
- return "unknown";
2514
- const data = result?.data;
2515
- if (!data || typeof data !== "object" || Array.isArray(data))
2516
- return "unknown";
2517
- const status = data[sessionID];
2518
- if (status === undefined || status === null)
2519
- return "idle";
2520
- if (typeof status !== "object" || Array.isArray(status))
2521
- return "unknown";
2522
- const type = status.type;
2523
- if (type === "busy" || type === "retry")
2524
- return type;
2525
- if (type === "idle")
2526
- return "idle";
2527
- return "unknown";
2528
- } catch {
2529
- return "unknown";
2530
- }
2531
- },
2532
- async abortSession(sessionID) {
2533
- try {
2534
- await client.session.abort({ path: { id: sessionID } });
2535
- } catch {}
2536
- },
2537
- async readMessages(sessionID, limit = 10) {
2538
- try {
2539
- const result = await client.session.messages({
2540
- path: { id: sessionID },
2541
- query: { limit }
2542
- });
2543
- const data = result?.data;
2544
- if (!Array.isArray(data))
2545
- return [];
2546
- return data.map((m) => ({
2547
- role: m.info?.role || "assistant",
2548
- content: m.parts?.filter((p) => p.type === "text").map((p) => p.text).join(`
2549
- `) || "",
2550
- timestamp: m.info?.time?.completed || m.info?.time?.created ? new Date(m.info.time.completed || m.info.time.created).toISOString() : undefined,
2551
- messageID: m.info?.id || m.id,
2552
- parentMessageID: m.info?.parentID,
2553
- completedAt: m.info?.time?.completed ? new Date(m.info.time.completed).toISOString() : undefined
2554
- }));
2555
- } catch {
2556
- return [];
2557
- }
2558
- },
2559
- async compactSession(sessionID) {
2560
- try {
2561
- await client.session.compact({ sessionID });
2562
- } catch {}
2563
- },
2564
- async notifyOwner(ownerSessionID, message) {
2565
- if (shouldDedupParentNotify(ownerSessionID, message)) {
2566
- await logServerEvent(directory, "parent.notify.deduped", { ownerSessionID, preview: message.slice(0, 160) });
2567
- return;
2568
- }
2569
- try {
2570
- const result = await withTimeout(client.session.promptAsync({
2571
- path: { id: ownerSessionID },
2572
- body: { parts: [{ type: "text", text: message }] }
2573
- }), 1e4, "OpenCode parent notify");
2574
- if (result?.error) {
2575
- await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(result.error) });
2576
- } else {
2577
- await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
2578
- }
2579
- } catch (error) {
2580
- await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
2581
- }
2582
- }
2583
- };
2584
- }
2585
- async function withTimeout(promise, timeoutMs, operation) {
2586
- let timer;
2587
- try {
2588
- return await Promise.race([
2589
- promise,
2590
- new Promise((_, reject) => {
2591
- timer = setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs);
2592
- })
2593
- ]);
2594
- } finally {
2595
- if (timer)
2596
- clearTimeout(timer);
2597
- }
2598
- }
2599
-
2600
3091
  // src/server/goal-tools.ts
2601
3092
  import { randomUUID as randomUUID5 } from "crypto";
2602
3093
  import { tool } from "@opencode-ai/plugin/tool";
@@ -2617,11 +3108,13 @@ var execAsync = promisify(execChild);
2617
3108
  function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2618
3109
  return {
2619
3110
  loopd_create_goal: tool({
2620
- description: "Create a new background loop goal (contract: objective + checks + agent + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Call this after clarifying the contract with the user. " + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals are serialized (only one active writer) and require checks. " + "agent is optional \u2014 uses the parent session's agent if omitted, or configure plugin defaultAgent in opencode.jsonc.",
3111
+ description: "Create a new background loop goal (contract: objective + checks + agent/model + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Call this after clarifying the contract with the user. " + "Worker identity is free-form: agent is any OpenCode agent name (built-in, ~/.config/opencode/agents/*.md, or opencode.jsonc agent.* \u2014 discover with `opencode agent list`), " + 'model is any "providerID/modelID" (discover with `opencode models [provider]`). Both are sent on every worker prompt. ' + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals are serialized (only one active writer) and require checks. " + "agent/model are optional \u2014 fall back to the parent session's agent/model, or plugin defaultAgent/defaultModel in opencode.jsonc.",
2621
3112
  args: {
2622
3113
  name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
2623
3114
  objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
2624
- agent: tool.schema.string().optional().describe("Agent to run the worker as. Optional \u2014 uses parent session's agent if omitted, or configure plugin defaultAgent in opencode.jsonc."),
3115
+ agent: tool.schema.string().optional().describe(`Agent to run the worker as (e.g. "researcher", "smart-agent"). Optional \u2014 uses parent session's agent if omitted, or plugin defaultAgent.`),
3116
+ model: tool.schema.string().optional().describe(`Model to run the worker as, as "providerID/modelID" (e.g. "openai/gpt-5.6-sol", "ollama/qwen3.8:27b"). Optional \u2014 uses parent session's model if omitted, or plugin defaultModel.`),
3117
+ costBudget: tool.schema.number().optional().describe("Max provider cost in dollars before the engine stops the goal as budget_limited (e.g. 0.5). Optional \u2014 unlimited if omitted."),
2625
3118
  checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
2626
3119
  checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
2627
3120
  workspaceWrite: tool.schema.boolean().optional().describe("Whether this goal edits the shared project workspace. Defaults to true; explicitly set false for artifact-only/read-only work."),
@@ -2650,6 +3143,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2650
3143
  };
2651
3144
  if (args.agent)
2652
3145
  config.agent = args.agent;
3146
+ if (args.model)
3147
+ config.model = args.model;
2653
3148
  if (args.checks)
2654
3149
  config.checks = args.checks;
2655
3150
  if (args.checkCwd)
@@ -2690,6 +3185,16 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2690
3185
  output: JSON.stringify({ ok: false, message: "scheduleMaxRuns requires scheduleEveryMs", errorCode: "invalid_schedule" })
2691
3186
  };
2692
3187
  }
3188
+ let costBudget;
3189
+ if (args.costBudget !== undefined) {
3190
+ if (typeof args.costBudget !== "number" || !Number.isFinite(args.costBudget) || args.costBudget <= 0) {
3191
+ return {
3192
+ title: "Goal not created",
3193
+ output: JSON.stringify({ ok: false, message: "costBudget must be a positive number of dollars", errorCode: "invalid_cost_budget" })
3194
+ };
3195
+ }
3196
+ costBudget = args.costBudget;
3197
+ }
2693
3198
  const resolution = resolveGoalCreationConfig({
2694
3199
  directory: dir,
2695
3200
  objective: args.objective,
@@ -2711,7 +3216,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2711
3216
  name: args.name,
2712
3217
  objective: args.objective,
2713
3218
  ownerSessionID: sessionID,
2714
- config: resolution.config
3219
+ config: resolution.config,
3220
+ costBudget
2715
3221
  });
2716
3222
  return {
2717
3223
  title: "Goal created",
@@ -2721,6 +3227,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2721
3227
  workerSessionID: worker.workerSessionID,
2722
3228
  artifactDir: goal.config.artifactDir,
2723
3229
  agent: resolution.config.agent,
3230
+ model: resolution.config.model,
3231
+ costBudget: goal.costBudget,
2724
3232
  checks: resolution.config.checks || [],
2725
3233
  workspaceWrite: resolution.config.workspaceWrite,
2726
3234
  defaultsApplied: resolution.defaultsApplied,
@@ -2933,11 +3441,17 @@ ${failureDetails.slice(0, 500)}`,
2933
3441
  evidence: args.evidence,
2934
3442
  at: new Date().toISOString()
2935
3443
  };
3444
+ const finalUsage = await goalService.accountUsage(dir, goal.id);
3445
+ goal.tokensUsed += finalUsage.tokenDelta;
3446
+ goal.costUsed = (goal.costUsed ?? 0) + finalUsage.costDelta;
3447
+ goal.timeUsedSeconds += finalUsage.timeDeltaSeconds;
2936
3448
  const runtime = state.runtimes.find((r) => r.goalID === goal.id);
2937
3449
  if (runtime) {
2938
3450
  Object.assign(runtime, releaseLease(runtime));
2939
3451
  runtime.activeRunID = undefined;
2940
3452
  runtime.lastError = undefined;
3453
+ runtime.turnTokensUsed = (runtime.turnTokensUsed ?? 0) + finalUsage.tokenDelta;
3454
+ runtime.accountedMessageIDs = [...runtime.accountedMessageIDs ?? [], ...finalUsage.counted].slice(-200);
2941
3455
  runtime.updatedAt = new Date().toISOString();
2942
3456
  const schedule = goal.config.schedule;
2943
3457
  if (schedule && typeof schedule.everyMs === "number" && schedule.everyMs >= 1000) {
@@ -3022,11 +3536,17 @@ ${failureDetails.slice(0, 500)}`,
3022
3536
  needed: args.needed,
3023
3537
  at: new Date().toISOString()
3024
3538
  };
3539
+ const finalUsage = await goalService.accountUsage(dir, goal.id);
3540
+ goal.tokensUsed += finalUsage.tokenDelta;
3541
+ goal.costUsed = (goal.costUsed ?? 0) + finalUsage.costDelta;
3542
+ goal.timeUsedSeconds += finalUsage.timeDeltaSeconds;
3025
3543
  const runtime = state.runtimes.find((r) => r.goalID === goal.id);
3026
3544
  if (runtime) {
3027
3545
  Object.assign(runtime, releaseLease(runtime));
3028
3546
  runtime.activeRunID = undefined;
3029
3547
  runtime.lastError = undefined;
3548
+ runtime.turnTokensUsed = (runtime.turnTokensUsed ?? 0) + finalUsage.tokenDelta;
3549
+ runtime.accountedMessageIDs = [...runtime.accountedMessageIDs ?? [], ...finalUsage.counted].slice(-200);
3030
3550
  runtime.updatedAt = new Date().toISOString();
3031
3551
  }
3032
3552
  await writeState(dir, state);
@@ -3076,6 +3596,7 @@ function formatGoalStructured(goal, runtime) {
3076
3596
  checkCwd: goal.config.checkCwd,
3077
3597
  workspaceWrite: goal.config.workspaceWrite,
3078
3598
  agent: goal.config.agent,
3599
+ model: goal.config.model,
3079
3600
  maxTurns: goal.config.maxTurns,
3080
3601
  maxNoProgress: goal.config.maxNoProgress,
3081
3602
  maxFailures: goal.config.maxFailures,
@@ -3087,6 +3608,9 @@ function formatGoalStructured(goal, runtime) {
3087
3608
  completionEvidence: goal.completionEvidence,
3088
3609
  blocker: goal.blocker,
3089
3610
  tokensUsed: goal.tokensUsed,
3611
+ tokenBudget: goal.tokenBudget,
3612
+ costUsed: goal.costUsed ?? 0,
3613
+ costBudget: goal.costBudget,
3090
3614
  timeUsedSeconds: goal.timeUsedSeconds
3091
3615
  };
3092
3616
  if (runtime) {
@@ -3186,6 +3710,8 @@ function ownerTools(options) {
3186
3710
  turn: runtime?.runCount ?? 0,
3187
3711
  budgetTurnCount: runtime?.budgetTurnCount ?? 0,
3188
3712
  maxTurns: g.config.maxTurns,
3713
+ agent: g.config.agent,
3714
+ model: g.config.model,
3189
3715
  lastProgress: g.lastProgress?.summary?.slice(0, 120),
3190
3716
  lastProgressAt: g.lastProgress?.at,
3191
3717
  blocker: g.blocker?.reason?.slice(0, 120),
@@ -3206,7 +3732,7 @@ function ownerTools(options) {
3206
3732
  }
3207
3733
  }),
3208
3734
  inspect_background_goal: tool2({
3209
- description: "Inspect a goal\u2019s full contract, runtime, and live execution state: objective, config{agent,checks,checkCwd,workspaceWrite,limits}, progress, blocker, runtime{phase,runCount,budgetTurnCount,runGeneration,evaluatorRejectionCount,unknownStatusCount,lastActivityAt,activePromptMessageID}, plus live transcriptTail, activeToolCallIDs, progressHistory, artifactSummary, pendingInbox. Single-call follow-up for parent to see what child is actually doing.",
3735
+ description: "Inspect a goal\u2019s full contract, runtime, and live execution state: objective, config{agent,model,checks,checkCwd,workspaceWrite,limits}, progress, blocker, runtime{phase,runCount,budgetTurnCount,runGeneration,evaluatorRejectionCount,unknownStatusCount,lastActivityAt,activePromptMessageID}, plus live transcriptTail, activeToolCallIDs, progressHistory, artifactSummary, pendingInbox. Single-call follow-up for parent to see what child is actually doing.",
3210
3736
  args: {
3211
3737
  goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to inspect the first active goal."),
3212
3738
  includeTranscript: tool2.schema.boolean().optional().describe("Include live transcript tail (adds ~100ms). Default true. Set false for fast metadata-only."),
@@ -3261,12 +3787,16 @@ function ownerTools(options) {
3261
3787
  checkCwd: goal.config.checkCwd,
3262
3788
  workspaceWrite: goal.config.workspaceWrite,
3263
3789
  agent: goal.config.agent,
3790
+ model: goal.config.model,
3264
3791
  schedule: goal.config.schedule
3265
3792
  },
3266
3793
  lastProgress: goal.lastProgress,
3267
3794
  completionEvidence: goal.completionEvidence,
3268
3795
  blocker: goal.blocker,
3269
3796
  tokensUsed: goal.tokensUsed,
3797
+ tokenBudget: goal.tokenBudget,
3798
+ costUsed: goal.costUsed ?? 0,
3799
+ costBudget: goal.costBudget,
3270
3800
  timeUsedSeconds: goal.timeUsedSeconds,
3271
3801
  progressHistory,
3272
3802
  pendingInbox,
@@ -3300,6 +3830,7 @@ function ownerTools(options) {
3300
3830
  unknownStatusCount: runtime.unknownStatusCount,
3301
3831
  lastUnknownStatusAt: runtime.lastUnknownStatusAt,
3302
3832
  workerUnreachableNotifiedAt: runtime.workerUnreachableNotifiedAt,
3833
+ workerAbortedAt: runtime.workerAbortedAt,
3303
3834
  retryAfter: runtime.retryAfter,
3304
3835
  forceFinishRequested: runtime.forceFinishRequested,
3305
3836
  scheduleRunCount: runtime.scheduleRunCount,
@@ -3693,9 +4224,11 @@ var server = async ({ client, directory }, pluginOptions) => {
3693
4224
  };
3694
4225
  function parsePluginDefaults(options) {
3695
4226
  const agent = typeof options?.defaultAgent === "string" ? options.defaultAgent.trim() : "";
4227
+ const model = typeof options?.defaultModel === "string" ? options.defaultModel.trim() : "";
3696
4228
  const checks = Array.isArray(options?.defaultChecks) ? options.defaultChecks.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [];
3697
4229
  return {
3698
4230
  defaultAgent: agent || undefined,
4231
+ defaultModel: model || undefined,
3699
4232
  defaultChecks: checks.length > 0 ? checks : undefined
3700
4233
  };
3701
4234
  }