@bojackduy/opencode-loopd 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,2402 @@
1
+ // @bun
2
+ var __defProp = Object.defineProperty;
3
+ var __returnValue = (v) => v;
4
+ function __exportSetter(name, newValue) {
5
+ this[name] = __returnValue.bind(null, newValue);
6
+ }
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, {
10
+ get: all[name],
11
+ enumerable: true,
12
+ configurable: true,
13
+ set: __exportSetter.bind(all, name)
14
+ });
15
+ };
16
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
+
18
+ // src/infrastructure/state-repository.ts
19
+ var exports_state_repository = {};
20
+ __export(exports_state_repository, {
21
+ writeState: () => writeState,
22
+ writeControlResponse: () => writeControlResponse,
23
+ writeControlRequest: () => writeControlRequest,
24
+ recoverStaleProcessing: () => recoverStaleProcessing,
25
+ readState: () => readState,
26
+ readEvents: () => readEvents,
27
+ readControlResponse: () => readControlResponse,
28
+ readControlRequest: () => readControlRequest,
29
+ mutateState: () => mutateState,
30
+ listPendingRequests: () => listPendingRequests,
31
+ goalArtifactDir: () => goalArtifactDir,
32
+ ensureGoalArtifactDir: () => ensureGoalArtifactDir,
33
+ drainGoalInbox: () => drainGoalInbox,
34
+ claimControlRequest: () => claimControlRequest,
35
+ appendGoalInbox: () => appendGoalInbox,
36
+ appendEvent: () => appendEvent
37
+ });
38
+ import { promises as fs } from "fs";
39
+ import path from "path";
40
+ import os from "os";
41
+ import { randomUUID } from "crypto";
42
+ function emptyState() {
43
+ return { version: CURRENT_VERSION, revision: 0, goals: [], runtimes: [], commandLedger: [] };
44
+ }
45
+ function loopDir(directory) {
46
+ return path.join(directory, ".opencode", "loopd");
47
+ }
48
+ function stateFile(directory) {
49
+ return path.join(loopDir(directory), "state.json");
50
+ }
51
+ function eventsFile(directory) {
52
+ return path.join(loopDir(directory), "events.ndjson");
53
+ }
54
+ function lockDir(directory) {
55
+ const projectHash = Buffer.from(directory).toString("base64url").slice(0, 32);
56
+ return path.join(os.tmpdir(), "loopd-locks", projectHash);
57
+ }
58
+ function lockFile(directory, key) {
59
+ return path.join(lockDir(directory), `${key}.lock`);
60
+ }
61
+ async function acquireLock(directory, key, operation) {
62
+ const dir = lockDir(directory);
63
+ await fs.mkdir(dir, { recursive: true });
64
+ const lockPath = lockFile(directory, key);
65
+ const lockID = randomUUID();
66
+ for (let attempt = 0;attempt < 10; attempt++) {
67
+ try {
68
+ try {
69
+ const raw = await fs.readFile(lockPath, "utf8");
70
+ const meta2 = JSON.parse(raw);
71
+ const age = Date.now() - Date.parse(meta2.acquiredAt);
72
+ if (age > LOCK_STALE_MS) {
73
+ await fs.rm(lockPath, { force: true });
74
+ }
75
+ } catch {}
76
+ const temp = lockPath + `.${lockID}.tmp`;
77
+ const meta = { pid: process.pid, operation, acquiredAt: new Date().toISOString() };
78
+ await fs.writeFile(temp, JSON.stringify(meta), "utf8");
79
+ try {
80
+ await fs.rename(temp, lockPath);
81
+ return;
82
+ } catch (error) {
83
+ await fs.rm(temp, { force: true });
84
+ if (error?.code !== "EEXIST")
85
+ throw error;
86
+ }
87
+ } catch (error) {
88
+ if (error?.code === "ENOENT") {
89
+ await fs.mkdir(dir, { recursive: true });
90
+ continue;
91
+ }
92
+ throw error;
93
+ }
94
+ await delay(25 * (attempt + 1));
95
+ }
96
+ throw new Error(`failed to acquire lock "${key}" for "${operation}" after retries`);
97
+ }
98
+ async function releaseLock(directory, key) {
99
+ try {
100
+ await fs.rm(lockFile(directory, key), { force: true });
101
+ } catch {}
102
+ }
103
+ async function readState(directory) {
104
+ const target = stateFile(directory);
105
+ const attempts = 5;
106
+ for (let attempt = 0;attempt < attempts; attempt++) {
107
+ try {
108
+ const raw = await fs.readFile(target, "utf8");
109
+ const parsed = JSON.parse(raw);
110
+ if (parsed && typeof parsed === "object" && Array.isArray(parsed.goals)) {
111
+ return migrate(parsed);
112
+ }
113
+ return emptyState();
114
+ } catch (error) {
115
+ if (error?.code === "ENOENT")
116
+ return emptyState();
117
+ const transient = error instanceof SyntaxError || error?.code === "EPERM" || error?.code === "EACCES" || error?.code === "EBUSY";
118
+ if (!transient || attempt === attempts - 1)
119
+ break;
120
+ await delay(25 * (attempt + 1));
121
+ }
122
+ }
123
+ return emptyState();
124
+ }
125
+ function migrate(state) {
126
+ if (state.version === CURRENT_VERSION)
127
+ return state;
128
+ let result = { ...state };
129
+ if (result.version < 2) {
130
+ result.version = 2;
131
+ if (!result.commandLedger)
132
+ result.commandLedger = [];
133
+ result.runtimes = result.runtimes.map((rt) => ({
134
+ ...rt,
135
+ progressDuringTurn: rt.progressDuringTurn ?? false
136
+ }));
137
+ result.goals = result.goals.map((g) => ({
138
+ ...g,
139
+ lastProgress: g.lastProgress ?? undefined,
140
+ completionEvidence: g.completionEvidence ?? undefined,
141
+ blocker: g.blocker ?? undefined
142
+ }));
143
+ }
144
+ return result;
145
+ }
146
+ async function writeAtomic(target, contents) {
147
+ const dir = path.dirname(target);
148
+ await fs.mkdir(dir, { recursive: true });
149
+ const temp = path.join(dir, `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
150
+ await fs.writeFile(temp, contents, "utf8");
151
+ try {
152
+ for (let attempt = 0;attempt < 5; attempt++) {
153
+ try {
154
+ await fs.rename(temp, target);
155
+ return;
156
+ } catch (error) {
157
+ if (error?.code === "EXDEV")
158
+ break;
159
+ if (error?.code !== "EPERM" && error?.code !== "EACCES" && error?.code !== "EBUSY" && error?.code !== "EEXIST" && error?.code !== "EAGAIN")
160
+ throw error;
161
+ if (attempt < 4)
162
+ await delay(25 * (attempt + 1));
163
+ }
164
+ }
165
+ await fs.copyFile(temp, target);
166
+ } finally {
167
+ try {
168
+ await fs.rm(temp, { force: true });
169
+ } catch {}
170
+ }
171
+ }
172
+ async function writeState(directory, state) {
173
+ state.revision += 1;
174
+ const payload = JSON.stringify(state, null, 2);
175
+ await writeAtomic(stateFile(directory), payload);
176
+ }
177
+ async function mutateState(directory, description, fn) {
178
+ await acquireLock(directory, "state", description);
179
+ try {
180
+ const state = await readState(directory);
181
+ const next = await fn(state);
182
+ await writeState(directory, next);
183
+ return next;
184
+ } finally {
185
+ await releaseLock(directory, "state");
186
+ }
187
+ }
188
+ async function appendEvent(directory, event) {
189
+ await fs.mkdir(loopDir(directory), { recursive: true });
190
+ const line = JSON.stringify(event) + `
191
+ `;
192
+ await fs.appendFile(eventsFile(directory), line, "utf8");
193
+ }
194
+ async function readEvents(directory, limit = 50) {
195
+ try {
196
+ const raw = await fs.readFile(eventsFile(directory), "utf8");
197
+ const lines = raw.trim().split(`
198
+ `).filter(Boolean);
199
+ return lines.slice(-limit).map((l) => JSON.parse(l));
200
+ } catch {
201
+ return [];
202
+ }
203
+ }
204
+ function controlDir(directory) {
205
+ return path.join(loopDir(directory), "control");
206
+ }
207
+ function requestFile(directory, requestID) {
208
+ return path.join(controlDir(directory), "requests", `${requestID}.json`);
209
+ }
210
+ function processingFile(directory, requestID) {
211
+ return path.join(controlDir(directory), "processing", `${requestID}.json`);
212
+ }
213
+ function responseFile(directory, requestID) {
214
+ return path.join(controlDir(directory), "responses", `${requestID}.json`);
215
+ }
216
+ async function writeControlRequest(directory, request) {
217
+ const dir = path.join(controlDir(directory), "requests");
218
+ await fs.mkdir(dir, { recursive: true });
219
+ await writeAtomic(requestFile(directory, request.requestID), JSON.stringify(request, null, 2));
220
+ }
221
+ async function readControlRequest(directory, requestID) {
222
+ try {
223
+ const raw = await fs.readFile(requestFile(directory, requestID), "utf8");
224
+ return JSON.parse(raw);
225
+ } catch {
226
+ return;
227
+ }
228
+ }
229
+ async function claimControlRequest(directory, requestID) {
230
+ const src = requestFile(directory, requestID);
231
+ const dst = processingFile(directory, requestID);
232
+ try {
233
+ await fs.mkdir(path.dirname(dst), { recursive: true });
234
+ await fs.rename(src, dst);
235
+ return true;
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+ async function writeControlResponse(directory, response) {
241
+ const dir = path.join(controlDir(directory), "responses");
242
+ await fs.mkdir(dir, { recursive: true });
243
+ await writeAtomic(responseFile(directory, response.requestID), JSON.stringify(response, null, 2));
244
+ try {
245
+ await fs.rm(processingFile(directory, response.requestID), { force: true });
246
+ } catch {}
247
+ }
248
+ async function readControlResponse(directory, requestID) {
249
+ try {
250
+ const raw = await fs.readFile(responseFile(directory, requestID), "utf8");
251
+ return JSON.parse(raw);
252
+ } catch {
253
+ return;
254
+ }
255
+ }
256
+ async function listPendingRequests(directory) {
257
+ const dir = path.join(controlDir(directory), "requests");
258
+ try {
259
+ const files = await fs.readdir(dir);
260
+ const requests = [];
261
+ for (const file of files) {
262
+ if (!file.endsWith(".json"))
263
+ continue;
264
+ try {
265
+ const raw = await fs.readFile(path.join(dir, file), "utf8");
266
+ requests.push(JSON.parse(raw));
267
+ } catch {}
268
+ }
269
+ return requests.sort((a, b) => a.requestedAt.localeCompare(b.requestedAt));
270
+ } catch {
271
+ return [];
272
+ }
273
+ }
274
+ async function recoverStaleProcessing(directory) {
275
+ const dir = path.join(controlDir(directory), "processing");
276
+ try {
277
+ const files = await fs.readdir(dir);
278
+ const recovered = [];
279
+ for (const file of files) {
280
+ if (!file.endsWith(".json"))
281
+ continue;
282
+ const processingPath = path.join(dir, file);
283
+ const requestPath = path.join(controlDir(directory), "requests", file);
284
+ try {
285
+ const raw = await fs.readFile(processingPath, "utf8");
286
+ const request = JSON.parse(raw);
287
+ await fs.rename(processingPath, requestPath);
288
+ recovered.push(request);
289
+ } catch {}
290
+ }
291
+ return recovered;
292
+ } catch {
293
+ return [];
294
+ }
295
+ }
296
+ function goalArtifactDir(directory, goalID) {
297
+ return path.join(loopDir(directory), "goals", goalID);
298
+ }
299
+ async function ensureGoalArtifactDir(directory, goalID) {
300
+ const dir = goalArtifactDir(directory, goalID);
301
+ await fs.mkdir(dir, { recursive: true });
302
+ return dir;
303
+ }
304
+ function inboxFile(directory, goalID) {
305
+ return path.join(loopDir(directory), "inboxes", `${goalID}.jsonl`);
306
+ }
307
+ async function appendGoalInbox(directory, goalID, from, text) {
308
+ const dir = path.join(loopDir(directory), "inboxes");
309
+ await fs.mkdir(dir, { recursive: true });
310
+ const msg = { from, text, at: new Date().toISOString() };
311
+ await fs.appendFile(inboxFile(directory, goalID), JSON.stringify(msg) + `
312
+ `, "utf8");
313
+ }
314
+ async function drainGoalInbox(directory, goalID) {
315
+ const file = inboxFile(directory, goalID);
316
+ try {
317
+ const raw = await fs.readFile(file, "utf8");
318
+ const lines = raw.trim().split(`
319
+ `).filter(Boolean);
320
+ if (lines.length === 0)
321
+ return [];
322
+ const messages = lines.map((l) => JSON.parse(l));
323
+ await fs.rm(file, { force: true });
324
+ return messages.map((m) => `[${m.from}] ${m.text}`);
325
+ } catch {
326
+ return [];
327
+ }
328
+ }
329
+ function delay(ms) {
330
+ return new Promise((resolve) => setTimeout(resolve, ms));
331
+ }
332
+ var CURRENT_VERSION = 2, LOCK_STALE_MS = 1e4;
333
+ var init_state_repository = () => {};
334
+
335
+ // src/application/control-worker.ts
336
+ init_state_repository();
337
+ import { randomUUID as randomUUID2 } from "crypto";
338
+
339
+ // src/infrastructure/server-log.ts
340
+ import { appendFile } from "fs/promises";
341
+ var SERVER_LOG_FILE = "/tmp/loopd-server.log";
342
+ async function logServerEvent(directory, event, details = {}) {
343
+ try {
344
+ await appendFile(SERVER_LOG_FILE, `${JSON.stringify({
345
+ timestamp: new Date().toISOString(),
346
+ directory,
347
+ event,
348
+ ...details
349
+ }, errorReplacer)}
350
+ `);
351
+ } catch {}
352
+ }
353
+ function describeError(value) {
354
+ if (value instanceof Error)
355
+ return value.message;
356
+ if (typeof value === "string")
357
+ return value;
358
+ try {
359
+ return JSON.stringify(value, errorReplacer);
360
+ } catch {
361
+ return String(value);
362
+ }
363
+ }
364
+ function errorReplacer(_key, value) {
365
+ if (value instanceof Error) {
366
+ return { name: value.name, message: value.message, stack: value.stack };
367
+ }
368
+ return value;
369
+ }
370
+
371
+ // src/application/control-worker.ts
372
+ var MAX_LEDGER_SIZE = 100;
373
+ var RESPONSE_CLEANUP_AGE_MS = 60 * 60 * 1000;
374
+ function createControlWorker(options) {
375
+ const directory = options.directory;
376
+ const pollMs = options.pollIntervalMs ?? 1000;
377
+ const goalSvc = options.goalService;
378
+ let running = false;
379
+ let pollTimer;
380
+ let processing = new Set;
381
+ let lastProcessDone = true;
382
+ function start() {
383
+ if (running)
384
+ return;
385
+ running = true;
386
+ processPending();
387
+ pollTimer = setInterval(() => {
388
+ if (running && lastProcessDone) {
389
+ lastProcessDone = false;
390
+ processPending().then(() => {
391
+ lastProcessDone = true;
392
+ });
393
+ }
394
+ }, pollMs);
395
+ }
396
+ async function stop() {
397
+ running = false;
398
+ if (pollTimer)
399
+ clearInterval(pollTimer);
400
+ pollTimer = undefined;
401
+ processing.clear();
402
+ }
403
+ async function processPending() {
404
+ if (!running)
405
+ return;
406
+ const requests = await listPendingRequests(directory);
407
+ for (const request of requests) {
408
+ if (processing.has(request.requestID))
409
+ continue;
410
+ const claimed = await claimControlRequest(directory, request.requestID);
411
+ if (!claimed)
412
+ continue;
413
+ processing.add(request.requestID);
414
+ options.onRequest?.(request);
415
+ try {
416
+ const response = await handleRequest(request);
417
+ await writeControlResponse(directory, response);
418
+ options.onResponse?.(response);
419
+ } catch (error) {
420
+ const detail = describeError(error);
421
+ await logServerEvent(directory, "control.request.failed", {
422
+ requestID: request.requestID,
423
+ command: request.command,
424
+ goalID: request.goalID,
425
+ detail
426
+ });
427
+ const response = {
428
+ requestID: request.requestID,
429
+ ok: false,
430
+ message: `internal error: ${detail}. Diagnostics: ${SERVER_LOG_FILE}`,
431
+ errorCode: "internal_error",
432
+ completedAt: new Date().toISOString()
433
+ };
434
+ await writeControlResponse(directory, response);
435
+ options.onResponse?.(response);
436
+ } finally {
437
+ processing.delete(request.requestID);
438
+ }
439
+ }
440
+ }
441
+ async function handleRequest(request) {
442
+ const existingResponse = await readControlResponse(directory, request.requestID);
443
+ if (existingResponse) {
444
+ return existingResponse;
445
+ }
446
+ const state = await readState(directory);
447
+ const ledgerEntry = state.commandLedger?.find((e) => e.requestID === request.requestID);
448
+ if (ledgerEntry?.completedAt) {
449
+ return {
450
+ requestID: request.requestID,
451
+ ok: true,
452
+ message: `command "${request.command}" already processed`,
453
+ stateRevision: state.revision,
454
+ completedAt: ledgerEntry.completedAt
455
+ };
456
+ }
457
+ const base = {
458
+ requestID: request.requestID,
459
+ ok: true,
460
+ message: "",
461
+ stateRevision: undefined,
462
+ errorCode: undefined,
463
+ completedAt: new Date().toISOString()
464
+ };
465
+ let response;
466
+ switch (request.command) {
467
+ case "start": {
468
+ const args = request.args;
469
+ if (!args.ownerSessionID || args.ownerSessionID === "main") {
470
+ response = {
471
+ ...base,
472
+ ok: false,
473
+ message: "cannot start goal without a valid owner session; open /loop from an active OpenCode session",
474
+ errorCode: "invalid_owner_session"
475
+ };
476
+ break;
477
+ }
478
+ const { goal } = await goalSvc.start(directory, {
479
+ name: args.name,
480
+ objective: args.objective,
481
+ ownerSessionID: args.ownerSessionID,
482
+ config: args.config
483
+ });
484
+ const state2 = await readState(directory);
485
+ response = {
486
+ ...base,
487
+ message: `goal "${args.name}" created (${goal.id.slice(0, 8)}...)`,
488
+ stateRevision: state2.revision
489
+ };
490
+ break;
491
+ }
492
+ case "pause": {
493
+ await goalSvc.pause(directory, request.goalID);
494
+ const state2 = await readState(directory);
495
+ const goal = state2.goals.find((g) => g.id === request.goalID);
496
+ response = {
497
+ ...base,
498
+ message: `goal "${goal?.name || request.goalID}" paused`,
499
+ stateRevision: state2.revision
500
+ };
501
+ break;
502
+ }
503
+ case "resume": {
504
+ await goalSvc.resume(directory, request.goalID);
505
+ const state2 = await readState(directory);
506
+ const goal = state2.goals.find((g) => g.id === request.goalID);
507
+ response = {
508
+ ...base,
509
+ message: `goal "${goal?.name || request.goalID}" resumed`,
510
+ stateRevision: state2.revision
511
+ };
512
+ break;
513
+ }
514
+ case "retry": {
515
+ await goalSvc.retry(directory, request.goalID);
516
+ const state2 = await readState(directory);
517
+ const goal = state2.goals.find((g) => g.id === request.goalID);
518
+ response = {
519
+ ...base,
520
+ message: `goal "${goal?.name || request.goalID}" retried`,
521
+ stateRevision: state2.revision
522
+ };
523
+ break;
524
+ }
525
+ case "clear": {
526
+ await goalSvc.clear(directory, request.goalID);
527
+ const state2 = await readState(directory);
528
+ response = {
529
+ ...base,
530
+ message: `goal cleared`,
531
+ stateRevision: state2.revision
532
+ };
533
+ break;
534
+ }
535
+ case "send": {
536
+ const args = request.args;
537
+ const text = String(args.message || "").trim();
538
+ if (!text) {
539
+ response = { ...base, ok: false, message: "message is required", errorCode: "bad_request" };
540
+ break;
541
+ }
542
+ if (!request.goalID) {
543
+ response = { ...base, ok: false, message: "goalID is required", errorCode: "bad_request" };
544
+ break;
545
+ }
546
+ await appendGoalInbox(directory, request.goalID, "user", text);
547
+ const state2 = await readState(directory);
548
+ const goal = state2.goals.find((g) => g.id === request.goalID);
549
+ response = {
550
+ ...base,
551
+ message: `sent to "${goal?.name || request.goalID}"`,
552
+ stateRevision: state2.revision
553
+ };
554
+ break;
555
+ }
556
+ case "force_complete": {
557
+ const args = request.args;
558
+ const state2 = await readState(directory);
559
+ const goal = state2.goals.find((g) => g.id === request.goalID);
560
+ if (!goal) {
561
+ response = { ...base, ok: false, message: "goal not found", errorCode: "not_found" };
562
+ break;
563
+ }
564
+ if (goal.status === "complete") {
565
+ response = { ...base, message: `goal "${goal.name}" already complete`, stateRevision: state2.revision };
566
+ break;
567
+ }
568
+ goal.status = "complete";
569
+ goal.updatedAt = new Date().toISOString();
570
+ goal.completionEvidence = {
571
+ summary: String(args.summary || "Force-completed from dashboard."),
572
+ evidence: String(args.evidence || "Manual override \u2014 no verification checks run."),
573
+ at: new Date().toISOString()
574
+ };
575
+ const runtime = state2.runtimes.find((r) => r.goalID === goal.id);
576
+ if (runtime) {
577
+ runtime.phase = "idle";
578
+ runtime.lastError = undefined;
579
+ runtime.updatedAt = new Date().toISOString();
580
+ }
581
+ await writeState(directory, state2);
582
+ await appendEvent(directory, {
583
+ version: 1,
584
+ eventID: randomUUID2(),
585
+ goalID: goal.id,
586
+ type: "goal.completed",
587
+ summary: goal.completionEvidence.summary,
588
+ evidence: goal.completionEvidence.evidence,
589
+ timestamp: new Date().toISOString(),
590
+ revision: state2.revision
591
+ });
592
+ response = { ...base, message: `goal "${goal.name}" force-completed`, stateRevision: state2.revision };
593
+ break;
594
+ }
595
+ case "force_block":
596
+ case "block": {
597
+ const args = request.args;
598
+ const state2 = await readState(directory);
599
+ const goal = state2.goals.find((g) => g.id === request.goalID);
600
+ if (!goal) {
601
+ response = { ...base, ok: false, message: "goal not found", errorCode: "not_found" };
602
+ break;
603
+ }
604
+ if (goal.status === "blocked") {
605
+ response = { ...base, message: `goal "${goal.name}" already blocked`, stateRevision: state2.revision };
606
+ break;
607
+ }
608
+ goal.status = "blocked";
609
+ goal.updatedAt = new Date().toISOString();
610
+ goal.blocker = {
611
+ reason: String(args.reason || "Blocked from dashboard."),
612
+ needed: String(args.needed || "User intervention required."),
613
+ at: new Date().toISOString()
614
+ };
615
+ const runtime = state2.runtimes.find((r) => r.goalID === goal.id);
616
+ if (runtime) {
617
+ runtime.phase = "idle";
618
+ runtime.lastError = undefined;
619
+ runtime.updatedAt = new Date().toISOString();
620
+ }
621
+ await writeState(directory, state2);
622
+ await appendEvent(directory, {
623
+ version: 1,
624
+ eventID: randomUUID2(),
625
+ goalID: goal.id,
626
+ type: "goal.blocked",
627
+ reason: goal.blocker.reason,
628
+ needed: goal.blocker.needed,
629
+ timestamp: new Date().toISOString(),
630
+ revision: state2.revision
631
+ });
632
+ response = { ...base, message: `goal "${goal.name}" blocked`, stateRevision: state2.revision };
633
+ break;
634
+ }
635
+ default: {
636
+ response = {
637
+ requestID: request.requestID,
638
+ ok: false,
639
+ message: `command "${request.command}" not implemented in worker`,
640
+ errorCode: "unknown_command",
641
+ completedAt: new Date().toISOString()
642
+ };
643
+ }
644
+ }
645
+ await recordInLedger(directory, request);
646
+ return response;
647
+ }
648
+ async function recordInLedger(directory2, request) {
649
+ const state = await readState(directory2);
650
+ if (!state.commandLedger)
651
+ state.commandLedger = [];
652
+ state.commandLedger.push({
653
+ requestID: request.requestID,
654
+ command: request.command,
655
+ goalID: request.goalID,
656
+ acceptedAt: request.requestedAt,
657
+ completedAt: new Date().toISOString()
658
+ });
659
+ if (state.commandLedger.length > MAX_LEDGER_SIZE) {
660
+ state.commandLedger = state.commandLedger.slice(-MAX_LEDGER_SIZE);
661
+ }
662
+ await writeState(directory2, state);
663
+ }
664
+ return { start, stop: async () => {
665
+ await stop();
666
+ }, isRunning: () => running };
667
+ }
668
+
669
+ // src/application/loop-engine.ts
670
+ init_state_repository();
671
+ import { randomUUID as randomUUID3 } from "crypto";
672
+
673
+ // src/domain/goal.ts
674
+ var MODEL_TRANSITIONS = {
675
+ active: ["complete", "blocked"],
676
+ paused: [],
677
+ blocked: [],
678
+ budget_limited: ["complete", "blocked"],
679
+ usage_limited: [],
680
+ complete: []
681
+ };
682
+ var USER_TRANSITIONS = {
683
+ active: ["paused"],
684
+ paused: ["active"],
685
+ blocked: ["active"],
686
+ budget_limited: ["active"],
687
+ usage_limited: ["active"],
688
+ complete: ["active"]
689
+ };
690
+ var SYSTEM_TRANSITIONS = {
691
+ active: ["budget_limited", "usage_limited"],
692
+ paused: [],
693
+ blocked: [],
694
+ budget_limited: [],
695
+ usage_limited: [],
696
+ complete: []
697
+ };
698
+ function canTransition(current, target, caller) {
699
+ const table = caller === "model" ? MODEL_TRANSITIONS : caller === "user" ? USER_TRANSITIONS : SYSTEM_TRANSITIONS;
700
+ return table[current]?.includes(target) ?? false;
701
+ }
702
+ function isTerminal(status) {
703
+ return status === "complete";
704
+ }
705
+ function createGoal(input) {
706
+ const now = new Date().toISOString();
707
+ return { ...input, tokensUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
708
+ }
709
+
710
+ // src/domain/runtime.ts
711
+ function createRuntimeState(goalID) {
712
+ const now = new Date().toISOString();
713
+ return {
714
+ goalID,
715
+ phase: "idle",
716
+ consecutiveFailures: 0,
717
+ runCount: 0,
718
+ turnCount: 0,
719
+ noProgressCount: 0,
720
+ progressDuringTurn: false,
721
+ createdAt: now,
722
+ updatedAt: now
723
+ };
724
+ }
725
+ function acquireLease(rt, timeoutMs) {
726
+ const now = Date.now();
727
+ const expires = new Date(now + timeoutMs).toISOString();
728
+ return {
729
+ ...rt,
730
+ phase: "running",
731
+ leaseExpiresAt: expires,
732
+ turnStartedAt: new Date(now).toISOString(),
733
+ progressDuringTurn: false,
734
+ turnTokensUsed: 0,
735
+ updatedAt: new Date(now).toISOString()
736
+ };
737
+ }
738
+ function releaseLease(rt) {
739
+ return {
740
+ ...rt,
741
+ phase: "idle",
742
+ leaseExpiresAt: undefined,
743
+ turnStartedAt: undefined,
744
+ updatedAt: new Date().toISOString()
745
+ };
746
+ }
747
+ function leaseIsValid(rt) {
748
+ if (!rt.leaseExpiresAt)
749
+ return false;
750
+ return Date.now() < Date.parse(rt.leaseExpiresAt);
751
+ }
752
+ function markProgress(rt) {
753
+ return { ...rt, progressDuringTurn: true, lastProgressAt: new Date().toISOString() };
754
+ }
755
+
756
+ // src/application/loop-engine.ts
757
+ var HANDLED_EVENT_TYPES = new Set([
758
+ "session.idle",
759
+ "session.status",
760
+ "session.error",
761
+ "session.compacted"
762
+ ]);
763
+ function createLoopEngine(options) {
764
+ const { directory, host, goalService } = options;
765
+ const maintenanceMs = options.pollIntervalMs ?? 30000;
766
+ let running = false;
767
+ let maintenanceTimer;
768
+ let knownWorkerSessions = new Set;
769
+ let knownWorkerSessionsLoaded = false;
770
+ const inflightContinuations = new Set;
771
+ async function loadWorkerSessionsIfneeded() {
772
+ if (knownWorkerSessionsLoaded)
773
+ return;
774
+ try {
775
+ const state = await readState(directory);
776
+ for (const g of state.goals) {
777
+ if (g.workerSessionID)
778
+ knownWorkerSessions.add(g.workerSessionID);
779
+ }
780
+ knownWorkerSessionsLoaded = true;
781
+ } catch {}
782
+ }
783
+ function syncWorkerSessionsFromService() {
784
+ for (const worker of goalService.getActiveWorkers().values()) {
785
+ knownWorkerSessions.add(worker.workerSessionID);
786
+ }
787
+ }
788
+ async function preloadWorkerSessions() {
789
+ await loadWorkerSessionsIfneeded();
790
+ syncWorkerSessionsFromService();
791
+ }
792
+ function start() {
793
+ if (running)
794
+ return;
795
+ running = true;
796
+ loadWorkerSessionsIfneeded().catch(() => {});
797
+ maintenanceTimer = setInterval(() => {
798
+ if (running)
799
+ maintenance().catch(() => {});
800
+ }, maintenanceMs);
801
+ }
802
+ function stop() {
803
+ running = false;
804
+ if (maintenanceTimer)
805
+ clearInterval(maintenanceTimer);
806
+ maintenanceTimer = undefined;
807
+ inflightContinuations.clear();
808
+ }
809
+ function isRunning() {
810
+ return running;
811
+ }
812
+ async function continueGoal(goalID) {
813
+ if (inflightContinuations.has(goalID))
814
+ return false;
815
+ inflightContinuations.add(goalID);
816
+ try {
817
+ await goalService.continueTurn(directory, goalID);
818
+ return true;
819
+ } finally {
820
+ inflightContinuations.delete(goalID);
821
+ }
822
+ }
823
+ async function handleEvent(event) {
824
+ if (!running || !event || typeof event !== "object")
825
+ return false;
826
+ const type = event.type;
827
+ if (!type || !HANDLED_EVENT_TYPES.has(type))
828
+ return false;
829
+ const sessionID = event.properties?.sessionID;
830
+ if (!sessionID)
831
+ return false;
832
+ await loadWorkerSessionsIfneeded();
833
+ syncWorkerSessionsFromService();
834
+ if (knownWorkerSessionsLoaded && knownWorkerSessions.size === 0)
835
+ return false;
836
+ if (knownWorkerSessions.size > 0 && !knownWorkerSessions.has(sessionID))
837
+ return false;
838
+ const state = await readState(directory);
839
+ const goal = state.goals.find((g) => g.workerSessionID === sessionID);
840
+ if (!goal)
841
+ return false;
842
+ if (goal.workerSessionID)
843
+ knownWorkerSessions.add(goal.workerSessionID);
844
+ if (isTerminal(goal.status) || goal.status === "paused")
845
+ return false;
846
+ switch (type) {
847
+ case "session.idle":
848
+ return await handleSessionIdle(state, goal);
849
+ case "session.status":
850
+ return await handleSessionStatus(state, goal, event);
851
+ case "session.error":
852
+ return await handleSessionError(state, goal, event);
853
+ case "session.compacted":
854
+ return await handleSessionCompacted(state, goal);
855
+ default:
856
+ return false;
857
+ }
858
+ }
859
+ async function handleSessionIdle(state, goal) {
860
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
861
+ if (!runtime)
862
+ return false;
863
+ if (inflightContinuations.has(goal.id))
864
+ return false;
865
+ if (runtime.phase === "running") {
866
+ const completedRunID = runtime.activeRunID;
867
+ Object.assign(runtime, releaseLease(runtime));
868
+ runtime.activeRunID = undefined;
869
+ runtime.lastWorkerStatus = "idle";
870
+ await writeState(directory, state);
871
+ if (completedRunID) {
872
+ await appendEvent(directory, {
873
+ version: 1,
874
+ eventID: randomUUID3(),
875
+ goalID: goal.id,
876
+ type: "run.completed",
877
+ runID: completedRunID,
878
+ timestamp: new Date().toISOString(),
879
+ revision: state.revision
880
+ });
881
+ }
882
+ }
883
+ if (goal.status !== "active")
884
+ return false;
885
+ const limitResult = enforceLimits(goal, runtime);
886
+ if (limitResult.stop === "force_finish") {
887
+ if (!runtime.forceFinishRequested) {
888
+ runtime.forceFinishRequested = true;
889
+ await writeState(directory, state);
890
+ await goalService.continueTurn(directory, goal.id, { forceFinish: true });
891
+ return true;
892
+ }
893
+ goal.status = "blocked";
894
+ goal.updatedAt = new Date().toISOString();
895
+ goal.blocker = {
896
+ reason: limitResult.reason + " (force-finish ignored)",
897
+ needed: "User intervention required. Use retry to attempt again.",
898
+ at: new Date().toISOString()
899
+ };
900
+ runtime.forceFinishRequested = undefined;
901
+ await writeState(directory, state);
902
+ await appendEvent(directory, {
903
+ version: 1,
904
+ eventID: randomUUID3(),
905
+ goalID: goal.id,
906
+ type: "goal.blocked",
907
+ reason: limitResult.reason + " (force-finish ignored)",
908
+ needed: goal.blocker.needed,
909
+ timestamp: new Date().toISOString(),
910
+ revision: state.revision
911
+ });
912
+ await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" stopped: ${limitResult.reason} (child did not wrap up). Status: blocked. Last progress: ${goal.lastProgress?.summary || "none"}.`);
913
+ return true;
914
+ }
915
+ if (limitResult.stop === "budget") {
916
+ await writeState(directory, state);
917
+ await appendEvent(directory, {
918
+ version: 1,
919
+ eventID: randomUUID3(),
920
+ goalID: goal.id,
921
+ type: "goal.status_changed",
922
+ from: "active",
923
+ to: goal.status,
924
+ timestamp: new Date().toISOString(),
925
+ revision: state.revision
926
+ });
927
+ return true;
928
+ }
929
+ if (shouldCompact(goal, runtime)) {
930
+ await doCompact(goal, runtime);
931
+ return true;
932
+ }
933
+ await continueGoal(goal.id);
934
+ return true;
935
+ }
936
+ async function handleSessionStatus(state, goal, event) {
937
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
938
+ if (!runtime)
939
+ return false;
940
+ const status = event.properties?.status;
941
+ const statusType = status?.type;
942
+ if (!statusType)
943
+ return false;
944
+ if (statusType === "idle")
945
+ return handleSessionIdle(state, goal);
946
+ runtime.lastWorkerStatus = statusType;
947
+ runtime.updatedAt = new Date().toISOString();
948
+ await writeState(directory, state);
949
+ return true;
950
+ }
951
+ async function handleSessionError(state, goal, event) {
952
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
953
+ if (!runtime)
954
+ return false;
955
+ const error = event.properties?.error;
956
+ const message = error?.message || error?.toString() || "unknown error";
957
+ runtime.consecutiveFailures += 1;
958
+ runtime.lastError = message;
959
+ runtime.updatedAt = new Date().toISOString();
960
+ if (runtime.phase === "running") {
961
+ Object.assign(runtime, releaseLease(runtime));
962
+ }
963
+ await appendEvent(directory, {
964
+ version: 1,
965
+ eventID: randomUUID3(),
966
+ goalID: goal.id,
967
+ type: "run.failed",
968
+ runID: runtime.activeRunID || "unknown",
969
+ error: message,
970
+ consecutiveFailures: runtime.consecutiveFailures,
971
+ timestamp: new Date().toISOString(),
972
+ revision: state.revision
973
+ });
974
+ const maxFailures = goal.config?.maxFailures || 5;
975
+ if (runtime.consecutiveFailures >= maxFailures) {
976
+ goal.status = "blocked";
977
+ goal.updatedAt = new Date().toISOString();
978
+ goal.blocker = {
979
+ reason: `Failed ${runtime.consecutiveFailures} times. Last error: ${message}`,
980
+ needed: "User intervention required. Use retry to attempt again.",
981
+ at: new Date().toISOString()
982
+ };
983
+ await appendEvent(directory, {
984
+ version: 1,
985
+ eventID: randomUUID3(),
986
+ goalID: goal.id,
987
+ type: "goal.blocked",
988
+ reason: `Failed ${runtime.consecutiveFailures} times`,
989
+ needed: "User intervention required",
990
+ timestamp: new Date().toISOString(),
991
+ revision: state.revision
992
+ });
993
+ await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" blocked after ${runtime.consecutiveFailures} failures. Last error: ${message}.`);
994
+ } else {
995
+ const backoffMs = Math.min(30000, 1000 * Math.pow(2, runtime.consecutiveFailures));
996
+ runtime.retryAfter = new Date(Date.now() + backoffMs).toISOString();
997
+ runtime.phase = "waiting_retry";
998
+ }
999
+ await writeState(directory, state);
1000
+ return true;
1001
+ }
1002
+ async function handleSessionCompacted(state, goal) {
1003
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
1004
+ if (!runtime)
1005
+ return false;
1006
+ runtime.lastCompactAt = new Date().toISOString();
1007
+ runtime.updatedAt = new Date().toISOString();
1008
+ await writeState(directory, state);
1009
+ await appendEvent(directory, {
1010
+ version: 1,
1011
+ eventID: randomUUID3(),
1012
+ goalID: goal.id,
1013
+ type: "compaction.completed",
1014
+ timestamp: new Date().toISOString(),
1015
+ revision: state.revision
1016
+ });
1017
+ return true;
1018
+ }
1019
+ function enforceLimits(goal, runtime) {
1020
+ const noResult = { stop: "none", blocked: false, event: "goal.status_changed", reason: "" };
1021
+ const maxTurns = goal.config?.maxTurns;
1022
+ if (maxTurns && runtime.turnCount >= maxTurns) {
1023
+ return {
1024
+ stop: "force_finish",
1025
+ blocked: true,
1026
+ event: "goal.blocked",
1027
+ reason: `Reached max turns (${maxTurns})`
1028
+ };
1029
+ }
1030
+ const maxNoProgress = goal.config?.maxNoProgress;
1031
+ if (maxNoProgress && runtime.noProgressCount >= maxNoProgress) {
1032
+ return {
1033
+ stop: "force_finish",
1034
+ blocked: true,
1035
+ event: "goal.blocked",
1036
+ reason: `No progress for ${runtime.noProgressCount} consecutive turns`
1037
+ };
1038
+ }
1039
+ if (goal.tokenBudget && goal.tokensUsed >= goal.tokenBudget) {
1040
+ goal.status = "budget_limited";
1041
+ goal.updatedAt = new Date().toISOString();
1042
+ return {
1043
+ stop: "budget",
1044
+ blocked: true,
1045
+ event: "goal.status_changed",
1046
+ reason: `Token budget exhausted (${goal.tokensUsed}/${goal.tokenBudget})`
1047
+ };
1048
+ }
1049
+ return noResult;
1050
+ }
1051
+ function shouldCompact(goal, runtime) {
1052
+ const compactEvery = goal.config?.compactEvery;
1053
+ if (!compactEvery)
1054
+ return false;
1055
+ return runtime.turnCount > 0 && runtime.turnCount % compactEvery === 0;
1056
+ }
1057
+ async function doCompact(goal, runtime) {
1058
+ if (!goal.workerSessionID)
1059
+ return;
1060
+ const prevPhase = runtime.phase;
1061
+ runtime.phase = "compacting";
1062
+ runtime.lastCompactAt = new Date().toISOString();
1063
+ const state = await readState(directory);
1064
+ await writeState(directory, state);
1065
+ await appendEvent(directory, {
1066
+ version: 1,
1067
+ eventID: randomUUID3(),
1068
+ goalID: goal.id,
1069
+ type: "compaction.started",
1070
+ timestamp: new Date().toISOString(),
1071
+ revision: state.revision
1072
+ });
1073
+ try {
1074
+ await host.compactSession(goal.workerSessionID);
1075
+ } catch {}
1076
+ const updatedState = await readState(directory);
1077
+ const updatedRuntime = updatedState.runtimes.find((r) => r.goalID === goal.id);
1078
+ if (updatedRuntime) {
1079
+ updatedRuntime.phase = prevPhase;
1080
+ await writeState(directory, updatedState);
1081
+ }
1082
+ }
1083
+ async function maintenance() {
1084
+ syncWorkerSessionsFromService();
1085
+ if (knownWorkerSessions.size === 0)
1086
+ return;
1087
+ const state = await readState(directory);
1088
+ const hasActiveGoals = state.goals.some((g) => !isTerminal(g.status) && g.status !== "paused");
1089
+ if (!hasActiveGoals)
1090
+ return;
1091
+ for (const goal of state.goals) {
1092
+ if (isTerminal(goal.status) || goal.status === "paused")
1093
+ continue;
1094
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
1095
+ if (!runtime)
1096
+ continue;
1097
+ if (runtime.phase === "waiting_retry" && runtime.retryAfter) {
1098
+ if (Date.now() >= Date.parse(runtime.retryAfter)) {
1099
+ runtime.retryAfter = undefined;
1100
+ runtime.phase = "idle";
1101
+ await writeState(directory, state);
1102
+ goalService.continueTurn(directory, goal.id).catch(() => {});
1103
+ }
1104
+ }
1105
+ if ((runtime.phase === "running" || runtime.phase === "idle") && goal.workerSessionID) {
1106
+ const status = await host.sessionStatus(goal.workerSessionID);
1107
+ if (status === "idle") {
1108
+ await handleSessionIdle(state, goal);
1109
+ continue;
1110
+ }
1111
+ }
1112
+ }
1113
+ }
1114
+ return { start, stop, isRunning, handleEvent, preloadWorkerSessions };
1115
+ }
1116
+
1117
+ // src/application/goal-service.ts
1118
+ import { randomUUID as randomUUID4 } from "crypto";
1119
+ init_state_repository();
1120
+ import * as path2 from "path";
1121
+
1122
+ // src/server/worker-session.ts
1123
+ function createWorkerManager(host) {
1124
+ return {
1125
+ async createWorker(goal) {
1126
+ const workerSessionID = await host.createWorker({
1127
+ parentID: goal.ownerSessionID,
1128
+ title: `loopd: ${goal.name}`
1129
+ });
1130
+ return {
1131
+ goalID: goal.id,
1132
+ workerSessionID,
1133
+ startedAt: new Date().toISOString()
1134
+ };
1135
+ },
1136
+ async continueWorker(worker, goal, runtime, context) {
1137
+ const prompt = buildContinuationSteering(goal, runtime, context);
1138
+ await host.promptWorker({
1139
+ sessionID: worker.workerSessionID,
1140
+ prompt
1141
+ });
1142
+ },
1143
+ async isIdle(workerSessionID) {
1144
+ const status = await host.sessionStatus(workerSessionID);
1145
+ return status === "idle";
1146
+ },
1147
+ async abortWorker(workerSessionID) {
1148
+ await host.abortSession(workerSessionID);
1149
+ },
1150
+ async compactWorker(workerSessionID) {
1151
+ await host.compactSession(workerSessionID);
1152
+ }
1153
+ };
1154
+ }
1155
+ function buildContinuationSteering(goal, runtime, context) {
1156
+ const parts = [];
1157
+ const artifactDir = goal.config.artifactDir;
1158
+ function outputLocationBlock() {
1159
+ if (!artifactDir)
1160
+ return [];
1161
+ return [
1162
+ ``,
1163
+ `## OUTPUT LOCATION`,
1164
+ `Write all files, logs, and artifacts under:`,
1165
+ artifactDir,
1166
+ ``,
1167
+ `Exception: if the objective explicitly specifies a different output directory, follow the objective instead.`
1168
+ ];
1169
+ }
1170
+ if (runtime.turnCount <= 1) {
1171
+ parts.push(`You are a worker for an active goal.`, ``, `Call get_goal to read the authoritative objective, acceptance criteria, and current state.`, `Perform one concrete batch of work. After durable verification:`, ``, `- Call report_goal_progress if work remains.`, `- Call complete_goal only if ALL acceptance criteria pass with concrete evidence.`, `- Call block_goal only for a real external blocker requiring user intervention.`, `- Use the built-in question tool when you need clarification only the user can provide.`, ``, `Do not ask questions unnecessarily. Make reasonable assumptions and work directly.`);
1172
+ parts.push(...outputLocationBlock());
1173
+ } else {
1174
+ parts.push(`This is continuation turn ${runtime.turnCount} for the goal below.`, ``, `## GOAL (user-provided data)`, goal.objective);
1175
+ const progress = context?.progressHistory;
1176
+ if (progress && progress.length > 0) {
1177
+ parts.push(``, `## PROGRESS SO FAR`);
1178
+ for (const p of progress) {
1179
+ parts.push(`- [${p.at.slice(11, 16)}] ${p.summary}`);
1180
+ if (p.next)
1181
+ parts.push(` \u2192 next: ${p.next}`);
1182
+ }
1183
+ }
1184
+ const tail = context?.transcriptTail;
1185
+ if (tail && tail.length > 0) {
1186
+ parts.push(``, `## RECENT WORK (last ${tail.length} messages)`);
1187
+ for (const m of tail) {
1188
+ const snippet = m.content.slice(0, 300).replace(/\n/g, " ");
1189
+ parts.push(`- [${m.role}] ${snippet}`);
1190
+ }
1191
+ }
1192
+ if (runtime.consecutiveFailures > 0) {
1193
+ parts.push(``, `## WARNINGS`);
1194
+ parts.push(`- ${runtime.consecutiveFailures} consecutive failure(s). Last error: ${runtime.lastError || "unknown"}.`);
1195
+ if (runtime.noProgressCount > 0) {
1196
+ parts.push(`- ${runtime.noProgressCount} turn(s) without progress. Work concretely this turn.`);
1197
+ }
1198
+ }
1199
+ parts.push(...outputLocationBlock());
1200
+ if (context?.forceFinish) {
1201
+ parts.push(``, `## FINAL REPORT REQUIRED \u2014 STOPPING SOON`, `The system requires you to wrap up now. Do NOT start new work.`, `Call complete_goal NOW with:`, `- summary: a specific semantic summary of what was accomplished (files changed, results, key findings)`, `- evidence: concrete proof (commands run, files created, checks passed)`, `If you cannot complete, call block_goal with the reason.`);
1202
+ } else {
1203
+ parts.push(``, `## INSTRUCTIONS`, `1. Inspect current workspace state \u2014 read files, check what exists. Do NOT redo completed work.`, `2. Continue concrete progress toward the objective.`, `3. After completing a batch, call report_goal_progress with what you did and what's next.`, `4. Verify completion requirement-by-requirement before calling complete_goal.`, `5. Call block_goal only if the same blocker persists across 3+ consecutive turns.`, `6. Use the built-in question tool only for genuinely risky ambiguity.`);
1204
+ }
1205
+ }
1206
+ if (context?.inboxMessages && context.inboxMessages.length > 0) {
1207
+ parts.push(``, `## USER INSTRUCTIONS`);
1208
+ for (const msg of context.inboxMessages) {
1209
+ parts.push(`- ${msg}`);
1210
+ }
1211
+ }
1212
+ return parts.join(`
1213
+ `);
1214
+ }
1215
+
1216
+ // src/application/goal-service.ts
1217
+ function createGoalService(host) {
1218
+ const workers = createWorkerManager(host);
1219
+ const sessions = new Map;
1220
+ async function start(directory, input) {
1221
+ const state = await readState(directory);
1222
+ const id = randomUUID4();
1223
+ const goal = createGoal({
1224
+ id,
1225
+ name: input.name,
1226
+ objective: input.objective,
1227
+ status: "active",
1228
+ ownerSessionID: input.ownerSessionID,
1229
+ config: {
1230
+ maxTurns: 50,
1231
+ ...input.config
1232
+ }
1233
+ });
1234
+ const artifactDir = goalArtifactDir(directory, id);
1235
+ goal.config.artifactDir = artifactDir;
1236
+ if (!goal.config.progressFile)
1237
+ goal.config.progressFile = path2.join(artifactDir, "progress.md");
1238
+ await ensureGoalArtifactDir(directory, id);
1239
+ state.goals.push(goal);
1240
+ state.runtimes.push(createRuntimeState(id));
1241
+ const runtime = state.runtimes.find((r) => r.goalID === id);
1242
+ if (runtime) {
1243
+ runtime.phase = "queued";
1244
+ await writeState(directory, state);
1245
+ }
1246
+ let worker;
1247
+ try {
1248
+ worker = await workers.createWorker(goal);
1249
+ } catch (error) {
1250
+ const detail = describeError(error);
1251
+ goal.status = "blocked";
1252
+ goal.updatedAt = new Date().toISOString();
1253
+ goal.blocker = {
1254
+ reason: detail,
1255
+ needed: "Start the goal again from a valid OpenCode session after correcting the worker creation error.",
1256
+ at: new Date().toISOString()
1257
+ };
1258
+ if (runtime) {
1259
+ runtime.phase = "idle";
1260
+ runtime.lastError = detail;
1261
+ runtime.updatedAt = new Date().toISOString();
1262
+ }
1263
+ await writeState(directory, state);
1264
+ await appendEvent(directory, {
1265
+ version: 1,
1266
+ eventID: randomUUID4(),
1267
+ goalID: id,
1268
+ type: "goal.blocked",
1269
+ reason: detail,
1270
+ needed: goal.blocker.needed,
1271
+ timestamp: new Date().toISOString(),
1272
+ revision: state.revision
1273
+ });
1274
+ await logServerEvent(directory, "goal.start.failed", { goalID: id, ownerSessionID: input.ownerSessionID, detail });
1275
+ throw error;
1276
+ }
1277
+ sessions.set(id, worker);
1278
+ goal.workerSessionID = worker.workerSessionID;
1279
+ await writeState(directory, state);
1280
+ await appendEvent(directory, {
1281
+ version: 1,
1282
+ eventID: randomUUID4(),
1283
+ goalID: id,
1284
+ type: "goal.created",
1285
+ name: input.name,
1286
+ objective: input.objective,
1287
+ ownerSessionID: input.ownerSessionID,
1288
+ timestamp: new Date().toISOString(),
1289
+ revision: state.revision
1290
+ });
1291
+ if (runtime) {
1292
+ const runID = randomUUID4();
1293
+ Object.assign(runtime, acquireLease(runtime, goal.config.timeoutMs || 300000));
1294
+ runtime.activeRunID = runID;
1295
+ runtime.turnCount = 1;
1296
+ runtime.runCount = 1;
1297
+ runtime.lastRunAt = new Date().toISOString();
1298
+ await writeState(directory, state);
1299
+ await appendEvent(directory, {
1300
+ version: 1,
1301
+ eventID: randomUUID4(),
1302
+ goalID: id,
1303
+ type: "run.started",
1304
+ runID,
1305
+ turnCount: runtime.turnCount,
1306
+ timestamp: new Date().toISOString(),
1307
+ revision: state.revision
1308
+ });
1309
+ await workers.continueWorker(worker, goal, runtime);
1310
+ }
1311
+ return { goal, worker };
1312
+ }
1313
+ async function continueTurn(directory, goalID, opts) {
1314
+ const state = await readState(directory);
1315
+ const goal = state.goals.find((g) => g.id === goalID);
1316
+ if (!goal || isTerminal(goal.status))
1317
+ return;
1318
+ const runtime = state.runtimes.find((r) => r.goalID === goalID);
1319
+ if (!runtime)
1320
+ return;
1321
+ if (runtime.phase === "running" && leaseIsValid(runtime))
1322
+ return;
1323
+ let session = sessions.get(goalID);
1324
+ if (!session && goal.workerSessionID) {
1325
+ session = {
1326
+ goalID: goal.id,
1327
+ workerSessionID: goal.workerSessionID,
1328
+ startedAt: goal.createdAt
1329
+ };
1330
+ sessions.set(goalID, session);
1331
+ }
1332
+ if (!session)
1333
+ return;
1334
+ if (!await workers.isIdle(session.workerSessionID))
1335
+ return;
1336
+ const timeoutMs = goal.config.timeoutMs || 300000;
1337
+ const leased = acquireLease(runtime, timeoutMs);
1338
+ Object.assign(runtime, leased);
1339
+ const runID = randomUUID4();
1340
+ runtime.activeRunID = runID;
1341
+ runtime.turnCount += 1;
1342
+ runtime.runCount += 1;
1343
+ runtime.lastRunAt = new Date().toISOString();
1344
+ await writeState(directory, state);
1345
+ await appendEvent(directory, {
1346
+ version: 1,
1347
+ eventID: randomUUID4(),
1348
+ goalID,
1349
+ type: "run.started",
1350
+ runID,
1351
+ turnCount: runtime.turnCount,
1352
+ timestamp: new Date().toISOString(),
1353
+ revision: state.revision
1354
+ });
1355
+ const inboxMessages = await drainGoalInbox(directory, goalID);
1356
+ const allEvents = await readEvents(directory, 200);
1357
+ const progressHistory = allEvents.filter((e) => e.goalID === goalID && e.type === "goal.progress").map((e) => ({
1358
+ summary: String(e.summary || ""),
1359
+ next: e.next ? String(e.next) : undefined,
1360
+ at: String(e.timestamp || "")
1361
+ }));
1362
+ let transcriptTail;
1363
+ try {
1364
+ transcriptTail = await host.readMessages(goal.workerSessionID, 5);
1365
+ } catch {
1366
+ transcriptTail = [];
1367
+ }
1368
+ const context = {
1369
+ inboxMessages: inboxMessages.length > 0 ? inboxMessages : undefined,
1370
+ progressHistory: progressHistory.length > 0 ? progressHistory : undefined,
1371
+ transcriptTail: transcriptTail && transcriptTail.length > 0 ? transcriptTail : undefined,
1372
+ forceFinish: opts?.forceFinish || undefined
1373
+ };
1374
+ await workers.continueWorker(session, goal, runtime, context);
1375
+ }
1376
+ async function pause(directory, goalID) {
1377
+ const state = await readState(directory);
1378
+ const goal = state.goals.find((g) => g.id === goalID);
1379
+ if (!goal)
1380
+ return;
1381
+ if (!canTransition(goal.status, "paused", "user"))
1382
+ return;
1383
+ goal.status = "paused";
1384
+ goal.updatedAt = new Date().toISOString();
1385
+ const session = sessions.get(goalID) || (goal.workerSessionID ? {
1386
+ goalID: goal.id,
1387
+ workerSessionID: goal.workerSessionID,
1388
+ startedAt: goal.createdAt
1389
+ } : undefined);
1390
+ if (session) {
1391
+ await workers.abortWorker(session.workerSessionID);
1392
+ sessions.delete(goalID);
1393
+ }
1394
+ const runtime = state.runtimes.find((r) => r.goalID === goalID);
1395
+ if (runtime) {
1396
+ Object.assign(runtime, releaseLease(runtime));
1397
+ }
1398
+ await writeState(directory, state);
1399
+ await appendEvent(directory, {
1400
+ version: 1,
1401
+ eventID: randomUUID4(),
1402
+ goalID,
1403
+ type: "goal.status_changed",
1404
+ from: "active",
1405
+ to: "paused",
1406
+ timestamp: new Date().toISOString(),
1407
+ revision: state.revision
1408
+ });
1409
+ }
1410
+ async function resume(directory, goalID) {
1411
+ const state = await readState(directory);
1412
+ const goal = state.goals.find((g) => g.id === goalID);
1413
+ if (!goal)
1414
+ return;
1415
+ if (!canTransition(goal.status, "active", "user"))
1416
+ return;
1417
+ goal.status = "active";
1418
+ goal.updatedAt = new Date().toISOString();
1419
+ let session = sessions.get(goalID);
1420
+ if (!session) {
1421
+ session = await workers.createWorker(goal);
1422
+ sessions.set(goalID, session);
1423
+ goal.workerSessionID = session.workerSessionID;
1424
+ }
1425
+ await writeState(directory, state);
1426
+ await appendEvent(directory, {
1427
+ version: 1,
1428
+ eventID: randomUUID4(),
1429
+ goalID,
1430
+ type: "goal.status_changed",
1431
+ from: "paused",
1432
+ to: "active",
1433
+ timestamp: new Date().toISOString(),
1434
+ revision: state.revision
1435
+ });
1436
+ await continueTurn(directory, goalID);
1437
+ }
1438
+ async function retry(directory, goalID) {
1439
+ const state = await readState(directory);
1440
+ const goal = state.goals.find((g) => g.id === goalID);
1441
+ if (!goal || goal.status !== "blocked")
1442
+ return;
1443
+ goal.status = "active";
1444
+ goal.updatedAt = new Date().toISOString();
1445
+ const runtime = state.runtimes.find((r) => r.goalID === goalID);
1446
+ if (runtime) {
1447
+ runtime.consecutiveFailures = 0;
1448
+ runtime.lastError = undefined;
1449
+ runtime.forceFinishRequested = undefined;
1450
+ runtime.phase = "idle";
1451
+ runtime.updatedAt = new Date().toISOString();
1452
+ }
1453
+ await writeState(directory, state);
1454
+ await appendEvent(directory, {
1455
+ version: 1,
1456
+ eventID: randomUUID4(),
1457
+ goalID,
1458
+ type: "goal.status_changed",
1459
+ from: "blocked",
1460
+ to: "active",
1461
+ timestamp: new Date().toISOString(),
1462
+ revision: state.revision
1463
+ });
1464
+ await continueTurn(directory, goalID);
1465
+ }
1466
+ async function clear(directory, goalID) {
1467
+ const state = await readState(directory);
1468
+ const goal = state.goals.find((g) => g.id === goalID);
1469
+ if (!goal)
1470
+ return;
1471
+ const session = sessions.get(goalID) || (goal.workerSessionID ? {
1472
+ goalID: goal.id,
1473
+ workerSessionID: goal.workerSessionID,
1474
+ startedAt: goal.createdAt
1475
+ } : undefined);
1476
+ if (session) {
1477
+ await workers.abortWorker(session.workerSessionID);
1478
+ sessions.delete(goalID);
1479
+ }
1480
+ await appendEvent(directory, {
1481
+ version: 1,
1482
+ eventID: randomUUID4(),
1483
+ goalID,
1484
+ type: "goal.cleared",
1485
+ timestamp: new Date().toISOString(),
1486
+ revision: state.revision
1487
+ });
1488
+ state.goals = state.goals.filter((g) => g.id !== goalID);
1489
+ state.runtimes = state.runtimes.filter((r) => r.goalID !== goalID);
1490
+ await writeState(directory, state);
1491
+ }
1492
+ function getWorker(goalID) {
1493
+ return sessions.get(goalID);
1494
+ }
1495
+ function getActiveWorkers() {
1496
+ return new Map(sessions);
1497
+ }
1498
+ async function reconcile(directory) {
1499
+ const state = await readState(directory);
1500
+ for (const goal of state.goals) {
1501
+ if (isTerminal(goal.status))
1502
+ continue;
1503
+ if (goal.status === "paused")
1504
+ continue;
1505
+ if (!goal.workerSessionID) {
1506
+ try {
1507
+ const worker = await workers.createWorker(goal);
1508
+ sessions.set(goal.id, worker);
1509
+ goal.workerSessionID = worker.workerSessionID;
1510
+ goal.updatedAt = new Date().toISOString();
1511
+ } catch (error) {
1512
+ const detail = describeError(error);
1513
+ goal.status = "blocked";
1514
+ goal.updatedAt = new Date().toISOString();
1515
+ goal.blocker = {
1516
+ reason: detail,
1517
+ needed: "Clear this goal and start it again from a valid OpenCode session.",
1518
+ at: new Date().toISOString()
1519
+ };
1520
+ const runtime2 = state.runtimes.find((item) => item.goalID === goal.id);
1521
+ if (runtime2) {
1522
+ runtime2.phase = "idle";
1523
+ runtime2.lastError = detail;
1524
+ runtime2.updatedAt = new Date().toISOString();
1525
+ }
1526
+ await logServerEvent(directory, "goal.reconcile.failed", { goalID: goal.id, ownerSessionID: goal.ownerSessionID, detail });
1527
+ continue;
1528
+ }
1529
+ }
1530
+ if (goal.workerSessionID && !sessions.has(goal.id)) {
1531
+ sessions.set(goal.id, {
1532
+ goalID: goal.id,
1533
+ workerSessionID: goal.workerSessionID,
1534
+ startedAt: goal.createdAt
1535
+ });
1536
+ }
1537
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
1538
+ if (runtime?.phase === "running" && !leaseIsValid(runtime)) {
1539
+ const session = sessions.get(goal.id);
1540
+ if (session && await workers.isIdle(session.workerSessionID)) {
1541
+ Object.assign(runtime, releaseLease(runtime));
1542
+ goal.updatedAt = new Date().toISOString();
1543
+ }
1544
+ }
1545
+ }
1546
+ await writeState(directory, state);
1547
+ }
1548
+ return { start, continueTurn, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile };
1549
+ }
1550
+
1551
+ // src/server/host-adapter.ts
1552
+ function createRealHost(client, directory) {
1553
+ return {
1554
+ async createWorker({ parentID, title }) {
1555
+ try {
1556
+ const result = await withTimeout(client.session.create({ body: { parentID, title } }), 1e4, "OpenCode session.create");
1557
+ const data = result?.data;
1558
+ if (result?.error || !data?.id) {
1559
+ const detail = describeError(result?.error || "response contained no session ID");
1560
+ await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
1561
+ throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
1562
+ }
1563
+ await logServerEvent(directory, "worker.created", { parentID, workerSessionID: data.id, title });
1564
+ return data.id;
1565
+ } catch (error) {
1566
+ if (error instanceof Error && error.message.startsWith("OpenCode session.create failed"))
1567
+ throw error;
1568
+ const detail = describeError(error);
1569
+ await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
1570
+ throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
1571
+ }
1572
+ },
1573
+ async promptWorker({ sessionID, prompt, model, agent }) {
1574
+ const body = {
1575
+ parts: [{ type: "text", text: prompt }]
1576
+ };
1577
+ if (model)
1578
+ body.model = model;
1579
+ if (agent)
1580
+ body.agent = agent;
1581
+ const result = await withTimeout(client.session.promptAsync({
1582
+ path: { id: sessionID },
1583
+ body
1584
+ }), 1e4, "OpenCode session.promptAsync");
1585
+ if (result?.error) {
1586
+ const detail = describeError(result.error);
1587
+ await logServerEvent(directory, "worker.prompt.failed", { sessionID, detail });
1588
+ throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`);
1589
+ }
1590
+ await logServerEvent(directory, "worker.prompted", { sessionID });
1591
+ },
1592
+ async sessionStatus(sessionID) {
1593
+ try {
1594
+ const result = await client.session.status({});
1595
+ const data = result?.data;
1596
+ if (!data || typeof data !== "object")
1597
+ return "idle";
1598
+ const status = data[sessionID];
1599
+ if (!status || typeof status !== "object")
1600
+ return "idle";
1601
+ const type = status.type;
1602
+ if (type === "busy" || type === "retry")
1603
+ return type;
1604
+ return "idle";
1605
+ } catch {
1606
+ return "idle";
1607
+ }
1608
+ },
1609
+ async abortSession(sessionID) {
1610
+ try {
1611
+ await client.session.abort({ path: { id: sessionID } });
1612
+ } catch {}
1613
+ },
1614
+ async readMessages(sessionID, limit = 10) {
1615
+ try {
1616
+ const result = await client.session.messages({
1617
+ path: { id: sessionID },
1618
+ query: { limit }
1619
+ });
1620
+ const data = result?.data;
1621
+ if (!Array.isArray(data))
1622
+ return [];
1623
+ return data.map((m) => ({
1624
+ role: m.info?.role || "assistant",
1625
+ content: m.parts?.filter((p) => p.type === "text").map((p) => p.text).join(`
1626
+ `) || "",
1627
+ timestamp: m.info?.time?.completed ? new Date(m.info.time.completed).toISOString() : undefined,
1628
+ messageID: m.id
1629
+ }));
1630
+ } catch {
1631
+ return [];
1632
+ }
1633
+ },
1634
+ async compactSession(sessionID) {
1635
+ try {
1636
+ await client.session.compact({ sessionID });
1637
+ } catch {}
1638
+ },
1639
+ async notifyOwner(ownerSessionID, message) {
1640
+ try {
1641
+ const result = await withTimeout(client.session.promptAsync({
1642
+ path: { id: ownerSessionID },
1643
+ body: { parts: [{ type: "text", text: message }] }
1644
+ }), 1e4, "OpenCode parent notify");
1645
+ if (result?.error) {
1646
+ await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(result.error) });
1647
+ } else {
1648
+ await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
1649
+ }
1650
+ } catch (error) {
1651
+ await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
1652
+ }
1653
+ }
1654
+ };
1655
+ }
1656
+ async function withTimeout(promise, timeoutMs, operation) {
1657
+ let timer;
1658
+ try {
1659
+ return await Promise.race([
1660
+ promise,
1661
+ new Promise((_, reject) => {
1662
+ timer = setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs);
1663
+ })
1664
+ ]);
1665
+ } finally {
1666
+ if (timer)
1667
+ clearTimeout(timer);
1668
+ }
1669
+ }
1670
+
1671
+ // src/server/goal-tools.ts
1672
+ init_state_repository();
1673
+ import { randomUUID as randomUUID5 } from "crypto";
1674
+ import { tool } from "@opencode-ai/plugin/tool";
1675
+ import { exec as execChild } from "child_process";
1676
+ import { promisify } from "util";
1677
+ var execAsync = promisify(execChild);
1678
+ function goalTools(dir, goalService, hostSessionID) {
1679
+ return {
1680
+ loopd_create_goal: tool({
1681
+ description: "Create a new background loop goal. The engine spawns a dedicated worker session " + "that does the work autonomously \u2014 it never runs in this chat. " + "Call this after clarifying the goal name, objective, and any config with the user. " + "The goal immediately starts in the background; the user can monitor it via /loop.",
1682
+ args: {
1683
+ name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
1684
+ objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
1685
+ checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
1686
+ progressFile: tool.schema.string().optional().describe("Markdown file the worker reads/writes as its transaction state."),
1687
+ maxTurns: tool.schema.number().optional().describe("Max turns before auto-block."),
1688
+ maxNoProgress: tool.schema.number().optional().describe("Block after N turns without progress."),
1689
+ maxFailures: tool.schema.number().optional().describe("Block after N consecutive failures."),
1690
+ compactEvery: tool.schema.number().optional().describe("Compact the worker session every N turns."),
1691
+ timeoutMs: tool.schema.number().optional().describe("Per-turn timeout in ms.")
1692
+ },
1693
+ execute: async (args, context) => {
1694
+ const sessionID = context?.sessionID || hostSessionID;
1695
+ if (!sessionID || sessionID === "main") {
1696
+ return {
1697
+ title: "Goal not created",
1698
+ output: JSON.stringify({
1699
+ ok: false,
1700
+ message: "A valid owner session is required. Run /goal from an active OpenCode session."
1701
+ })
1702
+ };
1703
+ }
1704
+ const config = {
1705
+ maxTurns: 50
1706
+ };
1707
+ if (args.checks)
1708
+ config.checks = args.checks;
1709
+ if (args.progressFile)
1710
+ config.progressFile = args.progressFile;
1711
+ if (args.maxTurns !== undefined)
1712
+ config.maxTurns = args.maxTurns;
1713
+ if (args.maxNoProgress !== undefined)
1714
+ config.maxNoProgress = args.maxNoProgress;
1715
+ if (args.maxFailures !== undefined)
1716
+ config.maxFailures = args.maxFailures;
1717
+ if (args.compactEvery !== undefined)
1718
+ config.compactEvery = args.compactEvery;
1719
+ if (args.timeoutMs !== undefined)
1720
+ config.timeoutMs = args.timeoutMs;
1721
+ try {
1722
+ const { goal, worker } = await goalService.start(dir, {
1723
+ name: args.name,
1724
+ objective: args.objective,
1725
+ ownerSessionID: sessionID,
1726
+ config
1727
+ });
1728
+ return {
1729
+ title: "Goal created",
1730
+ output: JSON.stringify({
1731
+ ok: true,
1732
+ goalID: goal.id,
1733
+ workerSessionID: worker.workerSessionID,
1734
+ artifactDir: goal.config.artifactDir,
1735
+ name: args.name,
1736
+ message: `Goal "${args.name}" created and started in the background. Artifacts: ${goal.config.artifactDir}. Monitor with /loop (Ctrl+L).`
1737
+ })
1738
+ };
1739
+ } catch (error) {
1740
+ return {
1741
+ title: "Goal creation failed",
1742
+ output: JSON.stringify({
1743
+ ok: false,
1744
+ name: args.name,
1745
+ message: error instanceof Error ? error.message : String(error),
1746
+ diagnostics: SERVER_LOG_FILE
1747
+ })
1748
+ };
1749
+ }
1750
+ }
1751
+ }),
1752
+ get_goal: tool({
1753
+ description: "Get the current goal state. Call at the start of every continuation turn " + "to retrieve the objective, current state, acceptance criteria, and recent failures.",
1754
+ args: {},
1755
+ execute: async (_args, context) => {
1756
+ const state = await readState(dir);
1757
+ const workerID = context?.sessionID || hostSessionID;
1758
+ const goal = findGoalByWorkerSession(state, workerID);
1759
+ if (!goal) {
1760
+ return {
1761
+ title: "No active goal",
1762
+ output: JSON.stringify({
1763
+ status: "none",
1764
+ message: "No active goal found for this worker session."
1765
+ })
1766
+ };
1767
+ }
1768
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
1769
+ return {
1770
+ title: `Goal: ${goal.name}`,
1771
+ output: formatGoalStructured(goal, runtime)
1772
+ };
1773
+ }
1774
+ }),
1775
+ report_goal_progress: tool({
1776
+ description: "Report meaningful progress on the current goal without completing it. " + "Call after durable state changes (file writes, verifications).",
1777
+ args: {
1778
+ summary: tool.schema.string().describe("What was accomplished."),
1779
+ next: tool.schema.string().describe("The next concrete step."),
1780
+ evidence: tool.schema.string().describe("Optional concrete evidence.")
1781
+ },
1782
+ execute: async (args, context) => {
1783
+ const state = await readState(dir);
1784
+ const workerID = context?.sessionID || hostSessionID;
1785
+ const goal = findGoalByWorkerSession(state, workerID);
1786
+ if (!goal) {
1787
+ return { title: "No goal", output: "No active goal to report progress for." };
1788
+ }
1789
+ if (goal.status !== "active") {
1790
+ return { title: "Invalid state", output: `Goal is ${goal.status}, not active. Cannot report progress.` };
1791
+ }
1792
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
1793
+ if (runtime) {
1794
+ Object.assign(runtime, markProgress(runtime));
1795
+ runtime.noProgressCount = 0;
1796
+ runtime.consecutiveFailures = 0;
1797
+ }
1798
+ goal.lastProgress = {
1799
+ summary: args.summary,
1800
+ next: args.next,
1801
+ at: new Date().toISOString()
1802
+ };
1803
+ await writeState(dir, state);
1804
+ const event = {
1805
+ version: 1,
1806
+ eventID: randomUUID5(),
1807
+ goalID: goal.id,
1808
+ type: "goal.progress",
1809
+ summary: args.summary,
1810
+ next: args.next,
1811
+ timestamp: new Date().toISOString(),
1812
+ revision: state.revision
1813
+ };
1814
+ await appendEvent(dir, event);
1815
+ return {
1816
+ title: "Progress recorded",
1817
+ output: JSON.stringify({
1818
+ goalName: goal.name,
1819
+ summary: args.summary,
1820
+ next: args.next,
1821
+ turn: runtime?.turnCount
1822
+ })
1823
+ };
1824
+ }
1825
+ }),
1826
+ complete_goal: tool({
1827
+ description: "Mark the current goal as completed. " + "Use only when all acceptance criteria pass with concrete evidence. " + "Runs configured completion checks before accepting.",
1828
+ args: {
1829
+ summary: tool.schema.string().describe("What was completed."),
1830
+ evidence: tool.schema.string().describe("Concrete evidence of completion.")
1831
+ },
1832
+ execute: async (args, context) => {
1833
+ const state = await readState(dir);
1834
+ const workerID = context?.sessionID || hostSessionID;
1835
+ const goal = findGoalByWorkerSession(state, workerID);
1836
+ if (!goal) {
1837
+ return { title: "No goal", output: "No active goal to complete." };
1838
+ }
1839
+ if (!canTransition(goal.status, "complete", "model")) {
1840
+ return { title: "Invalid transition", output: `Cannot complete goal in ${goal.status} state.` };
1841
+ }
1842
+ if (goal.config.checks?.length) {
1843
+ const checkResults = await runCompletionChecks(goal.config.checks);
1844
+ if (!checkResults.passed) {
1845
+ return {
1846
+ title: "Checks failed",
1847
+ output: JSON.stringify({
1848
+ passed: false,
1849
+ failedChecks: checkResults.failures,
1850
+ message: "Completion checks failed. Fix issues and try again."
1851
+ })
1852
+ };
1853
+ }
1854
+ }
1855
+ goal.status = "complete";
1856
+ goal.updatedAt = new Date().toISOString();
1857
+ goal.completionEvidence = {
1858
+ summary: args.summary,
1859
+ evidence: args.evidence,
1860
+ at: new Date().toISOString()
1861
+ };
1862
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
1863
+ if (runtime) {
1864
+ runtime.phase = "idle";
1865
+ runtime.lastError = undefined;
1866
+ }
1867
+ await writeState(dir, state);
1868
+ const event = {
1869
+ version: 1,
1870
+ eventID: randomUUID5(),
1871
+ goalID: goal.id,
1872
+ type: "goal.completed",
1873
+ summary: args.summary,
1874
+ evidence: args.evidence,
1875
+ timestamp: new Date().toISOString(),
1876
+ revision: state.revision
1877
+ };
1878
+ await appendEvent(dir, event);
1879
+ return {
1880
+ title: "Goal completed",
1881
+ output: JSON.stringify({
1882
+ goalID: goal.id,
1883
+ goalName: goal.name,
1884
+ status: "complete",
1885
+ summary: args.summary,
1886
+ evidence: args.evidence
1887
+ })
1888
+ };
1889
+ }
1890
+ }),
1891
+ block_goal: tool({
1892
+ description: "Mark the current goal as blocked. " + "Use only for a real external blocker requiring user intervention.",
1893
+ args: {
1894
+ reason: tool.schema.string().describe("Why the goal is blocked."),
1895
+ needed: tool.schema.string().describe("What is needed to unblock.")
1896
+ },
1897
+ execute: async (args, context) => {
1898
+ const state = await readState(dir);
1899
+ const workerID = context?.sessionID || hostSessionID;
1900
+ const goal = findGoalByWorkerSession(state, workerID);
1901
+ if (!goal) {
1902
+ return { title: "No goal", output: "No active goal to block." };
1903
+ }
1904
+ if (!canTransition(goal.status, "blocked", "model")) {
1905
+ return { title: "Invalid transition", output: `Cannot block goal in ${goal.status} state.` };
1906
+ }
1907
+ goal.status = "blocked";
1908
+ goal.updatedAt = new Date().toISOString();
1909
+ goal.blocker = {
1910
+ reason: args.reason,
1911
+ needed: args.needed,
1912
+ at: new Date().toISOString()
1913
+ };
1914
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
1915
+ if (runtime) {
1916
+ runtime.phase = "idle";
1917
+ runtime.lastError = undefined;
1918
+ }
1919
+ await writeState(dir, state);
1920
+ const event = {
1921
+ version: 1,
1922
+ eventID: randomUUID5(),
1923
+ goalID: goal.id,
1924
+ type: "goal.blocked",
1925
+ reason: args.reason,
1926
+ needed: args.needed,
1927
+ timestamp: new Date().toISOString(),
1928
+ revision: state.revision
1929
+ };
1930
+ await appendEvent(dir, event);
1931
+ return {
1932
+ title: "Goal blocked",
1933
+ output: JSON.stringify({
1934
+ goalID: goal.id,
1935
+ goalName: goal.name,
1936
+ status: "blocked",
1937
+ reason: args.reason,
1938
+ needed: args.needed
1939
+ })
1940
+ };
1941
+ }
1942
+ })
1943
+ };
1944
+ }
1945
+ function findGoalByWorkerSession(state, sessionID) {
1946
+ if (!sessionID)
1947
+ return;
1948
+ return state.goals.find((g) => g.workerSessionID === sessionID && (g.status === "active" || g.status === "blocked"));
1949
+ }
1950
+ function formatGoalStructured(goal, runtime) {
1951
+ const output = {
1952
+ id: goal.id,
1953
+ name: goal.name,
1954
+ objective: goal.objective,
1955
+ status: goal.status,
1956
+ ownerSessionID: goal.ownerSessionID,
1957
+ workerSessionID: goal.workerSessionID,
1958
+ config: {
1959
+ promptFile: goal.config.promptFile,
1960
+ progressFile: goal.config.progressFile,
1961
+ includeFiles: goal.config.includeFiles,
1962
+ checks: goal.config.checks,
1963
+ maxTurns: goal.config.maxTurns,
1964
+ maxNoProgress: goal.config.maxNoProgress,
1965
+ maxFailures: goal.config.maxFailures,
1966
+ compactEvery: goal.config.compactEvery,
1967
+ timeoutMs: goal.config.timeoutMs
1968
+ },
1969
+ lastProgress: goal.lastProgress,
1970
+ completionEvidence: goal.completionEvidence,
1971
+ blocker: goal.blocker,
1972
+ tokensUsed: goal.tokensUsed,
1973
+ timeUsedSeconds: goal.timeUsedSeconds
1974
+ };
1975
+ if (runtime) {
1976
+ output.runtime = {
1977
+ phase: runtime.phase,
1978
+ turnCount: runtime.turnCount,
1979
+ runCount: runtime.runCount,
1980
+ consecutiveFailures: runtime.consecutiveFailures,
1981
+ noProgressCount: runtime.noProgressCount,
1982
+ lastError: runtime.lastError,
1983
+ lastProgressAt: runtime.lastProgressAt,
1984
+ lastRunAt: runtime.lastRunAt,
1985
+ lastCompactAt: runtime.lastCompactAt
1986
+ };
1987
+ }
1988
+ return JSON.stringify(output, null, 2);
1989
+ }
1990
+ async function runCompletionChecks(checks) {
1991
+ const failures = [];
1992
+ for (const cmd of checks) {
1993
+ try {
1994
+ await execAsync(cmd, { timeout: 30000 });
1995
+ } catch (error) {
1996
+ failures.push({
1997
+ command: cmd,
1998
+ exitCode: error.code || 1,
1999
+ stderr: error.stderr || error.message || "unknown error"
2000
+ });
2001
+ }
2002
+ }
2003
+ return {
2004
+ passed: failures.length === 0,
2005
+ failures
2006
+ };
2007
+ }
2008
+
2009
+ // src/server/owner-tools.ts
2010
+ init_state_repository();
2011
+ import { tool as tool2 } from "@opencode-ai/plugin/tool";
2012
+ function ownerTools(options) {
2013
+ const { directory, host, goalService } = options;
2014
+ return {
2015
+ list_background_goals: tool2({
2016
+ description: "List all background loop goals visible to this session. " + "Shows name, status, progress, and whether any goal is waiting for user input.",
2017
+ args: {},
2018
+ execute: async (_args, context) => {
2019
+ const state = await readState(directory);
2020
+ const ownerID = context?.sessionID;
2021
+ if (!ownerID) {
2022
+ return {
2023
+ title: "No session",
2024
+ output: JSON.stringify({ ok: false, message: "No session context available." })
2025
+ };
2026
+ }
2027
+ const goals = state.goals.filter((g) => g.ownerSessionID === ownerID && g.status !== "complete");
2028
+ if (goals.length === 0) {
2029
+ return {
2030
+ title: "No active goals",
2031
+ output: JSON.stringify({
2032
+ ok: true,
2033
+ goals: [],
2034
+ message: "No active background goals for this session."
2035
+ })
2036
+ };
2037
+ }
2038
+ const summaries = goals.map((g) => {
2039
+ const runtime = state.runtimes.find((r) => r.goalID === g.id);
2040
+ return {
2041
+ id: g.id,
2042
+ name: g.name,
2043
+ status: g.status,
2044
+ phase: runtime?.phase ?? "unknown",
2045
+ turn: runtime?.turnCount ?? 0,
2046
+ lastProgress: g.lastProgress?.summary?.slice(0, 120),
2047
+ lastProgressAt: g.lastProgress?.at,
2048
+ blocker: g.blocker?.reason?.slice(0, 120)
2049
+ };
2050
+ });
2051
+ return {
2052
+ title: `${goals.length} active goal(s)`,
2053
+ output: JSON.stringify({ ok: true, goals: summaries }, null, 2)
2054
+ };
2055
+ }
2056
+ }),
2057
+ inspect_background_goal: tool2({
2058
+ description: "Inspect a background goal in detail: objective, contract, progress, " + "blockers, questions, runtime state, and recent events.",
2059
+ args: {
2060
+ goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to inspect the first active goal.")
2061
+ },
2062
+ execute: async (args, context) => {
2063
+ const state = await readState(directory);
2064
+ const ownerID = context?.sessionID;
2065
+ const goal = args.goal_id ? state.goals.find((g) => g.id === args.goal_id && g.ownerSessionID === ownerID) : state.goals.find((g) => g.ownerSessionID === ownerID && g.status !== "complete");
2066
+ if (!goal) {
2067
+ return {
2068
+ title: "No goal found",
2069
+ output: JSON.stringify({ ok: false, message: "No matching active goal for this session." })
2070
+ };
2071
+ }
2072
+ const runtime = state.runtimes.find((r) => r.goalID === goal.id);
2073
+ return {
2074
+ title: `Goal: ${goal.name}`,
2075
+ output: JSON.stringify({
2076
+ ok: true,
2077
+ id: goal.id,
2078
+ name: goal.name,
2079
+ objective: goal.objective,
2080
+ status: goal.status,
2081
+ ownerSessionID: goal.ownerSessionID,
2082
+ workerSessionID: goal.workerSessionID,
2083
+ config: {
2084
+ maxTurns: goal.config.maxTurns,
2085
+ maxFailures: goal.config.maxFailures,
2086
+ timeoutMs: goal.config.timeoutMs,
2087
+ progressFile: goal.config.progressFile,
2088
+ checks: goal.config.checks
2089
+ },
2090
+ lastProgress: goal.lastProgress,
2091
+ completionEvidence: goal.completionEvidence,
2092
+ blocker: goal.blocker,
2093
+ tokensUsed: goal.tokensUsed,
2094
+ timeUsedSeconds: goal.timeUsedSeconds,
2095
+ runtime: runtime ? {
2096
+ phase: runtime.phase,
2097
+ turnCount: runtime.turnCount,
2098
+ runCount: runtime.runCount,
2099
+ consecutiveFailures: runtime.consecutiveFailures,
2100
+ lastError: runtime.lastError,
2101
+ lastProgressAt: runtime.lastProgressAt,
2102
+ lastRunAt: runtime.lastRunAt
2103
+ } : undefined
2104
+ }, null, 2)
2105
+ };
2106
+ }
2107
+ }),
2108
+ read_goal_transcript: tool2({
2109
+ description: "Read the last N messages from a goal's worker session transcript. " + "Shows what the worker has been doing: tool calls, file changes, responses.",
2110
+ args: {
2111
+ goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to read the first active goal."),
2112
+ limit: tool2.schema.number().optional().describe("Max messages to return (default: 20).")
2113
+ },
2114
+ execute: async (args, context) => {
2115
+ const state = await readState(directory);
2116
+ const ownerID = context?.sessionID;
2117
+ const goal = args.goal_id ? state.goals.find((g) => g.id === args.goal_id && g.ownerSessionID === ownerID) : state.goals.find((g) => g.ownerSessionID === ownerID && g.status !== "complete");
2118
+ if (!goal) {
2119
+ return {
2120
+ title: "No goal found",
2121
+ output: JSON.stringify({ ok: false, message: "No matching active goal for this session." })
2122
+ };
2123
+ }
2124
+ if (!goal.workerSessionID) {
2125
+ return {
2126
+ title: "No worker",
2127
+ output: JSON.stringify({ ok: false, message: "Goal has no worker session yet." })
2128
+ };
2129
+ }
2130
+ try {
2131
+ const messages = await host.readMessages(goal.workerSessionID, args.limit || 20);
2132
+ return {
2133
+ title: `Transcript: ${goal.name}`,
2134
+ output: JSON.stringify({
2135
+ ok: true,
2136
+ goalID: goal.id,
2137
+ workerSessionID: goal.workerSessionID,
2138
+ messages: messages.map((m) => ({
2139
+ role: m.role,
2140
+ content: m.content.slice(0, 2000),
2141
+ timestamp: m.timestamp,
2142
+ messageID: m.messageID
2143
+ }))
2144
+ }, null, 2)
2145
+ };
2146
+ } catch (error) {
2147
+ return {
2148
+ title: "Transcript error",
2149
+ output: JSON.stringify({
2150
+ ok: false,
2151
+ message: error instanceof Error ? error.message : String(error)
2152
+ })
2153
+ };
2154
+ }
2155
+ }
2156
+ }),
2157
+ send_goal_input: tool2({
2158
+ description: "Send a message, instruction, or answer to a background goal's worker session. " + "The message will be injected into the worker's next continuation prompt. " + "Use this to answer worker questions, redirect work, or refine scope.",
2159
+ args: {
2160
+ goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to target the first active goal."),
2161
+ message: tool2.schema.string().describe("Message to send to the worker.")
2162
+ },
2163
+ execute: async (args, context) => {
2164
+ const state = await readState(directory);
2165
+ const ownerID = context?.sessionID;
2166
+ const goal = args.goal_id ? state.goals.find((g) => g.id === args.goal_id && g.ownerSessionID === ownerID) : state.goals.find((g) => g.ownerSessionID === ownerID && g.status !== "complete");
2167
+ if (!goal) {
2168
+ return {
2169
+ title: "No goal found",
2170
+ output: JSON.stringify({ ok: false, message: "No matching active goal for this session." })
2171
+ };
2172
+ }
2173
+ await appendGoalInbox(directory, goal.id, "user", args.message);
2174
+ return {
2175
+ title: "Message sent",
2176
+ output: JSON.stringify({
2177
+ ok: true,
2178
+ goalID: goal.id,
2179
+ goalName: goal.name,
2180
+ message: `Message delivered to "${goal.name}". It will appear in the worker's next turn.`
2181
+ })
2182
+ };
2183
+ }
2184
+ }),
2185
+ pause_goal: tool2({
2186
+ description: "Pause a background goal. The worker session is aborted and the goal stops running. " + "Use when you need to temporarily stop work (e.g., to investigate an issue or change priorities).",
2187
+ args: {
2188
+ goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to pause the first active goal.")
2189
+ },
2190
+ execute: async (args, context) => {
2191
+ const state = await readState(directory);
2192
+ const ownerID = context?.sessionID;
2193
+ const goal = args.goal_id ? state.goals.find((g) => g.id === args.goal_id && g.ownerSessionID === ownerID) : state.goals.find((g) => g.ownerSessionID === ownerID && g.status !== "complete");
2194
+ if (!goal) {
2195
+ return {
2196
+ title: "No goal found",
2197
+ output: JSON.stringify({ ok: false, message: "No matching active goal for this session." })
2198
+ };
2199
+ }
2200
+ if (goal.status === "paused") {
2201
+ return {
2202
+ title: "Already paused",
2203
+ output: JSON.stringify({ ok: true, message: `Goal "${goal.name}" is already paused.` })
2204
+ };
2205
+ }
2206
+ try {
2207
+ await goalService.pause(directory, goal.id);
2208
+ return {
2209
+ title: "Goal paused",
2210
+ output: JSON.stringify({
2211
+ ok: true,
2212
+ goalID: goal.id,
2213
+ goalName: goal.name,
2214
+ message: `Goal "${goal.name}" paused. Resume with resume_goal.`
2215
+ })
2216
+ };
2217
+ } catch (error) {
2218
+ return {
2219
+ title: "Pause failed",
2220
+ output: JSON.stringify({
2221
+ ok: false,
2222
+ goalName: goal.name,
2223
+ message: error instanceof Error ? error.message : String(error)
2224
+ })
2225
+ };
2226
+ }
2227
+ }
2228
+ }),
2229
+ resume_goal: tool2({
2230
+ description: "Resume a paused or blocked background goal. " + "For paused goals, creates a new worker session if needed. " + "For blocked goals, resets failure count and retries.",
2231
+ args: {
2232
+ goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to resume the first paused/blocked goal.")
2233
+ },
2234
+ execute: async (args, context) => {
2235
+ const state = await readState(directory);
2236
+ const ownerID = context?.sessionID;
2237
+ const goal = args.goal_id ? state.goals.find((g) => g.id === args.goal_id && g.ownerSessionID === ownerID) : state.goals.find((g) => g.ownerSessionID === ownerID && (g.status === "paused" || g.status === "blocked"));
2238
+ if (!goal) {
2239
+ return {
2240
+ title: "No goal found",
2241
+ output: JSON.stringify({
2242
+ ok: false,
2243
+ message: "No matching paused/blocked goal for this session."
2244
+ })
2245
+ };
2246
+ }
2247
+ if (goal.status === "active") {
2248
+ return {
2249
+ title: "Already active",
2250
+ output: JSON.stringify({ ok: true, message: `Goal "${goal.name}" is already active.` })
2251
+ };
2252
+ }
2253
+ try {
2254
+ if (goal.status === "paused") {
2255
+ await goalService.resume(directory, goal.id);
2256
+ } else if (goal.status === "blocked") {
2257
+ await goalService.retry(directory, goal.id);
2258
+ }
2259
+ return {
2260
+ title: "Goal resumed",
2261
+ output: JSON.stringify({
2262
+ ok: true,
2263
+ goalID: goal.id,
2264
+ goalName: goal.name,
2265
+ message: `Goal "${goal.name}" resumed.`
2266
+ })
2267
+ };
2268
+ } catch (error) {
2269
+ return {
2270
+ title: "Resume failed",
2271
+ output: JSON.stringify({
2272
+ ok: false,
2273
+ goalName: goal.name,
2274
+ message: error instanceof Error ? error.message : String(error)
2275
+ })
2276
+ };
2277
+ }
2278
+ }
2279
+ }),
2280
+ clear_goal: tool2({
2281
+ description: "Clear a background goal. Aborts the worker and removes the goal from the dashboard. " + "This action cannot be undone. Use when the goal is no longer needed.",
2282
+ args: {
2283
+ goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to clear the first active goal.")
2284
+ },
2285
+ execute: async (args, context) => {
2286
+ const state = await readState(directory);
2287
+ const ownerID = context?.sessionID;
2288
+ const goal = args.goal_id ? state.goals.find((g) => g.id === args.goal_id && g.ownerSessionID === ownerID) : state.goals.find((g) => g.ownerSessionID === ownerID && g.status !== "complete");
2289
+ if (!goal) {
2290
+ return {
2291
+ title: "No goal found",
2292
+ output: JSON.stringify({ ok: false, message: "No matching active goal for this session." })
2293
+ };
2294
+ }
2295
+ try {
2296
+ await goalService.clear(directory, goal.id);
2297
+ return {
2298
+ title: "Goal cleared",
2299
+ output: JSON.stringify({
2300
+ ok: true,
2301
+ goalID: goal.id,
2302
+ goalName: goal.name,
2303
+ message: `Goal "${goal.name}" cleared and removed.`
2304
+ })
2305
+ };
2306
+ } catch (error) {
2307
+ return {
2308
+ title: "Clear failed",
2309
+ output: JSON.stringify({
2310
+ ok: false,
2311
+ goalName: goal.name,
2312
+ message: error instanceof Error ? error.message : String(error)
2313
+ })
2314
+ };
2315
+ }
2316
+ }
2317
+ })
2318
+ };
2319
+ }
2320
+
2321
+ // src/server/plugin.ts
2322
+ var PLUGIN_ID = "opencode-loopd.server";
2323
+ var server = async ({ client, directory }) => {
2324
+ const host = createRealHost(client, directory);
2325
+ const goalService = createGoalService(host);
2326
+ const worker = createControlWorker({
2327
+ directory,
2328
+ goalService,
2329
+ pollIntervalMs: 1000
2330
+ });
2331
+ const engine = createLoopEngine({
2332
+ directory,
2333
+ host,
2334
+ goalService,
2335
+ pollIntervalMs: 30000
2336
+ });
2337
+ let started = false;
2338
+ let reconciliationStarted = false;
2339
+ function ensureStarted() {
2340
+ if (started)
2341
+ return;
2342
+ started = true;
2343
+ engine.start();
2344
+ worker.start();
2345
+ }
2346
+ function reconcileInBackground() {
2347
+ if (reconciliationStarted)
2348
+ return;
2349
+ reconciliationStarted = true;
2350
+ logServerEvent(directory, "reconcile.started");
2351
+ goalService.reconcile(directory).then(() => logServerEvent(directory, "reconcile.completed"), (error) => logServerEvent(directory, "reconcile.failed", { detail: describeError(error) }));
2352
+ }
2353
+ return {
2354
+ event: async ({ event }) => {
2355
+ const type = event?.type;
2356
+ if (type?.startsWith("session.")) {
2357
+ ensureStarted();
2358
+ }
2359
+ await engine.handleEvent(event);
2360
+ if (type?.startsWith("session."))
2361
+ reconcileInBackground();
2362
+ },
2363
+ tool: { ...goalTools(directory, goalService), ...ownerTools({ directory, host, goalService }) },
2364
+ "tool.execute.after": async (input, output) => {
2365
+ if (input.tool === "loopd_create_goal" || input.tool === "get_goal" || input.tool === "report_goal_progress") {
2366
+ ensureStarted();
2367
+ reconcileInBackground();
2368
+ }
2369
+ if (input.tool === "complete_goal" || input.tool === "block_goal") {
2370
+ try {
2371
+ const raw = output?.output;
2372
+ if (!raw)
2373
+ return;
2374
+ const parsed = JSON.parse(raw);
2375
+ if (parsed.status !== "complete" && parsed.status !== "blocked")
2376
+ return;
2377
+ const goalID = parsed.goalID;
2378
+ if (!goalID)
2379
+ return;
2380
+ const { readState: readState2 } = await Promise.resolve().then(() => (init_state_repository(), exports_state_repository));
2381
+ const state = await readState2(directory);
2382
+ const goal = state.goals.find((g) => g.id === goalID);
2383
+ if (!goal)
2384
+ return;
2385
+ const message = parsed.status === "complete" ? `Loop goal "${goal.name}" completed: ${parsed.summary || ""}. Evidence: ${parsed.evidence || ""}. Artifacts: ${goal.config.artifactDir || "n/a"}.` : `Loop goal "${goal.name}" blocked: ${parsed.reason || ""}. Needed: ${parsed.needed || ""}.`;
2386
+ await host.notifyOwner(goal.ownerSessionID, message);
2387
+ } catch {}
2388
+ }
2389
+ },
2390
+ dispose: async () => {
2391
+ engine.stop();
2392
+ await worker.stop();
2393
+ }
2394
+ };
2395
+ };
2396
+ var plugin_default = {
2397
+ id: PLUGIN_ID,
2398
+ server
2399
+ };
2400
+ export {
2401
+ plugin_default as default
2402
+ };