@bojackduy/opencode-loopd 1.8.3 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,9 @@ 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,
33
37
  activePromptObservedAt: undefined,
34
38
  activeAssistantMessageID: undefined,
35
39
  activeAssistantCompletedAt: undefined,
@@ -479,6 +483,16 @@ function resolveGoalCreationConfig(input) {
479
483
  const explicitAgent = cleanText(requested.agent);
480
484
  const defaultAgent = cleanText(defaults.defaultAgent);
481
485
  const agent = explicitAgent || defaultAgent || undefined;
486
+ const explicitModel = cleanText(requested.model);
487
+ const defaultModel = cleanText(defaults.defaultModel);
488
+ const model = explicitModel || defaultModel || undefined;
489
+ if (model && !isValidModelRef(model)) {
490
+ return {
491
+ ok: false,
492
+ errorCode: "invalid_model",
493
+ message: `Invalid model "${model}". Use "providerID/modelID" (e.g. "openai/gpt-5.6-sol", "ollama/qwen3.8:27b"). Discover with \`opencode models\`.`
494
+ };
495
+ }
482
496
  const workspaceWrite = requested.workspaceWrite ?? true;
483
497
  const explicitChecks = cleanList(requested.checks);
484
498
  const defaultChecks = workspaceWrite ? cleanList(defaults.defaultChecks || ["bun test"]) : [];
@@ -495,16 +509,30 @@ function resolveGoalCreationConfig(input) {
495
509
  config: {
496
510
  ...requested,
497
511
  agent,
512
+ model,
498
513
  workspaceWrite,
499
514
  checks: checks.length > 0 ? checks : undefined,
500
515
  checkCwd: requested.checkCwd || (workspaceWrite ? input.directory : undefined)
501
516
  },
502
517
  defaultsApplied: {
503
518
  agent: !explicitAgent && Boolean(defaultAgent),
519
+ model: !explicitModel && Boolean(defaultModel),
504
520
  checks: explicitChecks.length === 0 && defaultChecks.length > 0
505
521
  }
506
522
  };
507
523
  }
524
+ function isValidModelRef(value) {
525
+ const slash = value.indexOf("/");
526
+ if (slash <= 0 || slash >= value.length - 1)
527
+ return false;
528
+ const providerID = value.slice(0, slash).trim();
529
+ const modelID = value.slice(slash + 1).trim();
530
+ if (!providerID || !modelID)
531
+ return false;
532
+ if (/\s/.test(providerID) || /\s/.test(modelID))
533
+ return false;
534
+ return true;
535
+ }
508
536
  function cleanText(value) {
509
537
  if (typeof value !== "string")
510
538
  return;
@@ -904,7 +932,7 @@ function isTerminal(status) {
904
932
  }
905
933
  function createGoal(input) {
906
934
  const now = new Date().toISOString();
907
- return { ...input, tokensUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
935
+ return { ...input, tokensUsed: 0, costUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
908
936
  }
909
937
  // src/application/loop-engine.ts
910
938
  var CONFIRM_IDLE_DURATION_MS = 2000;
@@ -921,6 +949,9 @@ function createLoopEngine(options) {
921
949
  const maintenanceMs = options.pollIntervalMs ?? 30000;
922
950
  const confirmIdleMs = options.confirmIdleMs ?? CONFIRM_IDLE_DURATION_MS;
923
951
  const unknownStatusThreshold = Math.max(1, options.unknownStatusThreshold ?? 3);
952
+ const stuckRunningMs = options.stuckRunningMs ?? 10 * 60000;
953
+ const idleUnconfirmedMs = options.idleUnconfirmedMs ?? 5 * 60000;
954
+ const idleRecoverMs = options.idleRecoverMs ?? 3 * 60000;
924
955
  let running = false;
925
956
  let maintenanceTimer;
926
957
  let knownWorkerSessions = new Set;
@@ -1044,6 +1075,8 @@ function createLoopEngine(options) {
1044
1075
  } else {
1045
1076
  const part = event.properties?.part;
1046
1077
  if (part?.messageID && part.messageID === rt.activeAssistantMessageID) {
1078
+ if (rt.activeAssistantCompletedAt)
1079
+ return s;
1047
1080
  Object.assign(rt, recordActivity(rt));
1048
1081
  matched = true;
1049
1082
  }
@@ -1083,7 +1116,9 @@ function createLoopEngine(options) {
1083
1116
  const elapsed = now - Date.parse(rt.idleCandidateAt);
1084
1117
  if (elapsed < confirmIdleMs)
1085
1118
  return s;
1086
- if (rt.lastActivityAt && rt.lastActivityAt > rt.idleCandidateAt) {
1119
+ const anchoredHere = Boolean(rt.activeAssistantCompletedAt);
1120
+ const quietSince = anchoredHere && rt.activeAssistantCompletedAt ? rt.activeAssistantCompletedAt : rt.idleCandidateAt;
1121
+ if (rt.lastActivityAt && rt.lastActivityAt > rt.idleCandidateAt && rt.lastActivityAt > quietSince) {
1087
1122
  rt.idleCandidateAt = undefined;
1088
1123
  rt.idleCandidateGeneration = undefined;
1089
1124
  return s;
@@ -1105,8 +1140,35 @@ function createLoopEngine(options) {
1105
1140
  });
1106
1141
  if (confirmation) {
1107
1142
  const candidate = confirmation;
1143
+ const stagedRt = afterIdle.runtimes.find((r) => r.goalID === goalID);
1144
+ const eventAnchored = stagedRt?.runGeneration === candidate.generation && Boolean(stagedRt?.activeAssistantCompletedAt);
1108
1145
  const transcript = await inspectPromptTurn(goal.workerSessionID, candidate.promptMessageID);
1109
- if (!transcript.latestUserPrompt || !candidate.assistantCompleted && !transcript.assistantCompleted) {
1146
+ const failureReason = !transcript.latestUserPrompt && !eventAnchored ? "prompt-outside-window" : !eventAnchored && !candidate.assistantCompleted && !transcript.assistantCompleted ? "assistant-incomplete" : undefined;
1147
+ if (failureReason) {
1148
+ let newlyStamped = false;
1149
+ await mutateState(directory, `idle.confirm-stamp:${goalID}`, async (s) => {
1150
+ const rt = s.runtimes.find((r) => r.goalID === goalID);
1151
+ if (!rt || rt.runGeneration !== candidate.generation)
1152
+ return s;
1153
+ if (rt.idleConfirmFailedGeneration !== candidate.generation) {
1154
+ rt.idleConfirmFailedAt = new Date().toISOString();
1155
+ rt.idleConfirmFailedGeneration = candidate.generation;
1156
+ newlyStamped = true;
1157
+ }
1158
+ return s;
1159
+ });
1160
+ if (newlyStamped) {
1161
+ await appendEvent(directory, {
1162
+ version: 1,
1163
+ eventID: randomUUID2(),
1164
+ goalID,
1165
+ type: "idle.confirm-failed",
1166
+ reason: failureReason,
1167
+ runGeneration: candidate.generation,
1168
+ timestamp: new Date().toISOString(),
1169
+ revision: afterIdle.revision
1170
+ });
1171
+ }
1110
1172
  return true;
1111
1173
  }
1112
1174
  afterIdle = await mutateState(directory, `idle.confirm:${goalID}`, async (s) => {
@@ -1124,14 +1186,19 @@ function createLoopEngine(options) {
1124
1186
  return s;
1125
1187
  if (rt.idleCandidateAt !== candidate.candidateAt)
1126
1188
  return s;
1127
- if (rt.lastActivityAt && rt.lastActivityAt > candidate.candidateAt)
1128
- return s;
1189
+ if (rt.lastActivityAt && rt.lastActivityAt > candidate.candidateAt) {
1190
+ const quiet = rt.runGeneration === candidate.generation && rt.activeAssistantCompletedAt ? rt.activeAssistantCompletedAt : candidate.candidateAt;
1191
+ if (rt.lastActivityAt > quiet)
1192
+ return s;
1193
+ }
1129
1194
  if ((rt.activeToolCallIDs?.length ?? 0) > 0)
1130
1195
  return s;
1131
1196
  completedRunID = rt.activeRunID;
1132
1197
  Object.assign(rt, releaseLease(rt));
1133
1198
  rt.activeRunID = undefined;
1134
1199
  rt.lastWorkerStatus = "idle";
1200
+ rt.idleConfirmFailedAt = undefined;
1201
+ rt.idleConfirmFailedGeneration = undefined;
1135
1202
  return s;
1136
1203
  });
1137
1204
  }
@@ -1174,6 +1241,12 @@ function createLoopEngine(options) {
1174
1241
  return true;
1175
1242
  recentForceFinishBlocked.set(blockedKey, nowBlocked);
1176
1243
  let shouldNotifyBlocked = false;
1244
+ await goalService.accountUsage(directory, goalID).catch(() => ({
1245
+ tokenDelta: 0,
1246
+ costDelta: 0,
1247
+ timeDeltaSeconds: 0,
1248
+ counted: []
1249
+ }));
1177
1250
  const blockedState = await mutateState(directory, `idle.blocked:${goalID}`, async (s) => {
1178
1251
  const g = s.goals.find((item) => item.id === goalID);
1179
1252
  if (!g)
@@ -1244,7 +1317,7 @@ function createLoopEngine(options) {
1244
1317
  return noMatch;
1245
1318
  let messages;
1246
1319
  try {
1247
- messages = await host.readMessages(workerSessionID, 50);
1320
+ messages = await host.readMessages(workerSessionID, 200);
1248
1321
  } catch {
1249
1322
  return noMatch;
1250
1323
  }
@@ -1402,6 +1475,16 @@ function createLoopEngine(options) {
1402
1475
  reason: `Token budget exhausted (${goal.tokensUsed}/${goal.tokenBudget})`
1403
1476
  };
1404
1477
  }
1478
+ if (typeof goal.costBudget === "number" && (goal.costUsed ?? 0) >= goal.costBudget) {
1479
+ goal.status = "budget_limited";
1480
+ goal.updatedAt = new Date().toISOString();
1481
+ return {
1482
+ stop: "budget",
1483
+ blocked: true,
1484
+ event: "goal.status_changed",
1485
+ reason: `Cost budget exhausted ($${(goal.costUsed ?? 0).toFixed(4)}/$${goal.costBudget})`
1486
+ };
1487
+ }
1405
1488
  return noResult;
1406
1489
  }
1407
1490
  function shouldCompact(goal, runtime) {
@@ -1440,6 +1523,67 @@ function createLoopEngine(options) {
1440
1523
  return s;
1441
1524
  });
1442
1525
  }
1526
+ async function accountAndEnforceBudget(goal) {
1527
+ try {
1528
+ await goalService.accountUsage(directory, goal.id);
1529
+ } catch {}
1530
+ const fresh = await readState(directory);
1531
+ const g = fresh.goals.find((item) => item.id === goal.id);
1532
+ if (!g || g.status !== "active")
1533
+ return false;
1534
+ const overTokens = typeof g.tokenBudget === "number" && g.tokensUsed >= g.tokenBudget;
1535
+ const overCost = typeof g.costBudget === "number" && (g.costUsed ?? 0) >= g.costBudget;
1536
+ if (!overTokens && !overCost)
1537
+ return false;
1538
+ const reason = overCost ? `Cost budget exhausted ($${(g.costUsed ?? 0).toFixed(4)}/$${g.costBudget})` : `Token budget exhausted (${g.tokensUsed}/${g.tokenBudget})`;
1539
+ if (g.workerSessionID) {
1540
+ try {
1541
+ await host.abortSession(g.workerSessionID);
1542
+ } catch {}
1543
+ }
1544
+ let shouldNotify = false;
1545
+ const stoppedState = await mutateState(directory, `maintenance.budget:${goal.id}`, async (s) => {
1546
+ const target = s.goals.find((item) => item.id === goal.id);
1547
+ if (!target || target.status !== "active")
1548
+ return s;
1549
+ target.status = "budget_limited";
1550
+ target.updatedAt = new Date().toISOString();
1551
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1552
+ if (rt) {
1553
+ Object.assign(rt, releaseLease(rt));
1554
+ rt.activeRunID = undefined;
1555
+ rt.updatedAt = new Date().toISOString();
1556
+ if (shouldNotifyParent(rt, "stopped")) {
1557
+ markParentNotified(rt, "stopped");
1558
+ shouldNotify = true;
1559
+ }
1560
+ }
1561
+ return s;
1562
+ });
1563
+ const stoppedGoal = stoppedState.goals.find((item) => item.id === goal.id);
1564
+ if (stoppedGoal?.status !== "budget_limited")
1565
+ return false;
1566
+ await appendEvent(directory, {
1567
+ version: 1,
1568
+ eventID: randomUUID2(),
1569
+ goalID: goal.id,
1570
+ type: "goal.status_changed",
1571
+ from: "active",
1572
+ to: "budget_limited",
1573
+ timestamp: new Date().toISOString(),
1574
+ revision: stoppedState.revision
1575
+ });
1576
+ await logServerEvent(directory, "maintenance.budget-exhausted", {
1577
+ goalID: goal.id,
1578
+ reason,
1579
+ tokensUsed: stoppedGoal.tokensUsed,
1580
+ costUsed: stoppedGoal.costUsed
1581
+ });
1582
+ if (shouldNotify) {
1583
+ await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" stopped: ${reason}. Worker aborted, status: budget_limited. Resume with resume_goal to continue spending.`);
1584
+ }
1585
+ return true;
1586
+ }
1443
1587
  async function maintenance() {
1444
1588
  syncWorkerSessionsFromService();
1445
1589
  if (knownWorkerSessions.size === 0)
@@ -1476,6 +1620,11 @@ function createLoopEngine(options) {
1476
1620
  const runtime = state.runtimes.find((r) => r.goalID === goal.id);
1477
1621
  if (!runtime)
1478
1622
  continue;
1623
+ if (goal.workerSessionID) {
1624
+ const stopped = await accountAndEnforceBudget(goal);
1625
+ if (stopped)
1626
+ continue;
1627
+ }
1479
1628
  if (runtime.phase === "waiting_retry" && runtime.retryAfter) {
1480
1629
  if (Date.now() >= Date.parse(runtime.retryAfter)) {
1481
1630
  await mutateState(directory, `retry-ready:${goal.id}`, async (s) => {
@@ -1558,6 +1707,87 @@ function createLoopEngine(options) {
1558
1707
  });
1559
1708
  await logServerEvent(directory, "maintenance.worker-recovered", { goalID: goal.id });
1560
1709
  }
1710
+ if (status === "idle" && runtime.phase === "running") {
1711
+ 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);
1712
+ const quietMs = Date.now() - lastSignal;
1713
+ if (lastSignal > 0 && quietMs > idleRecoverMs) {
1714
+ const generation = runtime.runGeneration;
1715
+ const stalledRunID = runtime.activeRunID;
1716
+ let clearedToolCalls = 0;
1717
+ let recovered = false;
1718
+ const recoveredState = await mutateState(directory, `maintenance.idle-recover:${goal.id}`, async (s) => {
1719
+ const g = s.goals.find((item) => item.id === goal.id);
1720
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1721
+ if (!g || !rt || g.status !== "active")
1722
+ return s;
1723
+ if (rt.phase !== "running")
1724
+ return s;
1725
+ if (rt.runGeneration !== generation)
1726
+ return s;
1727
+ clearedToolCalls = rt.activeToolCallIDs?.length ?? 0;
1728
+ Object.assign(rt, releaseLease(rt));
1729
+ rt.activeRunID = undefined;
1730
+ rt.lastWorkerStatus = "idle";
1731
+ rt.idleConfirmFailedAt = undefined;
1732
+ rt.idleConfirmFailedGeneration = undefined;
1733
+ rt.idleStuckNotifiedGeneration = undefined;
1734
+ recovered = true;
1735
+ return s;
1736
+ });
1737
+ if (recovered) {
1738
+ await appendEvent(directory, {
1739
+ version: 1,
1740
+ eventID: randomUUID2(),
1741
+ goalID: goal.id,
1742
+ type: "run.recovered",
1743
+ runID: stalledRunID ?? "unknown",
1744
+ quietSeconds: Math.floor(quietMs / 1000),
1745
+ clearedToolCalls,
1746
+ timestamp: new Date().toISOString(),
1747
+ revision: recoveredState.revision
1748
+ });
1749
+ await logServerEvent(directory, "maintenance.idle-recovered", {
1750
+ goalID: goal.id,
1751
+ generation,
1752
+ quietSeconds: Math.floor(quietMs / 1000),
1753
+ clearedToolCalls
1754
+ });
1755
+ await continueGoal(goal.id);
1756
+ continue;
1757
+ }
1758
+ }
1759
+ }
1760
+ if (status === "idle" && runtime.phase === "running" && runtime.idleConfirmFailedGeneration === runtime.runGeneration && runtime.idleConfirmFailedAt && runtime.idleStuckNotifiedGeneration !== runtime.runGeneration && Date.now() - Date.parse(runtime.idleConfirmFailedAt) > idleUnconfirmedMs) {
1761
+ const failedAt = runtime.idleConfirmFailedAt;
1762
+ const generation = runtime.runGeneration;
1763
+ const stuckState = await mutateState(directory, `maintenance.idle-stuck:${goal.id}`, async (s) => {
1764
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1765
+ if (!rt || rt.runGeneration !== generation || rt.phase !== "running")
1766
+ return s;
1767
+ rt.idleStuckNotifiedGeneration = generation;
1768
+ rt.updatedAt = new Date().toISOString();
1769
+ return s;
1770
+ });
1771
+ const stuckSeconds = Math.floor((Date.now() - Date.parse(failedAt)) / 1000);
1772
+ await appendEvent(directory, {
1773
+ version: 1,
1774
+ eventID: randomUUID2(),
1775
+ goalID: goal.id,
1776
+ type: "run.stuck",
1777
+ runID: runtime.activeRunID ?? "unknown",
1778
+ stuckSeconds,
1779
+ timestamp: new Date().toISOString(),
1780
+ revision: stuckState.revision
1781
+ });
1782
+ await logServerEvent(directory, "maintenance.idle-stuck", {
1783
+ goalID: goal.id,
1784
+ generation,
1785
+ stuckSeconds
1786
+ });
1787
+ const quietMinutes = Math.max(1, Math.floor(stuckSeconds / 60));
1788
+ 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.`);
1789
+ continue;
1790
+ }
1561
1791
  if (status === "idle") {
1562
1792
  if (runtime.phase === "idle") {
1563
1793
  await continueGoal(goal.id);
@@ -1566,6 +1796,37 @@ function createLoopEngine(options) {
1566
1796
  }
1567
1797
  continue;
1568
1798
  }
1799
+ if ((status === "busy" || status === "retry") && runtime.phase === "running" && runtime.activeRunID && runtime.stuckNotifiedRunID !== runtime.activeRunID) {
1800
+ const leaseExpired = !runtime.leaseExpiresAt || Date.now() >= Date.parse(runtime.leaseExpiresAt);
1801
+ const lastActive = runtime.lastActivityAt ? Date.parse(runtime.lastActivityAt) : 0;
1802
+ if (leaseExpired && Date.now() - lastActive > stuckRunningMs) {
1803
+ const stuckSeconds = Math.floor((Date.now() - lastActive) / 1000);
1804
+ const stuckState = await mutateState(directory, `maintenance.run-stuck:${goal.id}`, async (s) => {
1805
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1806
+ if (!rt || rt.activeRunID !== runtime.activeRunID)
1807
+ return s;
1808
+ rt.stuckNotifiedRunID = rt.activeRunID;
1809
+ rt.updatedAt = new Date().toISOString();
1810
+ return s;
1811
+ });
1812
+ await appendEvent(directory, {
1813
+ version: 1,
1814
+ eventID: randomUUID2(),
1815
+ goalID: goal.id,
1816
+ type: "run.stuck",
1817
+ runID: runtime.activeRunID,
1818
+ stuckSeconds,
1819
+ timestamp: new Date().toISOString(),
1820
+ revision: stuckState.revision
1821
+ });
1822
+ await logServerEvent(directory, "maintenance.run-stuck", {
1823
+ goalID: goal.id,
1824
+ runID: runtime.activeRunID,
1825
+ stuckSeconds
1826
+ });
1827
+ 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.`);
1828
+ }
1829
+ }
1569
1830
  }
1570
1831
  }
1571
1832
  }
@@ -1577,6 +1838,194 @@ import { randomUUID as randomUUID3 } from "crypto";
1577
1838
  import * as path2 from "path";
1578
1839
  import { promises as fs2 } from "fs";
1579
1840
 
1841
+ // src/server/host-adapter.ts
1842
+ function parseModelRef(value) {
1843
+ if (value === undefined)
1844
+ return;
1845
+ const trimmed = value.trim();
1846
+ if (!trimmed)
1847
+ return;
1848
+ const slash = trimmed.indexOf("/");
1849
+ if (slash <= 0 || slash >= trimmed.length - 1) {
1850
+ throw new Error(`Invalid model "${value}". Use "providerID/modelID" (e.g. "openai/gpt-5.6-sol").`);
1851
+ }
1852
+ const providerID = trimmed.slice(0, slash).trim();
1853
+ const modelID = trimmed.slice(slash + 1).trim();
1854
+ if (!providerID || !modelID || /\s/.test(providerID) || /\s/.test(modelID)) {
1855
+ throw new Error(`Invalid model "${value}". Use "providerID/modelID" (e.g. "openai/gpt-5.6-sol").`);
1856
+ }
1857
+ return { providerID, modelID };
1858
+ }
1859
+ var recentParentNotifies = new Map;
1860
+ function shouldDedupParentNotify(ownerSessionID, message) {
1861
+ const key = `${ownerSessionID}:${message.slice(0, 200)}`;
1862
+ const now = Date.now();
1863
+ const last = recentParentNotifies.get(key);
1864
+ if (last !== undefined && now - last < 60000)
1865
+ return true;
1866
+ recentParentNotifies.set(key, now);
1867
+ if (recentParentNotifies.size > 200) {
1868
+ for (const [k, t] of recentParentNotifies.entries())
1869
+ if (now - t > 60000)
1870
+ recentParentNotifies.delete(k);
1871
+ }
1872
+ return false;
1873
+ }
1874
+ function createRealHost(client, directory) {
1875
+ return {
1876
+ async createWorker({ parentID, title, agent, model }) {
1877
+ try {
1878
+ const body = { parentID, title };
1879
+ if (agent)
1880
+ body.agent = agent;
1881
+ if (model)
1882
+ body.model = { id: model.modelID, providerID: model.providerID };
1883
+ const result = await withTimeout(client.session.create({ body }), 1e4, "OpenCode session.create");
1884
+ const data = result?.data;
1885
+ if (result?.error || !data?.id) {
1886
+ const detail = describeError(result?.error || "response contained no session ID");
1887
+ await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
1888
+ throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
1889
+ }
1890
+ await logServerEvent(directory, "worker.created", { parentID, workerSessionID: data.id, title });
1891
+ return data.id;
1892
+ } catch (error) {
1893
+ if (error instanceof Error && error.message.startsWith("OpenCode session.create failed"))
1894
+ throw error;
1895
+ const detail = describeError(error);
1896
+ await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
1897
+ throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
1898
+ }
1899
+ },
1900
+ async promptWorker({ sessionID, prompt, messageID, model, agent }) {
1901
+ const body = {
1902
+ parts: [{ type: "text", text: prompt }]
1903
+ };
1904
+ if (messageID) {
1905
+ const collapsed = messageID.replace(/^(msg-)+/, "msg-");
1906
+ body.messageID = collapsed.startsWith("msg-") ? collapsed : `msg-${messageID}`;
1907
+ }
1908
+ if (model)
1909
+ body.model = model;
1910
+ if (agent)
1911
+ body.agent = agent;
1912
+ const result = await withTimeout(client.session.promptAsync({
1913
+ path: { id: sessionID },
1914
+ body
1915
+ }), 1e4, "OpenCode session.promptAsync");
1916
+ if (result?.error) {
1917
+ const detail = describeError(result.error);
1918
+ await logServerEvent(directory, "worker.prompt.failed", { sessionID, detail });
1919
+ throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`);
1920
+ }
1921
+ await logServerEvent(directory, "worker.prompted", { sessionID });
1922
+ return { messageID: result?.data?.messageID };
1923
+ },
1924
+ async sessionStatus(sessionID) {
1925
+ try {
1926
+ const result = await client.session.status({});
1927
+ if (result?.error)
1928
+ return "unknown";
1929
+ const data = result?.data;
1930
+ if (!data || typeof data !== "object" || Array.isArray(data))
1931
+ return "unknown";
1932
+ const status = data[sessionID];
1933
+ if (status === undefined || status === null)
1934
+ return "idle";
1935
+ if (typeof status !== "object" || Array.isArray(status))
1936
+ return "unknown";
1937
+ const type = status.type;
1938
+ if (type === "busy" || type === "retry")
1939
+ return type;
1940
+ if (type === "idle")
1941
+ return "idle";
1942
+ return "unknown";
1943
+ } catch {
1944
+ return "unknown";
1945
+ }
1946
+ },
1947
+ async abortSession(sessionID) {
1948
+ try {
1949
+ await client.session.abort({ path: { id: sessionID } });
1950
+ } catch {}
1951
+ },
1952
+ async readMessages(sessionID, limit = 10) {
1953
+ try {
1954
+ const result = await client.session.messages({
1955
+ path: { id: sessionID },
1956
+ query: { limit }
1957
+ });
1958
+ const data = result?.data;
1959
+ if (!Array.isArray(data))
1960
+ return [];
1961
+ return data.map((m) => {
1962
+ const createdMs = m.info?.time?.created;
1963
+ const completedMs = m.info?.time?.completed;
1964
+ const tokens = m.info?.tokens;
1965
+ return {
1966
+ role: m.info?.role || "assistant",
1967
+ content: m.parts?.filter((p) => p.type === "text").map((p) => p.text).join(`
1968
+ `) || "",
1969
+ timestamp: completedMs || createdMs ? new Date(completedMs || createdMs).toISOString() : undefined,
1970
+ messageID: m.info?.id || m.id,
1971
+ parentMessageID: m.info?.parentID,
1972
+ completedAt: completedMs ? new Date(completedMs).toISOString() : undefined,
1973
+ tokens: tokens && typeof tokens.input === "number" ? {
1974
+ input: tokens.input || 0,
1975
+ output: tokens.output || 0,
1976
+ reasoning: tokens.reasoning || 0,
1977
+ cacheRead: tokens.cache?.read || 0,
1978
+ cacheWrite: tokens.cache?.write || 0
1979
+ } : undefined,
1980
+ cost: typeof m.info?.cost === "number" ? m.info.cost : undefined,
1981
+ durationMs: typeof createdMs === "number" && typeof completedMs === "number" && completedMs >= createdMs ? completedMs - createdMs : undefined
1982
+ };
1983
+ });
1984
+ } catch {
1985
+ return [];
1986
+ }
1987
+ },
1988
+ async compactSession(sessionID) {
1989
+ try {
1990
+ await client.session.compact({ sessionID });
1991
+ } catch {}
1992
+ },
1993
+ async notifyOwner(ownerSessionID, message) {
1994
+ if (shouldDedupParentNotify(ownerSessionID, message)) {
1995
+ await logServerEvent(directory, "parent.notify.deduped", { ownerSessionID, preview: message.slice(0, 160) });
1996
+ return;
1997
+ }
1998
+ try {
1999
+ const result = await withTimeout(client.session.promptAsync({
2000
+ path: { id: ownerSessionID },
2001
+ body: { parts: [{ type: "text", text: message }] }
2002
+ }), 1e4, "OpenCode parent notify");
2003
+ if (result?.error) {
2004
+ await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(result.error) });
2005
+ } else {
2006
+ await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
2007
+ }
2008
+ } catch (error) {
2009
+ await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
2010
+ }
2011
+ }
2012
+ };
2013
+ }
2014
+ async function withTimeout(promise, timeoutMs, operation) {
2015
+ let timer;
2016
+ try {
2017
+ return await Promise.race([
2018
+ promise,
2019
+ new Promise((_, reject) => {
2020
+ timer = setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs);
2021
+ })
2022
+ ]);
2023
+ } finally {
2024
+ if (timer)
2025
+ clearTimeout(timer);
2026
+ }
2027
+ }
2028
+
1580
2029
  // src/server/worker-session.ts
1581
2030
  function createWorkerManager(host) {
1582
2031
  return {
@@ -1584,7 +2033,8 @@ function createWorkerManager(host) {
1584
2033
  const workerSessionID = await host.createWorker({
1585
2034
  parentID: goal.ownerSessionID,
1586
2035
  title: `loopd: ${goal.name}`,
1587
- agent: goal.config.agent
2036
+ agent: goal.config.agent,
2037
+ model: parseModelRef(goal.config.model)
1588
2038
  });
1589
2039
  return {
1590
2040
  goalID: goal.id,
@@ -1598,7 +2048,8 @@ function createWorkerManager(host) {
1598
2048
  sessionID: worker.workerSessionID,
1599
2049
  prompt,
1600
2050
  messageID: runtime.activePromptMessageID,
1601
- agent: goal.config.agent
2051
+ agent: goal.config.agent,
2052
+ model: parseModelRef(goal.config.model)
1602
2053
  });
1603
2054
  return result;
1604
2055
  },
@@ -1832,6 +2283,8 @@ function createGoalService(host) {
1832
2283
  ...input.config
1833
2284
  }
1834
2285
  });
2286
+ if (typeof input.costBudget === "number")
2287
+ goal.costBudget = input.costBudget;
1835
2288
  const artifactDir = goalArtifactDir(directory, id);
1836
2289
  goal.config.artifactDir = artifactDir;
1837
2290
  if (!goal.config.progressFile)
@@ -1940,6 +2393,63 @@ function createGoalService(host) {
1940
2393
  }
1941
2394
  return { goal, worker };
1942
2395
  }
2396
+ async function accountTailUsage(directory, goalID, runtime, tail) {
2397
+ const seenIDs = new Set(runtime.accountedMessageIDs ?? []);
2398
+ let tokenDelta = 0;
2399
+ let costDelta = 0;
2400
+ let timeDeltaSeconds = 0;
2401
+ const counted = [];
2402
+ for (const m of tail) {
2403
+ if (m.role !== "assistant" || !m.messageID || !m.completedAt)
2404
+ continue;
2405
+ if (seenIDs.has(m.messageID))
2406
+ continue;
2407
+ seenIDs.add(m.messageID);
2408
+ counted.push(m.messageID);
2409
+ if (m.tokens) {
2410
+ tokenDelta += (m.tokens.input || 0) + (m.tokens.output || 0) + (m.tokens.reasoning || 0) + (m.tokens.cacheRead || 0) + (m.tokens.cacheWrite || 0);
2411
+ }
2412
+ if (typeof m.cost === "number")
2413
+ costDelta += m.cost;
2414
+ if (typeof m.durationMs === "number")
2415
+ timeDeltaSeconds += m.durationMs / 1000;
2416
+ }
2417
+ if (counted.length === 0)
2418
+ return { tokenDelta: 0, costDelta: 0, timeDeltaSeconds: 0, counted };
2419
+ const mergedWatermark = [...runtime.accountedMessageIDs ?? [], ...counted].slice(-200);
2420
+ await mutateState(directory, `turn.account-usage:${goalID}`, async (s) => {
2421
+ const g = s.goals.find((item) => item.id === goalID);
2422
+ if (g) {
2423
+ g.tokensUsed += tokenDelta;
2424
+ g.costUsed = (g.costUsed ?? 0) + costDelta;
2425
+ g.timeUsedSeconds += timeDeltaSeconds;
2426
+ g.updatedAt = new Date().toISOString();
2427
+ }
2428
+ const rt = s.runtimes.find((item) => item.goalID === goalID);
2429
+ if (rt) {
2430
+ rt.turnTokensUsed = (rt.turnTokensUsed ?? 0) + tokenDelta;
2431
+ rt.accountedMessageIDs = mergedWatermark;
2432
+ rt.updatedAt = new Date().toISOString();
2433
+ }
2434
+ return s;
2435
+ });
2436
+ return { tokenDelta, costDelta, timeDeltaSeconds, counted };
2437
+ }
2438
+ async function accountUsageUnlocked(directory, goalID) {
2439
+ const preState = await readState(directory);
2440
+ const goal = preState.goals.find((g) => g.id === goalID);
2441
+ const runtime = preState.runtimes.find((r) => r.goalID === goalID);
2442
+ if (!goal?.workerSessionID || !runtime) {
2443
+ return { tokenDelta: 0, costDelta: 0, timeDeltaSeconds: 0, counted: [] };
2444
+ }
2445
+ let tail = [];
2446
+ try {
2447
+ tail = await host.readMessages(goal.workerSessionID, 50);
2448
+ } catch {
2449
+ return { tokenDelta: 0, costDelta: 0, timeDeltaSeconds: 0, counted: [] };
2450
+ }
2451
+ return accountTailUsage(directory, goalID, runtime, tail);
2452
+ }
1943
2453
  async function continueTurnUnlocked(directory, goalID, opts) {
1944
2454
  const preState = await readState(directory);
1945
2455
  const goal = preState.goals.find((g) => g.id === goalID);
@@ -2014,6 +2524,7 @@ function createGoalService(host) {
2014
2524
  } catch {
2015
2525
  transcriptTail = [];
2016
2526
  }
2527
+ await accountTailUsage(directory, goalID, freshRuntime, transcriptTail ?? []);
2017
2528
  let verification;
2018
2529
  try {
2019
2530
  const artifactDir = freshGoal.config.artifactDir;
@@ -2325,7 +2836,10 @@ function createGoalService(host) {
2325
2836
  function nudge(directory, goalID) {
2326
2837
  return withGoalOperation(goalID, () => nudgeUnlocked(directory, goalID));
2327
2838
  }
2328
- return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile };
2839
+ function accountUsage(directory, goalID) {
2840
+ return withGoalOperation(goalID, () => accountUsageUnlocked(directory, goalID));
2841
+ }
2842
+ return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage };
2329
2843
  }
2330
2844
 
2331
2845
  // src/application/schedule-worker.ts
@@ -2442,161 +2956,6 @@ function createScheduleWorker(options) {
2442
2956
  return { start, stop, isRunning, tick };
2443
2957
  }
2444
2958
 
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
2959
  // src/server/goal-tools.ts
2601
2960
  import { randomUUID as randomUUID5 } from "crypto";
2602
2961
  import { tool } from "@opencode-ai/plugin/tool";
@@ -2617,11 +2976,13 @@ var execAsync = promisify(execChild);
2617
2976
  function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2618
2977
  return {
2619
2978
  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.",
2979
+ 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
2980
  args: {
2622
2981
  name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
2623
2982
  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."),
2983
+ 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.`),
2984
+ 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.`),
2985
+ 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
2986
  checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
2626
2987
  checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
2627
2988
  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 +3011,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2650
3011
  };
2651
3012
  if (args.agent)
2652
3013
  config.agent = args.agent;
3014
+ if (args.model)
3015
+ config.model = args.model;
2653
3016
  if (args.checks)
2654
3017
  config.checks = args.checks;
2655
3018
  if (args.checkCwd)
@@ -2690,6 +3053,16 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2690
3053
  output: JSON.stringify({ ok: false, message: "scheduleMaxRuns requires scheduleEveryMs", errorCode: "invalid_schedule" })
2691
3054
  };
2692
3055
  }
3056
+ let costBudget;
3057
+ if (args.costBudget !== undefined) {
3058
+ if (typeof args.costBudget !== "number" || !Number.isFinite(args.costBudget) || args.costBudget <= 0) {
3059
+ return {
3060
+ title: "Goal not created",
3061
+ output: JSON.stringify({ ok: false, message: "costBudget must be a positive number of dollars", errorCode: "invalid_cost_budget" })
3062
+ };
3063
+ }
3064
+ costBudget = args.costBudget;
3065
+ }
2693
3066
  const resolution = resolveGoalCreationConfig({
2694
3067
  directory: dir,
2695
3068
  objective: args.objective,
@@ -2711,7 +3084,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2711
3084
  name: args.name,
2712
3085
  objective: args.objective,
2713
3086
  ownerSessionID: sessionID,
2714
- config: resolution.config
3087
+ config: resolution.config,
3088
+ costBudget
2715
3089
  });
2716
3090
  return {
2717
3091
  title: "Goal created",
@@ -2721,6 +3095,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2721
3095
  workerSessionID: worker.workerSessionID,
2722
3096
  artifactDir: goal.config.artifactDir,
2723
3097
  agent: resolution.config.agent,
3098
+ model: resolution.config.model,
3099
+ costBudget: goal.costBudget,
2724
3100
  checks: resolution.config.checks || [],
2725
3101
  workspaceWrite: resolution.config.workspaceWrite,
2726
3102
  defaultsApplied: resolution.defaultsApplied,
@@ -2933,11 +3309,17 @@ ${failureDetails.slice(0, 500)}`,
2933
3309
  evidence: args.evidence,
2934
3310
  at: new Date().toISOString()
2935
3311
  };
3312
+ const finalUsage = await goalService.accountUsage(dir, goal.id);
3313
+ goal.tokensUsed += finalUsage.tokenDelta;
3314
+ goal.costUsed = (goal.costUsed ?? 0) + finalUsage.costDelta;
3315
+ goal.timeUsedSeconds += finalUsage.timeDeltaSeconds;
2936
3316
  const runtime = state.runtimes.find((r) => r.goalID === goal.id);
2937
3317
  if (runtime) {
2938
3318
  Object.assign(runtime, releaseLease(runtime));
2939
3319
  runtime.activeRunID = undefined;
2940
3320
  runtime.lastError = undefined;
3321
+ runtime.turnTokensUsed = (runtime.turnTokensUsed ?? 0) + finalUsage.tokenDelta;
3322
+ runtime.accountedMessageIDs = [...runtime.accountedMessageIDs ?? [], ...finalUsage.counted].slice(-200);
2941
3323
  runtime.updatedAt = new Date().toISOString();
2942
3324
  const schedule = goal.config.schedule;
2943
3325
  if (schedule && typeof schedule.everyMs === "number" && schedule.everyMs >= 1000) {
@@ -3022,11 +3404,17 @@ ${failureDetails.slice(0, 500)}`,
3022
3404
  needed: args.needed,
3023
3405
  at: new Date().toISOString()
3024
3406
  };
3407
+ const finalUsage = await goalService.accountUsage(dir, goal.id);
3408
+ goal.tokensUsed += finalUsage.tokenDelta;
3409
+ goal.costUsed = (goal.costUsed ?? 0) + finalUsage.costDelta;
3410
+ goal.timeUsedSeconds += finalUsage.timeDeltaSeconds;
3025
3411
  const runtime = state.runtimes.find((r) => r.goalID === goal.id);
3026
3412
  if (runtime) {
3027
3413
  Object.assign(runtime, releaseLease(runtime));
3028
3414
  runtime.activeRunID = undefined;
3029
3415
  runtime.lastError = undefined;
3416
+ runtime.turnTokensUsed = (runtime.turnTokensUsed ?? 0) + finalUsage.tokenDelta;
3417
+ runtime.accountedMessageIDs = [...runtime.accountedMessageIDs ?? [], ...finalUsage.counted].slice(-200);
3030
3418
  runtime.updatedAt = new Date().toISOString();
3031
3419
  }
3032
3420
  await writeState(dir, state);
@@ -3076,6 +3464,7 @@ function formatGoalStructured(goal, runtime) {
3076
3464
  checkCwd: goal.config.checkCwd,
3077
3465
  workspaceWrite: goal.config.workspaceWrite,
3078
3466
  agent: goal.config.agent,
3467
+ model: goal.config.model,
3079
3468
  maxTurns: goal.config.maxTurns,
3080
3469
  maxNoProgress: goal.config.maxNoProgress,
3081
3470
  maxFailures: goal.config.maxFailures,
@@ -3087,6 +3476,9 @@ function formatGoalStructured(goal, runtime) {
3087
3476
  completionEvidence: goal.completionEvidence,
3088
3477
  blocker: goal.blocker,
3089
3478
  tokensUsed: goal.tokensUsed,
3479
+ tokenBudget: goal.tokenBudget,
3480
+ costUsed: goal.costUsed ?? 0,
3481
+ costBudget: goal.costBudget,
3090
3482
  timeUsedSeconds: goal.timeUsedSeconds
3091
3483
  };
3092
3484
  if (runtime) {
@@ -3186,6 +3578,8 @@ function ownerTools(options) {
3186
3578
  turn: runtime?.runCount ?? 0,
3187
3579
  budgetTurnCount: runtime?.budgetTurnCount ?? 0,
3188
3580
  maxTurns: g.config.maxTurns,
3581
+ agent: g.config.agent,
3582
+ model: g.config.model,
3189
3583
  lastProgress: g.lastProgress?.summary?.slice(0, 120),
3190
3584
  lastProgressAt: g.lastProgress?.at,
3191
3585
  blocker: g.blocker?.reason?.slice(0, 120),
@@ -3206,7 +3600,7 @@ function ownerTools(options) {
3206
3600
  }
3207
3601
  }),
3208
3602
  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.",
3603
+ 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
3604
  args: {
3211
3605
  goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to inspect the first active goal."),
3212
3606
  includeTranscript: tool2.schema.boolean().optional().describe("Include live transcript tail (adds ~100ms). Default true. Set false for fast metadata-only."),
@@ -3261,12 +3655,16 @@ function ownerTools(options) {
3261
3655
  checkCwd: goal.config.checkCwd,
3262
3656
  workspaceWrite: goal.config.workspaceWrite,
3263
3657
  agent: goal.config.agent,
3658
+ model: goal.config.model,
3264
3659
  schedule: goal.config.schedule
3265
3660
  },
3266
3661
  lastProgress: goal.lastProgress,
3267
3662
  completionEvidence: goal.completionEvidence,
3268
3663
  blocker: goal.blocker,
3269
3664
  tokensUsed: goal.tokensUsed,
3665
+ tokenBudget: goal.tokenBudget,
3666
+ costUsed: goal.costUsed ?? 0,
3667
+ costBudget: goal.costBudget,
3270
3668
  timeUsedSeconds: goal.timeUsedSeconds,
3271
3669
  progressHistory,
3272
3670
  pendingInbox,
@@ -3693,9 +4091,11 @@ var server = async ({ client, directory }, pluginOptions) => {
3693
4091
  };
3694
4092
  function parsePluginDefaults(options) {
3695
4093
  const agent = typeof options?.defaultAgent === "string" ? options.defaultAgent.trim() : "";
4094
+ const model = typeof options?.defaultModel === "string" ? options.defaultModel.trim() : "";
3696
4095
  const checks = Array.isArray(options?.defaultChecks) ? options.defaultChecks.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [];
3697
4096
  return {
3698
4097
  defaultAgent: agent || undefined,
4098
+ defaultModel: model || undefined,
3699
4099
  defaultChecks: checks.length > 0 ? checks : undefined
3700
4100
  };
3701
4101
  }