@bojackduy/opencode-loopd 1.12.3 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -125,8 +125,59 @@ function removeToolCall(rt, callID) {
125
125
  }
126
126
  var PARENT_NOTIFY_DEDUPE_MS = 60000;
127
127
 
128
- // src/infrastructure/state-repository.ts
128
+ // src/infrastructure/event-tail.ts
129
129
  import { promises as fs } from "fs";
130
+ async function readEventTail(target, limit) {
131
+ try {
132
+ if (!Number.isFinite(limit) || limit < 1) {
133
+ const raw = await fs.readFile(target, "utf8");
134
+ return raw.trim().split(`
135
+ `).filter(Boolean).slice(-limit).map((line) => JSON.parse(line));
136
+ }
137
+ const count = Math.trunc(limit);
138
+ const file = await fs.open(target, "r");
139
+ try {
140
+ let position = (await file.stat()).size;
141
+ let fragments = [];
142
+ const lines = [];
143
+ const finishLine = (first = false) => {
144
+ const line = Buffer.concat(fragments.reverse()).toString("utf8");
145
+ fragments = [];
146
+ if (line && !((first || lines.length === 0) && !line.trim()))
147
+ lines.push(line);
148
+ };
149
+ while (position > 0 && lines.length < count) {
150
+ const length = Math.min(16 * 1024, position);
151
+ position -= length;
152
+ const buffer = Buffer.allocUnsafe(length);
153
+ const { bytesRead } = await file.read(buffer, 0, length, position);
154
+ let end = bytesRead;
155
+ for (let i = bytesRead - 1;i >= 0; i--) {
156
+ if (buffer[i] !== 10)
157
+ continue;
158
+ fragments.push(buffer.subarray(i + 1, end));
159
+ finishLine();
160
+ end = i;
161
+ if (lines.length === count)
162
+ break;
163
+ }
164
+ if (lines.length < count)
165
+ fragments.push(buffer.subarray(0, end));
166
+ }
167
+ if (lines.length < count)
168
+ finishLine(true);
169
+ return lines.reverse().map((line) => JSON.parse(line));
170
+ } finally {
171
+ await file.close();
172
+ }
173
+ } catch {
174
+ return [];
175
+ }
176
+ }
177
+ var init_event_tail = () => {};
178
+
179
+ // src/infrastructure/state-repository.ts
180
+ import { promises as fs2 } from "fs";
130
181
  import path from "path";
131
182
  import os from "os";
132
183
  import { createHash } from "crypto";
@@ -159,21 +210,21 @@ function processIsAlive(pid) {
159
210
  }
160
211
  async function acquireLock(directory, key, operation) {
161
212
  const dir = lockDir(directory);
162
- await fs.mkdir(dir, { recursive: true });
213
+ await fs2.mkdir(dir, { recursive: true });
163
214
  const lockPath = lockFile(directory, key);
164
215
  const deadline = performance.now() + LOCK_TIMEOUT_MS;
165
216
  for (let attempt = 0;performance.now() < deadline; attempt++) {
166
217
  try {
167
218
  try {
168
- const raw = await fs.readFile(lockPath, "utf8");
219
+ const raw = await fs2.readFile(lockPath, "utf8");
169
220
  const meta = JSON.parse(raw);
170
221
  const age = Date.now() - Date.parse(meta.acquiredAt);
171
222
  if (age > LOCK_STALE_MS && !processIsAlive(meta.pid)) {
172
- await fs.rm(lockPath, { force: true });
223
+ await fs2.rm(lockPath, { force: true });
173
224
  }
174
225
  } catch {}
175
226
  const meta = { pid: process.pid, operation, acquiredAt: new Date().toISOString() };
176
- const fd = await fs.open(lockPath, "wx");
227
+ const fd = await fs2.open(lockPath, "wx");
177
228
  try {
178
229
  await fd.writeFile(JSON.stringify(meta), "utf8");
179
230
  } finally {
@@ -182,7 +233,7 @@ async function acquireLock(directory, key, operation) {
182
233
  return;
183
234
  } catch (error) {
184
235
  if (error?.code === "EEXIST") {} else if (error?.code === "ENOENT") {
185
- await fs.mkdir(dir, { recursive: true });
236
+ await fs2.mkdir(dir, { recursive: true });
186
237
  continue;
187
238
  } else {
188
239
  throw error;
@@ -195,20 +246,20 @@ async function acquireLock(directory, key, operation) {
195
246
  async function releaseLock(directory, key) {
196
247
  const lockPath = lockFile(directory, key);
197
248
  try {
198
- const raw = await fs.readFile(lockPath, "utf8");
249
+ const raw = await fs2.readFile(lockPath, "utf8");
199
250
  const meta = JSON.parse(raw);
200
251
  const shouldRelease = meta.pid === process.pid;
201
252
  if (!shouldRelease)
202
253
  return;
203
254
  try {
204
- const raw2 = await fs.readFile(lockPath, "utf8");
255
+ const raw2 = await fs2.readFile(lockPath, "utf8");
205
256
  const meta2 = JSON.parse(raw2);
206
257
  if (meta2.acquiredAt !== meta.acquiredAt || meta2.pid !== meta.pid)
207
258
  return;
208
259
  } catch {
209
260
  return;
210
261
  }
211
- await fs.rm(lockPath, { force: true });
262
+ await fs2.rm(lockPath, { force: true });
212
263
  } catch {}
213
264
  }
214
265
  async function readState(directory) {
@@ -216,7 +267,7 @@ async function readState(directory) {
216
267
  const attempts = 5;
217
268
  for (let attempt = 0;attempt < attempts; attempt++) {
218
269
  try {
219
- const raw = await fs.readFile(target, "utf8");
270
+ const raw = await fs2.readFile(target, "utf8");
220
271
  const parsed = JSON.parse(raw);
221
272
  if (parsed && typeof parsed === "object" && Array.isArray(parsed.goals)) {
222
273
  return migrate(parsed);
@@ -347,13 +398,13 @@ function migrate(state) {
347
398
  }
348
399
  async function writeAtomic(target, contents) {
349
400
  const dir = path.dirname(target);
350
- await fs.mkdir(dir, { recursive: true });
401
+ await fs2.mkdir(dir, { recursive: true });
351
402
  const temp = path.join(dir, `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
352
- await fs.writeFile(temp, contents, "utf8");
403
+ await fs2.writeFile(temp, contents, "utf8");
353
404
  try {
354
405
  for (let attempt = 0;attempt < 5; attempt++) {
355
406
  try {
356
- await fs.rename(temp, target);
407
+ await fs2.rename(temp, target);
357
408
  return;
358
409
  } catch (error) {
359
410
  if (error?.code === "EXDEV")
@@ -364,10 +415,10 @@ async function writeAtomic(target, contents) {
364
415
  await delay(25 * (attempt + 1));
365
416
  }
366
417
  }
367
- await fs.copyFile(temp, target);
418
+ await fs2.copyFile(temp, target);
368
419
  } finally {
369
420
  try {
370
- await fs.rm(temp, { force: true });
421
+ await fs2.rm(temp, { force: true });
371
422
  } catch {}
372
423
  }
373
424
  }
@@ -388,20 +439,13 @@ async function mutateState(directory, description, fn) {
388
439
  }
389
440
  }
390
441
  async function appendEvent(directory, event) {
391
- await fs.mkdir(loopDir(directory), { recursive: true });
442
+ await fs2.mkdir(loopDir(directory), { recursive: true });
392
443
  const line = JSON.stringify(event) + `
393
444
  `;
394
- await fs.appendFile(eventsFile(directory), line, "utf8");
445
+ await fs2.appendFile(eventsFile(directory), line, "utf8");
395
446
  }
396
447
  async function readEvents(directory, limit = 50) {
397
- try {
398
- const raw = await fs.readFile(eventsFile(directory), "utf8");
399
- const lines = raw.trim().split(`
400
- `).filter(Boolean);
401
- return lines.slice(-limit).map((l) => JSON.parse(l));
402
- } catch {
403
- return [];
404
- }
448
+ return readEventTail(eventsFile(directory), limit);
405
449
  }
406
450
  function controlDir(directory) {
407
451
  return path.join(loopDir(directory), "control");
@@ -419,8 +463,8 @@ async function claimControlRequest(directory, requestID) {
419
463
  const src = requestFile(directory, requestID);
420
464
  const dst = processingFile(directory, requestID);
421
465
  try {
422
- await fs.mkdir(path.dirname(dst), { recursive: true });
423
- await fs.rename(src, dst);
466
+ await fs2.mkdir(path.dirname(dst), { recursive: true });
467
+ await fs2.rename(src, dst);
424
468
  return true;
425
469
  } catch {
426
470
  return false;
@@ -428,15 +472,15 @@ async function claimControlRequest(directory, requestID) {
428
472
  }
429
473
  async function writeControlResponse(directory, response) {
430
474
  const dir = path.join(controlDir(directory), "responses");
431
- await fs.mkdir(dir, { recursive: true });
475
+ await fs2.mkdir(dir, { recursive: true });
432
476
  await writeAtomic(responseFile(directory, response.requestID), JSON.stringify(response, null, 2));
433
477
  try {
434
- await fs.rm(processingFile(directory, response.requestID), { force: true });
478
+ await fs2.rm(processingFile(directory, response.requestID), { force: true });
435
479
  } catch {}
436
480
  }
437
481
  async function readControlResponse(directory, requestID) {
438
482
  try {
439
- const raw = await fs.readFile(responseFile(directory, requestID), "utf8");
483
+ const raw = await fs2.readFile(responseFile(directory, requestID), "utf8");
440
484
  return JSON.parse(raw);
441
485
  } catch {
442
486
  return;
@@ -445,13 +489,13 @@ async function readControlResponse(directory, requestID) {
445
489
  async function listPendingRequests(directory) {
446
490
  const dir = path.join(controlDir(directory), "requests");
447
491
  try {
448
- const files = await fs.readdir(dir);
492
+ const files = await fs2.readdir(dir);
449
493
  const requests = [];
450
494
  for (const file of files) {
451
495
  if (!file.endsWith(".json"))
452
496
  continue;
453
497
  try {
454
- const raw = await fs.readFile(path.join(dir, file), "utf8");
498
+ const raw = await fs2.readFile(path.join(dir, file), "utf8");
455
499
  requests.push(JSON.parse(raw));
456
500
  } catch {}
457
501
  }
@@ -465,7 +509,7 @@ function goalArtifactDir(directory, goalID) {
465
509
  }
466
510
  async function ensureGoalArtifactDir(directory, goalID) {
467
511
  const dir = goalArtifactDir(directory, goalID);
468
- await fs.mkdir(dir, { recursive: true });
512
+ await fs2.mkdir(dir, { recursive: true });
469
513
  return dir;
470
514
  }
471
515
  function inboxFile(directory, goalID) {
@@ -473,21 +517,21 @@ function inboxFile(directory, goalID) {
473
517
  }
474
518
  async function appendGoalInbox(directory, goalID, from, text) {
475
519
  const dir = path.join(loopDir(directory), "inboxes");
476
- await fs.mkdir(dir, { recursive: true });
520
+ await fs2.mkdir(dir, { recursive: true });
477
521
  const msg = { from, text, at: new Date().toISOString() };
478
- await fs.appendFile(inboxFile(directory, goalID), JSON.stringify(msg) + `
522
+ await fs2.appendFile(inboxFile(directory, goalID), JSON.stringify(msg) + `
479
523
  `, "utf8");
480
524
  }
481
525
  async function drainGoalInbox(directory, goalID) {
482
526
  const file = inboxFile(directory, goalID);
483
527
  try {
484
- const raw = await fs.readFile(file, "utf8");
528
+ const raw = await fs2.readFile(file, "utf8");
485
529
  const lines = raw.trim().split(`
486
530
  `).filter(Boolean);
487
531
  if (lines.length === 0)
488
532
  return [];
489
533
  const messages = lines.map((l) => JSON.parse(l));
490
- await fs.rm(file, { force: true });
534
+ await fs2.rm(file, { force: true });
491
535
  return messages.map((m) => `[${m.from}] ${m.text}`);
492
536
  } catch {
493
537
  return [];
@@ -496,7 +540,7 @@ async function drainGoalInbox(directory, goalID) {
496
540
  async function peekGoalInbox(directory, goalID) {
497
541
  const file = inboxFile(directory, goalID);
498
542
  try {
499
- const raw = await fs.readFile(file, "utf8");
543
+ const raw = await fs2.readFile(file, "utf8");
500
544
  const lines = raw.trim().split(`
501
545
  `).filter(Boolean);
502
546
  if (lines.length === 0)
@@ -512,18 +556,18 @@ function commandLogFile(directory, commandID) {
512
556
  }
513
557
  async function appendCommandLog(directory, commandID, chunk) {
514
558
  const file = commandLogFile(directory, commandID);
515
- await fs.mkdir(path.dirname(file), { recursive: true });
516
- await fs.appendFile(file, chunk, "utf8");
559
+ await fs2.mkdir(path.dirname(file), { recursive: true });
560
+ await fs2.appendFile(file, chunk, "utf8");
517
561
  }
518
562
  async function readCommandLog(directory, commandID, opts) {
519
563
  const file = commandLogFile(directory, commandID);
520
564
  try {
521
- const stat = await fs.stat(file);
565
+ const stat = await fs2.stat(file);
522
566
  const totalBytes = stat.size;
523
567
  const requestedStart = Math.max(0, opts?.offsetBytes ?? 0);
524
568
  if (requestedStart >= totalBytes)
525
569
  return { text: "", totalBytes, startByte: requestedStart };
526
- const fh = await fs.open(file, "r");
570
+ const fh = await fs2.open(file, "r");
527
571
  try {
528
572
  const want = Math.min(opts?.limitBytes ?? 64 * 1024, totalBytes - requestedStart);
529
573
  const buf = Buffer.alloc(want);
@@ -566,14 +610,16 @@ function decodeUtf8Window(buf, windowStart) {
566
610
  }
567
611
  async function removeCommandLog(directory, commandID) {
568
612
  try {
569
- await fs.rm(commandLogFile(directory, commandID), { force: true });
613
+ await fs2.rm(commandLogFile(directory, commandID), { force: true });
570
614
  } catch {}
571
615
  }
572
616
  function delay(ms) {
573
617
  return new Promise((resolve) => setTimeout(resolve, ms));
574
618
  }
575
619
  var CURRENT_VERSION = 9, LOCK_TIMEOUT_MS = 30000, LOCK_STALE_MS = 1e4;
576
- var init_state_repository = () => {};
620
+ var init_state_repository = __esm(() => {
621
+ init_event_tail();
622
+ });
577
623
 
578
624
  // src/domain/command-await.ts
579
625
  function isTerminalCommandStatus(status) {
@@ -1241,6 +1287,440 @@ var init_command_await2 = __esm(() => {
1241
1287
  init_state_repository();
1242
1288
  });
1243
1289
 
1290
+ // src/infrastructure/workspace-scope.ts
1291
+ import fs3 from "fs/promises";
1292
+ import path2 from "path";
1293
+ async function resolveExistingParent(target) {
1294
+ try {
1295
+ return await fs3.realpath(target);
1296
+ } catch (error) {
1297
+ if (error?.code !== "ENOENT")
1298
+ throw error;
1299
+ try {
1300
+ const stat = await fs3.lstat(target);
1301
+ if (stat.isSymbolicLink())
1302
+ throw new Error(`Dangling symlink in scope: ${target}`);
1303
+ } catch (missing) {
1304
+ if (missing?.code !== "ENOENT")
1305
+ throw missing;
1306
+ }
1307
+ const parent = path2.dirname(target);
1308
+ if (parent === target)
1309
+ throw error;
1310
+ return path2.join(await resolveExistingParent(parent), path2.basename(target));
1311
+ }
1312
+ }
1313
+ async function canonicalScopePath(directory, input) {
1314
+ if (typeof input !== "string" || !input.trim() || input.includes("\x00") || /[*?\[\]{}]/.test(input)) {
1315
+ throw new Error("write_scope requires exact non-empty file paths, not glob patterns");
1316
+ }
1317
+ const root = await fs3.realpath(directory);
1318
+ const resolved = await resolveExistingParent(path2.resolve(root, input));
1319
+ const relative = path2.relative(root, resolved);
1320
+ if (!relative || relative === ".." || relative.startsWith(`..${path2.sep}`) || path2.isAbsolute(relative)) {
1321
+ throw new Error(`Scope path is outside the workspace or names its root: ${input}`);
1322
+ }
1323
+ try {
1324
+ if ((await fs3.stat(resolved)).isDirectory())
1325
+ throw new Error(`Scope must name a file, not a directory: ${input}`);
1326
+ } catch (error) {
1327
+ if (error?.code !== "ENOENT")
1328
+ throw error;
1329
+ }
1330
+ return relative.split(path2.sep).join("/");
1331
+ }
1332
+ async function canonicalWriteScope(directory, inputs) {
1333
+ return [...new Set(await Promise.all(inputs.map((input) => canonicalScopePath(directory, input))))].sort();
1334
+ }
1335
+ function findScopeConflict(goals, candidate) {
1336
+ if (candidate.config.workspaceWrite === false || candidate.config.write_scope?.length === 0)
1337
+ return;
1338
+ for (const owner of goals) {
1339
+ if (owner.id === candidate.id || owner.status !== "active" || owner.config.workspaceWrite === false || owner.config.write_scope?.length === 0)
1340
+ continue;
1341
+ const requested = candidate.config.write_scope;
1342
+ const held = owner.config.write_scope;
1343
+ const overlap = requested === undefined || held === undefined ? requested?.[0] ?? held?.[0] ?? "*" : requested.find((file) => held.includes(file));
1344
+ if (overlap !== undefined)
1345
+ return { goalID: owner.id, ownerSessionID: owner.ownerSessionID, name: owner.name, path: overlap };
1346
+ }
1347
+ }
1348
+ function scopeConflictMessage(conflict) {
1349
+ return `Write scope conflict on "${conflict.path}": goal "${conflict.name}" (${conflict.goalID}), owned by session ${conflict.ownerSessionID}. Coordinate with the owner or release the conflicting scope before retrying; do not poll claims.`;
1350
+ }
1351
+ var init_workspace_scope = () => {};
1352
+
1353
+ // src/application/workspace-execution.ts
1354
+ import { randomUUID as randomUUID3 } from "crypto";
1355
+ import path3 from "path";
1356
+ function structuredWritePaths(tool, args) {
1357
+ if (tool === "write" || tool === "edit") {
1358
+ if (typeof args.filePath !== "string")
1359
+ throw new Error(`Scope denied: ${tool} requires filePath.`);
1360
+ return [args.filePath];
1361
+ }
1362
+ if (tool !== "apply_patch")
1363
+ return;
1364
+ const source = args.patchText ?? args.patch;
1365
+ if (typeof source !== "string" || !source.startsWith(`*** Begin Patch
1366
+ `) || !source.trimEnd().endsWith("*** End Patch")) {
1367
+ throw new Error("Scope denied: unsupported apply_patch format.");
1368
+ }
1369
+ const paths = [];
1370
+ let section = false;
1371
+ for (const line of source.split(`
1372
+ `)) {
1373
+ const header = /^\*\*\* (?:Add File|Update File|Delete File): (.+)$/.exec(line);
1374
+ if (header) {
1375
+ paths.push(header[1]);
1376
+ section = true;
1377
+ continue;
1378
+ }
1379
+ const move = /^\*\*\* Move to: (.+)$/.exec(line);
1380
+ if (move) {
1381
+ if (!section)
1382
+ throw new Error("Scope denied: rename outside patch section.");
1383
+ paths.push(move[1]);
1384
+ continue;
1385
+ }
1386
+ if (line.startsWith("*** ") && !["*** Begin Patch", "*** End Patch", "*** End of File"].includes(line)) {
1387
+ throw new Error("Scope denied: unknown patch operation.");
1388
+ }
1389
+ }
1390
+ if (!paths.length)
1391
+ throw new Error("Scope denied: patch has no file operations.");
1392
+ return paths;
1393
+ }
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;
1421
+ }
1422
+ 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;
1441
+ }
1442
+ function sweepStaleWorkspaceState(state, liveness = {}) {
1443
+ const now = liveness.now ?? Date.now();
1444
+ 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) {
1463
+ const referenced = kept.some((call) => call.goalID === goal.id) || state.workspaceOperation?.goalID === goal.id;
1464
+ if (goal.scopeClosing && !referenced && !(liveness.isWorkerLive?.(goal.id, goal.workerSessionID) ?? false)) {
1465
+ goal.scopeClosing = false;
1466
+ goal.updatedAt = new Date(now).toISOString();
1467
+ summary.resetClosing.push(goal.id);
1468
+ }
1469
+ if (goal.scopeClearPending && !referenced) {
1470
+ state.goals = state.goals.filter((g) => g.id !== goal.id);
1471
+ state.runtimes = state.runtimes.filter((r) => r.goalID !== goal.id);
1472
+ state.commandAwaits = (state.commandAwaits ?? []).filter((a) => a.goalID !== goal.id);
1473
+ summary.finalizedClears.push(goal.id);
1474
+ }
1475
+ }
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
+ return summary;
1496
+ }
1497
+ function stampCallGeneration(state, goalID) {
1498
+ if (!goalID)
1499
+ return;
1500
+ return state.runtimes.find((runtime) => runtime.goalID === goalID)?.runGeneration ?? 0;
1501
+ }
1502
+ function heldGoals(state) {
1503
+ const now = Date.now();
1504
+ return state.goals.filter((goal) => goal.status === "active" || goal.scopeClosing || (state.workspaceCalls ?? []).some((call) => call.goalID === goal.id && !isSyncStaleWorkspaceCall(state, call, now)) || state.workspaceOperation?.goalID === goal.id);
1505
+ }
1506
+ function mayBeAlive(pid) {
1507
+ if (!pid || pid < 1)
1508
+ return true;
1509
+ try {
1510
+ process.kill(pid, 0);
1511
+ return true;
1512
+ } catch (error) {
1513
+ return error?.code !== "ESRCH";
1514
+ }
1515
+ }
1516
+ function assertScopeAvailable(state, goal) {
1517
+ const now = Date.now();
1518
+ const liveCalls = (state.workspaceCalls ?? []).filter((call) => call.goalID === goal.id && !isSyncStaleWorkspaceCall(state, call, now));
1519
+ if (goal.scopeClosing || liveCalls.length) {
1520
+ throw new Error("Goal still has closing/in-flight writes; ownership cannot be reacquired before quiescence.");
1521
+ }
1522
+ if (state.workspaceOperation)
1523
+ throw new Error(`Workspace operation ${state.workspaceOperation.id} is active.`);
1524
+ if (goal.config.write_scope !== undefined || goal.config.workspaceWrite === false) {
1525
+ const orphan = [...state.orphanedCommandProcesses || [], ...(state.commands || []).filter((command) => command.status === "missing").map((command) => ({ commandID: command.id, pid: command.pid }))].find((process2) => mayBeAlive(process2.pid));
1526
+ if (orphan)
1527
+ throw new Error(`Protected activation denied: command ${orphan.commandID} lost its handle and process exit is unproved. Stop/verify the orphan process externally; removing its log does not release this fence.`);
1528
+ if (state.workspaceCalls?.some((call) => call.uncontrolled))
1529
+ throw new Error("Protected goal activation denied: an uncontrolled tool/shell is still executing. Wait for its exit event, do not poll claims.");
1530
+ const running = state.commands?.find((command) => command.status === "running");
1531
+ if (running)
1532
+ throw new Error(`Protected goal activation denied: uncontrolled command ${running.id} is still running. Terminate it before starting scoped/read-only work.`);
1533
+ }
1534
+ const conflict = findScopeConflict(heldGoals(state).map((owner) => ({ ...owner, status: "active" })), goal);
1535
+ if (conflict) {
1536
+ const owner = state.goals.find((item) => item.id === conflict.goalID);
1537
+ if (goal.config.write_scope === undefined && owner?.config.write_scope === undefined) {
1538
+ throw new Error(`Workspace-writing goal "${conflict.name}" (${conflict.goalID}) is already active (owned by session ${conflict.ownerSessionID}). Pause, block, complete, or clear it before activating another workspace-writing goal. Note: list_background_goals shows only this session's goals.`);
1539
+ }
1540
+ throw new Error(scopeConflictMessage(conflict));
1541
+ }
1542
+ if (goal.config.workspaceWrite !== false && goal.config.write_scope?.length !== 0) {
1543
+ const now2 = Date.now();
1544
+ const call = state.workspaceCalls?.find((call) => call.goalID !== goal.id && !isSyncStaleWorkspaceCall(state, call, now2) && (goal.config.write_scope === undefined || call.paths.some((file) => goal.config.write_scope.includes(file))));
1545
+ if (call)
1546
+ throw new Error(`Scope conflict with in-flight call ${call.callID}, session ${call.sessionID}, path ${call.paths[0]}.`);
1547
+ }
1548
+ }
1549
+ function retireWorker(state, goal) {
1550
+ if (!goal.workerSessionID)
1551
+ return;
1552
+ state.retiredWorkerSessions = [...new Set([...state.retiredWorkerSessions || [], goal.workerSessionID])];
1553
+ }
1554
+ async function beforeWorkspaceTool(directory, input, args) {
1555
+ if (readTools.has(input.tool))
1556
+ return;
1557
+ await mutateState(directory, `workspace.before:${input.callID}`, async (state) => {
1558
+ const goal = state.goals.find((item) => item.workerSessionID === input.sessionID);
1559
+ if (state.retiredWorkerSessions?.includes(input.sessionID))
1560
+ throw new Error("Scope denied: retired worker session; late executions are fenced.");
1561
+ const held = heldGoals(state);
1562
+ const protectedGoals = held.filter((item) => item.config.write_scope !== undefined || item.config.workspaceWrite === false);
1563
+ if (!goal && ownerTools.has(input.tool) && (!protectedGoals.length || state.goals.some((item) => item.ownerSessionID === input.sessionID)))
1564
+ return state;
1565
+ if (!state.workspaceOperation && !protectedGoals.length && (!goal || goal.config.write_scope === undefined && goal.config.workspaceWrite !== false)) {
1566
+ let paths = [];
1567
+ let uncontrolled = false;
1568
+ try {
1569
+ const writes = structuredWritePaths(input.tool, args);
1570
+ if (writes)
1571
+ paths = await Promise.all(writes.map((file) => canonicalScopePath(directory, file)));
1572
+ else
1573
+ uncontrolled = true;
1574
+ } catch {
1575
+ uncontrolled = true;
1576
+ }
1577
+ state.workspaceCalls ??= [];
1578
+ if (!state.workspaceCalls.some((call) => call.callID === input.callID && call.sessionID === input.sessionID)) {
1579
+ state.workspaceCalls.push({ callID: input.callID, sessionID: input.sessionID, goalID: goal?.id, generation: stampCallGeneration(state, goal?.id), paths, uncontrolled, at: new Date().toISOString() });
1580
+ }
1581
+ return state;
1582
+ }
1583
+ const writes = structuredWritePaths(input.tool, args);
1584
+ if (!writes)
1585
+ throw new Error(`Scope denied: uncontrolled tool "${input.tool}". Shell, commands, custom tools, batch and subagents are not allowed while protected goals exist. Use structured edits or run_goal_checks.`);
1586
+ if (state.workspaceOperation)
1587
+ throw new Error(`Workspace operation ${state.workspaceOperation.id} is active; source writes are denied until it finishes.`);
1588
+ if (goal && (goal.status !== "active" || goal.scopeClosing))
1589
+ throw new Error(`Scope denied: worker goal is ${goal.scopeClosing ? "closing" : goal.status}.`);
1590
+ if (!goal && !state.goals.some((item) => item.ownerSessionID === input.sessionID)) {
1591
+ throw new Error("Scope denied: unrecognized session/child. Subagent writes are unsupported.");
1592
+ }
1593
+ const paths = await Promise.all(writes.map((file) => canonicalScopePath(directory, file)));
1594
+ const artifact = goal?.config.artifactDir ? path3.relative(directory, goal.config.artifactDir).split(path3.sep).join("/") : undefined;
1595
+ for (const file of paths) {
1596
+ const inArtifact = artifact && file.startsWith(`${artifact}/`);
1597
+ if (goal && !inArtifact && (goal.config.workspaceWrite === false || goal.config.write_scope !== undefined && !goal.config.write_scope.includes(file))) {
1598
+ throw new Error(`Scope denied: "${file}" is outside this goal's write_scope. Claim it before editing.`);
1599
+ }
1600
+ const owner = !inArtifact && held.find((item) => item.id !== goal?.id && item.config.workspaceWrite !== false && (item.config.write_scope === undefined || item.config.write_scope.includes(file)));
1601
+ if (owner)
1602
+ throw new Error(scopeConflictMessage({ goalID: owner.id, ownerSessionID: owner.ownerSessionID, name: owner.name, path: file }));
1603
+ const now3 = Date.now();
1604
+ const call = state.workspaceCalls?.find((item) => !isSyncStaleWorkspaceCall(state, item, now3) && item.paths.includes(file) && !(item.callID === input.callID && item.sessionID === input.sessionID));
1605
+ if (call)
1606
+ throw new Error(`Scope denied: "${file}" is being written by session ${call.sessionID}, call ${call.callID}.`);
1607
+ }
1608
+ if (!state.workspaceCalls)
1609
+ state.workspaceCalls = [];
1610
+ if (!state.workspaceCalls.some((call) => call.callID === input.callID && call.sessionID === input.sessionID)) {
1611
+ state.workspaceCalls.push({ callID: input.callID, sessionID: input.sessionID, goalID: goal?.id, generation: stampCallGeneration(state, goal?.id), paths, at: new Date().toISOString() });
1612
+ }
1613
+ return state;
1614
+ });
1615
+ }
1616
+ async function afterWorkspaceTool(directory, sessionID, callID) {
1617
+ await mutateState(directory, `workspace.after:${callID}`, async (state) => {
1618
+ state.workspaceCalls = state.workspaceCalls?.filter((call) => call.sessionID !== sessionID || call.callID !== callID);
1619
+ const now = Date.now();
1620
+ state.goals = state.goals.filter((goal) => {
1621
+ const live = (state.workspaceCalls ?? []).some((call) => call.goalID === goal.id && !isSyncStaleWorkspaceCall(state, call, now));
1622
+ if (!goal.scopeClearPending || live || state.workspaceOperation?.goalID === goal.id)
1623
+ return true;
1624
+ state.workspaceCalls = (state.workspaceCalls ?? []).filter((call) => !(call.goalID === goal.id && isSyncStaleWorkspaceCall(state, call, now)));
1625
+ state.runtimes = state.runtimes.filter((runtime) => runtime.goalID !== goal.id);
1626
+ return false;
1627
+ });
1628
+ return state;
1629
+ });
1630
+ }
1631
+ function guardV2ToolEditor(directory, editor) {
1632
+ if (typeof editor.list !== "function" || typeof editor.update !== "function")
1633
+ throw new Error("Scoped execution requires the v2 tool editor list/update execution surface.");
1634
+ for (const entry of editor.list()) {
1635
+ editor.update(entry.id, (definition) => {
1636
+ const execute = definition.execute;
1637
+ definition.execute = async (args, context) => {
1638
+ await beforeWorkspaceTool(directory, { tool: entry.name, sessionID: context.sessionID, callID: context.id }, args);
1639
+ try {
1640
+ return await execute(args, context);
1641
+ } finally {
1642
+ await afterWorkspaceTool(directory, context.sessionID, context.id);
1643
+ }
1644
+ };
1645
+ });
1646
+ }
1647
+ }
1648
+ async function withWorkspaceOperation(directory, goalID, sessionID, run) {
1649
+ const id = randomUUID3();
1650
+ await mutateState(directory, `workspace.operation:${id}`, async (state) => {
1651
+ const now = Date.now();
1652
+ const live = (state.workspaceCalls ?? []).filter((call) => !isSyncStaleWorkspaceCall(state, call, now));
1653
+ if (state.workspaceOperation || live.length)
1654
+ throw new Error("Workspace is busy with an operation or in-flight writes; verification denied until quiescent.");
1655
+ state.workspaceOperation = { id, goalID, sessionID };
1656
+ return state;
1657
+ });
1658
+ try {
1659
+ return await run();
1660
+ } finally {
1661
+ await mutateState(directory, `workspace.operation-end:${id}`, async (state) => {
1662
+ if (state.workspaceOperation?.id === id)
1663
+ state.workspaceOperation = undefined;
1664
+ const now = Date.now();
1665
+ state.goals = state.goals.filter((goal) => {
1666
+ const live = (state.workspaceCalls ?? []).some((call) => call.goalID === goal.id && !isSyncStaleWorkspaceCall(state, call, now));
1667
+ if (!goal.scopeClearPending || live)
1668
+ return true;
1669
+ state.workspaceCalls = (state.workspaceCalls ?? []).filter((call) => !(call.goalID === goal.id && isSyncStaleWorkspaceCall(state, call, now)));
1670
+ state.runtimes = state.runtimes.filter((runtime) => runtime.goalID !== goal.id);
1671
+ return false;
1672
+ });
1673
+ return state;
1674
+ });
1675
+ }
1676
+ }
1677
+ var readTools, ownerTools, STALE_WORKSPACE_CALL_TTL_MS, CLEAR_PENDING_FORCE_TTL_MS, OWNER_CALL_TTL_MS;
1678
+ var init_workspace_execution = __esm(() => {
1679
+ init_state_repository();
1680
+ init_workspace_scope();
1681
+ readTools = new Set([
1682
+ "read",
1683
+ "glob",
1684
+ "grep",
1685
+ "list",
1686
+ "webfetch",
1687
+ "websearch",
1688
+ "todowrite",
1689
+ "skill",
1690
+ "get_goal",
1691
+ "claim_goal_scope",
1692
+ "report_goal_progress",
1693
+ "complete_goal",
1694
+ "block_goal",
1695
+ "list_background_goals",
1696
+ "inspect_background_goal",
1697
+ "read_goal_transcript",
1698
+ "loopd_command_get",
1699
+ "loopd_command_list",
1700
+ "loopd_command_await",
1701
+ "loopd_command_interrupt",
1702
+ "loopd_command_terminate",
1703
+ "loopd_command_remove",
1704
+ "loopd_list_agents",
1705
+ "loopd_list_models",
1706
+ "run_goal_checks",
1707
+ "codegraph_codegraph_context",
1708
+ "codegraph_codegraph_search",
1709
+ "codegraph_codegraph_node",
1710
+ "codegraph_codegraph_explore",
1711
+ "codegraph_codegraph_files",
1712
+ "codegraph_codegraph_trace",
1713
+ "codegraph_codegraph_callers",
1714
+ "codegraph_codegraph_callees",
1715
+ "codegraph_codegraph_impact",
1716
+ "codegraph_codegraph_status"
1717
+ ]);
1718
+ ownerTools = new Set(["loopd_create_goal", "switch_goal_identity", "pause_goal", "resume_goal", "clear_goal", "abort_goal_worker", "nudge_goal", "send_goal_input", "force_block_goal", "force_complete_goal"]);
1719
+ STALE_WORKSPACE_CALL_TTL_MS = 10 * 60000;
1720
+ CLEAR_PENDING_FORCE_TTL_MS = 30 * 60000;
1721
+ OWNER_CALL_TTL_MS = 30 * 60000;
1722
+ });
1723
+
1244
1724
  // src/server/plugin.ts
1245
1725
  import { tool as v1Tool } from "@opencode-ai/plugin/tool";
1246
1726
 
@@ -1404,12 +1884,24 @@ function createControlWorker(options) {
1404
1884
  continue;
1405
1885
  processing.add(request.requestID);
1406
1886
  options.onRequest?.(request);
1887
+ logServerEvent(directory, "control.request.claimed", {
1888
+ requestID: request.requestID,
1889
+ command: request.command,
1890
+ goalID: request.goalID
1891
+ }).catch(() => {});
1892
+ runClaimed(request).finally(() => {
1893
+ processing.delete(request.requestID);
1894
+ });
1895
+ }
1896
+ }
1897
+ async function runClaimed(request) {
1898
+ try {
1899
+ const response = await handleRequest(request);
1900
+ await writeControlResponse(directory, response);
1901
+ options.onResponse?.(response);
1902
+ } catch (error) {
1903
+ const detail = describeError(error);
1407
1904
  try {
1408
- const response = await handleRequest(request);
1409
- await writeControlResponse(directory, response);
1410
- options.onResponse?.(response);
1411
- } catch (error) {
1412
- const detail = describeError(error);
1413
1905
  await logServerEvent(directory, "control.request.failed", {
1414
1906
  requestID: request.requestID,
1415
1907
  command: request.command,
@@ -1425,9 +1917,7 @@ function createControlWorker(options) {
1425
1917
  };
1426
1918
  await writeControlResponse(directory, response);
1427
1919
  options.onResponse?.(response);
1428
- } finally {
1429
- processing.delete(request.requestID);
1430
- }
1920
+ } catch {}
1431
1921
  }
1432
1922
  }
1433
1923
  async function handleRequest(request) {
@@ -1679,7 +2169,21 @@ function createControlWorker(options) {
1679
2169
  runtime.lastError = undefined;
1680
2170
  runtime.updatedAt = new Date().toISOString();
1681
2171
  }
1682
- await writeState(directory, state);
2172
+ await mutateState(directory, `goal.force-complete:${goal.id}`, async (s) => {
2173
+ const current = s.goals.find((item) => item.id === goal.id);
2174
+ if (!current)
2175
+ return s;
2176
+ current.status = "complete";
2177
+ current.updatedAt = goal.updatedAt;
2178
+ current.completionEvidence = goal.completionEvidence;
2179
+ const rt = s.runtimes.find((item) => item.goalID === goal.id);
2180
+ if (rt) {
2181
+ Object.assign(rt, releaseLease(rt));
2182
+ rt.activeRunID = undefined;
2183
+ rt.lastError = undefined;
2184
+ }
2185
+ return s;
2186
+ });
1683
2187
  await appendEvent(directory, {
1684
2188
  version: 1,
1685
2189
  eventID: randomUUID2(),
@@ -1720,7 +2224,21 @@ function createControlWorker(options) {
1720
2224
  runtime.lastError = undefined;
1721
2225
  runtime.updatedAt = new Date().toISOString();
1722
2226
  }
1723
- await writeState(directory, state);
2227
+ await mutateState(directory, `goal.force-block:${goal.id}`, async (s) => {
2228
+ const current = s.goals.find((item) => item.id === goal.id);
2229
+ if (!current)
2230
+ return s;
2231
+ current.status = "blocked";
2232
+ current.updatedAt = goal.updatedAt;
2233
+ current.blocker = goal.blocker;
2234
+ const rt = s.runtimes.find((item) => item.goalID === goal.id);
2235
+ if (rt) {
2236
+ Object.assign(rt, releaseLease(rt));
2237
+ rt.activeRunID = undefined;
2238
+ rt.lastError = undefined;
2239
+ }
2240
+ return s;
2241
+ });
1724
2242
  await appendEvent(directory, {
1725
2243
  version: 1,
1726
2244
  eventID: randomUUID2(),
@@ -1891,7 +2409,7 @@ function createControlWorker(options) {
1891
2409
 
1892
2410
  // src/application/loop-engine.ts
1893
2411
  init_state_repository();
1894
- import { randomUUID as randomUUID3 } from "crypto";
2412
+ import { randomUUID as randomUUID4 } from "crypto";
1895
2413
 
1896
2414
  // src/domain/goal.ts
1897
2415
  var MODEL_TRANSITIONS = {
@@ -2173,7 +2691,7 @@ function createLoopEngine(options) {
2173
2691
  if (newlyStamped) {
2174
2692
  await appendEvent(directory, {
2175
2693
  version: 1,
2176
- eventID: randomUUID3(),
2694
+ eventID: randomUUID4(),
2177
2695
  goalID,
2178
2696
  type: "idle.confirm-failed",
2179
2697
  reason: failureReason,
@@ -2218,7 +2736,7 @@ function createLoopEngine(options) {
2218
2736
  if (completedRunID) {
2219
2737
  await appendEvent(directory, {
2220
2738
  version: 1,
2221
- eventID: randomUUID3(),
2739
+ eventID: randomUUID4(),
2222
2740
  goalID,
2223
2741
  type: "run.completed",
2224
2742
  runID: completedRunID,
@@ -2283,7 +2801,7 @@ function createLoopEngine(options) {
2283
2801
  });
2284
2802
  await appendEvent(directory, {
2285
2803
  version: 1,
2286
- eventID: randomUUID3(),
2804
+ eventID: randomUUID4(),
2287
2805
  goalID,
2288
2806
  type: "goal.blocked",
2289
2807
  reason: limitResult.reason + " (force-finish ignored)",
@@ -2307,7 +2825,7 @@ function createLoopEngine(options) {
2307
2825
  });
2308
2826
  await appendEvent(directory, {
2309
2827
  version: 1,
2310
- eventID: randomUUID3(),
2828
+ eventID: randomUUID4(),
2311
2829
  goalID,
2312
2830
  type: "goal.status_changed",
2313
2831
  from: "active",
@@ -2409,7 +2927,7 @@ function createLoopEngine(options) {
2409
2927
  });
2410
2928
  await appendEvent(directory, {
2411
2929
  version: 1,
2412
- eventID: randomUUID3(),
2930
+ eventID: randomUUID4(),
2413
2931
  goalID: goal.id,
2414
2932
  type: "run.failed",
2415
2933
  runID: runtime.activeRunID || "unknown",
@@ -2423,7 +2941,7 @@ function createLoopEngine(options) {
2423
2941
  if (updatedGoal?.status === "blocked") {
2424
2942
  await appendEvent(directory, {
2425
2943
  version: 1,
2426
- eventID: randomUUID3(),
2944
+ eventID: randomUUID4(),
2427
2945
  goalID: goal.id,
2428
2946
  type: "goal.blocked",
2429
2947
  reason: `Failed ${runtime.consecutiveFailures + 1} times`,
@@ -2451,7 +2969,7 @@ function createLoopEngine(options) {
2451
2969
  });
2452
2970
  await appendEvent(directory, {
2453
2971
  version: 1,
2454
- eventID: randomUUID3(),
2972
+ eventID: randomUUID4(),
2455
2973
  goalID: goal.id,
2456
2974
  type: "compaction.completed",
2457
2975
  timestamp: new Date().toISOString(),
@@ -2521,7 +3039,7 @@ function createLoopEngine(options) {
2521
3039
  });
2522
3040
  await appendEvent(directory, {
2523
3041
  version: 1,
2524
- eventID: randomUUID3(),
3042
+ eventID: randomUUID4(),
2525
3043
  goalID: goal.id,
2526
3044
  type: "compaction.started",
2527
3045
  timestamp: new Date().toISOString(),
@@ -2579,7 +3097,7 @@ function createLoopEngine(options) {
2579
3097
  return false;
2580
3098
  await appendEvent(directory, {
2581
3099
  version: 1,
2582
- eventID: randomUUID3(),
3100
+ eventID: randomUUID4(),
2583
3101
  goalID: goal.id,
2584
3102
  type: "goal.status_changed",
2585
3103
  from: "active",
@@ -2601,7 +3119,33 @@ function createLoopEngine(options) {
2601
3119
  function hasExecutionLeak(runtime) {
2602
3120
  return runtime.phase !== "idle" || Boolean(runtime.activeRunID) || Boolean(runtime.leaseExpiresAt) || Boolean(runtime.turnStartedAt) || Boolean(runtime.activePromptMessageID) || Boolean(runtime.activePromptObservedAt) || Boolean(runtime.activeAssistantMessageID) || Boolean(runtime.activeAssistantCompletedAt) || Boolean(runtime.idleCandidateAt) || (runtime.activeToolCallIDs?.length ?? 0) > 0;
2603
3121
  }
3122
+ async function sweepStaleWorkspaceOwnership() {
3123
+ try {
3124
+ const snapshot = await readState(directory);
3125
+ if (!snapshot.workspaceCalls?.length && !snapshot.goals.some((g) => g.scopeClosing || g.scopeClearPending)) {
3126
+ return;
3127
+ }
3128
+ await Promise.resolve().then(() => init_workspace_execution());
3129
+ let summary = { reapedCalls: [], resetClosing: [], finalizedClears: [], forceCleared: [] };
3130
+ await mutateState(directory, "maintenance.sweep-stale-workspace", async (s) => {
3131
+ summary = sweepStaleWorkspaceState(s, {
3132
+ isWorkerLive: () => true,
3133
+ now: Date.now()
3134
+ });
3135
+ return s;
3136
+ });
3137
+ if (summary.reapedCalls.length || summary.resetClosing.length || summary.finalizedClears.length || summary.forceCleared.length) {
3138
+ await logServerEvent(directory, "maintenance.sweep-stale-workspace", {
3139
+ reapedCalls: summary.reapedCalls,
3140
+ resetClosing: summary.resetClosing,
3141
+ finalizedClears: summary.finalizedClears,
3142
+ forceCleared: summary.forceCleared
3143
+ });
3144
+ }
3145
+ } catch {}
3146
+ }
2604
3147
  async function maintenance() {
3148
+ await sweepStaleWorkspaceOwnership();
2605
3149
  syncWorkerSessionsFromService();
2606
3150
  if (knownWorkerSessions.size === 0)
2607
3151
  return;
@@ -2756,7 +3300,7 @@ function createLoopEngine(options) {
2756
3300
  if (recovered) {
2757
3301
  await appendEvent(directory, {
2758
3302
  version: 1,
2759
- eventID: randomUUID3(),
3303
+ eventID: randomUUID4(),
2760
3304
  goalID: goal.id,
2761
3305
  type: "run.recovered",
2762
3306
  runID: stalledRunID ?? "unknown",
@@ -2790,7 +3334,7 @@ function createLoopEngine(options) {
2790
3334
  const stuckSeconds = Math.floor((Date.now() - Date.parse(failedAt)) / 1000);
2791
3335
  await appendEvent(directory, {
2792
3336
  version: 1,
2793
- eventID: randomUUID3(),
3337
+ eventID: randomUUID4(),
2794
3338
  goalID: goal.id,
2795
3339
  type: "run.stuck",
2796
3340
  runID: runtime.activeRunID ?? "unknown",
@@ -2830,7 +3374,7 @@ function createLoopEngine(options) {
2830
3374
  });
2831
3375
  await appendEvent(directory, {
2832
3376
  version: 1,
2833
- eventID: randomUUID3(),
3377
+ eventID: randomUUID4(),
2834
3378
  goalID: goal.id,
2835
3379
  type: "run.stuck",
2836
3380
  runID: runtime.activeRunID,
@@ -2853,14 +3397,14 @@ function createLoopEngine(options) {
2853
3397
  }
2854
3398
 
2855
3399
  // src/application/goal-service.ts
2856
- import { randomUUID as randomUUID5 } from "crypto";
3400
+ import { randomUUID as randomUUID6 } from "crypto";
2857
3401
  init_state_repository();
2858
3402
  init_command_await2();
2859
- import * as path2 from "path";
2860
- import { promises as fs2 } from "fs";
3403
+ import * as path4 from "path";
3404
+ import { promises as fs4 } from "fs";
2861
3405
 
2862
3406
  // src/server/host-adapter.ts
2863
- import { randomUUID as randomUUID4 } from "crypto";
3407
+ import { randomUUID as randomUUID5 } from "crypto";
2864
3408
 
2865
3409
  // src/v2/native-rpc.ts
2866
3410
  var NATIVE_RPC_ID = "loopd.native";
@@ -3086,7 +3630,7 @@ function parseModelRef(value) {
3086
3630
  return { providerID, modelID };
3087
3631
  }
3088
3632
  function newPromptMessageID() {
3089
- return `msg_${randomUUID4()}`;
3633
+ return `msg_${randomUUID5()}`;
3090
3634
  }
3091
3635
  function isV2PromptMessageID(messageID) {
3092
3636
  return messageID.startsWith("msg_");
@@ -3195,7 +3739,7 @@ function createRealHost(client, directory) {
3195
3739
  },
3196
3740
  async readSession(sessionID) {
3197
3741
  try {
3198
- const result = await client.session.get({ path: { id: sessionID } });
3742
+ const result = await withTimeout(client.session.get({ path: { id: sessionID } }), 1e4, "OpenCode session.get");
3199
3743
  if (result?.error)
3200
3744
  return;
3201
3745
  const data = result?.data;
@@ -3219,7 +3763,7 @@ function createRealHost(client, directory) {
3219
3763
  },
3220
3764
  async sessionStatus(sessionID) {
3221
3765
  try {
3222
- const result = await client.session.status({});
3766
+ const result = await withTimeout(client.session.status({}), 1e4, "OpenCode session.status");
3223
3767
  if (result?.error)
3224
3768
  return "unknown";
3225
3769
  const data = result?.data;
@@ -3242,15 +3786,15 @@ function createRealHost(client, directory) {
3242
3786
  },
3243
3787
  async abortSession(sessionID) {
3244
3788
  try {
3245
- await client.session.abort({ path: { id: sessionID } });
3789
+ await withTimeout(client.session.abort({ path: { id: sessionID } }), 1e4, "OpenCode session.abort");
3246
3790
  } catch {}
3247
3791
  },
3248
3792
  async readMessages(sessionID, limit = 10) {
3249
3793
  try {
3250
- const result = await client.session.messages({
3794
+ const result = await withTimeout(client.session.messages({
3251
3795
  path: { id: sessionID },
3252
3796
  query: { limit }
3253
- });
3797
+ }), 1e4, "OpenCode session.messages");
3254
3798
  const data = result?.data;
3255
3799
  if (!Array.isArray(data))
3256
3800
  return [];
@@ -3286,7 +3830,7 @@ function createRealHost(client, directory) {
3286
3830
  const identity = model ?? (await this.readSession(sessionID))?.model;
3287
3831
  if (!identity || typeof client.session.summarize !== "function")
3288
3832
  return;
3289
- await client.session.summarize({ path: { id: sessionID }, body: identity });
3833
+ await withTimeout(client.session.summarize({ path: { id: sessionID }, body: identity }), 1e4, "OpenCode session.summarize");
3290
3834
  } catch {}
3291
3835
  },
3292
3836
  async notifyOwner(ownerSessionID, message, agent) {
@@ -3297,7 +3841,7 @@ function createRealHost(client, directory) {
3297
3841
  let resolvedAgent = agent?.trim() || undefined;
3298
3842
  if (!resolvedAgent) {
3299
3843
  try {
3300
- const sess = await client.session.get({ path: { id: ownerSessionID } });
3844
+ const sess = await withTimeout(client.session.get({ path: { id: ownerSessionID } }), 1e4, "OpenCode session.get (parent identity)");
3301
3845
  const liveAgent = sess?.data?.agent;
3302
3846
  if (typeof liveAgent === "string" && liveAgent.trim())
3303
3847
  resolvedAgent = liveAgent.trim();
@@ -3332,6 +3876,7 @@ function toWorkerCreation(result) {
3332
3876
  function createV2Host(context, statuses, options = {}) {
3333
3877
  const directory = context.location.directory;
3334
3878
  return {
3879
+ scopedExecution: true,
3335
3880
  async listAgents() {
3336
3881
  const switching = typeof context.session.switchAgent === "function" ? "session" : "unsupported";
3337
3882
  if (typeof context.agent?.list !== "function")
@@ -3346,7 +3891,7 @@ function createV2Host(context, statuses, options = {}) {
3346
3891
  async switchSessionAgent(sessionID, agent) {
3347
3892
  if (typeof context.session.switchAgent !== "function")
3348
3893
  return "unsupported";
3349
- await context.session.switchAgent({ sessionID, agent });
3894
+ await withTimeout(context.session.switchAgent({ sessionID, agent }), 1e4, "OpenCode v2 session.switchAgent");
3350
3895
  return "applied";
3351
3896
  },
3352
3897
  async listModels() {
@@ -3369,7 +3914,7 @@ function createV2Host(context, statuses, options = {}) {
3369
3914
  async switchSessionModel(sessionID, model) {
3370
3915
  if (typeof context.session.switchModel !== "function")
3371
3916
  return "unsupported";
3372
- await context.session.switchModel({ sessionID, model: { id: model.modelID, providerID: model.providerID } });
3917
+ await withTimeout(context.session.switchModel({ sessionID, model: { id: model.modelID, providerID: model.providerID } }), 1e4, "OpenCode v2 session.switchModel");
3373
3918
  return "applied";
3374
3919
  },
3375
3920
  async createWorker({ parentID, title, agent, model, goalID }) {
@@ -3394,7 +3939,7 @@ function createV2Host(context, statuses, options = {}) {
3394
3939
  }
3395
3940
  if (outcome.kind === "native-child") {
3396
3941
  const childID = outcome.childSessionID;
3397
- const child = await context.session.get({ sessionID: childID });
3942
+ const child = await withTimeout(context.session.get({ sessionID: childID }), 1e4, "OpenCode v2 session.get (native parent verify)");
3398
3943
  const actualParent = resolveNativeParentID(child);
3399
3944
  if (actualParent !== parentID) {
3400
3945
  const detail = `resolved-parent=${JSON.stringify(actualParent)} expected=${JSON.stringify(parentID)}`;
@@ -3402,16 +3947,16 @@ function createV2Host(context, statuses, options = {}) {
3402
3947
  throw new Error(`loopd native worker creation failed for parent "${parentID}" (parent-mismatch): ${detail}`);
3403
3948
  }
3404
3949
  if (agent)
3405
- await context.session.switchAgent({ sessionID: childID, agent });
3950
+ await withTimeout(context.session.switchAgent({ sessionID: childID, agent }), 1e4, "OpenCode v2 session.switchAgent (native re-apply)");
3406
3951
  if (model) {
3407
- await context.session.switchModel({
3952
+ await withTimeout(context.session.switchModel({
3408
3953
  sessionID: childID,
3409
3954
  model: { id: model.modelID, providerID: model.providerID }
3410
- });
3955
+ }), 1e4, "OpenCode v2 session.switchModel (native re-apply)");
3411
3956
  }
3412
3957
  const rename = context.session.update;
3413
3958
  if (rename)
3414
- await rename({ sessionID: childID, title });
3959
+ await withTimeout(rename({ sessionID: childID, title }), 1e4, "OpenCode v2 session.update (native title)");
3415
3960
  statuses.set(childID, "idle");
3416
3961
  await logServerEvent(directory, "worker.created", {
3417
3962
  parentID,
@@ -3428,13 +3973,13 @@ function createV2Host(context, statuses, options = {}) {
3428
3973
  reason: outcome.kind === "fallback-safe" ? outcome.reason : "unclaimed"
3429
3974
  });
3430
3975
  }
3431
- const session = await context.session.create({
3976
+ const session = await withTimeout(context.session.create({
3432
3977
  title,
3433
3978
  ...agent !== undefined ? { agent } : {},
3434
3979
  ...model !== undefined ? { model: { id: model.modelID, providerID: model.providerID } } : {},
3435
3980
  location: { directory },
3436
3981
  metadata: { "loopd.parentID": parentID }
3437
- });
3982
+ }), 1e4, "OpenCode v2 session.create");
3438
3983
  statuses.set(session.id, "idle");
3439
3984
  await logServerEvent(directory, "worker.created", {
3440
3985
  parentID,
@@ -3446,28 +3991,28 @@ function createV2Host(context, statuses, options = {}) {
3446
3991
  },
3447
3992
  async promptWorker({ sessionID, prompt, messageID, model, agent }) {
3448
3993
  if (agent)
3449
- await context.session.switchAgent({ sessionID, agent });
3994
+ await withTimeout(context.session.switchAgent({ sessionID, agent }), 1e4, "OpenCode v2 session.switchAgent (prompt)");
3450
3995
  if (model) {
3451
- await context.session.switchModel({
3996
+ await withTimeout(context.session.switchModel({
3452
3997
  sessionID,
3453
3998
  model: { id: model.modelID, providerID: model.providerID }
3454
- });
3999
+ }), 1e4, "OpenCode v2 session.switchModel (prompt)");
3455
4000
  }
3456
4001
  if (messageID !== undefined && !isV2PromptMessageID(messageID)) {
3457
4002
  throw new Error(`loopd prompt ID "${messageID}" is invalid for OpenCode v2: must start with "msg_". ` + `Generate IDs with newPromptMessageID() so persisted, delivered, and correlated IDs agree.`);
3458
4003
  }
3459
- const result = await context.session.prompt({
4004
+ const result = await withTimeout(context.session.prompt({
3460
4005
  sessionID,
3461
4006
  id: messageID,
3462
4007
  text: prompt
3463
- });
4008
+ }), 30000, "OpenCode v2 session.prompt (acceptance)");
3464
4009
  statuses.set(sessionID, "busy");
3465
4010
  await logServerEvent(directory, "worker.prompted", { sessionID });
3466
4011
  return { messageID: result?.id };
3467
4012
  },
3468
4013
  async readSession(sessionID) {
3469
4014
  try {
3470
- const session = await context.session.get({ sessionID });
4015
+ const session = await withTimeout(context.session.get({ sessionID }), 1e4, "OpenCode v2 session.get");
3471
4016
  const model = session.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined;
3472
4017
  if (!session.agent && !model)
3473
4018
  return;
@@ -3487,7 +4032,7 @@ function createV2Host(context, statuses, options = {}) {
3487
4032
  },
3488
4033
  async readMessages(sessionID, limit = 10) {
3489
4034
  try {
3490
- const messages = await context.session.context({ sessionID });
4035
+ const messages = await withTimeout(context.session.context({ sessionID }), 1e4, "OpenCode v2 session.context");
3491
4036
  let parentMessageID;
3492
4037
  return messages.flatMap((message) => {
3493
4038
  if (message.type === "user") {
@@ -3531,14 +4076,14 @@ function createV2Host(context, statuses, options = {}) {
3531
4076
  if (model) {
3532
4077
  if (typeof context.session.switchModel !== "function")
3533
4078
  return;
3534
- await context.session.switchModel({ sessionID, model: { id: model.modelID, providerID: model.providerID } });
4079
+ await withTimeout(context.session.switchModel({ sessionID, model: { id: model.modelID, providerID: model.providerID } }), 1e4, "OpenCode v2 session.switchModel (compact)");
3535
4080
  }
3536
- await context.session.command({ sessionID, name: "compact", text: "" });
4081
+ await withTimeout(context.session.command({ sessionID, name: "compact", text: "" }), 1e4, "OpenCode v2 session.command (compact)");
3537
4082
  } catch {}
3538
4083
  },
3539
4084
  async notifyOwner(ownerSessionID, message) {
3540
4085
  try {
3541
- await context.session.prompt({ sessionID: ownerSessionID, text: message });
4086
+ await withTimeout(context.session.prompt({ sessionID: ownerSessionID, text: message }), 1e4, "OpenCode v2 parent notify (acceptance)");
3542
4087
  await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
3543
4088
  return true;
3544
4089
  } catch (error) {
@@ -3650,6 +4195,20 @@ function createWorkerManager(host) {
3650
4195
  }
3651
4196
  function buildContinuationSteering(goal, runtime, context) {
3652
4197
  const parts = [];
4198
+ const scopeInstructions = goal.config.write_scope !== undefined ? [
4199
+ ``,
4200
+ `## FILE OWNERSHIP`,
4201
+ `write_scope: ${JSON.stringify(goal.config.write_scope)} (exact files; [] means exploration-only).`,
4202
+ `Explore with reads/search \u2192 claim_goal_scope({ paths, runGeneration from get_goal }) \u2192 structured edit/write/apply_patch \u2192 run_goal_checks \u2192 report progress or complete_goal.`,
4203
+ `Claim only the minimum files needed. Request expansion BEFORE editing new paths; rename requires both endpoints. No globs.`,
4204
+ `Conflicts identify owning goal/session/path. Initial and expansion conflicts fail immediately with no partial claim and no automatic queue. Coordinate with the owner, then retry once; never sleep, poll claims, or repeatedly prompt waiting workers.`,
4205
+ `Arbitrary shell, loopd_command_start/write, custom tools, batch, and subagent writes are denied. Configured checks are trusted commands under a workspace-wide operation lock, not an OS sandbox.`,
4206
+ `Output-location exceptions do not grant extra source files. Only this goal's artifact directory is writable without source claims.`
4207
+ ] : goal.config.workspaceWrite === false ? [
4208
+ ``,
4209
+ `## READ-ONLY OWNERSHIP`,
4210
+ `Shared source writes and arbitrary shell/custom/subagent tools are denied. Write artifacts only under this goal's artifact directory. Use run_goal_checks for trusted configured verification.`
4211
+ ] : [];
3653
4212
  const artifactDir = goal.config.artifactDir;
3654
4213
  function outputLocationBlock() {
3655
4214
  if (!artifactDir)
@@ -3733,11 +4292,14 @@ function buildContinuationSteering(goal, runtime, context) {
3733
4292
  parts.push(`- ${msg}`);
3734
4293
  }
3735
4294
  }
4295
+ parts.push(...scopeInstructions);
3736
4296
  return parts.join(`
3737
4297
  `);
3738
4298
  }
3739
4299
 
3740
4300
  // src/application/goal-service.ts
4301
+ init_workspace_execution();
4302
+
3741
4303
  class GoalStartError extends Error {
3742
4304
  goalID;
3743
4305
  workerSessionID;
@@ -3772,13 +4334,9 @@ function createGoalService(host) {
3772
4334
  }
3773
4335
  }
3774
4336
  function assertWorkspaceWriteAvailable(state, goal, requesterSessionID) {
3775
- if (!goal.config.workspaceWrite)
3776
- return;
3777
- const activeWriter = state.goals.find((item) => item.id !== goal.id && item.status === "active" && item.config.workspaceWrite);
3778
- if (activeWriter) {
3779
- const ownedElsewhere = requesterSessionID !== undefined && activeWriter.ownerSessionID !== requesterSessionID;
3780
- throw new Error(`Workspace-writing goal "${activeWriter.name}" (${activeWriter.id}) is already active` + (ownedElsewhere ? ` (owned by session ${activeWriter.ownerSessionID}, not this session)` : "") + ". Pause, block, complete, or clear it before activating another workspace-writing goal." + (ownedElsewhere ? " Note: list_background_goals shows only this session's goals; clear/pause it from its owning session." : ""));
3781
- }
4337
+ if (goal.config.write_scope !== undefined && !host.scopedExecution)
4338
+ throw new Error("Enforced file scopes are unsupported on this host. Use the guarded v2 tool execution surface; v1 retains legacy exclusivity only.");
4339
+ assertScopeAvailable(state, goal);
3782
4340
  }
3783
4341
  async function listModels() {
3784
4342
  return host.listModels ? host.listModels() : emptyCatalog("host catalog capability absent", "unsupported");
@@ -3838,6 +4396,8 @@ function createGoalService(host) {
3838
4396
  throw new Error("Cannot switch a completed goal.");
3839
4397
  if (opts?.resume && goal.status !== "blocked")
3840
4398
  throw new Error("resume is only valid for a blocked goal.");
4399
+ if (opts?.resume && goal.config.write_scope !== undefined)
4400
+ throw new Error("Scoped identity-resume is unsupported. Use resume_goal to reacquire and fence the session, then switch identity.");
3841
4401
  if (opts?.resume)
3842
4402
  assertWorkspaceWriteAvailable(state, goal, ownerSessionID);
3843
4403
  const { agent, catalog } = await validateAgent(value);
@@ -3969,6 +4529,8 @@ function createGoalService(host) {
3969
4529
  if (opts?.resume && (goal.status !== "blocked" || goal.blocker?.kind !== "provider-limit")) {
3970
4530
  throw new Error("resume is only valid for a provider-limit-blocked goal.");
3971
4531
  }
4532
+ if (opts?.resume && goal.config.write_scope !== undefined)
4533
+ throw new Error("Scoped identity-resume is unsupported. Use resume_goal to reacquire and fence the session, then switch identity.");
3972
4534
  if (opts?.resume)
3973
4535
  assertWorkspaceWriteAvailable(state, goal, ownerSessionID);
3974
4536
  const { model, catalog } = await validateModel(value);
@@ -4027,6 +4589,8 @@ function createGoalService(host) {
4027
4589
  throw new Error("Cannot switch a completed goal.");
4028
4590
  if (opts.resume && goal.status !== "blocked")
4029
4591
  throw new Error("resume is only valid for a blocked goal.");
4592
+ if (opts.resume && goal.config.write_scope !== undefined)
4593
+ throw new Error("Scoped identity-resume is unsupported. Use resume_goal to reacquire and fence the session, then switch identity.");
4030
4594
  if (opts.resume)
4031
4595
  assertWorkspaceWriteAvailable(state, goal, ownerSessionID);
4032
4596
  const { model, catalog: models } = await validateModel(opts.model);
@@ -4240,17 +4804,22 @@ function createGoalService(host) {
4240
4804
  await host.compactSession(fresh.workerSessionID, parseModelRef(fresh.config.model));
4241
4805
  });
4242
4806
  }
4243
- async function recordPromptFailure(directory, goalID, error, blockImmediately = false) {
4807
+ async function recordPromptFailure(directory, goalID, error, blockImmediately = false, expectedRunID) {
4244
4808
  const detail = describeError(error);
4245
4809
  let runID = "unknown";
4246
4810
  let failureCount = 0;
4247
4811
  let blocked = false;
4812
+ let superseded = false;
4248
4813
  let blockerNeeded = "Retry after the OpenCode worker/session API is available.";
4249
4814
  const state = await mutateState(directory, `turn.prompt-failed:${goalID}`, async (s) => {
4250
4815
  const goal = s.goals.find((item) => item.id === goalID);
4251
4816
  const rt = s.runtimes.find((item) => item.goalID === goalID);
4252
4817
  if (!goal || !rt)
4253
4818
  return s;
4819
+ if (expectedRunID !== undefined && rt.activeRunID !== expectedRunID) {
4820
+ superseded = true;
4821
+ return s;
4822
+ }
4254
4823
  runID = rt.activeRunID || "unknown";
4255
4824
  failureCount = rt.consecutiveFailures + 1;
4256
4825
  Object.assign(rt, releaseLease(rt));
@@ -4275,9 +4844,14 @@ function createGoalService(host) {
4275
4844
  rt.updatedAt = new Date().toISOString();
4276
4845
  return s;
4277
4846
  });
4847
+ if (superseded) {
4848
+ await logServerEvent(directory, "turn.prompt-failed-superseded", { goalID, expectedRunID }).catch(() => {});
4849
+ await observeProviderErrorUnlocked(directory, goalID, error, "prompt-delivery");
4850
+ return;
4851
+ }
4278
4852
  await appendEvent(directory, {
4279
4853
  version: 1,
4280
- eventID: randomUUID5(),
4854
+ eventID: randomUUID6(),
4281
4855
  goalID,
4282
4856
  type: "run.failed",
4283
4857
  runID,
@@ -4289,7 +4863,7 @@ function createGoalService(host) {
4289
4863
  if (blocked) {
4290
4864
  await appendEvent(directory, {
4291
4865
  version: 1,
4292
- eventID: randomUUID5(),
4866
+ eventID: randomUUID6(),
4293
4867
  goalID,
4294
4868
  type: "goal.blocked",
4295
4869
  reason: `Worker prompt delivery failed: ${detail}`,
@@ -4300,6 +4874,41 @@ function createGoalService(host) {
4300
4874
  }
4301
4875
  await observeProviderErrorUnlocked(directory, goalID, error, "prompt-delivery");
4302
4876
  }
4877
+ function quotaHoldMessage(goal) {
4878
+ const observation = goal.lastProviderLimit;
4879
+ const retryMs = observation?.retryAt ? Date.parse(observation.retryAt) : NaN;
4880
+ if (!observation || !Number.isFinite(retryMs) || retryMs <= Date.now())
4881
+ return;
4882
+ if (observation.model && observation.model !== goal.config.model)
4883
+ return;
4884
+ const kind = observation.kind === "quota" ? "quota exhausted" : "rate-limited";
4885
+ const pending = goal.modelSwitch?.pending?.reason === "quota" ? ` A quota fallback to "${goal.modelSwitch.pending.model}" is prepared and applies on the next idle turn.` : "";
4886
+ return `Model "${goal.config.model ?? "default"}" is provider-${kind} ` + `(observed ${observation.observedAt}; provider says retry after ${observation.retryAt}).` + `${pending} Not re-prompting: nudging a limited model burns quota without helping. ` + `Wait for the retry time, switch model, or use retry_goal afterwards.`;
4887
+ }
4888
+ async function wasPromptAccepted(directory, workerSessionID, promptMessageID) {
4889
+ if (!promptMessageID)
4890
+ return false;
4891
+ try {
4892
+ const tail = await withStageTimeout(host.readMessages(workerSessionID, 10), PROMPT_VERIFY_TIMEOUT_MS, `late prompt-acceptance verify for session "${workerSessionID}"`);
4893
+ const found = tail.some((m) => m.messageID === promptMessageID || m.parentMessageID === promptMessageID);
4894
+ if (found) {
4895
+ await logServerEvent(directory, "worker.prompted-late", { workerSessionID, promptMessageID }).catch(() => {});
4896
+ }
4897
+ return found;
4898
+ } catch {
4899
+ return false;
4900
+ }
4901
+ }
4902
+ async function dispatchOrRecordFailure(directory, goalID, dispatch, run, workerSessionID, blockImmediately = false) {
4903
+ try {
4904
+ await dispatch();
4905
+ } catch (error) {
4906
+ if (await wasPromptAccepted(directory, workerSessionID, run.activePromptMessageID))
4907
+ return;
4908
+ await recordPromptFailure(directory, goalID, error, blockImmediately, run.activeRunID);
4909
+ throw error;
4910
+ }
4911
+ }
4303
4912
  async function ensureWorkerSession(directory, goal) {
4304
4913
  let session = sessions.get(goal.id);
4305
4914
  if (session)
@@ -4329,10 +4938,12 @@ function createGoalService(host) {
4329
4938
  return session;
4330
4939
  }
4331
4940
  async function start(directory, input) {
4332
- const id = randomUUID5();
4941
+ const id = randomUUID6();
4333
4942
  return withGoalOperation(id, () => startUnlocked(directory, input, id));
4334
4943
  }
4335
4944
  async function startUnlocked(directory, input, id) {
4945
+ if (input.config?.write_scope !== undefined && !host.scopedExecution)
4946
+ throw new Error("Enforced file scopes are unsupported on this host. Use the guarded v2 tool execution surface; v1 retains legacy exclusivity only.");
4336
4947
  if (input.config?.fallbackModels?.length) {
4337
4948
  if (input.config.fallbackModels.length > 16)
4338
4949
  throw new Error("At most 16 ordered fallback models are allowed.");
@@ -4370,6 +4981,15 @@ function createGoalService(host) {
4370
4981
  ...input.config
4371
4982
  }
4372
4983
  });
4984
+ if (goal.config.write_scope !== undefined) {
4985
+ if (!Array.isArray(goal.config.write_scope))
4986
+ throw new Error("write_scope must be an array of exact file paths");
4987
+ if (!goal.config.workspaceWrite && goal.config.write_scope.length > 0) {
4988
+ throw new Error("Read-only/artifact goals cannot claim shared workspace files.");
4989
+ }
4990
+ await Promise.resolve().then(() => init_workspace_scope());
4991
+ goal.config.write_scope = await canonicalWriteScope(directory, goal.config.write_scope);
4992
+ }
4373
4993
  if (typeof input.costBudget === "number")
4374
4994
  goal.costBudget = input.costBudget;
4375
4995
  if (input.interactive === true)
@@ -4381,7 +5001,7 @@ function createGoalService(host) {
4381
5001
  const artifactDir = goalArtifactDir(directory, id);
4382
5002
  goal.config.artifactDir = artifactDir;
4383
5003
  if (!goal.config.progressFile)
4384
- goal.config.progressFile = path2.join(artifactDir, "progress.md");
5004
+ goal.config.progressFile = path4.join(artifactDir, "progress.md");
4385
5005
  await ensureGoalArtifactDir(directory, id);
4386
5006
  const state1 = await mutateState(directory, `goal.create:${id}`, async (state) => {
4387
5007
  assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
@@ -4425,7 +5045,7 @@ function createGoalService(host) {
4425
5045
  });
4426
5046
  await appendEvent(directory, {
4427
5047
  version: 1,
4428
- eventID: randomUUID5(),
5048
+ eventID: randomUUID6(),
4429
5049
  goalID: id,
4430
5050
  type: "goal.blocked",
4431
5051
  reason: detail,
@@ -4452,7 +5072,7 @@ function createGoalService(host) {
4452
5072
  const rt = state.runtimes.find((item) => item.goalID === id);
4453
5073
  if (rt) {
4454
5074
  Object.assign(rt, acquireLease(rt, g.config.timeoutMs || 300000));
4455
- rt.activeRunID = randomUUID5();
5075
+ rt.activeRunID = randomUUID6();
4456
5076
  rt.activePromptMessageID = newPromptMessageID();
4457
5077
  rt.runCount = 1;
4458
5078
  rt.budgetTurnCount = 1;
@@ -4463,7 +5083,7 @@ function createGoalService(host) {
4463
5083
  runtime = state2.runtimes.find((r) => r.goalID === id);
4464
5084
  await appendEvent(directory, {
4465
5085
  version: 1,
4466
- eventID: randomUUID5(),
5086
+ eventID: randomUUID6(),
4467
5087
  goalID: id,
4468
5088
  type: "goal.created",
4469
5089
  name: input.name,
@@ -4475,7 +5095,7 @@ function createGoalService(host) {
4475
5095
  if (runtime) {
4476
5096
  await appendEvent(directory, {
4477
5097
  version: 1,
4478
- eventID: randomUUID5(),
5098
+ eventID: randomUUID6(),
4479
5099
  goalID: id,
4480
5100
  type: "run.started",
4481
5101
  runID: runtime.activeRunID,
@@ -4484,9 +5104,8 @@ function createGoalService(host) {
4484
5104
  revision: state2.revision
4485
5105
  });
4486
5106
  try {
4487
- await workers.continueWorker(worker, goal, runtime);
5107
+ await dispatchOrRecordFailure(directory, id, () => workers.continueWorker(worker, goal, runtime), runtime, worker.workerSessionID, true);
4488
5108
  } catch (error) {
4489
- await recordPromptFailure(directory, id, error, true);
4490
5109
  throw new GoalStartError(describeError(error), {
4491
5110
  goalID: id,
4492
5111
  workerSessionID: worker.workerSessionID,
@@ -4558,12 +5177,23 @@ function createGoalService(host) {
4558
5177
  const goal = preState.goals.find((g) => g.id === goalID);
4559
5178
  if (!goal || goal.status !== "active")
4560
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
+ }
4561
5187
  const runtime = preState.runtimes.find((r) => r.goalID === goalID);
4562
5188
  if (!runtime)
4563
5189
  return;
4564
5190
  if (runtime.phase === "running" && leaseIsValid(runtime))
4565
5191
  return;
4566
5192
  let session = sessions.get(goalID);
5193
+ if (session && session.workerSessionID !== goal.workerSessionID) {
5194
+ sessions.delete(goalID);
5195
+ session = undefined;
5196
+ }
4567
5197
  if (!session && goal.workerSessionID) {
4568
5198
  session = {
4569
5199
  goalID: goal.id,
@@ -4572,6 +5202,8 @@ function createGoalService(host) {
4572
5202
  };
4573
5203
  sessions.set(goalID, session);
4574
5204
  }
5205
+ if (!session && !goal.workerSessionID)
5206
+ session = await ensureWorkerSession(directory, goal);
4575
5207
  if (!session)
4576
5208
  return;
4577
5209
  if (!opts?.force && !await workers.isIdle(session.workerSessionID))
@@ -4595,7 +5227,7 @@ function createGoalService(host) {
4595
5227
  return s;
4596
5228
  const timeoutMs = g.config.timeoutMs || 300000;
4597
5229
  Object.assign(rt, acquireLease(rt, timeoutMs));
4598
- rt.activeRunID = randomUUID5();
5230
+ rt.activeRunID = randomUUID6();
4599
5231
  rt.activePromptMessageID = newPromptMessageID();
4600
5232
  rt.runCount += 1;
4601
5233
  if (rt.freeRetryPending) {
@@ -4613,7 +5245,7 @@ function createGoalService(host) {
4613
5245
  return;
4614
5246
  await appendEvent(directory, {
4615
5247
  version: 1,
4616
- eventID: randomUUID5(),
5248
+ eventID: randomUUID6(),
4617
5249
  goalID,
4618
5250
  type: "run.started",
4619
5251
  runID: freshRuntime.activeRunID,
@@ -4626,12 +5258,7 @@ function createGoalService(host) {
4626
5258
  const bareText = bareWords.join(`
4627
5259
  `).trim();
4628
5260
  if (bareText) {
4629
- try {
4630
- await workers.sendBare(session, freshGoal, freshRuntime, bareText);
4631
- } catch (error) {
4632
- await recordPromptFailure(directory, goalID, error);
4633
- throw error;
4634
- }
5261
+ await dispatchOrRecordFailure(directory, goalID, () => workers.sendBare(session, freshGoal, freshRuntime, bareText), freshRuntime, session.workerSessionID);
4635
5262
  return;
4636
5263
  }
4637
5264
  }
@@ -4654,7 +5281,7 @@ function createGoalService(host) {
4654
5281
  const artifactDir = freshGoal.config.artifactDir;
4655
5282
  if (artifactDir) {
4656
5283
  try {
4657
- const files = await fs2.readdir(artifactDir);
5284
+ const files = await fs4.readdir(artifactDir);
4658
5285
  verification = { artifactSummary: files.length ? `${files.length} file(s): ${files.slice(0, 8).join(", ")}` : "no artifacts yet" };
4659
5286
  } catch {
4660
5287
  verification = { artifactSummary: "no artifacts yet" };
@@ -4678,12 +5305,7 @@ function createGoalService(host) {
4678
5305
  forceFinish: opts?.forceFinish || undefined,
4679
5306
  verification
4680
5307
  };
4681
- try {
4682
- await workers.continueWorker(session, freshGoal, freshRuntime, context);
4683
- } catch (error) {
4684
- await recordPromptFailure(directory, goalID, error);
4685
- throw error;
4686
- }
5308
+ await dispatchOrRecordFailure(directory, goalID, () => workers.continueWorker(session, freshGoal, freshRuntime, context), freshRuntime, session.workerSessionID);
4687
5309
  }
4688
5310
  async function setInteractiveUnlocked(directory, goalID, interactive) {
4689
5311
  const state = await mutateState(directory, `goal.interactive:${goalID}`, async (s) => {
@@ -4702,7 +5324,7 @@ function createGoalService(host) {
4702
5324
  return { ok: false, message: "Goal not found." };
4703
5325
  await appendEvent(directory, {
4704
5326
  version: 1,
4705
- eventID: randomUUID5(),
5327
+ eventID: randomUUID6(),
4706
5328
  goalID,
4707
5329
  type: "goal.interactive_changed",
4708
5330
  interactive,
@@ -4724,11 +5346,25 @@ function createGoalService(host) {
4724
5346
  return;
4725
5347
  if (!canTransition(goal.status, "paused", "user"))
4726
5348
  return;
5349
+ if (goal.config.write_scope !== undefined) {
5350
+ await mutateState(directory, `goal.pause-closing:${goalID}`, async (s) => {
5351
+ const g = s.goals.find((item) => item.id === goalID);
5352
+ if (g) {
5353
+ g.scopeClosing = true;
5354
+ retireWorker(s, g);
5355
+ }
5356
+ return s;
5357
+ });
5358
+ if (goal.workerSessionID)
5359
+ await workers.abortWorker(goal.workerSessionID);
5360
+ sessions.delete(goalID);
5361
+ }
4727
5362
  const state = await mutateState(directory, `goal.pause:${goalID}`, async (s) => {
4728
5363
  const g = s.goals.find((item) => item.id === goalID);
4729
5364
  if (!g)
4730
5365
  return s;
4731
5366
  g.status = "paused";
5367
+ g.scopeClosing = false;
4732
5368
  g.updatedAt = new Date().toISOString();
4733
5369
  const rt = s.runtimes.find((r) => r.goalID === goalID);
4734
5370
  if (rt) {
@@ -4742,14 +5378,14 @@ function createGoalService(host) {
4742
5378
  workerSessionID: goal.workerSessionID,
4743
5379
  startedAt: goal.createdAt
4744
5380
  } : undefined);
4745
- if (session) {
5381
+ if (session && goal.config.write_scope === undefined) {
4746
5382
  await workers.abortWorker(session.workerSessionID);
4747
5383
  sessions.delete(goalID);
4748
5384
  }
4749
5385
  await cancelAwaitsForGoal(directory, goalID, "goal paused").catch(() => {});
4750
5386
  await appendEvent(directory, {
4751
5387
  version: 1,
4752
- eventID: randomUUID5(),
5388
+ eventID: randomUUID6(),
4753
5389
  goalID,
4754
5390
  type: "goal.status_changed",
4755
5391
  from: "active",
@@ -4758,7 +5394,36 @@ function createGoalService(host) {
4758
5394
  revision: state.revision
4759
5395
  });
4760
5396
  }
5397
+ async function withStageTimeout(promise, ms, stage) {
5398
+ let timer;
5399
+ try {
5400
+ return await Promise.race([
5401
+ promise,
5402
+ new Promise((_, reject) => {
5403
+ timer = setTimeout(() => reject(new Error(`${stage} timed out after ${ms}ms`)), ms);
5404
+ })
5405
+ ]);
5406
+ } finally {
5407
+ if (timer)
5408
+ clearTimeout(timer);
5409
+ }
5410
+ }
5411
+ const PROBE_TIMEOUT_MS = 15000;
5412
+ const PROMPT_VERIFY_TIMEOUT_MS = 12000;
5413
+ async function probeWorkerLiveness(workerSessionID) {
5414
+ if (!workerSessionID)
5415
+ return "dead";
5416
+ try {
5417
+ const status = await withStageTimeout(host.sessionStatus(workerSessionID), PROBE_TIMEOUT_MS, `worker liveness probe for session "${workerSessionID}"`);
5418
+ return status === "unknown" ? "unknown" : "live";
5419
+ } catch {
5420
+ return "unknown";
5421
+ }
5422
+ }
4761
5423
  async function resumeUnlocked(directory, goalID) {
5424
+ const preState = await readState(directory);
5425
+ const preGoal = preState.goals.find((g) => g.id === goalID);
5426
+ const workerLiveness = await probeWorkerLiveness(preGoal?.workerSessionID);
4762
5427
  let resumed = false;
4763
5428
  const state = await mutateState(directory, `goal.resume:${goalID}`, async (state) => {
4764
5429
  const goal = state.goals.find((g) => g.id === goalID);
@@ -4766,6 +5431,29 @@ function createGoalService(host) {
4766
5431
  return state;
4767
5432
  if (!canTransition(goal.status, "active", "user"))
4768
5433
  return state;
5434
+ const liveness = { isWorkerLive: (_gid, _wsid) => _gid === goalID ? workerLiveness !== "dead" : false, now: Date.now() };
5435
+ sweepStaleWorkspaceState(state, liveness);
5436
+ const rt = state.runtimes.find((r) => r.goalID === goalID);
5437
+ 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);
5439
+ if (blocking.length) {
5440
+ 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.`);
5442
+ }
5443
+ throw new Error("Goal still has closing/in-flight writes; ownership cannot be reacquired before quiescence.");
5444
+ }
5445
+ if (goal.config.write_scope !== undefined) {
5446
+ retireWorker(state, goal);
5447
+ goal.workerSessionID = undefined;
5448
+ sessions.delete(goalID);
5449
+ if (rt) {
5450
+ rt.runGeneration += 1;
5451
+ rt.updatedAt = new Date().toISOString();
5452
+ }
5453
+ }
5454
+ goal.scopeClosing = false;
5455
+ goal.scopeClearPending = false;
5456
+ state.workspaceCalls = (state.workspaceCalls ?? []).filter((call) => call.goalID !== goalID);
4769
5457
  assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
4770
5458
  goal.status = "active";
4771
5459
  goal.updatedAt = new Date().toISOString();
@@ -4788,7 +5476,7 @@ function createGoalService(host) {
4788
5476
  }
4789
5477
  await appendEvent(directory, {
4790
5478
  version: 1,
4791
- eventID: randomUUID5(),
5479
+ eventID: randomUUID6(),
4792
5480
  goalID,
4793
5481
  type: "goal.status_changed",
4794
5482
  from: "paused",
@@ -4799,11 +5487,36 @@ function createGoalService(host) {
4799
5487
  await continueTurnUnlocked(directory, goalID);
4800
5488
  }
4801
5489
  async function retryUnlocked(directory, goalID) {
5490
+ const preState = await readState(directory);
5491
+ const workerLiveness = await probeWorkerLiveness(preState.goals.find((g) => g.id === goalID)?.workerSessionID);
4802
5492
  let retried = false;
4803
5493
  const state = await mutateState(directory, `goal.retry:${goalID}`, async (state) => {
4804
5494
  const goal = state.goals.find((g) => g.id === goalID);
4805
5495
  if (!goal || goal.status !== "blocked")
4806
5496
  return state;
5497
+ const liveness = { isWorkerLive: (_gid, _wsid) => _gid === goalID ? workerLiveness !== "dead" : false, now: Date.now() };
5498
+ 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));
5501
+ if (blocking.length) {
5502
+ 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.`);
5504
+ }
5505
+ throw new Error("Goal still has closing/in-flight writes; ownership cannot be reacquired before quiescence.");
5506
+ }
5507
+ if (goal.config.write_scope !== undefined) {
5508
+ retireWorker(state, goal);
5509
+ goal.workerSessionID = undefined;
5510
+ sessions.delete(goalID);
5511
+ const rt = state.runtimes.find((r) => r.goalID === goalID);
5512
+ if (rt) {
5513
+ rt.runGeneration += 1;
5514
+ rt.updatedAt = new Date().toISOString();
5515
+ }
5516
+ }
5517
+ goal.scopeClosing = false;
5518
+ goal.scopeClearPending = false;
5519
+ state.workspaceCalls = (state.workspaceCalls ?? []).filter((call) => call.goalID !== goalID);
4807
5520
  assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
4808
5521
  goal.status = "active";
4809
5522
  goal.updatedAt = new Date().toISOString();
@@ -4840,7 +5553,7 @@ function createGoalService(host) {
4840
5553
  }
4841
5554
  await appendEvent(directory, {
4842
5555
  version: 1,
4843
- eventID: randomUUID5(),
5556
+ eventID: randomUUID6(),
4844
5557
  goalID,
4845
5558
  type: "goal.status_changed",
4846
5559
  from: "blocked",
@@ -4851,33 +5564,36 @@ function createGoalService(host) {
4851
5564
  await continueTurnUnlocked(directory, goalID);
4852
5565
  }
4853
5566
  async function clearUnlocked(directory, goalID) {
4854
- const state = await readState(directory);
4855
- const goal = state.goals.find((g) => g.id === goalID);
4856
- if (!goal)
4857
- return;
4858
- const session = sessions.get(goalID) || (goal.workerSessionID ? {
4859
- goalID: goal.id,
4860
- workerSessionID: goal.workerSessionID,
4861
- startedAt: goal.createdAt
4862
- } : undefined);
4863
- if (session) {
4864
- await workers.abortWorker(session.workerSessionID);
4865
- sessions.delete(goalID);
4866
- }
4867
- await mutateState(directory, `goal.clear:${goalID}`, async (s) => {
5567
+ const pre = await readState(directory);
5568
+ const workerID = sessions.get(goalID)?.workerSessionID || pre.goals.find((g) => g.id === goalID)?.workerSessionID;
5569
+ const state = await mutateState(directory, `goal.clear:${goalID}`, async (s) => {
5570
+ const g = s.goals.find((item) => item.id === goalID);
5571
+ if (g)
5572
+ retireWorker(s, g);
5573
+ s.workspaceCalls = (s.workspaceCalls ?? []).filter((call) => call.goalID !== goalID);
5574
+ if (s.workspaceOperation?.goalID === goalID)
5575
+ s.workspaceOperation = undefined;
4868
5576
  s.goals = s.goals.filter((g) => g.id !== goalID);
4869
5577
  s.runtimes = s.runtimes.filter((r) => r.goalID !== goalID);
4870
5578
  s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.goalID !== goalID);
4871
5579
  return s;
4872
5580
  });
5581
+ sessions.delete(goalID);
5582
+ if (workerID) {
5583
+ try {
5584
+ await workers.abortWorker(workerID);
5585
+ } catch {}
5586
+ }
5587
+ await cancelAwaitsForGoal(directory, goalID, "goal cleared").catch(() => {});
4873
5588
  await appendEvent(directory, {
4874
5589
  version: 1,
4875
- eventID: randomUUID5(),
5590
+ eventID: randomUUID6(),
4876
5591
  goalID,
4877
5592
  type: "goal.cleared",
4878
5593
  timestamp: new Date().toISOString(),
4879
5594
  revision: state.revision
4880
5595
  });
5596
+ await logServerEvent(directory, "goal.cleared-terminal", { goalID }).catch(() => {});
4881
5597
  }
4882
5598
  function getWorker(goalID) {
4883
5599
  return sessions.get(goalID);
@@ -4886,12 +5602,46 @@ function createGoalService(host) {
4886
5602
  return new Map(sessions);
4887
5603
  }
4888
5604
  async function reconcile(directory) {
5605
+ try {
5606
+ let summary = { reapedCalls: [], resetClosing: [], finalizedClears: [], forceCleared: [] };
5607
+ const pre = await readState(directory);
5608
+ if (pre.workspaceCalls?.length || pre.goals.some((g) => g.scopeClosing || g.scopeClearPending)) {
5609
+ await mutateState(directory, "reconcile.sweep-stale-workspace", async (s) => {
5610
+ summary = sweepStaleWorkspaceState(s);
5611
+ return s;
5612
+ });
5613
+ }
5614
+ if (summary.reapedCalls.length || summary.resetClosing.length || summary.finalizedClears.length || summary.forceCleared.length) {
5615
+ await logServerEvent(directory, "reconcile.sweep-stale-workspace", {
5616
+ reapedCalls: summary.reapedCalls,
5617
+ resetClosing: summary.resetClosing,
5618
+ finalizedClears: summary.finalizedClears,
5619
+ forceCleared: summary.forceCleared
5620
+ }).catch(() => {});
5621
+ }
5622
+ } catch {}
4889
5623
  const state = await readState(directory);
4890
5624
  for (const goal of state.goals) {
4891
5625
  if (isTerminal(goal.status))
4892
5626
  continue;
4893
5627
  if (goal.status === "paused")
4894
5628
  continue;
5629
+ if (goal.config.write_scope !== undefined && !host.scopedExecution) {
5630
+ await mutateState(directory, `reconcile.unsupported-scope:${goal.id}`, async (s) => {
5631
+ const g = s.goals.find((item) => item.id === goal.id);
5632
+ if (g) {
5633
+ g.scopeClosing = true;
5634
+ retireWorker(s, g);
5635
+ g.status = "blocked";
5636
+ g.blocker = { reason: "Host lacks audited scoped execution guard.", needed: "Load guarded v2 execution surface; do not resume scoped goals on v1.", at: new Date().toISOString() };
5637
+ }
5638
+ return s;
5639
+ });
5640
+ if (goal.workerSessionID)
5641
+ await workers.abortWorker(goal.workerSessionID);
5642
+ sessions.delete(goal.id);
5643
+ continue;
5644
+ }
4895
5645
  if (!goal.workerSessionID) {
4896
5646
  let worker;
4897
5647
  try {
@@ -4967,6 +5717,14 @@ function createGoalService(host) {
4967
5717
  if (goal.status !== "active") {
4968
5718
  return { ok: false, message: `Goal is ${goal.status}; resume or retry it before nudging.` };
4969
5719
  }
5720
+ const quotaHold = quotaHoldMessage(goal);
5721
+ if (quotaHold)
5722
+ return { ok: false, message: quotaHold };
5723
+ if (goal.config.write_scope !== undefined) {
5724
+ 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.` };
5727
+ }
4970
5728
  const cleared = await mutateState(directory, `goal.nudge:${goalID}`, async (s) => {
4971
5729
  const rt = s.runtimes.find((r) => r.goalID === goalID);
4972
5730
  if (!rt)
@@ -4998,6 +5756,10 @@ function createGoalService(host) {
4998
5756
  if (goal.status !== "active") {
4999
5757
  return { ok: true, message: `Queued for "${goal.name}" (goal is ${goal.status}; delivers on the next active turn).` };
5000
5758
  }
5759
+ 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.` };
5762
+ }
5001
5763
  const cleared = await mutateState(directory, `goal.send:${goalID}`, async (s) => {
5002
5764
  const rt = s.runtimes.find((r) => r.goalID === goalID);
5003
5765
  if (!rt)
@@ -5043,6 +5805,11 @@ function createGoalService(host) {
5043
5805
  const goal = preState.goals.find((g) => g.id === goalID);
5044
5806
  if (!goal)
5045
5807
  return { ok: false, message: "Goal not found." };
5808
+ if (goal.config.write_scope !== undefined && goal.status === "active") {
5809
+ await pauseUnlocked(directory, goalID);
5810
+ await resumeUnlocked(directory, goalID);
5811
+ return { ok: true, message: `Protected worker for "${goal.name}" aborted and replaced with a fenced session.` };
5812
+ }
5046
5813
  const workerID = sessions.get(goalID)?.workerSessionID || goal.workerSessionID;
5047
5814
  if (!workerID)
5048
5815
  return { ok: false, message: `Goal "${goal.name}" has no worker session to abort.` };
@@ -5079,12 +5846,13 @@ function createGoalService(host) {
5079
5846
  function accountUsage(directory, goalID) {
5080
5847
  return withGoalOperation(goalID, () => accountUsageUnlocked(directory, goalID));
5081
5848
  }
5082
- return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage, setInteractive, listModels, switchModel, listAgents, switchAgent, switchIdentity, observeProviderError, compact };
5849
+ return { scopedExecution: host.scopedExecution === true, start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage, setInteractive, listModels, switchModel, listAgents, switchAgent, switchIdentity, observeProviderError, compact };
5083
5850
  }
5084
5851
 
5085
5852
  // src/application/schedule-worker.ts
5086
5853
  init_state_repository();
5087
- import { randomUUID as randomUUID6 } from "crypto";
5854
+ import { randomUUID as randomUUID7 } from "crypto";
5855
+ init_workspace_execution();
5088
5856
  function createScheduleWorker(options) {
5089
5857
  const { directory, goalService } = options;
5090
5858
  const intervalMs = options.intervalMs ?? 5000;
@@ -5128,14 +5896,6 @@ function createScheduleWorker(options) {
5128
5896
  continue;
5129
5897
  if (Date.now() < Date.parse(nextAt))
5130
5898
  continue;
5131
- const activeWriter = state.goals.find((g) => g.id !== goal.id && g.status === "active" && g.config.workspaceWrite);
5132
- if (goal.config.workspaceWrite && activeWriter) {
5133
- await logServerEvent(directory, "schedule.skipped-writer-active", {
5134
- goalID: goal.id,
5135
- activeWriter: activeWriter.id
5136
- });
5137
- continue;
5138
- }
5139
5899
  if (runtime.phase === "running" || runtime.phase === "queued" || runtime.phase === "compacting")
5140
5900
  continue;
5141
5901
  if (leaseIsValid(runtime))
@@ -5145,6 +5905,8 @@ function createScheduleWorker(options) {
5145
5905
  const rt = s.runtimes.find((x) => x.goalID === goal.id);
5146
5906
  if (!g || !rt)
5147
5907
  return s;
5908
+ if (g.config.write_scope !== undefined && !goalService.scopedExecution)
5909
+ return s;
5148
5910
  if (g.status !== "complete")
5149
5911
  return s;
5150
5912
  const curCount = rt.scheduleRunCount ?? 0;
@@ -5153,6 +5915,15 @@ function createScheduleWorker(options) {
5153
5915
  const curNext = rt.nextRunAt;
5154
5916
  if (!curNext || Date.now() < Date.parse(curNext))
5155
5917
  return s;
5918
+ try {
5919
+ assertScopeAvailable(s, g);
5920
+ } catch {
5921
+ return s;
5922
+ }
5923
+ if (g.config.write_scope !== undefined) {
5924
+ retireWorker(s, g);
5925
+ g.workerSessionID = undefined;
5926
+ }
5156
5927
  g.status = "active";
5157
5928
  g.updatedAt = new Date().toISOString();
5158
5929
  g.blocker = undefined;
@@ -5172,7 +5943,7 @@ function createScheduleWorker(options) {
5172
5943
  continue;
5173
5944
  await appendEvent(directory, {
5174
5945
  version: 1,
5175
- eventID: randomUUID6(),
5946
+ eventID: randomUUID7(),
5176
5947
  goalID: goal.id,
5177
5948
  type: "schedule.tick",
5178
5949
  scheduleRunCount: count,
@@ -5198,7 +5969,7 @@ function createScheduleWorker(options) {
5198
5969
  }
5199
5970
 
5200
5971
  // src/v2/native-server.ts
5201
- import { randomUUID as randomUUID7 } from "crypto";
5972
+ import { randomUUID as randomUUID8 } from "crypto";
5202
5973
 
5203
5974
  // src/v2/native-bridge.ts
5204
5975
  class NativeBridgeError extends Error {
@@ -5340,7 +6111,7 @@ async function setupNativeServer(context) {
5340
6111
  bridge,
5341
6112
  requestWorker: async (input) => {
5342
6113
  const request = {
5343
- requestID: `req_${randomUUID7()}`,
6114
+ requestID: `req_${randomUUID8()}`,
5344
6115
  goalID: input.goalID ?? "",
5345
6116
  parentSessionID: input.parentSessionID,
5346
6117
  title: input.title,
@@ -5609,9 +6380,9 @@ function createCommandHost(opts) {
5609
6380
  }
5610
6381
 
5611
6382
  // src/application/command-service.ts
5612
- import { randomUUID as randomUUID8 } from "crypto";
5613
- import { promises as fs3 } from "fs";
5614
- import path3 from "path";
6383
+ import { randomUUID as randomUUID9 } from "crypto";
6384
+ import { promises as fs5 } from "fs";
6385
+ import path5 from "path";
5615
6386
 
5616
6387
  // src/domain/command-session.ts
5617
6388
  function normalizeTimeoutSeconds(timeoutSeconds) {
@@ -5865,6 +6636,7 @@ function validateCommandStreamMessage(value) {
5865
6636
  }
5866
6637
 
5867
6638
  // src/application/command-service.ts
6639
+ init_workspace_execution();
5868
6640
  init_command_await2();
5869
6641
  init_state_repository();
5870
6642
  var ANSI_PATTERN2 = new RegExp("\x1B\\[[0-9;?]*[ -/]*[@-~]|\x1B\\][^\x07]*(?:\x07|\x1B\\\\)|\x1B[()][0-9A-Z]", "g");
@@ -5899,7 +6671,7 @@ function shellJoin(command, args) {
5899
6671
  return [command, ...args].map(shellQuote).join(" ");
5900
6672
  }
5901
6673
  function loopCommandsDir(directory) {
5902
- return path3.join(directory, ".opencode", "loopd", "commands");
6674
+ return path5.join(directory, ".opencode", "loopd", "commands");
5903
6675
  }
5904
6676
  function createCommandService(host, opts) {
5905
6677
  const live = new Map;
@@ -5973,7 +6745,7 @@ function createCommandService(host, opts) {
5973
6745
  function watchLedger(directory, commandID, type, extra) {
5974
6746
  return appendEvent(directory, {
5975
6747
  version: 1,
5976
- eventID: randomUUID8(),
6748
+ eventID: randomUUID9(),
5977
6749
  commandID,
5978
6750
  type,
5979
6751
  timestamp: new Date().toISOString(),
@@ -6297,15 +7069,15 @@ function createCommandService(host, opts) {
6297
7069
  await appendCommandLog(directory, id, chunk);
6298
7070
  let retainedBytes = 0;
6299
7071
  let truncated = false;
6300
- const file = path3.join(loopCommandsDir(directory), `${id}.log`);
6301
- const stat = await fs3.stat(file);
7072
+ const file = path5.join(loopCommandsDir(directory), `${id}.log`);
7073
+ const stat = await fs5.stat(file);
6302
7074
  retainedBytes = stat.size;
6303
7075
  if (stat.size > MAX_COMMAND_OUTPUT_BYTES) {
6304
- const fh = await fs3.open(file, "r");
7076
+ const fh = await fs5.open(file, "r");
6305
7077
  try {
6306
7078
  const buf = Buffer.alloc(MAX_COMMAND_OUTPUT_BYTES);
6307
7079
  await fh.read(buf, 0, buf.length, stat.size - buf.length);
6308
- await fs3.writeFile(file, buf);
7080
+ await fs5.writeFile(file, buf);
6309
7081
  } finally {
6310
7082
  await fh.close();
6311
7083
  }
@@ -6450,7 +7222,7 @@ function createCommandService(host, opts) {
6450
7222
  }).catch(() => {});
6451
7223
  await appendEvent(directory, {
6452
7224
  version: 1,
6453
- eventID: randomUUID8(),
7225
+ eventID: randomUUID9(),
6454
7226
  commandID: id,
6455
7227
  type: "command.exited",
6456
7228
  exitCode: info.exitCode,
@@ -6495,90 +7267,96 @@ function createCommandService(host, opts) {
6495
7267
  watchState = initialWatchState();
6496
7268
  }
6497
7269
  const startedAt = new Date;
6498
- const id = randomUUID8();
7270
+ const id = randomUUID9();
6499
7271
  const cwd = input.cwd || directory;
6500
7272
  const useShell = input.shell === true;
6501
7273
  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 };
6502
7274
  const pending = [];
6503
7275
  let ready = false;
6504
- const handle = host.spawn(spawnOpts, (chunk) => {
6505
- if (!ready)
6506
- pending.push({ type: "output", chunk });
6507
- else
6508
- enqueue(id, () => persistOutput(directory, id, chunk)).catch(() => {});
6509
- }, (info) => {
6510
- if (!ready)
6511
- pending.push({ type: "exit", info });
6512
- else
6513
- enqueue(id, () => persistExit(directory, id, info)).catch(() => {});
6514
- });
6515
- const session = createCommandSession({
6516
- id,
6517
- title,
6518
- command,
6519
- args: input.args ?? [],
6520
- cwd,
6521
- ownerSessionID: input.ownerSessionID,
6522
- goalID: input.goalID,
6523
- notifyOnExit: input.notifyOnExit,
6524
- notifySuccessfulExit: true,
6525
- pid: handle.pid,
6526
- cols: input.cols,
6527
- rows: input.rows,
6528
- shell: useShell ? true : undefined,
6529
- timeoutSeconds,
6530
- deadlineAt: timeoutSeconds !== undefined ? computeDeadlineAt(startedAt, timeoutSeconds) : undefined,
6531
- envKeys: input.env ? Object.keys(input.env) : undefined,
6532
- watchFilter,
6533
- watchUntil,
6534
- watchIgnoreCase: input.watchIgnoreCase,
6535
- watchUntilAction: watcher?.untilAction,
6536
- watchState
6537
- });
7276
+ const scopeCallID = `command-start:${id}`;
7277
+ await beforeWorkspaceTool(directory, { tool: "loopd_command_start", sessionID: input.ownerSessionID, callID: scopeCallID }, {});
6538
7278
  try {
6539
- await mutateState(directory, `cmd.start:${id}`, async (s) => {
6540
- s.commands = [...s.commands ?? [], session];
6541
- return s;
7279
+ const handle = host.spawn(spawnOpts, (chunk) => {
7280
+ if (!ready)
7281
+ pending.push({ type: "output", chunk });
7282
+ else
7283
+ enqueue(id, () => persistOutput(directory, id, chunk)).catch(() => {});
7284
+ }, (info) => {
7285
+ if (!ready)
7286
+ pending.push({ type: "exit", info });
7287
+ else
7288
+ enqueue(id, () => persistExit(directory, id, info)).catch(() => {});
6542
7289
  });
6543
- } catch (error) {
6544
- handle.terminate();
6545
- const exited = await Promise.race([
6546
- handle.exited().then(() => true, () => true),
6547
- new Promise((resolve) => setTimeout(() => resolve(false), 1000))
6548
- ]);
6549
- if (!exited && handle.isAlive())
6550
- handle.kill();
6551
- throw error;
7290
+ const session = createCommandSession({
7291
+ id,
7292
+ title,
7293
+ command,
7294
+ args: input.args ?? [],
7295
+ cwd,
7296
+ ownerSessionID: input.ownerSessionID,
7297
+ goalID: input.goalID,
7298
+ notifyOnExit: input.notifyOnExit,
7299
+ notifySuccessfulExit: true,
7300
+ pid: handle.pid,
7301
+ cols: input.cols,
7302
+ rows: input.rows,
7303
+ shell: useShell ? true : undefined,
7304
+ timeoutSeconds,
7305
+ deadlineAt: timeoutSeconds !== undefined ? computeDeadlineAt(startedAt, timeoutSeconds) : undefined,
7306
+ envKeys: input.env ? Object.keys(input.env) : undefined,
7307
+ watchFilter,
7308
+ watchUntil,
7309
+ watchIgnoreCase: input.watchIgnoreCase,
7310
+ watchUntilAction: watcher?.untilAction,
7311
+ watchState
7312
+ });
7313
+ try {
7314
+ await mutateState(directory, `cmd.start:${id}`, async (s) => {
7315
+ s.commands = [...s.commands ?? [], session];
7316
+ return s;
7317
+ });
7318
+ } catch (error) {
7319
+ handle.terminate();
7320
+ const exited = await Promise.race([
7321
+ handle.exited().then(() => true, () => true),
7322
+ new Promise((resolve) => setTimeout(() => resolve(false), 1000))
7323
+ ]);
7324
+ if (!exited && handle.isAlive())
7325
+ handle.kill();
7326
+ throw error;
7327
+ }
7328
+ live.set(id, { handle, buffers: [], bufferedBytes: 0 });
7329
+ if (watcher)
7330
+ watchers.set(id, watcher);
7331
+ if (timeoutSeconds !== undefined)
7332
+ scheduleTimeout(directory, id, input.ownerSessionID, timeoutSeconds);
7333
+ rememberDir(id, directory);
7334
+ await appendEvent(directory, {
7335
+ version: 1,
7336
+ eventID: randomUUID9(),
7337
+ ...input.goalID ? { goalID: input.goalID } : {},
7338
+ commandID: id,
7339
+ type: "command.started",
7340
+ title,
7341
+ timestamp: new Date().toISOString(),
7342
+ revision: 0
7343
+ }).catch(() => {});
7344
+ try {
7345
+ const fresh = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
7346
+ if (fresh)
7347
+ emitBroker(id, { type: "status", command: fresh });
7348
+ } catch {}
7349
+ const initialEvents = pending.splice(0);
7350
+ ready = true;
7351
+ const initialOperations = [];
7352
+ for (const event of initialEvents) {
7353
+ initialOperations.push(event.type === "output" ? enqueue(id, () => persistOutput(directory, id, event.chunk)) : enqueue(id, () => persistExit(directory, id, event.info)));
7354
+ }
7355
+ await Promise.all(initialOperations);
7356
+ return await service.get(directory, id, input.ownerSessionID) ?? session;
7357
+ } finally {
7358
+ await afterWorkspaceTool(directory, input.ownerSessionID, scopeCallID);
6552
7359
  }
6553
- live.set(id, { handle, buffers: [], bufferedBytes: 0 });
6554
- if (watcher)
6555
- watchers.set(id, watcher);
6556
- if (timeoutSeconds !== undefined)
6557
- scheduleTimeout(directory, id, input.ownerSessionID, timeoutSeconds);
6558
- rememberDir(id, directory);
6559
- await appendEvent(directory, {
6560
- version: 1,
6561
- eventID: randomUUID8(),
6562
- ...input.goalID ? { goalID: input.goalID } : {},
6563
- commandID: id,
6564
- type: "command.started",
6565
- title,
6566
- timestamp: new Date().toISOString(),
6567
- revision: 0
6568
- }).catch(() => {});
6569
- try {
6570
- const fresh = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
6571
- if (fresh)
6572
- emitBroker(id, { type: "status", command: fresh });
6573
- } catch {}
6574
- const initialEvents = pending.splice(0);
6575
- ready = true;
6576
- const initialOperations = [];
6577
- for (const event of initialEvents) {
6578
- initialOperations.push(event.type === "output" ? enqueue(id, () => persistOutput(directory, id, event.chunk)) : enqueue(id, () => persistExit(directory, id, event.info)));
6579
- }
6580
- await Promise.all(initialOperations);
6581
- return await service.get(directory, id, input.ownerSessionID) ?? session;
6582
7360
  },
6583
7361
  async list(directory, ownerSessionID) {
6584
7362
  const s = await readState(directory);
@@ -6655,16 +7433,22 @@ function createCommandService(host, opts) {
6655
7433
  return { session, text: file.text, totalBytes: session.outputBytes, startByte: file.startByte, live: session.status === "running" };
6656
7434
  },
6657
7435
  async write(directory, id, ownerSessionID, data) {
6658
- const session = await service.get(directory, id, ownerSessionID);
6659
- if (!session)
6660
- return { ok: false, message: "Command not found." };
6661
- if (session.status !== "running")
6662
- return { ok: false, message: `Command is ${session.status}; only running commands accept input.` };
6663
- const entry = live.get(id);
6664
- const ok = entry ? entry.handle.write(data) : false;
6665
- if (!ok)
6666
- return { ok: false, message: "Process input unavailable (no live handle \u2014 host may have restarted; reconcile marks it honestly)." };
6667
- return { ok: true, message: `Sent ${Buffer.byteLength(data)} byte(s) to "${session.title}".` };
7436
+ const scopeCallID = `command-write:${randomUUID9()}`;
7437
+ await beforeWorkspaceTool(directory, { tool: "loopd_command_write", sessionID: ownerSessionID, callID: scopeCallID }, {});
7438
+ try {
7439
+ const session = await service.get(directory, id, ownerSessionID);
7440
+ if (!session)
7441
+ return { ok: false, message: "Command not found." };
7442
+ if (session.status !== "running")
7443
+ return { ok: false, message: `Command is ${session.status}; only running commands accept input.` };
7444
+ const entry = live.get(id);
7445
+ const ok = entry ? entry.handle.write(data) : false;
7446
+ if (!ok)
7447
+ return { ok: false, message: "Process input unavailable (no live handle \u2014 host may have restarted; reconcile marks it honestly)." };
7448
+ return { ok: true, message: `Sent ${Buffer.byteLength(data)} byte(s) to "${session.title}".` };
7449
+ } finally {
7450
+ await afterWorkspaceTool(directory, ownerSessionID, scopeCallID);
7451
+ }
6668
7452
  },
6669
7453
  async resize(directory, id, ownerSessionID, cols, rows) {
6670
7454
  const session = await service.get(directory, id, ownerSessionID);
@@ -7051,6 +7835,9 @@ function createCommandService(host, opts) {
7051
7835
  const x = (s.commands ?? []).find((y) => y.id === c.id);
7052
7836
  if (x && x.status === "running") {
7053
7837
  x.status = "missing";
7838
+ s.orphanedCommandProcesses ??= [];
7839
+ if (!s.orphanedCommandProcesses.some((process2) => process2.commandID === x.id))
7840
+ s.orphanedCommandProcesses.push({ commandID: x.id, pid: x.pid });
7054
7841
  x.endReason = "missing";
7055
7842
  x.lastError = deadlinePassed ? `Host restarted or handle lost \u2014 no live execution found (timeoutSeconds=${x.timeoutSeconds ?? "?"}s deadline ${x.deadlineAt} already passed; treated as missing, timeout elapsed). Output log retained; remove to clean up.` : "Host restarted or handle lost \u2014 no live execution found. Output log retained; remove to clean up.";
7056
7843
  x.updatedAt = new Date().toISOString();
@@ -7102,7 +7889,7 @@ function createCommandService(host, opts) {
7102
7889
  }
7103
7890
 
7104
7891
  // src/application/command-event-broker.ts
7105
- import { randomUUID as randomUUID9 } from "crypto";
7892
+ import { randomUUID as randomUUID10 } from "crypto";
7106
7893
  function safeDeliver(sink, msg) {
7107
7894
  try {
7108
7895
  sink(msg);
@@ -7145,7 +7932,7 @@ function createCommandEventBroker(resolver) {
7145
7932
  throw new Error("ownerSessionID is required.");
7146
7933
  if (typeof sink !== "function")
7147
7934
  throw new Error("sink must be a function.");
7148
- const sinkID = randomUUID9();
7935
+ const sinkID = randomUUID10();
7149
7936
  const entry = { sinkID, sink, state: "subscribing", buffer: [] };
7150
7937
  entriesFor(commandID).set(sinkID, entry);
7151
7938
  try {
@@ -7234,11 +8021,11 @@ function createCommandEventBroker(resolver) {
7234
8021
  }
7235
8022
 
7236
8023
  // src/server/command-stream-server.ts
7237
- import { randomBytes, randomUUID as randomUUID10, timingSafeEqual } from "crypto";
7238
- import { promises as fs4 } from "fs";
7239
- import path4 from "path";
8024
+ import { randomBytes, randomUUID as randomUUID11, timingSafeEqual } from "crypto";
8025
+ import { promises as fs6 } from "fs";
8026
+ import path6 from "path";
7240
8027
  function streamEndpointPath(directory) {
7241
- return path4.join(directory, ".opencode", "loopd", "commands", ".stream-endpoint.json");
8028
+ return path6.join(directory, ".opencode", "loopd", "commands", ".stream-endpoint.json");
7242
8029
  }
7243
8030
  function isPidAlive(pid) {
7244
8031
  if (!Number.isInteger(pid) || pid <= 0)
@@ -7268,7 +8055,7 @@ var MAX_BUFFERED_BYTES = 512 * 1024;
7268
8055
  function createCommandStreamServer(directory, commandService, broker) {
7269
8056
  const endpointPath = streamEndpointPath(directory);
7270
8057
  const token = randomBytes(32).toString("hex");
7271
- const generation = randomUUID10();
8058
+ const generation = randomUUID11();
7272
8059
  const startedAt = new Date().toISOString();
7273
8060
  let server;
7274
8061
  let serverURL;
@@ -7484,10 +8271,10 @@ function createCommandStreamServer(directory, commandService, broker) {
7484
8271
  generation,
7485
8272
  startedAt
7486
8273
  };
7487
- await fs4.mkdir(path4.dirname(endpointPath), { recursive: true });
7488
- await fs4.writeFile(endpointPath, JSON.stringify(endpoint, null, 2) + `
8274
+ await fs6.mkdir(path6.dirname(endpointPath), { recursive: true });
8275
+ await fs6.writeFile(endpointPath, JSON.stringify(endpoint, null, 2) + `
7489
8276
  `, { mode: 384 });
7490
- await fs4.chmod(endpointPath, 384);
8277
+ await fs6.chmod(endpointPath, 384);
7491
8278
  }
7492
8279
  return {
7493
8280
  get url() {
@@ -7501,7 +8288,7 @@ function createCommandStreamServer(directory, commandService, broker) {
7501
8288
  return;
7502
8289
  started = true;
7503
8290
  try {
7504
- const previous = await fs4.readFile(endpointPath, "utf8").then((text) => JSON.parse(text), () => {
8291
+ const previous = await fs6.readFile(endpointPath, "utf8").then((text) => JSON.parse(text), () => {
7505
8292
  return;
7506
8293
  });
7507
8294
  if (previous && typeof previous.pid === "number" && isPidAlive(previous.pid) && previous.pid !== process.pid) {
@@ -7571,10 +8358,10 @@ function createCommandStreamServer(directory, commandService, broker) {
7571
8358
  server = undefined;
7572
8359
  serverURL = undefined;
7573
8360
  try {
7574
- const text = await fs4.readFile(endpointPath, "utf8");
8361
+ const text = await fs6.readFile(endpointPath, "utf8");
7575
8362
  const current = JSON.parse(text);
7576
8363
  if (current?.generation === generation) {
7577
- await fs4.unlink(endpointPath);
8364
+ await fs6.unlink(endpointPath);
7578
8365
  } else {
7579
8366
  logServerEvent(directory, "command-stream.keep-endpoint", {
7580
8367
  reason: "generation mismatch \u2014 another server owns the file"
@@ -7588,7 +8375,7 @@ function createCommandStreamServer(directory, commandService, broker) {
7588
8375
 
7589
8376
  // src/server/goal-tools.ts
7590
8377
  init_state_repository();
7591
- import { randomUUID as randomUUID11 } from "crypto";
8378
+ import { randomUUID as randomUUID12 } from "crypto";
7592
8379
  import { tool } from "@opencode-ai/plugin/tool";
7593
8380
  // src/domain/verification.ts
7594
8381
  var MAX_RECENT_ATTEMPTS = 10;
@@ -7603,11 +8390,57 @@ function appendVerificationAttempt(recent, attempt) {
7603
8390
  // src/server/goal-tools.ts
7604
8391
  import { exec as execChild } from "child_process";
7605
8392
  import { promisify } from "util";
8393
+
8394
+ // src/application/goal-scope.ts
8395
+ init_state_repository();
8396
+ init_workspace_scope();
8397
+ init_workspace_execution();
8398
+
8399
+ class GoalScopeConflictError extends Error {
8400
+ conflict;
8401
+ kind;
8402
+ constructor(conflict, kind) {
8403
+ super(scopeConflictMessage(conflict));
8404
+ this.conflict = conflict;
8405
+ this.kind = kind;
8406
+ this.name = "GoalScopeConflictError";
8407
+ }
8408
+ }
8409
+ async function claimGoalScope(directory, goalID, workerSessionID, generation, paths) {
8410
+ let claimed = [];
8411
+ await mutateState(directory, `goal.claim-scope:${goalID}`, async (state) => {
8412
+ const goal = state.goals.find((item) => item.id === goalID);
8413
+ const runtime = state.runtimes.find((item) => item.goalID === goalID);
8414
+ if (!goal || goal.workerSessionID !== workerSessionID || runtime?.runGeneration !== generation) {
8415
+ throw new Error("Scope claim denied: stale worker session or run generation. Read get_goal before claiming.");
8416
+ }
8417
+ if (goal.status !== "active")
8418
+ throw new Error(`Scope claim denied: goal is ${goal.status}, not active.`);
8419
+ if (goal.config.workspaceWrite === false)
8420
+ throw new Error("Read-only/artifact goals cannot claim shared workspace files.");
8421
+ if (goal.config.write_scope === undefined)
8422
+ throw new Error("Legacy whole-workspace goals already own the workspace; explicit scopes must be selected at creation.");
8423
+ const additions = await canonicalWriteScope(directory, paths);
8424
+ claimed = [...new Set([...goal.config.write_scope, ...additions])].sort();
8425
+ const candidate = { ...goal, config: { ...goal.config, write_scope: claimed } };
8426
+ const conflict = findScopeConflict(heldGoals(state).map((owner) => ({ ...owner, status: "active" })), candidate);
8427
+ if (conflict)
8428
+ throw new GoalScopeConflictError(conflict, goal.config.write_scope.length === 0 ? "initial" : "expansion");
8429
+ assertScopeAvailable(state, candidate);
8430
+ goal.config.write_scope = claimed;
8431
+ goal.updatedAt = new Date().toISOString();
8432
+ return state;
8433
+ });
8434
+ return claimed;
8435
+ }
8436
+
8437
+ // src/server/goal-tools.ts
8438
+ init_workspace_execution();
7606
8439
  var execAsync = promisify(execChild);
7607
8440
  function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
7608
8441
  return {
7609
8442
  loopd_create_goal: tool({
7610
- description: "Create a new background loop GOAL: an autonomous AI worker that loops turn-by-turn on a multi-step objective until deterministic checks pass or it needs you (contract: objective + checks + agent/model + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Use this for AI reasoning work spanning multiple turns (implement a feature, fix a failing suite, research and write a report) \u2014 NOT for running a single process you just want to start, watch, and type into. " + "For that (dev servers, `npm test --watch`, REPLs, log tails, one-off scripts, interactive shells with a fullscreen terminal UI), use loopd_command_start instead: it is lighter-weight, has no agent/checks/turn loop, and is a raw OS process, not an AI worker. " + "Call this after clarifying the contract with the user. " + "Worker identity is free-form: agent is any OpenCode agent name (built-in, ~/.config/opencode/agents/*.md, or opencode.jsonc agent.* \u2014 discover with `opencode agent list`), " + 'model is any "providerID/modelID" (discover with `opencode models [provider]`). Omit both by default \u2014 when omitted, both inherit the CALLING session\'s live agent/model (read at creation), then plugin defaultAgent/defaultModel. ' + "Only pass agent/model when the caller explicitly requests a different identity or the task needs it; explicit values freeze identity and break session upgrades. " + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals are serialized (only one active writer) and require checks. " + "Monitor with the /loop dashboard's Goals tab (Tab/h to switch there if Commands is focused).",
8443
+ description: "Create a new background loop GOAL: an autonomous AI worker that loops turn-by-turn on a multi-step objective until deterministic checks pass or it needs you (contract: objective + checks + agent/model + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Use this for AI reasoning work spanning multiple turns (implement a feature, fix a failing suite, research and write a report) \u2014 NOT for running a single process you just want to start, watch, and type into. " + "For that (dev servers, `npm test --watch`, REPLs, log tails, one-off scripts, interactive shells with a fullscreen terminal UI), use loopd_command_start instead: it is lighter-weight, has no agent/checks/turn loop, and is a raw OS process, not an AI worker. " + "Call this after clarifying the contract with the user. " + "Worker identity is free-form: agent is any OpenCode agent name (built-in, ~/.config/opencode/agents/*.md, or opencode.jsonc agent.* \u2014 discover with `opencode agent list`), " + 'model is any "providerID/modelID" (discover with `opencode models [provider]`). Omit both by default \u2014 when omitted, both inherit the CALLING session\'s live agent/model (read at creation), then plugin defaultAgent/defaultModel. ' + "Only pass agent/model when the caller explicitly requests a different identity or the task needs it; explicit values freeze identity and break session upgrades. " + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals require checks. OMIT write_scope entirely for ordinary exclusive coding goals. [] explicitly opts into scoped exploration, not legacy mode. " + (goalService.scopedExecution ? "CURRENT HOST: enforced file scopes supported. Opt in with exact disjoint write_scope files for parallel editing, or [] to explore then claim_goal_scope. Scoped workers use structured edits and run_goal_checks, not arbitrary shell. " : "CURRENT HOST: enforced file scopes UNSUPPORTED. Do not supply write_scope, including []; ordinary exclusive goals still work. Do not tell legacy workers to call claim_goal_scope. If enforced parallelism was explicitly required, explain the limitation rather than silently downgrade. ") + "Monitor with the /loop dashboard's Goals tab (Tab/h to switch there if Commands is focused).",
7611
8444
  args: {
7612
8445
  name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
7613
8446
  objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
@@ -7618,6 +8451,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
7618
8451
  checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
7619
8452
  checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
7620
8453
  workspaceWrite: tool.schema.boolean().optional().describe("Whether this goal edits the shared project workspace. Defaults to true; explicitly set false for artifact-only/read-only work."),
8454
+ write_scope: tool.schema.array(tool.schema.string()).optional().describe("Opt-in enforced scopes ONLY when the CURRENT HOST description says supported. Omit the key for ordinary exclusive goals; [] requests scoped exploration and is also rejected on unsupported hosts. Exact workspace-relative files, no globs. Never substitute workspaceWrite:false for coding to bypass protection."),
7621
8455
  progressFile: tool.schema.string().optional().describe("Markdown file the worker reads/writes as its transaction state."),
7622
8456
  maxTurns: tool.schema.number().optional().describe("Max turns before auto-block."),
7623
8457
  maxNoProgress: tool.schema.number().optional().describe("Block after N turns without progress."),
@@ -7654,6 +8488,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
7654
8488
  config.checkCwd = args.checkCwd;
7655
8489
  if (args.workspaceWrite !== undefined)
7656
8490
  config.workspaceWrite = args.workspaceWrite;
8491
+ if (args.write_scope !== undefined)
8492
+ config.write_scope = args.write_scope;
7657
8493
  if (args.progressFile)
7658
8494
  config.progressFile = args.progressFile;
7659
8495
  if (args.maxTurns !== undefined)
@@ -7741,6 +8577,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
7741
8577
  interactive: goal.interactive === true,
7742
8578
  checks: resolution.config.checks || [],
7743
8579
  workspaceWrite: resolution.config.workspaceWrite,
8580
+ write_scope: goal.config.write_scope,
8581
+ enforcedScopedExecution: goalService.scopedExecution,
7744
8582
  defaultsApplied: resolution.defaultsApplied,
7745
8583
  name: args.name,
7746
8584
  message: `Goal "${args.name}" created and started in the background. Artifacts: ${goal.config.artifactDir}. Monitor with /loop (<leader>o).`
@@ -7786,10 +8624,54 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
7786
8624
  const runtime = state.runtimes.find((r) => r.goalID === goal.id);
7787
8625
  return {
7788
8626
  title: `Goal: ${goal.name}`,
7789
- output: formatGoalStructured(goal, runtime)
8627
+ output: formatGoalStructured(goal, runtime, goalService.scopedExecution)
7790
8628
  };
7791
8629
  }
7792
8630
  }),
8631
+ claim_goal_scope: tool({
8632
+ description: "Only for an existing scoped worker on an enforcing host; NOT required for ordinary legacy goals created without write_scope. Atomically add exact files before editing. Conflicts return owner/path without partial acquisition. Coordinate; never sleep or poll claims. Read-only and legacy goals cannot claim. " + (goalService.scopedExecution ? "CURRENT HOST: scoped claims supported." : "CURRENT HOST: scoped claims UNSUPPORTED; create ordinary exclusive goals with write_scope omitted."),
8633
+ args: {
8634
+ paths: tool.schema.array(tool.schema.string()).describe("Exact workspace-relative files to add (no globs)."),
8635
+ runGeneration: tool.schema.number().describe("Current runGeneration from get_goal; stale workers are denied.")
8636
+ },
8637
+ execute: async (args, context) => {
8638
+ if (!goalService.scopedExecution)
8639
+ return { title: "Scope denied", output: JSON.stringify({ ok: false, errorCode: "unsupported_scope_host", message: "Use the guarded v2 execution surface; v1 scoped claims are unsupported." }) };
8640
+ const workerID = context?.sessionID || hostSessionID;
8641
+ const goal = findGoalByWorkerSession(await readState(dir), workerID);
8642
+ if (!goal || !workerID)
8643
+ return { title: "Scope denied", output: JSON.stringify({ ok: false, errorCode: "worker_not_found" }) };
8644
+ try {
8645
+ const write_scope = await claimGoalScope(dir, goal.id, workerID, args.runGeneration, args.paths);
8646
+ return { title: "Scope claimed", output: JSON.stringify({ ok: true, goalID: goal.id, write_scope }) };
8647
+ } catch (error) {
8648
+ return {
8649
+ title: "Scope denied",
8650
+ output: JSON.stringify({
8651
+ ok: false,
8652
+ message: error instanceof Error ? error.message : String(error),
8653
+ ...error instanceof GoalScopeConflictError ? { errorCode: "scope_conflict", kind: error.kind, conflict: error.conflict, retry: "coordinate_then_retry" } : { errorCode: "scope_denied" }
8654
+ })
8655
+ };
8656
+ }
8657
+ }
8658
+ }),
8659
+ run_goal_checks: tool({
8660
+ description: "Run only this goal's configured trusted checks under the workspace-wide exclusive operation lock. Source edits are denied while checks run; checks cannot start over in-flight writes. No arbitrary command argument. This is coordination, not an OS/filesystem sandbox. Use instead of shell for scoped verification.",
8661
+ args: {},
8662
+ execute: async (_args, context) => {
8663
+ const workerID = context?.sessionID || hostSessionID;
8664
+ const goal = findGoalByWorkerSession(await readState(dir), workerID);
8665
+ if (!goal || !workerID || goal.status !== "active")
8666
+ return { title: "Verification denied", output: JSON.stringify({ ok: false, message: "An active exact worker session is required." }) };
8667
+ try {
8668
+ const checks = await withWorkspaceOperation(dir, goal.id, workerID, () => runCompletionChecks(goal.config.checks || [], goal.config.checkCwd || dir));
8669
+ return { title: checks.passed ? "Checks passed" : "Checks failed", output: JSON.stringify({ ok: checks.passed, ...checks }) };
8670
+ } catch (error) {
8671
+ return { title: "Verification denied", output: JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) }) };
8672
+ }
8673
+ }
8674
+ }),
7793
8675
  report_goal_progress: tool({
7794
8676
  description: "Report meaningful progress (resets consecutiveFailures/noProgressCount). Call after durable state changes (file writes, verifications) \u2014 not after thinking. The engine uses this to avoid force-finish.",
7795
8677
  args: {
@@ -7818,10 +8700,10 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
7818
8700
  next: args.next,
7819
8701
  at: new Date().toISOString()
7820
8702
  };
7821
- await writeState(dir, state);
8703
+ state.revision = (await persistWorkerMutation(dir, goal, runtime, ["active"])).revision;
7822
8704
  const event = {
7823
8705
  version: 1,
7824
- eventID: randomUUID11(),
8706
+ eventID: randomUUID12(),
7825
8707
  goalID: goal.id,
7826
8708
  type: "goal.progress",
7827
8709
  summary: args.summary,
@@ -7859,7 +8741,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
7859
8741
  }
7860
8742
  if (goal.config.checks?.length) {
7861
8743
  const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
7862
- const checkResults = await runCompletionChecks(goal.config.checks, cwd);
8744
+ const checkResults = await withWorkspaceOperation(dir, goal.id, goal.workerSessionID, () => runCompletionChecks(goal.config.checks, cwd));
7863
8745
  if (!checkResults.passed) {
7864
8746
  const failureDetails = checkResults.failures.map((f) => {
7865
8747
  const stdoutSnippet = f.stdout ? `
@@ -7886,7 +8768,7 @@ Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
7886
8768
  Working directory: ${cwd}
7887
8769
 
7888
8770
  ${failureDetails}`;
7889
- attemptID = randomUUID11();
8771
+ attemptID = randomUUID12();
7890
8772
  const verificationAttempt = {
7891
8773
  id: attemptID,
7892
8774
  sequence: runtime.evaluatorRejectionCount,
@@ -7931,7 +8813,7 @@ ${failureDetails.slice(0, 500)}`,
7931
8813
  if (attemptID) {
7932
8814
  await appendEvent(dir, {
7933
8815
  version: 1,
7934
- eventID: randomUUID11(),
8816
+ eventID: randomUUID12(),
7935
8817
  goalID: goal.id,
7936
8818
  type: "goal.completion_rejected",
7937
8819
  attemptID,
@@ -7949,7 +8831,7 @@ ${failureDetails.slice(0, 500)}`,
7949
8831
  if (blocked && rejectedGoal?.blocker) {
7950
8832
  await appendEvent(dir, {
7951
8833
  version: 1,
7952
- eventID: randomUUID11(),
8834
+ eventID: randomUUID12(),
7953
8835
  goalID: goal.id,
7954
8836
  type: "goal.blocked",
7955
8837
  reason: rejectedGoal.blocker.reason,
@@ -8009,7 +8891,7 @@ ${failureDetails.slice(0, 500)}`,
8009
8891
  }
8010
8892
  runtime.updatedAt = new Date().toISOString();
8011
8893
  }
8012
- const attemptID = randomUUID11();
8894
+ const attemptID = randomUUID12();
8013
8895
  const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
8014
8896
  const checks = (goal.config.checks || []).map((cmd) => ({
8015
8897
  command: cmd,
@@ -8030,10 +8912,10 @@ ${failureDetails.slice(0, 500)}`,
8030
8912
  runtime.lastVerificationAttempt = verificationAttempt;
8031
8913
  runtime.recentVerificationAttempts = appendVerificationAttempt(runtime.recentVerificationAttempts || [], verificationAttempt);
8032
8914
  }
8033
- await writeState(dir, state);
8915
+ state.revision = (await persistWorkerMutation(dir, goal, runtime, ["active", "budget_limited"])).revision;
8034
8916
  const event = {
8035
8917
  version: 1,
8036
- eventID: randomUUID11(),
8918
+ eventID: randomUUID12(),
8037
8919
  goalID: goal.id,
8038
8920
  type: "goal.completed",
8039
8921
  summary: args.summary,
@@ -8090,10 +8972,10 @@ ${failureDetails.slice(0, 500)}`,
8090
8972
  runtime.accountedMessageIDs = [...runtime.accountedMessageIDs ?? [], ...finalUsage.counted].slice(-200);
8091
8973
  runtime.updatedAt = new Date().toISOString();
8092
8974
  }
8093
- await writeState(dir, state);
8975
+ state.revision = (await persistWorkerMutation(dir, goal, runtime, ["active", "budget_limited"])).revision;
8094
8976
  const event = {
8095
8977
  version: 1,
8096
- eventID: randomUUID11(),
8978
+ eventID: randomUUID12(),
8097
8979
  goalID: goal.id,
8098
8980
  type: "goal.blocked",
8099
8981
  reason: args.reason,
@@ -8149,7 +9031,7 @@ function findGoalByWorkerSession(state, sessionID) {
8149
9031
  return;
8150
9032
  return state.goals.find((g) => g.workerSessionID === sessionID && (g.status === "active" || g.status === "blocked"));
8151
9033
  }
8152
- function formatGoalStructured(goal, runtime) {
9034
+ function formatGoalStructured(goal, runtime, enforcedScopedExecution = false) {
8153
9035
  const output = {
8154
9036
  id: goal.id,
8155
9037
  name: goal.name,
@@ -8164,6 +9046,11 @@ function formatGoalStructured(goal, runtime) {
8164
9046
  checks: goal.config.checks,
8165
9047
  checkCwd: goal.config.checkCwd,
8166
9048
  workspaceWrite: goal.config.workspaceWrite,
9049
+ write_scope: goal.config.write_scope,
9050
+ scopeClosing: goal.scopeClosing === true,
9051
+ scopeClearPending: goal.scopeClearPending === true,
9052
+ scopeRetryPolicy: "coordinate_then_retry_no_automatic_queue",
9053
+ enforcedScopedExecution,
8167
9054
  agent: goal.config.agent,
8168
9055
  model: goal.config.model,
8169
9056
  maxTurns: goal.config.maxTurns,
@@ -8214,6 +9101,25 @@ function formatGoalStructured(goal, runtime) {
8214
9101
  }
8215
9102
  return JSON.stringify(output, null, 2);
8216
9103
  }
9104
+ async function persistWorkerMutation(dir, goal, runtime, from) {
9105
+ return mutateState(dir, `worker.transition:${goal.id}`, async (state) => {
9106
+ const current = state.goals.find((item) => item.id === goal.id);
9107
+ const rt = state.runtimes.find((item) => item.goalID === goal.id);
9108
+ if (!current || current.workerSessionID !== goal.workerSessionID || !from.includes(current.status) || current.scopeClosing || runtime && rt?.runGeneration !== runtime.runGeneration) {
9109
+ throw new Error("Worker transition denied: stale session/generation or closing goal.");
9110
+ }
9111
+ current.status = goal.status;
9112
+ current.updatedAt = goal.updatedAt;
9113
+ current.lastProgress = goal.lastProgress;
9114
+ current.completionEvidence = goal.completionEvidence;
9115
+ current.blocker = goal.blocker;
9116
+ if (rt && runtime) {
9117
+ const { activeToolCallIDs, accountedMessageIDs, turnTokensUsed, ...transition } = runtime;
9118
+ Object.assign(rt, transition);
9119
+ }
9120
+ return state;
9121
+ });
9122
+ }
8217
9123
  async function runCompletionChecks(checks, cwd) {
8218
9124
  const failures = [];
8219
9125
  for (const cmd of checks) {
@@ -8273,14 +9179,14 @@ function describeGoalState(status, phase) {
8273
9179
  }
8274
9180
 
8275
9181
  // src/server/owner-tools.ts
8276
- import { promises as fs5 } from "fs";
9182
+ import { promises as fs7 } from "fs";
8277
9183
  function withTimeout2(promise, ms) {
8278
9184
  return Promise.race([
8279
9185
  promise,
8280
9186
  new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))
8281
9187
  ]);
8282
9188
  }
8283
- function ownerTools(options) {
9189
+ function ownerTools2(options) {
8284
9190
  const { directory, host, goalService } = options;
8285
9191
  return {
8286
9192
  loopd_list_models: tool2({
@@ -8448,7 +9354,7 @@ function ownerTools(options) {
8448
9354
  if (!dir)
8449
9355
  return;
8450
9356
  try {
8451
- const files = await fs5.readdir(dir);
9357
+ const files = await fs7.readdir(dir);
8452
9358
  return files.length ? `${files.length} file(s): ${files.slice(0, 8).join(", ")}` : "no artifacts yet";
8453
9359
  } catch {
8454
9360
  return "no artifacts yet";
@@ -8480,6 +9386,10 @@ function ownerTools(options) {
8480
9386
  checks: goal.config.checks,
8481
9387
  checkCwd: goal.config.checkCwd,
8482
9388
  workspaceWrite: goal.config.workspaceWrite,
9389
+ write_scope: goal.config.write_scope,
9390
+ scopeClosing: goal.scopeClosing === true,
9391
+ scopeClearPending: goal.scopeClearPending === true,
9392
+ scopeRetryPolicy: "coordinate_then_retry_no_automatic_queue",
8483
9393
  agent: goal.config.agent,
8484
9394
  model: goal.config.model,
8485
9395
  fallbackModels: goal.config.fallbackModels ?? [],
@@ -8624,7 +9534,7 @@ function ownerTools(options) {
8624
9534
  }
8625
9535
  }),
8626
9536
  pause_goal: tool2({
8627
- description: "Pause an active goal: status active \u2192 paused, releaseLease, abortWorker, per-goal mutex. Frees the workspaceWrite slot. Use to investigate or to free the single-writer slot.",
9537
+ description: "Pause an active goal under its operation mutex. Protected writes are fenced before abort; ownership remains held until in-flight tools drain. Use to investigate or release file claims after quiescence.",
8628
9538
  args: {
8629
9539
  goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to pause the first active goal.")
8630
9540
  },
@@ -8668,7 +9578,7 @@ function ownerTools(options) {
8668
9578
  }
8669
9579
  }),
8670
9580
  resume_goal: tool2({
8671
- description: "Resume a paused (\u2192active, reuses existing worker if sessionStatus still idle/busy) or retry a blocked (\u2192active, resets consecutiveFailures/forceFinish). Fails with 'already active' if another workspaceWrite writer is active. Per-goal mutex.",
9581
+ description: "Resume a paused or retry a blocked goal. Atomically reacquires scope; overlapping ownership/in-flight writes deny activation. Explicit scoped workers rotate sessions to fence late tools; legacy workers retain their session. Per-goal mutex.",
8672
9582
  args: {
8673
9583
  goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to resume the first paused/blocked goal.")
8674
9584
  },
@@ -9236,9 +10146,10 @@ function commandTools(options) {
9236
10146
  // src/server/plugin.ts
9237
10147
  init_state_repository();
9238
10148
  // package.json
9239
- var version = "1.12.3";
10149
+ var version = "1.13.0";
9240
10150
 
9241
10151
  // src/server/plugin.ts
10152
+ init_workspace_execution();
9242
10153
  var PLUGIN_ID = "opencode-loopd.server";
9243
10154
  var server = async ({ client, directory }, pluginOptions) => {
9244
10155
  const defaults = parsePluginDefaults(pluginOptions);
@@ -9341,8 +10252,9 @@ function createServerHooks(directory, host, defaults) {
9341
10252
  if (type?.startsWith("session."))
9342
10253
  reconcileInBackground();
9343
10254
  },
9344
- tool: { ...goalTools(directory, goalService, undefined, defaults, host), ...ownerTools({ directory, host, goalService }), ...commandTools({ directory, commandService, capabilities: commandHost.capabilities, goalService }) },
10255
+ tool: { ...goalTools(directory, goalService, undefined, defaults, host), ...ownerTools2({ directory, host, goalService }), ...commandTools({ directory, commandService, capabilities: commandHost.capabilities, goalService }) },
9345
10256
  "tool.execute.before": async (input, _output) => {
10257
+ await beforeWorkspaceTool(directory, input, _output.args);
9346
10258
  const activeWorkers = goalService.getActiveWorkers();
9347
10259
  let matchedGoalID;
9348
10260
  for (const [goalID, worker] of activeWorkers) {
@@ -9363,6 +10275,7 @@ function createServerHooks(directory, host, defaults) {
9363
10275
  } catch {}
9364
10276
  },
9365
10277
  "tool.execute.after": async (input, output) => {
10278
+ await afterWorkspaceTool(directory, input.sessionID, input.callID);
9366
10279
  if (input.tool === "loopd_create_goal" || input.tool === "get_goal" || input.tool === "report_goal_progress") {
9367
10280
  ensureStarted();
9368
10281
  reconcileInBackground();
@@ -9474,6 +10387,7 @@ var v2 = {
9474
10387
  for (const [id, definition] of Object.entries(hooks.tool ?? {})) {
9475
10388
  editor.add(toV2Tool(id, definition, directory));
9476
10389
  }
10390
+ guardV2ToolEditor(directory, editor);
9477
10391
  }));
9478
10392
  registrations.push(await context.tool.hook("execute.before", async (input) => {
9479
10393
  await hooks["tool.execute.before"]?.({