@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 +20 -0
- package/dist/server.js +123 -52
- package/dist/tui.js +60 -27
- package/package.json +1 -1
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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(
|
|
250
|
-
const lockPath = lockFile(directory, key);
|
|
257
|
+
async function releaseLock(lease) {
|
|
251
258
|
try {
|
|
252
|
-
const
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
268
|
+
throw error;
|
|
269
|
+
});
|
|
270
|
+
if (!current || current.dev !== held.dev || current.ino !== held.ino)
|
|
263
271
|
return;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
293
|
+
throw error;
|
|
285
294
|
await delay(25 * (attempt + 1));
|
|
286
295
|
}
|
|
287
296
|
}
|
|
288
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1717
|
+
result = await execute(args, context);
|
|
1718
|
+
return result;
|
|
1693
1719
|
} finally {
|
|
1694
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
3876
|
-
|
|
3877
|
-
|
|
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.
|
|
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
|
-
|
|
109
|
+
throw error;
|
|
110
110
|
await delay(25 * (attempt + 1));
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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.
|
|
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",
|