@bojackduy/opencode-loopd 1.12.3 → 1.12.4

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,69 @@ 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
+
1244
1353
  // src/server/plugin.ts
1245
1354
  import { tool as v1Tool } from "@opencode-ai/plugin/tool";
1246
1355
 
@@ -1679,7 +1788,21 @@ function createControlWorker(options) {
1679
1788
  runtime.lastError = undefined;
1680
1789
  runtime.updatedAt = new Date().toISOString();
1681
1790
  }
1682
- await writeState(directory, state);
1791
+ await mutateState(directory, `goal.force-complete:${goal.id}`, async (s) => {
1792
+ const current = s.goals.find((item) => item.id === goal.id);
1793
+ if (!current)
1794
+ return s;
1795
+ current.status = "complete";
1796
+ current.updatedAt = goal.updatedAt;
1797
+ current.completionEvidence = goal.completionEvidence;
1798
+ const rt = s.runtimes.find((item) => item.goalID === goal.id);
1799
+ if (rt) {
1800
+ Object.assign(rt, releaseLease(rt));
1801
+ rt.activeRunID = undefined;
1802
+ rt.lastError = undefined;
1803
+ }
1804
+ return s;
1805
+ });
1683
1806
  await appendEvent(directory, {
1684
1807
  version: 1,
1685
1808
  eventID: randomUUID2(),
@@ -1720,7 +1843,21 @@ function createControlWorker(options) {
1720
1843
  runtime.lastError = undefined;
1721
1844
  runtime.updatedAt = new Date().toISOString();
1722
1845
  }
1723
- await writeState(directory, state);
1846
+ await mutateState(directory, `goal.force-block:${goal.id}`, async (s) => {
1847
+ const current = s.goals.find((item) => item.id === goal.id);
1848
+ if (!current)
1849
+ return s;
1850
+ current.status = "blocked";
1851
+ current.updatedAt = goal.updatedAt;
1852
+ current.blocker = goal.blocker;
1853
+ const rt = s.runtimes.find((item) => item.goalID === goal.id);
1854
+ if (rt) {
1855
+ Object.assign(rt, releaseLease(rt));
1856
+ rt.activeRunID = undefined;
1857
+ rt.lastError = undefined;
1858
+ }
1859
+ return s;
1860
+ });
1724
1861
  await appendEvent(directory, {
1725
1862
  version: 1,
1726
1863
  eventID: randomUUID2(),
@@ -2853,11 +2990,11 @@ function createLoopEngine(options) {
2853
2990
  }
2854
2991
 
2855
2992
  // src/application/goal-service.ts
2856
- import { randomUUID as randomUUID5 } from "crypto";
2993
+ import { randomUUID as randomUUID6 } from "crypto";
2857
2994
  init_state_repository();
2858
2995
  init_command_await2();
2859
- import * as path2 from "path";
2860
- import { promises as fs2 } from "fs";
2996
+ import * as path4 from "path";
2997
+ import { promises as fs4 } from "fs";
2861
2998
 
2862
2999
  // src/server/host-adapter.ts
2863
3000
  import { randomUUID as randomUUID4 } from "crypto";
@@ -3332,6 +3469,7 @@ function toWorkerCreation(result) {
3332
3469
  function createV2Host(context, statuses, options = {}) {
3333
3470
  const directory = context.location.directory;
3334
3471
  return {
3472
+ scopedExecution: true,
3335
3473
  async listAgents() {
3336
3474
  const switching = typeof context.session.switchAgent === "function" ? "session" : "unsupported";
3337
3475
  if (typeof context.agent?.list !== "function")
@@ -3650,6 +3788,20 @@ function createWorkerManager(host) {
3650
3788
  }
3651
3789
  function buildContinuationSteering(goal, runtime, context) {
3652
3790
  const parts = [];
3791
+ const scopeInstructions = goal.config.write_scope !== undefined ? [
3792
+ ``,
3793
+ `## FILE OWNERSHIP`,
3794
+ `write_scope: ${JSON.stringify(goal.config.write_scope)} (exact files; [] means exploration-only).`,
3795
+ `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.`,
3796
+ `Claim only the minimum files needed. Request expansion BEFORE editing new paths; rename requires both endpoints. No globs.`,
3797
+ `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.`,
3798
+ `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.`,
3799
+ `Output-location exceptions do not grant extra source files. Only this goal's artifact directory is writable without source claims.`
3800
+ ] : goal.config.workspaceWrite === false ? [
3801
+ ``,
3802
+ `## READ-ONLY OWNERSHIP`,
3803
+ `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.`
3804
+ ] : [];
3653
3805
  const artifactDir = goal.config.artifactDir;
3654
3806
  function outputLocationBlock() {
3655
3807
  if (!artifactDir)
@@ -3733,10 +3885,255 @@ function buildContinuationSteering(goal, runtime, context) {
3733
3885
  parts.push(`- ${msg}`);
3734
3886
  }
3735
3887
  }
3888
+ parts.push(...scopeInstructions);
3736
3889
  return parts.join(`
3737
3890
  `);
3738
3891
  }
3739
3892
 
3893
+ // src/application/workspace-execution.ts
3894
+ init_state_repository();
3895
+ init_workspace_scope();
3896
+ import { randomUUID as randomUUID5 } from "crypto";
3897
+ import path3 from "path";
3898
+ var readTools = new Set([
3899
+ "read",
3900
+ "glob",
3901
+ "grep",
3902
+ "list",
3903
+ "webfetch",
3904
+ "websearch",
3905
+ "todowrite",
3906
+ "skill",
3907
+ "get_goal",
3908
+ "claim_goal_scope",
3909
+ "report_goal_progress",
3910
+ "complete_goal",
3911
+ "block_goal",
3912
+ "list_background_goals",
3913
+ "inspect_background_goal",
3914
+ "read_goal_transcript",
3915
+ "loopd_command_get",
3916
+ "loopd_command_list",
3917
+ "loopd_command_await",
3918
+ "loopd_command_interrupt",
3919
+ "loopd_command_terminate",
3920
+ "loopd_command_remove",
3921
+ "loopd_list_agents",
3922
+ "loopd_list_models",
3923
+ "run_goal_checks",
3924
+ "codegraph_codegraph_context",
3925
+ "codegraph_codegraph_search",
3926
+ "codegraph_codegraph_node",
3927
+ "codegraph_codegraph_explore",
3928
+ "codegraph_codegraph_files",
3929
+ "codegraph_codegraph_trace",
3930
+ "codegraph_codegraph_callers",
3931
+ "codegraph_codegraph_callees",
3932
+ "codegraph_codegraph_impact",
3933
+ "codegraph_codegraph_status"
3934
+ ]);
3935
+ var 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"]);
3936
+ function structuredWritePaths(tool, args) {
3937
+ if (tool === "write" || tool === "edit") {
3938
+ if (typeof args.filePath !== "string")
3939
+ throw new Error(`Scope denied: ${tool} requires filePath.`);
3940
+ return [args.filePath];
3941
+ }
3942
+ if (tool !== "apply_patch")
3943
+ return;
3944
+ const source = args.patchText ?? args.patch;
3945
+ if (typeof source !== "string" || !source.startsWith(`*** Begin Patch
3946
+ `) || !source.trimEnd().endsWith("*** End Patch")) {
3947
+ throw new Error("Scope denied: unsupported apply_patch format.");
3948
+ }
3949
+ const paths = [];
3950
+ let section = false;
3951
+ for (const line of source.split(`
3952
+ `)) {
3953
+ const header = /^\*\*\* (?:Add File|Update File|Delete File): (.+)$/.exec(line);
3954
+ if (header) {
3955
+ paths.push(header[1]);
3956
+ section = true;
3957
+ continue;
3958
+ }
3959
+ const move = /^\*\*\* Move to: (.+)$/.exec(line);
3960
+ if (move) {
3961
+ if (!section)
3962
+ throw new Error("Scope denied: rename outside patch section.");
3963
+ paths.push(move[1]);
3964
+ continue;
3965
+ }
3966
+ if (line.startsWith("*** ") && !["*** Begin Patch", "*** End Patch", "*** End of File"].includes(line)) {
3967
+ throw new Error("Scope denied: unknown patch operation.");
3968
+ }
3969
+ }
3970
+ if (!paths.length)
3971
+ throw new Error("Scope denied: patch has no file operations.");
3972
+ return paths;
3973
+ }
3974
+ function heldGoals(state) {
3975
+ return state.goals.filter((goal) => goal.status === "active" || goal.scopeClosing || state.workspaceCalls?.some((call) => call.goalID === goal.id) || state.workspaceOperation?.goalID === goal.id);
3976
+ }
3977
+ function mayBeAlive(pid) {
3978
+ if (!pid || pid < 1)
3979
+ return true;
3980
+ try {
3981
+ process.kill(pid, 0);
3982
+ return true;
3983
+ } catch (error) {
3984
+ return error?.code !== "ESRCH";
3985
+ }
3986
+ }
3987
+ function assertScopeAvailable(state, goal) {
3988
+ if (goal.scopeClosing || state.workspaceCalls?.some((call) => call.goalID === goal.id)) {
3989
+ throw new Error("Goal still has closing/in-flight writes; ownership cannot be reacquired before quiescence.");
3990
+ }
3991
+ if (state.workspaceOperation)
3992
+ throw new Error(`Workspace operation ${state.workspaceOperation.id} is active.`);
3993
+ if (goal.config.write_scope !== undefined || goal.config.workspaceWrite === false) {
3994
+ const orphan = [...state.orphanedCommandProcesses || [], ...(state.commands || []).filter((command) => command.status === "missing").map((command) => ({ commandID: command.id, pid: command.pid }))].find((process2) => mayBeAlive(process2.pid));
3995
+ if (orphan)
3996
+ 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.`);
3997
+ if (state.workspaceCalls?.some((call) => call.uncontrolled))
3998
+ throw new Error("Protected goal activation denied: an uncontrolled tool/shell is still executing. Wait for its exit event, do not poll claims.");
3999
+ const running = state.commands?.find((command) => command.status === "running");
4000
+ if (running)
4001
+ throw new Error(`Protected goal activation denied: uncontrolled command ${running.id} is still running. Terminate it before starting scoped/read-only work.`);
4002
+ }
4003
+ const conflict = findScopeConflict(heldGoals(state).map((owner) => ({ ...owner, status: "active" })), goal);
4004
+ if (conflict) {
4005
+ const owner = state.goals.find((item) => item.id === conflict.goalID);
4006
+ if (goal.config.write_scope === undefined && owner?.config.write_scope === undefined) {
4007
+ 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.`);
4008
+ }
4009
+ throw new Error(scopeConflictMessage(conflict));
4010
+ }
4011
+ if (goal.config.workspaceWrite !== false && goal.config.write_scope?.length !== 0) {
4012
+ const call = state.workspaceCalls?.find((call) => call.goalID !== goal.id && (goal.config.write_scope === undefined || call.paths.some((file) => goal.config.write_scope.includes(file))));
4013
+ if (call)
4014
+ throw new Error(`Scope conflict with in-flight call ${call.callID}, session ${call.sessionID}, path ${call.paths[0]}.`);
4015
+ }
4016
+ }
4017
+ function retireWorker(state, goal) {
4018
+ if (!goal.workerSessionID)
4019
+ return;
4020
+ state.retiredWorkerSessions = [...new Set([...state.retiredWorkerSessions || [], goal.workerSessionID])];
4021
+ }
4022
+ async function beforeWorkspaceTool(directory, input, args) {
4023
+ if (readTools.has(input.tool))
4024
+ return;
4025
+ await mutateState(directory, `workspace.before:${input.callID}`, async (state) => {
4026
+ const goal = state.goals.find((item) => item.workerSessionID === input.sessionID);
4027
+ if (state.retiredWorkerSessions?.includes(input.sessionID))
4028
+ throw new Error("Scope denied: retired worker session; late executions are fenced.");
4029
+ const held = heldGoals(state);
4030
+ const protectedGoals = held.filter((item) => item.config.write_scope !== undefined || item.config.workspaceWrite === false);
4031
+ if (!goal && ownerTools.has(input.tool) && (!protectedGoals.length || state.goals.some((item) => item.ownerSessionID === input.sessionID)))
4032
+ return state;
4033
+ if (!state.workspaceOperation && !protectedGoals.length && (!goal || goal.config.write_scope === undefined && goal.config.workspaceWrite !== false)) {
4034
+ let paths = [];
4035
+ let uncontrolled = false;
4036
+ try {
4037
+ const writes = structuredWritePaths(input.tool, args);
4038
+ if (writes)
4039
+ paths = await Promise.all(writes.map((file) => canonicalScopePath(directory, file)));
4040
+ else
4041
+ uncontrolled = true;
4042
+ } catch {
4043
+ uncontrolled = true;
4044
+ }
4045
+ state.workspaceCalls ??= [];
4046
+ if (!state.workspaceCalls.some((call) => call.callID === input.callID && call.sessionID === input.sessionID)) {
4047
+ state.workspaceCalls.push({ callID: input.callID, sessionID: input.sessionID, goalID: goal?.id, paths, uncontrolled, at: new Date().toISOString() });
4048
+ }
4049
+ return state;
4050
+ }
4051
+ const writes = structuredWritePaths(input.tool, args);
4052
+ if (!writes)
4053
+ 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.`);
4054
+ if (state.workspaceOperation)
4055
+ throw new Error(`Workspace operation ${state.workspaceOperation.id} is active; source writes are denied until it finishes.`);
4056
+ if (goal && (goal.status !== "active" || goal.scopeClosing))
4057
+ throw new Error(`Scope denied: worker goal is ${goal.scopeClosing ? "closing" : goal.status}.`);
4058
+ if (!goal && !state.goals.some((item) => item.ownerSessionID === input.sessionID)) {
4059
+ throw new Error("Scope denied: unrecognized session/child. Subagent writes are unsupported.");
4060
+ }
4061
+ const paths = await Promise.all(writes.map((file) => canonicalScopePath(directory, file)));
4062
+ const artifact = goal?.config.artifactDir ? path3.relative(directory, goal.config.artifactDir).split(path3.sep).join("/") : undefined;
4063
+ for (const file of paths) {
4064
+ const inArtifact = artifact && file.startsWith(`${artifact}/`);
4065
+ if (goal && !inArtifact && (goal.config.workspaceWrite === false || goal.config.write_scope !== undefined && !goal.config.write_scope.includes(file))) {
4066
+ throw new Error(`Scope denied: "${file}" is outside this goal's write_scope. Claim it before editing.`);
4067
+ }
4068
+ 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)));
4069
+ if (owner)
4070
+ throw new Error(scopeConflictMessage({ goalID: owner.id, ownerSessionID: owner.ownerSessionID, name: owner.name, path: file }));
4071
+ const call = state.workspaceCalls?.find((item) => item.paths.includes(file) && !(item.callID === input.callID && item.sessionID === input.sessionID));
4072
+ if (call)
4073
+ throw new Error(`Scope denied: "${file}" is being written by session ${call.sessionID}, call ${call.callID}.`);
4074
+ }
4075
+ if (!state.workspaceCalls)
4076
+ state.workspaceCalls = [];
4077
+ if (!state.workspaceCalls.some((call) => call.callID === input.callID && call.sessionID === input.sessionID)) {
4078
+ state.workspaceCalls.push({ callID: input.callID, sessionID: input.sessionID, goalID: goal?.id, generation: state.runtimes.find((runtime) => runtime.goalID === goal?.id)?.runGeneration, paths, at: new Date().toISOString() });
4079
+ }
4080
+ return state;
4081
+ });
4082
+ }
4083
+ async function afterWorkspaceTool(directory, sessionID, callID) {
4084
+ await mutateState(directory, `workspace.after:${callID}`, async (state) => {
4085
+ state.workspaceCalls = state.workspaceCalls?.filter((call) => call.sessionID !== sessionID || call.callID !== callID);
4086
+ state.goals = state.goals.filter((goal) => {
4087
+ if (!goal.scopeClearPending || state.workspaceCalls?.some((call) => call.goalID === goal.id) || state.workspaceOperation?.goalID === goal.id)
4088
+ return true;
4089
+ state.runtimes = state.runtimes.filter((runtime) => runtime.goalID !== goal.id);
4090
+ return false;
4091
+ });
4092
+ return state;
4093
+ });
4094
+ }
4095
+ function guardV2ToolEditor(directory, editor) {
4096
+ if (typeof editor.list !== "function" || typeof editor.update !== "function")
4097
+ throw new Error("Scoped execution requires the v2 tool editor list/update execution surface.");
4098
+ for (const entry of editor.list()) {
4099
+ editor.update(entry.id, (definition) => {
4100
+ const execute = definition.execute;
4101
+ definition.execute = async (args, context) => {
4102
+ await beforeWorkspaceTool(directory, { tool: entry.name, sessionID: context.sessionID, callID: context.id }, args);
4103
+ try {
4104
+ return await execute(args, context);
4105
+ } finally {
4106
+ await afterWorkspaceTool(directory, context.sessionID, context.id);
4107
+ }
4108
+ };
4109
+ });
4110
+ }
4111
+ }
4112
+ async function withWorkspaceOperation(directory, goalID, sessionID, run) {
4113
+ const id = randomUUID5();
4114
+ await mutateState(directory, `workspace.operation:${id}`, async (state) => {
4115
+ if (state.workspaceOperation || state.workspaceCalls?.length)
4116
+ throw new Error("Workspace is busy with an operation or in-flight writes; verification denied until quiescent.");
4117
+ state.workspaceOperation = { id, goalID, sessionID };
4118
+ return state;
4119
+ });
4120
+ try {
4121
+ return await run();
4122
+ } finally {
4123
+ await mutateState(directory, `workspace.operation-end:${id}`, async (state) => {
4124
+ if (state.workspaceOperation?.id === id)
4125
+ state.workspaceOperation = undefined;
4126
+ state.goals = state.goals.filter((goal) => {
4127
+ if (!goal.scopeClearPending || state.workspaceCalls?.some((call) => call.goalID === goal.id))
4128
+ return true;
4129
+ state.runtimes = state.runtimes.filter((runtime) => runtime.goalID !== goal.id);
4130
+ return false;
4131
+ });
4132
+ return state;
4133
+ });
4134
+ }
4135
+ }
4136
+
3740
4137
  // src/application/goal-service.ts
3741
4138
  class GoalStartError extends Error {
3742
4139
  goalID;
@@ -3772,13 +4169,9 @@ function createGoalService(host) {
3772
4169
  }
3773
4170
  }
3774
4171
  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
- }
4172
+ if (goal.config.write_scope !== undefined && !host.scopedExecution)
4173
+ throw new Error("Enforced file scopes are unsupported on this host. Use the guarded v2 tool execution surface; v1 retains legacy exclusivity only.");
4174
+ assertScopeAvailable(state, goal);
3782
4175
  }
3783
4176
  async function listModels() {
3784
4177
  return host.listModels ? host.listModels() : emptyCatalog("host catalog capability absent", "unsupported");
@@ -3838,6 +4231,8 @@ function createGoalService(host) {
3838
4231
  throw new Error("Cannot switch a completed goal.");
3839
4232
  if (opts?.resume && goal.status !== "blocked")
3840
4233
  throw new Error("resume is only valid for a blocked goal.");
4234
+ if (opts?.resume && goal.config.write_scope !== undefined)
4235
+ throw new Error("Scoped identity-resume is unsupported. Use resume_goal to reacquire and fence the session, then switch identity.");
3841
4236
  if (opts?.resume)
3842
4237
  assertWorkspaceWriteAvailable(state, goal, ownerSessionID);
3843
4238
  const { agent, catalog } = await validateAgent(value);
@@ -3969,6 +4364,8 @@ function createGoalService(host) {
3969
4364
  if (opts?.resume && (goal.status !== "blocked" || goal.blocker?.kind !== "provider-limit")) {
3970
4365
  throw new Error("resume is only valid for a provider-limit-blocked goal.");
3971
4366
  }
4367
+ if (opts?.resume && goal.config.write_scope !== undefined)
4368
+ throw new Error("Scoped identity-resume is unsupported. Use resume_goal to reacquire and fence the session, then switch identity.");
3972
4369
  if (opts?.resume)
3973
4370
  assertWorkspaceWriteAvailable(state, goal, ownerSessionID);
3974
4371
  const { model, catalog } = await validateModel(value);
@@ -4027,6 +4424,8 @@ function createGoalService(host) {
4027
4424
  throw new Error("Cannot switch a completed goal.");
4028
4425
  if (opts.resume && goal.status !== "blocked")
4029
4426
  throw new Error("resume is only valid for a blocked goal.");
4427
+ if (opts.resume && goal.config.write_scope !== undefined)
4428
+ throw new Error("Scoped identity-resume is unsupported. Use resume_goal to reacquire and fence the session, then switch identity.");
4030
4429
  if (opts.resume)
4031
4430
  assertWorkspaceWriteAvailable(state, goal, ownerSessionID);
4032
4431
  const { model, catalog: models } = await validateModel(opts.model);
@@ -4277,7 +4676,7 @@ function createGoalService(host) {
4277
4676
  });
4278
4677
  await appendEvent(directory, {
4279
4678
  version: 1,
4280
- eventID: randomUUID5(),
4679
+ eventID: randomUUID6(),
4281
4680
  goalID,
4282
4681
  type: "run.failed",
4283
4682
  runID,
@@ -4289,7 +4688,7 @@ function createGoalService(host) {
4289
4688
  if (blocked) {
4290
4689
  await appendEvent(directory, {
4291
4690
  version: 1,
4292
- eventID: randomUUID5(),
4691
+ eventID: randomUUID6(),
4293
4692
  goalID,
4294
4693
  type: "goal.blocked",
4295
4694
  reason: `Worker prompt delivery failed: ${detail}`,
@@ -4329,10 +4728,12 @@ function createGoalService(host) {
4329
4728
  return session;
4330
4729
  }
4331
4730
  async function start(directory, input) {
4332
- const id = randomUUID5();
4731
+ const id = randomUUID6();
4333
4732
  return withGoalOperation(id, () => startUnlocked(directory, input, id));
4334
4733
  }
4335
4734
  async function startUnlocked(directory, input, id) {
4735
+ if (input.config?.write_scope !== undefined && !host.scopedExecution)
4736
+ throw new Error("Enforced file scopes are unsupported on this host. Use the guarded v2 tool execution surface; v1 retains legacy exclusivity only.");
4336
4737
  if (input.config?.fallbackModels?.length) {
4337
4738
  if (input.config.fallbackModels.length > 16)
4338
4739
  throw new Error("At most 16 ordered fallback models are allowed.");
@@ -4370,6 +4771,15 @@ function createGoalService(host) {
4370
4771
  ...input.config
4371
4772
  }
4372
4773
  });
4774
+ if (goal.config.write_scope !== undefined) {
4775
+ if (!Array.isArray(goal.config.write_scope))
4776
+ throw new Error("write_scope must be an array of exact file paths");
4777
+ if (!goal.config.workspaceWrite && goal.config.write_scope.length > 0) {
4778
+ throw new Error("Read-only/artifact goals cannot claim shared workspace files.");
4779
+ }
4780
+ await Promise.resolve().then(() => init_workspace_scope());
4781
+ goal.config.write_scope = await canonicalWriteScope(directory, goal.config.write_scope);
4782
+ }
4373
4783
  if (typeof input.costBudget === "number")
4374
4784
  goal.costBudget = input.costBudget;
4375
4785
  if (input.interactive === true)
@@ -4381,7 +4791,7 @@ function createGoalService(host) {
4381
4791
  const artifactDir = goalArtifactDir(directory, id);
4382
4792
  goal.config.artifactDir = artifactDir;
4383
4793
  if (!goal.config.progressFile)
4384
- goal.config.progressFile = path2.join(artifactDir, "progress.md");
4794
+ goal.config.progressFile = path4.join(artifactDir, "progress.md");
4385
4795
  await ensureGoalArtifactDir(directory, id);
4386
4796
  const state1 = await mutateState(directory, `goal.create:${id}`, async (state) => {
4387
4797
  assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
@@ -4425,7 +4835,7 @@ function createGoalService(host) {
4425
4835
  });
4426
4836
  await appendEvent(directory, {
4427
4837
  version: 1,
4428
- eventID: randomUUID5(),
4838
+ eventID: randomUUID6(),
4429
4839
  goalID: id,
4430
4840
  type: "goal.blocked",
4431
4841
  reason: detail,
@@ -4452,7 +4862,7 @@ function createGoalService(host) {
4452
4862
  const rt = state.runtimes.find((item) => item.goalID === id);
4453
4863
  if (rt) {
4454
4864
  Object.assign(rt, acquireLease(rt, g.config.timeoutMs || 300000));
4455
- rt.activeRunID = randomUUID5();
4865
+ rt.activeRunID = randomUUID6();
4456
4866
  rt.activePromptMessageID = newPromptMessageID();
4457
4867
  rt.runCount = 1;
4458
4868
  rt.budgetTurnCount = 1;
@@ -4463,7 +4873,7 @@ function createGoalService(host) {
4463
4873
  runtime = state2.runtimes.find((r) => r.goalID === id);
4464
4874
  await appendEvent(directory, {
4465
4875
  version: 1,
4466
- eventID: randomUUID5(),
4876
+ eventID: randomUUID6(),
4467
4877
  goalID: id,
4468
4878
  type: "goal.created",
4469
4879
  name: input.name,
@@ -4475,7 +4885,7 @@ function createGoalService(host) {
4475
4885
  if (runtime) {
4476
4886
  await appendEvent(directory, {
4477
4887
  version: 1,
4478
- eventID: randomUUID5(),
4888
+ eventID: randomUUID6(),
4479
4889
  goalID: id,
4480
4890
  type: "run.started",
4481
4891
  runID: runtime.activeRunID,
@@ -4558,12 +4968,20 @@ function createGoalService(host) {
4558
4968
  const goal = preState.goals.find((g) => g.id === goalID);
4559
4969
  if (!goal || goal.status !== "active")
4560
4970
  return;
4971
+ if (goal.config.write_scope !== undefined && !host.scopedExecution)
4972
+ return;
4973
+ if (goal.scopeClosing || preState.workspaceCalls?.some((call) => call.goalID === goalID) || preState.workspaceOperation)
4974
+ return;
4561
4975
  const runtime = preState.runtimes.find((r) => r.goalID === goalID);
4562
4976
  if (!runtime)
4563
4977
  return;
4564
4978
  if (runtime.phase === "running" && leaseIsValid(runtime))
4565
4979
  return;
4566
4980
  let session = sessions.get(goalID);
4981
+ if (session && session.workerSessionID !== goal.workerSessionID) {
4982
+ sessions.delete(goalID);
4983
+ session = undefined;
4984
+ }
4567
4985
  if (!session && goal.workerSessionID) {
4568
4986
  session = {
4569
4987
  goalID: goal.id,
@@ -4572,6 +4990,8 @@ function createGoalService(host) {
4572
4990
  };
4573
4991
  sessions.set(goalID, session);
4574
4992
  }
4993
+ if (!session && !goal.workerSessionID)
4994
+ session = await ensureWorkerSession(directory, goal);
4575
4995
  if (!session)
4576
4996
  return;
4577
4997
  if (!opts?.force && !await workers.isIdle(session.workerSessionID))
@@ -4595,7 +5015,7 @@ function createGoalService(host) {
4595
5015
  return s;
4596
5016
  const timeoutMs = g.config.timeoutMs || 300000;
4597
5017
  Object.assign(rt, acquireLease(rt, timeoutMs));
4598
- rt.activeRunID = randomUUID5();
5018
+ rt.activeRunID = randomUUID6();
4599
5019
  rt.activePromptMessageID = newPromptMessageID();
4600
5020
  rt.runCount += 1;
4601
5021
  if (rt.freeRetryPending) {
@@ -4613,7 +5033,7 @@ function createGoalService(host) {
4613
5033
  return;
4614
5034
  await appendEvent(directory, {
4615
5035
  version: 1,
4616
- eventID: randomUUID5(),
5036
+ eventID: randomUUID6(),
4617
5037
  goalID,
4618
5038
  type: "run.started",
4619
5039
  runID: freshRuntime.activeRunID,
@@ -4654,7 +5074,7 @@ function createGoalService(host) {
4654
5074
  const artifactDir = freshGoal.config.artifactDir;
4655
5075
  if (artifactDir) {
4656
5076
  try {
4657
- const files = await fs2.readdir(artifactDir);
5077
+ const files = await fs4.readdir(artifactDir);
4658
5078
  verification = { artifactSummary: files.length ? `${files.length} file(s): ${files.slice(0, 8).join(", ")}` : "no artifacts yet" };
4659
5079
  } catch {
4660
5080
  verification = { artifactSummary: "no artifacts yet" };
@@ -4702,7 +5122,7 @@ function createGoalService(host) {
4702
5122
  return { ok: false, message: "Goal not found." };
4703
5123
  await appendEvent(directory, {
4704
5124
  version: 1,
4705
- eventID: randomUUID5(),
5125
+ eventID: randomUUID6(),
4706
5126
  goalID,
4707
5127
  type: "goal.interactive_changed",
4708
5128
  interactive,
@@ -4724,11 +5144,25 @@ function createGoalService(host) {
4724
5144
  return;
4725
5145
  if (!canTransition(goal.status, "paused", "user"))
4726
5146
  return;
5147
+ if (goal.config.write_scope !== undefined) {
5148
+ await mutateState(directory, `goal.pause-closing:${goalID}`, async (s) => {
5149
+ const g = s.goals.find((item) => item.id === goalID);
5150
+ if (g) {
5151
+ g.scopeClosing = true;
5152
+ retireWorker(s, g);
5153
+ }
5154
+ return s;
5155
+ });
5156
+ if (goal.workerSessionID)
5157
+ await workers.abortWorker(goal.workerSessionID);
5158
+ sessions.delete(goalID);
5159
+ }
4727
5160
  const state = await mutateState(directory, `goal.pause:${goalID}`, async (s) => {
4728
5161
  const g = s.goals.find((item) => item.id === goalID);
4729
5162
  if (!g)
4730
5163
  return s;
4731
5164
  g.status = "paused";
5165
+ g.scopeClosing = false;
4732
5166
  g.updatedAt = new Date().toISOString();
4733
5167
  const rt = s.runtimes.find((r) => r.goalID === goalID);
4734
5168
  if (rt) {
@@ -4742,14 +5176,14 @@ function createGoalService(host) {
4742
5176
  workerSessionID: goal.workerSessionID,
4743
5177
  startedAt: goal.createdAt
4744
5178
  } : undefined);
4745
- if (session) {
5179
+ if (session && goal.config.write_scope === undefined) {
4746
5180
  await workers.abortWorker(session.workerSessionID);
4747
5181
  sessions.delete(goalID);
4748
5182
  }
4749
5183
  await cancelAwaitsForGoal(directory, goalID, "goal paused").catch(() => {});
4750
5184
  await appendEvent(directory, {
4751
5185
  version: 1,
4752
- eventID: randomUUID5(),
5186
+ eventID: randomUUID6(),
4753
5187
  goalID,
4754
5188
  type: "goal.status_changed",
4755
5189
  from: "active",
@@ -4767,6 +5201,11 @@ function createGoalService(host) {
4767
5201
  if (!canTransition(goal.status, "active", "user"))
4768
5202
  return state;
4769
5203
  assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
5204
+ if (goal.config.write_scope !== undefined) {
5205
+ retireWorker(state, goal);
5206
+ goal.workerSessionID = undefined;
5207
+ sessions.delete(goalID);
5208
+ }
4770
5209
  goal.status = "active";
4771
5210
  goal.updatedAt = new Date().toISOString();
4772
5211
  resumed = true;
@@ -4788,7 +5227,7 @@ function createGoalService(host) {
4788
5227
  }
4789
5228
  await appendEvent(directory, {
4790
5229
  version: 1,
4791
- eventID: randomUUID5(),
5230
+ eventID: randomUUID6(),
4792
5231
  goalID,
4793
5232
  type: "goal.status_changed",
4794
5233
  from: "paused",
@@ -4805,6 +5244,11 @@ function createGoalService(host) {
4805
5244
  if (!goal || goal.status !== "blocked")
4806
5245
  return state;
4807
5246
  assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
5247
+ if (goal.config.write_scope !== undefined) {
5248
+ retireWorker(state, goal);
5249
+ goal.workerSessionID = undefined;
5250
+ sessions.delete(goalID);
5251
+ }
4808
5252
  goal.status = "active";
4809
5253
  goal.updatedAt = new Date().toISOString();
4810
5254
  retried = true;
@@ -4840,7 +5284,7 @@ function createGoalService(host) {
4840
5284
  }
4841
5285
  await appendEvent(directory, {
4842
5286
  version: 1,
4843
- eventID: randomUUID5(),
5287
+ eventID: randomUUID6(),
4844
5288
  goalID,
4845
5289
  type: "goal.status_changed",
4846
5290
  from: "blocked",
@@ -4855,6 +5299,14 @@ function createGoalService(host) {
4855
5299
  const goal = state.goals.find((g) => g.id === goalID);
4856
5300
  if (!goal)
4857
5301
  return;
5302
+ await mutateState(directory, `goal.clear-closing:${goalID}`, async (s) => {
5303
+ const g = s.goals.find((item) => item.id === goalID);
5304
+ if (g) {
5305
+ g.scopeClosing = true;
5306
+ retireWorker(s, g);
5307
+ }
5308
+ return s;
5309
+ });
4858
5310
  const session = sessions.get(goalID) || (goal.workerSessionID ? {
4859
5311
  goalID: goal.id,
4860
5312
  workerSessionID: goal.workerSessionID,
@@ -4865,6 +5317,15 @@ function createGoalService(host) {
4865
5317
  sessions.delete(goalID);
4866
5318
  }
4867
5319
  await mutateState(directory, `goal.clear:${goalID}`, async (s) => {
5320
+ if (s.workspaceCalls?.some((call) => call.goalID === goalID) || s.workspaceOperation?.goalID === goalID) {
5321
+ const g = s.goals.find((item) => item.id === goalID);
5322
+ if (g) {
5323
+ g.status = "paused";
5324
+ g.scopeClearPending = true;
5325
+ }
5326
+ s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.goalID !== goalID);
5327
+ return s;
5328
+ }
4868
5329
  s.goals = s.goals.filter((g) => g.id !== goalID);
4869
5330
  s.runtimes = s.runtimes.filter((r) => r.goalID !== goalID);
4870
5331
  s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.goalID !== goalID);
@@ -4872,7 +5333,7 @@ function createGoalService(host) {
4872
5333
  });
4873
5334
  await appendEvent(directory, {
4874
5335
  version: 1,
4875
- eventID: randomUUID5(),
5336
+ eventID: randomUUID6(),
4876
5337
  goalID,
4877
5338
  type: "goal.cleared",
4878
5339
  timestamp: new Date().toISOString(),
@@ -4892,6 +5353,22 @@ function createGoalService(host) {
4892
5353
  continue;
4893
5354
  if (goal.status === "paused")
4894
5355
  continue;
5356
+ if (goal.config.write_scope !== undefined && !host.scopedExecution) {
5357
+ await mutateState(directory, `reconcile.unsupported-scope:${goal.id}`, async (s) => {
5358
+ const g = s.goals.find((item) => item.id === goal.id);
5359
+ if (g) {
5360
+ g.scopeClosing = true;
5361
+ retireWorker(s, g);
5362
+ g.status = "blocked";
5363
+ 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() };
5364
+ }
5365
+ return s;
5366
+ });
5367
+ if (goal.workerSessionID)
5368
+ await workers.abortWorker(goal.workerSessionID);
5369
+ sessions.delete(goal.id);
5370
+ continue;
5371
+ }
4895
5372
  if (!goal.workerSessionID) {
4896
5373
  let worker;
4897
5374
  try {
@@ -4967,6 +5444,11 @@ function createGoalService(host) {
4967
5444
  if (goal.status !== "active") {
4968
5445
  return { ok: false, message: `Goal is ${goal.status}; resume or retry it before nudging.` };
4969
5446
  }
5447
+ if (goal.config.write_scope !== undefined) {
5448
+ await pauseUnlocked(directory, goalID);
5449
+ await resumeUnlocked(directory, goalID);
5450
+ return { ok: true, message: `Re-prompted protected worker for "${goal.name}" in a freshly fenced session.` };
5451
+ }
4970
5452
  const cleared = await mutateState(directory, `goal.nudge:${goalID}`, async (s) => {
4971
5453
  const rt = s.runtimes.find((r) => r.goalID === goalID);
4972
5454
  if (!rt)
@@ -4998,6 +5480,10 @@ function createGoalService(host) {
4998
5480
  if (goal.status !== "active") {
4999
5481
  return { ok: true, message: `Queued for "${goal.name}" (goal is ${goal.status}; delivers on the next active turn).` };
5000
5482
  }
5483
+ if (goal.config.write_scope !== undefined) {
5484
+ await continueTurnUnlocked(directory, goalID, { bare: true });
5485
+ return { ok: true, message: `Queued for "${goal.name}"; protected workers receive it on the next quiescent turn.` };
5486
+ }
5001
5487
  const cleared = await mutateState(directory, `goal.send:${goalID}`, async (s) => {
5002
5488
  const rt = s.runtimes.find((r) => r.goalID === goalID);
5003
5489
  if (!rt)
@@ -5043,6 +5529,11 @@ function createGoalService(host) {
5043
5529
  const goal = preState.goals.find((g) => g.id === goalID);
5044
5530
  if (!goal)
5045
5531
  return { ok: false, message: "Goal not found." };
5532
+ if (goal.config.write_scope !== undefined && goal.status === "active") {
5533
+ await pauseUnlocked(directory, goalID);
5534
+ await resumeUnlocked(directory, goalID);
5535
+ return { ok: true, message: `Protected worker for "${goal.name}" aborted and replaced with a fenced session.` };
5536
+ }
5046
5537
  const workerID = sessions.get(goalID)?.workerSessionID || goal.workerSessionID;
5047
5538
  if (!workerID)
5048
5539
  return { ok: false, message: `Goal "${goal.name}" has no worker session to abort.` };
@@ -5079,12 +5570,12 @@ function createGoalService(host) {
5079
5570
  function accountUsage(directory, goalID) {
5080
5571
  return withGoalOperation(goalID, () => accountUsageUnlocked(directory, goalID));
5081
5572
  }
5082
- return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage, setInteractive, listModels, switchModel, listAgents, switchAgent, switchIdentity, observeProviderError, compact };
5573
+ 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
5574
  }
5084
5575
 
5085
5576
  // src/application/schedule-worker.ts
5086
5577
  init_state_repository();
5087
- import { randomUUID as randomUUID6 } from "crypto";
5578
+ import { randomUUID as randomUUID7 } from "crypto";
5088
5579
  function createScheduleWorker(options) {
5089
5580
  const { directory, goalService } = options;
5090
5581
  const intervalMs = options.intervalMs ?? 5000;
@@ -5128,14 +5619,6 @@ function createScheduleWorker(options) {
5128
5619
  continue;
5129
5620
  if (Date.now() < Date.parse(nextAt))
5130
5621
  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
5622
  if (runtime.phase === "running" || runtime.phase === "queued" || runtime.phase === "compacting")
5140
5623
  continue;
5141
5624
  if (leaseIsValid(runtime))
@@ -5145,6 +5628,8 @@ function createScheduleWorker(options) {
5145
5628
  const rt = s.runtimes.find((x) => x.goalID === goal.id);
5146
5629
  if (!g || !rt)
5147
5630
  return s;
5631
+ if (g.config.write_scope !== undefined && !goalService.scopedExecution)
5632
+ return s;
5148
5633
  if (g.status !== "complete")
5149
5634
  return s;
5150
5635
  const curCount = rt.scheduleRunCount ?? 0;
@@ -5153,6 +5638,15 @@ function createScheduleWorker(options) {
5153
5638
  const curNext = rt.nextRunAt;
5154
5639
  if (!curNext || Date.now() < Date.parse(curNext))
5155
5640
  return s;
5641
+ try {
5642
+ assertScopeAvailable(s, g);
5643
+ } catch {
5644
+ return s;
5645
+ }
5646
+ if (g.config.write_scope !== undefined) {
5647
+ retireWorker(s, g);
5648
+ g.workerSessionID = undefined;
5649
+ }
5156
5650
  g.status = "active";
5157
5651
  g.updatedAt = new Date().toISOString();
5158
5652
  g.blocker = undefined;
@@ -5172,7 +5666,7 @@ function createScheduleWorker(options) {
5172
5666
  continue;
5173
5667
  await appendEvent(directory, {
5174
5668
  version: 1,
5175
- eventID: randomUUID6(),
5669
+ eventID: randomUUID7(),
5176
5670
  goalID: goal.id,
5177
5671
  type: "schedule.tick",
5178
5672
  scheduleRunCount: count,
@@ -5198,7 +5692,7 @@ function createScheduleWorker(options) {
5198
5692
  }
5199
5693
 
5200
5694
  // src/v2/native-server.ts
5201
- import { randomUUID as randomUUID7 } from "crypto";
5695
+ import { randomUUID as randomUUID8 } from "crypto";
5202
5696
 
5203
5697
  // src/v2/native-bridge.ts
5204
5698
  class NativeBridgeError extends Error {
@@ -5340,7 +5834,7 @@ async function setupNativeServer(context) {
5340
5834
  bridge,
5341
5835
  requestWorker: async (input) => {
5342
5836
  const request = {
5343
- requestID: `req_${randomUUID7()}`,
5837
+ requestID: `req_${randomUUID8()}`,
5344
5838
  goalID: input.goalID ?? "",
5345
5839
  parentSessionID: input.parentSessionID,
5346
5840
  title: input.title,
@@ -5609,9 +6103,9 @@ function createCommandHost(opts) {
5609
6103
  }
5610
6104
 
5611
6105
  // src/application/command-service.ts
5612
- import { randomUUID as randomUUID8 } from "crypto";
5613
- import { promises as fs3 } from "fs";
5614
- import path3 from "path";
6106
+ import { randomUUID as randomUUID9 } from "crypto";
6107
+ import { promises as fs5 } from "fs";
6108
+ import path5 from "path";
5615
6109
 
5616
6110
  // src/domain/command-session.ts
5617
6111
  function normalizeTimeoutSeconds(timeoutSeconds) {
@@ -5899,7 +6393,7 @@ function shellJoin(command, args) {
5899
6393
  return [command, ...args].map(shellQuote).join(" ");
5900
6394
  }
5901
6395
  function loopCommandsDir(directory) {
5902
- return path3.join(directory, ".opencode", "loopd", "commands");
6396
+ return path5.join(directory, ".opencode", "loopd", "commands");
5903
6397
  }
5904
6398
  function createCommandService(host, opts) {
5905
6399
  const live = new Map;
@@ -5973,7 +6467,7 @@ function createCommandService(host, opts) {
5973
6467
  function watchLedger(directory, commandID, type, extra) {
5974
6468
  return appendEvent(directory, {
5975
6469
  version: 1,
5976
- eventID: randomUUID8(),
6470
+ eventID: randomUUID9(),
5977
6471
  commandID,
5978
6472
  type,
5979
6473
  timestamp: new Date().toISOString(),
@@ -6297,15 +6791,15 @@ function createCommandService(host, opts) {
6297
6791
  await appendCommandLog(directory, id, chunk);
6298
6792
  let retainedBytes = 0;
6299
6793
  let truncated = false;
6300
- const file = path3.join(loopCommandsDir(directory), `${id}.log`);
6301
- const stat = await fs3.stat(file);
6794
+ const file = path5.join(loopCommandsDir(directory), `${id}.log`);
6795
+ const stat = await fs5.stat(file);
6302
6796
  retainedBytes = stat.size;
6303
6797
  if (stat.size > MAX_COMMAND_OUTPUT_BYTES) {
6304
- const fh = await fs3.open(file, "r");
6798
+ const fh = await fs5.open(file, "r");
6305
6799
  try {
6306
6800
  const buf = Buffer.alloc(MAX_COMMAND_OUTPUT_BYTES);
6307
6801
  await fh.read(buf, 0, buf.length, stat.size - buf.length);
6308
- await fs3.writeFile(file, buf);
6802
+ await fs5.writeFile(file, buf);
6309
6803
  } finally {
6310
6804
  await fh.close();
6311
6805
  }
@@ -6450,7 +6944,7 @@ function createCommandService(host, opts) {
6450
6944
  }).catch(() => {});
6451
6945
  await appendEvent(directory, {
6452
6946
  version: 1,
6453
- eventID: randomUUID8(),
6947
+ eventID: randomUUID9(),
6454
6948
  commandID: id,
6455
6949
  type: "command.exited",
6456
6950
  exitCode: info.exitCode,
@@ -6495,90 +6989,96 @@ function createCommandService(host, opts) {
6495
6989
  watchState = initialWatchState();
6496
6990
  }
6497
6991
  const startedAt = new Date;
6498
- const id = randomUUID8();
6992
+ const id = randomUUID9();
6499
6993
  const cwd = input.cwd || directory;
6500
6994
  const useShell = input.shell === true;
6501
6995
  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
6996
  const pending = [];
6503
6997
  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
- });
6998
+ const scopeCallID = `command-start:${id}`;
6999
+ await beforeWorkspaceTool(directory, { tool: "loopd_command_start", sessionID: input.ownerSessionID, callID: scopeCallID }, {});
6538
7000
  try {
6539
- await mutateState(directory, `cmd.start:${id}`, async (s) => {
6540
- s.commands = [...s.commands ?? [], session];
6541
- return s;
7001
+ const handle = host.spawn(spawnOpts, (chunk) => {
7002
+ if (!ready)
7003
+ pending.push({ type: "output", chunk });
7004
+ else
7005
+ enqueue(id, () => persistOutput(directory, id, chunk)).catch(() => {});
7006
+ }, (info) => {
7007
+ if (!ready)
7008
+ pending.push({ type: "exit", info });
7009
+ else
7010
+ enqueue(id, () => persistExit(directory, id, info)).catch(() => {});
6542
7011
  });
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;
7012
+ const session = createCommandSession({
7013
+ id,
7014
+ title,
7015
+ command,
7016
+ args: input.args ?? [],
7017
+ cwd,
7018
+ ownerSessionID: input.ownerSessionID,
7019
+ goalID: input.goalID,
7020
+ notifyOnExit: input.notifyOnExit,
7021
+ notifySuccessfulExit: true,
7022
+ pid: handle.pid,
7023
+ cols: input.cols,
7024
+ rows: input.rows,
7025
+ shell: useShell ? true : undefined,
7026
+ timeoutSeconds,
7027
+ deadlineAt: timeoutSeconds !== undefined ? computeDeadlineAt(startedAt, timeoutSeconds) : undefined,
7028
+ envKeys: input.env ? Object.keys(input.env) : undefined,
7029
+ watchFilter,
7030
+ watchUntil,
7031
+ watchIgnoreCase: input.watchIgnoreCase,
7032
+ watchUntilAction: watcher?.untilAction,
7033
+ watchState
7034
+ });
7035
+ try {
7036
+ await mutateState(directory, `cmd.start:${id}`, async (s) => {
7037
+ s.commands = [...s.commands ?? [], session];
7038
+ return s;
7039
+ });
7040
+ } catch (error) {
7041
+ handle.terminate();
7042
+ const exited = await Promise.race([
7043
+ handle.exited().then(() => true, () => true),
7044
+ new Promise((resolve) => setTimeout(() => resolve(false), 1000))
7045
+ ]);
7046
+ if (!exited && handle.isAlive())
7047
+ handle.kill();
7048
+ throw error;
7049
+ }
7050
+ live.set(id, { handle, buffers: [], bufferedBytes: 0 });
7051
+ if (watcher)
7052
+ watchers.set(id, watcher);
7053
+ if (timeoutSeconds !== undefined)
7054
+ scheduleTimeout(directory, id, input.ownerSessionID, timeoutSeconds);
7055
+ rememberDir(id, directory);
7056
+ await appendEvent(directory, {
7057
+ version: 1,
7058
+ eventID: randomUUID9(),
7059
+ ...input.goalID ? { goalID: input.goalID } : {},
7060
+ commandID: id,
7061
+ type: "command.started",
7062
+ title,
7063
+ timestamp: new Date().toISOString(),
7064
+ revision: 0
7065
+ }).catch(() => {});
7066
+ try {
7067
+ const fresh = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
7068
+ if (fresh)
7069
+ emitBroker(id, { type: "status", command: fresh });
7070
+ } catch {}
7071
+ const initialEvents = pending.splice(0);
7072
+ ready = true;
7073
+ const initialOperations = [];
7074
+ for (const event of initialEvents) {
7075
+ initialOperations.push(event.type === "output" ? enqueue(id, () => persistOutput(directory, id, event.chunk)) : enqueue(id, () => persistExit(directory, id, event.info)));
7076
+ }
7077
+ await Promise.all(initialOperations);
7078
+ return await service.get(directory, id, input.ownerSessionID) ?? session;
7079
+ } finally {
7080
+ await afterWorkspaceTool(directory, input.ownerSessionID, scopeCallID);
6552
7081
  }
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
7082
  },
6583
7083
  async list(directory, ownerSessionID) {
6584
7084
  const s = await readState(directory);
@@ -6655,16 +7155,22 @@ function createCommandService(host, opts) {
6655
7155
  return { session, text: file.text, totalBytes: session.outputBytes, startByte: file.startByte, live: session.status === "running" };
6656
7156
  },
6657
7157
  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}".` };
7158
+ const scopeCallID = `command-write:${randomUUID9()}`;
7159
+ await beforeWorkspaceTool(directory, { tool: "loopd_command_write", sessionID: ownerSessionID, callID: scopeCallID }, {});
7160
+ try {
7161
+ const session = await service.get(directory, id, ownerSessionID);
7162
+ if (!session)
7163
+ return { ok: false, message: "Command not found." };
7164
+ if (session.status !== "running")
7165
+ return { ok: false, message: `Command is ${session.status}; only running commands accept input.` };
7166
+ const entry = live.get(id);
7167
+ const ok = entry ? entry.handle.write(data) : false;
7168
+ if (!ok)
7169
+ return { ok: false, message: "Process input unavailable (no live handle \u2014 host may have restarted; reconcile marks it honestly)." };
7170
+ return { ok: true, message: `Sent ${Buffer.byteLength(data)} byte(s) to "${session.title}".` };
7171
+ } finally {
7172
+ await afterWorkspaceTool(directory, ownerSessionID, scopeCallID);
7173
+ }
6668
7174
  },
6669
7175
  async resize(directory, id, ownerSessionID, cols, rows) {
6670
7176
  const session = await service.get(directory, id, ownerSessionID);
@@ -7051,6 +7557,9 @@ function createCommandService(host, opts) {
7051
7557
  const x = (s.commands ?? []).find((y) => y.id === c.id);
7052
7558
  if (x && x.status === "running") {
7053
7559
  x.status = "missing";
7560
+ s.orphanedCommandProcesses ??= [];
7561
+ if (!s.orphanedCommandProcesses.some((process2) => process2.commandID === x.id))
7562
+ s.orphanedCommandProcesses.push({ commandID: x.id, pid: x.pid });
7054
7563
  x.endReason = "missing";
7055
7564
  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
7565
  x.updatedAt = new Date().toISOString();
@@ -7102,7 +7611,7 @@ function createCommandService(host, opts) {
7102
7611
  }
7103
7612
 
7104
7613
  // src/application/command-event-broker.ts
7105
- import { randomUUID as randomUUID9 } from "crypto";
7614
+ import { randomUUID as randomUUID10 } from "crypto";
7106
7615
  function safeDeliver(sink, msg) {
7107
7616
  try {
7108
7617
  sink(msg);
@@ -7145,7 +7654,7 @@ function createCommandEventBroker(resolver) {
7145
7654
  throw new Error("ownerSessionID is required.");
7146
7655
  if (typeof sink !== "function")
7147
7656
  throw new Error("sink must be a function.");
7148
- const sinkID = randomUUID9();
7657
+ const sinkID = randomUUID10();
7149
7658
  const entry = { sinkID, sink, state: "subscribing", buffer: [] };
7150
7659
  entriesFor(commandID).set(sinkID, entry);
7151
7660
  try {
@@ -7234,11 +7743,11 @@ function createCommandEventBroker(resolver) {
7234
7743
  }
7235
7744
 
7236
7745
  // 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";
7746
+ import { randomBytes, randomUUID as randomUUID11, timingSafeEqual } from "crypto";
7747
+ import { promises as fs6 } from "fs";
7748
+ import path6 from "path";
7240
7749
  function streamEndpointPath(directory) {
7241
- return path4.join(directory, ".opencode", "loopd", "commands", ".stream-endpoint.json");
7750
+ return path6.join(directory, ".opencode", "loopd", "commands", ".stream-endpoint.json");
7242
7751
  }
7243
7752
  function isPidAlive(pid) {
7244
7753
  if (!Number.isInteger(pid) || pid <= 0)
@@ -7268,7 +7777,7 @@ var MAX_BUFFERED_BYTES = 512 * 1024;
7268
7777
  function createCommandStreamServer(directory, commandService, broker) {
7269
7778
  const endpointPath = streamEndpointPath(directory);
7270
7779
  const token = randomBytes(32).toString("hex");
7271
- const generation = randomUUID10();
7780
+ const generation = randomUUID11();
7272
7781
  const startedAt = new Date().toISOString();
7273
7782
  let server;
7274
7783
  let serverURL;
@@ -7484,10 +7993,10 @@ function createCommandStreamServer(directory, commandService, broker) {
7484
7993
  generation,
7485
7994
  startedAt
7486
7995
  };
7487
- await fs4.mkdir(path4.dirname(endpointPath), { recursive: true });
7488
- await fs4.writeFile(endpointPath, JSON.stringify(endpoint, null, 2) + `
7996
+ await fs6.mkdir(path6.dirname(endpointPath), { recursive: true });
7997
+ await fs6.writeFile(endpointPath, JSON.stringify(endpoint, null, 2) + `
7489
7998
  `, { mode: 384 });
7490
- await fs4.chmod(endpointPath, 384);
7999
+ await fs6.chmod(endpointPath, 384);
7491
8000
  }
7492
8001
  return {
7493
8002
  get url() {
@@ -7501,7 +8010,7 @@ function createCommandStreamServer(directory, commandService, broker) {
7501
8010
  return;
7502
8011
  started = true;
7503
8012
  try {
7504
- const previous = await fs4.readFile(endpointPath, "utf8").then((text) => JSON.parse(text), () => {
8013
+ const previous = await fs6.readFile(endpointPath, "utf8").then((text) => JSON.parse(text), () => {
7505
8014
  return;
7506
8015
  });
7507
8016
  if (previous && typeof previous.pid === "number" && isPidAlive(previous.pid) && previous.pid !== process.pid) {
@@ -7571,10 +8080,10 @@ function createCommandStreamServer(directory, commandService, broker) {
7571
8080
  server = undefined;
7572
8081
  serverURL = undefined;
7573
8082
  try {
7574
- const text = await fs4.readFile(endpointPath, "utf8");
8083
+ const text = await fs6.readFile(endpointPath, "utf8");
7575
8084
  const current = JSON.parse(text);
7576
8085
  if (current?.generation === generation) {
7577
- await fs4.unlink(endpointPath);
8086
+ await fs6.unlink(endpointPath);
7578
8087
  } else {
7579
8088
  logServerEvent(directory, "command-stream.keep-endpoint", {
7580
8089
  reason: "generation mismatch \u2014 another server owns the file"
@@ -7588,7 +8097,7 @@ function createCommandStreamServer(directory, commandService, broker) {
7588
8097
 
7589
8098
  // src/server/goal-tools.ts
7590
8099
  init_state_repository();
7591
- import { randomUUID as randomUUID11 } from "crypto";
8100
+ import { randomUUID as randomUUID12 } from "crypto";
7592
8101
  import { tool } from "@opencode-ai/plugin/tool";
7593
8102
  // src/domain/verification.ts
7594
8103
  var MAX_RECENT_ATTEMPTS = 10;
@@ -7603,11 +8112,54 @@ function appendVerificationAttempt(recent, attempt) {
7603
8112
  // src/server/goal-tools.ts
7604
8113
  import { exec as execChild } from "child_process";
7605
8114
  import { promisify } from "util";
8115
+
8116
+ // src/application/goal-scope.ts
8117
+ init_state_repository();
8118
+ init_workspace_scope();
8119
+ class GoalScopeConflictError extends Error {
8120
+ conflict;
8121
+ kind;
8122
+ constructor(conflict, kind) {
8123
+ super(scopeConflictMessage(conflict));
8124
+ this.conflict = conflict;
8125
+ this.kind = kind;
8126
+ this.name = "GoalScopeConflictError";
8127
+ }
8128
+ }
8129
+ async function claimGoalScope(directory, goalID, workerSessionID, generation, paths) {
8130
+ let claimed = [];
8131
+ await mutateState(directory, `goal.claim-scope:${goalID}`, async (state) => {
8132
+ const goal = state.goals.find((item) => item.id === goalID);
8133
+ const runtime = state.runtimes.find((item) => item.goalID === goalID);
8134
+ if (!goal || goal.workerSessionID !== workerSessionID || runtime?.runGeneration !== generation) {
8135
+ throw new Error("Scope claim denied: stale worker session or run generation. Read get_goal before claiming.");
8136
+ }
8137
+ if (goal.status !== "active")
8138
+ throw new Error(`Scope claim denied: goal is ${goal.status}, not active.`);
8139
+ if (goal.config.workspaceWrite === false)
8140
+ throw new Error("Read-only/artifact goals cannot claim shared workspace files.");
8141
+ if (goal.config.write_scope === undefined)
8142
+ throw new Error("Legacy whole-workspace goals already own the workspace; explicit scopes must be selected at creation.");
8143
+ const additions = await canonicalWriteScope(directory, paths);
8144
+ claimed = [...new Set([...goal.config.write_scope, ...additions])].sort();
8145
+ const candidate = { ...goal, config: { ...goal.config, write_scope: claimed } };
8146
+ const conflict = findScopeConflict(heldGoals(state).map((owner) => ({ ...owner, status: "active" })), candidate);
8147
+ if (conflict)
8148
+ throw new GoalScopeConflictError(conflict, goal.config.write_scope.length === 0 ? "initial" : "expansion");
8149
+ assertScopeAvailable(state, candidate);
8150
+ goal.config.write_scope = claimed;
8151
+ goal.updatedAt = new Date().toISOString();
8152
+ return state;
8153
+ });
8154
+ return claimed;
8155
+ }
8156
+
8157
+ // src/server/goal-tools.ts
7606
8158
  var execAsync = promisify(execChild);
7607
8159
  function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
7608
8160
  return {
7609
8161
  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).",
8162
+ 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
8163
  args: {
7612
8164
  name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
7613
8165
  objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
@@ -7618,6 +8170,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
7618
8170
  checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
7619
8171
  checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
7620
8172
  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."),
8173
+ 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
8174
  progressFile: tool.schema.string().optional().describe("Markdown file the worker reads/writes as its transaction state."),
7622
8175
  maxTurns: tool.schema.number().optional().describe("Max turns before auto-block."),
7623
8176
  maxNoProgress: tool.schema.number().optional().describe("Block after N turns without progress."),
@@ -7654,6 +8207,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
7654
8207
  config.checkCwd = args.checkCwd;
7655
8208
  if (args.workspaceWrite !== undefined)
7656
8209
  config.workspaceWrite = args.workspaceWrite;
8210
+ if (args.write_scope !== undefined)
8211
+ config.write_scope = args.write_scope;
7657
8212
  if (args.progressFile)
7658
8213
  config.progressFile = args.progressFile;
7659
8214
  if (args.maxTurns !== undefined)
@@ -7741,6 +8296,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
7741
8296
  interactive: goal.interactive === true,
7742
8297
  checks: resolution.config.checks || [],
7743
8298
  workspaceWrite: resolution.config.workspaceWrite,
8299
+ write_scope: goal.config.write_scope,
8300
+ enforcedScopedExecution: goalService.scopedExecution,
7744
8301
  defaultsApplied: resolution.defaultsApplied,
7745
8302
  name: args.name,
7746
8303
  message: `Goal "${args.name}" created and started in the background. Artifacts: ${goal.config.artifactDir}. Monitor with /loop (<leader>o).`
@@ -7786,10 +8343,54 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
7786
8343
  const runtime = state.runtimes.find((r) => r.goalID === goal.id);
7787
8344
  return {
7788
8345
  title: `Goal: ${goal.name}`,
7789
- output: formatGoalStructured(goal, runtime)
8346
+ output: formatGoalStructured(goal, runtime, goalService.scopedExecution)
7790
8347
  };
7791
8348
  }
7792
8349
  }),
8350
+ claim_goal_scope: tool({
8351
+ 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."),
8352
+ args: {
8353
+ paths: tool.schema.array(tool.schema.string()).describe("Exact workspace-relative files to add (no globs)."),
8354
+ runGeneration: tool.schema.number().describe("Current runGeneration from get_goal; stale workers are denied.")
8355
+ },
8356
+ execute: async (args, context) => {
8357
+ if (!goalService.scopedExecution)
8358
+ 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." }) };
8359
+ const workerID = context?.sessionID || hostSessionID;
8360
+ const goal = findGoalByWorkerSession(await readState(dir), workerID);
8361
+ if (!goal || !workerID)
8362
+ return { title: "Scope denied", output: JSON.stringify({ ok: false, errorCode: "worker_not_found" }) };
8363
+ try {
8364
+ const write_scope = await claimGoalScope(dir, goal.id, workerID, args.runGeneration, args.paths);
8365
+ return { title: "Scope claimed", output: JSON.stringify({ ok: true, goalID: goal.id, write_scope }) };
8366
+ } catch (error) {
8367
+ return {
8368
+ title: "Scope denied",
8369
+ output: JSON.stringify({
8370
+ ok: false,
8371
+ message: error instanceof Error ? error.message : String(error),
8372
+ ...error instanceof GoalScopeConflictError ? { errorCode: "scope_conflict", kind: error.kind, conflict: error.conflict, retry: "coordinate_then_retry" } : { errorCode: "scope_denied" }
8373
+ })
8374
+ };
8375
+ }
8376
+ }
8377
+ }),
8378
+ run_goal_checks: tool({
8379
+ 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.",
8380
+ args: {},
8381
+ execute: async (_args, context) => {
8382
+ const workerID = context?.sessionID || hostSessionID;
8383
+ const goal = findGoalByWorkerSession(await readState(dir), workerID);
8384
+ if (!goal || !workerID || goal.status !== "active")
8385
+ return { title: "Verification denied", output: JSON.stringify({ ok: false, message: "An active exact worker session is required." }) };
8386
+ try {
8387
+ const checks = await withWorkspaceOperation(dir, goal.id, workerID, () => runCompletionChecks(goal.config.checks || [], goal.config.checkCwd || dir));
8388
+ return { title: checks.passed ? "Checks passed" : "Checks failed", output: JSON.stringify({ ok: checks.passed, ...checks }) };
8389
+ } catch (error) {
8390
+ return { title: "Verification denied", output: JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) }) };
8391
+ }
8392
+ }
8393
+ }),
7793
8394
  report_goal_progress: tool({
7794
8395
  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
8396
  args: {
@@ -7818,10 +8419,10 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
7818
8419
  next: args.next,
7819
8420
  at: new Date().toISOString()
7820
8421
  };
7821
- await writeState(dir, state);
8422
+ state.revision = (await persistWorkerMutation(dir, goal, runtime, ["active"])).revision;
7822
8423
  const event = {
7823
8424
  version: 1,
7824
- eventID: randomUUID11(),
8425
+ eventID: randomUUID12(),
7825
8426
  goalID: goal.id,
7826
8427
  type: "goal.progress",
7827
8428
  summary: args.summary,
@@ -7859,7 +8460,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
7859
8460
  }
7860
8461
  if (goal.config.checks?.length) {
7861
8462
  const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
7862
- const checkResults = await runCompletionChecks(goal.config.checks, cwd);
8463
+ const checkResults = await withWorkspaceOperation(dir, goal.id, goal.workerSessionID, () => runCompletionChecks(goal.config.checks, cwd));
7863
8464
  if (!checkResults.passed) {
7864
8465
  const failureDetails = checkResults.failures.map((f) => {
7865
8466
  const stdoutSnippet = f.stdout ? `
@@ -7886,7 +8487,7 @@ Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
7886
8487
  Working directory: ${cwd}
7887
8488
 
7888
8489
  ${failureDetails}`;
7889
- attemptID = randomUUID11();
8490
+ attemptID = randomUUID12();
7890
8491
  const verificationAttempt = {
7891
8492
  id: attemptID,
7892
8493
  sequence: runtime.evaluatorRejectionCount,
@@ -7931,7 +8532,7 @@ ${failureDetails.slice(0, 500)}`,
7931
8532
  if (attemptID) {
7932
8533
  await appendEvent(dir, {
7933
8534
  version: 1,
7934
- eventID: randomUUID11(),
8535
+ eventID: randomUUID12(),
7935
8536
  goalID: goal.id,
7936
8537
  type: "goal.completion_rejected",
7937
8538
  attemptID,
@@ -7949,7 +8550,7 @@ ${failureDetails.slice(0, 500)}`,
7949
8550
  if (blocked && rejectedGoal?.blocker) {
7950
8551
  await appendEvent(dir, {
7951
8552
  version: 1,
7952
- eventID: randomUUID11(),
8553
+ eventID: randomUUID12(),
7953
8554
  goalID: goal.id,
7954
8555
  type: "goal.blocked",
7955
8556
  reason: rejectedGoal.blocker.reason,
@@ -8009,7 +8610,7 @@ ${failureDetails.slice(0, 500)}`,
8009
8610
  }
8010
8611
  runtime.updatedAt = new Date().toISOString();
8011
8612
  }
8012
- const attemptID = randomUUID11();
8613
+ const attemptID = randomUUID12();
8013
8614
  const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
8014
8615
  const checks = (goal.config.checks || []).map((cmd) => ({
8015
8616
  command: cmd,
@@ -8030,10 +8631,10 @@ ${failureDetails.slice(0, 500)}`,
8030
8631
  runtime.lastVerificationAttempt = verificationAttempt;
8031
8632
  runtime.recentVerificationAttempts = appendVerificationAttempt(runtime.recentVerificationAttempts || [], verificationAttempt);
8032
8633
  }
8033
- await writeState(dir, state);
8634
+ state.revision = (await persistWorkerMutation(dir, goal, runtime, ["active", "budget_limited"])).revision;
8034
8635
  const event = {
8035
8636
  version: 1,
8036
- eventID: randomUUID11(),
8637
+ eventID: randomUUID12(),
8037
8638
  goalID: goal.id,
8038
8639
  type: "goal.completed",
8039
8640
  summary: args.summary,
@@ -8090,10 +8691,10 @@ ${failureDetails.slice(0, 500)}`,
8090
8691
  runtime.accountedMessageIDs = [...runtime.accountedMessageIDs ?? [], ...finalUsage.counted].slice(-200);
8091
8692
  runtime.updatedAt = new Date().toISOString();
8092
8693
  }
8093
- await writeState(dir, state);
8694
+ state.revision = (await persistWorkerMutation(dir, goal, runtime, ["active", "budget_limited"])).revision;
8094
8695
  const event = {
8095
8696
  version: 1,
8096
- eventID: randomUUID11(),
8697
+ eventID: randomUUID12(),
8097
8698
  goalID: goal.id,
8098
8699
  type: "goal.blocked",
8099
8700
  reason: args.reason,
@@ -8149,7 +8750,7 @@ function findGoalByWorkerSession(state, sessionID) {
8149
8750
  return;
8150
8751
  return state.goals.find((g) => g.workerSessionID === sessionID && (g.status === "active" || g.status === "blocked"));
8151
8752
  }
8152
- function formatGoalStructured(goal, runtime) {
8753
+ function formatGoalStructured(goal, runtime, enforcedScopedExecution = false) {
8153
8754
  const output = {
8154
8755
  id: goal.id,
8155
8756
  name: goal.name,
@@ -8164,6 +8765,11 @@ function formatGoalStructured(goal, runtime) {
8164
8765
  checks: goal.config.checks,
8165
8766
  checkCwd: goal.config.checkCwd,
8166
8767
  workspaceWrite: goal.config.workspaceWrite,
8768
+ write_scope: goal.config.write_scope,
8769
+ scopeClosing: goal.scopeClosing === true,
8770
+ scopeClearPending: goal.scopeClearPending === true,
8771
+ scopeRetryPolicy: "coordinate_then_retry_no_automatic_queue",
8772
+ enforcedScopedExecution,
8167
8773
  agent: goal.config.agent,
8168
8774
  model: goal.config.model,
8169
8775
  maxTurns: goal.config.maxTurns,
@@ -8214,6 +8820,25 @@ function formatGoalStructured(goal, runtime) {
8214
8820
  }
8215
8821
  return JSON.stringify(output, null, 2);
8216
8822
  }
8823
+ async function persistWorkerMutation(dir, goal, runtime, from) {
8824
+ return mutateState(dir, `worker.transition:${goal.id}`, async (state) => {
8825
+ const current = state.goals.find((item) => item.id === goal.id);
8826
+ const rt = state.runtimes.find((item) => item.goalID === goal.id);
8827
+ if (!current || current.workerSessionID !== goal.workerSessionID || !from.includes(current.status) || current.scopeClosing || runtime && rt?.runGeneration !== runtime.runGeneration) {
8828
+ throw new Error("Worker transition denied: stale session/generation or closing goal.");
8829
+ }
8830
+ current.status = goal.status;
8831
+ current.updatedAt = goal.updatedAt;
8832
+ current.lastProgress = goal.lastProgress;
8833
+ current.completionEvidence = goal.completionEvidence;
8834
+ current.blocker = goal.blocker;
8835
+ if (rt && runtime) {
8836
+ const { activeToolCallIDs, accountedMessageIDs, turnTokensUsed, ...transition } = runtime;
8837
+ Object.assign(rt, transition);
8838
+ }
8839
+ return state;
8840
+ });
8841
+ }
8217
8842
  async function runCompletionChecks(checks, cwd) {
8218
8843
  const failures = [];
8219
8844
  for (const cmd of checks) {
@@ -8273,14 +8898,14 @@ function describeGoalState(status, phase) {
8273
8898
  }
8274
8899
 
8275
8900
  // src/server/owner-tools.ts
8276
- import { promises as fs5 } from "fs";
8901
+ import { promises as fs7 } from "fs";
8277
8902
  function withTimeout2(promise, ms) {
8278
8903
  return Promise.race([
8279
8904
  promise,
8280
8905
  new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))
8281
8906
  ]);
8282
8907
  }
8283
- function ownerTools(options) {
8908
+ function ownerTools2(options) {
8284
8909
  const { directory, host, goalService } = options;
8285
8910
  return {
8286
8911
  loopd_list_models: tool2({
@@ -8448,7 +9073,7 @@ function ownerTools(options) {
8448
9073
  if (!dir)
8449
9074
  return;
8450
9075
  try {
8451
- const files = await fs5.readdir(dir);
9076
+ const files = await fs7.readdir(dir);
8452
9077
  return files.length ? `${files.length} file(s): ${files.slice(0, 8).join(", ")}` : "no artifacts yet";
8453
9078
  } catch {
8454
9079
  return "no artifacts yet";
@@ -8480,6 +9105,10 @@ function ownerTools(options) {
8480
9105
  checks: goal.config.checks,
8481
9106
  checkCwd: goal.config.checkCwd,
8482
9107
  workspaceWrite: goal.config.workspaceWrite,
9108
+ write_scope: goal.config.write_scope,
9109
+ scopeClosing: goal.scopeClosing === true,
9110
+ scopeClearPending: goal.scopeClearPending === true,
9111
+ scopeRetryPolicy: "coordinate_then_retry_no_automatic_queue",
8483
9112
  agent: goal.config.agent,
8484
9113
  model: goal.config.model,
8485
9114
  fallbackModels: goal.config.fallbackModels ?? [],
@@ -8624,7 +9253,7 @@ function ownerTools(options) {
8624
9253
  }
8625
9254
  }),
8626
9255
  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.",
9256
+ 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
9257
  args: {
8629
9258
  goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to pause the first active goal.")
8630
9259
  },
@@ -8668,7 +9297,7 @@ function ownerTools(options) {
8668
9297
  }
8669
9298
  }),
8670
9299
  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.",
9300
+ 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
9301
  args: {
8673
9302
  goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to resume the first paused/blocked goal.")
8674
9303
  },
@@ -9236,7 +9865,7 @@ function commandTools(options) {
9236
9865
  // src/server/plugin.ts
9237
9866
  init_state_repository();
9238
9867
  // package.json
9239
- var version = "1.12.3";
9868
+ var version = "1.12.4";
9240
9869
 
9241
9870
  // src/server/plugin.ts
9242
9871
  var PLUGIN_ID = "opencode-loopd.server";
@@ -9341,8 +9970,9 @@ function createServerHooks(directory, host, defaults) {
9341
9970
  if (type?.startsWith("session."))
9342
9971
  reconcileInBackground();
9343
9972
  },
9344
- tool: { ...goalTools(directory, goalService, undefined, defaults, host), ...ownerTools({ directory, host, goalService }), ...commandTools({ directory, commandService, capabilities: commandHost.capabilities, goalService }) },
9973
+ tool: { ...goalTools(directory, goalService, undefined, defaults, host), ...ownerTools2({ directory, host, goalService }), ...commandTools({ directory, commandService, capabilities: commandHost.capabilities, goalService }) },
9345
9974
  "tool.execute.before": async (input, _output) => {
9975
+ await beforeWorkspaceTool(directory, input, _output.args);
9346
9976
  const activeWorkers = goalService.getActiveWorkers();
9347
9977
  let matchedGoalID;
9348
9978
  for (const [goalID, worker] of activeWorkers) {
@@ -9363,6 +9993,7 @@ function createServerHooks(directory, host, defaults) {
9363
9993
  } catch {}
9364
9994
  },
9365
9995
  "tool.execute.after": async (input, output) => {
9996
+ await afterWorkspaceTool(directory, input.sessionID, input.callID);
9366
9997
  if (input.tool === "loopd_create_goal" || input.tool === "get_goal" || input.tool === "report_goal_progress") {
9367
9998
  ensureStarted();
9368
9999
  reconcileInBackground();
@@ -9474,6 +10105,7 @@ var v2 = {
9474
10105
  for (const [id, definition] of Object.entries(hooks.tool ?? {})) {
9475
10106
  editor.add(toV2Tool(id, definition, directory));
9476
10107
  }
10108
+ guardV2ToolEditor(directory, editor);
9477
10109
  }));
9478
10110
  registrations.push(await context.tool.hook("execute.before", async (input) => {
9479
10111
  await hooks["tool.execute.before"]?.({