@bojackduy/opencode-loopd 1.8.2 → 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
@@ -1,34 +1,5 @@
1
1
  // @bun
2
- var __defProp = Object.defineProperty;
3
- var __returnValue = (v) => v;
4
- function __exportSetter(name, newValue) {
5
- this[name] = __returnValue.bind(null, newValue);
6
- }
7
- var __export = (target, all) => {
8
- for (var name in all)
9
- __defProp(target, name, {
10
- get: all[name],
11
- enumerable: true,
12
- configurable: true,
13
- set: __exportSetter.bind(all, name)
14
- });
15
- };
16
-
17
2
  // src/domain/runtime.ts
18
- var exports_runtime = {};
19
- __export(exports_runtime, {
20
- acquireLease: () => acquireLease,
21
- addToolCall: () => addToolCall,
22
- createRuntimeState: () => createRuntimeState,
23
- hasActiveToolCalls: () => hasActiveToolCalls,
24
- leaseIsValid: () => leaseIsValid,
25
- markParentNotified: () => markParentNotified,
26
- markProgress: () => markProgress,
27
- recordActivity: () => recordActivity,
28
- releaseLease: () => releaseLease,
29
- removeToolCall: () => removeToolCall,
30
- shouldNotifyParent: () => shouldNotifyParent
31
- });
32
3
  function createRuntimeState(goalID) {
33
4
  const now = new Date().toISOString();
34
5
  return {
@@ -40,6 +11,7 @@ function createRuntimeState(goalID) {
40
11
  noProgressCount: 0,
41
12
  progressDuringTurn: false,
42
13
  unknownStatusCount: 0,
14
+ accountedMessageIDs: [],
43
15
  runGeneration: 0,
44
16
  createdAt: now,
45
17
  updatedAt: now
@@ -59,6 +31,9 @@ function acquireLease(rt, timeoutMs) {
59
31
  lastActivityAt: new Date(now).toISOString(),
60
32
  idleCandidateAt: undefined,
61
33
  idleCandidateGeneration: undefined,
34
+ idleConfirmFailedAt: undefined,
35
+ idleConfirmFailedGeneration: undefined,
36
+ idleStuckNotifiedGeneration: undefined,
62
37
  activePromptObservedAt: undefined,
63
38
  activeAssistantMessageID: undefined,
64
39
  activeAssistantCompletedAt: undefined,
@@ -135,9 +110,6 @@ function removeToolCall(rt, callID) {
135
110
  updatedAt: new Date().toISOString()
136
111
  };
137
112
  }
138
- function hasActiveToolCalls(rt) {
139
- return (rt.activeToolCallIDs?.length ?? 0) > 0;
140
- }
141
113
  var PARENT_NOTIFY_DEDUPE_MS = 60000;
142
114
 
143
115
  // src/application/control-worker.ts
@@ -176,8 +148,8 @@ async function acquireLock(directory, key, operation) {
176
148
  try {
177
149
  try {
178
150
  const raw = await fs.readFile(lockPath, "utf8");
179
- const meta2 = JSON.parse(raw);
180
- const age = Date.now() - Date.parse(meta2.acquiredAt);
151
+ const meta = JSON.parse(raw);
152
+ const age = Date.now() - Date.parse(meta.acquiredAt);
181
153
  if (age > LOCK_STALE_MS) {
182
154
  await fs.rm(lockPath, { force: true });
183
155
  }
@@ -511,6 +483,16 @@ function resolveGoalCreationConfig(input) {
511
483
  const explicitAgent = cleanText(requested.agent);
512
484
  const defaultAgent = cleanText(defaults.defaultAgent);
513
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
+ }
514
496
  const workspaceWrite = requested.workspaceWrite ?? true;
515
497
  const explicitChecks = cleanList(requested.checks);
516
498
  const defaultChecks = workspaceWrite ? cleanList(defaults.defaultChecks || ["bun test"]) : [];
@@ -527,16 +509,30 @@ function resolveGoalCreationConfig(input) {
527
509
  config: {
528
510
  ...requested,
529
511
  agent,
512
+ model,
530
513
  workspaceWrite,
531
514
  checks: checks.length > 0 ? checks : undefined,
532
515
  checkCwd: requested.checkCwd || (workspaceWrite ? input.directory : undefined)
533
516
  },
534
517
  defaultsApplied: {
535
518
  agent: !explicitAgent && Boolean(defaultAgent),
519
+ model: !explicitModel && Boolean(defaultModel),
536
520
  checks: explicitChecks.length === 0 && defaultChecks.length > 0
537
521
  }
538
522
  };
539
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
+ }
540
536
  function cleanText(value) {
541
537
  if (typeof value !== "string")
542
538
  return;
@@ -712,54 +708,54 @@ function createControlWorker(options) {
712
708
  ownerSessionID: args.ownerSessionID,
713
709
  config: resolution.config
714
710
  });
715
- const state2 = await readState(directory);
711
+ const state = await readState(directory);
716
712
  response = {
717
713
  ...base,
718
714
  message: `goal "${args.name}" created (${goal.id.slice(0, 8)}...)`,
719
- stateRevision: state2.revision
715
+ stateRevision: state.revision
720
716
  };
721
717
  break;
722
718
  }
723
719
  case "pause": {
724
720
  await goalSvc.pause(directory, request.goalID);
725
- const state2 = await readState(directory);
726
- const goal = state2.goals.find((g) => g.id === request.goalID);
721
+ const state = await readState(directory);
722
+ const goal = state.goals.find((g) => g.id === request.goalID);
727
723
  response = {
728
724
  ...base,
729
725
  message: `goal "${goal?.name || request.goalID}" paused`,
730
- stateRevision: state2.revision
726
+ stateRevision: state.revision
731
727
  };
732
728
  break;
733
729
  }
734
730
  case "resume": {
735
731
  await goalSvc.resume(directory, request.goalID);
736
- const state2 = await readState(directory);
737
- const goal = state2.goals.find((g) => g.id === request.goalID);
732
+ const state = await readState(directory);
733
+ const goal = state.goals.find((g) => g.id === request.goalID);
738
734
  response = {
739
735
  ...base,
740
736
  message: `goal "${goal?.name || request.goalID}" resumed`,
741
- stateRevision: state2.revision
737
+ stateRevision: state.revision
742
738
  };
743
739
  break;
744
740
  }
745
741
  case "retry": {
746
742
  await goalSvc.retry(directory, request.goalID);
747
- const state2 = await readState(directory);
748
- const goal = state2.goals.find((g) => g.id === request.goalID);
743
+ const state = await readState(directory);
744
+ const goal = state.goals.find((g) => g.id === request.goalID);
749
745
  response = {
750
746
  ...base,
751
747
  message: `goal "${goal?.name || request.goalID}" retried`,
752
- stateRevision: state2.revision
748
+ stateRevision: state.revision
753
749
  };
754
750
  break;
755
751
  }
756
752
  case "clear": {
757
753
  await goalSvc.clear(directory, request.goalID);
758
- const state2 = await readState(directory);
754
+ const state = await readState(directory);
759
755
  response = {
760
756
  ...base,
761
757
  message: `goal cleared`,
762
- stateRevision: state2.revision
758
+ stateRevision: state.revision
763
759
  };
764
760
  break;
765
761
  }
@@ -775,25 +771,25 @@ function createControlWorker(options) {
775
771
  break;
776
772
  }
777
773
  await appendGoalInbox(directory, request.goalID, "user", text);
778
- const state2 = await readState(directory);
779
- const goal = state2.goals.find((g) => g.id === request.goalID);
774
+ const state = await readState(directory);
775
+ const goal = state.goals.find((g) => g.id === request.goalID);
780
776
  response = {
781
777
  ...base,
782
778
  message: `sent to "${goal?.name || request.goalID}"`,
783
- stateRevision: state2.revision
779
+ stateRevision: state.revision
784
780
  };
785
781
  break;
786
782
  }
787
783
  case "force_complete": {
788
784
  const args = request.args;
789
- const state2 = await readState(directory);
790
- const goal = state2.goals.find((g) => g.id === request.goalID);
785
+ const state = await readState(directory);
786
+ const goal = state.goals.find((g) => g.id === request.goalID);
791
787
  if (!goal) {
792
788
  response = { ...base, ok: false, message: "goal not found", errorCode: "not_found" };
793
789
  break;
794
790
  }
795
791
  if (goal.status === "complete") {
796
- response = { ...base, message: `goal "${goal.name}" already complete`, stateRevision: state2.revision };
792
+ response = { ...base, message: `goal "${goal.name}" already complete`, stateRevision: state.revision };
797
793
  break;
798
794
  }
799
795
  goal.status = "complete";
@@ -803,14 +799,14 @@ function createControlWorker(options) {
803
799
  evidence: String(args.evidence || "Manual override \u2014 no verification checks run."),
804
800
  at: new Date().toISOString()
805
801
  };
806
- const runtime = state2.runtimes.find((r) => r.goalID === goal.id);
802
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
807
803
  if (runtime) {
808
804
  Object.assign(runtime, releaseLease(runtime));
809
805
  runtime.activeRunID = undefined;
810
806
  runtime.lastError = undefined;
811
807
  runtime.updatedAt = new Date().toISOString();
812
808
  }
813
- await writeState(directory, state2);
809
+ await writeState(directory, state);
814
810
  await appendEvent(directory, {
815
811
  version: 1,
816
812
  eventID: randomUUID(),
@@ -819,22 +815,22 @@ function createControlWorker(options) {
819
815
  summary: goal.completionEvidence.summary,
820
816
  evidence: goal.completionEvidence.evidence,
821
817
  timestamp: new Date().toISOString(),
822
- revision: state2.revision
818
+ revision: state.revision
823
819
  });
824
- response = { ...base, message: `goal "${goal.name}" force-completed`, stateRevision: state2.revision };
820
+ response = { ...base, message: `goal "${goal.name}" force-completed`, stateRevision: state.revision };
825
821
  break;
826
822
  }
827
823
  case "force_block":
828
824
  case "block": {
829
825
  const args = request.args;
830
- const state2 = await readState(directory);
831
- const goal = state2.goals.find((g) => g.id === request.goalID);
826
+ const state = await readState(directory);
827
+ const goal = state.goals.find((g) => g.id === request.goalID);
832
828
  if (!goal) {
833
829
  response = { ...base, ok: false, message: "goal not found", errorCode: "not_found" };
834
830
  break;
835
831
  }
836
832
  if (goal.status === "blocked") {
837
- response = { ...base, message: `goal "${goal.name}" already blocked`, stateRevision: state2.revision };
833
+ response = { ...base, message: `goal "${goal.name}" already blocked`, stateRevision: state.revision };
838
834
  break;
839
835
  }
840
836
  goal.status = "blocked";
@@ -844,14 +840,14 @@ function createControlWorker(options) {
844
840
  needed: String(args.needed || "User intervention required."),
845
841
  at: new Date().toISOString()
846
842
  };
847
- const runtime = state2.runtimes.find((r) => r.goalID === goal.id);
843
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
848
844
  if (runtime) {
849
845
  Object.assign(runtime, releaseLease(runtime));
850
846
  runtime.activeRunID = undefined;
851
847
  runtime.lastError = undefined;
852
848
  runtime.updatedAt = new Date().toISOString();
853
849
  }
854
- await writeState(directory, state2);
850
+ await writeState(directory, state);
855
851
  await appendEvent(directory, {
856
852
  version: 1,
857
853
  eventID: randomUUID(),
@@ -860,9 +856,9 @@ function createControlWorker(options) {
860
856
  reason: goal.blocker.reason,
861
857
  needed: goal.blocker.needed,
862
858
  timestamp: new Date().toISOString(),
863
- revision: state2.revision
859
+ revision: state.revision
864
860
  });
865
- response = { ...base, message: `goal "${goal.name}" blocked`, stateRevision: state2.revision };
861
+ response = { ...base, message: `goal "${goal.name}" blocked`, stateRevision: state.revision };
866
862
  break;
867
863
  }
868
864
  default: {
@@ -878,8 +874,8 @@ function createControlWorker(options) {
878
874
  await recordInLedger(directory, request);
879
875
  return response;
880
876
  }
881
- async function recordInLedger(directory2, request) {
882
- const state = await readState(directory2);
877
+ async function recordInLedger(directory, request) {
878
+ const state = await readState(directory);
883
879
  if (!state.commandLedger)
884
880
  state.commandLedger = [];
885
881
  state.commandLedger.push({
@@ -892,7 +888,7 @@ function createControlWorker(options) {
892
888
  if (state.commandLedger.length > MAX_LEDGER_SIZE) {
893
889
  state.commandLedger = state.commandLedger.slice(-MAX_LEDGER_SIZE);
894
890
  }
895
- await writeState(directory2, state);
891
+ await writeState(directory, state);
896
892
  }
897
893
  return { start, stop: async () => {
898
894
  await stop();
@@ -936,7 +932,7 @@ function isTerminal(status) {
936
932
  }
937
933
  function createGoal(input) {
938
934
  const now = new Date().toISOString();
939
- return { ...input, tokensUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
935
+ return { ...input, tokensUsed: 0, costUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
940
936
  }
941
937
  // src/application/loop-engine.ts
942
938
  var CONFIRM_IDLE_DURATION_MS = 2000;
@@ -953,6 +949,9 @@ function createLoopEngine(options) {
953
949
  const maintenanceMs = options.pollIntervalMs ?? 30000;
954
950
  const confirmIdleMs = options.confirmIdleMs ?? CONFIRM_IDLE_DURATION_MS;
955
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;
956
955
  let running = false;
957
956
  let maintenanceTimer;
958
957
  let knownWorkerSessions = new Set;
@@ -1076,6 +1075,8 @@ function createLoopEngine(options) {
1076
1075
  } else {
1077
1076
  const part = event.properties?.part;
1078
1077
  if (part?.messageID && part.messageID === rt.activeAssistantMessageID) {
1078
+ if (rt.activeAssistantCompletedAt)
1079
+ return s;
1079
1080
  Object.assign(rt, recordActivity(rt));
1080
1081
  matched = true;
1081
1082
  }
@@ -1115,7 +1116,9 @@ function createLoopEngine(options) {
1115
1116
  const elapsed = now - Date.parse(rt.idleCandidateAt);
1116
1117
  if (elapsed < confirmIdleMs)
1117
1118
  return s;
1118
- 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) {
1119
1122
  rt.idleCandidateAt = undefined;
1120
1123
  rt.idleCandidateGeneration = undefined;
1121
1124
  return s;
@@ -1137,8 +1140,35 @@ function createLoopEngine(options) {
1137
1140
  });
1138
1141
  if (confirmation) {
1139
1142
  const candidate = confirmation;
1143
+ const stagedRt = afterIdle.runtimes.find((r) => r.goalID === goalID);
1144
+ const eventAnchored = stagedRt?.runGeneration === candidate.generation && Boolean(stagedRt?.activeAssistantCompletedAt);
1140
1145
  const transcript = await inspectPromptTurn(goal.workerSessionID, candidate.promptMessageID);
1141
- 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
+ }
1142
1172
  return true;
1143
1173
  }
1144
1174
  afterIdle = await mutateState(directory, `idle.confirm:${goalID}`, async (s) => {
@@ -1156,14 +1186,19 @@ function createLoopEngine(options) {
1156
1186
  return s;
1157
1187
  if (rt.idleCandidateAt !== candidate.candidateAt)
1158
1188
  return s;
1159
- if (rt.lastActivityAt && rt.lastActivityAt > candidate.candidateAt)
1160
- 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
+ }
1161
1194
  if ((rt.activeToolCallIDs?.length ?? 0) > 0)
1162
1195
  return s;
1163
1196
  completedRunID = rt.activeRunID;
1164
1197
  Object.assign(rt, releaseLease(rt));
1165
1198
  rt.activeRunID = undefined;
1166
1199
  rt.lastWorkerStatus = "idle";
1200
+ rt.idleConfirmFailedAt = undefined;
1201
+ rt.idleConfirmFailedGeneration = undefined;
1167
1202
  return s;
1168
1203
  });
1169
1204
  }
@@ -1206,6 +1241,12 @@ function createLoopEngine(options) {
1206
1241
  return true;
1207
1242
  recentForceFinishBlocked.set(blockedKey, nowBlocked);
1208
1243
  let shouldNotifyBlocked = false;
1244
+ await goalService.accountUsage(directory, goalID).catch(() => ({
1245
+ tokenDelta: 0,
1246
+ costDelta: 0,
1247
+ timeDeltaSeconds: 0,
1248
+ counted: []
1249
+ }));
1209
1250
  const blockedState = await mutateState(directory, `idle.blocked:${goalID}`, async (s) => {
1210
1251
  const g = s.goals.find((item) => item.id === goalID);
1211
1252
  if (!g)
@@ -1276,7 +1317,7 @@ function createLoopEngine(options) {
1276
1317
  return noMatch;
1277
1318
  let messages;
1278
1319
  try {
1279
- messages = await host.readMessages(workerSessionID, 50);
1320
+ messages = await host.readMessages(workerSessionID, 200);
1280
1321
  } catch {
1281
1322
  return noMatch;
1282
1323
  }
@@ -1434,6 +1475,16 @@ function createLoopEngine(options) {
1434
1475
  reason: `Token budget exhausted (${goal.tokensUsed}/${goal.tokenBudget})`
1435
1476
  };
1436
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
+ }
1437
1488
  return noResult;
1438
1489
  }
1439
1490
  function shouldCompact(goal, runtime) {
@@ -1472,6 +1523,67 @@ function createLoopEngine(options) {
1472
1523
  return s;
1473
1524
  });
1474
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
+ }
1475
1587
  async function maintenance() {
1476
1588
  syncWorkerSessionsFromService();
1477
1589
  if (knownWorkerSessions.size === 0)
@@ -1508,6 +1620,11 @@ function createLoopEngine(options) {
1508
1620
  const runtime = state.runtimes.find((r) => r.goalID === goal.id);
1509
1621
  if (!runtime)
1510
1622
  continue;
1623
+ if (goal.workerSessionID) {
1624
+ const stopped = await accountAndEnforceBudget(goal);
1625
+ if (stopped)
1626
+ continue;
1627
+ }
1511
1628
  if (runtime.phase === "waiting_retry" && runtime.retryAfter) {
1512
1629
  if (Date.now() >= Date.parse(runtime.retryAfter)) {
1513
1630
  await mutateState(directory, `retry-ready:${goal.id}`, async (s) => {
@@ -1590,6 +1707,87 @@ function createLoopEngine(options) {
1590
1707
  });
1591
1708
  await logServerEvent(directory, "maintenance.worker-recovered", { goalID: goal.id });
1592
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
+ }
1593
1791
  if (status === "idle") {
1594
1792
  if (runtime.phase === "idle") {
1595
1793
  await continueGoal(goal.id);
@@ -1598,6 +1796,37 @@ function createLoopEngine(options) {
1598
1796
  }
1599
1797
  continue;
1600
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
+ }
1601
1830
  }
1602
1831
  }
1603
1832
  }
@@ -1609,6 +1838,194 @@ import { randomUUID as randomUUID3 } from "crypto";
1609
1838
  import * as path2 from "path";
1610
1839
  import { promises as fs2 } from "fs";
1611
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
+
1612
2029
  // src/server/worker-session.ts
1613
2030
  function createWorkerManager(host) {
1614
2031
  return {
@@ -1616,7 +2033,8 @@ function createWorkerManager(host) {
1616
2033
  const workerSessionID = await host.createWorker({
1617
2034
  parentID: goal.ownerSessionID,
1618
2035
  title: `loopd: ${goal.name}`,
1619
- agent: goal.config.agent
2036
+ agent: goal.config.agent,
2037
+ model: parseModelRef(goal.config.model)
1620
2038
  });
1621
2039
  return {
1622
2040
  goalID: goal.id,
@@ -1630,7 +2048,8 @@ function createWorkerManager(host) {
1630
2048
  sessionID: worker.workerSessionID,
1631
2049
  prompt,
1632
2050
  messageID: runtime.activePromptMessageID,
1633
- agent: goal.config.agent
2051
+ agent: goal.config.agent,
2052
+ model: parseModelRef(goal.config.model)
1634
2053
  });
1635
2054
  return result;
1636
2055
  },
@@ -1864,6 +2283,8 @@ function createGoalService(host) {
1864
2283
  ...input.config
1865
2284
  }
1866
2285
  });
2286
+ if (typeof input.costBudget === "number")
2287
+ goal.costBudget = input.costBudget;
1867
2288
  const artifactDir = goalArtifactDir(directory, id);
1868
2289
  goal.config.artifactDir = artifactDir;
1869
2290
  if (!goal.config.progressFile)
@@ -1879,9 +2300,9 @@ function createGoalService(host) {
1879
2300
  rt.lastScheduleAt = undefined;
1880
2301
  }
1881
2302
  state.runtimes.push(rt);
1882
- const runtime2 = state.runtimes.find((r) => r.goalID === id);
1883
- if (runtime2)
1884
- runtime2.phase = "queued";
2303
+ const runtime = state.runtimes.find((r) => r.goalID === id);
2304
+ if (runtime)
2305
+ runtime.phase = "queued";
1885
2306
  return state;
1886
2307
  });
1887
2308
  let runtime = state1.runtimes.find((r) => r.goalID === id);
@@ -1972,6 +2393,63 @@ function createGoalService(host) {
1972
2393
  }
1973
2394
  return { goal, worker };
1974
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
+ }
1975
2453
  async function continueTurnUnlocked(directory, goalID, opts) {
1976
2454
  const preState = await readState(directory);
1977
2455
  const goal = preState.goals.find((g) => g.id === goalID);
@@ -2046,6 +2524,7 @@ function createGoalService(host) {
2046
2524
  } catch {
2047
2525
  transcriptTail = [];
2048
2526
  }
2527
+ await accountTailUsage(directory, goalID, freshRuntime, transcriptTail ?? []);
2049
2528
  let verification;
2050
2529
  try {
2051
2530
  const artifactDir = freshGoal.config.artifactDir;
@@ -2122,17 +2601,17 @@ function createGoalService(host) {
2122
2601
  }
2123
2602
  async function resumeUnlocked(directory, goalID) {
2124
2603
  let resumed = false;
2125
- const state = await mutateState(directory, `goal.resume:${goalID}`, async (state2) => {
2126
- const goal2 = state2.goals.find((g) => g.id === goalID);
2127
- if (!goal2)
2128
- return state2;
2129
- if (!canTransition(goal2.status, "active", "user"))
2130
- return state2;
2131
- assertWorkspaceWriteAvailable(state2, goal2);
2132
- goal2.status = "active";
2133
- goal2.updatedAt = new Date().toISOString();
2604
+ const state = await mutateState(directory, `goal.resume:${goalID}`, async (state) => {
2605
+ const goal = state.goals.find((g) => g.id === goalID);
2606
+ if (!goal)
2607
+ return state;
2608
+ if (!canTransition(goal.status, "active", "user"))
2609
+ return state;
2610
+ assertWorkspaceWriteAvailable(state, goal);
2611
+ goal.status = "active";
2612
+ goal.updatedAt = new Date().toISOString();
2134
2613
  resumed = true;
2135
- return state2;
2614
+ return state;
2136
2615
  });
2137
2616
  const goal = state.goals.find((g) => g.id === goalID);
2138
2617
  if (!goal || !resumed)
@@ -2162,15 +2641,15 @@ function createGoalService(host) {
2162
2641
  }
2163
2642
  async function retryUnlocked(directory, goalID) {
2164
2643
  let retried = false;
2165
- const state = await mutateState(directory, `goal.retry:${goalID}`, async (state2) => {
2166
- const goal2 = state2.goals.find((g) => g.id === goalID);
2167
- if (!goal2 || goal2.status !== "blocked")
2168
- return state2;
2169
- assertWorkspaceWriteAvailable(state2, goal2);
2170
- goal2.status = "active";
2171
- goal2.updatedAt = new Date().toISOString();
2644
+ const state = await mutateState(directory, `goal.retry:${goalID}`, async (state) => {
2645
+ const goal = state.goals.find((g) => g.id === goalID);
2646
+ if (!goal || goal.status !== "blocked")
2647
+ return state;
2648
+ assertWorkspaceWriteAvailable(state, goal);
2649
+ goal.status = "active";
2650
+ goal.updatedAt = new Date().toISOString();
2172
2651
  retried = true;
2173
- const runtime = state2.runtimes.find((r) => r.goalID === goalID);
2652
+ const runtime = state.runtimes.find((r) => r.goalID === goalID);
2174
2653
  if (runtime) {
2175
2654
  runtime.consecutiveFailures = 0;
2176
2655
  runtime.lastError = undefined;
@@ -2180,7 +2659,7 @@ function createGoalService(host) {
2180
2659
  runtime.phase = "idle";
2181
2660
  runtime.updatedAt = new Date().toISOString();
2182
2661
  }
2183
- return state2;
2662
+ return state;
2184
2663
  });
2185
2664
  const goal = state.goals.find((g) => g.id === goalID);
2186
2665
  if (!goal || !retried)
@@ -2357,7 +2836,10 @@ function createGoalService(host) {
2357
2836
  function nudge(directory, goalID) {
2358
2837
  return withGoalOperation(goalID, () => nudgeUnlocked(directory, goalID));
2359
2838
  }
2360
- 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 };
2361
2843
  }
2362
2844
 
2363
2845
  // src/application/schedule-worker.ts
@@ -2474,155 +2956,6 @@ function createScheduleWorker(options) {
2474
2956
  return { start, stop, isRunning, tick };
2475
2957
  }
2476
2958
 
2477
- // src/server/host-adapter.ts
2478
- var recentParentNotifies = new Map;
2479
- function shouldDedupParentNotify(ownerSessionID, message) {
2480
- const key = `${ownerSessionID}:${message.slice(0, 200)}`;
2481
- const now = Date.now();
2482
- const last = recentParentNotifies.get(key);
2483
- if (last !== undefined && now - last < 60000)
2484
- return true;
2485
- recentParentNotifies.set(key, now);
2486
- if (recentParentNotifies.size > 200) {
2487
- for (const [k, t] of recentParentNotifies.entries())
2488
- if (now - t > 60000)
2489
- recentParentNotifies.delete(k);
2490
- }
2491
- return false;
2492
- }
2493
- function createRealHost(client, directory) {
2494
- return {
2495
- async createWorker({ parentID, title, agent }) {
2496
- try {
2497
- const body = { parentID, title };
2498
- if (agent)
2499
- body.agent = agent;
2500
- const result = await withTimeout(client.session.create({ body }), 1e4, "OpenCode session.create");
2501
- const data = result?.data;
2502
- if (result?.error || !data?.id) {
2503
- const detail = describeError(result?.error || "response contained no session ID");
2504
- await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
2505
- throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
2506
- }
2507
- await logServerEvent(directory, "worker.created", { parentID, workerSessionID: data.id, title });
2508
- return data.id;
2509
- } catch (error) {
2510
- if (error instanceof Error && error.message.startsWith("OpenCode session.create failed"))
2511
- throw error;
2512
- const detail = describeError(error);
2513
- await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
2514
- throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
2515
- }
2516
- },
2517
- async promptWorker({ sessionID, prompt, messageID, model, agent }) {
2518
- const body = {
2519
- parts: [{ type: "text", text: prompt }]
2520
- };
2521
- if (messageID) {
2522
- const collapsed = messageID.replace(/^(msg-)+/, "msg-");
2523
- body.messageID = collapsed.startsWith("msg-") ? collapsed : `msg-${messageID}`;
2524
- }
2525
- if (model)
2526
- body.model = model;
2527
- if (agent)
2528
- body.agent = agent;
2529
- const result = await withTimeout(client.session.promptAsync({
2530
- path: { id: sessionID },
2531
- body
2532
- }), 1e4, "OpenCode session.promptAsync");
2533
- if (result?.error) {
2534
- const detail = describeError(result.error);
2535
- await logServerEvent(directory, "worker.prompt.failed", { sessionID, detail });
2536
- throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`);
2537
- }
2538
- await logServerEvent(directory, "worker.prompted", { sessionID });
2539
- return { messageID: result?.data?.messageID };
2540
- },
2541
- async sessionStatus(sessionID) {
2542
- try {
2543
- const result = await client.session.status({});
2544
- const data = result?.data;
2545
- if (!data || typeof data !== "object")
2546
- return "unknown";
2547
- const status = data[sessionID];
2548
- if (!status || typeof status !== "object")
2549
- return "unknown";
2550
- const type = status.type;
2551
- if (type === "busy" || type === "retry")
2552
- return type;
2553
- return "idle";
2554
- } catch {
2555
- return "unknown";
2556
- }
2557
- },
2558
- async abortSession(sessionID) {
2559
- try {
2560
- await client.session.abort({ path: { id: sessionID } });
2561
- } catch {}
2562
- },
2563
- async readMessages(sessionID, limit = 10) {
2564
- try {
2565
- const result = await client.session.messages({
2566
- path: { id: sessionID },
2567
- query: { limit }
2568
- });
2569
- const data = result?.data;
2570
- if (!Array.isArray(data))
2571
- return [];
2572
- return data.map((m) => ({
2573
- role: m.info?.role || "assistant",
2574
- content: m.parts?.filter((p) => p.type === "text").map((p) => p.text).join(`
2575
- `) || "",
2576
- timestamp: m.info?.time?.completed || m.info?.time?.created ? new Date(m.info.time.completed || m.info.time.created).toISOString() : undefined,
2577
- messageID: m.info?.id || m.id,
2578
- parentMessageID: m.info?.parentID,
2579
- completedAt: m.info?.time?.completed ? new Date(m.info.time.completed).toISOString() : undefined
2580
- }));
2581
- } catch {
2582
- return [];
2583
- }
2584
- },
2585
- async compactSession(sessionID) {
2586
- try {
2587
- await client.session.compact({ sessionID });
2588
- } catch {}
2589
- },
2590
- async notifyOwner(ownerSessionID, message) {
2591
- if (shouldDedupParentNotify(ownerSessionID, message)) {
2592
- await logServerEvent(directory, "parent.notify.deduped", { ownerSessionID, preview: message.slice(0, 160) });
2593
- return;
2594
- }
2595
- try {
2596
- const result = await withTimeout(client.session.promptAsync({
2597
- path: { id: ownerSessionID },
2598
- body: { parts: [{ type: "text", text: message }] }
2599
- }), 1e4, "OpenCode parent notify");
2600
- if (result?.error) {
2601
- await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(result.error) });
2602
- } else {
2603
- await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
2604
- }
2605
- } catch (error) {
2606
- await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
2607
- }
2608
- }
2609
- };
2610
- }
2611
- async function withTimeout(promise, timeoutMs, operation) {
2612
- let timer;
2613
- try {
2614
- return await Promise.race([
2615
- promise,
2616
- new Promise((_, reject) => {
2617
- timer = setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs);
2618
- })
2619
- ]);
2620
- } finally {
2621
- if (timer)
2622
- clearTimeout(timer);
2623
- }
2624
- }
2625
-
2626
2959
  // src/server/goal-tools.ts
2627
2960
  import { randomUUID as randomUUID5 } from "crypto";
2628
2961
  import { tool } from "@opencode-ai/plugin/tool";
@@ -2643,11 +2976,13 @@ var execAsync = promisify(execChild);
2643
2976
  function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2644
2977
  return {
2645
2978
  loopd_create_goal: tool({
2646
- 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.",
2647
2980
  args: {
2648
2981
  name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
2649
2982
  objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
2650
- 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."),
2651
2986
  checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
2652
2987
  checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
2653
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."),
@@ -2676,6 +3011,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2676
3011
  };
2677
3012
  if (args.agent)
2678
3013
  config.agent = args.agent;
3014
+ if (args.model)
3015
+ config.model = args.model;
2679
3016
  if (args.checks)
2680
3017
  config.checks = args.checks;
2681
3018
  if (args.checkCwd)
@@ -2716,6 +3053,16 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2716
3053
  output: JSON.stringify({ ok: false, message: "scheduleMaxRuns requires scheduleEveryMs", errorCode: "invalid_schedule" })
2717
3054
  };
2718
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
+ }
2719
3066
  const resolution = resolveGoalCreationConfig({
2720
3067
  directory: dir,
2721
3068
  objective: args.objective,
@@ -2737,7 +3084,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2737
3084
  name: args.name,
2738
3085
  objective: args.objective,
2739
3086
  ownerSessionID: sessionID,
2740
- config: resolution.config
3087
+ config: resolution.config,
3088
+ costBudget
2741
3089
  });
2742
3090
  return {
2743
3091
  title: "Goal created",
@@ -2747,6 +3095,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2747
3095
  workerSessionID: worker.workerSessionID,
2748
3096
  artifactDir: goal.config.artifactDir,
2749
3097
  agent: resolution.config.agent,
3098
+ model: resolution.config.model,
3099
+ costBudget: goal.costBudget,
2750
3100
  checks: resolution.config.checks || [],
2751
3101
  workspaceWrite: resolution.config.workspaceWrite,
2752
3102
  defaultsApplied: resolution.defaultsApplied,
@@ -2861,9 +3211,9 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
2861
3211
  const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
2862
3212
  const checkResults = await runCompletionChecks(goal.config.checks, cwd);
2863
3213
  if (!checkResults.passed) {
2864
- const runtime2 = state.runtimes.find((r) => r.goalID === goal.id);
2865
- if (runtime2) {
2866
- runtime2.evaluatorRejectionCount = (runtime2.evaluatorRejectionCount || 0) + 1;
3214
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
3215
+ if (runtime) {
3216
+ runtime.evaluatorRejectionCount = (runtime.evaluatorRejectionCount || 0) + 1;
2867
3217
  const failureDetails = checkResults.failures.map((f) => {
2868
3218
  const stdoutSnippet = f.stdout ? `
2869
3219
  Stdout: ${f.stdout.slice(0, 500)}` : "";
@@ -2874,7 +3224,7 @@ Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
2874
3224
  }).join(`
2875
3225
 
2876
3226
  `);
2877
- runtime2.lastRejectionDetails = `Rejection #${runtime2.evaluatorRejectionCount} at ${new Date().toISOString()}
3227
+ runtime.lastRejectionDetails = `Rejection #${runtime.evaluatorRejectionCount} at ${new Date().toISOString()}
2878
3228
 
2879
3229
  Working directory: ${cwd}
2880
3230
 
@@ -2882,8 +3232,8 @@ ${failureDetails}`;
2882
3232
  const attemptID = randomUUID5();
2883
3233
  const verificationAttempt = {
2884
3234
  id: attemptID,
2885
- sequence: runtime2.evaluatorRejectionCount,
2886
- runGeneration: runtime2.runGeneration,
3235
+ sequence: runtime.evaluatorRejectionCount,
3236
+ runGeneration: runtime.runGeneration,
2887
3237
  claimedSummary: args.summary,
2888
3238
  claimedEvidence: args.evidence,
2889
3239
  startedAt: new Date().toISOString(),
@@ -2897,15 +3247,15 @@ ${failureDetails}`;
2897
3247
  stdout: f.stdout
2898
3248
  }))
2899
3249
  };
2900
- runtime2.lastVerificationAttempt = verificationAttempt;
2901
- runtime2.recentVerificationAttempts = appendVerificationAttempt(runtime2.recentVerificationAttempts || [], verificationAttempt);
3250
+ runtime.lastVerificationAttempt = verificationAttempt;
3251
+ runtime.recentVerificationAttempts = appendVerificationAttempt(runtime.recentVerificationAttempts || [], verificationAttempt);
2902
3252
  const rejectEvent = {
2903
3253
  version: 1,
2904
3254
  eventID: randomUUID5(),
2905
3255
  goalID: goal.id,
2906
3256
  type: "goal.completion_rejected",
2907
3257
  attemptID,
2908
- rejectionCount: runtime2.evaluatorRejectionCount,
3258
+ rejectionCount: runtime.evaluatorRejectionCount,
2909
3259
  failedCheckCount: checkResults.failures.length,
2910
3260
  failureSummary: failureDetails.slice(0, 500),
2911
3261
  timestamp: new Date().toISOString(),
@@ -2913,16 +3263,16 @@ ${failureDetails}`;
2913
3263
  };
2914
3264
  await appendEvent(dir, rejectEvent);
2915
3265
  const maxRejections = goal.config.maxEvaluatorRejections || 3;
2916
- if (runtime2.evaluatorRejectionCount >= maxRejections) {
3266
+ if (runtime.evaluatorRejectionCount >= maxRejections) {
2917
3267
  goal.status = "blocked";
2918
3268
  goal.updatedAt = new Date().toISOString();
2919
3269
  goal.blocker = {
2920
- reason: `Evaluator rejected ${runtime2.evaluatorRejectionCount} time(s). Last failure:
3270
+ reason: `Evaluator rejected ${runtime.evaluatorRejectionCount} time(s). Last failure:
2921
3271
  ${failureDetails.slice(0, 500)}`,
2922
3272
  needed: "Fix the failing checks and retry the goal.",
2923
3273
  at: new Date().toISOString()
2924
3274
  };
2925
- runtime2.forceFinishRequested = undefined;
3275
+ runtime.forceFinishRequested = undefined;
2926
3276
  await appendEvent(dir, {
2927
3277
  version: 1,
2928
3278
  eventID: randomUUID5(),
@@ -2934,10 +3284,10 @@ ${failureDetails.slice(0, 500)}`,
2934
3284
  revision: state.revision
2935
3285
  });
2936
3286
  } else {
2937
- runtime2.forceFinishRequested = false;
2938
- runtime2.freeRetryPending = true;
3287
+ runtime.forceFinishRequested = false;
3288
+ runtime.freeRetryPending = true;
2939
3289
  }
2940
- runtime2.updatedAt = new Date().toISOString();
3290
+ runtime.updatedAt = new Date().toISOString();
2941
3291
  await writeState(dir, state);
2942
3292
  }
2943
3293
  return {
@@ -2946,7 +3296,7 @@ ${failureDetails.slice(0, 500)}`,
2946
3296
  passed: false,
2947
3297
  failedChecks: checkResults.failures,
2948
3298
  message: "Evaluator rejected completion. Fix the issues above and try again.",
2949
- rejectionCount: runtime2?.evaluatorRejectionCount || 0,
3299
+ rejectionCount: runtime?.evaluatorRejectionCount || 0,
2950
3300
  status: goal.status
2951
3301
  })
2952
3302
  };
@@ -2959,11 +3309,17 @@ ${failureDetails.slice(0, 500)}`,
2959
3309
  evidence: args.evidence,
2960
3310
  at: new Date().toISOString()
2961
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;
2962
3316
  const runtime = state.runtimes.find((r) => r.goalID === goal.id);
2963
3317
  if (runtime) {
2964
3318
  Object.assign(runtime, releaseLease(runtime));
2965
3319
  runtime.activeRunID = undefined;
2966
3320
  runtime.lastError = undefined;
3321
+ runtime.turnTokensUsed = (runtime.turnTokensUsed ?? 0) + finalUsage.tokenDelta;
3322
+ runtime.accountedMessageIDs = [...runtime.accountedMessageIDs ?? [], ...finalUsage.counted].slice(-200);
2967
3323
  runtime.updatedAt = new Date().toISOString();
2968
3324
  const schedule = goal.config.schedule;
2969
3325
  if (schedule && typeof schedule.everyMs === "number" && schedule.everyMs >= 1000) {
@@ -3048,11 +3404,17 @@ ${failureDetails.slice(0, 500)}`,
3048
3404
  needed: args.needed,
3049
3405
  at: new Date().toISOString()
3050
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;
3051
3411
  const runtime = state.runtimes.find((r) => r.goalID === goal.id);
3052
3412
  if (runtime) {
3053
3413
  Object.assign(runtime, releaseLease(runtime));
3054
3414
  runtime.activeRunID = undefined;
3055
3415
  runtime.lastError = undefined;
3416
+ runtime.turnTokensUsed = (runtime.turnTokensUsed ?? 0) + finalUsage.tokenDelta;
3417
+ runtime.accountedMessageIDs = [...runtime.accountedMessageIDs ?? [], ...finalUsage.counted].slice(-200);
3056
3418
  runtime.updatedAt = new Date().toISOString();
3057
3419
  }
3058
3420
  await writeState(dir, state);
@@ -3102,6 +3464,7 @@ function formatGoalStructured(goal, runtime) {
3102
3464
  checkCwd: goal.config.checkCwd,
3103
3465
  workspaceWrite: goal.config.workspaceWrite,
3104
3466
  agent: goal.config.agent,
3467
+ model: goal.config.model,
3105
3468
  maxTurns: goal.config.maxTurns,
3106
3469
  maxNoProgress: goal.config.maxNoProgress,
3107
3470
  maxFailures: goal.config.maxFailures,
@@ -3113,6 +3476,9 @@ function formatGoalStructured(goal, runtime) {
3113
3476
  completionEvidence: goal.completionEvidence,
3114
3477
  blocker: goal.blocker,
3115
3478
  tokensUsed: goal.tokensUsed,
3479
+ tokenBudget: goal.tokenBudget,
3480
+ costUsed: goal.costUsed ?? 0,
3481
+ costBudget: goal.costBudget,
3116
3482
  timeUsedSeconds: goal.timeUsedSeconds
3117
3483
  };
3118
3484
  if (runtime) {
@@ -3212,6 +3578,8 @@ function ownerTools(options) {
3212
3578
  turn: runtime?.runCount ?? 0,
3213
3579
  budgetTurnCount: runtime?.budgetTurnCount ?? 0,
3214
3580
  maxTurns: g.config.maxTurns,
3581
+ agent: g.config.agent,
3582
+ model: g.config.model,
3215
3583
  lastProgress: g.lastProgress?.summary?.slice(0, 120),
3216
3584
  lastProgressAt: g.lastProgress?.at,
3217
3585
  blocker: g.blocker?.reason?.slice(0, 120),
@@ -3232,7 +3600,7 @@ function ownerTools(options) {
3232
3600
  }
3233
3601
  }),
3234
3602
  inspect_background_goal: tool2({
3235
- 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.",
3236
3604
  args: {
3237
3605
  goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to inspect the first active goal."),
3238
3606
  includeTranscript: tool2.schema.boolean().optional().describe("Include live transcript tail (adds ~100ms). Default true. Set false for fast metadata-only."),
@@ -3287,12 +3655,16 @@ function ownerTools(options) {
3287
3655
  checkCwd: goal.config.checkCwd,
3288
3656
  workspaceWrite: goal.config.workspaceWrite,
3289
3657
  agent: goal.config.agent,
3658
+ model: goal.config.model,
3290
3659
  schedule: goal.config.schedule
3291
3660
  },
3292
3661
  lastProgress: goal.lastProgress,
3293
3662
  completionEvidence: goal.completionEvidence,
3294
3663
  blocker: goal.blocker,
3295
3664
  tokensUsed: goal.tokensUsed,
3665
+ tokenBudget: goal.tokenBudget,
3666
+ costUsed: goal.costUsed ?? 0,
3667
+ costBudget: goal.costBudget,
3296
3668
  timeUsedSeconds: goal.timeUsedSeconds,
3297
3669
  progressHistory,
3298
3670
  pendingInbox,
@@ -3637,8 +4009,8 @@ var server = async ({ client, directory }, pluginOptions) => {
3637
4009
  "tool.execute.before": async (input, _output) => {
3638
4010
  const activeWorkers = goalService.getActiveWorkers();
3639
4011
  let matchedGoalID;
3640
- for (const [goalID, worker2] of activeWorkers) {
3641
- if (worker2.workerSessionID === input.sessionID) {
4012
+ for (const [goalID, worker] of activeWorkers) {
4013
+ if (worker.workerSessionID === input.sessionID) {
3642
4014
  matchedGoalID = goalID;
3643
4015
  break;
3644
4016
  }
@@ -3661,8 +4033,8 @@ var server = async ({ client, directory }, pluginOptions) => {
3661
4033
  }
3662
4034
  const activeWorkers = goalService.getActiveWorkers();
3663
4035
  let matchedGoalID;
3664
- for (const [goalID, worker2] of activeWorkers) {
3665
- if (worker2.workerSessionID === input.sessionID) {
4036
+ for (const [goalID, worker] of activeWorkers) {
4037
+ if (worker.workerSessionID === input.sessionID) {
3666
4038
  matchedGoalID = goalID;
3667
4039
  break;
3668
4040
  }
@@ -3688,20 +4060,20 @@ var server = async ({ client, directory }, pluginOptions) => {
3688
4060
  const goalID = parsed.goalID;
3689
4061
  if (!goalID)
3690
4062
  return;
3691
- const { shouldNotifyParent: shouldNotifyParent2, markParentNotified: markParentNotified2 } = await Promise.resolve().then(() => exports_runtime);
4063
+ await Promise.resolve();
3692
4064
  const state = await readState(directory);
3693
4065
  const goal = state.goals.find((g) => g.id === goalID);
3694
4066
  if (!goal)
3695
4067
  return;
3696
4068
  const runtime = state.runtimes.find((r) => r.goalID === goalID);
3697
4069
  const notifyType = parsed.status === "complete" ? "complete" : "blocked";
3698
- if (runtime && !shouldNotifyParent2(runtime, notifyType))
4070
+ if (runtime && !shouldNotifyParent(runtime, notifyType))
3699
4071
  return;
3700
4072
  if (runtime) {
3701
4073
  await mutateState(directory, `notify-parent:${goalID}`, async (s) => {
3702
4074
  const rt = s.runtimes.find((r) => r.goalID === goalID);
3703
4075
  if (rt)
3704
- markParentNotified2(rt, notifyType);
4076
+ markParentNotified(rt, notifyType);
3705
4077
  return s;
3706
4078
  });
3707
4079
  }
@@ -3719,9 +4091,11 @@ var server = async ({ client, directory }, pluginOptions) => {
3719
4091
  };
3720
4092
  function parsePluginDefaults(options) {
3721
4093
  const agent = typeof options?.defaultAgent === "string" ? options.defaultAgent.trim() : "";
4094
+ const model = typeof options?.defaultModel === "string" ? options.defaultModel.trim() : "";
3722
4095
  const checks = Array.isArray(options?.defaultChecks) ? options.defaultChecks.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [];
3723
4096
  return {
3724
4097
  defaultAgent: agent || undefined,
4098
+ defaultModel: model || undefined,
3725
4099
  defaultChecks: checks.length > 0 ? checks : undefined
3726
4100
  };
3727
4101
  }