@bojackduy/opencode-loopd 1.13.1 → 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/CHANGELOG.md CHANGED
@@ -17,6 +17,26 @@
17
17
 
18
18
  - Scratch harness (real host + real service): timeout→`terminated/timeout`+notify; until→`terminated/until` with 1 notify and `until-matched` state; pattern read returns ANSI-original lines case-insensitively, rejects `(a+)+`; env value reaches process while state keeps only the name; terminate+remove in one call. 555 tests pass.
19
19
 
20
+ ## 1.13.2 (2026-10-12) — Diagnostic Isolation and Lock Recovery
21
+
22
+ ### Fixed
23
+
24
+ - Read/exempt tools no longer wait on workspace bookkeeping in either hook or worker tracking. After-hooks without an execution reservation avoid unnecessary state mutations; write and scope guards remain enforced.
25
+ - State and inbox locks retain their acquisition descriptor for cleanup under file-table exhaustion, validate token and inode ownership, and remove incomplete acquisition metadata on failure. Lock errors identify loopd and the last observed owner PID and operation.
26
+ - State read errors fail closed instead of allowing a mutation to overwrite saved goals with an empty store.
27
+ - Dashboard control timeouts bound request submission, response reads, and timeout diagnostics. A timeout reports uncertain/late delivery rather than implying cancellation.
28
+ - v2 CodeMode orchestration no longer blocks itself on the outer execute reservation. Nested executors retain independent guards and reservations even when the host reuses a call ID.
29
+ - Automatic terminal-tool cleanup handles missing part identity only with matching message-envelope identity, and retained idle runtime IDs only with exact terminal evidence.
30
+ - Idle continuation distinguishes completed tool-call steps from finished turns, and newer unfinished assistant steps invalidate older completion anchors.
31
+ - Repeated block acknowledgements preserve the original blocker and do not duplicate accounting, events, or parent notifications.
32
+
33
+ ### Verification
34
+
35
+ - 794 tests pass across 70 files; the ten control-I/O and diagnostic-isolation regressions also pass ten repetitions.
36
+ - Typecheck, server/TUI builds, and whitespace checks pass.
37
+
38
+ Fully stop the old OpenCode instance before restarting to load the updated plugins. This release does not steal or delete locks held by a live process.
39
+
20
40
  ## 1.13.1 (2026-10-11) — Truthful Goal Delivery and Safe Recovery
21
41
 
22
42
  ### Fixed
package/dist/server.js CHANGED
@@ -215,25 +215,32 @@ async function acquireLock(directory, key, operation) {
215
215
  const dir = lockDir(directory);
216
216
  await fs2.mkdir(dir, { recursive: true });
217
217
  const lockPath = lockFile(directory, key);
218
+ let lastOwner;
218
219
  const deadline = performance.now() + LOCK_TIMEOUT_MS;
219
220
  for (let attempt = 0;performance.now() < deadline; attempt++) {
220
221
  try {
221
222
  try {
222
223
  const raw = await fs2.readFile(lockPath, "utf8");
223
224
  const meta = JSON.parse(raw);
225
+ lastOwner = meta;
224
226
  const age = Date.now() - Date.parse(meta.acquiredAt);
225
227
  if (age > LOCK_STALE_MS && !processIsAlive(meta.pid)) {
226
228
  await fs2.rm(lockPath, { force: true });
227
229
  }
228
230
  } catch {}
229
- const meta = { pid: process.pid, operation, acquiredAt: new Date().toISOString() };
230
- 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+");
231
233
  try {
232
234
  await fd.writeFile(JSON.stringify(meta), "utf8");
233
- } finally {
234
- 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;
235
243
  }
236
- return;
237
244
  } catch (error) {
238
245
  if (error?.code === "EEXIST") {} else if (error?.code === "ENOENT") {
239
246
  await fs2.mkdir(dir, { recursive: true });
@@ -244,26 +251,28 @@ async function acquireLock(directory, key, operation) {
244
251
  }
245
252
  await delay(Math.min(25 * (attempt + 1), 250, Math.max(0, deadline - performance.now())));
246
253
  }
247
- 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}`);
248
256
  }
249
- async function releaseLock(directory, key) {
250
- const lockPath = lockFile(directory, key);
257
+ async function releaseLock(lease) {
251
258
  try {
252
- const raw = await fs2.readFile(lockPath, "utf8");
253
- const meta = JSON.parse(raw);
254
- const shouldRelease = meta.pid === process.pid;
255
- 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)
256
264
  return;
257
- try {
258
- const raw2 = await fs2.readFile(lockPath, "utf8");
259
- const meta2 = JSON.parse(raw2);
260
- if (meta2.acquiredAt !== meta.acquiredAt || meta2.pid !== meta.pid)
265
+ const current = await fs2.stat(lease.path).catch((error) => {
266
+ if (error?.code === "ENOENT")
261
267
  return;
262
- } catch {
268
+ throw error;
269
+ });
270
+ if (!current || current.dev !== held.dev || current.ino !== held.ino)
263
271
  return;
264
- }
265
- await fs2.rm(lockPath, { force: true });
266
- } catch {}
272
+ await fs2.rm(lease.path, { force: true });
273
+ } finally {
274
+ await lease.handle.close();
275
+ }
267
276
  }
268
277
  async function readState(directory) {
269
278
  const target = stateFile(directory);
@@ -281,11 +290,11 @@ async function readState(directory) {
281
290
  return emptyState();
282
291
  const transient = error instanceof SyntaxError || error?.code === "EPERM" || error?.code === "EACCES" || error?.code === "EBUSY";
283
292
  if (!transient || attempt === attempts - 1)
284
- break;
293
+ throw error;
285
294
  await delay(25 * (attempt + 1));
286
295
  }
287
296
  }
288
- return emptyState();
297
+ throw new Error("State could not be read.");
289
298
  }
290
299
  function migrate(state) {
291
300
  if (state.version === CURRENT_VERSION)
@@ -431,14 +440,14 @@ async function writeState(directory, state) {
431
440
  await writeAtomic(stateFile(directory), payload);
432
441
  }
433
442
  async function mutateState(directory, description, fn) {
434
- await acquireLock(directory, "state", description);
443
+ const lease = await acquireLock(directory, "state", description);
435
444
  try {
436
445
  const state = await readState(directory);
437
446
  const next = await fn(state);
438
447
  await writeState(directory, next);
439
448
  return next;
440
449
  } finally {
441
- await releaseLock(directory, "state");
450
+ await releaseLock(lease);
442
451
  }
443
452
  }
444
453
  async function appendEvent(directory, event) {
@@ -523,7 +532,7 @@ async function appendGoalInbox(directory, goalID, from, text) {
523
532
  await mutateState(directory, `inbox.enqueue:${goalID}`, async (state) => {
524
533
  if (!state.goals.some((goal) => goal.id === goalID))
525
534
  return state;
526
- await acquireLock(directory, `inbox-${goalID}`, "inbox.append");
535
+ const lease = await acquireLock(directory, `inbox-${goalID}`, "inbox.append");
527
536
  try {
528
537
  const dir = path.join(loopDir(directory), "inboxes");
529
538
  await fs2.mkdir(dir, { recursive: true });
@@ -532,24 +541,24 @@ async function appendGoalInbox(directory, goalID, from, text) {
532
541
  `, "utf8");
533
542
  accepted = true;
534
543
  } finally {
535
- await releaseLock(directory, `inbox-${goalID}`);
544
+ await releaseLock(lease);
536
545
  }
537
546
  return state;
538
547
  });
539
548
  return accepted;
540
549
  }
541
550
  async function deleteGoalInbox(directory, goalID) {
542
- await acquireLock(directory, `inbox-${goalID}`, "inbox.delete");
551
+ const lease = await acquireLock(directory, `inbox-${goalID}`, "inbox.delete");
543
552
  try {
544
553
  await fs2.rm(inboxFile(directory, goalID), { force: true });
545
554
  } finally {
546
- await releaseLock(directory, `inbox-${goalID}`);
555
+ await releaseLock(lease);
547
556
  }
548
557
  }
549
558
  async function acknowledgeGoalInbox(directory, goalID, delivered) {
550
559
  if (!delivered.length)
551
560
  return;
552
- await acquireLock(directory, `inbox-${goalID}`, "inbox.acknowledge");
561
+ const lease = await acquireLock(directory, `inbox-${goalID}`, "inbox.acknowledge");
553
562
  try {
554
563
  let raw;
555
564
  try {
@@ -571,11 +580,11 @@ async function acknowledgeGoalInbox(directory, goalID, delivered) {
571
580
  else
572
581
  await fs2.rm(inboxFile(directory, goalID), { force: true });
573
582
  } finally {
574
- await releaseLock(directory, `inbox-${goalID}`);
583
+ await releaseLock(lease);
575
584
  }
576
585
  }
577
586
  async function snapshotGoalInbox(directory, goalID) {
578
- await acquireLock(directory, `inbox-${goalID}`, "inbox.snapshot");
587
+ const lease = await acquireLock(directory, `inbox-${goalID}`, "inbox.snapshot");
579
588
  try {
580
589
  const raw = await fs2.readFile(inboxFile(directory, goalID), "utf8");
581
590
  const lines = raw.trim().split(`
@@ -587,7 +596,7 @@ async function snapshotGoalInbox(directory, goalID) {
587
596
  return { lines: [], messages: [] };
588
597
  throw error;
589
598
  } finally {
590
- await releaseLock(directory, `inbox-${goalID}`);
599
+ await releaseLock(lease);
591
600
  }
592
601
  }
593
602
  async function peekGoalInbox(directory, goalID) {
@@ -1423,6 +1432,9 @@ var init_workspace_scope = () => {};
1423
1432
  // src/application/workspace-execution.ts
1424
1433
  import { randomUUID as randomUUID4 } from "crypto";
1425
1434
  import path3 from "path";
1435
+ function isWorkspaceToolExempt(tool) {
1436
+ return readTools.has(tool);
1437
+ }
1426
1438
  function structuredWritePaths(tool, args) {
1427
1439
  if (tool === "write" || tool === "edit") {
1428
1440
  if (typeof args.filePath !== "string")
@@ -1607,7 +1619,12 @@ async function beforeWorkspaceTool(directory, input, args) {
1607
1619
  return state;
1608
1620
  });
1609
1621
  }
1610
- 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;
1611
1628
  await mutateState(directory, `workspace.after:${callID}`, async (state) => {
1612
1629
  state.workspaceCalls = state.workspaceCalls?.filter((call) => call.sessionID !== sessionID || call.callID !== callID);
1613
1630
  const now = Date.now();
@@ -1646,7 +1663,7 @@ async function releaseTerminalWorkspaceCalls(directory, evidence) {
1646
1663
  });
1647
1664
  for (const goal of state.goals) {
1648
1665
  const rt = state.runtimes.find((item) => item.goalID === goal.id);
1649
- const started = rt?.turnStartedAt ? Date.parse(rt.turnStartedAt) : NaN;
1666
+ const started = rt?.turnStartedAt || rt?.lastRunAt ? Date.parse(rt.turnStartedAt ?? rt.lastRunAt) : NaN;
1650
1667
  if (!rt?.activeToolCallIDs?.length || !Number.isFinite(started))
1651
1668
  continue;
1652
1669
  rt.activeToolCallIDs = rt.activeToolCallIDs.filter((callID) => {
@@ -1680,22 +1697,35 @@ async function reconcileTerminalWorkspaceCalls(directory, host) {
1680
1697
  }));
1681
1698
  await releaseTerminalWorkspaceCalls(directory, proof.flat());
1682
1699
  }
1683
- function guardV2ToolEditor(directory, editor) {
1700
+ function guardV2ToolEditor(directory, editor, lifecycle) {
1684
1701
  if (typeof editor.list !== "function" || typeof editor.update !== "function")
1685
1702
  throw new Error("Scoped execution requires the v2 tool editor list/update execution surface.");
1703
+ const guarded = new Set;
1686
1704
  for (const entry of editor.list()) {
1705
+ guarded.add(entry.id);
1687
1706
  editor.update(entry.id, (definition) => {
1688
1707
  const execute = definition.execute;
1689
1708
  definition.execute = async (args, context) => {
1690
- 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;
1691
1716
  try {
1692
- return await execute(args, context);
1717
+ result = await execute(args, context);
1718
+ return result;
1693
1719
  } finally {
1694
- 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);
1695
1724
  }
1696
1725
  };
1697
1726
  });
1698
1727
  }
1728
+ return guarded;
1699
1729
  }
1700
1730
  async function withWorkspaceOperation(directory, goalID, sessionID, run) {
1701
1731
  const id = randomUUID4();
@@ -2602,9 +2632,7 @@ function createLoopEngine(options) {
2602
2632
  Object.assign(rt, recordActivity(rt));
2603
2633
  rt.activePromptObservedAt ??= new Date().toISOString();
2604
2634
  rt.activeAssistantMessageID = info.id;
2605
- if (info.time?.completed) {
2606
- rt.activeAssistantCompletedAt = new Date(info.time.completed).toISOString();
2607
- }
2635
+ rt.activeAssistantCompletedAt = info.time?.completed && info.finish !== "tool-calls" ? new Date(info.time.completed).toISOString() : undefined;
2608
2636
  matched = true;
2609
2637
  }
2610
2638
  } else {
@@ -2692,7 +2720,7 @@ function createLoopEngine(options) {
2692
2720
  const stagedRt = afterIdle.runtimes.find((r) => r.goalID === goalID);
2693
2721
  const eventAnchored = stagedRt?.runGeneration === candidate.generation && Boolean(stagedRt?.activeAssistantCompletedAt);
2694
2722
  const transcript = await inspectPromptTurn(goal.workerSessionID, candidate.promptMessageID);
2695
- 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;
2696
2724
  if (failureReason) {
2697
2725
  let newlyStamped = false;
2698
2726
  await mutateState(directory, `idle.confirm-stamp:${goalID}`, async (s) => {
@@ -2855,7 +2883,7 @@ function createLoopEngine(options) {
2855
2883
  return true;
2856
2884
  }
2857
2885
  async function inspectPromptTurn(workerSessionID, promptMessageID) {
2858
- const noMatch = { latestUserPrompt: false, assistantCompleted: false };
2886
+ const noMatch = { latestUserPrompt: false, assistantCompleted: false, assistantInProgress: false };
2859
2887
  if (!workerSessionID)
2860
2888
  return noMatch;
2861
2889
  let messages;
@@ -2869,9 +2897,14 @@ function createLoopEngine(options) {
2869
2897
  return noMatch;
2870
2898
  const allTimestamped = users.every((message) => message.timestamp && Number.isFinite(Date.parse(message.timestamp)));
2871
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";
2872
2904
  return {
2873
2905
  latestUserPrompt: ordered.at(-1)?.messageID === promptMessageID,
2874
- assistantCompleted: messages.some((message) => message.role === "assistant" && message.parentMessageID === promptMessageID && Boolean(message.completedAt))
2906
+ assistantCompleted,
2907
+ assistantInProgress: Boolean(latestAssistant) && !assistantCompleted
2875
2908
  };
2876
2909
  }
2877
2910
  async function handleSessionStatus(state, goal, event) {
@@ -3852,6 +3885,8 @@ function createRealHost(client, directory) {
3852
3885
  messageID: m.info?.id || m.id,
3853
3886
  parentMessageID: m.info?.parentID,
3854
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,
3855
3890
  tokens: tokens && typeof tokens.input === "number" ? {
3856
3891
  input: tokens.input || 0,
3857
3892
  output: tokens.output || 0,
@@ -3872,10 +3907,14 @@ function createRealHost(client, directory) {
3872
3907
  const result = await withTimeout(client.session.messages({ path: { id: sessionID } }), 1e4, "OpenCode session.messages (tool terminal evidence)");
3873
3908
  if (!Array.isArray(result?.data))
3874
3909
  return [];
3875
- return result.data.flatMap((message) => (message.parts ?? []).flatMap((part) => {
3876
- const evidence = terminalToolExecution(part);
3877
- return evidence?.sessionID === sessionID ? [evidence] : [];
3878
- }));
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
+ });
3879
3918
  } catch {
3880
3919
  return [];
3881
3920
  }
@@ -4111,6 +4150,7 @@ function createV2Host(context, statuses, options = {}) {
4111
4150
  messageID: message.id,
4112
4151
  parentMessageID,
4113
4152
  completedAt: completed ? new Date(completed).toISOString() : undefined,
4153
+ createdAt: new Date(created).toISOString(),
4114
4154
  tokens: message.tokens ? {
4115
4155
  input: message.tokens.input,
4116
4156
  output: message.tokens.output,
@@ -9184,6 +9224,16 @@ ${failureDetails.slice(0, 500)}`,
9184
9224
  if (!goal) {
9185
9225
  return { title: "No goal", output: "No active goal to block." };
9186
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
+ }
9187
9237
  if (!canTransition(goal.status, "blocked", "model")) {
9188
9238
  return { title: "Invalid transition", output: `Cannot block goal in ${goal.status} state.` };
9189
9239
  }
@@ -10411,7 +10461,7 @@ function commandTools(options) {
10411
10461
  init_server_log();
10412
10462
  init_state_repository();
10413
10463
  // package.json
10414
- var version = "1.13.1";
10464
+ var version = "1.13.2";
10415
10465
 
10416
10466
  // src/server/plugin.ts
10417
10467
  init_workspace_execution();
@@ -10520,6 +10570,8 @@ function createServerHooks(directory, host, defaults) {
10520
10570
  tool: { ...goalTools(directory, goalService, undefined, defaults, host), ...ownerTools2({ directory, host, goalService }), ...commandTools({ directory, commandService, capabilities: commandHost.capabilities, goalService }) },
10521
10571
  "tool.execute.before": async (input, _output) => {
10522
10572
  await beforeWorkspaceTool(directory, input, _output.args);
10573
+ if (isWorkspaceToolExempt(input.tool))
10574
+ return;
10523
10575
  const activeWorkers = goalService.getActiveWorkers();
10524
10576
  let matchedGoalID;
10525
10577
  for (const [goalID, worker] of activeWorkers) {
@@ -10540,7 +10592,7 @@ function createServerHooks(directory, host, defaults) {
10540
10592
  } catch {}
10541
10593
  },
10542
10594
  "tool.execute.after": async (input, output) => {
10543
- await afterWorkspaceTool(directory, input.sessionID, input.callID);
10595
+ await afterWorkspaceTool(directory, input.sessionID, input.callID, input.tool);
10544
10596
  if (input.tool === "loopd_create_goal" || input.tool === "get_goal" || input.tool === "report_goal_progress") {
10545
10597
  ensureStarted();
10546
10598
  reconcileInBackground();
@@ -10548,7 +10600,7 @@ function createServerHooks(directory, host, defaults) {
10548
10600
  const activeWorkers = goalService.getActiveWorkers();
10549
10601
  let matchedGoalID;
10550
10602
  for (const [goalID, worker] of activeWorkers) {
10551
- if (worker.workerSessionID === input.sessionID) {
10603
+ if (!isWorkspaceToolExempt(input.tool) && worker.workerSessionID === input.sessionID) {
10552
10604
  matchedGoalID = goalID;
10553
10605
  break;
10554
10606
  }
@@ -10569,6 +10621,8 @@ function createServerHooks(directory, host, defaults) {
10569
10621
  if (!raw)
10570
10622
  return;
10571
10623
  const parsed = JSON.parse(raw);
10624
+ if (parsed.alreadyBlocked === true)
10625
+ return;
10572
10626
  if (parsed.status !== "complete" && parsed.status !== "blocked")
10573
10627
  return;
10574
10628
  const goalID = parsed.goalID;
@@ -10635,6 +10689,7 @@ var v2 = {
10635
10689
  const eventController = new AbortController;
10636
10690
  let eventTask = Promise.resolve();
10637
10691
  let disposed = false;
10692
+ let guardedToolIDs = new Set;
10638
10693
  const cleanup = async () => {
10639
10694
  if (disposed)
10640
10695
  return;
@@ -10652,9 +10707,23 @@ var v2 = {
10652
10707
  for (const [id, definition] of Object.entries(hooks.tool ?? {})) {
10653
10708
  editor.add(toV2Tool(id, definition, directory));
10654
10709
  }
10655
- 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
+ });
10656
10723
  }));
10657
10724
  registrations.push(await context.tool.hook("execute.before", async (input) => {
10725
+ if (input.tool === "execute" || guardedToolIDs.has(input.tool))
10726
+ return;
10658
10727
  await hooks["tool.execute.before"]?.({
10659
10728
  tool: input.tool,
10660
10729
  sessionID: input.sessionID,
@@ -10662,6 +10731,8 @@ var v2 = {
10662
10731
  }, { args: input.input });
10663
10732
  }));
10664
10733
  registrations.push(await context.tool.hook("execute.after", async (input) => {
10734
+ if (input.tool === "execute" || guardedToolIDs.has(input.tool))
10735
+ return;
10665
10736
  const output = input.status === "completed" ? input.result : { content: JSON.stringify(input.error) };
10666
10737
  await hooks["tool.execute.after"]?.({
10667
10738
  tool: input.tool,
package/dist/tui.js CHANGED
@@ -106,11 +106,11 @@ async function readState(directory) {
106
106
  return emptyState();
107
107
  const transient = error instanceof SyntaxError || error?.code === "EPERM" || error?.code === "EACCES" || error?.code === "EBUSY";
108
108
  if (!transient || attempt === attempts - 1)
109
- break;
109
+ throw error;
110
110
  await delay(25 * (attempt + 1));
111
111
  }
112
112
  }
113
- return emptyState();
113
+ throw new Error("State could not be read.");
114
114
  }
115
115
  function migrate(state) {
116
116
  if (state.version === CURRENT_VERSION)
@@ -359,33 +359,66 @@ function createControlClient(directory) {
359
359
  args: command.args,
360
360
  requestedAt: new Date().toISOString()
361
361
  };
362
- await writeControlRequest(directory, request);
363
- const deadline = Date.now() + timeoutMs;
364
- while (Date.now() < deadline) {
365
- const response = await readControlResponse(directory, request.requestID);
366
- if (response)
367
- return response;
368
- await delay2(100);
369
- }
370
- const late = await readControlResponse(directory, request.requestID).catch(() => {
371
- return;
362
+ let submitted = false;
363
+ let expired = false;
364
+ let timeoutReply;
365
+ const deadline = performance.now() + timeoutMs;
366
+ function timedOut() {
367
+ return timeoutReply ??= (async () => {
368
+ let probeTimer;
369
+ let claimed;
370
+ try {
371
+ claimed = await Promise.race([
372
+ statProcessingRequest(directory, request.requestID),
373
+ new Promise((resolve) => {
374
+ probeTimer = setTimeout(() => resolve(undefined), 100);
375
+ })
376
+ ]);
377
+ } catch {} finally {
378
+ if (probeTimer)
379
+ clearTimeout(probeTimer);
380
+ }
381
+ const stage = claimed ? `accepted by the server ${(claimed.requestAgeMs / 1000).toFixed(1)}s ago, still processing` : submitted ? "request submitted; server acceptance or response could not be confirmed" : "request submission is still pending";
382
+ return {
383
+ requestID: request.requestID,
384
+ ok: false,
385
+ errorCode: "timeout",
386
+ message: `timeout waiting for response (${stage}; the operation may complete later under this requestID \u2014 check the server log before retrying)`,
387
+ completedAt: new Date().toISOString()
388
+ };
389
+ })();
390
+ }
391
+ async function exchange() {
392
+ await writeControlRequest(directory, request);
393
+ submitted = true;
394
+ while (!expired && performance.now() < deadline) {
395
+ const response = await readControlResponse(directory, request.requestID);
396
+ if (response)
397
+ return response;
398
+ if (!expired)
399
+ await delay2(Math.min(100, Math.max(0, deadline - performance.now())));
400
+ }
401
+ if (!expired) {
402
+ const late = await readControlResponse(directory, request.requestID);
403
+ if (late)
404
+ return late;
405
+ }
406
+ return timedOut();
407
+ }
408
+ let timer;
409
+ const timeout = new Promise((resolve) => {
410
+ timer = setTimeout(() => {
411
+ expired = true;
412
+ timedOut().then(resolve);
413
+ }, timeoutMs);
372
414
  });
373
- if (late)
374
- return late;
375
- let stage = "not yet accepted by the server";
376
415
  try {
377
- const claimed = await statProcessingRequest(directory, request.requestID);
378
- if (claimed) {
379
- stage = `accepted by the server ${(claimed.requestAgeMs / 1000).toFixed(1)}s ago, still processing \u2014 ` + `the operation may complete later (late reply under this requestID); ` + `check the server log before retrying`;
380
- }
381
- } catch {}
382
- return {
383
- requestID: request.requestID,
384
- ok: false,
385
- message: `timeout waiting for response (${stage})`,
386
- errorCode: "timeout",
387
- completedAt: new Date().toISOString()
388
- };
416
+ return await Promise.race([exchange(), timeout]);
417
+ } finally {
418
+ expired = true;
419
+ if (timer)
420
+ clearTimeout(timer);
421
+ }
389
422
  }
390
423
  async function getState() {
391
424
  return readState(directory);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-loopd",
4
- "version": "1.13.1",
4
+ "version": "1.13.2",
5
5
  "description": "Codex-inspired background goal engine for OpenCode — autonomous subagents, engine-driven loop, child worker sessions and modal TUI dashboard. Like Claude Code loop for OpenCode.",
6
6
  "type": "module",
7
7
  "license": "AGPL-3.0-or-later",