@bojackduy/opencode-loopd 1.13.0 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -47,6 +47,9 @@ function acquireLease(rt, timeoutMs) {
47
47
  idleConfirmFailedGeneration: undefined,
48
48
  idleStuckNotifiedGeneration: undefined,
49
49
  workerAbortedAt: undefined,
50
+ activePromptDispatchAt: undefined,
51
+ activePromptAcceptedAt: undefined,
52
+ activeInboxLines: undefined,
50
53
  activePromptObservedAt: undefined,
51
54
  activeAssistantMessageID: undefined,
52
55
  activeAssistantCompletedAt: undefined,
@@ -180,7 +183,7 @@ var init_event_tail = () => {};
180
183
  import { promises as fs2 } from "fs";
181
184
  import path from "path";
182
185
  import os from "os";
183
- import { createHash } from "crypto";
186
+ import { createHash, randomUUID } from "crypto";
184
187
  function emptyState() {
185
188
  return { version: CURRENT_VERSION, revision: 0, goals: [], runtimes: [], commandLedger: [], commands: [] };
186
189
  }
@@ -212,25 +215,32 @@ async function acquireLock(directory, key, operation) {
212
215
  const dir = lockDir(directory);
213
216
  await fs2.mkdir(dir, { recursive: true });
214
217
  const lockPath = lockFile(directory, key);
218
+ let lastOwner;
215
219
  const deadline = performance.now() + LOCK_TIMEOUT_MS;
216
220
  for (let attempt = 0;performance.now() < deadline; attempt++) {
217
221
  try {
218
222
  try {
219
223
  const raw = await fs2.readFile(lockPath, "utf8");
220
224
  const meta = JSON.parse(raw);
225
+ lastOwner = meta;
221
226
  const age = Date.now() - Date.parse(meta.acquiredAt);
222
227
  if (age > LOCK_STALE_MS && !processIsAlive(meta.pid)) {
223
228
  await fs2.rm(lockPath, { force: true });
224
229
  }
225
230
  } catch {}
226
- const meta = { pid: process.pid, operation, acquiredAt: new Date().toISOString() };
227
- const fd = await fs2.open(lockPath, "wx");
231
+ const meta = { pid: process.pid, operation, acquiredAt: new Date().toISOString(), token: randomUUID() };
232
+ const fd = await fs2.open(lockPath, "wx+");
228
233
  try {
229
234
  await fd.writeFile(JSON.stringify(meta), "utf8");
230
- } finally {
231
- await fd.close();
235
+ return { path: lockPath, handle: fd, token: meta.token };
236
+ } catch (error) {
237
+ try {
238
+ await fs2.rm(lockPath, { force: true });
239
+ } finally {
240
+ await fd.close();
241
+ }
242
+ throw error;
232
243
  }
233
- return;
234
244
  } catch (error) {
235
245
  if (error?.code === "EEXIST") {} else if (error?.code === "ENOENT") {
236
246
  await fs2.mkdir(dir, { recursive: true });
@@ -241,26 +251,28 @@ async function acquireLock(directory, key, operation) {
241
251
  }
242
252
  await delay(Math.min(25 * (attempt + 1), 250, Math.max(0, deadline - performance.now())));
243
253
  }
244
- throw new Error(`failed to acquire lock "${key}" for "${operation}" after ${LOCK_TIMEOUT_MS}ms`);
254
+ const owner = lastOwner ? ` Last observed owner: PID ${lastOwner.pid}, operation "${lastOwner.operation}", acquired ${lastOwner.acquiredAt}.` : " Owner metadata unavailable.";
255
+ throw new Error(`failed to acquire lock "${key}" for "${operation}" after ${LOCK_TIMEOUT_MS}ms. This is the loopd plugin's lock, not OpenCode core.${owner}`);
245
256
  }
246
- async function releaseLock(directory, key) {
247
- const lockPath = lockFile(directory, key);
257
+ async function releaseLock(lease) {
248
258
  try {
249
- const raw = await fs2.readFile(lockPath, "utf8");
250
- const meta = JSON.parse(raw);
251
- const shouldRelease = meta.pid === process.pid;
252
- if (!shouldRelease)
259
+ const held = await lease.handle.stat();
260
+ const buffer = Buffer.alloc(held.size);
261
+ const { bytesRead } = await lease.handle.read(buffer, 0, buffer.length, 0);
262
+ const meta = JSON.parse(buffer.subarray(0, bytesRead).toString("utf8"));
263
+ if (meta.pid !== process.pid || meta.token !== lease.token)
253
264
  return;
254
- try {
255
- const raw2 = await fs2.readFile(lockPath, "utf8");
256
- const meta2 = JSON.parse(raw2);
257
- if (meta2.acquiredAt !== meta.acquiredAt || meta2.pid !== meta.pid)
265
+ const current = await fs2.stat(lease.path).catch((error) => {
266
+ if (error?.code === "ENOENT")
258
267
  return;
259
- } catch {
268
+ throw error;
269
+ });
270
+ if (!current || current.dev !== held.dev || current.ino !== held.ino)
260
271
  return;
261
- }
262
- await fs2.rm(lockPath, { force: true });
263
- } catch {}
272
+ await fs2.rm(lease.path, { force: true });
273
+ } finally {
274
+ await lease.handle.close();
275
+ }
264
276
  }
265
277
  async function readState(directory) {
266
278
  const target = stateFile(directory);
@@ -278,11 +290,11 @@ async function readState(directory) {
278
290
  return emptyState();
279
291
  const transient = error instanceof SyntaxError || error?.code === "EPERM" || error?.code === "EACCES" || error?.code === "EBUSY";
280
292
  if (!transient || attempt === attempts - 1)
281
- break;
293
+ throw error;
282
294
  await delay(25 * (attempt + 1));
283
295
  }
284
296
  }
285
- return emptyState();
297
+ throw new Error("State could not be read.");
286
298
  }
287
299
  function migrate(state) {
288
300
  if (state.version === CURRENT_VERSION)
@@ -428,14 +440,14 @@ async function writeState(directory, state) {
428
440
  await writeAtomic(stateFile(directory), payload);
429
441
  }
430
442
  async function mutateState(directory, description, fn) {
431
- await acquireLock(directory, "state", description);
443
+ const lease = await acquireLock(directory, "state", description);
432
444
  try {
433
445
  const state = await readState(directory);
434
446
  const next = await fn(state);
435
447
  await writeState(directory, next);
436
448
  return next;
437
449
  } finally {
438
- await releaseLock(directory, "state");
450
+ await releaseLock(lease);
439
451
  }
440
452
  }
441
453
  async function appendEvent(directory, event) {
@@ -516,37 +528,80 @@ function inboxFile(directory, goalID) {
516
528
  return path.join(loopDir(directory), "inboxes", `${goalID}.jsonl`);
517
529
  }
518
530
  async function appendGoalInbox(directory, goalID, from, text) {
519
- const dir = path.join(loopDir(directory), "inboxes");
520
- await fs2.mkdir(dir, { recursive: true });
521
- const msg = { from, text, at: new Date().toISOString() };
522
- await fs2.appendFile(inboxFile(directory, goalID), JSON.stringify(msg) + `
531
+ let accepted = false;
532
+ await mutateState(directory, `inbox.enqueue:${goalID}`, async (state) => {
533
+ if (!state.goals.some((goal) => goal.id === goalID))
534
+ return state;
535
+ const lease = await acquireLock(directory, `inbox-${goalID}`, "inbox.append");
536
+ try {
537
+ const dir = path.join(loopDir(directory), "inboxes");
538
+ await fs2.mkdir(dir, { recursive: true });
539
+ const msg = { id: randomUUID(), from, text, at: new Date().toISOString() };
540
+ await fs2.appendFile(inboxFile(directory, goalID), JSON.stringify(msg) + `
523
541
  `, "utf8");
542
+ accepted = true;
543
+ } finally {
544
+ await releaseLock(lease);
545
+ }
546
+ return state;
547
+ });
548
+ return accepted;
524
549
  }
525
- async function drainGoalInbox(directory, goalID) {
526
- const file = inboxFile(directory, goalID);
550
+ async function deleteGoalInbox(directory, goalID) {
551
+ const lease = await acquireLock(directory, `inbox-${goalID}`, "inbox.delete");
527
552
  try {
528
- const raw = await fs2.readFile(file, "utf8");
553
+ await fs2.rm(inboxFile(directory, goalID), { force: true });
554
+ } finally {
555
+ await releaseLock(lease);
556
+ }
557
+ }
558
+ async function acknowledgeGoalInbox(directory, goalID, delivered) {
559
+ if (!delivered.length)
560
+ return;
561
+ const lease = await acquireLock(directory, `inbox-${goalID}`, "inbox.acknowledge");
562
+ try {
563
+ let raw;
564
+ try {
565
+ raw = await fs2.readFile(inboxFile(directory, goalID), "utf8");
566
+ } catch (error) {
567
+ if (error?.code === "ENOENT")
568
+ return;
569
+ throw error;
570
+ }
529
571
  const lines = raw.trim().split(`
530
572
  `).filter(Boolean);
531
- if (lines.length === 0)
532
- return [];
533
- const messages = lines.map((l) => JSON.parse(l));
534
- await fs2.rm(file, { force: true });
535
- return messages.map((m) => `[${m.from}] ${m.text}`);
536
- } catch {
537
- return [];
573
+ if (!delivered.every((line, index) => lines[index] === line))
574
+ return;
575
+ const rest = lines.slice(delivered.length);
576
+ if (rest.length)
577
+ await writeAtomic(inboxFile(directory, goalID), rest.join(`
578
+ `) + `
579
+ `);
580
+ else
581
+ await fs2.rm(inboxFile(directory, goalID), { force: true });
582
+ } finally {
583
+ await releaseLock(lease);
538
584
  }
539
585
  }
540
- async function peekGoalInbox(directory, goalID) {
541
- const file = inboxFile(directory, goalID);
586
+ async function snapshotGoalInbox(directory, goalID) {
587
+ const lease = await acquireLock(directory, `inbox-${goalID}`, "inbox.snapshot");
542
588
  try {
543
- const raw = await fs2.readFile(file, "utf8");
589
+ const raw = await fs2.readFile(inboxFile(directory, goalID), "utf8");
544
590
  const lines = raw.trim().split(`
545
591
  `).filter(Boolean);
546
- if (lines.length === 0)
547
- return [];
548
- const messages = lines.map((l) => JSON.parse(l));
549
- return messages.map((m) => `[${m.from}] ${m.text}`);
592
+ const messages = lines.map((line) => JSON.parse(line));
593
+ return { lines, messages: messages.map((m) => `[${m.from}] ${m.text}`) };
594
+ } catch (error) {
595
+ if (error?.code === "ENOENT")
596
+ return { lines: [], messages: [] };
597
+ throw error;
598
+ } finally {
599
+ await releaseLock(lease);
600
+ }
601
+ }
602
+ async function peekGoalInbox(directory, goalID) {
603
+ try {
604
+ return (await snapshotGoalInbox(directory, goalID)).messages;
550
605
  } catch {
551
606
  return [];
552
607
  }
@@ -621,6 +676,42 @@ var init_state_repository = __esm(() => {
621
676
  init_event_tail();
622
677
  });
623
678
 
679
+ // src/infrastructure/server-log.ts
680
+ import { appendFile } from "fs/promises";
681
+ async function logServerEvent(directory, event, details = {}) {
682
+ try {
683
+ await appendFile(SERVER_LOG_FILE, `${JSON.stringify({
684
+ timestamp: new Date().toISOString(),
685
+ directory,
686
+ event,
687
+ ...details
688
+ }, errorReplacer)}
689
+ `);
690
+ } catch {}
691
+ }
692
+ function describeError(value) {
693
+ if (value instanceof Error)
694
+ return value.message;
695
+ if (typeof value === "string")
696
+ return value;
697
+ if (typeof value === "object" && value !== null && "message" in value) {
698
+ return String(value.message);
699
+ }
700
+ try {
701
+ return JSON.stringify(value, errorReplacer);
702
+ } catch {
703
+ return String(value);
704
+ }
705
+ }
706
+ function errorReplacer(_key, value) {
707
+ if (value instanceof Error) {
708
+ return { name: value.name, message: value.message, stack: value.stack };
709
+ }
710
+ return value;
711
+ }
712
+ var SERVER_LOG_FILE = "/tmp/loopd-server.log";
713
+ var init_server_log = () => {};
714
+
624
715
  // src/domain/command-await.ts
625
716
  function isTerminalCommandStatus(status) {
626
717
  return TERMINAL_COMMAND_STATUSES.includes(status);
@@ -886,11 +977,11 @@ var init_command_watch = __esm(() => {
886
977
  });
887
978
 
888
979
  // src/application/command-await.ts
889
- import { randomUUID } from "crypto";
980
+ import { randomUUID as randomUUID2 } from "crypto";
890
981
  function ledgerEvent(base) {
891
982
  return {
892
983
  version: 1,
893
- eventID: randomUUID(),
984
+ eventID: randomUUID2(),
894
985
  timestamp: new Date().toISOString(),
895
986
  revision: 0,
896
987
  ...base
@@ -1266,20 +1357,8 @@ async function wakeGoalForAwait(directory, continuation, goalID) {
1266
1357
  if (goal.status !== "active") {
1267
1358
  return { woke: false, message: `Goal is ${goal.status}; evidence waits in pendingInbox.` };
1268
1359
  }
1269
- await mutateState(directory, `cmd.await-wake:${goalID}`, async (s) => {
1270
- const rt = s.runtimes.find((r) => r.goalID === goalID);
1271
- if (!rt)
1272
- return s;
1273
- rt.phase = "idle";
1274
- rt.activeRunID = undefined;
1275
- rt.idleCandidateAt = undefined;
1276
- rt.activePromptMessageID = undefined;
1277
- rt.activeToolCallIDs = [];
1278
- rt.updatedAt = new Date().toISOString();
1279
- return s;
1280
- });
1281
- await continuation.continueTurn(directory, goalID, { force: true });
1282
- return { woke: true, message: `Woke goal "${goal.name}" for command exit.` };
1360
+ const result = await continuation.nudge(directory, goalID);
1361
+ return { woke: result.ok && result.outcome === "sent", message: result.message };
1283
1362
  }
1284
1363
  var init_command_await2 = __esm(() => {
1285
1364
  init_command_await();
@@ -1351,8 +1430,11 @@ function scopeConflictMessage(conflict) {
1351
1430
  var init_workspace_scope = () => {};
1352
1431
 
1353
1432
  // src/application/workspace-execution.ts
1354
- import { randomUUID as randomUUID3 } from "crypto";
1433
+ import { randomUUID as randomUUID4 } from "crypto";
1355
1434
  import path3 from "path";
1435
+ function isWorkspaceToolExempt(tool) {
1436
+ return readTools.has(tool);
1437
+ }
1356
1438
  function structuredWritePaths(tool, args) {
1357
1439
  if (tool === "write" || tool === "edit") {
1358
1440
  if (typeof args.filePath !== "string")
@@ -1391,107 +1473,31 @@ function structuredWritePaths(tool, args) {
1391
1473
  throw new Error("Scope denied: patch has no file operations.");
1392
1474
  return paths;
1393
1475
  }
1394
- function callAgeMs(call, now) {
1395
- if (!call.at)
1396
- return Number.POSITIVE_INFINITY;
1397
- const age = now - Date.parse(call.at);
1398
- return Number.isFinite(age) ? age : Number.POSITIVE_INFINITY;
1399
- }
1400
- function runtimeActivityFresh(state, goalID, now) {
1401
- if (!goalID)
1402
- return false;
1403
- const rt = state.runtimes.find((r) => r.goalID === goalID);
1404
- if (!rt?.lastActivityAt)
1405
- return false;
1406
- const age = now - Date.parse(rt.lastActivityAt);
1407
- return Number.isFinite(age) && age < STALE_WORKSPACE_CALL_TTL_MS;
1408
- }
1409
- function isGenerationSuperseded(state, call) {
1410
- if (call.generation === undefined || !call.goalID)
1411
- return false;
1412
- const rt = state.runtimes.find((r) => r.goalID === call.goalID);
1413
- return !!rt && call.generation < rt.runGeneration;
1414
- }
1415
- function isSyncStaleWorkspaceCall(state, call, now = Date.now()) {
1416
- if (isGenerationSuperseded(state, call))
1417
- return true;
1418
- if (call.goalID && !state.goals.some((g) => g.id === call.goalID))
1419
- return true;
1420
- return callAgeMs(call, now) > CLEAR_PENDING_FORCE_TTL_MS;
1476
+ function isSyncStaleWorkspaceCall(_state, _call, _now = Date.now()) {
1477
+ return false;
1421
1478
  }
1422
1479
  function isStaleWorkspaceCall(state, call, liveness = {}) {
1423
- if (isSyncStaleWorkspaceCall(state, call, liveness.now ?? Date.now()))
1424
- return true;
1425
- const now = liveness.now ?? Date.now();
1426
- const age = callAgeMs(call, now);
1427
- if (!call.goalID)
1428
- return age > OWNER_CALL_TTL_MS;
1429
- const goal = state.goals.find((g) => g.id === call.goalID);
1430
- if (!goal)
1431
- return true;
1432
- if (age <= STALE_WORKSPACE_CALL_TTL_MS)
1433
- return false;
1434
- const live = liveness.isWorkerLive?.(goal.id, goal.workerSessionID) ?? false;
1435
- if (live && runtimeActivityFresh(state, goal.id, now))
1436
- return false;
1437
- if (live) {
1438
- return age > CLEAR_PENDING_FORCE_TTL_MS;
1439
- }
1440
- return true;
1480
+ return isSyncStaleWorkspaceCall(state, call, liveness.now ?? Date.now());
1441
1481
  }
1442
1482
  function sweepStaleWorkspaceState(state, liveness = {}) {
1443
1483
  const now = liveness.now ?? Date.now();
1444
1484
  const summary = { reapedCalls: [], resetClosing: [], finalizedClears: [], forceCleared: [] };
1445
- const clearPendingGoals = new Set(state.goals.filter((g) => g.scopeClearPending).map((g) => g.id));
1446
- const before = state.workspaceCalls ?? [];
1447
- const kept = before.filter((call) => {
1448
- if (call.goalID && clearPendingGoals.has(call.goalID) && !isGenerationSuperseded(state, call) && state.goals.some((g) => g.id === call.goalID)) {
1449
- return true;
1450
- }
1451
- if (!isStaleWorkspaceCall(state, call, { ...liveness, now }))
1452
- return true;
1453
- summary.reapedCalls.push({
1454
- callID: call.callID,
1455
- sessionID: call.sessionID,
1456
- goalID: call.goalID,
1457
- reason: isGenerationSuperseded(state, call) ? "generation-superseded" : call.goalID && !state.goals.some((g) => g.id === call.goalID) ? "goal-gone" : "age-ttl-no-live-backing"
1458
- });
1459
- return false;
1460
- });
1461
- state.workspaceCalls = kept;
1462
- for (const goal of state.goals) {
1485
+ const kept = state.workspaceCalls ?? [];
1486
+ for (const goal of [...state.goals]) {
1463
1487
  const referenced = kept.some((call) => call.goalID === goal.id) || state.workspaceOperation?.goalID === goal.id;
1464
1488
  if (goal.scopeClosing && !referenced && !(liveness.isWorkerLive?.(goal.id, goal.workerSessionID) ?? false)) {
1465
1489
  goal.scopeClosing = false;
1466
1490
  goal.updatedAt = new Date(now).toISOString();
1467
1491
  summary.resetClosing.push(goal.id);
1468
1492
  }
1469
- if (goal.scopeClearPending && !referenced) {
1493
+ if (goal.scopeClearPending) {
1494
+ retireWorker(state, goal);
1470
1495
  state.goals = state.goals.filter((g) => g.id !== goal.id);
1471
1496
  state.runtimes = state.runtimes.filter((r) => r.goalID !== goal.id);
1472
1497
  state.commandAwaits = (state.commandAwaits ?? []).filter((a) => a.goalID !== goal.id);
1473
1498
  summary.finalizedClears.push(goal.id);
1474
1499
  }
1475
1500
  }
1476
- for (const goal of [...state.goals]) {
1477
- if (!goal.scopeClearPending)
1478
- continue;
1479
- const refs = (state.workspaceCalls ?? []).filter((call) => call.goalID === goal.id);
1480
- if (!refs.length)
1481
- continue;
1482
- if (refs.every((call) => callAgeMs(call, now) > CLEAR_PENDING_FORCE_TTL_MS)) {
1483
- for (const call of refs) {
1484
- summary.reapedCalls.push({ callID: call.callID, sessionID: call.sessionID, goalID: call.goalID, reason: "clear-pending-force" });
1485
- }
1486
- state.workspaceCalls = (state.workspaceCalls ?? []).filter((call) => call.goalID !== goal.id);
1487
- if (!(state.workspaceCalls ?? []).some((call) => call.goalID === goal.id) && state.workspaceOperation?.goalID !== goal.id) {
1488
- state.goals = state.goals.filter((g) => g.id !== goal.id);
1489
- state.runtimes = state.runtimes.filter((r) => r.goalID !== goal.id);
1490
- state.commandAwaits = (state.commandAwaits ?? []).filter((a) => a.goalID !== goal.id);
1491
- summary.forceCleared.push(goal.id);
1492
- }
1493
- }
1494
- }
1495
1501
  return summary;
1496
1502
  }
1497
1503
  function stampCallGeneration(state, goalID) {
@@ -1557,7 +1563,7 @@ async function beforeWorkspaceTool(directory, input, args) {
1557
1563
  await mutateState(directory, `workspace.before:${input.callID}`, async (state) => {
1558
1564
  const goal = state.goals.find((item) => item.workerSessionID === input.sessionID);
1559
1565
  if (state.retiredWorkerSessions?.includes(input.sessionID))
1560
- throw new Error("Scope denied: retired worker session; late executions are fenced.");
1566
+ throw new Error("Scope denied: retired worker session; goal cleared or worker rotated. Stop work; late executions are fenced.");
1561
1567
  const held = heldGoals(state);
1562
1568
  const protectedGoals = held.filter((item) => item.config.write_scope !== undefined || item.config.workspaceWrite === false);
1563
1569
  if (!goal && ownerTools.has(input.tool) && (!protectedGoals.length || state.goals.some((item) => item.ownerSessionID === input.sessionID)))
@@ -1613,7 +1619,12 @@ async function beforeWorkspaceTool(directory, input, args) {
1613
1619
  return state;
1614
1620
  });
1615
1621
  }
1616
- async function afterWorkspaceTool(directory, sessionID, callID) {
1622
+ async function afterWorkspaceTool(directory, sessionID, callID, tool) {
1623
+ if (tool && isWorkspaceToolExempt(tool))
1624
+ return;
1625
+ const snapshot = await readState(directory);
1626
+ if (!snapshot.workspaceCalls?.some((call) => call.sessionID === sessionID && call.callID === callID))
1627
+ return;
1617
1628
  await mutateState(directory, `workspace.after:${callID}`, async (state) => {
1618
1629
  state.workspaceCalls = state.workspaceCalls?.filter((call) => call.sessionID !== sessionID || call.callID !== callID);
1619
1630
  const now = Date.now();
@@ -1621,6 +1632,7 @@ async function afterWorkspaceTool(directory, sessionID, callID) {
1621
1632
  const live = (state.workspaceCalls ?? []).some((call) => call.goalID === goal.id && !isSyncStaleWorkspaceCall(state, call, now));
1622
1633
  if (!goal.scopeClearPending || live || state.workspaceOperation?.goalID === goal.id)
1623
1634
  return true;
1635
+ retireWorker(state, goal);
1624
1636
  state.workspaceCalls = (state.workspaceCalls ?? []).filter((call) => !(call.goalID === goal.id && isSyncStaleWorkspaceCall(state, call, now)));
1625
1637
  state.runtimes = state.runtimes.filter((runtime) => runtime.goalID !== goal.id);
1626
1638
  return false;
@@ -1628,25 +1640,95 @@ async function afterWorkspaceTool(directory, sessionID, callID) {
1628
1640
  return state;
1629
1641
  });
1630
1642
  }
1631
- function guardV2ToolEditor(directory, editor) {
1643
+ async function releaseTerminalWorkspaceCalls(directory, evidence) {
1644
+ if (!evidence.length)
1645
+ return 0;
1646
+ const snapshot = await readState(directory);
1647
+ const relevant = evidence.some((proof) => snapshot.workspaceCalls?.some((call) => call.sessionID === proof.sessionID && call.callID === proof.callID) || snapshot.goals.some((goal) => goal.workerSessionID === proof.sessionID && snapshot.runtimes.some((rt) => rt.goalID === goal.id && rt.activeToolCallIDs?.includes(proof.callID))));
1648
+ if (!relevant)
1649
+ return 0;
1650
+ let released = 0;
1651
+ await mutateState(directory, "workspace.terminal-evidence", async (state) => {
1652
+ state.workspaceCalls = (state.workspaceCalls ?? []).filter((call) => {
1653
+ const proof = evidence.find((item) => item.sessionID === call.sessionID && item.callID === call.callID && (item.status === "completed" || item.status === "error") && Number.isFinite(item.endedAt) && typeof call.at === "string" && Number.isFinite(Date.parse(call.at)) && item.endedAt >= Date.parse(call.at));
1654
+ if (!proof)
1655
+ return true;
1656
+ released += 1;
1657
+ const goal = state.goals.find((item) => item.id === call.goalID && item.workerSessionID === call.sessionID);
1658
+ const rt = goal && state.runtimes.find((item) => item.goalID === goal.id);
1659
+ if (rt && (call.generation === undefined || rt.runGeneration === call.generation)) {
1660
+ rt.activeToolCallIDs = rt.activeToolCallIDs?.filter((id) => id !== call.callID);
1661
+ }
1662
+ return false;
1663
+ });
1664
+ for (const goal of state.goals) {
1665
+ const rt = state.runtimes.find((item) => item.goalID === goal.id);
1666
+ const started = rt?.turnStartedAt || rt?.lastRunAt ? Date.parse(rt.turnStartedAt ?? rt.lastRunAt) : NaN;
1667
+ if (!rt?.activeToolCallIDs?.length || !Number.isFinite(started))
1668
+ continue;
1669
+ rt.activeToolCallIDs = rt.activeToolCallIDs.filter((callID) => {
1670
+ if (state.workspaceCalls?.some((call) => call.sessionID === goal.workerSessionID && call.callID === callID))
1671
+ return true;
1672
+ return !evidence.some((item) => item.sessionID === goal.workerSessionID && item.callID === callID && (item.status === "completed" || item.status === "error") && Number.isFinite(item.endedAt) && item.endedAt >= started);
1673
+ });
1674
+ }
1675
+ return state;
1676
+ });
1677
+ if (released)
1678
+ await logServerEvent(directory, "workspace.terminal-evidence-released", { released }).catch(() => {});
1679
+ return released;
1680
+ }
1681
+ async function reconcileTerminalWorkspaceCalls(directory, host) {
1682
+ if (!host.readToolExecutions)
1683
+ return;
1684
+ const state = await readState(directory);
1685
+ const sessions = [...new Set([
1686
+ ...(state.workspaceCalls ?? []).map((call) => call.sessionID),
1687
+ ...state.goals.filter((goal) => goal.workerSessionID && state.runtimes.some((rt) => rt.goalID === goal.id && rt.activeToolCallIDs?.length)).map((goal) => goal.workerSessionID)
1688
+ ])];
1689
+ if (!sessions.length)
1690
+ return;
1691
+ const proof = await Promise.all(sessions.map(async (sessionID) => {
1692
+ try {
1693
+ return (await host.readToolExecutions(sessionID)).filter((item) => item.sessionID === sessionID);
1694
+ } catch {
1695
+ return [];
1696
+ }
1697
+ }));
1698
+ await releaseTerminalWorkspaceCalls(directory, proof.flat());
1699
+ }
1700
+ function guardV2ToolEditor(directory, editor, lifecycle) {
1632
1701
  if (typeof editor.list !== "function" || typeof editor.update !== "function")
1633
1702
  throw new Error("Scoped execution requires the v2 tool editor list/update execution surface.");
1703
+ const guarded = new Set;
1634
1704
  for (const entry of editor.list()) {
1705
+ guarded.add(entry.id);
1635
1706
  editor.update(entry.id, (definition) => {
1636
1707
  const execute = definition.execute;
1637
1708
  definition.execute = async (args, context) => {
1638
- await beforeWorkspaceTool(directory, { tool: entry.name, sessionID: context.sessionID, callID: context.id }, args);
1709
+ const input = { tool: entry.id, sessionID: context.sessionID, callID: `${context.id}:${randomUUID4()}` };
1710
+ const values = args;
1711
+ if (lifecycle)
1712
+ await lifecycle.before(input, values);
1713
+ else
1714
+ await beforeWorkspaceTool(directory, input, values);
1715
+ let result;
1639
1716
  try {
1640
- return await execute(args, context);
1717
+ result = await execute(args, context);
1718
+ return result;
1641
1719
  } finally {
1642
- await afterWorkspaceTool(directory, context.sessionID, context.id);
1720
+ if (lifecycle)
1721
+ await lifecycle.after(input, values, result);
1722
+ else
1723
+ await afterWorkspaceTool(directory, input.sessionID, input.callID, input.tool);
1643
1724
  }
1644
1725
  };
1645
1726
  });
1646
1727
  }
1728
+ return guarded;
1647
1729
  }
1648
1730
  async function withWorkspaceOperation(directory, goalID, sessionID, run) {
1649
- const id = randomUUID3();
1731
+ const id = randomUUID4();
1650
1732
  await mutateState(directory, `workspace.operation:${id}`, async (state) => {
1651
1733
  const now = Date.now();
1652
1734
  const live = (state.workspaceCalls ?? []).filter((call) => !isSyncStaleWorkspaceCall(state, call, now));
@@ -1664,8 +1746,9 @@ async function withWorkspaceOperation(directory, goalID, sessionID, run) {
1664
1746
  const now = Date.now();
1665
1747
  state.goals = state.goals.filter((goal) => {
1666
1748
  const live = (state.workspaceCalls ?? []).some((call) => call.goalID === goal.id && !isSyncStaleWorkspaceCall(state, call, now));
1667
- if (!goal.scopeClearPending || live)
1749
+ if (!goal.scopeClearPending || live || state.workspaceOperation?.goalID === goal.id)
1668
1750
  return true;
1751
+ retireWorker(state, goal);
1669
1752
  state.workspaceCalls = (state.workspaceCalls ?? []).filter((call) => !(call.goalID === goal.id && isSyncStaleWorkspaceCall(state, call, now)));
1670
1753
  state.runtimes = state.runtimes.filter((runtime) => runtime.goalID !== goal.id);
1671
1754
  return false;
@@ -1677,6 +1760,7 @@ async function withWorkspaceOperation(directory, goalID, sessionID, run) {
1677
1760
  var readTools, ownerTools, STALE_WORKSPACE_CALL_TTL_MS, CLEAR_PENDING_FORCE_TTL_MS, OWNER_CALL_TTL_MS;
1678
1761
  var init_workspace_execution = __esm(() => {
1679
1762
  init_state_repository();
1763
+ init_server_log();
1680
1764
  init_workspace_scope();
1681
1765
  readTools = new Set([
1682
1766
  "read",
@@ -1726,7 +1810,7 @@ import { tool as v1Tool } from "@opencode-ai/plugin/tool";
1726
1810
 
1727
1811
  // src/application/control-worker.ts
1728
1812
  init_state_repository();
1729
- import { randomUUID as randomUUID2 } from "crypto";
1813
+ import { randomUUID as randomUUID3 } from "crypto";
1730
1814
 
1731
1815
  // src/application/goal-policy.ts
1732
1816
  function resolveGoalCreationConfig(input) {
@@ -1799,42 +1883,8 @@ function cleanList(value) {
1799
1883
  return value.map(cleanText).filter((item) => Boolean(item));
1800
1884
  }
1801
1885
 
1802
- // src/infrastructure/server-log.ts
1803
- import { appendFile } from "fs/promises";
1804
- var SERVER_LOG_FILE = "/tmp/loopd-server.log";
1805
- async function logServerEvent(directory, event, details = {}) {
1806
- try {
1807
- await appendFile(SERVER_LOG_FILE, `${JSON.stringify({
1808
- timestamp: new Date().toISOString(),
1809
- directory,
1810
- event,
1811
- ...details
1812
- }, errorReplacer)}
1813
- `);
1814
- } catch {}
1815
- }
1816
- function describeError(value) {
1817
- if (value instanceof Error)
1818
- return value.message;
1819
- if (typeof value === "string")
1820
- return value;
1821
- if (typeof value === "object" && value !== null && "message" in value) {
1822
- return String(value.message);
1823
- }
1824
- try {
1825
- return JSON.stringify(value, errorReplacer);
1826
- } catch {
1827
- return String(value);
1828
- }
1829
- }
1830
- function errorReplacer(_key, value) {
1831
- if (value instanceof Error) {
1832
- return { name: value.name, message: value.message, stack: value.stack };
1833
- }
1834
- return value;
1835
- }
1836
-
1837
1886
  // src/application/control-worker.ts
1887
+ init_server_log();
1838
1888
  init_command_await2();
1839
1889
  var MAX_LEDGER_SIZE = 100;
1840
1890
  var RESPONSE_CLEANUP_AGE_MS = 60 * 60 * 1000;
@@ -1998,23 +2048,25 @@ function createControlWorker(options) {
1998
2048
  break;
1999
2049
  }
2000
2050
  case "resume": {
2001
- await goalSvc.resume(directory, request.goalID);
2051
+ const result = await goalSvc.resume(directory, request.goalID);
2002
2052
  const state = await readState(directory);
2003
- const goal = state.goals.find((g) => g.id === request.goalID);
2004
2053
  response = {
2005
2054
  ...base,
2006
- message: `goal "${goal?.name || request.goalID}" resumed`,
2055
+ ok: result.ok,
2056
+ message: result.message,
2057
+ errorCode: result.ok ? undefined : result.outcome,
2007
2058
  stateRevision: state.revision
2008
2059
  };
2009
2060
  break;
2010
2061
  }
2011
2062
  case "retry": {
2012
- await goalSvc.retry(directory, request.goalID);
2063
+ const result = await goalSvc.retry(directory, request.goalID);
2013
2064
  const state = await readState(directory);
2014
- const goal = state.goals.find((g) => g.id === request.goalID);
2015
2065
  response = {
2016
2066
  ...base,
2017
- message: `goal "${goal?.name || request.goalID}" retried`,
2067
+ ok: result.ok,
2068
+ message: result.message,
2069
+ errorCode: result.ok ? undefined : result.outcome,
2018
2070
  stateRevision: state.revision
2019
2071
  };
2020
2072
  break;
@@ -2030,6 +2082,7 @@ function createControlWorker(options) {
2030
2082
  ...base,
2031
2083
  ok: result.ok,
2032
2084
  message: result.message,
2085
+ errorCode: result.ok ? undefined : result.outcome,
2033
2086
  stateRevision: state.revision
2034
2087
  };
2035
2088
  break;
@@ -2124,6 +2177,7 @@ function createControlWorker(options) {
2124
2177
  ...base,
2125
2178
  ok: sent.ok,
2126
2179
  message: sent.message,
2180
+ errorCode: sent.ok ? undefined : sent.outcome,
2127
2181
  stateRevision: state.revision
2128
2182
  };
2129
2183
  break;
@@ -2186,7 +2240,7 @@ function createControlWorker(options) {
2186
2240
  });
2187
2241
  await appendEvent(directory, {
2188
2242
  version: 1,
2189
- eventID: randomUUID2(),
2243
+ eventID: randomUUID3(),
2190
2244
  goalID: goal.id,
2191
2245
  type: "goal.completed",
2192
2246
  summary: goal.completionEvidence.summary,
@@ -2241,7 +2295,7 @@ function createControlWorker(options) {
2241
2295
  });
2242
2296
  await appendEvent(directory, {
2243
2297
  version: 1,
2244
- eventID: randomUUID2(),
2298
+ eventID: randomUUID3(),
2245
2299
  goalID: goal.id,
2246
2300
  type: "goal.blocked",
2247
2301
  reason: goal.blocker.reason,
@@ -2409,45 +2463,18 @@ function createControlWorker(options) {
2409
2463
 
2410
2464
  // src/application/loop-engine.ts
2411
2465
  init_state_repository();
2412
- import { randomUUID as randomUUID4 } from "crypto";
2466
+ init_workspace_execution();
2467
+ import { randomUUID as randomUUID5 } from "crypto";
2413
2468
 
2414
- // src/domain/goal.ts
2415
- var MODEL_TRANSITIONS = {
2416
- active: ["complete", "blocked"],
2417
- paused: [],
2418
- blocked: [],
2419
- budget_limited: ["complete", "blocked"],
2420
- usage_limited: [],
2421
- complete: []
2422
- };
2423
- var USER_TRANSITIONS = {
2424
- active: ["paused"],
2425
- paused: ["active"],
2426
- blocked: ["active"],
2427
- budget_limited: ["active"],
2428
- usage_limited: ["active"],
2429
- complete: ["active"]
2430
- };
2431
- var SYSTEM_TRANSITIONS = {
2432
- active: ["budget_limited", "usage_limited"],
2433
- paused: [],
2434
- blocked: [],
2435
- budget_limited: [],
2436
- usage_limited: [],
2437
- complete: []
2438
- };
2439
- function canTransition(current, target, caller) {
2440
- const table = caller === "model" ? MODEL_TRANSITIONS : caller === "user" ? USER_TRANSITIONS : SYSTEM_TRANSITIONS;
2441
- return table[current]?.includes(target) ?? false;
2442
- }
2443
- function isTerminal(status) {
2444
- return status === "complete";
2445
- }
2446
- function createGoal(input) {
2447
- const now = new Date().toISOString();
2448
- return { ...input, tokensUsed: 0, costUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
2469
+ // src/domain/tool-execution.ts
2470
+ function terminalToolExecution(part) {
2471
+ if (part?.type !== "tool" || typeof part.sessionID !== "string" || !part.sessionID || typeof part.callID !== "string" || !part.callID || part.state?.status !== "completed" && part.state?.status !== "error" || typeof part.state?.time?.end !== "number" || !Number.isFinite(part.state.time.end) || part.state.time.end <= 0)
2472
+ return;
2473
+ return { sessionID: part.sessionID, callID: part.callID, status: part.state.status, endedAt: part.state.time.end };
2449
2474
  }
2475
+
2450
2476
  // src/application/loop-engine.ts
2477
+ init_server_log();
2451
2478
  var CONFIRM_IDLE_DURATION_MS = 2000;
2452
2479
  var HANDLED_EVENT_TYPES = new Set([
2453
2480
  "session.idle",
@@ -2468,6 +2495,7 @@ function createLoopEngine(options) {
2468
2495
  const idleRecoverMs = options.idleRecoverMs ?? 3 * 60000;
2469
2496
  let running = false;
2470
2497
  let maintenanceTimer;
2498
+ let maintaining = false;
2471
2499
  let knownWorkerSessions = new Set;
2472
2500
  let knownWorkerSessionsLoaded = false;
2473
2501
  const inflightContinuations = new Set;
@@ -2499,8 +2527,12 @@ function createLoopEngine(options) {
2499
2527
  running = true;
2500
2528
  loadWorkerSessionsIfneeded().catch(() => {});
2501
2529
  maintenanceTimer = setInterval(() => {
2502
- if (running)
2503
- maintenance().catch(() => {});
2530
+ if (running && !maintaining) {
2531
+ maintaining = true;
2532
+ maintenance().catch(() => {}).finally(() => {
2533
+ maintaining = false;
2534
+ });
2535
+ }
2504
2536
  }, maintenanceMs);
2505
2537
  }
2506
2538
  function stop() {
@@ -2513,13 +2545,21 @@ function createLoopEngine(options) {
2513
2545
  function isRunning() {
2514
2546
  return running;
2515
2547
  }
2516
- async function continueGoal(goalID) {
2548
+ async function continueGoal(goalID, opts) {
2517
2549
  if (inflightContinuations.has(goalID))
2518
2550
  return false;
2519
2551
  inflightContinuations.add(goalID);
2520
2552
  try {
2521
- await goalService.continueTurn(directory, goalID);
2522
- return true;
2553
+ const state = await readState(directory);
2554
+ const goal = state.goals.find((g) => g.id === goalID);
2555
+ const rt = state.runtimes.find((r) => r.goalID === goalID);
2556
+ if (!running || !goal || goal.status !== "active" || goal.interactive || !rt)
2557
+ return false;
2558
+ const forceFinish = opts?.forceFinish || enforceLimits(goal, rt).stop === "force_finish";
2559
+ const result = await goalService.continueTurn(directory, goalID, { forceFinish });
2560
+ if (!result.ok)
2561
+ await logServerEvent(directory, "engine.continuation-denied", { goalID, outcome: result.outcome, reason: result.message });
2562
+ return result.ok && result.outcome === "sent";
2523
2563
  } finally {
2524
2564
  inflightContinuations.delete(goalID);
2525
2565
  }
@@ -2530,6 +2570,11 @@ function createLoopEngine(options) {
2530
2570
  const type = event.type;
2531
2571
  if (!type || !HANDLED_EVENT_TYPES.has(type))
2532
2572
  return false;
2573
+ if (type === "message.part.updated") {
2574
+ const proof = terminalToolExecution(event.properties?.part);
2575
+ if (proof)
2576
+ await releaseTerminalWorkspaceCalls(directory, [proof]);
2577
+ }
2533
2578
  const sessionID = eventSessionID(event);
2534
2579
  if (!sessionID)
2535
2580
  return false;
@@ -2573,8 +2618,9 @@ function createLoopEngine(options) {
2573
2618
  async function handleMessageActivity(goal, event) {
2574
2619
  let matched = false;
2575
2620
  await mutateState(directory, `message-activity:${goal.id}`, async (s) => {
2621
+ const g = s.goals.find((item) => item.id === goal.id);
2576
2622
  const rt = s.runtimes.find((r) => r.goalID === goal.id);
2577
- if (!rt || rt.phase !== "running" || !rt.activePromptMessageID)
2623
+ if (!g || g.status !== "active" || g.workerSessionID !== goal.workerSessionID || !rt || rt.phase !== "running" || !rt.activePromptMessageID)
2578
2624
  return s;
2579
2625
  if (event.type === "message.updated") {
2580
2626
  const info = event.properties?.info;
@@ -2584,10 +2630,9 @@ function createLoopEngine(options) {
2584
2630
  matched = true;
2585
2631
  } else if (info?.role === "assistant" && info.parentID === rt.activePromptMessageID) {
2586
2632
  Object.assign(rt, recordActivity(rt));
2633
+ rt.activePromptObservedAt ??= new Date().toISOString();
2587
2634
  rt.activeAssistantMessageID = info.id;
2588
- if (info.time?.completed) {
2589
- rt.activeAssistantCompletedAt = new Date(info.time.completed).toISOString();
2590
- }
2635
+ rt.activeAssistantCompletedAt = info.time?.completed && info.finish !== "tool-calls" ? new Date(info.time.completed).toISOString() : undefined;
2591
2636
  matched = true;
2592
2637
  }
2593
2638
  } else {
@@ -2606,8 +2651,9 @@ function createLoopEngine(options) {
2606
2651
  async function handleExecutionSucceeded(goal) {
2607
2652
  let matched = false;
2608
2653
  await mutateState(directory, `execution-succeeded:${goal.id}`, async (s) => {
2654
+ const g = s.goals.find((item) => item.id === goal.id);
2609
2655
  const rt = s.runtimes.find((r) => r.goalID === goal.id);
2610
- if (!rt || rt.phase !== "running" || !rt.activePromptMessageID)
2656
+ if (!g || g.status !== "active" || g.workerSessionID !== goal.workerSessionID || !rt || rt.phase !== "running" || !rt.activePromptMessageID)
2611
2657
  return s;
2612
2658
  Object.assign(rt, recordActivity(rt));
2613
2659
  rt.activeAssistantCompletedAt = new Date().toISOString();
@@ -2626,7 +2672,7 @@ function createLoopEngine(options) {
2626
2672
  const g = s.goals.find((item) => item.id === goalID);
2627
2673
  if (!g)
2628
2674
  return s;
2629
- if (isTerminal(g.status) || g.status === "paused")
2675
+ if (g.status !== "active" || g.interactive || g.workerSessionID !== goal.workerSessionID)
2630
2676
  return s;
2631
2677
  const rt = s.runtimes.find((r) => r.goalID === goalID);
2632
2678
  if (!rt)
@@ -2674,7 +2720,7 @@ function createLoopEngine(options) {
2674
2720
  const stagedRt = afterIdle.runtimes.find((r) => r.goalID === goalID);
2675
2721
  const eventAnchored = stagedRt?.runGeneration === candidate.generation && Boolean(stagedRt?.activeAssistantCompletedAt);
2676
2722
  const transcript = await inspectPromptTurn(goal.workerSessionID, candidate.promptMessageID);
2677
- const failureReason = !transcript.latestUserPrompt && !eventAnchored ? "prompt-outside-window" : !eventAnchored && !candidate.assistantCompleted && !transcript.assistantCompleted ? "assistant-incomplete" : undefined;
2723
+ const failureReason = transcript.assistantInProgress ? "assistant-incomplete" : !transcript.latestUserPrompt && !eventAnchored ? "prompt-outside-window" : !eventAnchored && !candidate.assistantCompleted && !transcript.assistantCompleted ? "assistant-incomplete" : undefined;
2678
2724
  if (failureReason) {
2679
2725
  let newlyStamped = false;
2680
2726
  await mutateState(directory, `idle.confirm-stamp:${goalID}`, async (s) => {
@@ -2691,7 +2737,7 @@ function createLoopEngine(options) {
2691
2737
  if (newlyStamped) {
2692
2738
  await appendEvent(directory, {
2693
2739
  version: 1,
2694
- eventID: randomUUID4(),
2740
+ eventID: randomUUID5(),
2695
2741
  goalID,
2696
2742
  type: "idle.confirm-failed",
2697
2743
  reason: failureReason,
@@ -2705,7 +2751,7 @@ function createLoopEngine(options) {
2705
2751
  afterIdle = await mutateState(directory, `idle.confirm:${goalID}`, async (s) => {
2706
2752
  const g = s.goals.find((item) => item.id === goalID);
2707
2753
  const rt = s.runtimes.find((r) => r.goalID === goalID);
2708
- if (!g || !rt || isTerminal(g.status) || g.status === "paused")
2754
+ if (!g || !rt || g.status !== "active" || g.interactive || g.workerSessionID !== goal.workerSessionID)
2709
2755
  return s;
2710
2756
  if (rt.phase !== "running")
2711
2757
  return s;
@@ -2736,7 +2782,7 @@ function createLoopEngine(options) {
2736
2782
  if (completedRunID) {
2737
2783
  await appendEvent(directory, {
2738
2784
  version: 1,
2739
- eventID: randomUUID4(),
2785
+ eventID: randomUUID5(),
2740
2786
  goalID,
2741
2787
  type: "run.completed",
2742
2788
  runID: completedRunID,
@@ -2756,13 +2802,7 @@ function createLoopEngine(options) {
2756
2802
  const limitResult = enforceLimits(freshGoal, freshRuntime);
2757
2803
  if (limitResult.stop === "force_finish") {
2758
2804
  if (!freshRuntime.forceFinishRequested) {
2759
- await mutateState(directory, `idle.force-finish:${goalID}`, async (s) => {
2760
- const rt = s.runtimes.find((r) => r.goalID === goalID);
2761
- if (rt)
2762
- rt.forceFinishRequested = true;
2763
- return s;
2764
- });
2765
- await goalService.continueTurn(directory, goalID, { forceFinish: true });
2805
+ await continueGoal(goalID, { forceFinish: true });
2766
2806
  return true;
2767
2807
  }
2768
2808
  const blockedKey = goalID;
@@ -2801,7 +2841,7 @@ function createLoopEngine(options) {
2801
2841
  });
2802
2842
  await appendEvent(directory, {
2803
2843
  version: 1,
2804
- eventID: randomUUID4(),
2844
+ eventID: randomUUID5(),
2805
2845
  goalID,
2806
2846
  type: "goal.blocked",
2807
2847
  reason: limitResult.reason + " (force-finish ignored)",
@@ -2825,7 +2865,7 @@ function createLoopEngine(options) {
2825
2865
  });
2826
2866
  await appendEvent(directory, {
2827
2867
  version: 1,
2828
- eventID: randomUUID4(),
2868
+ eventID: randomUUID5(),
2829
2869
  goalID,
2830
2870
  type: "goal.status_changed",
2831
2871
  from: "active",
@@ -2843,7 +2883,7 @@ function createLoopEngine(options) {
2843
2883
  return true;
2844
2884
  }
2845
2885
  async function inspectPromptTurn(workerSessionID, promptMessageID) {
2846
- const noMatch = { latestUserPrompt: false, assistantCompleted: false };
2886
+ const noMatch = { latestUserPrompt: false, assistantCompleted: false, assistantInProgress: false };
2847
2887
  if (!workerSessionID)
2848
2888
  return noMatch;
2849
2889
  let messages;
@@ -2857,9 +2897,14 @@ function createLoopEngine(options) {
2857
2897
  return noMatch;
2858
2898
  const allTimestamped = users.every((message) => message.timestamp && Number.isFinite(Date.parse(message.timestamp)));
2859
2899
  const ordered = allTimestamped ? [...users].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)) : users;
2900
+ const assistants = messages.filter((message) => message.role === "assistant" && message.parentMessageID === promptMessageID);
2901
+ const orderedAssistants = assistants.every((message) => Number.isFinite(Date.parse(message.createdAt ?? message.timestamp ?? ""))) ? [...assistants].sort((a, b) => Date.parse(a.createdAt ?? a.timestamp) - Date.parse(b.createdAt ?? b.timestamp)) : assistants;
2902
+ const latestAssistant = orderedAssistants.at(-1);
2903
+ const assistantCompleted = Boolean(latestAssistant?.completedAt) && latestAssistant?.finish !== "tool-calls";
2860
2904
  return {
2861
2905
  latestUserPrompt: ordered.at(-1)?.messageID === promptMessageID,
2862
- assistantCompleted: messages.some((message) => message.role === "assistant" && message.parentMessageID === promptMessageID && Boolean(message.completedAt))
2906
+ assistantCompleted,
2907
+ assistantInProgress: Boolean(latestAssistant) && !assistantCompleted
2863
2908
  };
2864
2909
  }
2865
2910
  async function handleSessionStatus(state, goal, event) {
@@ -2873,8 +2918,9 @@ function createLoopEngine(options) {
2873
2918
  if (statusType === "idle")
2874
2919
  return handleSessionIdle(state, goal);
2875
2920
  await mutateState(directory, `status:${goal.id}`, async (s) => {
2921
+ const g = s.goals.find((item) => item.id === goal.id);
2876
2922
  const rt = s.runtimes.find((r) => r.goalID === goal.id);
2877
- if (!rt)
2923
+ if (!g || g.status !== "active" || g.workerSessionID !== goal.workerSessionID || !rt || rt.runGeneration !== runtime.runGeneration)
2878
2924
  return s;
2879
2925
  if (statusType === "busy" || statusType === "retry") {
2880
2926
  Object.assign(rt, recordActivity(rt));
@@ -2895,7 +2941,7 @@ function createLoopEngine(options) {
2895
2941
  const newState = await mutateState(directory, `error:${goal.id}`, async (s) => {
2896
2942
  const g = s.goals.find((item) => item.id === goal.id);
2897
2943
  const rt = s.runtimes.find((r) => r.goalID === goal.id);
2898
- if (!rt)
2944
+ if (!g || g.status !== "active" || g.workerSessionID !== goal.workerSessionID || !rt || rt.runGeneration !== runtime.runGeneration)
2899
2945
  return s;
2900
2946
  rt.consecutiveFailures += 1;
2901
2947
  rt.lastError = message;
@@ -2927,7 +2973,7 @@ function createLoopEngine(options) {
2927
2973
  });
2928
2974
  await appendEvent(directory, {
2929
2975
  version: 1,
2930
- eventID: randomUUID4(),
2976
+ eventID: randomUUID5(),
2931
2977
  goalID: goal.id,
2932
2978
  type: "run.failed",
2933
2979
  runID: runtime.activeRunID || "unknown",
@@ -2941,7 +2987,7 @@ function createLoopEngine(options) {
2941
2987
  if (updatedGoal?.status === "blocked") {
2942
2988
  await appendEvent(directory, {
2943
2989
  version: 1,
2944
- eventID: randomUUID4(),
2990
+ eventID: randomUUID5(),
2945
2991
  goalID: goal.id,
2946
2992
  type: "goal.blocked",
2947
2993
  reason: `Failed ${runtime.consecutiveFailures + 1} times`,
@@ -2969,7 +3015,7 @@ function createLoopEngine(options) {
2969
3015
  });
2970
3016
  await appendEvent(directory, {
2971
3017
  version: 1,
2972
- eventID: randomUUID4(),
3018
+ eventID: randomUUID5(),
2973
3019
  goalID: goal.id,
2974
3020
  type: "compaction.completed",
2975
3021
  timestamp: new Date().toISOString(),
@@ -3039,7 +3085,7 @@ function createLoopEngine(options) {
3039
3085
  });
3040
3086
  await appendEvent(directory, {
3041
3087
  version: 1,
3042
- eventID: randomUUID4(),
3088
+ eventID: randomUUID5(),
3043
3089
  goalID: goal.id,
3044
3090
  type: "compaction.started",
3045
3091
  timestamp: new Date().toISOString(),
@@ -3097,7 +3143,7 @@ function createLoopEngine(options) {
3097
3143
  return false;
3098
3144
  await appendEvent(directory, {
3099
3145
  version: 1,
3100
- eventID: randomUUID4(),
3146
+ eventID: randomUUID5(),
3101
3147
  goalID: goal.id,
3102
3148
  type: "goal.status_changed",
3103
3149
  from: "active",
@@ -3121,6 +3167,7 @@ function createLoopEngine(options) {
3121
3167
  }
3122
3168
  async function sweepStaleWorkspaceOwnership() {
3123
3169
  try {
3170
+ await reconcileTerminalWorkspaceCalls(directory, host);
3124
3171
  const snapshot = await readState(directory);
3125
3172
  if (!snapshot.workspaceCalls?.length && !snapshot.goals.some((g) => g.scopeClosing || g.scopeClearPending)) {
3126
3173
  return;
@@ -3198,7 +3245,7 @@ function createLoopEngine(options) {
3198
3245
  }
3199
3246
  return s;
3200
3247
  });
3201
- goalService.continueTurn(directory, goal.id).catch(() => {});
3248
+ await continueGoal(goal.id);
3202
3249
  }
3203
3250
  }
3204
3251
  if (!manual && (runtime.phase === "running" || runtime.phase === "idle") && goal.workerSessionID) {
@@ -3214,22 +3261,6 @@ function createLoopEngine(options) {
3214
3261
  });
3215
3262
  await logServerEvent(directory, "maintenance.stale-run-cleared", { goalID: goal.id });
3216
3263
  }
3217
- if ((runtime.activeToolCallIDs?.length ?? 0) > 0 && runtime.lastActivityAt) {
3218
- const age = Date.now() - Date.parse(runtime.lastActivityAt);
3219
- if (age > 30000) {
3220
- await mutateState(directory, `maintenance.toolcall-ttl:${goal.id}`, async (s) => {
3221
- const rt = s.runtimes.find((r) => r.goalID === goal.id);
3222
- if (rt && (rt.activeToolCallIDs?.length ?? 0) > 0) {
3223
- rt.activeToolCallIDs = [];
3224
- rt.idleCandidateAt = undefined;
3225
- rt.idleCandidateGeneration = undefined;
3226
- rt.updatedAt = new Date().toISOString();
3227
- }
3228
- return s;
3229
- });
3230
- await logServerEvent(directory, "maintenance.toolcall-ttl-cleared", { goalID: goal.id, age });
3231
- }
3232
- }
3233
3264
  const status = await host.sessionStatus(goal.workerSessionID);
3234
3265
  if (status === "unknown") {
3235
3266
  let shouldNotify = false;
@@ -3283,10 +3314,14 @@ function createLoopEngine(options) {
3283
3314
  const rt = s.runtimes.find((r) => r.goalID === goal.id);
3284
3315
  if (!g || !rt || g.status !== "active")
3285
3316
  return s;
3317
+ if (g.interactive || g.scopeClosing || g.workerSessionID !== goal.workerSessionID)
3318
+ return s;
3286
3319
  if (rt.phase !== "running")
3287
3320
  return s;
3288
3321
  if (rt.runGeneration !== generation)
3289
3322
  return s;
3323
+ if (s.workspaceOperation || (s.workspaceCalls ?? []).some((call) => call.goalID === goal.id) || rt.activeToolCallIDs?.length)
3324
+ return s;
3290
3325
  clearedToolCalls = rt.activeToolCallIDs?.length ?? 0;
3291
3326
  Object.assign(rt, releaseLease(rt));
3292
3327
  rt.activeRunID = undefined;
@@ -3300,7 +3335,7 @@ function createLoopEngine(options) {
3300
3335
  if (recovered) {
3301
3336
  await appendEvent(directory, {
3302
3337
  version: 1,
3303
- eventID: randomUUID4(),
3338
+ eventID: randomUUID5(),
3304
3339
  goalID: goal.id,
3305
3340
  type: "run.recovered",
3306
3341
  runID: stalledRunID ?? "unknown",
@@ -3334,7 +3369,7 @@ function createLoopEngine(options) {
3334
3369
  const stuckSeconds = Math.floor((Date.now() - Date.parse(failedAt)) / 1000);
3335
3370
  await appendEvent(directory, {
3336
3371
  version: 1,
3337
- eventID: randomUUID4(),
3372
+ eventID: randomUUID5(),
3338
3373
  goalID: goal.id,
3339
3374
  type: "run.stuck",
3340
3375
  runID: runtime.activeRunID ?? "unknown",
@@ -3374,7 +3409,7 @@ function createLoopEngine(options) {
3374
3409
  });
3375
3410
  await appendEvent(directory, {
3376
3411
  version: 1,
3377
- eventID: randomUUID4(),
3412
+ eventID: randomUUID5(),
3378
3413
  goalID: goal.id,
3379
3414
  type: "run.stuck",
3380
3415
  runID: runtime.activeRunID,
@@ -3397,14 +3432,54 @@ function createLoopEngine(options) {
3397
3432
  }
3398
3433
 
3399
3434
  // src/application/goal-service.ts
3400
- import { randomUUID as randomUUID6 } from "crypto";
3435
+ import { randomUUID as randomUUID7 } from "crypto";
3436
+
3437
+ // src/domain/goal.ts
3438
+ var MODEL_TRANSITIONS = {
3439
+ active: ["complete", "blocked"],
3440
+ paused: [],
3441
+ blocked: [],
3442
+ budget_limited: ["complete", "blocked"],
3443
+ usage_limited: [],
3444
+ complete: []
3445
+ };
3446
+ var USER_TRANSITIONS = {
3447
+ active: ["paused"],
3448
+ paused: ["active"],
3449
+ blocked: ["active"],
3450
+ budget_limited: ["active"],
3451
+ usage_limited: ["active"],
3452
+ complete: ["active"]
3453
+ };
3454
+ var SYSTEM_TRANSITIONS = {
3455
+ active: ["budget_limited", "usage_limited"],
3456
+ paused: [],
3457
+ blocked: [],
3458
+ budget_limited: [],
3459
+ usage_limited: [],
3460
+ complete: []
3461
+ };
3462
+ function canTransition(current, target, caller) {
3463
+ const table = caller === "model" ? MODEL_TRANSITIONS : caller === "user" ? USER_TRANSITIONS : SYSTEM_TRANSITIONS;
3464
+ return table[current]?.includes(target) ?? false;
3465
+ }
3466
+ function isTerminal(status) {
3467
+ return status === "complete";
3468
+ }
3469
+ function createGoal(input) {
3470
+ const now = new Date().toISOString();
3471
+ return { ...input, tokensUsed: 0, costUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
3472
+ }
3473
+
3474
+ // src/application/goal-service.ts
3401
3475
  init_state_repository();
3402
3476
  init_command_await2();
3403
3477
  import * as path4 from "path";
3404
3478
  import { promises as fs4 } from "fs";
3405
3479
 
3406
3480
  // src/server/host-adapter.ts
3407
- import { randomUUID as randomUUID5 } from "crypto";
3481
+ init_server_log();
3482
+ import { randomUUID as randomUUID6 } from "crypto";
3408
3483
 
3409
3484
  // src/v2/native-rpc.ts
3410
3485
  var NATIVE_RPC_ID = "loopd.native";
@@ -3630,7 +3705,7 @@ function parseModelRef(value) {
3630
3705
  return { providerID, modelID };
3631
3706
  }
3632
3707
  function newPromptMessageID() {
3633
- return `msg_${randomUUID5()}`;
3708
+ return `msg_${randomUUID6()}`;
3634
3709
  }
3635
3710
  function isV2PromptMessageID(messageID) {
3636
3711
  return messageID.startsWith("msg_");
@@ -3810,6 +3885,8 @@ function createRealHost(client, directory) {
3810
3885
  messageID: m.info?.id || m.id,
3811
3886
  parentMessageID: m.info?.parentID,
3812
3887
  completedAt: completedMs ? new Date(completedMs).toISOString() : undefined,
3888
+ finish: typeof m.info?.finish === "string" ? m.info.finish : undefined,
3889
+ createdAt: typeof createdMs === "number" ? new Date(createdMs).toISOString() : undefined,
3813
3890
  tokens: tokens && typeof tokens.input === "number" ? {
3814
3891
  input: tokens.input || 0,
3815
3892
  output: tokens.output || 0,
@@ -3825,6 +3902,23 @@ function createRealHost(client, directory) {
3825
3902
  return [];
3826
3903
  }
3827
3904
  },
3905
+ async readToolExecutions(sessionID) {
3906
+ try {
3907
+ const result = await withTimeout(client.session.messages({ path: { id: sessionID } }), 1e4, "OpenCode session.messages (tool terminal evidence)");
3908
+ if (!Array.isArray(result?.data))
3909
+ return [];
3910
+ return result.data.flatMap((message) => {
3911
+ if (message.info?.sessionID !== undefined && message.info.sessionID !== sessionID)
3912
+ return [];
3913
+ return (message.parts ?? []).flatMap((part) => {
3914
+ const evidence = terminalToolExecution(part?.sessionID === undefined && message.info?.sessionID === sessionID ? { ...part, sessionID } : part);
3915
+ return evidence?.sessionID === sessionID ? [evidence] : [];
3916
+ });
3917
+ });
3918
+ } catch {
3919
+ return [];
3920
+ }
3921
+ },
3828
3922
  async compactSession(sessionID, model) {
3829
3923
  try {
3830
3924
  const identity = model ?? (await this.readSession(sessionID))?.model;
@@ -4056,6 +4150,7 @@ function createV2Host(context, statuses, options = {}) {
4056
4150
  messageID: message.id,
4057
4151
  parentMessageID,
4058
4152
  completedAt: completed ? new Date(completed).toISOString() : undefined,
4153
+ createdAt: new Date(created).toISOString(),
4059
4154
  tokens: message.tokens ? {
4060
4155
  input: message.tokens.input,
4061
4156
  output: message.tokens.output,
@@ -4298,6 +4393,7 @@ function buildContinuationSteering(goal, runtime, context) {
4298
4393
  }
4299
4394
 
4300
4395
  // src/application/goal-service.ts
4396
+ init_server_log();
4301
4397
  init_workspace_execution();
4302
4398
 
4303
4399
  class GoalStartError extends Error {
@@ -4312,6 +4408,14 @@ class GoalStartError extends Error {
4312
4408
  this.failedStage = info.failedStage;
4313
4409
  }
4314
4410
  }
4411
+
4412
+ class GoalDeliveryBlockedError extends Error {
4413
+ result;
4414
+ constructor(result) {
4415
+ super(result.message);
4416
+ this.result = result;
4417
+ }
4418
+ }
4315
4419
  function createGoalService(host) {
4316
4420
  const workers = createWorkerManager(host);
4317
4421
  const sessions = new Map;
@@ -4851,7 +4955,7 @@ function createGoalService(host) {
4851
4955
  }
4852
4956
  await appendEvent(directory, {
4853
4957
  version: 1,
4854
- eventID: randomUUID6(),
4958
+ eventID: randomUUID7(),
4855
4959
  goalID,
4856
4960
  type: "run.failed",
4857
4961
  runID,
@@ -4863,7 +4967,7 @@ function createGoalService(host) {
4863
4967
  if (blocked) {
4864
4968
  await appendEvent(directory, {
4865
4969
  version: 1,
4866
- eventID: randomUUID6(),
4970
+ eventID: randomUUID7(),
4867
4971
  goalID,
4868
4972
  type: "goal.blocked",
4869
4973
  reason: `Worker prompt delivery failed: ${detail}`,
@@ -4900,13 +5004,67 @@ function createGoalService(host) {
4900
5004
  }
4901
5005
  }
4902
5006
  async function dispatchOrRecordFailure(directory, goalID, dispatch, run, workerSessionID, blockImmediately = false) {
5007
+ let blocked;
5008
+ await mutateState(directory, `turn.dispatch-intent:${goalID}`, async (s) => {
5009
+ blocked = continuationGuard(s, goalID);
5010
+ const g = s.goals.find((item) => item.id === goalID);
5011
+ const rt = s.runtimes.find((item) => item.goalID === goalID);
5012
+ const ownsRun = rt && g?.workerSessionID === workerSessionID && rt.activeRunID === run.activeRunID && rt.activePromptMessageID === run.activePromptMessageID && (run.runGeneration === undefined || rt.runGeneration === run.runGeneration);
5013
+ if (!ownsRun)
5014
+ blocked ??= rejected(goalID, "blocked", "Run or worker was superseded before dispatch; no prompt was sent.");
5015
+ if (blocked) {
5016
+ if (ownsRun) {
5017
+ Object.assign(rt, releaseLease(rt));
5018
+ rt.activeRunID = undefined;
5019
+ }
5020
+ } else if (rt) {
5021
+ rt.activePromptDispatchAt = new Date().toISOString();
5022
+ }
5023
+ return s;
5024
+ });
5025
+ if (blocked) {
5026
+ if (blocked.outcome === "not_found")
5027
+ sessions.delete(goalID);
5028
+ throw new GoalDeliveryBlockedError(blocked);
5029
+ }
5030
+ let observed = false;
4903
5031
  try {
4904
5032
  await dispatch();
5033
+ observed = await wasPromptAccepted(directory, workerSessionID, run.activePromptMessageID);
4905
5034
  } catch (error) {
4906
- if (await wasPromptAccepted(directory, workerSessionID, run.activePromptMessageID))
4907
- return;
4908
- await recordPromptFailure(directory, goalID, error, blockImmediately, run.activeRunID);
4909
- throw error;
5035
+ const current = await readState(directory);
5036
+ if (!current.goals.some((goal) => goal.id === goalID)) {
5037
+ throw new GoalDeliveryBlockedError(rejected(goalID, "not_found", "Goal cleared while host delivery was pending. Its late result was ignored; list current goals instead of retrying this ID."));
5038
+ }
5039
+ observed = await wasPromptAccepted(directory, workerSessionID, run.activePromptMessageID);
5040
+ if (!observed) {
5041
+ await recordPromptFailure(directory, goalID, error, blockImmediately, run.activeRunID);
5042
+ throw error;
5043
+ }
5044
+ }
5045
+ let removed = false;
5046
+ let acceptedRun = false;
5047
+ const accepted = await mutateState(directory, `turn.dispatch-accepted:${goalID}`, async (s) => {
5048
+ const g = s.goals.find((item) => item.id === goalID);
5049
+ removed = !g;
5050
+ const rt = s.runtimes.find((item) => item.goalID === goalID);
5051
+ if (rt && g?.status === "active" && g.workerSessionID === workerSessionID && rt.activeRunID === run.activeRunID && rt.activePromptMessageID === run.activePromptMessageID && (run.runGeneration === undefined || rt.runGeneration === run.runGeneration)) {
5052
+ acceptedRun = true;
5053
+ rt.activePromptAcceptedAt = new Date().toISOString();
5054
+ if (run.forceFinish)
5055
+ rt.forceFinishRequested = true;
5056
+ if (observed)
5057
+ rt.activePromptObservedAt ??= rt.activePromptAcceptedAt;
5058
+ }
5059
+ return s;
5060
+ });
5061
+ if (removed)
5062
+ throw new GoalDeliveryBlockedError(rejected(goalID, "not_found", "Goal cleared while host delivery was pending. Its late result was ignored; list current goals instead of retrying this ID."));
5063
+ if (!acceptedRun)
5064
+ throw new GoalDeliveryBlockedError(rejected(goalID, "blocked", "The turn or worker changed while host delivery was pending. Its late result was ignored; inspect the current run before retrying."));
5065
+ const rt = accepted.runtimes.find((item) => item.goalID === goalID);
5066
+ if (rt && rt.activeRunID === run.activeRunID && rt.activePromptAcceptedAt) {
5067
+ await acknowledgeGoalInbox(directory, goalID, rt.activeInboxLines ?? []);
4910
5068
  }
4911
5069
  }
4912
5070
  async function ensureWorkerSession(directory, goal) {
@@ -4923,22 +5081,36 @@ function createGoalService(host) {
4923
5081
  return session;
4924
5082
  }
4925
5083
  session = await workers.createWorker(goal);
4926
- sessions.set(goal.id, session);
5084
+ let linked = false;
5085
+ let failure;
4927
5086
  await mutateState(directory, `goal.set-worker:${goal.id}`, async (s) => {
4928
5087
  const persisted = s.goals.find((item) => item.id === goal.id);
4929
- if (persisted) {
5088
+ if (!persisted)
5089
+ failure = rejected(goal.id, "not_found", "Goal cleared during worker creation; the new worker was retired. List your goals instead of retrying this ID.");
5090
+ else if (persisted.ownerSessionID !== goal.ownerSessionID || persisted.workerSessionID !== goal.workerSessionID || persisted.status !== "active") {
5091
+ failure = rejected(goal.id, "blocked", "Goal changed during worker creation; the unused worker was retired. Inspect current state before retrying.");
5092
+ } else {
4930
5093
  persisted.workerSessionID = session.workerSessionID;
5094
+ linked = true;
4931
5095
  if (session.topology) {
4932
5096
  persisted.workerTopology = session.topology;
4933
5097
  persisted.nativeParentID = session.nativeParentID;
4934
5098
  }
4935
5099
  }
5100
+ if (!linked)
5101
+ retireWorker(s, { ...goal, workerSessionID: session.workerSessionID });
4936
5102
  return s;
4937
5103
  });
5104
+ if (!linked) {
5105
+ sessions.delete(goal.id);
5106
+ await workers.abortWorker(session.workerSessionID).catch(() => {});
5107
+ throw new GoalDeliveryBlockedError(failure);
5108
+ }
5109
+ sessions.set(goal.id, session);
4938
5110
  return session;
4939
5111
  }
4940
5112
  async function start(directory, input) {
4941
- const id = randomUUID6();
5113
+ const id = randomUUID7();
4942
5114
  return withGoalOperation(id, () => startUnlocked(directory, input, id));
4943
5115
  }
4944
5116
  async function startUnlocked(directory, input, id) {
@@ -5045,7 +5217,7 @@ function createGoalService(host) {
5045
5217
  });
5046
5218
  await appendEvent(directory, {
5047
5219
  version: 1,
5048
- eventID: randomUUID6(),
5220
+ eventID: randomUUID7(),
5049
5221
  goalID: id,
5050
5222
  type: "goal.blocked",
5051
5223
  reason: detail,
@@ -5072,7 +5244,7 @@ function createGoalService(host) {
5072
5244
  const rt = state.runtimes.find((item) => item.goalID === id);
5073
5245
  if (rt) {
5074
5246
  Object.assign(rt, acquireLease(rt, g.config.timeoutMs || 300000));
5075
- rt.activeRunID = randomUUID6();
5247
+ rt.activeRunID = randomUUID7();
5076
5248
  rt.activePromptMessageID = newPromptMessageID();
5077
5249
  rt.runCount = 1;
5078
5250
  rt.budgetTurnCount = 1;
@@ -5083,7 +5255,7 @@ function createGoalService(host) {
5083
5255
  runtime = state2.runtimes.find((r) => r.goalID === id);
5084
5256
  await appendEvent(directory, {
5085
5257
  version: 1,
5086
- eventID: randomUUID6(),
5258
+ eventID: randomUUID7(),
5087
5259
  goalID: id,
5088
5260
  type: "goal.created",
5089
5261
  name: input.name,
@@ -5095,7 +5267,7 @@ function createGoalService(host) {
5095
5267
  if (runtime) {
5096
5268
  await appendEvent(directory, {
5097
5269
  version: 1,
5098
- eventID: randomUUID6(),
5270
+ eventID: randomUUID7(),
5099
5271
  goalID: id,
5100
5272
  type: "run.started",
5101
5273
  runID: runtime.activeRunID,
@@ -5172,23 +5344,48 @@ function createGoalService(host) {
5172
5344
  }
5173
5345
  return accountTailUsage(directory, goalID, runtime, tail);
5174
5346
  }
5347
+ function rejected(goalID, outcome, message) {
5348
+ if (outcome === "not_found")
5349
+ sessions.delete(goalID);
5350
+ return { ok: false, outcome, goalID, message };
5351
+ }
5352
+ function continuationGuard(state, goalID) {
5353
+ const goal = state.goals.find((g) => g.id === goalID);
5354
+ if (!goal)
5355
+ return rejected(goalID, "not_found", "Goal not found (it may have been cleared). List your goals instead of retrying this ID.");
5356
+ if (goal.status !== "active")
5357
+ return rejected(goalID, "invalid_state", `Goal is ${goal.status}; resume or retry it before requesting a turn.`);
5358
+ if (goal.config.write_scope !== undefined && !host.scopedExecution)
5359
+ return rejected(goalID, "blocked", "Enforced scopes are unsupported on this host.");
5360
+ if (goal.scopeClosing)
5361
+ return rejected(goalID, "blocked", "Goal is closing; wait for in-flight writes to finish before resuming.");
5362
+ if ((state.workspaceCalls ?? []).some((call) => call.goalID === goalID && !isSyncStaleWorkspaceCall(state, call, Date.now()))) {
5363
+ return rejected(goalID, "blocked", "Goal has in-flight write reservations. Nothing was sent; retry after writes finish.");
5364
+ }
5365
+ if (state.workspaceOperation)
5366
+ return rejected(goalID, "blocked", `Workspace operation ${state.workspaceOperation.id} is in progress. Nothing was sent; retry after it finishes.`);
5367
+ const runtime = state.runtimes.find((r) => r.goalID === goalID);
5368
+ if (!runtime)
5369
+ return rejected(goalID, "blocked", "Goal runtime is missing; no turn was sent.");
5370
+ if (runtime.activeToolCallIDs?.length)
5371
+ return rejected(goalID, "blocked", "Worker has outstanding tool calls. Nothing was sent; wait for terminal tool evidence.");
5372
+ }
5175
5373
  async function continueTurnUnlocked(directory, goalID, opts) {
5374
+ await reconcileTerminalWorkspaceCalls(directory, host);
5176
5375
  const preState = await readState(directory);
5177
5376
  const goal = preState.goals.find((g) => g.id === goalID);
5178
- if (!goal || goal.status !== "active")
5179
- return;
5180
- if (goal.config.write_scope !== undefined && !host.scopedExecution)
5181
- return;
5182
- {
5183
- const now = Date.now();
5184
- if (goal.scopeClosing || (preState.workspaceCalls ?? []).some((call) => call.goalID === goalID && !isSyncStaleWorkspaceCall(preState, call, now)) || preState.workspaceOperation)
5185
- return;
5186
- }
5377
+ const guard = continuationGuard(preState, goalID);
5378
+ if (guard)
5379
+ return guard;
5380
+ if (!goal)
5381
+ return rejected(goalID, "not_found", "Goal not found.");
5187
5382
  const runtime = preState.runtimes.find((r) => r.goalID === goalID);
5188
5383
  if (!runtime)
5189
- return;
5384
+ return rejected(goalID, "blocked", "Goal runtime is missing; no turn was sent.");
5385
+ if (runtime.activePromptAcceptedAt)
5386
+ await acknowledgeGoalInbox(directory, goalID, runtime.activeInboxLines ?? []);
5190
5387
  if (runtime.phase === "running" && leaseIsValid(runtime))
5191
- return;
5388
+ return rejected(goalID, "blocked", "An existing turn holds the run lease; no additional prompt was sent.");
5192
5389
  let session = sessions.get(goalID);
5193
5390
  if (session && session.workerSessionID !== goal.workerSessionID) {
5194
5391
  sessions.delete(goalID);
@@ -5205,18 +5402,22 @@ function createGoalService(host) {
5205
5402
  if (!session && !goal.workerSessionID)
5206
5403
  session = await ensureWorkerSession(directory, goal);
5207
5404
  if (!session)
5208
- return;
5405
+ return rejected(goalID, "blocked", "Worker session is missing; resume or retry the goal.");
5209
5406
  if (!opts?.force && !await workers.isIdle(session.workerSessionID))
5210
- return;
5407
+ return rejected(goalID, "blocked", "Worker is busy or its status is unknown; no prompt was sent. Retry after confirmed idle.");
5211
5408
  if (goal.pendingIdentity || goal.modelSwitch?.pending || goal.agentSwitch?.pending) {
5212
5409
  if (await host.sessionStatus(session.workerSessionID) !== "idle")
5213
- return;
5410
+ return rejected(goalID, "blocked", "Pending identity requires an idle worker; no prompt was sent. Wait for idle and retry.");
5214
5411
  await applyPendingIdentity(directory, goal);
5215
5412
  await applyPendingModel(directory, goal);
5216
5413
  await applyPendingAgent(directory, goal);
5217
5414
  }
5218
5415
  let acquired = false;
5416
+ let acquisitionFailure;
5219
5417
  const state = await mutateState(directory, `turn.acquire:${goalID}`, async (s) => {
5418
+ acquisitionFailure = continuationGuard(s, goalID);
5419
+ if (acquisitionFailure)
5420
+ return s;
5220
5421
  const g = s.goals.find((item) => item.id === goalID);
5221
5422
  if (!g || g.status !== "active")
5222
5423
  return s;
@@ -5227,7 +5428,7 @@ function createGoalService(host) {
5227
5428
  return s;
5228
5429
  const timeoutMs = g.config.timeoutMs || 300000;
5229
5430
  Object.assign(rt, acquireLease(rt, timeoutMs));
5230
- rt.activeRunID = randomUUID6();
5431
+ rt.activeRunID = randomUUID7();
5231
5432
  rt.activePromptMessageID = newPromptMessageID();
5232
5433
  rt.runCount += 1;
5233
5434
  if (rt.freeRetryPending) {
@@ -5242,70 +5443,97 @@ function createGoalService(host) {
5242
5443
  const freshGoal = state.goals.find((g) => g.id === goalID);
5243
5444
  const freshRuntime = state.runtimes.find((r) => r.goalID === goalID);
5244
5445
  if (!acquired || !freshGoal || freshGoal.status !== "active" || !freshRuntime?.activeRunID)
5245
- return;
5246
- await appendEvent(directory, {
5247
- version: 1,
5248
- eventID: randomUUID6(),
5446
+ return acquisitionFailure ?? rejected(goalID, "blocked", "Run lease acquisition lost to another turn; no prompt was sent.");
5447
+ const sent = {
5448
+ ok: true,
5449
+ outcome: "sent",
5249
5450
  goalID,
5250
- type: "run.started",
5251
- runID: freshRuntime.activeRunID,
5252
- turnCount: freshRuntime.runCount,
5253
- timestamp: new Date().toISOString(),
5254
- revision: state.revision
5255
- });
5256
- if (opts?.bare) {
5257
- const bareWords = await drainGoalInbox(directory, goalID);
5258
- const bareText = bareWords.join(`
5259
- `).trim();
5260
- if (bareText) {
5261
- await dispatchOrRecordFailure(directory, goalID, () => workers.sendBare(session, freshGoal, freshRuntime, bareText), freshRuntime, session.workerSessionID);
5262
- return;
5263
- }
5264
- }
5265
- const inboxMessages = await drainGoalInbox(directory, goalID);
5266
- const allEvents = await readEvents(directory, 200);
5267
- const progressHistory = allEvents.filter((e) => e.goalID === goalID && e.type === "goal.progress").map((e) => ({
5268
- summary: String(e.summary || ""),
5269
- next: e.next ? String(e.next) : undefined,
5270
- at: String(e.timestamp || "")
5271
- }));
5272
- let transcriptTail;
5273
- try {
5274
- transcriptTail = await host.readMessages(freshGoal.workerSessionID, 5);
5275
- } catch {
5276
- transcriptTail = [];
5277
- }
5278
- await accountTailUsage(directory, goalID, freshRuntime, transcriptTail ?? []);
5279
- let verification;
5451
+ workerSessionID: session.workerSessionID,
5452
+ runGeneration: freshRuntime.runGeneration,
5453
+ promptMessageID: freshRuntime.activePromptMessageID,
5454
+ message: `Prompt submitted for "${freshGoal.name}"; worker activity is not yet confirmed.`
5455
+ };
5456
+ let dispatchAttempted = false;
5280
5457
  try {
5281
- const artifactDir = freshGoal.config.artifactDir;
5282
- if (artifactDir) {
5283
- try {
5284
- const files = await fs4.readdir(artifactDir);
5285
- verification = { artifactSummary: files.length ? `${files.length} file(s): ${files.slice(0, 8).join(", ")}` : "no artifacts yet" };
5286
- } catch {
5287
- verification = { artifactSummary: "no artifacts yet" };
5458
+ const inbox = await snapshotGoalInbox(directory, goalID);
5459
+ const inboxMessages = inbox.messages;
5460
+ await mutateState(directory, `turn.inbox-snapshot:${goalID}`, async (s) => {
5461
+ const rt = s.runtimes.find((r) => r.goalID === goalID);
5462
+ if (rt && rt.activeRunID === freshRuntime.activeRunID)
5463
+ rt.activeInboxLines = inbox.lines;
5464
+ return s;
5465
+ });
5466
+ await appendEvent(directory, {
5467
+ version: 1,
5468
+ eventID: randomUUID7(),
5469
+ goalID,
5470
+ type: "run.started",
5471
+ runID: freshRuntime.activeRunID,
5472
+ turnCount: freshRuntime.runCount,
5473
+ timestamp: new Date().toISOString(),
5474
+ revision: state.revision
5475
+ });
5476
+ if (opts?.bare) {
5477
+ const bareText = inboxMessages.join(`
5478
+ `).trim();
5479
+ if (bareText) {
5480
+ dispatchAttempted = true;
5481
+ await dispatchOrRecordFailure(directory, goalID, () => workers.sendBare(session, freshGoal, freshRuntime, bareText), freshRuntime, session.workerSessionID);
5482
+ return sent;
5288
5483
  }
5289
5484
  }
5290
- if (freshGoal.config.checks?.length) {
5291
- const c = `checks configured: ${freshGoal.config.checks.length} \u2014 run them before claiming completion`;
5292
- verification = { ...verification || {}, failedChecks: [c], checksPassed: undefined };
5293
- }
5294
- if (freshRuntime.evaluatorRejectionCount && freshRuntime.evaluatorRejectionCount > 0) {
5295
- verification = { ...verification || {}, evaluatorRejectionCount: freshRuntime.evaluatorRejectionCount };
5296
- }
5297
- if (freshRuntime.lastRejectionDetails) {
5298
- verification = { ...verification || {}, lastRejectionDetails: freshRuntime.lastRejectionDetails };
5485
+ const allEvents = await readEvents(directory, 200);
5486
+ const progressHistory = allEvents.filter((e) => e.goalID === goalID && e.type === "goal.progress").map((e) => ({
5487
+ summary: String(e.summary || ""),
5488
+ next: e.next ? String(e.next) : undefined,
5489
+ at: String(e.timestamp || "")
5490
+ }));
5491
+ let transcriptTail;
5492
+ try {
5493
+ transcriptTail = await host.readMessages(freshGoal.workerSessionID, 5);
5494
+ } catch {
5495
+ transcriptTail = [];
5299
5496
  }
5300
- } catch {}
5301
- const context = {
5302
- inboxMessages: inboxMessages.length > 0 ? inboxMessages : undefined,
5303
- progressHistory: progressHistory.length > 0 ? progressHistory : undefined,
5304
- transcriptTail: transcriptTail && transcriptTail.length > 0 ? transcriptTail : undefined,
5305
- forceFinish: opts?.forceFinish || undefined,
5306
- verification
5307
- };
5308
- await dispatchOrRecordFailure(directory, goalID, () => workers.continueWorker(session, freshGoal, freshRuntime, context), freshRuntime, session.workerSessionID);
5497
+ await accountTailUsage(directory, goalID, freshRuntime, transcriptTail ?? []);
5498
+ let verification;
5499
+ try {
5500
+ const artifactDir = freshGoal.config.artifactDir;
5501
+ if (artifactDir) {
5502
+ try {
5503
+ const files = await fs4.readdir(artifactDir);
5504
+ verification = { artifactSummary: files.length ? `${files.length} file(s): ${files.slice(0, 8).join(", ")}` : "no artifacts yet" };
5505
+ } catch {
5506
+ verification = { artifactSummary: "no artifacts yet" };
5507
+ }
5508
+ }
5509
+ if (freshGoal.config.checks?.length) {
5510
+ const c = `checks configured: ${freshGoal.config.checks.length} \u2014 run them before claiming completion`;
5511
+ verification = { ...verification || {}, failedChecks: [c], checksPassed: undefined };
5512
+ }
5513
+ if (freshRuntime.evaluatorRejectionCount && freshRuntime.evaluatorRejectionCount > 0) {
5514
+ verification = { ...verification || {}, evaluatorRejectionCount: freshRuntime.evaluatorRejectionCount };
5515
+ }
5516
+ if (freshRuntime.lastRejectionDetails) {
5517
+ verification = { ...verification || {}, lastRejectionDetails: freshRuntime.lastRejectionDetails };
5518
+ }
5519
+ } catch {}
5520
+ const context = {
5521
+ inboxMessages: inboxMessages.length > 0 ? inboxMessages : undefined,
5522
+ progressHistory: progressHistory.length > 0 ? progressHistory : undefined,
5523
+ transcriptTail: transcriptTail && transcriptTail.length > 0 ? transcriptTail : undefined,
5524
+ forceFinish: opts?.forceFinish || undefined,
5525
+ verification
5526
+ };
5527
+ dispatchAttempted = true;
5528
+ await dispatchOrRecordFailure(directory, goalID, () => workers.continueWorker(session, freshGoal, freshRuntime, context), { ...freshRuntime, forceFinish: opts?.forceFinish }, session.workerSessionID);
5529
+ return sent;
5530
+ } catch (error) {
5531
+ if (error instanceof GoalDeliveryBlockedError)
5532
+ return error.result;
5533
+ if (!dispatchAttempted)
5534
+ await recordPromptFailure(directory, goalID, error, false, freshRuntime.activeRunID);
5535
+ throw error;
5536
+ }
5309
5537
  }
5310
5538
  async function setInteractiveUnlocked(directory, goalID, interactive) {
5311
5539
  const state = await mutateState(directory, `goal.interactive:${goalID}`, async (s) => {
@@ -5324,7 +5552,7 @@ function createGoalService(host) {
5324
5552
  return { ok: false, message: "Goal not found." };
5325
5553
  await appendEvent(directory, {
5326
5554
  version: 1,
5327
- eventID: randomUUID6(),
5555
+ eventID: randomUUID7(),
5328
5556
  goalID,
5329
5557
  type: "goal.interactive_changed",
5330
5558
  interactive,
@@ -5385,7 +5613,7 @@ function createGoalService(host) {
5385
5613
  await cancelAwaitsForGoal(directory, goalID, "goal paused").catch(() => {});
5386
5614
  await appendEvent(directory, {
5387
5615
  version: 1,
5388
- eventID: randomUUID6(),
5616
+ eventID: randomUUID7(),
5389
5617
  goalID,
5390
5618
  type: "goal.status_changed",
5391
5619
  from: "active",
@@ -5421,6 +5649,7 @@ function createGoalService(host) {
5421
5649
  }
5422
5650
  }
5423
5651
  async function resumeUnlocked(directory, goalID) {
5652
+ await reconcileTerminalWorkspaceCalls(directory, host);
5424
5653
  const preState = await readState(directory);
5425
5654
  const preGoal = preState.goals.find((g) => g.id === goalID);
5426
5655
  const workerLiveness = await probeWorkerLiveness(preGoal?.workerSessionID);
@@ -5435,10 +5664,10 @@ function createGoalService(host) {
5435
5664
  sweepStaleWorkspaceState(state, liveness);
5436
5665
  const rt = state.runtimes.find((r) => r.goalID === goalID);
5437
5666
  const liveCalls = (state.workspaceCalls ?? []).filter((call) => call.goalID === goalID && !isStaleWorkspaceCall(state, call, liveness));
5438
- const blocking = liveCalls.filter((call) => call.generation === undefined || !rt || call.generation >= rt.runGeneration);
5667
+ const blocking = liveCalls;
5439
5668
  if (blocking.length) {
5440
5669
  if (workerLiveness === "unknown") {
5441
- throw new Error(`Worker liveness unknown for session "${goal.workerSessionID}" (probe timed out or unreachable); keeping ${blocking.length} write reservation(s) \u2014 nothing reaped, nothing rotated. ` + `The worker may still be running. Retry resume (a flaky probe recovers on retry); if the worker is gone its quiet reservations reap automatically past the force TTL.`);
5670
+ throw new Error(`Worker liveness unknown for session "${goal.workerSessionID}" (probe timed out or unreachable); keeping ${blocking.length} write reservation(s) \u2014 nothing reaped, nothing rotated. ` + `The worker may still be running; wait for actual tool completion/quiescence. Neither age nor a missing session releases execution fences.`);
5442
5671
  }
5443
5672
  throw new Error("Goal still has closing/in-flight writes; ownership cannot be reacquired before quiescence.");
5444
5673
  }
@@ -5453,7 +5682,6 @@ function createGoalService(host) {
5453
5682
  }
5454
5683
  goal.scopeClosing = false;
5455
5684
  goal.scopeClearPending = false;
5456
- state.workspaceCalls = (state.workspaceCalls ?? []).filter((call) => call.goalID !== goalID);
5457
5685
  assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
5458
5686
  goal.status = "active";
5459
5687
  goal.updatedAt = new Date().toISOString();
@@ -5461,11 +5689,15 @@ function createGoalService(host) {
5461
5689
  return state;
5462
5690
  });
5463
5691
  const goal = state.goals.find((g) => g.id === goalID);
5464
- if (!goal || !resumed)
5465
- return;
5692
+ if (!goal)
5693
+ return rejected(goalID, "not_found", "Goal not found (it may have been cleared). List your goals instead of retrying this ID.");
5694
+ if (!resumed)
5695
+ return rejected(goalID, "invalid_state", `Cannot resume a ${goal.status} goal; no turn was sent.`);
5466
5696
  try {
5467
5697
  await ensureWorkerSession(directory, goal);
5468
5698
  } catch (error) {
5699
+ if (error instanceof GoalDeliveryBlockedError)
5700
+ return error.result;
5469
5701
  await mutateState(directory, `goal.resume-rollback:${goalID}`, async (s) => {
5470
5702
  const g = s.goals.find((item) => item.id === goalID);
5471
5703
  if (g?.status === "active")
@@ -5476,7 +5708,7 @@ function createGoalService(host) {
5476
5708
  }
5477
5709
  await appendEvent(directory, {
5478
5710
  version: 1,
5479
- eventID: randomUUID6(),
5711
+ eventID: randomUUID7(),
5480
5712
  goalID,
5481
5713
  type: "goal.status_changed",
5482
5714
  from: "paused",
@@ -5484,9 +5716,11 @@ function createGoalService(host) {
5484
5716
  timestamp: new Date().toISOString(),
5485
5717
  revision: state.revision
5486
5718
  });
5487
- await continueTurnUnlocked(directory, goalID);
5719
+ const result = await continueTurnUnlocked(directory, goalID);
5720
+ return result.ok ? { ...result, message: `Goal "${goal.name}" resumed; ${result.message}` } : result;
5488
5721
  }
5489
5722
  async function retryUnlocked(directory, goalID) {
5723
+ await reconcileTerminalWorkspaceCalls(directory, host);
5490
5724
  const preState = await readState(directory);
5491
5725
  const workerLiveness = await probeWorkerLiveness(preState.goals.find((g) => g.id === goalID)?.workerSessionID);
5492
5726
  let retried = false;
@@ -5496,11 +5730,10 @@ function createGoalService(host) {
5496
5730
  return state;
5497
5731
  const liveness = { isWorkerLive: (_gid, _wsid) => _gid === goalID ? workerLiveness !== "dead" : false, now: Date.now() };
5498
5732
  sweepStaleWorkspaceState(state, liveness);
5499
- const rt0 = state.runtimes.find((r) => r.goalID === goalID);
5500
- const blocking = (state.workspaceCalls ?? []).filter((call) => call.goalID === goalID && !isStaleWorkspaceCall(state, call, liveness) && (call.generation === undefined || !rt0 || call.generation >= rt0.runGeneration));
5733
+ const blocking = (state.workspaceCalls ?? []).filter((call) => call.goalID === goalID && !isStaleWorkspaceCall(state, call, liveness));
5501
5734
  if (blocking.length) {
5502
5735
  if (workerLiveness === "unknown") {
5503
- throw new Error(`Worker liveness unknown for session "${goal.workerSessionID}" (probe timed out or unreachable); keeping ${blocking.length} write reservation(s) \u2014 nothing reaped, nothing rotated. ` + `The worker may still be running. Retry (a flaky probe recovers on retry); if the worker is gone its quiet reservations reap automatically past the force TTL.`);
5736
+ throw new Error(`Worker liveness unknown for session "${goal.workerSessionID}" (probe timed out or unreachable); keeping ${blocking.length} write reservation(s) \u2014 nothing reaped, nothing rotated. ` + `The worker may still be running; wait for actual tool completion/quiescence. Neither age nor a missing session releases execution fences.`);
5504
5737
  }
5505
5738
  throw new Error("Goal still has closing/in-flight writes; ownership cannot be reacquired before quiescence.");
5506
5739
  }
@@ -5538,11 +5771,15 @@ function createGoalService(host) {
5538
5771
  return state;
5539
5772
  });
5540
5773
  const goal = state.goals.find((g) => g.id === goalID);
5541
- if (!goal || !retried)
5542
- return;
5774
+ if (!goal)
5775
+ return rejected(goalID, "not_found", "Goal not found (it may have been cleared). List your goals instead of retrying this ID.");
5776
+ if (!retried)
5777
+ return rejected(goalID, "invalid_state", `Cannot retry a ${goal.status} goal; no turn was sent.`);
5543
5778
  try {
5544
5779
  await ensureWorkerSession(directory, goal);
5545
5780
  } catch (error) {
5781
+ if (error instanceof GoalDeliveryBlockedError)
5782
+ return error.result;
5546
5783
  await mutateState(directory, `goal.retry-rollback:${goalID}`, async (s) => {
5547
5784
  const g = s.goals.find((item) => item.id === goalID);
5548
5785
  if (g?.status === "active")
@@ -5553,7 +5790,7 @@ function createGoalService(host) {
5553
5790
  }
5554
5791
  await appendEvent(directory, {
5555
5792
  version: 1,
5556
- eventID: randomUUID6(),
5793
+ eventID: randomUUID7(),
5557
5794
  goalID,
5558
5795
  type: "goal.status_changed",
5559
5796
  from: "blocked",
@@ -5561,7 +5798,7 @@ function createGoalService(host) {
5561
5798
  timestamp: new Date().toISOString(),
5562
5799
  revision: state.revision
5563
5800
  });
5564
- await continueTurnUnlocked(directory, goalID);
5801
+ return continueTurnUnlocked(directory, goalID);
5565
5802
  }
5566
5803
  async function clearUnlocked(directory, goalID) {
5567
5804
  const pre = await readState(directory);
@@ -5570,12 +5807,10 @@ function createGoalService(host) {
5570
5807
  const g = s.goals.find((item) => item.id === goalID);
5571
5808
  if (g)
5572
5809
  retireWorker(s, g);
5573
- s.workspaceCalls = (s.workspaceCalls ?? []).filter((call) => call.goalID !== goalID);
5574
- if (s.workspaceOperation?.goalID === goalID)
5575
- s.workspaceOperation = undefined;
5576
5810
  s.goals = s.goals.filter((g) => g.id !== goalID);
5577
5811
  s.runtimes = s.runtimes.filter((r) => r.goalID !== goalID);
5578
5812
  s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.goalID !== goalID);
5813
+ await deleteGoalInbox(directory, goalID);
5579
5814
  return s;
5580
5815
  });
5581
5816
  sessions.delete(goalID);
@@ -5587,7 +5822,7 @@ function createGoalService(host) {
5587
5822
  await cancelAwaitsForGoal(directory, goalID, "goal cleared").catch(() => {});
5588
5823
  await appendEvent(directory, {
5589
5824
  version: 1,
5590
- eventID: randomUUID6(),
5825
+ eventID: randomUUID7(),
5591
5826
  goalID,
5592
5827
  type: "goal.cleared",
5593
5828
  timestamp: new Date().toISOString(),
@@ -5602,6 +5837,7 @@ function createGoalService(host) {
5602
5837
  return new Map(sessions);
5603
5838
  }
5604
5839
  async function reconcile(directory) {
5840
+ await reconcileTerminalWorkspaceCalls(directory, host);
5605
5841
  try {
5606
5842
  let summary = { reapedCalls: [], resetClosing: [], finalizedClears: [], forceCleared: [] };
5607
5843
  const pre = await readState(directory);
@@ -5710,22 +5946,39 @@ function createGoalService(host) {
5710
5946
  }
5711
5947
  }
5712
5948
  async function nudgeUnlocked(directory, goalID) {
5949
+ await reconcileTerminalWorkspaceCalls(directory, host);
5713
5950
  const preState = await readState(directory);
5714
5951
  const goal = preState.goals.find((g) => g.id === goalID);
5715
5952
  if (!goal)
5716
- return { ok: false, message: "Goal not found." };
5953
+ return rejected(goalID, "not_found", "Goal not found (it may have been cleared). List your goals instead of retrying this ID.");
5717
5954
  if (goal.status !== "active") {
5718
- return { ok: false, message: `Goal is ${goal.status}; resume or retry it before nudging.` };
5955
+ return rejected(goalID, "invalid_state", `Goal is ${goal.status}; resume or retry it before nudging.`);
5719
5956
  }
5720
5957
  const quotaHold = quotaHoldMessage(goal);
5721
5958
  if (quotaHold)
5722
- return { ok: false, message: quotaHold };
5959
+ return rejected(goalID, "blocked", quotaHold);
5960
+ const guard = continuationGuard(preState, goalID);
5961
+ if (guard)
5962
+ return guard;
5963
+ if (goal.workerSessionID) {
5964
+ const status = await host.sessionStatus(goal.workerSessionID);
5965
+ if (status === "busy")
5966
+ return rejected(goalID, "blocked", "Worker is busy; no prompt was sent and its active turn was left intact. Retry after idle.");
5967
+ if (status !== "idle" && (goal.pendingIdentity || goal.modelSwitch?.pending || goal.agentSwitch?.pending)) {
5968
+ return rejected(goalID, "blocked", "Pending identity requires confirmed idle; worker status is unknown. No prompt was sent.");
5969
+ }
5970
+ if (status !== "idle")
5971
+ return rejected(goalID, "blocked", "Worker status is unknown or retrying; an outstanding prompt may already be accepted. Nothing was sent or reset. Retry after confirmed idle.");
5972
+ }
5723
5973
  if (goal.config.write_scope !== undefined) {
5724
5974
  await pauseUnlocked(directory, goalID);
5725
- await resumeUnlocked(directory, goalID);
5726
- return { ok: true, message: `Re-prompted protected worker for "${goal.name}" in a freshly fenced session.` };
5975
+ return resumeUnlocked(directory, goalID);
5727
5976
  }
5977
+ let resetFailure;
5728
5978
  const cleared = await mutateState(directory, `goal.nudge:${goalID}`, async (s) => {
5979
+ resetFailure = continuationGuard(s, goalID);
5980
+ if (resetFailure)
5981
+ return s;
5729
5982
  const rt = s.runtimes.find((r) => r.goalID === goalID);
5730
5983
  if (!rt)
5731
5984
  return s;
@@ -5737,30 +5990,46 @@ function createGoalService(host) {
5737
5990
  rt.updatedAt = new Date().toISOString();
5738
5991
  return s;
5739
5992
  });
5993
+ if (resetFailure)
5994
+ return resetFailure;
5740
5995
  const freshGoal = cleared.goals.find((g) => g.id === goalID);
5741
5996
  if (!freshGoal || !freshGoal.workerSessionID) {
5742
- return { ok: false, message: "Goal has no worker session. Use resume_goal or retry_goal." };
5997
+ return rejected(goalID, "blocked", "Goal has no worker session. Use resume_goal or retry_goal.");
5743
5998
  }
5744
- await continueTurnUnlocked(directory, goalID, { force: true });
5745
- return { ok: true, message: `Re-prompted worker for "${freshGoal.name}".` };
5999
+ return continueTurnUnlocked(directory, goalID, { force: true });
5746
6000
  }
5747
6001
  async function sendUnlocked(directory, goalID, text) {
5748
6002
  const trimmed = text.trim();
5749
6003
  if (!trimmed)
5750
- return { ok: false, message: "Nothing to send." };
6004
+ return rejected(goalID, "invalid_state", "Nothing to send.");
6005
+ await reconcileTerminalWorkspaceCalls(directory, host);
5751
6006
  const preState = await readState(directory);
5752
6007
  const goal = preState.goals.find((g) => g.id === goalID);
5753
6008
  if (!goal)
5754
- return { ok: false, message: "Goal not found." };
5755
- await appendGoalInbox(directory, goalID, "user", trimmed);
6009
+ return rejected(goalID, "not_found", "Goal not found (it may have been cleared). List your goals instead of retrying this ID.");
6010
+ if (!await appendGoalInbox(directory, goalID, "user", trimmed))
6011
+ return rejected(goalID, "not_found", "Goal cleared before words could be queued. List current goals instead of retrying this ID.");
5756
6012
  if (goal.status !== "active") {
5757
- return { ok: true, message: `Queued for "${goal.name}" (goal is ${goal.status}; delivers on the next active turn).` };
6013
+ return { ok: true, outcome: "queued", goalID, message: `Queued for "${goal.name}" (goal is ${goal.status}; delivers on the next active turn).` };
6014
+ }
6015
+ const guard = continuationGuard(preState, goalID);
6016
+ if (guard)
6017
+ return { ok: true, outcome: "queued", goalID, message: `Saved in inbox for "${goal.name}". ${guard.message}` };
6018
+ const status = goal.workerSessionID ? await host.sessionStatus(goal.workerSessionID) : undefined;
6019
+ if (!(await readState(directory)).goals.some((item) => item.id === goalID))
6020
+ return rejected(goalID, "not_found", "Goal cleared during worker status probe; no prompt was sent.");
6021
+ if (goal.workerSessionID && status !== "idle") {
6022
+ return { ok: true, outcome: "queued", goalID, message: `Saved in inbox for "${goal.name}"; worker is busy or unconfirmed. Its active turn was left intact.` };
5758
6023
  }
5759
6024
  if (goal.config.write_scope !== undefined) {
5760
- await continueTurnUnlocked(directory, goalID, { bare: true });
5761
- return { ok: true, message: `Queued for "${goal.name}"; protected workers receive it on the next quiescent turn.` };
6025
+ const result = await continueTurnUnlocked(directory, goalID, { bare: true });
6026
+ return result.ok || result.outcome === "not_found" ? result : { ok: true, outcome: "queued", goalID, message: `Saved in inbox for "${goal.name}". ${result.message}` };
5762
6027
  }
6028
+ let resetFailure;
5763
6029
  const cleared = await mutateState(directory, `goal.send:${goalID}`, async (s) => {
6030
+ resetFailure = continuationGuard(s, goalID);
6031
+ if (resetFailure)
6032
+ return s;
5764
6033
  const rt = s.runtimes.find((r) => r.goalID === goalID);
5765
6034
  if (!rt)
5766
6035
  return s;
@@ -5772,12 +6041,14 @@ function createGoalService(host) {
5772
6041
  rt.updatedAt = new Date().toISOString();
5773
6042
  return s;
5774
6043
  });
6044
+ if (resetFailure)
6045
+ return resetFailure.outcome === "not_found" ? resetFailure : { ok: true, outcome: "queued", goalID, message: `Words saved in inbox. ${resetFailure.message}` };
5775
6046
  const freshGoal = cleared.goals.find((g) => g.id === goalID);
5776
6047
  if (!freshGoal || !freshGoal.workerSessionID) {
5777
- return { ok: true, message: `Queued for "${goal.name}" (no worker session yet; delivers on the next turn).` };
6048
+ return { ok: true, outcome: "queued", goalID, message: `Queued for "${goal.name}" (no worker session yet; delivers on the next turn).` };
5778
6049
  }
5779
- await continueTurnUnlocked(directory, goalID, { force: true, bare: true });
5780
- return { ok: true, message: `Sent to "${freshGoal.name}" as its own turn.` };
6050
+ const result = await continueTurnUnlocked(directory, goalID, { force: true, bare: true });
6051
+ return result.ok || result.outcome === "not_found" ? result : { ok: true, outcome: "queued", goalID, message: `Saved in inbox for "${goal.name}". ${result.message}` };
5781
6052
  }
5782
6053
  function sendUserMessage(directory, goalID, text) {
5783
6054
  return withGoalOperation(goalID, () => sendUnlocked(directory, goalID, text));
@@ -5851,8 +6122,9 @@ function createGoalService(host) {
5851
6122
 
5852
6123
  // src/application/schedule-worker.ts
5853
6124
  init_state_repository();
5854
- import { randomUUID as randomUUID7 } from "crypto";
6125
+ init_server_log();
5855
6126
  init_workspace_execution();
6127
+ import { randomUUID as randomUUID8 } from "crypto";
5856
6128
  function createScheduleWorker(options) {
5857
6129
  const { directory, goalService } = options;
5858
6130
  const intervalMs = options.intervalMs ?? 5000;
@@ -5943,7 +6215,7 @@ function createScheduleWorker(options) {
5943
6215
  continue;
5944
6216
  await appendEvent(directory, {
5945
6217
  version: 1,
5946
- eventID: randomUUID7(),
6218
+ eventID: randomUUID8(),
5947
6219
  goalID: goal.id,
5948
6220
  type: "schedule.tick",
5949
6221
  scheduleRunCount: count,
@@ -5969,7 +6241,7 @@ function createScheduleWorker(options) {
5969
6241
  }
5970
6242
 
5971
6243
  // src/v2/native-server.ts
5972
- import { randomUUID as randomUUID8 } from "crypto";
6244
+ import { randomUUID as randomUUID9 } from "crypto";
5973
6245
 
5974
6246
  // src/v2/native-bridge.ts
5975
6247
  class NativeBridgeError extends Error {
@@ -6111,7 +6383,7 @@ async function setupNativeServer(context) {
6111
6383
  bridge,
6112
6384
  requestWorker: async (input) => {
6113
6385
  const request = {
6114
- requestID: `req_${randomUUID8()}`,
6386
+ requestID: `req_${randomUUID9()}`,
6115
6387
  goalID: input.goalID ?? "",
6116
6388
  parentSessionID: input.parentSessionID,
6117
6389
  title: input.title,
@@ -6380,7 +6652,7 @@ function createCommandHost(opts) {
6380
6652
  }
6381
6653
 
6382
6654
  // src/application/command-service.ts
6383
- import { randomUUID as randomUUID9 } from "crypto";
6655
+ import { randomUUID as randomUUID10 } from "crypto";
6384
6656
  import { promises as fs5 } from "fs";
6385
6657
  import path5 from "path";
6386
6658
 
@@ -6636,6 +6908,7 @@ function validateCommandStreamMessage(value) {
6636
6908
  }
6637
6909
 
6638
6910
  // src/application/command-service.ts
6911
+ init_server_log();
6639
6912
  init_workspace_execution();
6640
6913
  init_command_await2();
6641
6914
  init_state_repository();
@@ -6745,7 +7018,7 @@ function createCommandService(host, opts) {
6745
7018
  function watchLedger(directory, commandID, type, extra) {
6746
7019
  return appendEvent(directory, {
6747
7020
  version: 1,
6748
- eventID: randomUUID9(),
7021
+ eventID: randomUUID10(),
6749
7022
  commandID,
6750
7023
  type,
6751
7024
  timestamp: new Date().toISOString(),
@@ -7222,7 +7495,7 @@ function createCommandService(host, opts) {
7222
7495
  }).catch(() => {});
7223
7496
  await appendEvent(directory, {
7224
7497
  version: 1,
7225
- eventID: randomUUID9(),
7498
+ eventID: randomUUID10(),
7226
7499
  commandID: id,
7227
7500
  type: "command.exited",
7228
7501
  exitCode: info.exitCode,
@@ -7267,7 +7540,7 @@ function createCommandService(host, opts) {
7267
7540
  watchState = initialWatchState();
7268
7541
  }
7269
7542
  const startedAt = new Date;
7270
- const id = randomUUID9();
7543
+ const id = randomUUID10();
7271
7544
  const cwd = input.cwd || directory;
7272
7545
  const useShell = input.shell === true;
7273
7546
  const spawnOpts = useShell ? { command: "/bin/sh", args: ["-c", shellJoin(command, input.args ?? [])], cwd, cols: input.cols, rows: input.rows, env: input.env } : { command, args: input.args ?? [], cwd, cols: input.cols, rows: input.rows, env: input.env };
@@ -7333,7 +7606,7 @@ function createCommandService(host, opts) {
7333
7606
  rememberDir(id, directory);
7334
7607
  await appendEvent(directory, {
7335
7608
  version: 1,
7336
- eventID: randomUUID9(),
7609
+ eventID: randomUUID10(),
7337
7610
  ...input.goalID ? { goalID: input.goalID } : {},
7338
7611
  commandID: id,
7339
7612
  type: "command.started",
@@ -7433,7 +7706,7 @@ function createCommandService(host, opts) {
7433
7706
  return { session, text: file.text, totalBytes: session.outputBytes, startByte: file.startByte, live: session.status === "running" };
7434
7707
  },
7435
7708
  async write(directory, id, ownerSessionID, data) {
7436
- const scopeCallID = `command-write:${randomUUID9()}`;
7709
+ const scopeCallID = `command-write:${randomUUID10()}`;
7437
7710
  await beforeWorkspaceTool(directory, { tool: "loopd_command_write", sessionID: ownerSessionID, callID: scopeCallID }, {});
7438
7711
  try {
7439
7712
  const session = await service.get(directory, id, ownerSessionID);
@@ -7889,7 +8162,7 @@ function createCommandService(host, opts) {
7889
8162
  }
7890
8163
 
7891
8164
  // src/application/command-event-broker.ts
7892
- import { randomUUID as randomUUID10 } from "crypto";
8165
+ import { randomUUID as randomUUID11 } from "crypto";
7893
8166
  function safeDeliver(sink, msg) {
7894
8167
  try {
7895
8168
  sink(msg);
@@ -7932,7 +8205,7 @@ function createCommandEventBroker(resolver) {
7932
8205
  throw new Error("ownerSessionID is required.");
7933
8206
  if (typeof sink !== "function")
7934
8207
  throw new Error("sink must be a function.");
7935
- const sinkID = randomUUID10();
8208
+ const sinkID = randomUUID11();
7936
8209
  const entry = { sinkID, sink, state: "subscribing", buffer: [] };
7937
8210
  entriesFor(commandID).set(sinkID, entry);
7938
8211
  try {
@@ -8021,9 +8294,10 @@ function createCommandEventBroker(resolver) {
8021
8294
  }
8022
8295
 
8023
8296
  // src/server/command-stream-server.ts
8024
- import { randomBytes, randomUUID as randomUUID11, timingSafeEqual } from "crypto";
8297
+ import { randomBytes, randomUUID as randomUUID12, timingSafeEqual } from "crypto";
8025
8298
  import { promises as fs6 } from "fs";
8026
8299
  import path6 from "path";
8300
+ init_server_log();
8027
8301
  function streamEndpointPath(directory) {
8028
8302
  return path6.join(directory, ".opencode", "loopd", "commands", ".stream-endpoint.json");
8029
8303
  }
@@ -8055,7 +8329,7 @@ var MAX_BUFFERED_BYTES = 512 * 1024;
8055
8329
  function createCommandStreamServer(directory, commandService, broker) {
8056
8330
  const endpointPath = streamEndpointPath(directory);
8057
8331
  const token = randomBytes(32).toString("hex");
8058
- const generation = randomUUID11();
8332
+ const generation = randomUUID12();
8059
8333
  const startedAt = new Date().toISOString();
8060
8334
  let server;
8061
8335
  let serverURL;
@@ -8375,7 +8649,7 @@ function createCommandStreamServer(directory, commandService, broker) {
8375
8649
 
8376
8650
  // src/server/goal-tools.ts
8377
8651
  init_state_repository();
8378
- import { randomUUID as randomUUID12 } from "crypto";
8652
+ import { randomUUID as randomUUID13 } from "crypto";
8379
8653
  import { tool } from "@opencode-ai/plugin/tool";
8380
8654
  // src/domain/verification.ts
8381
8655
  var MAX_RECENT_ATTEMPTS = 10;
@@ -8390,6 +8664,7 @@ function appendVerificationAttempt(recent, attempt) {
8390
8664
  // src/server/goal-tools.ts
8391
8665
  import { exec as execChild } from "child_process";
8392
8666
  import { promisify } from "util";
8667
+ init_server_log();
8393
8668
 
8394
8669
  // src/application/goal-scope.ts
8395
8670
  init_state_repository();
@@ -8703,7 +8978,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
8703
8978
  state.revision = (await persistWorkerMutation(dir, goal, runtime, ["active"])).revision;
8704
8979
  const event = {
8705
8980
  version: 1,
8706
- eventID: randomUUID12(),
8981
+ eventID: randomUUID13(),
8707
8982
  goalID: goal.id,
8708
8983
  type: "goal.progress",
8709
8984
  summary: args.summary,
@@ -8768,7 +9043,7 @@ Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
8768
9043
  Working directory: ${cwd}
8769
9044
 
8770
9045
  ${failureDetails}`;
8771
- attemptID = randomUUID12();
9046
+ attemptID = randomUUID13();
8772
9047
  const verificationAttempt = {
8773
9048
  id: attemptID,
8774
9049
  sequence: runtime.evaluatorRejectionCount,
@@ -8813,7 +9088,7 @@ ${failureDetails.slice(0, 500)}`,
8813
9088
  if (attemptID) {
8814
9089
  await appendEvent(dir, {
8815
9090
  version: 1,
8816
- eventID: randomUUID12(),
9091
+ eventID: randomUUID13(),
8817
9092
  goalID: goal.id,
8818
9093
  type: "goal.completion_rejected",
8819
9094
  attemptID,
@@ -8831,7 +9106,7 @@ ${failureDetails.slice(0, 500)}`,
8831
9106
  if (blocked && rejectedGoal?.blocker) {
8832
9107
  await appendEvent(dir, {
8833
9108
  version: 1,
8834
- eventID: randomUUID12(),
9109
+ eventID: randomUUID13(),
8835
9110
  goalID: goal.id,
8836
9111
  type: "goal.blocked",
8837
9112
  reason: rejectedGoal.blocker.reason,
@@ -8891,7 +9166,7 @@ ${failureDetails.slice(0, 500)}`,
8891
9166
  }
8892
9167
  runtime.updatedAt = new Date().toISOString();
8893
9168
  }
8894
- const attemptID = randomUUID12();
9169
+ const attemptID = randomUUID13();
8895
9170
  const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
8896
9171
  const checks = (goal.config.checks || []).map((cmd) => ({
8897
9172
  command: cmd,
@@ -8915,7 +9190,7 @@ ${failureDetails.slice(0, 500)}`,
8915
9190
  state.revision = (await persistWorkerMutation(dir, goal, runtime, ["active", "budget_limited"])).revision;
8916
9191
  const event = {
8917
9192
  version: 1,
8918
- eventID: randomUUID12(),
9193
+ eventID: randomUUID13(),
8919
9194
  goalID: goal.id,
8920
9195
  type: "goal.completed",
8921
9196
  summary: args.summary,
@@ -8949,6 +9224,16 @@ ${failureDetails.slice(0, 500)}`,
8949
9224
  if (!goal) {
8950
9225
  return { title: "No goal", output: "No active goal to block." };
8951
9226
  }
9227
+ if (goal.status === "blocked") {
9228
+ return { title: "Goal already blocked", output: JSON.stringify({
9229
+ goalID: goal.id,
9230
+ goalName: goal.name,
9231
+ status: "blocked",
9232
+ alreadyBlocked: true,
9233
+ reason: goal.blocker?.reason,
9234
+ needed: goal.blocker?.needed
9235
+ }) };
9236
+ }
8952
9237
  if (!canTransition(goal.status, "blocked", "model")) {
8953
9238
  return { title: "Invalid transition", output: `Cannot block goal in ${goal.status} state.` };
8954
9239
  }
@@ -8975,7 +9260,7 @@ ${failureDetails.slice(0, 500)}`,
8975
9260
  state.revision = (await persistWorkerMutation(dir, goal, runtime, ["active", "budget_limited"])).revision;
8976
9261
  const event = {
8977
9262
  version: 1,
8978
- eventID: randomUUID12(),
9263
+ eventID: randomUUID13(),
8979
9264
  goalID: goal.id,
8980
9265
  type: "goal.blocked",
8981
9266
  reason: args.reason,
@@ -9029,6 +9314,9 @@ async function withParentIdentity(defaults, host, ownerSessionID) {
9029
9314
  function findGoalByWorkerSession(state, sessionID) {
9030
9315
  if (!sessionID)
9031
9316
  return;
9317
+ if (state.retiredWorkerSessions?.includes(sessionID)) {
9318
+ throw new Error("Goal cleared or worker retired. This session has no goal authority; stop work and ask the owner to list their current goals. Do not resume a remembered ID.");
9319
+ }
9032
9320
  return state.goals.find((g) => g.workerSessionID === sessionID && (g.status === "active" || g.status === "blocked"));
9033
9321
  }
9034
9322
  function formatGoalStructured(goal, runtime, enforcedScopedExecution = false) {
@@ -9156,7 +9444,7 @@ var GOAL_STATUS_META = {
9156
9444
  var PHASE_META = {
9157
9445
  idle: { short: "Idle", hint: "between turns" },
9158
9446
  queued: { short: "Queued", hint: "waiting to start" },
9159
- running: { short: "Running", hint: "worker acting now" },
9447
+ running: { short: "Reserved", hint: "turn reserved; activity unconfirmed" },
9160
9448
  compacting: { short: "Compacting", hint: "summarizing context" },
9161
9449
  waiting_retry: { short: "Retrying", hint: "backing off" },
9162
9450
  stopping: { short: "Stopping", hint: "abort in flight" }
@@ -9167,9 +9455,30 @@ function goalStatusLabel(status) {
9167
9455
  function phaseLabel(phase) {
9168
9456
  return PHASE_META[phase] ?? { short: phase, hint: "" };
9169
9457
  }
9170
- function describeGoalState(status, phase) {
9458
+ function workerActivityLabel(runtime, now = Date.now()) {
9459
+ if (runtime.phase !== "running")
9460
+ return { ...phaseLabel(runtime.phase), acting: false };
9461
+ const expires = runtime.leaseExpiresAt ? Date.parse(runtime.leaseExpiresAt) : NaN;
9462
+ if (Number.isFinite(expires) && expires <= now)
9463
+ return { short: "Stale", hint: "lease expired; activity unconfirmed (not proof of worker exit)", acting: false };
9464
+ if (!Number.isFinite(expires) || (runtime.unknownStatusCount ?? 0) > 0)
9465
+ return { short: "Unconfirmed", hint: "worker status unavailable; do not assume the prompt failed", acting: false };
9466
+ if (runtime.activeAssistantCompletedAt)
9467
+ return { short: "Finishing", hint: "response completed; waiting for turn reconciliation", acting: false };
9468
+ if (runtime.activePromptObservedAt && runtime.lastWorkerStatus !== "idle" && runtime.lastWorkerStatus !== "retry" && (runtime.activeAssistantMessageID || runtime.activeToolCallIDs?.length)) {
9469
+ return { short: "Running", hint: "correlated assistant or tool activity observed", acting: true };
9470
+ }
9471
+ if (runtime.activePromptObservedAt)
9472
+ return { short: "Prompt seen", hint: "prompt observed; response activity not confirmed", acting: false };
9473
+ if (runtime.activePromptAcceptedAt)
9474
+ return { short: "Awaiting", hint: "host accepted prompt; transcript activity not confirmed", acting: false };
9475
+ if (runtime.activePromptDispatchAt)
9476
+ return { short: "Sending", hint: "dispatch requested; awaiting host acceptance", acting: false };
9477
+ return { short: "Requested", hint: "run reserved; no dispatch evidence yet", acting: false };
9478
+ }
9479
+ function describeGoalState(status, phase, now = Date.now()) {
9171
9480
  const goal = goalStatusLabel(status);
9172
- const activity = phase ? phaseLabel(phase) : undefined;
9481
+ const activity = phase ? typeof phase === "string" ? phaseLabel(phase) : workerActivityLabel(phase, now) : undefined;
9173
9482
  const workerClause = activity ? `worker is ${activity.hint || activity.short.toLowerCase()}` : "worker state unknown";
9174
9483
  if (status === "active")
9175
9484
  return `Loopd owns this; ${workerClause}.`;
@@ -9514,18 +9823,21 @@ function ownerTools2(options) {
9514
9823
  execute: async (args, context) => {
9515
9824
  const state = await readState(directory);
9516
9825
  const ownerID = context?.sessionID;
9517
- 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");
9826
+ const goal = args.goal_id ? state.goals.find((g) => g.id === args.goal_id) : state.goals.find((g) => g.ownerSessionID === ownerID && g.status !== "complete");
9518
9827
  if (!goal) {
9519
9828
  return {
9520
9829
  title: "No goal found",
9521
- output: JSON.stringify({ ok: false, message: "No matching active goal for this session." })
9830
+ output: JSON.stringify({ ok: false, outcome: "not_found", message: args.goal_id ? "Goal not found (it may have been cleared). List your goals instead of retrying this ID." : "No matching active goal for this session." })
9522
9831
  };
9523
9832
  }
9833
+ if (goal.ownerSessionID !== ownerID) {
9834
+ return { title: "Not authorized", output: JSON.stringify({ ok: false, outcome: "unauthorized", message: "This session cannot control that goal." }) };
9835
+ }
9524
9836
  const result = await goalService.sendUserMessage(directory, goal.id, args.message);
9525
9837
  return {
9526
- title: result.ok ? "Message sent" : "Send failed",
9838
+ title: result.ok ? result.outcome === "queued" ? "Message queued" : "Message submitted" : "Send failed",
9527
9839
  output: JSON.stringify({
9528
- ok: result.ok,
9840
+ ...result,
9529
9841
  goalID: goal.id,
9530
9842
  goalName: goal.name,
9531
9843
  message: result.message
@@ -9585,16 +9897,20 @@ function ownerTools2(options) {
9585
9897
  execute: async (args, context) => {
9586
9898
  const state = await readState(directory);
9587
9899
  const ownerID = context?.sessionID;
9588
- 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 === "paused" || g.status === "blocked"));
9900
+ const goal = args.goal_id ? state.goals.find((g) => g.id === args.goal_id) : state.goals.find((g) => g.ownerSessionID === ownerID && (g.status === "paused" || g.status === "blocked"));
9589
9901
  if (!goal) {
9590
9902
  return {
9591
9903
  title: "No goal found",
9592
9904
  output: JSON.stringify({
9593
9905
  ok: false,
9594
- message: "No matching paused/blocked goal for this session."
9906
+ outcome: "not_found",
9907
+ message: args.goal_id ? "Goal not found (it may have been cleared). List your goals instead of retrying this ID." : "No matching paused/blocked goal for this session."
9595
9908
  })
9596
9909
  };
9597
9910
  }
9911
+ if (goal.ownerSessionID !== ownerID) {
9912
+ return { title: "Not authorized", output: JSON.stringify({ ok: false, outcome: "unauthorized", message: "This session cannot control that goal." }) };
9913
+ }
9598
9914
  if (goal.status === "active") {
9599
9915
  return {
9600
9916
  title: "Already active",
@@ -9602,18 +9918,13 @@ function ownerTools2(options) {
9602
9918
  };
9603
9919
  }
9604
9920
  try {
9605
- if (goal.status === "paused") {
9606
- await goalService.resume(directory, goal.id);
9607
- } else if (goal.status === "blocked") {
9608
- await goalService.retry(directory, goal.id);
9609
- }
9921
+ const result = goal.status === "blocked" ? await goalService.retry(directory, goal.id) : await goalService.resume(directory, goal.id);
9610
9922
  return {
9611
- title: "Goal resumed",
9923
+ title: result.ok ? "Resume submitted" : "Resume not delivered",
9612
9924
  output: JSON.stringify({
9613
- ok: true,
9925
+ ...result,
9614
9926
  goalID: goal.id,
9615
- goalName: goal.name,
9616
- message: `Goal "${goal.name}" resumed.`
9927
+ goalName: goal.name
9617
9928
  })
9618
9929
  };
9619
9930
  } catch (error) {
@@ -9629,20 +9940,23 @@ function ownerTools2(options) {
9629
9940
  }
9630
9941
  }),
9631
9942
  nudge_goal: tool2({
9632
- 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.",
9943
+ description: "Request a recovery turn for an active goal after confirmed worker idle. Returns the actual delivery outcome or actionable blocker (in-flight writes, workspace operation, busy/unknown worker, pending identity, quota). Never interrupts a live worker or duplicates an unconfirmed prompt. Per-goal mutex.",
9633
9944
  args: {
9634
9945
  goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to nudge the first active goal.")
9635
9946
  },
9636
9947
  execute: async (args, context) => {
9637
9948
  const state = await readState(directory);
9638
9949
  const ownerID = context?.sessionID;
9639
- 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");
9950
+ const goal = args.goal_id ? state.goals.find((g) => g.id === args.goal_id) : state.goals.find((g) => g.ownerSessionID === ownerID && g.status !== "complete");
9640
9951
  if (!goal) {
9641
9952
  return {
9642
9953
  title: "No goal found",
9643
- output: JSON.stringify({ ok: false, message: "No matching active goal for this session." })
9954
+ output: JSON.stringify({ ok: false, outcome: "not_found", message: args.goal_id ? "Goal not found (it may have been cleared). List your goals instead of retrying this ID." : "No matching active goal for this session." })
9644
9955
  };
9645
9956
  }
9957
+ if (goal.ownerSessionID !== ownerID) {
9958
+ return { title: "Not authorized", output: JSON.stringify({ ok: false, outcome: "unauthorized", message: "This session cannot control that goal." }) };
9959
+ }
9646
9960
  try {
9647
9961
  const result = await goalService.nudge(directory, goal.id);
9648
9962
  return {
@@ -10144,9 +10458,10 @@ function commandTools(options) {
10144
10458
  }
10145
10459
 
10146
10460
  // src/server/plugin.ts
10461
+ init_server_log();
10147
10462
  init_state_repository();
10148
10463
  // package.json
10149
- var version = "1.13.0";
10464
+ var version = "1.13.2";
10150
10465
 
10151
10466
  // src/server/plugin.ts
10152
10467
  init_workspace_execution();
@@ -10255,6 +10570,8 @@ function createServerHooks(directory, host, defaults) {
10255
10570
  tool: { ...goalTools(directory, goalService, undefined, defaults, host), ...ownerTools2({ directory, host, goalService }), ...commandTools({ directory, commandService, capabilities: commandHost.capabilities, goalService }) },
10256
10571
  "tool.execute.before": async (input, _output) => {
10257
10572
  await beforeWorkspaceTool(directory, input, _output.args);
10573
+ if (isWorkspaceToolExempt(input.tool))
10574
+ return;
10258
10575
  const activeWorkers = goalService.getActiveWorkers();
10259
10576
  let matchedGoalID;
10260
10577
  for (const [goalID, worker] of activeWorkers) {
@@ -10275,7 +10592,7 @@ function createServerHooks(directory, host, defaults) {
10275
10592
  } catch {}
10276
10593
  },
10277
10594
  "tool.execute.after": async (input, output) => {
10278
- await afterWorkspaceTool(directory, input.sessionID, input.callID);
10595
+ await afterWorkspaceTool(directory, input.sessionID, input.callID, input.tool);
10279
10596
  if (input.tool === "loopd_create_goal" || input.tool === "get_goal" || input.tool === "report_goal_progress") {
10280
10597
  ensureStarted();
10281
10598
  reconcileInBackground();
@@ -10283,7 +10600,7 @@ function createServerHooks(directory, host, defaults) {
10283
10600
  const activeWorkers = goalService.getActiveWorkers();
10284
10601
  let matchedGoalID;
10285
10602
  for (const [goalID, worker] of activeWorkers) {
10286
- if (worker.workerSessionID === input.sessionID) {
10603
+ if (!isWorkspaceToolExempt(input.tool) && worker.workerSessionID === input.sessionID) {
10287
10604
  matchedGoalID = goalID;
10288
10605
  break;
10289
10606
  }
@@ -10304,6 +10621,8 @@ function createServerHooks(directory, host, defaults) {
10304
10621
  if (!raw)
10305
10622
  return;
10306
10623
  const parsed = JSON.parse(raw);
10624
+ if (parsed.alreadyBlocked === true)
10625
+ return;
10307
10626
  if (parsed.status !== "complete" && parsed.status !== "blocked")
10308
10627
  return;
10309
10628
  const goalID = parsed.goalID;
@@ -10370,6 +10689,7 @@ var v2 = {
10370
10689
  const eventController = new AbortController;
10371
10690
  let eventTask = Promise.resolve();
10372
10691
  let disposed = false;
10692
+ let guardedToolIDs = new Set;
10373
10693
  const cleanup = async () => {
10374
10694
  if (disposed)
10375
10695
  return;
@@ -10387,9 +10707,23 @@ var v2 = {
10387
10707
  for (const [id, definition] of Object.entries(hooks.tool ?? {})) {
10388
10708
  editor.add(toV2Tool(id, definition, directory));
10389
10709
  }
10390
- guardV2ToolEditor(directory, editor);
10710
+ guardedToolIDs = guardV2ToolEditor(directory, editor, {
10711
+ before: async (input, args) => {
10712
+ await hooks["tool.execute.before"]?.(input, { args });
10713
+ },
10714
+ after: async (input, args, result) => {
10715
+ const output = result;
10716
+ await hooks["tool.execute.after"]?.({ ...input, args }, {
10717
+ title: "",
10718
+ output: typeof output?.content === "string" ? output.content : JSON.stringify(output?.content ?? ""),
10719
+ metadata: output?.metadata ?? {}
10720
+ });
10721
+ }
10722
+ });
10391
10723
  }));
10392
10724
  registrations.push(await context.tool.hook("execute.before", async (input) => {
10725
+ if (input.tool === "execute" || guardedToolIDs.has(input.tool))
10726
+ return;
10393
10727
  await hooks["tool.execute.before"]?.({
10394
10728
  tool: input.tool,
10395
10729
  sessionID: input.sessionID,
@@ -10397,6 +10731,8 @@ var v2 = {
10397
10731
  }, { args: input.input });
10398
10732
  }));
10399
10733
  registrations.push(await context.tool.hook("execute.after", async (input) => {
10734
+ if (input.tool === "execute" || guardedToolIDs.has(input.tool))
10735
+ return;
10400
10736
  const output = input.status === "completed" ? input.result : { content: JSON.stringify(input.error) };
10401
10737
  await hooks["tool.execute.after"]?.({
10402
10738
  tool: input.tool,