@bojackduy/opencode-loopd 1.12.2 → 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/README.md +4 -0
- package/dist/server.js +849 -217
- package/dist/tui.js +1431 -6824
- package/package.json +1 -1
- package/scripts/build-tui.ts +3 -0
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/
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
403
|
+
await fs2.writeFile(temp, contents, "utf8");
|
|
353
404
|
try {
|
|
354
405
|
for (let attempt = 0;attempt < 5; attempt++) {
|
|
355
406
|
try {
|
|
356
|
-
await
|
|
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
|
|
418
|
+
await fs2.copyFile(temp, target);
|
|
368
419
|
} finally {
|
|
369
420
|
try {
|
|
370
|
-
await
|
|
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
|
|
442
|
+
await fs2.mkdir(loopDir(directory), { recursive: true });
|
|
392
443
|
const line = JSON.stringify(event) + `
|
|
393
444
|
`;
|
|
394
|
-
await
|
|
445
|
+
await fs2.appendFile(eventsFile(directory), line, "utf8");
|
|
395
446
|
}
|
|
396
447
|
async function readEvents(directory, limit = 50) {
|
|
397
|
-
|
|
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
|
|
423
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
520
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
477
521
|
const msg = { from, text, at: new Date().toISOString() };
|
|
478
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
516
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2993
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
2857
2994
|
init_state_repository();
|
|
2858
2995
|
init_command_await2();
|
|
2859
|
-
import * as
|
|
2860
|
-
import { promises as
|
|
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 (
|
|
3776
|
-
|
|
3777
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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 =
|
|
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:
|
|
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 =
|
|
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:
|
|
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:
|
|
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 =
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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_${
|
|
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
|
|
5613
|
-
import { promises as
|
|
5614
|
-
import
|
|
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
|
|
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:
|
|
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 =
|
|
6301
|
-
const stat = await
|
|
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
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
|
6505
|
-
|
|
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
|
-
|
|
6540
|
-
|
|
6541
|
-
|
|
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
|
-
|
|
6544
|
-
|
|
6545
|
-
|
|
6546
|
-
|
|
6547
|
-
|
|
6548
|
-
|
|
6549
|
-
|
|
6550
|
-
|
|
6551
|
-
|
|
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
|
|
6659
|
-
|
|
6660
|
-
|
|
6661
|
-
|
|
6662
|
-
|
|
6663
|
-
|
|
6664
|
-
|
|
6665
|
-
|
|
6666
|
-
|
|
6667
|
-
|
|
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
|
|
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 =
|
|
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
|
|
7238
|
-
import { promises as
|
|
7239
|
-
import
|
|
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
|
|
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 =
|
|
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
|
|
7488
|
-
await
|
|
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
|
|
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
|
|
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
|
|
8083
|
+
const text = await fs6.readFile(endpointPath, "utf8");
|
|
7575
8084
|
const current = JSON.parse(text);
|
|
7576
8085
|
if (current?.generation === generation) {
|
|
7577
|
-
await
|
|
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
|
|
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
|
|
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
|
|
8422
|
+
state.revision = (await persistWorkerMutation(dir, goal, runtime, ["active"])).revision;
|
|
7822
8423
|
const event = {
|
|
7823
8424
|
version: 1,
|
|
7824
|
-
eventID:
|
|
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 =
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
|
8634
|
+
state.revision = (await persistWorkerMutation(dir, goal, runtime, ["active", "budget_limited"])).revision;
|
|
8034
8635
|
const event = {
|
|
8035
8636
|
version: 1,
|
|
8036
|
-
eventID:
|
|
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
|
|
8694
|
+
state.revision = (await persistWorkerMutation(dir, goal, runtime, ["active", "budget_limited"])).revision;
|
|
8094
8695
|
const event = {
|
|
8095
8696
|
version: 1,
|
|
8096
|
-
eventID:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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), ...
|
|
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"]?.({
|