@bojackduy/opencode-loopd 1.5.2 → 1.6.0

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
@@ -13,32 +13,141 @@ var __export = (target, all) => {
13
13
  set: __exportSetter.bind(all, name)
14
14
  });
15
15
  };
16
- var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
16
 
18
- // src/infrastructure/state-repository.ts
19
- var exports_state_repository = {};
20
- __export(exports_state_repository, {
21
- appendEvent: () => appendEvent,
22
- appendGoalInbox: () => appendGoalInbox,
23
- claimControlRequest: () => claimControlRequest,
24
- drainGoalInbox: () => drainGoalInbox,
25
- ensureGoalArtifactDir: () => ensureGoalArtifactDir,
26
- goalArtifactDir: () => goalArtifactDir,
27
- listPendingRequests: () => listPendingRequests,
28
- mutateState: () => mutateState,
29
- readControlRequest: () => readControlRequest,
30
- readControlResponse: () => readControlResponse,
31
- readEvents: () => readEvents,
32
- readState: () => readState,
33
- recoverStaleProcessing: () => recoverStaleProcessing,
34
- writeControlRequest: () => writeControlRequest,
35
- writeControlResponse: () => writeControlResponse,
36
- writeState: () => writeState
17
+ // 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
37
31
  });
32
+ function createRuntimeState(goalID) {
33
+ const now = new Date().toISOString();
34
+ return {
35
+ goalID,
36
+ phase: "idle",
37
+ consecutiveFailures: 0,
38
+ runCount: 0,
39
+ budgetTurnCount: 0,
40
+ noProgressCount: 0,
41
+ progressDuringTurn: false,
42
+ unknownStatusCount: 0,
43
+ runGeneration: 0,
44
+ createdAt: now,
45
+ updatedAt: now
46
+ };
47
+ }
48
+ function acquireLease(rt, timeoutMs) {
49
+ const now = Date.now();
50
+ const expires = new Date(now + timeoutMs).toISOString();
51
+ return {
52
+ ...rt,
53
+ phase: "running",
54
+ leaseExpiresAt: expires,
55
+ turnStartedAt: new Date(now).toISOString(),
56
+ progressDuringTurn: false,
57
+ turnTokensUsed: 0,
58
+ runGeneration: rt.runGeneration + 1,
59
+ lastActivityAt: new Date(now).toISOString(),
60
+ idleCandidateAt: undefined,
61
+ idleCandidateGeneration: undefined,
62
+ activePromptObservedAt: undefined,
63
+ activeAssistantMessageID: undefined,
64
+ activeAssistantCompletedAt: undefined,
65
+ activeToolCallIDs: [],
66
+ updatedAt: new Date(now).toISOString()
67
+ };
68
+ }
69
+ function releaseLease(rt) {
70
+ return {
71
+ ...rt,
72
+ phase: "idle",
73
+ leaseExpiresAt: undefined,
74
+ turnStartedAt: undefined,
75
+ activePromptMessageID: undefined,
76
+ activePromptObservedAt: undefined,
77
+ activeAssistantMessageID: undefined,
78
+ activeAssistantCompletedAt: undefined,
79
+ idleCandidateAt: undefined,
80
+ idleCandidateGeneration: undefined,
81
+ activeToolCallIDs: [],
82
+ updatedAt: new Date().toISOString()
83
+ };
84
+ }
85
+ function leaseIsValid(rt) {
86
+ if (!rt.leaseExpiresAt)
87
+ return false;
88
+ return Date.now() < Date.parse(rt.leaseExpiresAt);
89
+ }
90
+ function markProgress(rt) {
91
+ return { ...rt, progressDuringTurn: true, lastProgressAt: new Date().toISOString() };
92
+ }
93
+ function shouldNotifyParent(runtime, type) {
94
+ if (!runtime.lastParentNotifiedAt || !runtime.lastParentNotifiedFor)
95
+ return true;
96
+ if (runtime.lastParentNotifiedFor !== type)
97
+ return true;
98
+ const elapsed = Date.now() - Date.parse(runtime.lastParentNotifiedAt);
99
+ return !Number.isFinite(elapsed) || elapsed > PARENT_NOTIFY_DEDUPE_MS;
100
+ }
101
+ function markParentNotified(runtime, type) {
102
+ runtime.lastParentNotifiedFor = type;
103
+ runtime.lastParentNotifiedAt = new Date().toISOString();
104
+ runtime.updatedAt = new Date().toISOString();
105
+ }
106
+ function recordActivity(rt) {
107
+ return {
108
+ ...rt,
109
+ lastActivityAt: new Date().toISOString(),
110
+ idleCandidateAt: undefined,
111
+ idleCandidateGeneration: undefined,
112
+ updatedAt: new Date().toISOString()
113
+ };
114
+ }
115
+ function addToolCall(rt, callID) {
116
+ const ids = new Set(rt.activeToolCallIDs || []);
117
+ ids.add(callID);
118
+ return {
119
+ ...rt,
120
+ activeToolCallIDs: Array.from(ids),
121
+ lastActivityAt: new Date().toISOString(),
122
+ idleCandidateAt: undefined,
123
+ idleCandidateGeneration: undefined,
124
+ updatedAt: new Date().toISOString()
125
+ };
126
+ }
127
+ function removeToolCall(rt, callID) {
128
+ const ids = (rt.activeToolCallIDs || []).filter((id) => id !== callID);
129
+ return {
130
+ ...rt,
131
+ activeToolCallIDs: ids,
132
+ lastActivityAt: new Date().toISOString(),
133
+ idleCandidateAt: undefined,
134
+ idleCandidateGeneration: undefined,
135
+ updatedAt: new Date().toISOString()
136
+ };
137
+ }
138
+ function hasActiveToolCalls(rt) {
139
+ return (rt.activeToolCallIDs?.length ?? 0) > 0;
140
+ }
141
+ var PARENT_NOTIFY_DEDUPE_MS = 60000;
142
+
143
+ // src/application/control-worker.ts
144
+ import { randomUUID } from "crypto";
145
+
146
+ // src/infrastructure/state-repository.ts
38
147
  import { promises as fs } from "fs";
39
148
  import path from "path";
40
149
  import os from "os";
41
- import { randomUUID } from "crypto";
150
+ var CURRENT_VERSION = 5;
42
151
  function emptyState() {
43
152
  return { version: CURRENT_VERSION, revision: 0, goals: [], runtimes: [], commandLedger: [] };
44
153
  }
@@ -58,11 +167,11 @@ function lockDir(directory) {
58
167
  function lockFile(directory, key) {
59
168
  return path.join(lockDir(directory), `${key}.lock`);
60
169
  }
170
+ var LOCK_STALE_MS = 1e4;
61
171
  async function acquireLock(directory, key, operation) {
62
172
  const dir = lockDir(directory);
63
173
  await fs.mkdir(dir, { recursive: true });
64
174
  const lockPath = lockFile(directory, key);
65
- const lockID = randomUUID();
66
175
  for (let attempt = 0;attempt < 10; attempt++) {
67
176
  try {
68
177
  try {
@@ -73,31 +182,44 @@ async function acquireLock(directory, key, operation) {
73
182
  await fs.rm(lockPath, { force: true });
74
183
  }
75
184
  } catch {}
76
- const temp = lockPath + `.${lockID}.tmp`;
77
185
  const meta = { pid: process.pid, operation, acquiredAt: new Date().toISOString() };
78
- await fs.writeFile(temp, JSON.stringify(meta), "utf8");
186
+ const fd = await fs.open(lockPath, "wx");
79
187
  try {
80
- await fs.rename(temp, lockPath);
81
- return;
82
- } catch (error) {
83
- await fs.rm(temp, { force: true });
84
- if (error?.code !== "EEXIST")
85
- throw error;
188
+ await fd.writeFile(JSON.stringify(meta), "utf8");
189
+ } finally {
190
+ await fd.close();
86
191
  }
192
+ return;
87
193
  } catch (error) {
88
- if (error?.code === "ENOENT") {
194
+ if (error?.code === "EEXIST") {} else if (error?.code === "ENOENT") {
89
195
  await fs.mkdir(dir, { recursive: true });
90
196
  continue;
197
+ } else {
198
+ throw error;
91
199
  }
92
- throw error;
93
200
  }
94
201
  await delay(25 * (attempt + 1));
95
202
  }
96
203
  throw new Error(`failed to acquire lock "${key}" for "${operation}" after retries`);
97
204
  }
98
205
  async function releaseLock(directory, key) {
206
+ const lockPath = lockFile(directory, key);
99
207
  try {
100
- await fs.rm(lockFile(directory, key), { force: true });
208
+ const raw = await fs.readFile(lockPath, "utf8");
209
+ const meta = JSON.parse(raw);
210
+ const age = Date.now() - Date.parse(meta.acquiredAt);
211
+ const shouldRelease = meta.pid === process.pid || age > LOCK_STALE_MS;
212
+ if (!shouldRelease)
213
+ return;
214
+ try {
215
+ const raw2 = await fs.readFile(lockPath, "utf8");
216
+ const meta2 = JSON.parse(raw2);
217
+ if (meta2.acquiredAt !== meta.acquiredAt || meta2.pid !== meta.pid)
218
+ return;
219
+ } catch {
220
+ return;
221
+ }
222
+ await fs.rm(lockPath, { force: true });
101
223
  } catch {}
102
224
  }
103
225
  async function readState(directory) {
@@ -141,6 +263,53 @@ function migrate(state) {
141
263
  blocker: g.blocker ?? undefined
142
264
  }));
143
265
  }
266
+ if (result.version < 3) {
267
+ result.version = 3;
268
+ result.runtimes = result.runtimes.map((rt) => {
269
+ const oldTurnCount = rt.turnCount ?? 0;
270
+ const { turnCount: _deprecatedTurnCount, ...rest } = rt;
271
+ return {
272
+ ...rest,
273
+ budgetTurnCount: rest.budgetTurnCount ?? oldTurnCount,
274
+ runCount: rest.runCount ?? oldTurnCount,
275
+ runGeneration: rest.runGeneration ?? 0,
276
+ freeRetryPending: rest.freeRetryPending ?? false,
277
+ lastRejectionDetails: rest.lastRejectionDetails ?? undefined,
278
+ activePromptMessageID: rest.activePromptMessageID ?? undefined,
279
+ lastActivityAt: rest.lastActivityAt ?? undefined,
280
+ idleCandidateAt: rest.idleCandidateAt ?? undefined,
281
+ activeToolCallIDs: rest.activeToolCallIDs ?? []
282
+ };
283
+ });
284
+ }
285
+ if (result.version < 4) {
286
+ result.version = 4;
287
+ result.runtimes = result.runtimes.map((rt) => ({
288
+ ...rt,
289
+ lastVerificationAttempt: rt.lastVerificationAttempt ?? undefined,
290
+ recentVerificationAttempts: rt.recentVerificationAttempts ?? []
291
+ }));
292
+ }
293
+ if (result.version < 5) {
294
+ result.version = 5;
295
+ result.goals = result.goals.map((goal) => ({
296
+ ...goal,
297
+ config: {
298
+ ...goal.config,
299
+ workspaceWrite: goal.config?.workspaceWrite ?? true
300
+ }
301
+ }));
302
+ result.runtimes = result.runtimes.map((rt) => ({
303
+ ...rt,
304
+ activePromptObservedAt: rt.activePromptObservedAt ?? undefined,
305
+ activeAssistantMessageID: rt.activeAssistantMessageID ?? undefined,
306
+ activeAssistantCompletedAt: rt.activeAssistantCompletedAt ?? undefined,
307
+ idleCandidateGeneration: rt.idleCandidateGeneration ?? undefined,
308
+ unknownStatusCount: rt.unknownStatusCount ?? 0,
309
+ lastUnknownStatusAt: rt.lastUnknownStatusAt ?? undefined,
310
+ workerUnreachableNotifiedAt: rt.workerUnreachableNotifiedAt ?? undefined
311
+ }));
312
+ }
144
313
  return result;
145
314
  }
146
315
  async function writeAtomic(target, contents) {
@@ -213,19 +382,6 @@ function processingFile(directory, requestID) {
213
382
  function responseFile(directory, requestID) {
214
383
  return path.join(controlDir(directory), "responses", `${requestID}.json`);
215
384
  }
216
- async function writeControlRequest(directory, request) {
217
- const dir = path.join(controlDir(directory), "requests");
218
- await fs.mkdir(dir, { recursive: true });
219
- await writeAtomic(requestFile(directory, request.requestID), JSON.stringify(request, null, 2));
220
- }
221
- async function readControlRequest(directory, requestID) {
222
- try {
223
- const raw = await fs.readFile(requestFile(directory, requestID), "utf8");
224
- return JSON.parse(raw);
225
- } catch {
226
- return;
227
- }
228
- }
229
385
  async function claimControlRequest(directory, requestID) {
230
386
  const src = requestFile(directory, requestID);
231
387
  const dst = processingFile(directory, requestID);
@@ -271,28 +427,6 @@ async function listPendingRequests(directory) {
271
427
  return [];
272
428
  }
273
429
  }
274
- async function recoverStaleProcessing(directory) {
275
- const dir = path.join(controlDir(directory), "processing");
276
- try {
277
- const files = await fs.readdir(dir);
278
- const recovered = [];
279
- for (const file of files) {
280
- if (!file.endsWith(".json"))
281
- continue;
282
- const processingPath = path.join(dir, file);
283
- const requestPath = path.join(controlDir(directory), "requests", file);
284
- try {
285
- const raw = await fs.readFile(processingPath, "utf8");
286
- const request = JSON.parse(raw);
287
- await fs.rename(processingPath, requestPath);
288
- recovered.push(request);
289
- } catch {}
290
- }
291
- return recovered;
292
- } catch {
293
- return [];
294
- }
295
- }
296
430
  function goalArtifactDir(directory, goalID) {
297
431
  return path.join(loopDir(directory), "goals", goalID);
298
432
  }
@@ -329,82 +463,58 @@ async function drainGoalInbox(directory, goalID) {
329
463
  function delay(ms) {
330
464
  return new Promise((resolve) => setTimeout(resolve, ms));
331
465
  }
332
- var CURRENT_VERSION = 2, LOCK_STALE_MS = 1e4;
333
- var init_state_repository = () => {};
334
466
 
335
- // src/domain/runtime.ts
336
- var exports_runtime = {};
337
- __export(exports_runtime, {
338
- acquireLease: () => acquireLease,
339
- createRuntimeState: () => createRuntimeState,
340
- leaseIsValid: () => leaseIsValid,
341
- markParentNotified: () => markParentNotified,
342
- markProgress: () => markProgress,
343
- releaseLease: () => releaseLease,
344
- shouldNotifyParent: () => shouldNotifyParent
345
- });
346
- function createRuntimeState(goalID) {
347
- const now = new Date().toISOString();
348
- return {
349
- goalID,
350
- phase: "idle",
351
- consecutiveFailures: 0,
352
- runCount: 0,
353
- turnCount: 0,
354
- noProgressCount: 0,
355
- progressDuringTurn: false,
356
- createdAt: now,
357
- updatedAt: now
358
- };
359
- }
360
- function acquireLease(rt, timeoutMs) {
361
- const now = Date.now();
362
- const expires = new Date(now + timeoutMs).toISOString();
363
- return {
364
- ...rt,
365
- phase: "running",
366
- leaseExpiresAt: expires,
367
- turnStartedAt: new Date(now).toISOString(),
368
- progressDuringTurn: false,
369
- turnTokensUsed: 0,
370
- updatedAt: new Date(now).toISOString()
371
- };
372
- }
373
- function releaseLease(rt) {
467
+ // src/application/goal-policy.ts
468
+ function resolveGoalCreationConfig(input) {
469
+ const requested = input.config || {};
470
+ const defaults = input.defaults || {};
471
+ const explicitAgent = cleanText(requested.agent);
472
+ const defaultAgent = cleanText(defaults.defaultAgent);
473
+ const agent = explicitAgent || defaultAgent;
474
+ if (!agent) {
475
+ return {
476
+ ok: false,
477
+ errorCode: "missing_agent",
478
+ message: "An agent is required. Pass agent explicitly or configure plugin option defaultAgent."
479
+ };
480
+ }
481
+ const workspaceWrite = requested.workspaceWrite ?? true;
482
+ const explicitChecks = cleanList(requested.checks);
483
+ const defaultChecks = workspaceWrite ? cleanList(defaults.defaultChecks) : [];
484
+ const checks = explicitChecks.length > 0 ? explicitChecks : defaultChecks;
485
+ if (workspaceWrite && checks.length === 0) {
486
+ return {
487
+ ok: false,
488
+ errorCode: "missing_checks",
489
+ message: "Workspace-writing goals require completion checks. Pass checks or configure plugin option defaultChecks."
490
+ };
491
+ }
374
492
  return {
375
- ...rt,
376
- phase: "idle",
377
- leaseExpiresAt: undefined,
378
- turnStartedAt: undefined,
379
- updatedAt: new Date().toISOString()
493
+ ok: true,
494
+ config: {
495
+ ...requested,
496
+ agent,
497
+ workspaceWrite,
498
+ checks: checks.length > 0 ? checks : undefined,
499
+ checkCwd: requested.checkCwd || (workspaceWrite ? input.directory : undefined)
500
+ },
501
+ defaultsApplied: {
502
+ agent: !explicitAgent && Boolean(defaultAgent),
503
+ checks: explicitChecks.length === 0 && defaultChecks.length > 0
504
+ }
380
505
  };
381
506
  }
382
- function leaseIsValid(rt) {
383
- if (!rt.leaseExpiresAt)
384
- return false;
385
- return Date.now() < Date.parse(rt.leaseExpiresAt);
386
- }
387
- function markProgress(rt) {
388
- return { ...rt, progressDuringTurn: true, lastProgressAt: new Date().toISOString() };
389
- }
390
- function shouldNotifyParent(runtime, type) {
391
- if (!runtime.lastParentNotifiedAt || !runtime.lastParentNotifiedFor)
392
- return true;
393
- if (runtime.lastParentNotifiedFor !== type)
394
- return true;
395
- const elapsed = Date.now() - Date.parse(runtime.lastParentNotifiedAt);
396
- return !Number.isFinite(elapsed) || elapsed > PARENT_NOTIFY_DEDUPE_MS;
507
+ function cleanText(value) {
508
+ if (typeof value !== "string")
509
+ return;
510
+ const trimmed = value.trim();
511
+ return trimmed || undefined;
397
512
  }
398
- function markParentNotified(runtime, type) {
399
- runtime.lastParentNotifiedFor = type;
400
- runtime.lastParentNotifiedAt = new Date().toISOString();
401
- runtime.updatedAt = new Date().toISOString();
513
+ function cleanList(value) {
514
+ if (!Array.isArray(value))
515
+ return [];
516
+ return value.map(cleanText).filter((item) => Boolean(item));
402
517
  }
403
- var PARENT_NOTIFY_DEDUPE_MS = 60000;
404
-
405
- // src/application/control-worker.ts
406
- init_state_repository();
407
- import { randomUUID as randomUUID2 } from "crypto";
408
518
 
409
519
  // src/infrastructure/server-log.ts
410
520
  import { appendFile } from "fs/promises";
@@ -548,11 +658,26 @@ function createControlWorker(options) {
548
658
  };
549
659
  break;
550
660
  }
661
+ const resolution = resolveGoalCreationConfig({
662
+ directory,
663
+ objective: args.objective,
664
+ config: args.config,
665
+ defaults: options.defaults
666
+ });
667
+ if (!resolution.ok) {
668
+ response = {
669
+ ...base,
670
+ ok: false,
671
+ message: resolution.message,
672
+ errorCode: resolution.errorCode
673
+ };
674
+ break;
675
+ }
551
676
  const { goal } = await goalSvc.start(directory, {
552
677
  name: args.name,
553
678
  objective: args.objective,
554
679
  ownerSessionID: args.ownerSessionID,
555
- config: args.config
680
+ config: resolution.config
556
681
  });
557
682
  const state2 = await readState(directory);
558
683
  response = {
@@ -654,7 +779,7 @@ function createControlWorker(options) {
654
779
  await writeState(directory, state2);
655
780
  await appendEvent(directory, {
656
781
  version: 1,
657
- eventID: randomUUID2(),
782
+ eventID: randomUUID(),
658
783
  goalID: goal.id,
659
784
  type: "goal.completed",
660
785
  summary: goal.completionEvidence.summary,
@@ -694,7 +819,7 @@ function createControlWorker(options) {
694
819
  await writeState(directory, state2);
695
820
  await appendEvent(directory, {
696
821
  version: 1,
697
- eventID: randomUUID2(),
822
+ eventID: randomUUID(),
698
823
  goalID: goal.id,
699
824
  type: "goal.blocked",
700
825
  reason: goal.blocker.reason,
@@ -740,8 +865,7 @@ function createControlWorker(options) {
740
865
  }
741
866
 
742
867
  // src/application/loop-engine.ts
743
- init_state_repository();
744
- import { randomUUID as randomUUID3 } from "crypto";
868
+ import { randomUUID as randomUUID2 } from "crypto";
745
869
 
746
870
  // src/domain/goal.ts
747
871
  var MODEL_TRANSITIONS = {
@@ -780,15 +904,20 @@ function createGoal(input) {
780
904
  return { ...input, tokensUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
781
905
  }
782
906
  // src/application/loop-engine.ts
907
+ var CONFIRM_IDLE_DURATION_MS = 2000;
783
908
  var HANDLED_EVENT_TYPES = new Set([
784
909
  "session.idle",
785
910
  "session.status",
786
911
  "session.error",
787
- "session.compacted"
912
+ "session.compacted",
913
+ "message.updated",
914
+ "message.part.updated"
788
915
  ]);
789
916
  function createLoopEngine(options) {
790
917
  const { directory, host, goalService } = options;
791
918
  const maintenanceMs = options.pollIntervalMs ?? 30000;
919
+ const confirmIdleMs = options.confirmIdleMs ?? CONFIRM_IDLE_DURATION_MS;
920
+ const unknownStatusThreshold = Math.max(1, options.unknownStatusThreshold ?? 3);
792
921
  let running = false;
793
922
  let maintenanceTimer;
794
923
  let knownWorkerSessions = new Set;
@@ -853,7 +982,7 @@ function createLoopEngine(options) {
853
982
  const type = event.type;
854
983
  if (!type || !HANDLED_EVENT_TYPES.has(type))
855
984
  return false;
856
- const sessionID = event.properties?.sessionID;
985
+ const sessionID = eventSessionID(event);
857
986
  if (!sessionID)
858
987
  return false;
859
988
  await loadWorkerSessionsIfneeded();
@@ -868,7 +997,7 @@ function createLoopEngine(options) {
868
997
  return false;
869
998
  if (goal.workerSessionID)
870
999
  knownWorkerSessions.add(goal.workerSessionID);
871
- if (isTerminal(goal.status) || goal.status === "paused")
1000
+ if (goal.status !== "active")
872
1001
  return false;
873
1002
  switch (type) {
874
1003
  case "session.idle":
@@ -879,98 +1008,253 @@ function createLoopEngine(options) {
879
1008
  return await handleSessionError(state, goal, event);
880
1009
  case "session.compacted":
881
1010
  return await handleSessionCompacted(state, goal);
1011
+ case "message.updated":
1012
+ case "message.part.updated":
1013
+ return await handleMessageActivity(goal, event);
882
1014
  default:
883
1015
  return false;
884
1016
  }
885
1017
  }
1018
+ function eventSessionID(event) {
1019
+ return event.properties?.sessionID || event.properties?.info?.sessionID || event.properties?.part?.sessionID;
1020
+ }
1021
+ async function handleMessageActivity(goal, event) {
1022
+ let matched = false;
1023
+ await mutateState(directory, `message-activity:${goal.id}`, async (s) => {
1024
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1025
+ if (!rt || rt.phase !== "running" || !rt.activePromptMessageID)
1026
+ return s;
1027
+ if (event.type === "message.updated") {
1028
+ const info = event.properties?.info;
1029
+ if (info?.role === "user" && info.id === rt.activePromptMessageID) {
1030
+ Object.assign(rt, recordActivity(rt));
1031
+ rt.activePromptObservedAt = new Date().toISOString();
1032
+ matched = true;
1033
+ } else if (info?.role === "assistant" && info.parentID === rt.activePromptMessageID) {
1034
+ Object.assign(rt, recordActivity(rt));
1035
+ rt.activeAssistantMessageID = info.id;
1036
+ if (info.time?.completed) {
1037
+ rt.activeAssistantCompletedAt = new Date(info.time.completed).toISOString();
1038
+ }
1039
+ matched = true;
1040
+ }
1041
+ } else {
1042
+ const part = event.properties?.part;
1043
+ if (part?.messageID && part.messageID === rt.activeAssistantMessageID) {
1044
+ Object.assign(rt, recordActivity(rt));
1045
+ matched = true;
1046
+ }
1047
+ }
1048
+ return s;
1049
+ });
1050
+ return matched;
1051
+ }
886
1052
  async function handleSessionIdle(state, goal) {
887
- const runtime = state.runtimes.find((r) => r.goalID === goal.id);
888
- if (!runtime)
889
- return false;
890
- if (inflightContinuations.has(goal.id))
1053
+ const goalID = goal.id;
1054
+ if (inflightContinuations.has(goalID))
891
1055
  return false;
892
- if (runtime.phase === "running") {
893
- const completedRunID = runtime.activeRunID;
894
- Object.assign(runtime, releaseLease(runtime));
895
- runtime.activeRunID = undefined;
896
- runtime.lastWorkerStatus = "idle";
897
- await writeState(directory, state);
898
- if (completedRunID) {
899
- await appendEvent(directory, {
900
- version: 1,
901
- eventID: randomUUID3(),
902
- goalID: goal.id,
903
- type: "run.completed",
904
- runID: completedRunID,
905
- timestamp: new Date().toISOString(),
906
- revision: state.revision
907
- });
1056
+ let completedRunID;
1057
+ let confirmation;
1058
+ let afterIdle = await mutateState(directory, `idle:${goalID}`, async (s) => {
1059
+ const g = s.goals.find((item) => item.id === goalID);
1060
+ if (!g)
1061
+ return s;
1062
+ if (isTerminal(g.status) || g.status === "paused")
1063
+ return s;
1064
+ const rt = s.runtimes.find((r) => r.goalID === goalID);
1065
+ if (!rt)
1066
+ return s;
1067
+ if (rt.phase !== "running")
1068
+ return s;
1069
+ if ((rt.activeToolCallIDs?.length ?? 0) > 0) {
1070
+ rt.idleCandidateAt = undefined;
1071
+ rt.idleCandidateGeneration = undefined;
1072
+ return s;
1073
+ }
1074
+ const now = Date.now();
1075
+ if (!rt.idleCandidateAt || rt.idleCandidateGeneration !== rt.runGeneration) {
1076
+ rt.idleCandidateAt = new Date(now).toISOString();
1077
+ rt.idleCandidateGeneration = rt.runGeneration;
1078
+ return s;
1079
+ }
1080
+ const elapsed = now - Date.parse(rt.idleCandidateAt);
1081
+ if (elapsed < confirmIdleMs)
1082
+ return s;
1083
+ if (rt.lastActivityAt && rt.lastActivityAt > rt.idleCandidateAt) {
1084
+ rt.idleCandidateAt = undefined;
1085
+ rt.idleCandidateGeneration = undefined;
1086
+ return s;
908
1087
  }
1088
+ if (rt.activePromptMessageID) {
1089
+ confirmation = {
1090
+ generation: rt.runGeneration,
1091
+ promptMessageID: rt.activePromptMessageID,
1092
+ candidateAt: rt.idleCandidateAt,
1093
+ assistantCompleted: Boolean(rt.activeAssistantCompletedAt)
1094
+ };
1095
+ } else {
1096
+ completedRunID = rt.activeRunID;
1097
+ Object.assign(rt, releaseLease(rt));
1098
+ rt.activeRunID = undefined;
1099
+ rt.lastWorkerStatus = "idle";
1100
+ }
1101
+ return s;
1102
+ });
1103
+ if (confirmation) {
1104
+ const candidate = confirmation;
1105
+ const transcript = await inspectPromptTurn(goal.workerSessionID, candidate.promptMessageID);
1106
+ if (!transcript.latestUserPrompt || !candidate.assistantCompleted && !transcript.assistantCompleted) {
1107
+ return true;
1108
+ }
1109
+ afterIdle = await mutateState(directory, `idle.confirm:${goalID}`, async (s) => {
1110
+ const g = s.goals.find((item) => item.id === goalID);
1111
+ const rt = s.runtimes.find((r) => r.goalID === goalID);
1112
+ if (!g || !rt || isTerminal(g.status) || g.status === "paused")
1113
+ return s;
1114
+ if (rt.phase !== "running")
1115
+ return s;
1116
+ if (rt.runGeneration !== candidate.generation)
1117
+ return s;
1118
+ if (rt.activePromptMessageID !== candidate.promptMessageID)
1119
+ return s;
1120
+ if (rt.idleCandidateGeneration !== candidate.generation)
1121
+ return s;
1122
+ if (rt.idleCandidateAt !== candidate.candidateAt)
1123
+ return s;
1124
+ if (rt.lastActivityAt && rt.lastActivityAt > candidate.candidateAt)
1125
+ return s;
1126
+ if ((rt.activeToolCallIDs?.length ?? 0) > 0)
1127
+ return s;
1128
+ completedRunID = rt.activeRunID;
1129
+ Object.assign(rt, releaseLease(rt));
1130
+ rt.activeRunID = undefined;
1131
+ rt.lastWorkerStatus = "idle";
1132
+ return s;
1133
+ });
909
1134
  }
910
- if (goal.status !== "active")
1135
+ if (completedRunID) {
1136
+ await appendEvent(directory, {
1137
+ version: 1,
1138
+ eventID: randomUUID2(),
1139
+ goalID,
1140
+ type: "run.completed",
1141
+ runID: completedRunID,
1142
+ timestamp: new Date().toISOString(),
1143
+ revision: afterIdle.revision
1144
+ });
1145
+ } else {
1146
+ return true;
1147
+ }
1148
+ const freshState = await readState(directory);
1149
+ const freshGoal = freshState.goals.find((g) => g.id === goalID);
1150
+ if (!freshGoal || freshGoal.status !== "active")
1151
+ return false;
1152
+ const freshRuntime = freshState.runtimes.find((r) => r.goalID === goalID);
1153
+ if (!freshRuntime)
911
1154
  return false;
912
- const limitResult = enforceLimits(goal, runtime);
1155
+ const limitResult = enforceLimits(freshGoal, freshRuntime);
913
1156
  if (limitResult.stop === "force_finish") {
914
- if (!runtime.forceFinishRequested) {
915
- runtime.forceFinishRequested = true;
916
- await writeState(directory, state);
917
- await goalService.continueTurn(directory, goal.id, { forceFinish: true });
1157
+ if (!freshRuntime.forceFinishRequested) {
1158
+ await mutateState(directory, `idle.force-finish:${goalID}`, async (s) => {
1159
+ const rt = s.runtimes.find((r) => r.goalID === goalID);
1160
+ if (rt)
1161
+ rt.forceFinishRequested = true;
1162
+ return s;
1163
+ });
1164
+ await goalService.continueTurn(directory, goalID, { forceFinish: true });
918
1165
  return true;
919
1166
  }
920
- const blockedKey = goal.id;
1167
+ const blockedKey = goalID;
921
1168
  const nowBlocked = Date.now();
922
1169
  const lastBlocked = recentForceFinishBlocked.get(blockedKey);
923
1170
  if (lastBlocked !== undefined && nowBlocked - lastBlocked < 60000)
924
1171
  return true;
925
1172
  recentForceFinishBlocked.set(blockedKey, nowBlocked);
926
- goal.status = "blocked";
927
- goal.updatedAt = new Date().toISOString();
928
- goal.blocker = {
929
- reason: limitResult.reason + " (force-finish ignored)",
930
- needed: "User intervention required. Use retry to attempt again.",
931
- at: new Date().toISOString()
932
- };
933
- runtime.forceFinishRequested = undefined;
934
- const shouldNotify = shouldNotifyParent(runtime, "stopped");
935
- if (shouldNotify)
936
- markParentNotified(runtime, "stopped");
937
- await writeState(directory, state);
1173
+ let shouldNotifyBlocked = false;
1174
+ const blockedState = await mutateState(directory, `idle.blocked:${goalID}`, async (s) => {
1175
+ const g = s.goals.find((item) => item.id === goalID);
1176
+ if (!g)
1177
+ return s;
1178
+ g.status = "blocked";
1179
+ g.updatedAt = new Date().toISOString();
1180
+ g.blocker = {
1181
+ reason: limitResult.reason + " (force-finish ignored)",
1182
+ needed: "User intervention required. Use retry to attempt again.",
1183
+ at: new Date().toISOString()
1184
+ };
1185
+ const rt = s.runtimes.find((r) => r.goalID === goalID);
1186
+ if (rt) {
1187
+ rt.forceFinishRequested = undefined;
1188
+ if (shouldNotifyParent(rt, "stopped")) {
1189
+ markParentNotified(rt, "stopped");
1190
+ shouldNotifyBlocked = true;
1191
+ }
1192
+ }
1193
+ return s;
1194
+ });
938
1195
  await appendEvent(directory, {
939
1196
  version: 1,
940
- eventID: randomUUID3(),
941
- goalID: goal.id,
1197
+ eventID: randomUUID2(),
1198
+ goalID,
942
1199
  type: "goal.blocked",
943
1200
  reason: limitResult.reason + " (force-finish ignored)",
944
- needed: goal.blocker.needed,
1201
+ needed: "User intervention required. Use retry to attempt again.",
945
1202
  timestamp: new Date().toISOString(),
946
- revision: state.revision
1203
+ revision: blockedState.revision
947
1204
  });
948
- if (shouldNotify) {
1205
+ if (shouldNotifyBlocked) {
949
1206
  await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" stopped: ${limitResult.reason} (child did not wrap up). Status: blocked. Last progress: ${goal.lastProgress?.summary || "none"}.`);
950
1207
  }
951
1208
  return true;
952
1209
  }
953
1210
  if (limitResult.stop === "budget") {
954
- await writeState(directory, state);
1211
+ const budgetState = await mutateState(directory, `idle.budget:${goalID}`, async (s) => {
1212
+ const g = s.goals.find((item) => item.id === goalID);
1213
+ if (g) {
1214
+ g.status = "budget_limited";
1215
+ g.updatedAt = new Date().toISOString();
1216
+ }
1217
+ return s;
1218
+ });
955
1219
  await appendEvent(directory, {
956
1220
  version: 1,
957
- eventID: randomUUID3(),
958
- goalID: goal.id,
1221
+ eventID: randomUUID2(),
1222
+ goalID,
959
1223
  type: "goal.status_changed",
960
1224
  from: "active",
961
- to: goal.status,
1225
+ to: "budget_limited",
962
1226
  timestamp: new Date().toISOString(),
963
- revision: state.revision
1227
+ revision: budgetState.revision
964
1228
  });
965
1229
  return true;
966
1230
  }
967
- if (shouldCompact(goal, runtime)) {
968
- await doCompact(goal, runtime);
1231
+ if (shouldCompact(freshGoal, freshRuntime)) {
1232
+ await doCompact(freshGoal, freshRuntime);
969
1233
  return true;
970
1234
  }
971
- await continueGoal(goal.id);
1235
+ await continueGoal(goalID);
972
1236
  return true;
973
1237
  }
1238
+ async function inspectPromptTurn(workerSessionID, promptMessageID) {
1239
+ const noMatch = { latestUserPrompt: false, assistantCompleted: false };
1240
+ if (!workerSessionID)
1241
+ return noMatch;
1242
+ let messages;
1243
+ try {
1244
+ messages = await host.readMessages(workerSessionID, 50);
1245
+ } catch {
1246
+ return noMatch;
1247
+ }
1248
+ const users = messages.filter((message) => message.role === "user");
1249
+ if (users.length === 0)
1250
+ return noMatch;
1251
+ const allTimestamped = users.every((message) => message.timestamp && Number.isFinite(Date.parse(message.timestamp)));
1252
+ const ordered = allTimestamped ? [...users].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)) : users;
1253
+ return {
1254
+ latestUserPrompt: ordered.at(-1)?.messageID === promptMessageID,
1255
+ assistantCompleted: messages.some((message) => message.role === "assistant" && message.parentMessageID === promptMessageID && Boolean(message.completedAt))
1256
+ };
1257
+ }
974
1258
  async function handleSessionStatus(state, goal, event) {
975
1259
  const runtime = state.runtimes.find((r) => r.goalID === goal.id);
976
1260
  if (!runtime)
@@ -981,9 +1265,17 @@ function createLoopEngine(options) {
981
1265
  return false;
982
1266
  if (statusType === "idle")
983
1267
  return handleSessionIdle(state, goal);
984
- runtime.lastWorkerStatus = statusType;
985
- runtime.updatedAt = new Date().toISOString();
986
- await writeState(directory, state);
1268
+ await mutateState(directory, `status:${goal.id}`, async (s) => {
1269
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1270
+ if (!rt)
1271
+ return s;
1272
+ if (statusType === "busy" || statusType === "retry") {
1273
+ Object.assign(rt, recordActivity(rt));
1274
+ }
1275
+ rt.lastWorkerStatus = statusType;
1276
+ rt.updatedAt = new Date().toISOString();
1277
+ return s;
1278
+ });
987
1279
  return true;
988
1280
  }
989
1281
  async function handleSessionError(state, goal, event) {
@@ -992,75 +1284,95 @@ function createLoopEngine(options) {
992
1284
  return false;
993
1285
  const error = event.properties?.error;
994
1286
  const message = describeError(error) || "unknown error";
995
- runtime.consecutiveFailures += 1;
996
- runtime.lastError = message;
997
- runtime.updatedAt = new Date().toISOString();
998
- if (runtime.phase === "running") {
999
- Object.assign(runtime, releaseLease(runtime));
1000
- }
1287
+ let shouldNotify = false;
1288
+ const newState = await mutateState(directory, `error:${goal.id}`, async (s) => {
1289
+ const g = s.goals.find((item) => item.id === goal.id);
1290
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1291
+ if (!rt)
1292
+ return s;
1293
+ rt.consecutiveFailures += 1;
1294
+ rt.lastError = message;
1295
+ rt.updatedAt = new Date().toISOString();
1296
+ rt.idleCandidateAt = undefined;
1297
+ if (rt.phase === "running") {
1298
+ Object.assign(rt, releaseLease(rt));
1299
+ }
1300
+ if (rt.consecutiveFailures >= (goal.config?.maxFailures || 5)) {
1301
+ if (g) {
1302
+ g.status = "blocked";
1303
+ g.updatedAt = new Date().toISOString();
1304
+ g.blocker = {
1305
+ reason: `Failed ${rt.consecutiveFailures} times. Last error: ${message}`,
1306
+ needed: "User intervention required. Use retry to attempt again.",
1307
+ at: new Date().toISOString()
1308
+ };
1309
+ }
1310
+ if (shouldNotifyParent(rt, "failed")) {
1311
+ markParentNotified(rt, "failed");
1312
+ shouldNotify = true;
1313
+ }
1314
+ } else {
1315
+ const backoffMs = Math.min(30000, 1000 * Math.pow(2, rt.consecutiveFailures));
1316
+ rt.retryAfter = new Date(Date.now() + backoffMs).toISOString();
1317
+ rt.phase = "waiting_retry";
1318
+ }
1319
+ return s;
1320
+ });
1001
1321
  await appendEvent(directory, {
1002
1322
  version: 1,
1003
- eventID: randomUUID3(),
1323
+ eventID: randomUUID2(),
1004
1324
  goalID: goal.id,
1005
1325
  type: "run.failed",
1006
1326
  runID: runtime.activeRunID || "unknown",
1007
1327
  error: message,
1008
- consecutiveFailures: runtime.consecutiveFailures,
1328
+ consecutiveFailures: runtime.consecutiveFailures + 1,
1009
1329
  timestamp: new Date().toISOString(),
1010
- revision: state.revision
1330
+ revision: newState.revision
1011
1331
  });
1012
- const maxFailures = goal.config?.maxFailures || 5;
1013
- if (runtime.consecutiveFailures >= maxFailures) {
1014
- goal.status = "blocked";
1015
- goal.updatedAt = new Date().toISOString();
1016
- goal.blocker = {
1017
- reason: `Failed ${runtime.consecutiveFailures} times. Last error: ${message}`,
1018
- needed: "User intervention required. Use retry to attempt again.",
1019
- at: new Date().toISOString()
1020
- };
1332
+ const updatedGoal = newState.goals.find((g) => g.id === goal.id);
1333
+ if (updatedGoal?.status === "blocked") {
1021
1334
  await appendEvent(directory, {
1022
1335
  version: 1,
1023
- eventID: randomUUID3(),
1336
+ eventID: randomUUID2(),
1024
1337
  goalID: goal.id,
1025
1338
  type: "goal.blocked",
1026
- reason: `Failed ${runtime.consecutiveFailures} times`,
1339
+ reason: `Failed ${runtime.consecutiveFailures + 1} times`,
1027
1340
  needed: "User intervention required",
1028
1341
  timestamp: new Date().toISOString(),
1029
- revision: state.revision
1342
+ revision: newState.revision
1030
1343
  });
1031
- if (shouldNotifyParent(runtime, "failed")) {
1032
- markParentNotified(runtime, "failed");
1033
- await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" blocked after ${runtime.consecutiveFailures} failures. Last error: ${message}.`);
1344
+ if (shouldNotify) {
1345
+ await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" blocked after ${runtime.consecutiveFailures + 1} failures. Last error: ${message}.`);
1034
1346
  }
1035
- } else {
1036
- const backoffMs = Math.min(30000, 1000 * Math.pow(2, runtime.consecutiveFailures));
1037
- runtime.retryAfter = new Date(Date.now() + backoffMs).toISOString();
1038
- runtime.phase = "waiting_retry";
1039
1347
  }
1040
- await writeState(directory, state);
1041
1348
  return true;
1042
1349
  }
1043
1350
  async function handleSessionCompacted(state, goal) {
1044
1351
  const runtime = state.runtimes.find((r) => r.goalID === goal.id);
1045
1352
  if (!runtime)
1046
1353
  return false;
1047
- runtime.lastCompactAt = new Date().toISOString();
1048
- runtime.updatedAt = new Date().toISOString();
1049
- await writeState(directory, state);
1354
+ const newState = await mutateState(directory, `compacted:${goal.id}`, async (s) => {
1355
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1356
+ if (!rt)
1357
+ return s;
1358
+ rt.lastCompactAt = new Date().toISOString();
1359
+ rt.updatedAt = new Date().toISOString();
1360
+ return s;
1361
+ });
1050
1362
  await appendEvent(directory, {
1051
1363
  version: 1,
1052
- eventID: randomUUID3(),
1364
+ eventID: randomUUID2(),
1053
1365
  goalID: goal.id,
1054
1366
  type: "compaction.completed",
1055
1367
  timestamp: new Date().toISOString(),
1056
- revision: state.revision
1368
+ revision: newState.revision
1057
1369
  });
1058
1370
  return true;
1059
1371
  }
1060
1372
  function enforceLimits(goal, runtime) {
1061
1373
  const noResult = { stop: "none", blocked: false, event: "goal.status_changed", reason: "" };
1062
1374
  const maxTurns = goal.config?.maxTurns;
1063
- if (maxTurns && runtime.turnCount >= maxTurns) {
1375
+ if (maxTurns && runtime.budgetTurnCount >= maxTurns) {
1064
1376
  return {
1065
1377
  stop: "force_finish",
1066
1378
  blocked: true,
@@ -1093,19 +1405,23 @@ function createLoopEngine(options) {
1093
1405
  const compactEvery = goal.config?.compactEvery;
1094
1406
  if (!compactEvery)
1095
1407
  return false;
1096
- return runtime.turnCount > 0 && runtime.turnCount % compactEvery === 0;
1408
+ return runtime.runCount > 0 && runtime.runCount % compactEvery === 0;
1097
1409
  }
1098
1410
  async function doCompact(goal, runtime) {
1099
1411
  if (!goal.workerSessionID)
1100
1412
  return;
1101
1413
  const prevPhase = runtime.phase;
1102
- runtime.phase = "compacting";
1103
- runtime.lastCompactAt = new Date().toISOString();
1104
- const state = await readState(directory);
1105
- await writeState(directory, state);
1414
+ const state = await mutateState(directory, `compact.start:${goal.id}`, async (s) => {
1415
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1416
+ if (!rt)
1417
+ return s;
1418
+ rt.phase = "compacting";
1419
+ rt.lastCompactAt = new Date().toISOString();
1420
+ return s;
1421
+ });
1106
1422
  await appendEvent(directory, {
1107
1423
  version: 1,
1108
- eventID: randomUUID3(),
1424
+ eventID: randomUUID2(),
1109
1425
  goalID: goal.id,
1110
1426
  type: "compaction.started",
1111
1427
  timestamp: new Date().toISOString(),
@@ -1114,39 +1430,99 @@ function createLoopEngine(options) {
1114
1430
  try {
1115
1431
  await host.compactSession(goal.workerSessionID);
1116
1432
  } catch {}
1117
- const updatedState = await readState(directory);
1118
- const updatedRuntime = updatedState.runtimes.find((r) => r.goalID === goal.id);
1119
- if (updatedRuntime) {
1120
- updatedRuntime.phase = prevPhase;
1121
- await writeState(directory, updatedState);
1122
- }
1433
+ await mutateState(directory, `compact.end:${goal.id}`, async (s) => {
1434
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1435
+ if (rt)
1436
+ rt.phase = prevPhase;
1437
+ return s;
1438
+ });
1123
1439
  }
1124
1440
  async function maintenance() {
1125
1441
  syncWorkerSessionsFromService();
1126
1442
  if (knownWorkerSessions.size === 0)
1127
1443
  return;
1128
1444
  const state = await readState(directory);
1129
- const hasActiveGoals = state.goals.some((g) => !isTerminal(g.status) && g.status !== "paused");
1445
+ const hasActiveGoals = state.goals.some((g) => g.status === "active");
1130
1446
  if (!hasActiveGoals)
1131
1447
  return;
1132
1448
  for (const goal of state.goals) {
1133
- if (isTerminal(goal.status) || goal.status === "paused")
1449
+ if (goal.status !== "active")
1134
1450
  continue;
1135
1451
  const runtime = state.runtimes.find((r) => r.goalID === goal.id);
1136
1452
  if (!runtime)
1137
1453
  continue;
1138
1454
  if (runtime.phase === "waiting_retry" && runtime.retryAfter) {
1139
1455
  if (Date.now() >= Date.parse(runtime.retryAfter)) {
1140
- runtime.retryAfter = undefined;
1141
- runtime.phase = "idle";
1142
- await writeState(directory, state);
1456
+ await mutateState(directory, `retry-ready:${goal.id}`, async (s) => {
1457
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1458
+ if (rt) {
1459
+ rt.retryAfter = undefined;
1460
+ rt.phase = "idle";
1461
+ }
1462
+ return s;
1463
+ });
1143
1464
  goalService.continueTurn(directory, goal.id).catch(() => {});
1144
1465
  }
1145
1466
  }
1146
1467
  if ((runtime.phase === "running" || runtime.phase === "idle") && goal.workerSessionID) {
1468
+ if (runtime.phase === "idle" && runtime.activeRunID) {
1469
+ await mutateState(directory, `maintenance.clear-stale-run:${goal.id}`, async (s) => {
1470
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1471
+ if (rt?.phase === "idle" && rt.activeRunID) {
1472
+ Object.assign(rt, releaseLease(rt));
1473
+ rt.activeRunID = undefined;
1474
+ rt.lastWorkerStatus = "idle";
1475
+ }
1476
+ return s;
1477
+ });
1478
+ await logServerEvent(directory, "maintenance.stale-run-cleared", { goalID: goal.id });
1479
+ }
1147
1480
  const status = await host.sessionStatus(goal.workerSessionID);
1481
+ if (status === "unknown") {
1482
+ let shouldNotify = false;
1483
+ const unknownState = await mutateState(directory, `maintenance.unknown-status:${goal.id}`, async (s) => {
1484
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1485
+ if (!rt)
1486
+ return s;
1487
+ rt.unknownStatusCount = Math.min(unknownStatusThreshold, (rt.unknownStatusCount ?? 0) + 1);
1488
+ rt.lastUnknownStatusAt = new Date().toISOString();
1489
+ if (rt.unknownStatusCount >= unknownStatusThreshold && !rt.workerUnreachableNotifiedAt) {
1490
+ rt.workerUnreachableNotifiedAt = new Date().toISOString();
1491
+ shouldNotify = true;
1492
+ }
1493
+ rt.updatedAt = new Date().toISOString();
1494
+ return s;
1495
+ });
1496
+ const unknownRuntime = unknownState.runtimes.find((r) => r.goalID === goal.id);
1497
+ if (shouldNotify) {
1498
+ await logServerEvent(directory, "maintenance.worker-unreachable", {
1499
+ goalID: goal.id,
1500
+ workerSessionID: goal.workerSessionID,
1501
+ count: unknownRuntime?.unknownStatusCount
1502
+ });
1503
+ await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" worker is unreachable after ${unknownRuntime?.unknownStatusCount ?? unknownStatusThreshold} status checks. The goal remains active; use inspect_background_goal, nudge_goal, pause_goal, or resume_goal to recover it.`);
1504
+ }
1505
+ continue;
1506
+ }
1507
+ if ((runtime.unknownStatusCount ?? 0) > 0 || runtime.workerUnreachableNotifiedAt) {
1508
+ await mutateState(directory, `maintenance.status-recovered:${goal.id}`, async (s) => {
1509
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1510
+ if (rt) {
1511
+ rt.unknownStatusCount = 0;
1512
+ rt.lastUnknownStatusAt = undefined;
1513
+ rt.workerUnreachableNotifiedAt = undefined;
1514
+ rt.updatedAt = new Date().toISOString();
1515
+ }
1516
+ return s;
1517
+ });
1518
+ await logServerEvent(directory, "maintenance.worker-recovered", { goalID: goal.id });
1519
+ }
1148
1520
  if (status === "idle") {
1149
- await handleSessionIdle(state, goal);
1521
+ if (runtime.phase === "idle") {
1522
+ await continueGoal(goal.id);
1523
+ } else {
1524
+ await handleSessionIdle(state, goal);
1525
+ }
1150
1526
  continue;
1151
1527
  }
1152
1528
  }
@@ -1156,8 +1532,7 @@ function createLoopEngine(options) {
1156
1532
  }
1157
1533
 
1158
1534
  // src/application/goal-service.ts
1159
- import { randomUUID as randomUUID4 } from "crypto";
1160
- init_state_repository();
1535
+ import { randomUUID as randomUUID3 } from "crypto";
1161
1536
  import * as path2 from "path";
1162
1537
  import { promises as fs2 } from "fs";
1163
1538
 
@@ -1178,11 +1553,13 @@ function createWorkerManager(host) {
1178
1553
  },
1179
1554
  async continueWorker(worker, goal, runtime, context) {
1180
1555
  const prompt = buildContinuationSteering(goal, runtime, context);
1181
- await host.promptWorker({
1556
+ const result = await host.promptWorker({
1182
1557
  sessionID: worker.workerSessionID,
1183
1558
  prompt,
1559
+ messageID: runtime.activePromptMessageID,
1184
1560
  agent: goal.config.agent
1185
1561
  });
1562
+ return result;
1186
1563
  },
1187
1564
  async isIdle(workerSessionID) {
1188
1565
  const status = await host.sessionStatus(workerSessionID);
@@ -1211,11 +1588,11 @@ function buildContinuationSteering(goal, runtime, context) {
1211
1588
  `Exception: if the objective explicitly specifies a different output directory, follow the objective instead.`
1212
1589
  ];
1213
1590
  }
1214
- if (runtime.turnCount <= 1) {
1591
+ if (runtime.runCount <= 1) {
1215
1592
  parts.push(`You are a worker for an active goal.`, ``, `Call get_goal to read the authoritative objective, acceptance criteria, and current state.`, `Perform one concrete batch of work. After durable verification:`, ``, `- Call report_goal_progress if work remains.`, `- Call complete_goal only if ALL acceptance criteria pass with concrete evidence.`, `- Call block_goal only for a real external blocker requiring user intervention.`, `- Use the built-in question tool when you need clarification only the user can provide.`, ``, `Do not ask questions unnecessarily. Make reasonable assumptions and work directly.`);
1216
1593
  parts.push(...outputLocationBlock());
1217
1594
  } else {
1218
- parts.push(`This is continuation turn ${runtime.turnCount} for the goal below.`, ``, `## GOAL (user-provided data)`, goal.objective);
1595
+ parts.push(`This is continuation run ${runtime.runCount} for the goal below.`, ``, `## GOAL (user-provided data)`, goal.objective);
1219
1596
  const progress = context?.progressHistory;
1220
1597
  if (progress && progress.length > 0) {
1221
1598
  parts.push(``, `## PROGRESS SO FAR`);
@@ -1243,22 +1620,32 @@ function buildContinuationSteering(goal, runtime, context) {
1243
1620
  parts.push(...outputLocationBlock());
1244
1621
  if (context?.verification) {
1245
1622
  const v = context.verification;
1246
- parts.push(``, `## VERIFICATION (deterministic pre-screen)`);
1247
- if (v.checksPassed !== undefined) {
1248
- if (v.checksPassed)
1249
- parts.push(`- checks: all passed`);
1250
- else if (v.failedChecks?.length)
1251
- parts.push(`- checks FAILED: ${v.failedChecks.join(", ")} \u2014 fix before claiming completion`);
1252
- else
1253
- parts.push(`- checks: not yet run`);
1254
- }
1255
- if (v.artifactSummary)
1256
- parts.push(`- artifacts: ${v.artifactSummary}`);
1257
1623
  if (v.evaluatorRejectionCount && v.evaluatorRejectionCount > 0) {
1258
- parts.push(`- evaluator rejected ${v.evaluatorRejectionCount} time(s): previous completion claim had weak evidence \u2014 fix the issues and call complete_goal again with stronger evidence`);
1624
+ parts.push(``, `## HOST VERDICT: COMPLETION REJECTED`);
1625
+ parts.push(`Rejection #${v.evaluatorRejectionCount}`);
1626
+ if (v.lastRejectionDetails) {
1627
+ parts.push(v.lastRejectionDetails);
1628
+ }
1629
+ parts.push(``, `Required action:`);
1630
+ parts.push(`- Fix the behavior causing the command(s) above to fail.`);
1631
+ parts.push(`- Do NOT merely rewrite the completion evidence.`);
1632
+ parts.push(`- Rerun the command from the stated directory.`);
1633
+ parts.push(`- Call complete_goal only after the command passes.`);
1634
+ } else {
1635
+ parts.push(``, `## VERIFICATION (deterministic pre-screen)`);
1636
+ if (v.checksPassed !== undefined) {
1637
+ if (v.checksPassed)
1638
+ parts.push(`- checks: all passed`);
1639
+ else if (v.failedChecks?.length)
1640
+ parts.push(`- checks FAILED: ${v.failedChecks.join(", ")} \u2014 fix before claiming completion`);
1641
+ else
1642
+ parts.push(`- checks: not yet run`);
1643
+ }
1644
+ if (v.artifactSummary)
1645
+ parts.push(`- artifacts: ${v.artifactSummary}`);
1259
1646
  }
1260
1647
  }
1261
- parts.push(``, `## COMPLETION AUDIT \u2014 you ARE the evaluator`, `Before deciding the goal is achieved, treat completion as unproven:`, `1. Derive concrete requirements from the objective and any referenced files/plans/specs/issues. Preserve original scope; do not redefine success.`, `2. For _every_ explicit requirement, numbered item, named artifact, command, test, gate, invariant, deliverable \u2192 identify authoritative evidence: files, command output, test results, PR state, rendered artifacts, runtime behavior.`, `3. Judge each per-requirement: proves | contradicts | incomplete | too weak/indirect | missing \u2014 matching scope narrowly (narrow check \u2260 broad claim).`, `4. Treat tests/manifests/verifiers as evidence only after confirming they cover the relevant requirement. Treat uncertain/indirect as NOT achieved.`, `5. Only call complete_goal when _every_ requirement's current-state evidence proves it and no required work remains. If any requirement is missing/incomplete/weak \u2192 keep working, do not call complete_goal.`);
1648
+ parts.push(``, `## COMPLETION REVIEW`, `You are the semantic reviewer. The host is the acceptance authority.`, `Before proposing completion:`, `1. Derive concrete requirements from the objective and any referenced files/plans/specs/issues.`, `2. For each requirement, identify authoritative evidence: files, command output, test results.`, `3. Judge each: proves | contradicts | incomplete | missing.`, `4. Only call complete_goal when you have verified every requirement yourself.`, `5. If objective and checks appear contradictory, call block_goal \u2014 do not silently violate either.`);
1262
1649
  if (context?.forceFinish) {
1263
1650
  parts.push(``, `## FINAL REPORT REQUIRED \u2014 STOPPING SOON`, `The system requires you to wrap up now. Do NOT start new work.`, `Call complete_goal NOW with:`, `- summary: a specific semantic summary of what was accomplished (files changed, results, key findings)`, `- evidence: concrete proof (commands run, files created, checks passed)`, `If you cannot complete truthfully, call block_goal with the reason \u2014 do not fabricate evidence.`);
1264
1651
  } else {
@@ -1279,9 +1666,119 @@ function buildContinuationSteering(goal, runtime, context) {
1279
1666
  function createGoalService(host) {
1280
1667
  const workers = createWorkerManager(host);
1281
1668
  const sessions = new Map;
1669
+ const goalOperations = new Map;
1670
+ async function withGoalOperation(goalID, fn) {
1671
+ const previous = goalOperations.get(goalID) || Promise.resolve();
1672
+ let release;
1673
+ const gate = new Promise((resolve) => {
1674
+ release = resolve;
1675
+ });
1676
+ const current = previous.catch(() => {}).then(() => gate);
1677
+ goalOperations.set(goalID, current);
1678
+ await previous.catch(() => {});
1679
+ try {
1680
+ return await fn();
1681
+ } finally {
1682
+ release();
1683
+ if (goalOperations.get(goalID) === current)
1684
+ goalOperations.delete(goalID);
1685
+ }
1686
+ }
1687
+ function assertWorkspaceWriteAvailable(state, goal) {
1688
+ if (!goal.config.workspaceWrite)
1689
+ return;
1690
+ const activeWriter = state.goals.find((item) => item.id !== goal.id && item.status === "active" && item.config.workspaceWrite);
1691
+ if (activeWriter) {
1692
+ throw new Error(`Workspace-writing goal "${activeWriter.name}" (${activeWriter.id}) is already active. ` + "Pause, block, complete, or clear it before activating another workspace-writing goal.");
1693
+ }
1694
+ }
1695
+ async function recordPromptFailure(directory, goalID, error, blockImmediately = false) {
1696
+ const detail = describeError(error);
1697
+ let runID = "unknown";
1698
+ let failureCount = 0;
1699
+ let blocked = false;
1700
+ let blockerNeeded = "Retry after the OpenCode worker/session API is available.";
1701
+ const state = await mutateState(directory, `turn.prompt-failed:${goalID}`, async (s) => {
1702
+ const goal = s.goals.find((item) => item.id === goalID);
1703
+ const rt = s.runtimes.find((item) => item.goalID === goalID);
1704
+ if (!goal || !rt)
1705
+ return s;
1706
+ runID = rt.activeRunID || "unknown";
1707
+ failureCount = rt.consecutiveFailures + 1;
1708
+ Object.assign(rt, releaseLease(rt));
1709
+ rt.activeRunID = undefined;
1710
+ rt.consecutiveFailures = failureCount;
1711
+ rt.lastError = detail;
1712
+ blocked = blockImmediately || failureCount >= (goal.config.maxFailures || 5);
1713
+ if (blocked) {
1714
+ blockerNeeded = blockImmediately ? "Retry after the OpenCode worker/session API is available." : "Fix the underlying error and use retry_goal to attempt again.";
1715
+ goal.status = "blocked";
1716
+ goal.blocker = {
1717
+ reason: `Worker prompt delivery failed: ${detail}`,
1718
+ needed: blockerNeeded,
1719
+ at: new Date().toISOString()
1720
+ };
1721
+ } else {
1722
+ const backoffMs = Math.min(30000, 1000 * Math.pow(2, failureCount));
1723
+ rt.phase = "waiting_retry";
1724
+ rt.retryAfter = new Date(Date.now() + backoffMs).toISOString();
1725
+ }
1726
+ goal.updatedAt = new Date().toISOString();
1727
+ rt.updatedAt = new Date().toISOString();
1728
+ return s;
1729
+ });
1730
+ await appendEvent(directory, {
1731
+ version: 1,
1732
+ eventID: randomUUID3(),
1733
+ goalID,
1734
+ type: "run.failed",
1735
+ runID,
1736
+ error: detail,
1737
+ consecutiveFailures: failureCount,
1738
+ timestamp: new Date().toISOString(),
1739
+ revision: state.revision
1740
+ });
1741
+ if (blocked) {
1742
+ await appendEvent(directory, {
1743
+ version: 1,
1744
+ eventID: randomUUID3(),
1745
+ goalID,
1746
+ type: "goal.blocked",
1747
+ reason: `Worker prompt delivery failed: ${detail}`,
1748
+ needed: blockerNeeded,
1749
+ timestamp: new Date().toISOString(),
1750
+ revision: state.revision
1751
+ });
1752
+ }
1753
+ }
1754
+ async function ensureWorkerSession(directory, goal) {
1755
+ let session = sessions.get(goal.id);
1756
+ if (session)
1757
+ return session;
1758
+ if (goal.workerSessionID) {
1759
+ session = {
1760
+ goalID: goal.id,
1761
+ workerSessionID: goal.workerSessionID,
1762
+ startedAt: goal.createdAt
1763
+ };
1764
+ sessions.set(goal.id, session);
1765
+ return session;
1766
+ }
1767
+ session = await workers.createWorker(goal);
1768
+ sessions.set(goal.id, session);
1769
+ await mutateState(directory, `goal.set-worker:${goal.id}`, async (s) => {
1770
+ const persisted = s.goals.find((item) => item.id === goal.id);
1771
+ if (persisted)
1772
+ persisted.workerSessionID = session.workerSessionID;
1773
+ return s;
1774
+ });
1775
+ return session;
1776
+ }
1282
1777
  async function start(directory, input) {
1283
- const state = await readState(directory);
1284
- const id = randomUUID4();
1778
+ const id = randomUUID3();
1779
+ return withGoalOperation(id, () => startUnlocked(directory, input, id));
1780
+ }
1781
+ async function startUnlocked(directory, input, id) {
1285
1782
  const goal = createGoal({
1286
1783
  id,
1287
1784
  name: input.name,
@@ -1290,6 +1787,7 @@ function createGoalService(host) {
1290
1787
  ownerSessionID: input.ownerSessionID,
1291
1788
  config: {
1292
1789
  maxTurns: 50,
1790
+ workspaceWrite: true,
1293
1791
  ...input.config
1294
1792
  }
1295
1793
  });
@@ -1298,86 +1796,109 @@ function createGoalService(host) {
1298
1796
  if (!goal.config.progressFile)
1299
1797
  goal.config.progressFile = path2.join(artifactDir, "progress.md");
1300
1798
  await ensureGoalArtifactDir(directory, id);
1301
- state.goals.push(goal);
1302
- state.runtimes.push(createRuntimeState(id));
1303
- const runtime = state.runtimes.find((r) => r.goalID === id);
1304
- if (runtime) {
1305
- runtime.phase = "queued";
1306
- await writeState(directory, state);
1307
- }
1799
+ const state1 = await mutateState(directory, `goal.create:${id}`, async (state) => {
1800
+ assertWorkspaceWriteAvailable(state, goal);
1801
+ state.goals.push(goal);
1802
+ state.runtimes.push(createRuntimeState(id));
1803
+ const runtime2 = state.runtimes.find((r) => r.goalID === id);
1804
+ if (runtime2)
1805
+ runtime2.phase = "queued";
1806
+ return state;
1807
+ });
1808
+ let runtime = state1.runtimes.find((r) => r.goalID === id);
1308
1809
  let worker;
1309
1810
  try {
1310
1811
  worker = await workers.createWorker(goal);
1311
1812
  } catch (error) {
1312
1813
  const detail = describeError(error);
1313
- goal.status = "blocked";
1314
- goal.updatedAt = new Date().toISOString();
1315
- goal.blocker = {
1316
- reason: detail,
1317
- needed: "Start the goal again from a valid OpenCode session after correcting the worker creation error.",
1318
- at: new Date().toISOString()
1319
- };
1320
- if (runtime) {
1321
- runtime.phase = "idle";
1322
- runtime.lastError = detail;
1323
- runtime.updatedAt = new Date().toISOString();
1324
- }
1325
- await writeState(directory, state);
1814
+ const blockedState = await mutateState(directory, `goal.blocked:${id}`, async (state) => {
1815
+ const g = state.goals.find((item) => item.id === id);
1816
+ if (!g)
1817
+ return state;
1818
+ g.status = "blocked";
1819
+ g.updatedAt = new Date().toISOString();
1820
+ g.blocker = {
1821
+ reason: detail,
1822
+ needed: "Start the goal again from a valid OpenCode session after correcting the worker creation error.",
1823
+ at: new Date().toISOString()
1824
+ };
1825
+ const rt = state.runtimes.find((item) => item.goalID === id);
1826
+ if (rt) {
1827
+ rt.phase = "idle";
1828
+ rt.lastError = detail;
1829
+ rt.updatedAt = new Date().toISOString();
1830
+ }
1831
+ return state;
1832
+ });
1326
1833
  await appendEvent(directory, {
1327
1834
  version: 1,
1328
- eventID: randomUUID4(),
1835
+ eventID: randomUUID3(),
1329
1836
  goalID: id,
1330
1837
  type: "goal.blocked",
1331
1838
  reason: detail,
1332
- needed: goal.blocker.needed,
1839
+ needed: goal.blocker?.needed || "",
1333
1840
  timestamp: new Date().toISOString(),
1334
- revision: state.revision
1841
+ revision: blockedState.revision
1335
1842
  });
1336
1843
  await logServerEvent(directory, "goal.start.failed", { goalID: id, ownerSessionID: input.ownerSessionID, detail });
1337
1844
  throw error;
1338
1845
  }
1339
1846
  sessions.set(id, worker);
1340
- goal.workerSessionID = worker.workerSessionID;
1341
- await writeState(directory, state);
1847
+ const state2 = await mutateState(directory, `goal.worker-assign:${id}`, async (state) => {
1848
+ const g = state.goals.find((item) => item.id === id);
1849
+ if (!g)
1850
+ return state;
1851
+ g.workerSessionID = worker.workerSessionID;
1852
+ goal.workerSessionID = worker.workerSessionID;
1853
+ const rt = state.runtimes.find((item) => item.goalID === id);
1854
+ if (rt) {
1855
+ Object.assign(rt, acquireLease(rt, g.config.timeoutMs || 300000));
1856
+ rt.activeRunID = randomUUID3();
1857
+ rt.activePromptMessageID = `msg-${randomUUID3()}`;
1858
+ rt.runCount = 1;
1859
+ rt.budgetTurnCount = 1;
1860
+ rt.lastRunAt = new Date().toISOString();
1861
+ }
1862
+ return state;
1863
+ });
1864
+ runtime = state2.runtimes.find((r) => r.goalID === id);
1342
1865
  await appendEvent(directory, {
1343
1866
  version: 1,
1344
- eventID: randomUUID4(),
1867
+ eventID: randomUUID3(),
1345
1868
  goalID: id,
1346
1869
  type: "goal.created",
1347
1870
  name: input.name,
1348
1871
  objective: input.objective,
1349
1872
  ownerSessionID: input.ownerSessionID,
1350
1873
  timestamp: new Date().toISOString(),
1351
- revision: state.revision
1874
+ revision: state2.revision
1352
1875
  });
1353
1876
  if (runtime) {
1354
- const runID = randomUUID4();
1355
- Object.assign(runtime, acquireLease(runtime, goal.config.timeoutMs || 300000));
1356
- runtime.activeRunID = runID;
1357
- runtime.turnCount = 1;
1358
- runtime.runCount = 1;
1359
- runtime.lastRunAt = new Date().toISOString();
1360
- await writeState(directory, state);
1361
1877
  await appendEvent(directory, {
1362
1878
  version: 1,
1363
- eventID: randomUUID4(),
1879
+ eventID: randomUUID3(),
1364
1880
  goalID: id,
1365
1881
  type: "run.started",
1366
- runID,
1367
- turnCount: runtime.turnCount,
1882
+ runID: runtime.activeRunID,
1883
+ turnCount: runtime.runCount,
1368
1884
  timestamp: new Date().toISOString(),
1369
- revision: state.revision
1885
+ revision: state2.revision
1370
1886
  });
1371
- await workers.continueWorker(worker, goal, runtime);
1887
+ try {
1888
+ await workers.continueWorker(worker, goal, runtime);
1889
+ } catch (error) {
1890
+ await recordPromptFailure(directory, id, error, true);
1891
+ throw error;
1892
+ }
1372
1893
  }
1373
1894
  return { goal, worker };
1374
1895
  }
1375
- async function continueTurn(directory, goalID, opts) {
1376
- const state = await readState(directory);
1377
- const goal = state.goals.find((g) => g.id === goalID);
1378
- if (!goal || isTerminal(goal.status))
1896
+ async function continueTurnUnlocked(directory, goalID, opts) {
1897
+ const preState = await readState(directory);
1898
+ const goal = preState.goals.find((g) => g.id === goalID);
1899
+ if (!goal || goal.status !== "active")
1379
1900
  return;
1380
- const runtime = state.runtimes.find((r) => r.goalID === goalID);
1901
+ const runtime = preState.runtimes.find((r) => r.goalID === goalID);
1381
1902
  if (!runtime)
1382
1903
  return;
1383
1904
  if (runtime.phase === "running" && leaseIsValid(runtime))
@@ -1393,24 +1914,43 @@ function createGoalService(host) {
1393
1914
  }
1394
1915
  if (!session)
1395
1916
  return;
1396
- if (!await workers.isIdle(session.workerSessionID))
1917
+ if (!opts?.force && !await workers.isIdle(session.workerSessionID))
1918
+ return;
1919
+ let acquired = false;
1920
+ const state = await mutateState(directory, `turn.acquire:${goalID}`, async (s) => {
1921
+ const g = s.goals.find((item) => item.id === goalID);
1922
+ if (!g || g.status !== "active")
1923
+ return s;
1924
+ const rt = s.runtimes.find((item) => item.goalID === goalID);
1925
+ if (!rt)
1926
+ return s;
1927
+ if (rt.phase === "running" && leaseIsValid(rt))
1928
+ return s;
1929
+ const timeoutMs = g.config.timeoutMs || 300000;
1930
+ Object.assign(rt, acquireLease(rt, timeoutMs));
1931
+ rt.activeRunID = randomUUID3();
1932
+ rt.activePromptMessageID = `msg-${randomUUID3()}`;
1933
+ rt.runCount += 1;
1934
+ if (rt.freeRetryPending) {
1935
+ rt.freeRetryPending = false;
1936
+ } else {
1937
+ rt.budgetTurnCount += 1;
1938
+ }
1939
+ rt.lastRunAt = new Date().toISOString();
1940
+ acquired = true;
1941
+ return s;
1942
+ });
1943
+ const freshGoal = state.goals.find((g) => g.id === goalID);
1944
+ const freshRuntime = state.runtimes.find((r) => r.goalID === goalID);
1945
+ if (!acquired || !freshGoal || freshGoal.status !== "active" || !freshRuntime?.activeRunID)
1397
1946
  return;
1398
- const timeoutMs = goal.config.timeoutMs || 300000;
1399
- const leased = acquireLease(runtime, timeoutMs);
1400
- Object.assign(runtime, leased);
1401
- const runID = randomUUID4();
1402
- runtime.activeRunID = runID;
1403
- runtime.turnCount += 1;
1404
- runtime.runCount += 1;
1405
- runtime.lastRunAt = new Date().toISOString();
1406
- await writeState(directory, state);
1407
1947
  await appendEvent(directory, {
1408
1948
  version: 1,
1409
- eventID: randomUUID4(),
1949
+ eventID: randomUUID3(),
1410
1950
  goalID,
1411
1951
  type: "run.started",
1412
- runID,
1413
- turnCount: runtime.turnCount,
1952
+ runID: freshRuntime.activeRunID,
1953
+ turnCount: freshRuntime.runCount,
1414
1954
  timestamp: new Date().toISOString(),
1415
1955
  revision: state.revision
1416
1956
  });
@@ -1423,13 +1963,13 @@ function createGoalService(host) {
1423
1963
  }));
1424
1964
  let transcriptTail;
1425
1965
  try {
1426
- transcriptTail = await host.readMessages(goal.workerSessionID, 5);
1966
+ transcriptTail = await host.readMessages(freshGoal.workerSessionID, 5);
1427
1967
  } catch {
1428
1968
  transcriptTail = [];
1429
1969
  }
1430
1970
  let verification;
1431
1971
  try {
1432
- const artifactDir = goal.config.artifactDir;
1972
+ const artifactDir = freshGoal.config.artifactDir;
1433
1973
  if (artifactDir) {
1434
1974
  try {
1435
1975
  const files = await fs2.readdir(artifactDir);
@@ -1438,12 +1978,15 @@ function createGoalService(host) {
1438
1978
  verification = { artifactSummary: "no artifacts yet" };
1439
1979
  }
1440
1980
  }
1441
- if (goal.config.checks?.length) {
1442
- const c = `checks configured: ${goal.config.checks.length} \u2014 run them before claiming completion`;
1981
+ if (freshGoal.config.checks?.length) {
1982
+ const c = `checks configured: ${freshGoal.config.checks.length} \u2014 run them before claiming completion`;
1443
1983
  verification = { ...verification || {}, failedChecks: [c], checksPassed: undefined };
1444
1984
  }
1445
- if (runtime.evaluatorRejectionCount && runtime.evaluatorRejectionCount > 0) {
1446
- verification = { ...verification || {}, evaluatorRejectionCount: runtime.evaluatorRejectionCount };
1985
+ if (freshRuntime.evaluatorRejectionCount && freshRuntime.evaluatorRejectionCount > 0) {
1986
+ verification = { ...verification || {}, evaluatorRejectionCount: freshRuntime.evaluatorRejectionCount };
1987
+ }
1988
+ if (freshRuntime.lastRejectionDetails) {
1989
+ verification = { ...verification || {}, lastRejectionDetails: freshRuntime.lastRejectionDetails };
1447
1990
  }
1448
1991
  } catch {}
1449
1992
  const context = {
@@ -1453,17 +1996,31 @@ function createGoalService(host) {
1453
1996
  forceFinish: opts?.forceFinish || undefined,
1454
1997
  verification
1455
1998
  };
1456
- await workers.continueWorker(session, goal, runtime, context);
1999
+ try {
2000
+ await workers.continueWorker(session, freshGoal, freshRuntime, context);
2001
+ } catch (error) {
2002
+ await recordPromptFailure(directory, goalID, error);
2003
+ throw error;
2004
+ }
1457
2005
  }
1458
- async function pause(directory, goalID) {
1459
- const state = await readState(directory);
1460
- const goal = state.goals.find((g) => g.id === goalID);
2006
+ async function pauseUnlocked(directory, goalID) {
2007
+ const preState = await readState(directory);
2008
+ const goal = preState.goals.find((g) => g.id === goalID);
1461
2009
  if (!goal)
1462
2010
  return;
1463
2011
  if (!canTransition(goal.status, "paused", "user"))
1464
2012
  return;
1465
- goal.status = "paused";
1466
- goal.updatedAt = new Date().toISOString();
2013
+ const state = await mutateState(directory, `goal.pause:${goalID}`, async (s) => {
2014
+ const g = s.goals.find((item) => item.id === goalID);
2015
+ if (!g)
2016
+ return s;
2017
+ g.status = "paused";
2018
+ g.updatedAt = new Date().toISOString();
2019
+ const rt = s.runtimes.find((r) => r.goalID === goalID);
2020
+ if (rt)
2021
+ Object.assign(rt, releaseLease(rt));
2022
+ return s;
2023
+ });
1467
2024
  const session = sessions.get(goalID) || (goal.workerSessionID ? {
1468
2025
  goalID: goal.id,
1469
2026
  workerSessionID: goal.workerSessionID,
@@ -1473,14 +2030,9 @@ function createGoalService(host) {
1473
2030
  await workers.abortWorker(session.workerSessionID);
1474
2031
  sessions.delete(goalID);
1475
2032
  }
1476
- const runtime = state.runtimes.find((r) => r.goalID === goalID);
1477
- if (runtime) {
1478
- Object.assign(runtime, releaseLease(runtime));
1479
- }
1480
- await writeState(directory, state);
1481
2033
  await appendEvent(directory, {
1482
2034
  version: 1,
1483
- eventID: randomUUID4(),
2035
+ eventID: randomUUID3(),
1484
2036
  goalID,
1485
2037
  type: "goal.status_changed",
1486
2038
  from: "active",
@@ -1489,25 +2041,37 @@ function createGoalService(host) {
1489
2041
  revision: state.revision
1490
2042
  });
1491
2043
  }
1492
- async function resume(directory, goalID) {
1493
- const state = await readState(directory);
2044
+ async function resumeUnlocked(directory, goalID) {
2045
+ let resumed = false;
2046
+ const state = await mutateState(directory, `goal.resume:${goalID}`, async (state2) => {
2047
+ const goal2 = state2.goals.find((g) => g.id === goalID);
2048
+ if (!goal2)
2049
+ return state2;
2050
+ if (!canTransition(goal2.status, "active", "user"))
2051
+ return state2;
2052
+ assertWorkspaceWriteAvailable(state2, goal2);
2053
+ goal2.status = "active";
2054
+ goal2.updatedAt = new Date().toISOString();
2055
+ resumed = true;
2056
+ return state2;
2057
+ });
1494
2058
  const goal = state.goals.find((g) => g.id === goalID);
1495
- if (!goal)
2059
+ if (!goal || !resumed)
1496
2060
  return;
1497
- if (!canTransition(goal.status, "active", "user"))
1498
- return;
1499
- goal.status = "active";
1500
- goal.updatedAt = new Date().toISOString();
1501
- let session = sessions.get(goalID);
1502
- if (!session) {
1503
- session = await workers.createWorker(goal);
1504
- sessions.set(goalID, session);
1505
- goal.workerSessionID = session.workerSessionID;
2061
+ try {
2062
+ await ensureWorkerSession(directory, goal);
2063
+ } catch (error) {
2064
+ await mutateState(directory, `goal.resume-rollback:${goalID}`, async (s) => {
2065
+ const g = s.goals.find((item) => item.id === goalID);
2066
+ if (g?.status === "active")
2067
+ g.status = "paused";
2068
+ return s;
2069
+ });
2070
+ throw error;
1506
2071
  }
1507
- await writeState(directory, state);
1508
2072
  await appendEvent(directory, {
1509
2073
  version: 1,
1510
- eventID: randomUUID4(),
2074
+ eventID: randomUUID3(),
1511
2075
  goalID,
1512
2076
  type: "goal.status_changed",
1513
2077
  from: "paused",
@@ -1515,29 +2079,47 @@ function createGoalService(host) {
1515
2079
  timestamp: new Date().toISOString(),
1516
2080
  revision: state.revision
1517
2081
  });
1518
- await continueTurn(directory, goalID);
2082
+ await continueTurnUnlocked(directory, goalID);
1519
2083
  }
1520
- async function retry(directory, goalID) {
1521
- const state = await readState(directory);
2084
+ async function retryUnlocked(directory, goalID) {
2085
+ let retried = false;
2086
+ const state = await mutateState(directory, `goal.retry:${goalID}`, async (state2) => {
2087
+ const goal2 = state2.goals.find((g) => g.id === goalID);
2088
+ if (!goal2 || goal2.status !== "blocked")
2089
+ return state2;
2090
+ assertWorkspaceWriteAvailable(state2, goal2);
2091
+ goal2.status = "active";
2092
+ goal2.updatedAt = new Date().toISOString();
2093
+ retried = true;
2094
+ const runtime = state2.runtimes.find((r) => r.goalID === goalID);
2095
+ if (runtime) {
2096
+ runtime.consecutiveFailures = 0;
2097
+ runtime.lastError = undefined;
2098
+ runtime.forceFinishRequested = undefined;
2099
+ runtime.lastParentNotifiedAt = undefined;
2100
+ runtime.lastParentNotifiedFor = undefined;
2101
+ runtime.phase = "idle";
2102
+ runtime.updatedAt = new Date().toISOString();
2103
+ }
2104
+ return state2;
2105
+ });
1522
2106
  const goal = state.goals.find((g) => g.id === goalID);
1523
- if (!goal || goal.status !== "blocked")
2107
+ if (!goal || !retried)
1524
2108
  return;
1525
- goal.status = "active";
1526
- goal.updatedAt = new Date().toISOString();
1527
- const runtime = state.runtimes.find((r) => r.goalID === goalID);
1528
- if (runtime) {
1529
- runtime.consecutiveFailures = 0;
1530
- runtime.lastError = undefined;
1531
- runtime.forceFinishRequested = undefined;
1532
- runtime.lastParentNotifiedAt = undefined;
1533
- runtime.lastParentNotifiedFor = undefined;
1534
- runtime.phase = "idle";
1535
- runtime.updatedAt = new Date().toISOString();
2109
+ try {
2110
+ await ensureWorkerSession(directory, goal);
2111
+ } catch (error) {
2112
+ await mutateState(directory, `goal.retry-rollback:${goalID}`, async (s) => {
2113
+ const g = s.goals.find((item) => item.id === goalID);
2114
+ if (g?.status === "active")
2115
+ g.status = "blocked";
2116
+ return s;
2117
+ });
2118
+ throw error;
1536
2119
  }
1537
- await writeState(directory, state);
1538
2120
  await appendEvent(directory, {
1539
2121
  version: 1,
1540
- eventID: randomUUID4(),
2122
+ eventID: randomUUID3(),
1541
2123
  goalID,
1542
2124
  type: "goal.status_changed",
1543
2125
  from: "blocked",
@@ -1545,9 +2127,9 @@ function createGoalService(host) {
1545
2127
  timestamp: new Date().toISOString(),
1546
2128
  revision: state.revision
1547
2129
  });
1548
- await continueTurn(directory, goalID);
2130
+ await continueTurnUnlocked(directory, goalID);
1549
2131
  }
1550
- async function clear(directory, goalID) {
2132
+ async function clearUnlocked(directory, goalID) {
1551
2133
  const state = await readState(directory);
1552
2134
  const goal = state.goals.find((g) => g.id === goalID);
1553
2135
  if (!goal)
@@ -1561,17 +2143,19 @@ function createGoalService(host) {
1561
2143
  await workers.abortWorker(session.workerSessionID);
1562
2144
  sessions.delete(goalID);
1563
2145
  }
2146
+ await mutateState(directory, `goal.clear:${goalID}`, async (s) => {
2147
+ s.goals = s.goals.filter((g) => g.id !== goalID);
2148
+ s.runtimes = s.runtimes.filter((r) => r.goalID !== goalID);
2149
+ return s;
2150
+ });
1564
2151
  await appendEvent(directory, {
1565
2152
  version: 1,
1566
- eventID: randomUUID4(),
2153
+ eventID: randomUUID3(),
1567
2154
  goalID,
1568
2155
  type: "goal.cleared",
1569
2156
  timestamp: new Date().toISOString(),
1570
2157
  revision: state.revision
1571
2158
  });
1572
- state.goals = state.goals.filter((g) => g.id !== goalID);
1573
- state.runtimes = state.runtimes.filter((r) => r.goalID !== goalID);
1574
- await writeState(directory, state);
1575
2159
  }
1576
2160
  function getWorker(goalID) {
1577
2161
  return sessions.get(goalID);
@@ -1587,29 +2171,42 @@ function createGoalService(host) {
1587
2171
  if (goal.status === "paused")
1588
2172
  continue;
1589
2173
  if (!goal.workerSessionID) {
2174
+ let worker;
1590
2175
  try {
1591
- const worker = await workers.createWorker(goal);
2176
+ worker = await workers.createWorker(goal);
1592
2177
  sessions.set(goal.id, worker);
1593
- goal.workerSessionID = worker.workerSessionID;
1594
- goal.updatedAt = new Date().toISOString();
1595
2178
  } catch (error) {
1596
2179
  const detail = describeError(error);
1597
- goal.status = "blocked";
1598
- goal.updatedAt = new Date().toISOString();
1599
- goal.blocker = {
1600
- reason: detail,
1601
- needed: "Clear this goal and start it again from a valid OpenCode session.",
1602
- at: new Date().toISOString()
1603
- };
1604
- const runtime2 = state.runtimes.find((item) => item.goalID === goal.id);
1605
- if (runtime2) {
1606
- runtime2.phase = "idle";
1607
- runtime2.lastError = detail;
1608
- runtime2.updatedAt = new Date().toISOString();
1609
- }
2180
+ await mutateState(directory, `reconcile.block:${goal.id}`, async (s) => {
2181
+ const g = s.goals.find((item) => item.id === goal.id);
2182
+ if (!g)
2183
+ return s;
2184
+ g.status = "blocked";
2185
+ g.updatedAt = new Date().toISOString();
2186
+ g.blocker = {
2187
+ reason: detail,
2188
+ needed: "Clear this goal and start it again from a valid OpenCode session.",
2189
+ at: new Date().toISOString()
2190
+ };
2191
+ const rt = s.runtimes.find((item) => item.goalID === goal.id);
2192
+ if (rt) {
2193
+ rt.phase = "idle";
2194
+ rt.lastError = detail;
2195
+ rt.updatedAt = new Date().toISOString();
2196
+ }
2197
+ return s;
2198
+ });
1610
2199
  await logServerEvent(directory, "goal.reconcile.failed", { goalID: goal.id, ownerSessionID: goal.ownerSessionID, detail });
1611
2200
  continue;
1612
2201
  }
2202
+ await mutateState(directory, `reconcile.set-worker:${goal.id}`, async (s) => {
2203
+ const g = s.goals.find((item) => item.id === goal.id);
2204
+ if (g) {
2205
+ g.workerSessionID = worker.workerSessionID;
2206
+ g.updatedAt = new Date().toISOString();
2207
+ }
2208
+ return s;
2209
+ });
1613
2210
  }
1614
2211
  if (goal.workerSessionID && !sessions.has(goal.id)) {
1615
2212
  sessions.set(goal.id, {
@@ -1618,18 +2215,70 @@ function createGoalService(host) {
1618
2215
  startedAt: goal.createdAt
1619
2216
  });
1620
2217
  }
1621
- const runtime = state.runtimes.find((r) => r.goalID === goal.id);
1622
- if (runtime?.phase === "running" && !leaseIsValid(runtime)) {
2218
+ const preRt = state.runtimes.find((r) => r.goalID === goal.id);
2219
+ if (preRt?.phase === "running" && !leaseIsValid(preRt)) {
1623
2220
  const session = sessions.get(goal.id);
1624
2221
  if (session && await workers.isIdle(session.workerSessionID)) {
1625
- Object.assign(runtime, releaseLease(runtime));
1626
- goal.updatedAt = new Date().toISOString();
2222
+ await mutateState(directory, `reconcile.release-lease:${goal.id}`, async (s) => {
2223
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
2224
+ if (rt) {
2225
+ Object.assign(rt, releaseLease(rt));
2226
+ const g = s.goals.find((item) => item.id === goal.id);
2227
+ if (g)
2228
+ g.updatedAt = new Date().toISOString();
2229
+ }
2230
+ return s;
2231
+ });
1627
2232
  }
1628
2233
  }
1629
2234
  }
1630
- await writeState(directory, state);
1631
2235
  }
1632
- return { start, continueTurn, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile };
2236
+ async function nudgeUnlocked(directory, goalID) {
2237
+ const preState = await readState(directory);
2238
+ const goal = preState.goals.find((g) => g.id === goalID);
2239
+ if (!goal)
2240
+ return { ok: false, message: "Goal not found." };
2241
+ if (goal.status !== "active") {
2242
+ return { ok: false, message: `Goal is ${goal.status}; resume or retry it before nudging.` };
2243
+ }
2244
+ const cleared = await mutateState(directory, `goal.nudge:${goalID}`, async (s) => {
2245
+ const rt = s.runtimes.find((r) => r.goalID === goalID);
2246
+ if (!rt)
2247
+ return s;
2248
+ rt.phase = "idle";
2249
+ rt.activeRunID = undefined;
2250
+ rt.idleCandidateAt = undefined;
2251
+ rt.activePromptMessageID = undefined;
2252
+ rt.activeToolCallIDs = [];
2253
+ rt.updatedAt = new Date().toISOString();
2254
+ return s;
2255
+ });
2256
+ const freshGoal = cleared.goals.find((g) => g.id === goalID);
2257
+ if (!freshGoal || !freshGoal.workerSessionID) {
2258
+ return { ok: false, message: "Goal has no worker session. Use resume_goal or retry_goal." };
2259
+ }
2260
+ await continueTurnUnlocked(directory, goalID, { force: true });
2261
+ return { ok: true, message: `Re-prompted worker for "${freshGoal.name}".` };
2262
+ }
2263
+ function continueTurn(directory, goalID, opts) {
2264
+ return withGoalOperation(goalID, () => continueTurnUnlocked(directory, goalID, opts));
2265
+ }
2266
+ function pause(directory, goalID) {
2267
+ return withGoalOperation(goalID, () => pauseUnlocked(directory, goalID));
2268
+ }
2269
+ function resume(directory, goalID) {
2270
+ return withGoalOperation(goalID, () => resumeUnlocked(directory, goalID));
2271
+ }
2272
+ function retry(directory, goalID) {
2273
+ return withGoalOperation(goalID, () => retryUnlocked(directory, goalID));
2274
+ }
2275
+ function clear(directory, goalID) {
2276
+ return withGoalOperation(goalID, () => clearUnlocked(directory, goalID));
2277
+ }
2278
+ function nudge(directory, goalID) {
2279
+ return withGoalOperation(goalID, () => nudgeUnlocked(directory, goalID));
2280
+ }
2281
+ return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile };
1633
2282
  }
1634
2283
 
1635
2284
  // src/server/host-adapter.ts
@@ -1672,10 +2321,14 @@ function createRealHost(client, directory) {
1672
2321
  throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
1673
2322
  }
1674
2323
  },
1675
- async promptWorker({ sessionID, prompt, model, agent }) {
2324
+ async promptWorker({ sessionID, prompt, messageID, model, agent }) {
1676
2325
  const body = {
1677
2326
  parts: [{ type: "text", text: prompt }]
1678
2327
  };
2328
+ if (messageID) {
2329
+ const collapsed = messageID.replace(/^(msg-)+/, "msg-");
2330
+ body.messageID = collapsed.startsWith("msg-") ? collapsed : `msg-${messageID}`;
2331
+ }
1679
2332
  if (model)
1680
2333
  body.model = model;
1681
2334
  if (agent)
@@ -1690,22 +2343,23 @@ function createRealHost(client, directory) {
1690
2343
  throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`);
1691
2344
  }
1692
2345
  await logServerEvent(directory, "worker.prompted", { sessionID });
2346
+ return { messageID: result?.data?.messageID };
1693
2347
  },
1694
2348
  async sessionStatus(sessionID) {
1695
2349
  try {
1696
2350
  const result = await client.session.status({});
1697
2351
  const data = result?.data;
1698
2352
  if (!data || typeof data !== "object")
1699
- return "idle";
2353
+ return "unknown";
1700
2354
  const status = data[sessionID];
1701
2355
  if (!status || typeof status !== "object")
1702
- return "idle";
2356
+ return "unknown";
1703
2357
  const type = status.type;
1704
2358
  if (type === "busy" || type === "retry")
1705
2359
  return type;
1706
2360
  return "idle";
1707
2361
  } catch {
1708
- return "idle";
2362
+ return "unknown";
1709
2363
  }
1710
2364
  },
1711
2365
  async abortSession(sessionID) {
@@ -1726,8 +2380,10 @@ function createRealHost(client, directory) {
1726
2380
  role: m.info?.role || "assistant",
1727
2381
  content: m.parts?.filter((p) => p.type === "text").map((p) => p.text).join(`
1728
2382
  `) || "",
1729
- timestamp: m.info?.time?.completed ? new Date(m.info.time.completed).toISOString() : undefined,
1730
- messageID: m.id
2383
+ timestamp: m.info?.time?.completed || m.info?.time?.created ? new Date(m.info.time.completed || m.info.time.created).toISOString() : undefined,
2384
+ messageID: m.info?.id || m.id,
2385
+ parentMessageID: m.info?.parentID,
2386
+ completedAt: m.info?.time?.completed ? new Date(m.info.time.completed).toISOString() : undefined
1731
2387
  }));
1732
2388
  } catch {
1733
2389
  return [];
@@ -1775,27 +2431,39 @@ async function withTimeout(promise, timeoutMs, operation) {
1775
2431
  }
1776
2432
 
1777
2433
  // src/server/goal-tools.ts
1778
- init_state_repository();
1779
- import { randomUUID as randomUUID5 } from "crypto";
2434
+ import { randomUUID as randomUUID4 } from "crypto";
1780
2435
  import { tool } from "@opencode-ai/plugin/tool";
2436
+ // src/domain/verification.ts
2437
+ var MAX_RECENT_ATTEMPTS = 10;
2438
+ function appendVerificationAttempt(recent, attempt) {
2439
+ const next = [...recent, attempt];
2440
+ if (next.length > MAX_RECENT_ATTEMPTS) {
2441
+ return next.slice(next.length - MAX_RECENT_ATTEMPTS);
2442
+ }
2443
+ return next;
2444
+ }
2445
+
2446
+ // src/server/goal-tools.ts
1781
2447
  import { exec as execChild } from "child_process";
1782
2448
  import { promisify } from "util";
1783
2449
  var execAsync = promisify(execChild);
1784
- function goalTools(dir, goalService, hostSessionID) {
2450
+ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
1785
2451
  return {
1786
2452
  loopd_create_goal: tool({
1787
- description: "Create a new background loop goal. The engine spawns a dedicated worker session " + "that does the work autonomously \u2014 it never runs in this chat. " + "Call this after clarifying the goal name, objective, and any config with the user. " + "The goal immediately starts in the background; the user can monitor it via /loop.",
2453
+ 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. " + "Specify 'agent' or configure plugin defaultAgent.",
1788
2454
  args: {
1789
2455
  name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
1790
2456
  objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
2457
+ agent: tool.schema.string().optional().describe("Agent to run the worker as. Required unless the plugin has defaultAgent configured."),
1791
2458
  checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
2459
+ checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
2460
+ 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."),
1792
2461
  progressFile: tool.schema.string().optional().describe("Markdown file the worker reads/writes as its transaction state."),
1793
2462
  maxTurns: tool.schema.number().optional().describe("Max turns before auto-block."),
1794
2463
  maxNoProgress: tool.schema.number().optional().describe("Block after N turns without progress."),
1795
2464
  maxFailures: tool.schema.number().optional().describe("Block after N consecutive failures."),
1796
2465
  compactEvery: tool.schema.number().optional().describe("Compact the worker session every N turns."),
1797
- timeoutMs: tool.schema.number().optional().describe("Per-turn timeout in ms."),
1798
- agent: tool.schema.string().optional().describe('Agent to run the worker as (e.g. "dumb-agent", "build"). Defaults to primary agent.')
2466
+ timeoutMs: tool.schema.number().optional().describe("Per-turn timeout in ms.")
1799
2467
  },
1800
2468
  execute: async (args, context) => {
1801
2469
  const sessionID = context?.sessionID || hostSessionID;
@@ -1811,8 +2479,14 @@ function goalTools(dir, goalService, hostSessionID) {
1811
2479
  const config = {
1812
2480
  maxTurns: 50
1813
2481
  };
2482
+ if (args.agent)
2483
+ config.agent = args.agent;
1814
2484
  if (args.checks)
1815
2485
  config.checks = args.checks;
2486
+ if (args.checkCwd)
2487
+ config.checkCwd = args.checkCwd;
2488
+ if (args.workspaceWrite !== undefined)
2489
+ config.workspaceWrite = args.workspaceWrite;
1816
2490
  if (args.progressFile)
1817
2491
  config.progressFile = args.progressFile;
1818
2492
  if (args.maxTurns !== undefined)
@@ -1825,14 +2499,28 @@ function goalTools(dir, goalService, hostSessionID) {
1825
2499
  config.compactEvery = args.compactEvery;
1826
2500
  if (args.timeoutMs !== undefined)
1827
2501
  config.timeoutMs = args.timeoutMs;
1828
- if (args.agent !== undefined)
1829
- config.agent = args.agent;
2502
+ const resolution = resolveGoalCreationConfig({
2503
+ directory: dir,
2504
+ objective: args.objective,
2505
+ config,
2506
+ defaults
2507
+ });
2508
+ if (!resolution.ok) {
2509
+ return {
2510
+ title: "Goal not created",
2511
+ output: JSON.stringify({
2512
+ ok: false,
2513
+ message: resolution.message,
2514
+ errorCode: resolution.errorCode
2515
+ })
2516
+ };
2517
+ }
1830
2518
  try {
1831
2519
  const { goal, worker } = await goalService.start(dir, {
1832
2520
  name: args.name,
1833
2521
  objective: args.objective,
1834
2522
  ownerSessionID: sessionID,
1835
- config
2523
+ config: resolution.config
1836
2524
  });
1837
2525
  return {
1838
2526
  title: "Goal created",
@@ -1841,8 +2529,12 @@ function goalTools(dir, goalService, hostSessionID) {
1841
2529
  goalID: goal.id,
1842
2530
  workerSessionID: worker.workerSessionID,
1843
2531
  artifactDir: goal.config.artifactDir,
2532
+ agent: resolution.config.agent,
2533
+ checks: resolution.config.checks || [],
2534
+ workspaceWrite: resolution.config.workspaceWrite,
2535
+ defaultsApplied: resolution.defaultsApplied,
1844
2536
  name: args.name,
1845
- message: `Goal "${args.name}" created and started in the background. Artifacts: ${goal.config.artifactDir}. Monitor with /loop (<leader>d).`
2537
+ message: `Goal "${args.name}" created and started in the background. Artifacts: ${goal.config.artifactDir}. Monitor with /loop (<leader>o).`
1846
2538
  })
1847
2539
  };
1848
2540
  } catch (error) {
@@ -1859,7 +2551,7 @@ function goalTools(dir, goalService, hostSessionID) {
1859
2551
  }
1860
2552
  }),
1861
2553
  get_goal: tool({
1862
- description: "Get the current goal state. Call at the start of every continuation turn " + "to retrieve the objective, current state, acceptance criteria, and recent failures.",
2554
+ description: "Get the current goal contract and state. Call at the start of every turn to retrieve the objective, checks, limits, and recent failures (including HOST VERDICT if the last completion was rejected). Returns structured JSON with config and runtime (phase, runGeneration, rejectionCount).",
1863
2555
  args: {},
1864
2556
  execute: async (_args, context) => {
1865
2557
  const state = await readState(dir);
@@ -1882,7 +2574,7 @@ function goalTools(dir, goalService, hostSessionID) {
1882
2574
  }
1883
2575
  }),
1884
2576
  report_goal_progress: tool({
1885
- description: "Report meaningful progress on the current goal without completing it. " + "Call after durable state changes (file writes, verifications).",
2577
+ description: "Report meaningful progress (resets consecutiveFailures/noProgressCount). Call after durable state changes (file writes, verifications) \u2014 not after thinking. The engine uses this to avoid force-finish.",
1886
2578
  args: {
1887
2579
  summary: tool.schema.string().describe("What was accomplished."),
1888
2580
  next: tool.schema.string().describe("The next concrete step."),
@@ -1912,7 +2604,7 @@ function goalTools(dir, goalService, hostSessionID) {
1912
2604
  await writeState(dir, state);
1913
2605
  const event = {
1914
2606
  version: 1,
1915
- eventID: randomUUID5(),
2607
+ eventID: randomUUID4(),
1916
2608
  goalID: goal.id,
1917
2609
  type: "goal.progress",
1918
2610
  summary: args.summary,
@@ -1927,13 +2619,13 @@ function goalTools(dir, goalService, hostSessionID) {
1927
2619
  goalName: goal.name,
1928
2620
  summary: args.summary,
1929
2621
  next: args.next,
1930
- turn: runtime?.turnCount
2622
+ turn: runtime?.runCount
1931
2623
  })
1932
2624
  };
1933
2625
  }
1934
2626
  }),
1935
2627
  complete_goal: tool({
1936
- description: "Mark the current goal as completed. " + "Use only when all acceptance criteria pass with concrete evidence. " + "Runs configured completion checks before accepting.",
2628
+ description: "Propose completion. Host runs checks from checkCwd (writers default to project root) \u2014 if any fail, host rejects (rejectionCount++, freeRetryPending if <3, blocked after 3 with HOST VERDICT). Only call when every requirement is proved with evidence; the host decides, not the model.",
1937
2629
  args: {
1938
2630
  summary: tool.schema.string().describe("What was completed."),
1939
2631
  evidence: tool.schema.string().describe("Concrete evidence of completion.")
@@ -1949,16 +2641,84 @@ function goalTools(dir, goalService, hostSessionID) {
1949
2641
  return { title: "Invalid transition", output: `Cannot complete goal in ${goal.status} state.` };
1950
2642
  }
1951
2643
  if (goal.config.checks?.length) {
1952
- const checkResults = await runCompletionChecks(goal.config.checks);
2644
+ const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
2645
+ const checkResults = await runCompletionChecks(goal.config.checks, cwd);
1953
2646
  if (!checkResults.passed) {
1954
2647
  const runtime2 = state.runtimes.find((r) => r.goalID === goal.id);
1955
2648
  if (runtime2) {
1956
2649
  runtime2.evaluatorRejectionCount = (runtime2.evaluatorRejectionCount || 0) + 1;
1957
- if (runtime2.evaluatorRejectionCount >= 3) {
1958
- runtime2.forceFinishRequested = true;
2650
+ const failureDetails = checkResults.failures.map((f) => {
2651
+ const stdoutSnippet = f.stdout ? `
2652
+ Stdout: ${f.stdout.slice(0, 500)}` : "";
2653
+ const stderrSnippet = f.stderr ? `
2654
+ Stderr: ${f.stderr.slice(0, 500)}` : "";
2655
+ return `Command: ${f.command}
2656
+ Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
2657
+ }).join(`
2658
+
2659
+ `);
2660
+ runtime2.lastRejectionDetails = `Rejection #${runtime2.evaluatorRejectionCount} at ${new Date().toISOString()}
2661
+
2662
+ Working directory: ${cwd}
2663
+
2664
+ ${failureDetails}`;
2665
+ const attemptID = randomUUID4();
2666
+ const verificationAttempt = {
2667
+ id: attemptID,
2668
+ sequence: runtime2.evaluatorRejectionCount,
2669
+ runGeneration: runtime2.runGeneration,
2670
+ claimedSummary: args.summary,
2671
+ claimedEvidence: args.evidence,
2672
+ startedAt: new Date().toISOString(),
2673
+ completedAt: new Date().toISOString(),
2674
+ status: "failed",
2675
+ cwd,
2676
+ checks: checkResults.failures.map((f) => ({
2677
+ command: f.command,
2678
+ exitCode: f.exitCode,
2679
+ stderr: f.stderr,
2680
+ stdout: f.stdout
2681
+ }))
2682
+ };
2683
+ runtime2.lastVerificationAttempt = verificationAttempt;
2684
+ runtime2.recentVerificationAttempts = appendVerificationAttempt(runtime2.recentVerificationAttempts || [], verificationAttempt);
2685
+ const rejectEvent = {
2686
+ version: 1,
2687
+ eventID: randomUUID4(),
2688
+ goalID: goal.id,
2689
+ type: "goal.completion_rejected",
2690
+ attemptID,
2691
+ rejectionCount: runtime2.evaluatorRejectionCount,
2692
+ failedCheckCount: checkResults.failures.length,
2693
+ failureSummary: failureDetails.slice(0, 500),
2694
+ timestamp: new Date().toISOString(),
2695
+ revision: state.revision
2696
+ };
2697
+ await appendEvent(dir, rejectEvent);
2698
+ const maxRejections = goal.config.maxEvaluatorRejections || 3;
2699
+ if (runtime2.evaluatorRejectionCount >= maxRejections) {
2700
+ goal.status = "blocked";
2701
+ goal.updatedAt = new Date().toISOString();
2702
+ goal.blocker = {
2703
+ reason: `Evaluator rejected ${runtime2.evaluatorRejectionCount} time(s). Last failure:
2704
+ ${failureDetails.slice(0, 500)}`,
2705
+ needed: "Fix the failing checks and retry the goal.",
2706
+ at: new Date().toISOString()
2707
+ };
2708
+ runtime2.forceFinishRequested = undefined;
2709
+ await appendEvent(dir, {
2710
+ version: 1,
2711
+ eventID: randomUUID4(),
2712
+ goalID: goal.id,
2713
+ type: "goal.blocked",
2714
+ reason: goal.blocker.reason,
2715
+ needed: goal.blocker.needed,
2716
+ timestamp: new Date().toISOString(),
2717
+ revision: state.revision
2718
+ });
1959
2719
  } else {
1960
2720
  runtime2.forceFinishRequested = false;
1961
- runtime2.turnCount = Math.max(0, runtime2.turnCount - 1);
2721
+ runtime2.freeRetryPending = true;
1962
2722
  }
1963
2723
  runtime2.updatedAt = new Date().toISOString();
1964
2724
  await writeState(dir, state);
@@ -1969,7 +2729,8 @@ function goalTools(dir, goalService, hostSessionID) {
1969
2729
  passed: false,
1970
2730
  failedChecks: checkResults.failures,
1971
2731
  message: "Evaluator rejected completion. Fix the issues above and try again.",
1972
- rejectionCount: runtime2?.evaluatorRejectionCount || 0
2732
+ rejectionCount: runtime2?.evaluatorRejectionCount || 0,
2733
+ status: goal.status
1973
2734
  })
1974
2735
  };
1975
2736
  }
@@ -1985,11 +2746,31 @@ function goalTools(dir, goalService, hostSessionID) {
1985
2746
  if (runtime) {
1986
2747
  runtime.phase = "idle";
1987
2748
  runtime.lastError = undefined;
2749
+ const attemptID = randomUUID4();
2750
+ const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
2751
+ const checks = (goal.config.checks || []).map((cmd) => ({
2752
+ command: cmd,
2753
+ exitCode: 0
2754
+ }));
2755
+ const verificationAttempt = {
2756
+ id: attemptID,
2757
+ sequence: (runtime.evaluatorRejectionCount || 0) + 1,
2758
+ runGeneration: runtime.runGeneration,
2759
+ claimedSummary: args.summary,
2760
+ claimedEvidence: args.evidence,
2761
+ startedAt: new Date().toISOString(),
2762
+ completedAt: new Date().toISOString(),
2763
+ status: "passed",
2764
+ cwd,
2765
+ checks
2766
+ };
2767
+ runtime.lastVerificationAttempt = verificationAttempt;
2768
+ runtime.recentVerificationAttempts = appendVerificationAttempt(runtime.recentVerificationAttempts || [], verificationAttempt);
1988
2769
  }
1989
2770
  await writeState(dir, state);
1990
2771
  const event = {
1991
2772
  version: 1,
1992
- eventID: randomUUID5(),
2773
+ eventID: randomUUID4(),
1993
2774
  goalID: goal.id,
1994
2775
  type: "goal.completed",
1995
2776
  summary: args.summary,
@@ -2011,7 +2792,7 @@ function goalTools(dir, goalService, hostSessionID) {
2011
2792
  }
2012
2793
  }),
2013
2794
  block_goal: tool({
2014
- description: "Mark the current goal as blocked. " + "Use only for a real external blocker requiring user intervention.",
2795
+ description: "Mark blocked for a real external blocker (missing creds, contradictory objective vs checks). Use only when you cannot proceed \u2014 the engine will not auto-continue blocked goals until resume/retry.",
2015
2796
  args: {
2016
2797
  reason: tool.schema.string().describe("Why the goal is blocked."),
2017
2798
  needed: tool.schema.string().describe("What is needed to unblock.")
@@ -2041,7 +2822,7 @@ function goalTools(dir, goalService, hostSessionID) {
2041
2822
  await writeState(dir, state);
2042
2823
  const event = {
2043
2824
  version: 1,
2044
- eventID: randomUUID5(),
2825
+ eventID: randomUUID4(),
2045
2826
  goalID: goal.id,
2046
2827
  type: "goal.blocked",
2047
2828
  reason: args.reason,
@@ -2082,6 +2863,9 @@ function formatGoalStructured(goal, runtime) {
2082
2863
  progressFile: goal.config.progressFile,
2083
2864
  includeFiles: goal.config.includeFiles,
2084
2865
  checks: goal.config.checks,
2866
+ checkCwd: goal.config.checkCwd,
2867
+ workspaceWrite: goal.config.workspaceWrite,
2868
+ agent: goal.config.agent,
2085
2869
  maxTurns: goal.config.maxTurns,
2086
2870
  maxNoProgress: goal.config.maxNoProgress,
2087
2871
  maxFailures: goal.config.maxFailures,
@@ -2097,28 +2881,43 @@ function formatGoalStructured(goal, runtime) {
2097
2881
  if (runtime) {
2098
2882
  output.runtime = {
2099
2883
  phase: runtime.phase,
2100
- turnCount: runtime.turnCount,
2101
2884
  runCount: runtime.runCount,
2885
+ budgetTurnCount: runtime.budgetTurnCount,
2886
+ runGeneration: runtime.runGeneration,
2887
+ evaluatorRejectionCount: runtime.evaluatorRejectionCount,
2888
+ freeRetryPending: runtime.freeRetryPending,
2889
+ lastRejectionDetails: runtime.lastRejectionDetails,
2102
2890
  consecutiveFailures: runtime.consecutiveFailures,
2103
2891
  noProgressCount: runtime.noProgressCount,
2104
2892
  lastError: runtime.lastError,
2105
2893
  lastProgressAt: runtime.lastProgressAt,
2106
2894
  lastRunAt: runtime.lastRunAt,
2107
- lastCompactAt: runtime.lastCompactAt
2895
+ lastCompactAt: runtime.lastCompactAt,
2896
+ lastActivityAt: runtime.lastActivityAt,
2897
+ activePromptMessageID: runtime.activePromptMessageID,
2898
+ activeAssistantMessageID: runtime.activeAssistantMessageID,
2899
+ activeAssistantCompletedAt: runtime.activeAssistantCompletedAt,
2900
+ idleCandidateGeneration: runtime.idleCandidateGeneration,
2901
+ unknownStatusCount: runtime.unknownStatusCount,
2902
+ lastUnknownStatusAt: runtime.lastUnknownStatusAt,
2903
+ workerUnreachableNotifiedAt: runtime.workerUnreachableNotifiedAt,
2904
+ lastVerificationAttempt: runtime.lastVerificationAttempt,
2905
+ recentVerificationAttempts: runtime.recentVerificationAttempts
2108
2906
  };
2109
2907
  }
2110
2908
  return JSON.stringify(output, null, 2);
2111
2909
  }
2112
- async function runCompletionChecks(checks) {
2910
+ async function runCompletionChecks(checks, cwd) {
2113
2911
  const failures = [];
2114
2912
  for (const cmd of checks) {
2115
2913
  try {
2116
- await execAsync(cmd, { timeout: 30000 });
2914
+ const { stdout, stderr } = await execAsync(cmd, { timeout: 30000, cwd });
2117
2915
  } catch (error) {
2118
2916
  failures.push({
2119
2917
  command: cmd,
2120
- exitCode: error.code || 1,
2121
- stderr: error.stderr || error.message || "unknown error"
2918
+ exitCode: error.code ?? 1,
2919
+ stderr: String(error.stderr || error.message || "unknown error").slice(0, 1000),
2920
+ stdout: String(error.stdout || "").slice(0, 1000)
2122
2921
  });
2123
2922
  }
2124
2923
  }
@@ -2129,13 +2928,12 @@ async function runCompletionChecks(checks) {
2129
2928
  }
2130
2929
 
2131
2930
  // src/server/owner-tools.ts
2132
- init_state_repository();
2133
2931
  import { tool as tool2 } from "@opencode-ai/plugin/tool";
2134
2932
  function ownerTools(options) {
2135
2933
  const { directory, host, goalService } = options;
2136
2934
  return {
2137
2935
  list_background_goals: tool2({
2138
- description: "List all background loop goals visible to this session. " + "Shows name, status, progress, and whether any goal is waiting for user input.",
2936
+ description: "List all active background goals owned by this session. Shows contract (name, status, phase, turn, last progress, blocker) \u2014 only goals with your ownerSessionID appear.",
2139
2937
  args: {},
2140
2938
  execute: async (_args, context) => {
2141
2939
  const state = await readState(directory);
@@ -2164,7 +2962,7 @@ function ownerTools(options) {
2164
2962
  name: g.name,
2165
2963
  status: g.status,
2166
2964
  phase: runtime?.phase ?? "unknown",
2167
- turn: runtime?.turnCount ?? 0,
2965
+ turn: runtime?.runCount ?? 0,
2168
2966
  lastProgress: g.lastProgress?.summary?.slice(0, 120),
2169
2967
  lastProgressAt: g.lastProgress?.at,
2170
2968
  blocker: g.blocker?.reason?.slice(0, 120)
@@ -2177,7 +2975,7 @@ function ownerTools(options) {
2177
2975
  }
2178
2976
  }),
2179
2977
  inspect_background_goal: tool2({
2180
- description: "Inspect a background goal in detail: objective, contract, progress, " + "blockers, questions, runtime state, and recent events.",
2978
+ description: "Inspect a goal\u2019s full contract and runtime: objective, config{agent,checks,checkCwd,workspaceWrite,limits}, progress, blocker, and runtime{phase,runCount,budgetTurnCount,runGeneration,evaluatorRejectionCount,unknownStatusCount,lastActivityAt,activePromptMessageID}. The source for recovery decisions.",
2181
2979
  args: {
2182
2980
  goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to inspect the first active goal.")
2183
2981
  },
@@ -2207,7 +3005,10 @@ function ownerTools(options) {
2207
3005
  maxFailures: goal.config.maxFailures,
2208
3006
  timeoutMs: goal.config.timeoutMs,
2209
3007
  progressFile: goal.config.progressFile,
2210
- checks: goal.config.checks
3008
+ checks: goal.config.checks,
3009
+ checkCwd: goal.config.checkCwd,
3010
+ workspaceWrite: goal.config.workspaceWrite,
3011
+ agent: goal.config.agent
2211
3012
  },
2212
3013
  lastProgress: goal.lastProgress,
2213
3014
  completionEvidence: goal.completionEvidence,
@@ -2216,8 +3017,11 @@ function ownerTools(options) {
2216
3017
  timeUsedSeconds: goal.timeUsedSeconds,
2217
3018
  runtime: runtime ? {
2218
3019
  phase: runtime.phase,
2219
- turnCount: runtime.turnCount,
2220
3020
  runCount: runtime.runCount,
3021
+ budgetTurnCount: runtime.budgetTurnCount,
3022
+ runGeneration: runtime.runGeneration,
3023
+ evaluatorRejectionCount: runtime.evaluatorRejectionCount,
3024
+ freeRetryPending: runtime.freeRetryPending,
2221
3025
  consecutiveFailures: runtime.consecutiveFailures,
2222
3026
  lastError: runtime.lastError,
2223
3027
  lastProgressAt: runtime.lastProgressAt,
@@ -2228,7 +3032,7 @@ function ownerTools(options) {
2228
3032
  }
2229
3033
  }),
2230
3034
  read_goal_transcript: tool2({
2231
- description: "Read the last N messages from a goal's worker session transcript. " + "Shows what the worker has been doing: tool calls, file changes, responses.",
3035
+ description: "Read the last N messages from the worker transcript (role, content, messageID). Shows HOST VERDICT, steering, and whether the worker\u2019s last prompt was correlated. Use to debug why a completion was rejected or why a worker is stuck.",
2232
3036
  args: {
2233
3037
  goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to read the first active goal."),
2234
3038
  limit: tool2.schema.number().optional().describe("Max messages to return (default: 20).")
@@ -2277,7 +3081,7 @@ function ownerTools(options) {
2277
3081
  }
2278
3082
  }),
2279
3083
  send_goal_input: tool2({
2280
- description: "Send a message, instruction, or answer to a background goal's worker session. " + "The message will be injected into the worker's next continuation prompt. " + "Use this to answer worker questions, redirect work, or refine scope.",
3084
+ description: "Send an inbox message to the worker (injected as ## USER INSTRUCTIONS on next turn). Use to answer `question`, redirect, or refine scope. Does NOT itself re-prompt \u2014 the engine re-prompts on next idle/maintenance; use nudge_goal if the worker is stuck with no activity.",
2281
3085
  args: {
2282
3086
  goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to target the first active goal."),
2283
3087
  message: tool2.schema.string().describe("Message to send to the worker.")
@@ -2305,7 +3109,7 @@ function ownerTools(options) {
2305
3109
  }
2306
3110
  }),
2307
3111
  pause_goal: tool2({
2308
- description: "Pause a background goal. The worker session is aborted and the goal stops running. " + "Use when you need to temporarily stop work (e.g., to investigate an issue or change priorities).",
3112
+ description: "Pause an active goal: status active \u2192 paused, releaseLease, abortWorker, per-goal mutex. Frees the workspaceWrite slot. Use to investigate or to free the single-writer slot.",
2309
3113
  args: {
2310
3114
  goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to pause the first active goal.")
2311
3115
  },
@@ -2349,7 +3153,7 @@ function ownerTools(options) {
2349
3153
  }
2350
3154
  }),
2351
3155
  resume_goal: tool2({
2352
- description: "Resume a paused or blocked background goal. " + "For paused goals, creates a new worker session if needed. " + "For blocked goals, resets failure count and retries.",
3156
+ description: "Resume a paused (\u2192active, reuses existing worker if sessionStatus still idle/busy) or retry a blocked (\u2192active, resets consecutiveFailures/forceFinish). Fails with 'already active' if another workspaceWrite writer is active. Per-goal mutex.",
2353
3157
  args: {
2354
3158
  goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to resume the first paused/blocked goal.")
2355
3159
  },
@@ -2399,8 +3203,41 @@ function ownerTools(options) {
2399
3203
  }
2400
3204
  }
2401
3205
  }),
3206
+ nudge_goal: tool2({
3207
+ description: "Force re-prompt a stuck active worker (the hardened recovery). Clears stale activeRunID/idleCandidate/generation/lease (phase\u2192idle) and calls continueTurn({force:true}) even if sessionStatus is not idle. Use when unknownStatusCount\u22653, lastActivityAt is stale, or maintenance notified 'worker is unreachable'. Per-goal mutex.",
3208
+ args: {
3209
+ goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to nudge the first active goal.")
3210
+ },
3211
+ execute: async (args, context) => {
3212
+ const state = await readState(directory);
3213
+ const ownerID = context?.sessionID;
3214
+ const goal = args.goal_id ? state.goals.find((g) => g.id === args.goal_id && g.ownerSessionID === ownerID) : state.goals.find((g) => g.ownerSessionID === ownerID && g.status !== "complete");
3215
+ if (!goal) {
3216
+ return {
3217
+ title: "No goal found",
3218
+ output: JSON.stringify({ ok: false, message: "No matching active goal for this session." })
3219
+ };
3220
+ }
3221
+ try {
3222
+ const result = await goalService.nudge(directory, goal.id);
3223
+ return {
3224
+ title: result.ok ? "Goal nudged" : "Nudge failed",
3225
+ output: JSON.stringify({ ...result, goalID: goal.id, goalName: goal.name })
3226
+ };
3227
+ } catch (error) {
3228
+ return {
3229
+ title: "Nudge failed",
3230
+ output: JSON.stringify({
3231
+ ok: false,
3232
+ goalName: goal.name,
3233
+ message: error instanceof Error ? error.message : String(error)
3234
+ })
3235
+ };
3236
+ }
3237
+ }
3238
+ }),
2402
3239
  clear_goal: tool2({
2403
- description: "Clear a background goal. Aborts the worker and removes the goal from the dashboard. " + "This action cannot be undone. Use when the goal is no longer needed.",
3240
+ description: "Clear a goal: aborts worker and removes goal+runtime+ledger (cannot be undone). Use when the goal is no longer needed or to free a stuck writer slot after inspection.",
2404
3241
  args: {
2405
3242
  goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to clear the first active goal.")
2406
3243
  },
@@ -2439,16 +3276,17 @@ function ownerTools(options) {
2439
3276
  })
2440
3277
  };
2441
3278
  }
2442
-
2443
3279
  // src/server/plugin.ts
2444
3280
  var PLUGIN_ID = "opencode-loopd.server";
2445
- var server = async ({ client, directory }) => {
3281
+ var server = async ({ client, directory }, pluginOptions) => {
3282
+ const defaults = parsePluginDefaults(pluginOptions);
2446
3283
  const host = createRealHost(client, directory);
2447
3284
  const goalService = createGoalService(host);
2448
3285
  const worker = createControlWorker({
2449
3286
  directory,
2450
3287
  goalService,
2451
- pollIntervalMs: 1000
3288
+ pollIntervalMs: 1000,
3289
+ defaults
2452
3290
  });
2453
3291
  const engine = createLoopEngine({
2454
3292
  directory,
@@ -2482,12 +3320,50 @@ var server = async ({ client, directory }) => {
2482
3320
  if (type?.startsWith("session."))
2483
3321
  reconcileInBackground();
2484
3322
  },
2485
- tool: { ...goalTools(directory, goalService), ...ownerTools({ directory, host, goalService }) },
3323
+ tool: { ...goalTools(directory, goalService, undefined, defaults), ...ownerTools({ directory, host, goalService }) },
3324
+ "tool.execute.before": async (input, _output) => {
3325
+ const activeWorkers = goalService.getActiveWorkers();
3326
+ let matchedGoalID;
3327
+ for (const [goalID, worker2] of activeWorkers) {
3328
+ if (worker2.workerSessionID === input.sessionID) {
3329
+ matchedGoalID = goalID;
3330
+ break;
3331
+ }
3332
+ }
3333
+ if (!matchedGoalID)
3334
+ return;
3335
+ try {
3336
+ await mutateState(directory, `tool-call.start:${matchedGoalID}:${input.callID}`, async (s) => {
3337
+ const runtime = s.runtimes.find((r) => r.goalID === matchedGoalID);
3338
+ if (runtime)
3339
+ Object.assign(runtime, addToolCall(runtime, input.callID));
3340
+ return s;
3341
+ });
3342
+ } catch {}
3343
+ },
2486
3344
  "tool.execute.after": async (input, output) => {
2487
3345
  if (input.tool === "loopd_create_goal" || input.tool === "get_goal" || input.tool === "report_goal_progress") {
2488
3346
  ensureStarted();
2489
3347
  reconcileInBackground();
2490
3348
  }
3349
+ const activeWorkers = goalService.getActiveWorkers();
3350
+ let matchedGoalID;
3351
+ for (const [goalID, worker2] of activeWorkers) {
3352
+ if (worker2.workerSessionID === input.sessionID) {
3353
+ matchedGoalID = goalID;
3354
+ break;
3355
+ }
3356
+ }
3357
+ if (matchedGoalID) {
3358
+ try {
3359
+ await mutateState(directory, `tool-call.end:${matchedGoalID}:${input.callID}`, async (s) => {
3360
+ const runtime = s.runtimes.find((r) => r.goalID === matchedGoalID);
3361
+ if (runtime)
3362
+ Object.assign(runtime, removeToolCall(runtime, input.callID));
3363
+ return s;
3364
+ });
3365
+ } catch {}
3366
+ }
2491
3367
  if (input.tool === "complete_goal" || input.tool === "block_goal") {
2492
3368
  try {
2493
3369
  const raw = output?.output;
@@ -2499,9 +3375,8 @@ var server = async ({ client, directory }) => {
2499
3375
  const goalID = parsed.goalID;
2500
3376
  if (!goalID)
2501
3377
  return;
2502
- const { readState: readState2, writeState: writeState2 } = await Promise.resolve().then(() => (init_state_repository(), exports_state_repository));
2503
3378
  const { shouldNotifyParent: shouldNotifyParent2, markParentNotified: markParentNotified2 } = await Promise.resolve().then(() => exports_runtime);
2504
- const state = await readState2(directory);
3379
+ const state = await readState(directory);
2505
3380
  const goal = state.goals.find((g) => g.id === goalID);
2506
3381
  if (!goal)
2507
3382
  return;
@@ -2510,8 +3385,12 @@ var server = async ({ client, directory }) => {
2510
3385
  if (runtime && !shouldNotifyParent2(runtime, notifyType))
2511
3386
  return;
2512
3387
  if (runtime) {
2513
- markParentNotified2(runtime, notifyType);
2514
- await writeState2(directory, state);
3388
+ await mutateState(directory, `notify-parent:${goalID}`, async (s) => {
3389
+ const rt = s.runtimes.find((r) => r.goalID === goalID);
3390
+ if (rt)
3391
+ markParentNotified2(rt, notifyType);
3392
+ return s;
3393
+ });
2515
3394
  }
2516
3395
  const message = parsed.status === "complete" ? `Loop goal "${goal.name}" completed: ${parsed.summary || ""}. Evidence: ${parsed.evidence || ""}. Artifacts: ${goal.config.artifactDir || "n/a"}.` : `Loop goal "${goal.name}" blocked: ${parsed.reason || ""}. Needed: ${parsed.needed || ""}.`;
2517
3396
  await host.notifyOwner(goal.ownerSessionID, message);
@@ -2524,6 +3403,14 @@ var server = async ({ client, directory }) => {
2524
3403
  }
2525
3404
  };
2526
3405
  };
3406
+ function parsePluginDefaults(options) {
3407
+ const agent = typeof options?.defaultAgent === "string" ? options.defaultAgent.trim() : "";
3408
+ const checks = Array.isArray(options?.defaultChecks) ? options.defaultChecks.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [];
3409
+ return {
3410
+ defaultAgent: agent || undefined,
3411
+ defaultChecks: checks.length > 0 ? checks : undefined
3412
+ };
3413
+ }
2527
3414
  var plugin_default = {
2528
3415
  id: PLUGIN_ID,
2529
3416
  server