@bojackduy/opencode-loopd 1.10.1 → 1.10.3

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.
Files changed (4) hide show
  1. package/README.md +13 -6
  2. package/dist/server.js +2399 -134
  3. package/dist/tui.js +11436 -1917
  4. package/package.json +5 -1
package/dist/server.js CHANGED
@@ -130,7 +130,7 @@ import { promises as fs } from "fs";
130
130
  import path from "path";
131
131
  import os from "os";
132
132
  function emptyState() {
133
- return { version: CURRENT_VERSION, revision: 0, goals: [], runtimes: [], commandLedger: [] };
133
+ return { version: CURRENT_VERSION, revision: 0, goals: [], runtimes: [], commandLedger: [], commands: [] };
134
134
  }
135
135
  function loopDir(directory) {
136
136
  return path.join(directory, ".opencode", "loopd");
@@ -316,6 +316,16 @@ function migrate(state) {
316
316
  lastScheduleAt: rt.lastScheduleAt ?? undefined
317
317
  }));
318
318
  }
319
+ if (result.version < 7) {
320
+ result.version = 7;
321
+ if (!Array.isArray(result.commands))
322
+ result.commands = [];
323
+ }
324
+ if (result.version < 8) {
325
+ result.version = 8;
326
+ if (!Array.isArray(result.commandAwaits))
327
+ result.commandAwaits = [];
328
+ }
319
329
  return result;
320
330
  }
321
331
  async function writeAtomic(target, contents) {
@@ -480,18 +490,373 @@ async function peekGoalInbox(directory, goalID) {
480
490
  return [];
481
491
  }
482
492
  }
493
+ function commandLogFile(directory, commandID) {
494
+ return path.join(loopDir(directory), "commands", `${commandID}.log`);
495
+ }
496
+ async function appendCommandLog(directory, commandID, chunk) {
497
+ const file = commandLogFile(directory, commandID);
498
+ await fs.mkdir(path.dirname(file), { recursive: true });
499
+ await fs.appendFile(file, chunk, "utf8");
500
+ }
501
+ async function readCommandLog(directory, commandID, opts) {
502
+ const file = commandLogFile(directory, commandID);
503
+ try {
504
+ const stat = await fs.stat(file);
505
+ const totalBytes = stat.size;
506
+ const requestedStart = Math.max(0, opts?.offsetBytes ?? 0);
507
+ if (requestedStart >= totalBytes)
508
+ return { text: "", totalBytes, startByte: requestedStart };
509
+ const fh = await fs.open(file, "r");
510
+ try {
511
+ const want = Math.min(opts?.limitBytes ?? 64 * 1024, totalBytes - requestedStart);
512
+ const buf = Buffer.alloc(want);
513
+ await fh.read(buf, 0, want, requestedStart);
514
+ const { text, startByte, endByte } = decodeUtf8Window(buf, requestedStart);
515
+ return { text, totalBytes, startByte };
516
+ } finally {
517
+ await fh.close();
518
+ }
519
+ } catch {
520
+ return { text: "", totalBytes: 0, startByte: 0 };
521
+ }
522
+ }
523
+ function decodeUtf8Window(buf, windowStart) {
524
+ let start = 0;
525
+ while (start < buf.length && (buf[start] & 192) === 128 && start < 4)
526
+ start++;
527
+ let end = buf.length;
528
+ let leadIndex = end;
529
+ while (leadIndex > start && (buf[leadIndex - 1] & 192) === 128)
530
+ leadIndex--;
531
+ if (leadIndex > start) {
532
+ const lead = buf[leadIndex - 1];
533
+ let expected = 1;
534
+ if ((lead & 128) === 0)
535
+ expected = 1;
536
+ else if ((lead & 224) === 192)
537
+ expected = 2;
538
+ else if ((lead & 240) === 224)
539
+ expected = 3;
540
+ else if ((lead & 248) === 240)
541
+ expected = 4;
542
+ if (end - (leadIndex - 1) < expected)
543
+ end = leadIndex - 1;
544
+ } else if (leadIndex === start && start > 0 && end > start) {
545
+ end = start;
546
+ }
547
+ const slice = buf.subarray(start, end);
548
+ return { text: slice.toString("utf8"), startByte: windowStart + start, endByte: windowStart + end };
549
+ }
550
+ async function removeCommandLog(directory, commandID) {
551
+ try {
552
+ await fs.rm(commandLogFile(directory, commandID), { force: true });
553
+ } catch {}
554
+ }
483
555
  function delay(ms) {
484
556
  return new Promise((resolve) => setTimeout(resolve, ms));
485
557
  }
486
- var CURRENT_VERSION = 6, LOCK_STALE_MS = 1e4;
558
+ var CURRENT_VERSION = 8, LOCK_STALE_MS = 1e4;
487
559
  var init_state_repository = () => {};
488
560
 
561
+ // src/domain/command-await.ts
562
+ function isTerminalCommandStatus(status) {
563
+ return TERMINAL_COMMAND_STATUSES.includes(status);
564
+ }
565
+ function tailLastBytes(text, maxBytes = MAX_AWAIT_TAIL_BYTES) {
566
+ const buf = Buffer.from(text, "utf8");
567
+ if (buf.length <= maxBytes)
568
+ return text;
569
+ return buf.subarray(buf.length - maxBytes).toString("utf8");
570
+ }
571
+ function formatAwaitEvidence(input) {
572
+ const short = input.commandID.slice(0, 8);
573
+ const header = `[command "${input.title}" (${input.argv.join(" ") || input.title}) ${input.status}` + ` ${short}... exitCode=${input.exitCode ?? "unknown"} signal=${input.signal ?? "none"}]`;
574
+ const tail = tailLastBytes(input.tail);
575
+ return tail ? `${header}
576
+ ${tail}` : header;
577
+ }
578
+ var TERMINAL_COMMAND_STATUSES, MAX_AWAIT_TAIL_BYTES;
579
+ var init_command_await = __esm(() => {
580
+ TERMINAL_COMMAND_STATUSES = [
581
+ "exited",
582
+ "terminated",
583
+ "missing"
584
+ ];
585
+ MAX_AWAIT_TAIL_BYTES = 4 * 1024;
586
+ });
587
+
588
+ // src/application/command-await.ts
589
+ import { randomUUID } from "crypto";
590
+ function ledgerEvent(base) {
591
+ return {
592
+ version: 1,
593
+ eventID: randomUUID(),
594
+ timestamp: new Date().toISOString(),
595
+ revision: 0,
596
+ ...base
597
+ };
598
+ }
599
+ async function requestCommandAwait(directory, input) {
600
+ const snapshot = await readState(directory);
601
+ const goal = snapshot.goals.find((g) => g.id === input.goalID);
602
+ if (!goal)
603
+ return { ok: false, message: "Goal not found." };
604
+ const command = (snapshot.commands ?? []).find((c) => c.id === input.commandID);
605
+ if (!command)
606
+ return { ok: false, message: "Command not found." };
607
+ if (input.ownerSessionID !== goal.ownerSessionID || input.ownerSessionID !== command.ownerSessionID) {
608
+ return { ok: false, message: "Owner mismatch: the await requester must own both the goal and the command." };
609
+ }
610
+ if (goal.ownerSessionID !== command.ownerSessionID) {
611
+ return { ok: false, message: "Owner mismatch: goal and command are owned by different sessions." };
612
+ }
613
+ const existing = (snapshot.commandAwaits ?? []).some((a) => a.goalID === input.goalID && a.commandID === input.commandID);
614
+ if (existing) {
615
+ return { ok: true, message: `Already awaiting "${command.title}" for goal "${goal.name}".` };
616
+ }
617
+ if (isTerminalCommandStatus(command.status)) {
618
+ await mutateState(directory, `cmd.await-request:${input.goalID}:${input.commandID}`, async (s) => {
619
+ s.commandAwaits = [...s.commandAwaits ?? [], {
620
+ goalID: input.goalID,
621
+ commandID: input.commandID,
622
+ ownerSessionID: input.ownerSessionID,
623
+ createdAt: new Date().toISOString()
624
+ }];
625
+ return s;
626
+ });
627
+ const fired = await fireCommandAwaits(directory, input.commandID);
628
+ const mine = fired.find((f) => f.goalID === input.goalID);
629
+ return {
630
+ ok: true,
631
+ message: `Command "${command.title}" already ${command.status}; wake-up delivered to goal "${goal.name}".`,
632
+ fired: true,
633
+ active: mine?.active ?? goal.status === "active"
634
+ };
635
+ }
636
+ let becameTerminal = false;
637
+ await mutateState(directory, `cmd.await-request:${input.goalID}:${input.commandID}`, async (s) => {
638
+ const dup = (s.commandAwaits ?? []).some((a) => a.goalID === input.goalID && a.commandID === input.commandID);
639
+ if (!dup) {
640
+ s.commandAwaits = [...s.commandAwaits ?? [], {
641
+ goalID: input.goalID,
642
+ commandID: input.commandID,
643
+ ownerSessionID: input.ownerSessionID,
644
+ createdAt: new Date().toISOString()
645
+ }];
646
+ }
647
+ const current = (s.commands ?? []).find((c) => c.id === input.commandID);
648
+ if (current && isTerminalCommandStatus(current.status)) {
649
+ becameTerminal = true;
650
+ }
651
+ return s;
652
+ });
653
+ if (becameTerminal) {
654
+ const fired = await fireCommandAwaits(directory, input.commandID);
655
+ const mine = fired.find((f) => f.goalID === input.goalID);
656
+ return {
657
+ ok: true,
658
+ message: `Command "${command.title}" exited while registering; wake-up delivered to goal "${goal.name}".`,
659
+ fired: true,
660
+ active: mine?.active ?? goal.status === "active"
661
+ };
662
+ }
663
+ await appendEvent(directory, ledgerEvent({
664
+ goalID: input.goalID,
665
+ commandID: input.commandID,
666
+ type: "command.await-requested"
667
+ })).catch(() => {});
668
+ return { ok: true, message: `Goal "${goal.name}" now awaits "${command.title}" (fires once on exit).` };
669
+ }
670
+ async function readBoundedTail(directory, commandID) {
671
+ try {
672
+ const probe = await readCommandLog(directory, commandID, { offsetBytes: 0, limitBytes: 0 });
673
+ if (probe.totalBytes <= MAX_AWAIT_TAIL_BYTES) {
674
+ const full = await readCommandLog(directory, commandID, {
675
+ offsetBytes: 0,
676
+ limitBytes: MAX_AWAIT_TAIL_BYTES
677
+ });
678
+ return full.text;
679
+ }
680
+ const tail = await readCommandLog(directory, commandID, {
681
+ offsetBytes: Math.max(0, probe.totalBytes - MAX_AWAIT_TAIL_BYTES),
682
+ limitBytes: MAX_AWAIT_TAIL_BYTES
683
+ });
684
+ return tail.text;
685
+ } catch {
686
+ return "";
687
+ }
688
+ }
689
+ async function fireCommandAwaits(directory, commandID) {
690
+ let consumed = [];
691
+ let command;
692
+ let commandGone = false;
693
+ await mutateState(directory, `cmd.await-consume:${commandID}`, async (s) => {
694
+ const outstanding = (s.commandAwaits ?? []).filter((a) => a.commandID === commandID);
695
+ if (outstanding.length === 0)
696
+ return s;
697
+ const current = (s.commands ?? []).find((c) => c.id === commandID);
698
+ if (!current) {
699
+ commandGone = true;
700
+ consumed = outstanding;
701
+ s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.commandID !== commandID);
702
+ return s;
703
+ }
704
+ if (!isTerminalCommandStatus(current.status))
705
+ return s;
706
+ command = current;
707
+ consumed = outstanding;
708
+ s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.commandID !== commandID);
709
+ return s;
710
+ });
711
+ if (consumed.length === 0)
712
+ return [];
713
+ if (commandGone || !command) {
714
+ for (const a of consumed) {
715
+ await appendEvent(directory, ledgerEvent({
716
+ goalID: a.goalID,
717
+ commandID,
718
+ type: "command.await-discarded",
719
+ reason: "command record gone"
720
+ })).catch(() => {});
721
+ }
722
+ return [];
723
+ }
724
+ const tail = await readBoundedTail(directory, commandID);
725
+ const fired = [];
726
+ for (const a of consumed) {
727
+ const fresh = await readState(directory);
728
+ const goal = fresh.goals.find((g) => g.id === a.goalID);
729
+ if (!goal) {
730
+ await appendEvent(directory, ledgerEvent({
731
+ goalID: a.goalID,
732
+ commandID,
733
+ type: "command.await-discarded",
734
+ reason: "goal gone"
735
+ })).catch(() => {});
736
+ continue;
737
+ }
738
+ const evidence = formatAwaitEvidence({
739
+ title: command.title,
740
+ argv: [command.command, ...command.args],
741
+ commandID: command.id,
742
+ status: command.status,
743
+ exitCode: command.exitCode,
744
+ signal: command.signal,
745
+ tail
746
+ });
747
+ await appendGoalInbox(directory, a.goalID, "worker", evidence);
748
+ await appendEvent(directory, ledgerEvent({
749
+ goalID: a.goalID,
750
+ commandID,
751
+ type: "command.await-fired",
752
+ status: command.status,
753
+ exitCode: command.exitCode,
754
+ signal: command.signal
755
+ })).catch(() => {});
756
+ fired.push({ goalID: a.goalID, commandID, active: goal.status === "active" });
757
+ }
758
+ return fired;
759
+ }
760
+ async function cancelAwaitsForGoal(directory, goalID, reason) {
761
+ const snapshot = await readState(directory);
762
+ const doomed = (snapshot.commandAwaits ?? []).filter((a) => a.goalID === goalID);
763
+ if (doomed.length === 0)
764
+ return 0;
765
+ await mutateState(directory, `cmd.await-cancel-goal:${goalID}`, async (s) => {
766
+ s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.goalID !== goalID);
767
+ return s;
768
+ });
769
+ await appendEvent(directory, ledgerEvent({
770
+ goalID,
771
+ type: "command.await-cancelled",
772
+ reason,
773
+ count: doomed.length
774
+ })).catch(() => {});
775
+ return doomed.length;
776
+ }
777
+ async function clearAwaitsForCommand(directory, commandID, reason) {
778
+ const snapshot = await readState(directory);
779
+ const doomed = (snapshot.commandAwaits ?? []).filter((a) => a.commandID === commandID);
780
+ if (doomed.length === 0)
781
+ return 0;
782
+ await mutateState(directory, `cmd.await-clear-command:${commandID}`, async (s) => {
783
+ s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.commandID !== commandID);
784
+ return s;
785
+ });
786
+ for (const a of doomed) {
787
+ await appendEvent(directory, ledgerEvent({
788
+ goalID: a.goalID,
789
+ commandID,
790
+ type: "command.await-cancelled",
791
+ reason
792
+ })).catch(() => {});
793
+ }
794
+ return doomed.length;
795
+ }
796
+ async function reconcileCommandAwaits(directory) {
797
+ const snapshot = await readState(directory);
798
+ const outstanding = [...snapshot.commandAwaits ?? []];
799
+ const fired = [];
800
+ for (const a of outstanding) {
801
+ const fresh = await readState(directory);
802
+ const still = (fresh.commandAwaits ?? []).some((x) => x.goalID === a.goalID && x.commandID === a.commandID);
803
+ if (!still)
804
+ continue;
805
+ const command = (fresh.commands ?? []).find((c) => c.id === a.commandID);
806
+ if (!command) {
807
+ await mutateState(directory, `cmd.await-reconcile-discard:${a.commandID}`, async (s) => {
808
+ s.commandAwaits = (s.commandAwaits ?? []).filter((x) => !(x.goalID === a.goalID && x.commandID === a.commandID));
809
+ return s;
810
+ });
811
+ await appendEvent(directory, ledgerEvent({
812
+ goalID: a.goalID,
813
+ commandID: a.commandID,
814
+ type: "command.await-discarded",
815
+ reason: "command record gone at reconcile"
816
+ })).catch(() => {});
817
+ continue;
818
+ }
819
+ if (!isTerminalCommandStatus(command.status))
820
+ continue;
821
+ const now = await fireCommandAwaits(directory, a.commandID);
822
+ fired.push(...now.filter((f) => f.goalID === a.goalID));
823
+ }
824
+ return fired;
825
+ }
826
+ async function wakeGoalForAwait(directory, continuation, goalID) {
827
+ const snapshot = await readState(directory);
828
+ const goal = snapshot.goals.find((g) => g.id === goalID);
829
+ if (!goal)
830
+ return { woke: false, message: "Goal gone; wake skipped." };
831
+ if (goal.status !== "active") {
832
+ return { woke: false, message: `Goal is ${goal.status}; evidence waits in pendingInbox.` };
833
+ }
834
+ await mutateState(directory, `cmd.await-wake:${goalID}`, async (s) => {
835
+ const rt = s.runtimes.find((r) => r.goalID === goalID);
836
+ if (!rt)
837
+ return s;
838
+ rt.phase = "idle";
839
+ rt.activeRunID = undefined;
840
+ rt.idleCandidateAt = undefined;
841
+ rt.activePromptMessageID = undefined;
842
+ rt.activeToolCallIDs = [];
843
+ rt.updatedAt = new Date().toISOString();
844
+ return s;
845
+ });
846
+ await continuation.continueTurn(directory, goalID, { force: true });
847
+ return { woke: true, message: `Woke goal "${goal.name}" for command exit.` };
848
+ }
849
+ var init_command_await2 = __esm(() => {
850
+ init_command_await();
851
+ init_state_repository();
852
+ });
853
+
489
854
  // src/server/plugin.ts
490
855
  import { tool as v1Tool } from "@opencode-ai/plugin/tool";
491
856
 
492
857
  // src/application/control-worker.ts
493
858
  init_state_repository();
494
- import { randomUUID } from "crypto";
859
+ import { randomUUID as randomUUID2 } from "crypto";
495
860
 
496
861
  // src/application/goal-policy.ts
497
862
  function resolveGoalCreationConfig(input) {
@@ -600,6 +965,7 @@ function errorReplacer(_key, value) {
600
965
  }
601
966
 
602
967
  // src/application/control-worker.ts
968
+ init_command_await2();
603
969
  var MAX_LEDGER_SIZE = 100;
604
970
  var RESPONSE_CLEANUP_AGE_MS = 60 * 60 * 1000;
605
971
  function createControlWorker(options) {
@@ -863,7 +1229,7 @@ function createControlWorker(options) {
863
1229
  await writeState(directory, state);
864
1230
  await appendEvent(directory, {
865
1231
  version: 1,
866
- eventID: randomUUID(),
1232
+ eventID: randomUUID2(),
867
1233
  goalID: goal.id,
868
1234
  type: "goal.completed",
869
1235
  summary: goal.completionEvidence.summary,
@@ -904,7 +1270,7 @@ function createControlWorker(options) {
904
1270
  await writeState(directory, state);
905
1271
  await appendEvent(directory, {
906
1272
  version: 1,
907
- eventID: randomUUID(),
1273
+ eventID: randomUUID2(),
908
1274
  goalID: goal.id,
909
1275
  type: "goal.blocked",
910
1276
  reason: goal.blocker.reason,
@@ -915,6 +1281,103 @@ function createControlWorker(options) {
915
1281
  response = { ...base, message: `goal "${goal.name}" blocked`, stateRevision: state.revision };
916
1282
  break;
917
1283
  }
1284
+ case "cmd_await": {
1285
+ const args = request.args ?? {};
1286
+ const ownerSessionID = typeof args.ownerSessionID === "string" ? args.ownerSessionID : "";
1287
+ if (!ownerSessionID || ownerSessionID === "main") {
1288
+ response = { ...base, ok: false, message: "ownerSessionID is required for command operations", errorCode: "no_session" };
1289
+ break;
1290
+ }
1291
+ const goalID = typeof args.goalID === "string" ? args.goalID : "";
1292
+ const commandID = typeof args.commandID === "string" && args.commandID ? String(args.commandID) : String(request.goalID || "");
1293
+ if (!goalID || !commandID) {
1294
+ response = { ...base, ok: false, message: "goalID and commandID are required", errorCode: "bad_request" };
1295
+ break;
1296
+ }
1297
+ const result = await requestCommandAwait(directory, { goalID, commandID, ownerSessionID });
1298
+ if (result.ok && result.fired && result.active) {
1299
+ await wakeGoalForAwait(directory, goalSvc, goalID).catch(() => {});
1300
+ }
1301
+ const state = await readState(directory);
1302
+ response = {
1303
+ ...base,
1304
+ ok: result.ok,
1305
+ message: result.message,
1306
+ stateRevision: state.revision,
1307
+ errorCode: result.ok ? undefined : "await_failed"
1308
+ };
1309
+ break;
1310
+ }
1311
+ case "cmd_start":
1312
+ case "cmd_write":
1313
+ case "cmd_interrupt":
1314
+ case "cmd_terminate":
1315
+ case "cmd_remove":
1316
+ case "cmd_resize": {
1317
+ const cmdSvc = options.commandService;
1318
+ if (!cmdSvc) {
1319
+ response = {
1320
+ requestID: request.requestID,
1321
+ ok: false,
1322
+ message: `command "${request.command}" unavailable (command service not initialized)`,
1323
+ errorCode: "unavailable",
1324
+ completedAt: new Date().toISOString()
1325
+ };
1326
+ break;
1327
+ }
1328
+ const args = request.args ?? {};
1329
+ const ownerSessionID = typeof args.ownerSessionID === "string" ? args.ownerSessionID : "";
1330
+ if (!ownerSessionID || ownerSessionID === "main") {
1331
+ response = { ...base, ok: false, message: "ownerSessionID is required for command operations", errorCode: "no_session" };
1332
+ break;
1333
+ }
1334
+ try {
1335
+ if (request.command === "cmd_start") {
1336
+ const session = await cmdSvc.start(directory, {
1337
+ title: String(args.title || "command"),
1338
+ command: String(args.command || ""),
1339
+ args: Array.isArray(args.cmdArgs) ? args.cmdArgs : [],
1340
+ cwd: typeof args.cwd === "string" ? args.cwd : undefined,
1341
+ ownerSessionID,
1342
+ goalID: typeof args.goalID === "string" ? args.goalID : undefined,
1343
+ cols: typeof args.cols === "number" ? args.cols : undefined,
1344
+ rows: typeof args.rows === "number" ? args.rows : undefined
1345
+ });
1346
+ const state = await readState(directory);
1347
+ response = { ...base, message: `command "${session.title}" started (${session.id.slice(0, 8)}...)`, stateRevision: state.revision };
1348
+ } else {
1349
+ const id = String(args.commandID || request.goalID || "");
1350
+ if (!id) {
1351
+ response = { ...base, ok: false, message: "commandID is required", errorCode: "bad_request" };
1352
+ break;
1353
+ }
1354
+ if (request.command === "cmd_write") {
1355
+ const r = await cmdSvc.write(directory, id, ownerSessionID, String(args.input ?? ""));
1356
+ const state = await readState(directory);
1357
+ response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1358
+ } else if (request.command === "cmd_interrupt") {
1359
+ const r = await cmdSvc.interrupt(directory, id, ownerSessionID);
1360
+ const state = await readState(directory);
1361
+ response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1362
+ } else if (request.command === "cmd_terminate") {
1363
+ const r = await cmdSvc.terminate(directory, id, ownerSessionID);
1364
+ const state = await readState(directory);
1365
+ response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1366
+ } else if (request.command === "cmd_remove") {
1367
+ const r = await cmdSvc.remove(directory, id, ownerSessionID);
1368
+ const state = await readState(directory);
1369
+ response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1370
+ } else {
1371
+ const r = await cmdSvc.resize(directory, id, ownerSessionID, Number(args.cols), Number(args.rows));
1372
+ const state = await readState(directory);
1373
+ response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1374
+ }
1375
+ }
1376
+ } catch (error) {
1377
+ response = { ...base, ok: false, message: error instanceof Error ? error.message : String(error), errorCode: "command_failed" };
1378
+ }
1379
+ break;
1380
+ }
918
1381
  default: {
919
1382
  response = {
920
1383
  requestID: request.requestID,
@@ -929,20 +1392,23 @@ function createControlWorker(options) {
929
1392
  return response;
930
1393
  }
931
1394
  async function recordInLedger(directory, request) {
932
- const state = await readState(directory);
933
- if (!state.commandLedger)
934
- state.commandLedger = [];
935
- state.commandLedger.push({
936
- requestID: request.requestID,
937
- command: request.command,
938
- goalID: request.goalID,
939
- acceptedAt: request.requestedAt,
940
- completedAt: new Date().toISOString()
1395
+ await mutateState(directory, `control.ledger:${request.requestID}`, async (state) => {
1396
+ if (!state.commandLedger)
1397
+ state.commandLedger = [];
1398
+ if (state.commandLedger.some((entry) => entry.requestID === request.requestID))
1399
+ return state;
1400
+ state.commandLedger.push({
1401
+ requestID: request.requestID,
1402
+ command: request.command,
1403
+ goalID: request.goalID,
1404
+ acceptedAt: request.requestedAt,
1405
+ completedAt: new Date().toISOString()
1406
+ });
1407
+ if (state.commandLedger.length > MAX_LEDGER_SIZE) {
1408
+ state.commandLedger = state.commandLedger.slice(-MAX_LEDGER_SIZE);
1409
+ }
1410
+ return state;
941
1411
  });
942
- if (state.commandLedger.length > MAX_LEDGER_SIZE) {
943
- state.commandLedger = state.commandLedger.slice(-MAX_LEDGER_SIZE);
944
- }
945
- await writeState(directory, state);
946
1412
  }
947
1413
  return { start, stop: async () => {
948
1414
  await stop();
@@ -951,7 +1417,7 @@ function createControlWorker(options) {
951
1417
 
952
1418
  // src/application/loop-engine.ts
953
1419
  init_state_repository();
954
- import { randomUUID as randomUUID2 } from "crypto";
1420
+ import { randomUUID as randomUUID3 } from "crypto";
955
1421
 
956
1422
  // src/domain/goal.ts
957
1423
  var MODEL_TRANSITIONS = {
@@ -996,6 +1462,7 @@ var HANDLED_EVENT_TYPES = new Set([
996
1462
  "session.status",
997
1463
  "session.error",
998
1464
  "session.compacted",
1465
+ "session.execution.succeeded",
999
1466
  "message.updated",
1000
1467
  "message.part.updated"
1001
1468
  ]);
@@ -1100,6 +1567,8 @@ function createLoopEngine(options) {
1100
1567
  case "message.updated":
1101
1568
  case "message.part.updated":
1102
1569
  return await handleMessageActivity(goal, event);
1570
+ case "session.execution.succeeded":
1571
+ return await handleExecutionSucceeded(goal);
1103
1572
  default:
1104
1573
  return false;
1105
1574
  }
@@ -1140,6 +1609,19 @@ function createLoopEngine(options) {
1140
1609
  });
1141
1610
  return matched;
1142
1611
  }
1612
+ async function handleExecutionSucceeded(goal) {
1613
+ let matched = false;
1614
+ await mutateState(directory, `execution-succeeded:${goal.id}`, async (s) => {
1615
+ const rt = s.runtimes.find((r) => r.goalID === goal.id);
1616
+ if (!rt || rt.phase !== "running" || !rt.activePromptMessageID)
1617
+ return s;
1618
+ Object.assign(rt, recordActivity(rt));
1619
+ rt.activeAssistantCompletedAt = new Date().toISOString();
1620
+ matched = true;
1621
+ return s;
1622
+ });
1623
+ return matched;
1624
+ }
1143
1625
  async function handleSessionIdle(state, goal) {
1144
1626
  const goalID = goal.id;
1145
1627
  if (inflightContinuations.has(goalID))
@@ -1215,7 +1697,7 @@ function createLoopEngine(options) {
1215
1697
  if (newlyStamped) {
1216
1698
  await appendEvent(directory, {
1217
1699
  version: 1,
1218
- eventID: randomUUID2(),
1700
+ eventID: randomUUID3(),
1219
1701
  goalID,
1220
1702
  type: "idle.confirm-failed",
1221
1703
  reason: failureReason,
@@ -1260,7 +1742,7 @@ function createLoopEngine(options) {
1260
1742
  if (completedRunID) {
1261
1743
  await appendEvent(directory, {
1262
1744
  version: 1,
1263
- eventID: randomUUID2(),
1745
+ eventID: randomUUID3(),
1264
1746
  goalID,
1265
1747
  type: "run.completed",
1266
1748
  runID: completedRunID,
@@ -1325,7 +1807,7 @@ function createLoopEngine(options) {
1325
1807
  });
1326
1808
  await appendEvent(directory, {
1327
1809
  version: 1,
1328
- eventID: randomUUID2(),
1810
+ eventID: randomUUID3(),
1329
1811
  goalID,
1330
1812
  type: "goal.blocked",
1331
1813
  reason: limitResult.reason + " (force-finish ignored)",
@@ -1349,7 +1831,7 @@ function createLoopEngine(options) {
1349
1831
  });
1350
1832
  await appendEvent(directory, {
1351
1833
  version: 1,
1352
- eventID: randomUUID2(),
1834
+ eventID: randomUUID3(),
1353
1835
  goalID,
1354
1836
  type: "goal.status_changed",
1355
1837
  from: "active",
@@ -1451,7 +1933,7 @@ function createLoopEngine(options) {
1451
1933
  });
1452
1934
  await appendEvent(directory, {
1453
1935
  version: 1,
1454
- eventID: randomUUID2(),
1936
+ eventID: randomUUID3(),
1455
1937
  goalID: goal.id,
1456
1938
  type: "run.failed",
1457
1939
  runID: runtime.activeRunID || "unknown",
@@ -1464,7 +1946,7 @@ function createLoopEngine(options) {
1464
1946
  if (updatedGoal?.status === "blocked") {
1465
1947
  await appendEvent(directory, {
1466
1948
  version: 1,
1467
- eventID: randomUUID2(),
1949
+ eventID: randomUUID3(),
1468
1950
  goalID: goal.id,
1469
1951
  type: "goal.blocked",
1470
1952
  reason: `Failed ${runtime.consecutiveFailures + 1} times`,
@@ -1492,7 +1974,7 @@ function createLoopEngine(options) {
1492
1974
  });
1493
1975
  await appendEvent(directory, {
1494
1976
  version: 1,
1495
- eventID: randomUUID2(),
1977
+ eventID: randomUUID3(),
1496
1978
  goalID: goal.id,
1497
1979
  type: "compaction.completed",
1498
1980
  timestamp: new Date().toISOString(),
@@ -1562,7 +2044,7 @@ function createLoopEngine(options) {
1562
2044
  });
1563
2045
  await appendEvent(directory, {
1564
2046
  version: 1,
1565
- eventID: randomUUID2(),
2047
+ eventID: randomUUID3(),
1566
2048
  goalID: goal.id,
1567
2049
  type: "compaction.started",
1568
2050
  timestamp: new Date().toISOString(),
@@ -1620,7 +2102,7 @@ function createLoopEngine(options) {
1620
2102
  return false;
1621
2103
  await appendEvent(directory, {
1622
2104
  version: 1,
1623
- eventID: randomUUID2(),
2105
+ eventID: randomUUID3(),
1624
2106
  goalID: goal.id,
1625
2107
  type: "goal.status_changed",
1626
2108
  from: "active",
@@ -1796,7 +2278,7 @@ function createLoopEngine(options) {
1796
2278
  if (recovered) {
1797
2279
  await appendEvent(directory, {
1798
2280
  version: 1,
1799
- eventID: randomUUID2(),
2281
+ eventID: randomUUID3(),
1800
2282
  goalID: goal.id,
1801
2283
  type: "run.recovered",
1802
2284
  runID: stalledRunID ?? "unknown",
@@ -1830,7 +2312,7 @@ function createLoopEngine(options) {
1830
2312
  const stuckSeconds = Math.floor((Date.now() - Date.parse(failedAt)) / 1000);
1831
2313
  await appendEvent(directory, {
1832
2314
  version: 1,
1833
- eventID: randomUUID2(),
2315
+ eventID: randomUUID3(),
1834
2316
  goalID: goal.id,
1835
2317
  type: "run.stuck",
1836
2318
  runID: runtime.activeRunID ?? "unknown",
@@ -1870,7 +2352,7 @@ function createLoopEngine(options) {
1870
2352
  });
1871
2353
  await appendEvent(directory, {
1872
2354
  version: 1,
1873
- eventID: randomUUID2(),
2355
+ eventID: randomUUID3(),
1874
2356
  goalID: goal.id,
1875
2357
  type: "run.stuck",
1876
2358
  runID: runtime.activeRunID,
@@ -1893,12 +2375,14 @@ function createLoopEngine(options) {
1893
2375
  }
1894
2376
 
1895
2377
  // src/application/goal-service.ts
1896
- import { randomUUID as randomUUID3 } from "crypto";
2378
+ import { randomUUID as randomUUID5 } from "crypto";
1897
2379
  init_state_repository();
2380
+ init_command_await2();
1898
2381
  import * as path2 from "path";
1899
2382
  import { promises as fs2 } from "fs";
1900
2383
 
1901
2384
  // src/server/host-adapter.ts
2385
+ import { randomUUID as randomUUID4 } from "crypto";
1902
2386
  function parseModelRef(value) {
1903
2387
  if (value === undefined)
1904
2388
  return;
@@ -1916,6 +2400,12 @@ function parseModelRef(value) {
1916
2400
  }
1917
2401
  return { providerID, modelID };
1918
2402
  }
2403
+ function newPromptMessageID() {
2404
+ return `msg_${randomUUID4()}`;
2405
+ }
2406
+ function isV2PromptMessageID(messageID) {
2407
+ return messageID.startsWith("msg_");
2408
+ }
1919
2409
  var recentParentNotifies = new Map;
1920
2410
  function shouldDedupParentNotify(ownerSessionID, message) {
1921
2411
  const key = `${ownerSessionID}:${message.slice(0, 200)}`;
@@ -1962,8 +2452,7 @@ function createRealHost(client, directory) {
1962
2452
  parts: [{ type: "text", text: prompt }]
1963
2453
  };
1964
2454
  if (messageID) {
1965
- const collapsed = messageID.replace(/^(msg-)+/, "msg-");
1966
- body.messageID = collapsed.startsWith("msg-") ? collapsed : `msg-${messageID}`;
2455
+ body.messageID = /^msg[-_]/.test(messageID) ? messageID : `msg_${messageID}`;
1967
2456
  }
1968
2457
  if (model)
1969
2458
  body.model = model;
@@ -2131,6 +2620,9 @@ function createV2Host(context, statuses) {
2131
2620
  model: { id: model.modelID, providerID: model.providerID }
2132
2621
  });
2133
2622
  }
2623
+ if (messageID !== undefined && !isV2PromptMessageID(messageID)) {
2624
+ throw new Error(`loopd prompt ID "${messageID}" is invalid for OpenCode v2: must start with "msg_". ` + `Generate IDs with newPromptMessageID() so persisted, delivered, and correlated IDs agree.`);
2625
+ }
2134
2626
  const result = await context.session.prompt({
2135
2627
  sessionID,
2136
2628
  id: messageID,
@@ -2138,7 +2630,7 @@ function createV2Host(context, statuses) {
2138
2630
  });
2139
2631
  statuses.set(sessionID, "busy");
2140
2632
  await logServerEvent(directory, "worker.prompted", { sessionID });
2141
- return { messageID: result.id };
2633
+ return { messageID: result?.id };
2142
2634
  },
2143
2635
  async readSession(sessionID) {
2144
2636
  try {
@@ -2370,6 +2862,18 @@ function buildContinuationSteering(goal, runtime, context) {
2370
2862
  }
2371
2863
 
2372
2864
  // src/application/goal-service.ts
2865
+ class GoalStartError extends Error {
2866
+ goalID;
2867
+ workerSessionID;
2868
+ failedStage;
2869
+ constructor(message, info) {
2870
+ super(message);
2871
+ this.name = "GoalStartError";
2872
+ this.goalID = info.goalID;
2873
+ this.workerSessionID = info.workerSessionID;
2874
+ this.failedStage = info.failedStage;
2875
+ }
2876
+ }
2373
2877
  function createGoalService(host) {
2374
2878
  const workers = createWorkerManager(host);
2375
2879
  const sessions = new Map;
@@ -2391,12 +2895,13 @@ function createGoalService(host) {
2391
2895
  goalOperations.delete(goalID);
2392
2896
  }
2393
2897
  }
2394
- function assertWorkspaceWriteAvailable(state, goal) {
2898
+ function assertWorkspaceWriteAvailable(state, goal, requesterSessionID) {
2395
2899
  if (!goal.config.workspaceWrite)
2396
2900
  return;
2397
2901
  const activeWriter = state.goals.find((item) => item.id !== goal.id && item.status === "active" && item.config.workspaceWrite);
2398
2902
  if (activeWriter) {
2399
- throw new Error(`Workspace-writing goal "${activeWriter.name}" (${activeWriter.id}) is already active. ` + "Pause, block, complete, or clear it before activating another workspace-writing goal.");
2903
+ const ownedElsewhere = requesterSessionID !== undefined && activeWriter.ownerSessionID !== requesterSessionID;
2904
+ 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." : ""));
2400
2905
  }
2401
2906
  }
2402
2907
  async function recordPromptFailure(directory, goalID, error, blockImmediately = false) {
@@ -2436,7 +2941,7 @@ function createGoalService(host) {
2436
2941
  });
2437
2942
  await appendEvent(directory, {
2438
2943
  version: 1,
2439
- eventID: randomUUID3(),
2944
+ eventID: randomUUID5(),
2440
2945
  goalID,
2441
2946
  type: "run.failed",
2442
2947
  runID,
@@ -2448,7 +2953,7 @@ function createGoalService(host) {
2448
2953
  if (blocked) {
2449
2954
  await appendEvent(directory, {
2450
2955
  version: 1,
2451
- eventID: randomUUID3(),
2956
+ eventID: randomUUID5(),
2452
2957
  goalID,
2453
2958
  type: "goal.blocked",
2454
2959
  reason: `Worker prompt delivery failed: ${detail}`,
@@ -2482,7 +2987,7 @@ function createGoalService(host) {
2482
2987
  return session;
2483
2988
  }
2484
2989
  async function start(directory, input) {
2485
- const id = randomUUID3();
2990
+ const id = randomUUID5();
2486
2991
  return withGoalOperation(id, () => startUnlocked(directory, input, id));
2487
2992
  }
2488
2993
  async function startUnlocked(directory, input, id) {
@@ -2521,7 +3026,7 @@ function createGoalService(host) {
2521
3026
  goal.config.progressFile = path2.join(artifactDir, "progress.md");
2522
3027
  await ensureGoalArtifactDir(directory, id);
2523
3028
  const state1 = await mutateState(directory, `goal.create:${id}`, async (state) => {
2524
- assertWorkspaceWriteAvailable(state, goal);
3029
+ assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
2525
3030
  state.goals.push(goal);
2526
3031
  const rt = createRuntimeState(id);
2527
3032
  if (goal.config.schedule) {
@@ -2562,7 +3067,7 @@ function createGoalService(host) {
2562
3067
  });
2563
3068
  await appendEvent(directory, {
2564
3069
  version: 1,
2565
- eventID: randomUUID3(),
3070
+ eventID: randomUUID5(),
2566
3071
  goalID: id,
2567
3072
  type: "goal.blocked",
2568
3073
  reason: detail,
@@ -2571,7 +3076,7 @@ function createGoalService(host) {
2571
3076
  revision: blockedState.revision
2572
3077
  });
2573
3078
  await logServerEvent(directory, "goal.start.failed", { goalID: id, ownerSessionID: input.ownerSessionID, detail });
2574
- throw error;
3079
+ throw new GoalStartError(detail, { goalID: id, failedStage: "worker_create" });
2575
3080
  }
2576
3081
  sessions.set(id, worker);
2577
3082
  const state2 = await mutateState(directory, `goal.worker-assign:${id}`, async (state) => {
@@ -2583,8 +3088,8 @@ function createGoalService(host) {
2583
3088
  const rt = state.runtimes.find((item) => item.goalID === id);
2584
3089
  if (rt) {
2585
3090
  Object.assign(rt, acquireLease(rt, g.config.timeoutMs || 300000));
2586
- rt.activeRunID = randomUUID3();
2587
- rt.activePromptMessageID = `msg-${randomUUID3()}`;
3091
+ rt.activeRunID = randomUUID5();
3092
+ rt.activePromptMessageID = newPromptMessageID();
2588
3093
  rt.runCount = 1;
2589
3094
  rt.budgetTurnCount = 1;
2590
3095
  rt.lastRunAt = new Date().toISOString();
@@ -2594,7 +3099,7 @@ function createGoalService(host) {
2594
3099
  runtime = state2.runtimes.find((r) => r.goalID === id);
2595
3100
  await appendEvent(directory, {
2596
3101
  version: 1,
2597
- eventID: randomUUID3(),
3102
+ eventID: randomUUID5(),
2598
3103
  goalID: id,
2599
3104
  type: "goal.created",
2600
3105
  name: input.name,
@@ -2606,7 +3111,7 @@ function createGoalService(host) {
2606
3111
  if (runtime) {
2607
3112
  await appendEvent(directory, {
2608
3113
  version: 1,
2609
- eventID: randomUUID3(),
3114
+ eventID: randomUUID5(),
2610
3115
  goalID: id,
2611
3116
  type: "run.started",
2612
3117
  runID: runtime.activeRunID,
@@ -2618,7 +3123,11 @@ function createGoalService(host) {
2618
3123
  await workers.continueWorker(worker, goal, runtime);
2619
3124
  } catch (error) {
2620
3125
  await recordPromptFailure(directory, id, error, true);
2621
- throw error;
3126
+ throw new GoalStartError(describeError(error), {
3127
+ goalID: id,
3128
+ workerSessionID: worker.workerSessionID,
3129
+ failedStage: "prompt_delivery"
3130
+ });
2622
3131
  }
2623
3132
  }
2624
3133
  return { goal, worker };
@@ -2715,8 +3224,8 @@ function createGoalService(host) {
2715
3224
  return s;
2716
3225
  const timeoutMs = g.config.timeoutMs || 300000;
2717
3226
  Object.assign(rt, acquireLease(rt, timeoutMs));
2718
- rt.activeRunID = randomUUID3();
2719
- rt.activePromptMessageID = `msg-${randomUUID3()}`;
3227
+ rt.activeRunID = randomUUID5();
3228
+ rt.activePromptMessageID = newPromptMessageID();
2720
3229
  rt.runCount += 1;
2721
3230
  if (rt.freeRetryPending) {
2722
3231
  rt.freeRetryPending = false;
@@ -2733,7 +3242,7 @@ function createGoalService(host) {
2733
3242
  return;
2734
3243
  await appendEvent(directory, {
2735
3244
  version: 1,
2736
- eventID: randomUUID3(),
3245
+ eventID: randomUUID5(),
2737
3246
  goalID,
2738
3247
  type: "run.started",
2739
3248
  runID: freshRuntime.activeRunID,
@@ -2819,8 +3328,10 @@ function createGoalService(host) {
2819
3328
  g.status = "paused";
2820
3329
  g.updatedAt = new Date().toISOString();
2821
3330
  const rt = s.runtimes.find((r) => r.goalID === goalID);
2822
- if (rt)
3331
+ if (rt) {
2823
3332
  Object.assign(rt, releaseLease(rt));
3333
+ rt.activeRunID = undefined;
3334
+ }
2824
3335
  return s;
2825
3336
  });
2826
3337
  const session = sessions.get(goalID) || (goal.workerSessionID ? {
@@ -2832,9 +3343,10 @@ function createGoalService(host) {
2832
3343
  await workers.abortWorker(session.workerSessionID);
2833
3344
  sessions.delete(goalID);
2834
3345
  }
3346
+ await cancelAwaitsForGoal(directory, goalID, "goal paused").catch(() => {});
2835
3347
  await appendEvent(directory, {
2836
3348
  version: 1,
2837
- eventID: randomUUID3(),
3349
+ eventID: randomUUID5(),
2838
3350
  goalID,
2839
3351
  type: "goal.status_changed",
2840
3352
  from: "active",
@@ -2851,7 +3363,7 @@ function createGoalService(host) {
2851
3363
  return state;
2852
3364
  if (!canTransition(goal.status, "active", "user"))
2853
3365
  return state;
2854
- assertWorkspaceWriteAvailable(state, goal);
3366
+ assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
2855
3367
  goal.status = "active";
2856
3368
  goal.updatedAt = new Date().toISOString();
2857
3369
  resumed = true;
@@ -2873,7 +3385,7 @@ function createGoalService(host) {
2873
3385
  }
2874
3386
  await appendEvent(directory, {
2875
3387
  version: 1,
2876
- eventID: randomUUID3(),
3388
+ eventID: randomUUID5(),
2877
3389
  goalID,
2878
3390
  type: "goal.status_changed",
2879
3391
  from: "paused",
@@ -2889,7 +3401,7 @@ function createGoalService(host) {
2889
3401
  const goal = state.goals.find((g) => g.id === goalID);
2890
3402
  if (!goal || goal.status !== "blocked")
2891
3403
  return state;
2892
- assertWorkspaceWriteAvailable(state, goal);
3404
+ assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
2893
3405
  goal.status = "active";
2894
3406
  goal.updatedAt = new Date().toISOString();
2895
3407
  retried = true;
@@ -2925,7 +3437,7 @@ function createGoalService(host) {
2925
3437
  }
2926
3438
  await appendEvent(directory, {
2927
3439
  version: 1,
2928
- eventID: randomUUID3(),
3440
+ eventID: randomUUID5(),
2929
3441
  goalID,
2930
3442
  type: "goal.status_changed",
2931
3443
  from: "blocked",
@@ -2952,11 +3464,12 @@ function createGoalService(host) {
2952
3464
  await mutateState(directory, `goal.clear:${goalID}`, async (s) => {
2953
3465
  s.goals = s.goals.filter((g) => g.id !== goalID);
2954
3466
  s.runtimes = s.runtimes.filter((r) => r.goalID !== goalID);
3467
+ s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.goalID !== goalID);
2955
3468
  return s;
2956
3469
  });
2957
3470
  await appendEvent(directory, {
2958
3471
  version: 1,
2959
- eventID: randomUUID3(),
3472
+ eventID: randomUUID5(),
2960
3473
  goalID,
2961
3474
  type: "goal.cleared",
2962
3475
  timestamp: new Date().toISOString(),
@@ -3164,7 +3677,7 @@ function createGoalService(host) {
3164
3677
 
3165
3678
  // src/application/schedule-worker.ts
3166
3679
  init_state_repository();
3167
- import { randomUUID as randomUUID4 } from "crypto";
3680
+ import { randomUUID as randomUUID6 } from "crypto";
3168
3681
  function createScheduleWorker(options) {
3169
3682
  const { directory, goalService } = options;
3170
3683
  const intervalMs = options.intervalMs ?? 5000;
@@ -3252,7 +3765,7 @@ function createScheduleWorker(options) {
3252
3765
  continue;
3253
3766
  await appendEvent(directory, {
3254
3767
  version: 1,
3255
- eventID: randomUUID4(),
3768
+ eventID: randomUUID6(),
3256
3769
  goalID: goal.id,
3257
3770
  type: "schedule.tick",
3258
3771
  scheduleRunCount: count,
@@ -3277,71 +3790,1526 @@ function createScheduleWorker(options) {
3277
3790
  return { start, stop, isRunning, tick };
3278
3791
  }
3279
3792
 
3280
- // src/server/goal-tools.ts
3281
- init_state_repository();
3282
- import { randomUUID as randomUUID5 } from "crypto";
3283
- import { tool } from "@opencode-ai/plugin/tool";
3284
- // src/domain/verification.ts
3285
- var MAX_RECENT_ATTEMPTS = 10;
3286
- function appendVerificationAttempt(recent, attempt) {
3287
- const next = [...recent, attempt];
3288
- if (next.length > MAX_RECENT_ATTEMPTS) {
3289
- return next.slice(next.length - MAX_RECENT_ATTEMPTS);
3793
+ // src/server/command-host.ts
3794
+ import { createRequire } from "module";
3795
+ var COMMAND_HOST_CAPABILITIES = {
3796
+ spawn: true,
3797
+ write: true,
3798
+ interruptSignal: true,
3799
+ terminate: true,
3800
+ resize: false,
3801
+ terminalEmulation: false
3802
+ };
3803
+ var PTY_HOST_CAPABILITIES = {
3804
+ spawn: true,
3805
+ write: true,
3806
+ interruptSignal: true,
3807
+ terminate: true,
3808
+ resize: true,
3809
+ terminalEmulation: false
3810
+ };
3811
+ function createLocalProcessHost() {
3812
+ return {
3813
+ capabilities: COMMAND_HOST_CAPABILITIES,
3814
+ spawn(opts, onOutput, onExit) {
3815
+ const proc = Bun.spawn([opts.command, ...opts.args ?? []], {
3816
+ cwd: opts.cwd,
3817
+ env: { ...process.env, ...opts.env ?? {} },
3818
+ stdin: "pipe",
3819
+ stdout: "pipe",
3820
+ stderr: "pipe"
3821
+ });
3822
+ let settled = false;
3823
+ let signal;
3824
+ const exitPromise = (async () => {
3825
+ const pumps = [pumpStream(proc.stdout, onOutput), pumpStream(proc.stderr, onOutput)];
3826
+ const code = await proc.exited;
3827
+ await Promise.allSettled(pumps);
3828
+ settled = true;
3829
+ const info = { exitCode: code ?? 0, signal };
3830
+ onExit(info);
3831
+ return info;
3832
+ })();
3833
+ return {
3834
+ pid: proc.pid,
3835
+ write(data) {
3836
+ try {
3837
+ proc.stdin.write(data);
3838
+ return true;
3839
+ } catch {
3840
+ return false;
3841
+ }
3842
+ },
3843
+ interrupt() {
3844
+ try {
3845
+ signal = "SIGINT";
3846
+ proc.kill("SIGINT");
3847
+ return true;
3848
+ } catch {
3849
+ return false;
3850
+ }
3851
+ },
3852
+ terminate() {
3853
+ try {
3854
+ if (!signal)
3855
+ signal = "SIGTERM";
3856
+ proc.kill("SIGTERM");
3857
+ return true;
3858
+ } catch {
3859
+ return false;
3860
+ }
3861
+ },
3862
+ kill() {
3863
+ try {
3864
+ signal = "SIGKILL";
3865
+ proc.kill("SIGKILL");
3866
+ return true;
3867
+ } catch {
3868
+ return false;
3869
+ }
3870
+ },
3871
+ resize() {
3872
+ return false;
3873
+ },
3874
+ isAlive() {
3875
+ if (settled)
3876
+ return false;
3877
+ try {
3878
+ process.kill(proc.pid, 0);
3879
+ return true;
3880
+ } catch {
3881
+ return false;
3882
+ }
3883
+ },
3884
+ exited() {
3885
+ return exitPromise;
3886
+ }
3887
+ };
3888
+ },
3889
+ livePids() {
3890
+ return new Set;
3891
+ }
3892
+ };
3893
+ }
3894
+ async function pumpStream(stream, onOutput) {
3895
+ if (!stream)
3896
+ return;
3897
+ const reader = stream.getReader();
3898
+ const decoder = new TextDecoder;
3899
+ try {
3900
+ for (;; ) {
3901
+ const { done, value } = await reader.read();
3902
+ if (done)
3903
+ break;
3904
+ if (value && value.length > 0)
3905
+ onOutput(decoder.decode(value, { stream: true }));
3906
+ }
3907
+ const rest = decoder.decode();
3908
+ if (rest)
3909
+ onOutput(rest);
3910
+ } catch {} finally {
3911
+ try {
3912
+ reader.releaseLock();
3913
+ } catch {}
3290
3914
  }
3291
- return next;
3292
3915
  }
3293
-
3294
- // src/server/goal-tools.ts
3295
- import { exec as execChild } from "child_process";
3296
- import { promisify } from "util";
3297
- var execAsync = promisify(execChild);
3298
- function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
3916
+ function loadBunPty() {
3917
+ return createRequire(import.meta.url)("bun-pty");
3918
+ }
3919
+ function asBunPtyModule(loaded) {
3920
+ const spawn = loaded?.spawn;
3921
+ if (typeof spawn !== "function")
3922
+ throw new Error("bun-pty module has no spawn() export");
3923
+ return { spawn };
3924
+ }
3925
+ function createPtyHost(load = loadBunPty) {
3926
+ const { spawn } = asBunPtyModule(load());
3299
3927
  return {
3300
- loopd_create_goal: tool({
3301
- description: "Create a new background loop goal (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. " + "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]`). When omitted, both inherit the CALLING session\'s live agent/model (read at creation), then plugin defaultAgent/defaultModel. ' + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals are serialized (only one active writer) and require checks.",
3302
- args: {
3303
- name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
3304
- objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
3305
- agent: tool.schema.string().optional().describe(`Agent to run the worker as (e.g. "researcher", "smart-agent"). Optional \u2014 inherits the calling session's agent if omitted, else plugin defaultAgent.`),
3306
- model: tool.schema.string().optional().describe(`Model to run the worker as, as "providerID/modelID" (e.g. "openai/gpt-5.6-sol", "ollama/qwen3.8:27b"). Optional \u2014 inherits the calling session's live model if omitted, else plugin defaultModel.`),
3307
- costBudget: tool.schema.number().optional().describe("Max provider cost in dollars before the engine stops the goal as budget_limited (e.g. 0.5). Optional \u2014 unlimited if omitted."),
3308
- checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
3309
- checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
3310
- 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."),
3311
- progressFile: tool.schema.string().optional().describe("Markdown file the worker reads/writes as its transaction state."),
3312
- maxTurns: tool.schema.number().optional().describe("Max turns before auto-block."),
3313
- maxNoProgress: tool.schema.number().optional().describe("Block after N turns without progress."),
3314
- maxFailures: tool.schema.number().optional().describe("Block after N consecutive failures."),
3315
- compactEvery: tool.schema.number().optional().describe("Compact the worker session every N turns."),
3316
- timeoutMs: tool.schema.number().optional().describe("Per-turn timeout in ms."),
3317
- scheduleEveryMs: tool.schema.number().optional().describe("Interval in ms to auto-requeue the same goal after each completion. Minimum 1000. Enables repetitive dialogue reduction."),
3318
- scheduleMaxRuns: tool.schema.number().optional().describe("Maximum total runs including the initial run. Undefined = unlimited. Requires scheduleEveryMs.")
3319
- },
3320
- execute: async (args, context) => {
3321
- const sessionID = context?.sessionID || hostSessionID;
3322
- if (!sessionID || sessionID === "main") {
3323
- return {
3324
- title: "Goal not created",
3325
- output: JSON.stringify({
3326
- ok: false,
3327
- message: "A valid owner session is required. Run /goal from an active OpenCode session."
3328
- })
3329
- };
3330
- }
3331
- const config = {
3332
- maxTurns: 50
3928
+ capabilities: PTY_HOST_CAPABILITIES,
3929
+ spawn(opts, onOutput, onExit) {
3930
+ const pty = spawn(opts.command, opts.args ?? [], {
3931
+ name: "xterm-256color",
3932
+ cols: opts.cols ?? 80,
3933
+ rows: opts.rows ?? 24,
3934
+ cwd: opts.cwd,
3935
+ env: { ...process.env, ...opts.env ?? {}, TERM: "xterm-256color" }
3936
+ });
3937
+ let settled = false;
3938
+ let killedSignal;
3939
+ let resolveExit;
3940
+ const exitPromise = new Promise((r) => {
3941
+ resolveExit = r;
3942
+ });
3943
+ pty.onData((data) => {
3944
+ onOutput(data);
3945
+ });
3946
+ pty.onExit((event) => {
3947
+ if (settled)
3948
+ return;
3949
+ settled = true;
3950
+ const raw = event?.signal;
3951
+ const info = {
3952
+ exitCode: event?.exitCode ?? 0,
3953
+ signal: typeof raw === "string" ? raw : raw == null ? killedSignal : String(raw)
3333
3954
  };
3334
- if (args.agent)
3335
- config.agent = args.agent;
3336
- if (args.model)
3337
- config.model = args.model;
3338
- if (args.checks)
3339
- config.checks = args.checks;
3340
- if (args.checkCwd)
3341
- config.checkCwd = args.checkCwd;
3342
- if (args.workspaceWrite !== undefined)
3343
- config.workspaceWrite = args.workspaceWrite;
3344
- if (args.progressFile)
3955
+ onExit(info);
3956
+ resolveExit(info);
3957
+ });
3958
+ return {
3959
+ pid: pty.pid,
3960
+ write(data) {
3961
+ if (settled)
3962
+ return false;
3963
+ try {
3964
+ pty.write(data);
3965
+ return true;
3966
+ } catch {
3967
+ return false;
3968
+ }
3969
+ },
3970
+ interrupt() {
3971
+ if (settled)
3972
+ return false;
3973
+ try {
3974
+ pty.write("\x03");
3975
+ return true;
3976
+ } catch {
3977
+ return false;
3978
+ }
3979
+ },
3980
+ terminate() {
3981
+ if (settled)
3982
+ return false;
3983
+ try {
3984
+ killedSignal ??= "SIGTERM";
3985
+ pty.kill();
3986
+ return true;
3987
+ } catch {
3988
+ return false;
3989
+ }
3990
+ },
3991
+ kill() {
3992
+ if (settled)
3993
+ return false;
3994
+ try {
3995
+ killedSignal = "SIGKILL";
3996
+ pty.kill("SIGKILL");
3997
+ return true;
3998
+ } catch {
3999
+ return false;
4000
+ }
4001
+ },
4002
+ resize(cols, rows) {
4003
+ if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols <= 0 || rows <= 0)
4004
+ return false;
4005
+ if (settled)
4006
+ return false;
4007
+ try {
4008
+ pty.resize(cols, rows);
4009
+ return true;
4010
+ } catch {
4011
+ return false;
4012
+ }
4013
+ },
4014
+ isAlive() {
4015
+ if (settled)
4016
+ return false;
4017
+ try {
4018
+ process.kill(pty.pid, 0);
4019
+ return true;
4020
+ } catch {
4021
+ return false;
4022
+ }
4023
+ },
4024
+ exited() {
4025
+ return exitPromise;
4026
+ }
4027
+ };
4028
+ },
4029
+ livePids() {
4030
+ return new Set;
4031
+ }
4032
+ };
4033
+ }
4034
+ function createCommandHost(opts) {
4035
+ try {
4036
+ return Object.assign(createPtyHost(opts?.load ?? loadBunPty), { backend: "pty" });
4037
+ } catch {
4038
+ return Object.assign(createLocalProcessHost(), { backend: "pipe" });
4039
+ }
4040
+ }
4041
+
4042
+ // src/application/command-service.ts
4043
+ import { randomUUID as randomUUID7 } from "crypto";
4044
+ import { promises as fs3 } from "fs";
4045
+ import path3 from "path";
4046
+
4047
+ // src/domain/command-session.ts
4048
+ function createCommandSession(input) {
4049
+ const now = new Date().toISOString();
4050
+ return {
4051
+ id: input.id,
4052
+ title: input.title,
4053
+ command: input.command,
4054
+ args: input.args ?? [],
4055
+ cwd: input.cwd,
4056
+ ownerSessionID: input.ownerSessionID,
4057
+ goalID: input.goalID,
4058
+ status: "running",
4059
+ pid: input.pid,
4060
+ cols: input.cols,
4061
+ rows: input.rows,
4062
+ outputBytes: 0,
4063
+ streamBytes: 0,
4064
+ truncated: false,
4065
+ createdAt: now,
4066
+ updatedAt: now
4067
+ };
4068
+ }
4069
+ var MAX_COMMAND_OUTPUT_BYTES = 512 * 1024;
4070
+
4071
+ // src/domain/command-events.ts
4072
+ var _encoder = new TextEncoder;
4073
+ function utf8ByteLength(data) {
4074
+ return _encoder.encode(data).length;
4075
+ }
4076
+ function expectedNextEnd(startOffset, data) {
4077
+ return startOffset + utf8ByteLength(data);
4078
+ }
4079
+ var VALID_STATUSES = [
4080
+ "running",
4081
+ "exited",
4082
+ "terminated",
4083
+ "missing"
4084
+ ];
4085
+ function isRecord(value) {
4086
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4087
+ }
4088
+ function isNonEmptyString(value) {
4089
+ return typeof value === "string" && value.length > 0;
4090
+ }
4091
+ function isNonNegativeInt(value) {
4092
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
4093
+ }
4094
+ function isCommandSessionLike(value) {
4095
+ if (!isRecord(value))
4096
+ return false;
4097
+ if (typeof value["id"] !== "string" || value["id"].length === 0)
4098
+ return false;
4099
+ if (typeof value["title"] !== "string")
4100
+ return false;
4101
+ if (typeof value["command"] !== "string")
4102
+ return false;
4103
+ if (typeof value["cwd"] !== "string")
4104
+ return false;
4105
+ if (typeof value["ownerSessionID"] !== "string")
4106
+ return false;
4107
+ if (typeof value["status"] !== "string")
4108
+ return false;
4109
+ if (!VALID_STATUSES.includes(value["status"]))
4110
+ return false;
4111
+ if (!isNonNegativeInt(value["outputBytes"]))
4112
+ return false;
4113
+ if (typeof value["truncated"] !== "boolean")
4114
+ return false;
4115
+ if (typeof value["createdAt"] !== "string")
4116
+ return false;
4117
+ if (typeof value["updatedAt"] !== "string")
4118
+ return false;
4119
+ if (value["args"] !== undefined && !Array.isArray(value["args"]))
4120
+ return false;
4121
+ if (value["streamBytes"] !== undefined && !isNonNegativeInt(value["streamBytes"]))
4122
+ return false;
4123
+ return true;
4124
+ }
4125
+ function checkOffsets(startOffset, endOffset, data) {
4126
+ if (typeof data !== "string")
4127
+ return "data must be a string";
4128
+ if (!isNonNegativeInt(startOffset))
4129
+ return "startOffset must be a non-negative integer";
4130
+ if (!isNonNegativeInt(endOffset))
4131
+ return "endOffset must be a non-negative integer";
4132
+ if (endOffset < startOffset)
4133
+ return "endOffset must be >= startOffset";
4134
+ const expected = expectedNextEnd(startOffset, data);
4135
+ if (endOffset !== expected)
4136
+ return `endOffset mismatch: expected ${expected} (startOffset + UTF-8 byte length ${expected - startOffset}), got ${endOffset}`;
4137
+ return null;
4138
+ }
4139
+ function validateCommandStreamMessage(value) {
4140
+ try {
4141
+ if (!isRecord(value))
4142
+ return { ok: false, error: "message must be an object" };
4143
+ const type = value["type"];
4144
+ if (typeof type !== "string")
4145
+ return { ok: false, error: "missing type field" };
4146
+ switch (type) {
4147
+ case "subscribe": {
4148
+ if (!isNonEmptyString(value["commandID"]))
4149
+ return { ok: false, error: "subscribe.commandID must be a non-empty string" };
4150
+ if (!isNonEmptyString(value["ownerSessionID"]))
4151
+ return { ok: false, error: "subscribe.ownerSessionID must be a non-empty string" };
4152
+ return {
4153
+ ok: true,
4154
+ message: {
4155
+ type: "subscribe",
4156
+ commandID: value["commandID"],
4157
+ ownerSessionID: value["ownerSessionID"]
4158
+ }
4159
+ };
4160
+ }
4161
+ case "snapshot": {
4162
+ if (!isCommandSessionLike(value["command"]))
4163
+ return { ok: false, error: "snapshot.command must be CommandSession metadata" };
4164
+ const offsetError = checkOffsets(value["startOffset"], value["endOffset"], value["data"]);
4165
+ if (offsetError)
4166
+ return { ok: false, error: `snapshot.${offsetError}` };
4167
+ return {
4168
+ ok: true,
4169
+ message: {
4170
+ type: "snapshot",
4171
+ command: value["command"],
4172
+ data: value["data"],
4173
+ startOffset: value["startOffset"],
4174
+ endOffset: value["endOffset"]
4175
+ }
4176
+ };
4177
+ }
4178
+ case "output": {
4179
+ if (!isNonEmptyString(value["commandID"]))
4180
+ return { ok: false, error: "output.commandID must be a non-empty string" };
4181
+ const offsetError = checkOffsets(value["startOffset"], value["endOffset"], value["data"]);
4182
+ if (offsetError)
4183
+ return { ok: false, error: `output.${offsetError}` };
4184
+ return {
4185
+ ok: true,
4186
+ message: {
4187
+ type: "output",
4188
+ commandID: value["commandID"],
4189
+ data: value["data"],
4190
+ startOffset: value["startOffset"],
4191
+ endOffset: value["endOffset"]
4192
+ }
4193
+ };
4194
+ }
4195
+ case "status": {
4196
+ if (!isCommandSessionLike(value["command"]))
4197
+ return { ok: false, error: "status.command must be CommandSession metadata" };
4198
+ return { ok: true, message: { type: "status", command: value["command"] } };
4199
+ }
4200
+ case "input": {
4201
+ if (!isNonEmptyString(value["commandID"]))
4202
+ return { ok: false, error: "input.commandID must be a non-empty string" };
4203
+ if (typeof value["data"] !== "string")
4204
+ return { ok: false, error: "input.data must be a string" };
4205
+ return {
4206
+ ok: true,
4207
+ message: { type: "input", commandID: value["commandID"], data: value["data"] }
4208
+ };
4209
+ }
4210
+ case "interrupt": {
4211
+ if (!isNonEmptyString(value["commandID"]))
4212
+ return { ok: false, error: "interrupt.commandID must be a non-empty string" };
4213
+ return { ok: true, message: { type: "interrupt", commandID: value["commandID"] } };
4214
+ }
4215
+ case "resync": {
4216
+ if (!isNonEmptyString(value["commandID"]))
4217
+ return { ok: false, error: "resync.commandID must be a non-empty string" };
4218
+ return { ok: true, message: { type: "resync", commandID: value["commandID"] } };
4219
+ }
4220
+ case "unsubscribe": {
4221
+ if (!isNonEmptyString(value["commandID"]))
4222
+ return { ok: false, error: "unsubscribe.commandID must be a non-empty string" };
4223
+ return { ok: true, message: { type: "unsubscribe", commandID: value["commandID"] } };
4224
+ }
4225
+ case "error": {
4226
+ if (!isNonEmptyString(value["code"]))
4227
+ return { ok: false, error: "error.code must be a non-empty string" };
4228
+ if (typeof value["message"] !== "string")
4229
+ return { ok: false, error: "error.message must be a string" };
4230
+ return {
4231
+ ok: true,
4232
+ message: { type: "error", code: value["code"], message: value["message"] }
4233
+ };
4234
+ }
4235
+ default:
4236
+ return { ok: false, error: `unknown message type: ${type}` };
4237
+ }
4238
+ } catch (err) {
4239
+ return { ok: false, error: `validation failed: ${String(err)}` };
4240
+ }
4241
+ }
4242
+
4243
+ // src/application/command-service.ts
4244
+ init_command_await2();
4245
+ init_state_repository();
4246
+ function loopCommandsDir(directory) {
4247
+ return path3.join(directory, ".opencode", "loopd", "commands");
4248
+ }
4249
+ function createCommandService(host, opts) {
4250
+ const live = new Map;
4251
+ const operations = new Map;
4252
+ const broker = opts?.broker;
4253
+ const commandDirs = new Map;
4254
+ function tailText(entry, limitBytes = 64 * 1024) {
4255
+ let want = limitBytes;
4256
+ const parts = [];
4257
+ for (let i = entry.buffers.length - 1;i >= 0 && want > 0; i--) {
4258
+ const chunk = entry.buffers[i];
4259
+ if (!chunk)
4260
+ continue;
4261
+ parts.unshift(chunk.subarray(Math.max(0, chunk.length - want)));
4262
+ want -= chunk.length;
4263
+ }
4264
+ return Buffer.concat(parts).toString("utf8");
4265
+ }
4266
+ function enqueue(id, operation) {
4267
+ const previous = operations.get(id) ?? Promise.resolve();
4268
+ const next = previous.then(operation, operation);
4269
+ operations.set(id, next);
4270
+ next.finally(() => {
4271
+ if (operations.get(id) === next)
4272
+ operations.delete(id);
4273
+ }).catch(() => {});
4274
+ return next;
4275
+ }
4276
+ async function waitForOperations(id) {
4277
+ await operations.get(id)?.catch(() => {});
4278
+ }
4279
+ function emitBroker(commandID, message) {
4280
+ if (!broker)
4281
+ return;
4282
+ try {
4283
+ broker.publish(commandID, message);
4284
+ } catch {}
4285
+ }
4286
+ function rememberDir(id, directory) {
4287
+ commandDirs.set(id, directory);
4288
+ }
4289
+ async function fireAwaits(directory, id) {
4290
+ try {
4291
+ const fired = await fireCommandAwaits(directory, id);
4292
+ if (fired.length === 0)
4293
+ return;
4294
+ await opts?.onAwaitFired?.(directory, fired);
4295
+ } catch {}
4296
+ }
4297
+ async function readBrokerSnapshot(commandID, ownerSessionID) {
4298
+ const directory = commandDirs.get(commandID);
4299
+ if (!directory)
4300
+ return;
4301
+ const state = await readState(directory);
4302
+ const session = (state.commands ?? []).find((x) => x.id === commandID && x.ownerSessionID === ownerSessionID);
4303
+ if (!session)
4304
+ return;
4305
+ const log = await readCommandLog(directory, commandID, {
4306
+ offsetBytes: 0,
4307
+ limitBytes: MAX_COMMAND_OUTPUT_BYTES
4308
+ });
4309
+ const data = log.text;
4310
+ const byteLen = utf8ByteLength(data);
4311
+ const lifetime = session.streamBytes ?? 0;
4312
+ const endOffset = lifetime >= byteLen ? lifetime : byteLen;
4313
+ const startOffset = endOffset - byteLen;
4314
+ return { command: session, data, startOffset, endOffset };
4315
+ }
4316
+ if (broker) {
4317
+ broker.setResolver({
4318
+ async getSession(commandID, ownerSessionID) {
4319
+ const directory = commandDirs.get(commandID);
4320
+ if (!directory)
4321
+ return;
4322
+ const s = await readState(directory);
4323
+ const c = (s.commands ?? []).find((x) => x.id === commandID);
4324
+ return c && c.ownerSessionID === ownerSessionID ? c : undefined;
4325
+ },
4326
+ async waitForQuiesce(commandID) {
4327
+ await waitForOperations(commandID);
4328
+ },
4329
+ async readSnapshot(commandID, ownerSessionID) {
4330
+ return readBrokerSnapshot(commandID, ownerSessionID);
4331
+ }
4332
+ });
4333
+ }
4334
+ async function persistOutput(directory, id, chunk) {
4335
+ rememberDir(id, directory);
4336
+ const entry = live.get(id);
4337
+ if (entry) {
4338
+ const bytes = Buffer.from(chunk);
4339
+ entry.buffers.push(bytes);
4340
+ entry.bufferedBytes += bytes.length;
4341
+ while (entry.bufferedBytes > 128 * 1024 && entry.buffers.length > 1) {
4342
+ const dropped = entry.buffers.shift();
4343
+ entry.bufferedBytes -= dropped.length;
4344
+ }
4345
+ }
4346
+ await appendCommandLog(directory, id, chunk);
4347
+ let retainedBytes = 0;
4348
+ let truncated = false;
4349
+ const file = path3.join(loopCommandsDir(directory), `${id}.log`);
4350
+ const stat = await fs3.stat(file);
4351
+ retainedBytes = stat.size;
4352
+ if (stat.size > MAX_COMMAND_OUTPUT_BYTES) {
4353
+ const fh = await fs3.open(file, "r");
4354
+ try {
4355
+ const buf = Buffer.alloc(MAX_COMMAND_OUTPUT_BYTES);
4356
+ await fh.read(buf, 0, buf.length, stat.size - buf.length);
4357
+ await fs3.writeFile(file, buf);
4358
+ } finally {
4359
+ await fh.close();
4360
+ }
4361
+ retainedBytes = MAX_COMMAND_OUTPUT_BYTES;
4362
+ truncated = true;
4363
+ }
4364
+ await mutateState(directory, `cmd.output:${id}`, async (s) => {
4365
+ const c = (s.commands ?? []).find((x) => x.id === id);
4366
+ if (!c)
4367
+ return s;
4368
+ const byteLen = utf8ByteLength(chunk);
4369
+ const startOffset = c.streamBytes ?? 0;
4370
+ const endOffset = startOffset + byteLen;
4371
+ c.streamBytes = endOffset;
4372
+ c.outputBytes = retainedBytes;
4373
+ if (truncated)
4374
+ c.truncated = true;
4375
+ c.updatedAt = new Date().toISOString();
4376
+ return s;
4377
+ });
4378
+ try {
4379
+ const fresh = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
4380
+ const endOffset = fresh?.streamBytes ?? 0;
4381
+ const startOffset = endOffset - utf8ByteLength(chunk);
4382
+ if (fresh && startOffset >= 0) {
4383
+ emitBroker(id, {
4384
+ type: "output",
4385
+ commandID: id,
4386
+ data: chunk,
4387
+ startOffset,
4388
+ endOffset
4389
+ });
4390
+ }
4391
+ } catch {}
4392
+ }
4393
+ async function persistExit(directory, id, info) {
4394
+ rememberDir(id, directory);
4395
+ live.delete(id);
4396
+ await mutateState(directory, `cmd.exit:${id}`, async (s) => {
4397
+ const c = (s.commands ?? []).find((x) => x.id === id);
4398
+ if (!c || c.status !== "running")
4399
+ return s;
4400
+ c.exitCode = info.exitCode;
4401
+ if (info.signal && (c.signal === "SIGKILL" || c.signal === "SIGTERM")) {
4402
+ c.status = "terminated";
4403
+ } else {
4404
+ c.status = "exited";
4405
+ if (info.signal)
4406
+ c.signal = info.signal;
4407
+ }
4408
+ c.endedAt = new Date().toISOString();
4409
+ c.updatedAt = c.endedAt;
4410
+ return s;
4411
+ }).catch(() => {});
4412
+ await appendEvent(directory, {
4413
+ version: 1,
4414
+ eventID: randomUUID7(),
4415
+ commandID: id,
4416
+ type: "command.exited",
4417
+ exitCode: info.exitCode,
4418
+ timestamp: new Date().toISOString(),
4419
+ revision: 0
4420
+ }).catch(() => {});
4421
+ try {
4422
+ const fresh = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
4423
+ if (fresh)
4424
+ emitBroker(id, { type: "status", command: fresh });
4425
+ } catch {}
4426
+ await fireAwaits(directory, id);
4427
+ }
4428
+ function owned(cmd, ownerSessionID) {
4429
+ return !!cmd && cmd.ownerSessionID === ownerSessionID;
4430
+ }
4431
+ return {
4432
+ async start(directory, input) {
4433
+ const title = input.title.trim();
4434
+ const command = input.command.trim();
4435
+ if (!title)
4436
+ throw new Error("title is required");
4437
+ if (!command)
4438
+ throw new Error("command is required");
4439
+ if (!input.ownerSessionID || input.ownerSessionID === "main") {
4440
+ throw new Error("A valid owner session is required. Run from an active OpenCode session.");
4441
+ }
4442
+ const id = randomUUID7();
4443
+ const cwd = input.cwd || directory;
4444
+ const pending = [];
4445
+ let ready = false;
4446
+ const handle = host.spawn({ command, args: input.args ?? [], cwd, cols: input.cols, rows: input.rows }, (chunk) => {
4447
+ if (!ready)
4448
+ pending.push({ type: "output", chunk });
4449
+ else
4450
+ enqueue(id, () => persistOutput(directory, id, chunk)).catch(() => {});
4451
+ }, (info) => {
4452
+ if (!ready)
4453
+ pending.push({ type: "exit", info });
4454
+ else
4455
+ enqueue(id, () => persistExit(directory, id, info)).catch(() => {});
4456
+ });
4457
+ const session = createCommandSession({
4458
+ id,
4459
+ title,
4460
+ command,
4461
+ args: input.args ?? [],
4462
+ cwd,
4463
+ ownerSessionID: input.ownerSessionID,
4464
+ goalID: input.goalID,
4465
+ pid: handle.pid,
4466
+ cols: input.cols,
4467
+ rows: input.rows
4468
+ });
4469
+ try {
4470
+ await mutateState(directory, `cmd.start:${id}`, async (s) => {
4471
+ s.commands = [...s.commands ?? [], session];
4472
+ return s;
4473
+ });
4474
+ } catch (error) {
4475
+ handle.terminate();
4476
+ const exited = await Promise.race([
4477
+ handle.exited().then(() => true, () => true),
4478
+ new Promise((resolve) => setTimeout(() => resolve(false), 1000))
4479
+ ]);
4480
+ if (!exited && handle.isAlive())
4481
+ handle.kill();
4482
+ throw error;
4483
+ }
4484
+ live.set(id, { handle, buffers: [], bufferedBytes: 0 });
4485
+ rememberDir(id, directory);
4486
+ await appendEvent(directory, {
4487
+ version: 1,
4488
+ eventID: randomUUID7(),
4489
+ ...input.goalID ? { goalID: input.goalID } : {},
4490
+ commandID: id,
4491
+ type: "command.started",
4492
+ title,
4493
+ timestamp: new Date().toISOString(),
4494
+ revision: 0
4495
+ }).catch(() => {});
4496
+ try {
4497
+ const fresh = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
4498
+ if (fresh)
4499
+ emitBroker(id, { type: "status", command: fresh });
4500
+ } catch {}
4501
+ const initialEvents = pending.splice(0);
4502
+ ready = true;
4503
+ const initialOperations = [];
4504
+ for (const event of initialEvents) {
4505
+ initialOperations.push(event.type === "output" ? enqueue(id, () => persistOutput(directory, id, event.chunk)) : enqueue(id, () => persistExit(directory, id, event.info)));
4506
+ }
4507
+ await Promise.all(initialOperations);
4508
+ return await this.get(directory, id, input.ownerSessionID) ?? session;
4509
+ },
4510
+ async list(directory, ownerSessionID) {
4511
+ const s = await readState(directory);
4512
+ for (const c of s.commands ?? [])
4513
+ rememberDir(c.id, directory);
4514
+ return (s.commands ?? []).filter((c) => c.ownerSessionID === ownerSessionID);
4515
+ },
4516
+ async get(directory, id, ownerSessionID) {
4517
+ rememberDir(id, directory);
4518
+ const s = await readState(directory);
4519
+ const c = (s.commands ?? []).find((x) => x.id === id);
4520
+ return owned(c, ownerSessionID) ? c : undefined;
4521
+ },
4522
+ async read(directory, id, ownerSessionID, opts) {
4523
+ await waitForOperations(id);
4524
+ const session = await this.get(directory, id, ownerSessionID);
4525
+ if (!session)
4526
+ return;
4527
+ const entry = live.get(id);
4528
+ const offset = opts?.offsetBytes ?? 0;
4529
+ const limit = Math.min(opts?.limitBytes ?? 64 * 1024, 256 * 1024);
4530
+ if (entry && offset === 0) {
4531
+ const mem = tailText(entry, limit);
4532
+ if (entry.bufferedBytes <= limit) {
4533
+ const file = await readCommandLog(directory, id, { offsetBytes: 0, limitBytes: 0 });
4534
+ const memBytes = Buffer.byteLength(mem);
4535
+ return { session, text: mem, totalBytes: session.outputBytes, startByte: Math.max(0, session.outputBytes - memBytes), live: session.status === "running" };
4536
+ }
4537
+ }
4538
+ const file = await readCommandLog(directory, id, { offsetBytes: offset, limitBytes: limit });
4539
+ return { session, text: file.text, totalBytes: session.outputBytes, startByte: file.startByte, live: session.status === "running" };
4540
+ },
4541
+ async write(directory, id, ownerSessionID, data) {
4542
+ const session = await this.get(directory, id, ownerSessionID);
4543
+ if (!session)
4544
+ return { ok: false, message: "Command not found." };
4545
+ if (session.status !== "running")
4546
+ return { ok: false, message: `Command is ${session.status}; only running commands accept input.` };
4547
+ const entry = live.get(id);
4548
+ const ok = entry ? entry.handle.write(data) : false;
4549
+ if (!ok)
4550
+ return { ok: false, message: "Process input unavailable (no live handle \u2014 host may have restarted; reconcile marks it honestly)." };
4551
+ return { ok: true, message: `Sent ${Buffer.byteLength(data)} byte(s) to "${session.title}".` };
4552
+ },
4553
+ async resize(directory, id, ownerSessionID, cols, rows) {
4554
+ const session = await this.get(directory, id, ownerSessionID);
4555
+ if (!session)
4556
+ return { ok: false, message: "Command not found." };
4557
+ if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols <= 0 || rows <= 0) {
4558
+ return { ok: false, message: `Invalid size ${cols}x${rows}: cols and rows must be positive integers.` };
4559
+ }
4560
+ const entry = live.get(id);
4561
+ let applied = false;
4562
+ if (entry) {
4563
+ try {
4564
+ applied = entry.handle.resize(cols, rows);
4565
+ } catch {
4566
+ applied = false;
4567
+ }
4568
+ }
4569
+ await mutateState(directory, `cmd.resize:${id}`, async (s) => {
4570
+ const c = (s.commands ?? []).find((x) => x.id === id);
4571
+ if (c && c.ownerSessionID === ownerSessionID) {
4572
+ c.cols = cols;
4573
+ c.rows = rows;
4574
+ c.updatedAt = new Date().toISOString();
4575
+ }
4576
+ return s;
4577
+ });
4578
+ if (applied)
4579
+ return { ok: true, message: `Terminal resized to ${cols}x${rows} for "${session.title}".` };
4580
+ return { ok: false, unsupported: true, message: "Resize is not supported by the active pipe host (pipes have no tty winsize; the PTY backend was unavailable). Size stored; output remains a byte stream." };
4581
+ },
4582
+ async interrupt(directory, id, ownerSessionID) {
4583
+ const session = await this.get(directory, id, ownerSessionID);
4584
+ if (!session)
4585
+ return { ok: false, message: "Command not found." };
4586
+ if (session.status !== "running")
4587
+ return { ok: false, message: `Command is ${session.status}; nothing to interrupt.` };
4588
+ const entry = live.get(id);
4589
+ if (!entry)
4590
+ return { ok: false, message: "No live handle (host restarted?). Reconcile will mark it missing; use terminate/remove to clean up." };
4591
+ const delivered = entry.handle.interrupt();
4592
+ if (!delivered)
4593
+ return { ok: false, message: "Failed to deliver SIGINT." };
4594
+ await mutateState(directory, `cmd.interrupt:${id}`, async (s) => {
4595
+ const c = (s.commands ?? []).find((x) => x.id === id);
4596
+ if (c && c.ownerSessionID === ownerSessionID) {
4597
+ c.signal = "SIGINT";
4598
+ c.updatedAt = new Date().toISOString();
4599
+ }
4600
+ return s;
4601
+ });
4602
+ return { ok: true, message: `SIGINT delivered to "${session.title}" (process may continue if it traps the signal).` };
4603
+ },
4604
+ async terminate(directory, id, ownerSessionID) {
4605
+ rememberDir(id, directory);
4606
+ const session = await this.get(directory, id, ownerSessionID);
4607
+ if (!session)
4608
+ return { ok: false, message: "Command not found." };
4609
+ if (session.status !== "running")
4610
+ return { ok: false, message: `Command is ${session.status}; nothing to terminate.` };
4611
+ await mutateState(directory, `cmd.terminate-claim:${id}`, async (s) => {
4612
+ const c = (s.commands ?? []).find((x) => x.id === id);
4613
+ if (c && c.ownerSessionID === ownerSessionID && c.status === "running") {
4614
+ c.signal = "SIGTERM";
4615
+ c.updatedAt = new Date().toISOString();
4616
+ }
4617
+ return s;
4618
+ }).catch(() => {});
4619
+ try {
4620
+ const claimed = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
4621
+ if (claimed)
4622
+ emitBroker(id, { type: "status", command: claimed });
4623
+ } catch {}
4624
+ const entry = live.get(id);
4625
+ let status = "terminated";
4626
+ if (entry) {
4627
+ const aliveBefore = entry.handle.isAlive();
4628
+ if (!aliveBefore) {
4629
+ status = "exited";
4630
+ } else {
4631
+ entry.handle.terminate();
4632
+ const exited = await Promise.race([
4633
+ entry.handle.exited().then(() => true),
4634
+ new Promise((r) => setTimeout(() => r(false), 3000))
4635
+ ]);
4636
+ if (!exited && entry.handle.isAlive())
4637
+ entry.handle.kill();
4638
+ try {
4639
+ await entry.handle.exited();
4640
+ } catch {}
4641
+ }
4642
+ live.delete(id);
4643
+ }
4644
+ const exitCode = status === "terminated" ? 143 : undefined;
4645
+ await mutateState(directory, `cmd.terminate:${id}`, async (s) => {
4646
+ const c = (s.commands ?? []).find((x) => x.id === id);
4647
+ if (!c || c.ownerSessionID !== ownerSessionID)
4648
+ return s;
4649
+ if (c.status === "running") {
4650
+ c.status = status;
4651
+ if (exitCode !== undefined)
4652
+ c.exitCode = c.exitCode ?? exitCode;
4653
+ c.signal = c.signal ?? "SIGTERM";
4654
+ c.endedAt = new Date().toISOString();
4655
+ c.updatedAt = c.endedAt;
4656
+ } else if (c.status === "terminated" && !c.endedAt) {
4657
+ if (exitCode !== undefined)
4658
+ c.exitCode = c.exitCode ?? exitCode;
4659
+ c.endedAt = new Date().toISOString();
4660
+ c.updatedAt = c.endedAt;
4661
+ }
4662
+ return s;
4663
+ });
4664
+ try {
4665
+ const fresh = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
4666
+ if (fresh)
4667
+ emitBroker(id, { type: "status", command: fresh });
4668
+ } catch {}
4669
+ await fireAwaits(directory, id);
4670
+ return { ok: true, message: `Command "${session.title}" ${status}.` };
4671
+ },
4672
+ async remove(directory, id, ownerSessionID) {
4673
+ rememberDir(id, directory);
4674
+ const session = await this.get(directory, id, ownerSessionID);
4675
+ if (!session)
4676
+ return { ok: false, message: "Command not found." };
4677
+ if (session.status === "running") {
4678
+ return { ok: false, message: `Command "${session.title}" is still running \u2014 terminate it first (terminate \u2260 remove).` };
4679
+ }
4680
+ const lastKnown = { ...session };
4681
+ await mutateState(directory, `cmd.remove:${id}`, async (s) => {
4682
+ s.commands = (s.commands ?? []).filter((x) => !(x.id === id && x.ownerSessionID === ownerSessionID));
4683
+ return s;
4684
+ });
4685
+ await removeCommandLog(directory, id);
4686
+ emitBroker(id, { type: "status", command: lastKnown });
4687
+ await clearAwaitsForCommand(directory, id, "command removed").catch(() => {});
4688
+ return { ok: true, message: `Command "${session.title}" removed.` };
4689
+ },
4690
+ async reconcile(directory) {
4691
+ const state = await readState(directory);
4692
+ const cmds = state.commands ?? [];
4693
+ for (const c of cmds)
4694
+ rememberDir(c.id, directory);
4695
+ let markedMissing = 0;
4696
+ for (const c of cmds) {
4697
+ if (c.status !== "running")
4698
+ continue;
4699
+ const entry = live.get(c.id);
4700
+ if (entry) {
4701
+ if (!entry.handle.isAlive()) {
4702
+ live.delete(c.id);
4703
+ await mutateState(directory, `cmd.reconcile-exit:${c.id}`, async (s) => {
4704
+ const x = (s.commands ?? []).find((y) => y.id === c.id);
4705
+ if (x && x.status === "running") {
4706
+ x.status = "exited";
4707
+ x.endedAt = new Date().toISOString();
4708
+ x.updatedAt = x.endedAt;
4709
+ x.lastError = x.lastError ?? "Process handle died without an exit event.";
4710
+ }
4711
+ return s;
4712
+ }).catch(() => {});
4713
+ try {
4714
+ const fresh = await readState(directory).then((st) => (st.commands ?? []).find((y) => y.id === c.id));
4715
+ if (fresh)
4716
+ emitBroker(c.id, { type: "status", command: fresh });
4717
+ } catch {}
4718
+ await fireAwaits(directory, c.id);
4719
+ }
4720
+ continue;
4721
+ }
4722
+ await mutateState(directory, `cmd.reconcile-missing:${c.id}`, async (s) => {
4723
+ const x = (s.commands ?? []).find((y) => y.id === c.id);
4724
+ if (x && x.status === "running") {
4725
+ x.status = "missing";
4726
+ x.lastError = "Host restarted or handle lost \u2014 no live execution found. Output log retained; remove to clean up.";
4727
+ x.updatedAt = new Date().toISOString();
4728
+ markedMissing++;
4729
+ }
4730
+ return s;
4731
+ }).catch(() => {});
4732
+ try {
4733
+ const fresh = await readState(directory).then((st) => (st.commands ?? []).find((y) => y.id === c.id));
4734
+ if (fresh && fresh.status === "missing")
4735
+ emitBroker(c.id, { type: "status", command: fresh });
4736
+ } catch {}
4737
+ await fireAwaits(directory, c.id);
4738
+ }
4739
+ return { markedMissing };
4740
+ },
4741
+ async dispose(directory) {
4742
+ const state = await readState(directory);
4743
+ const owned = new Map((state.commands ?? []).map((command) => [command.id, command.ownerSessionID]));
4744
+ await Promise.all([...live.entries()].map(async ([id, entry]) => {
4745
+ const owner = owned.get(id);
4746
+ if (owner) {
4747
+ await this.terminate(directory, id, owner).catch(() => {});
4748
+ return;
4749
+ }
4750
+ entry.handle.terminate();
4751
+ const exited = await Promise.race([
4752
+ entry.handle.exited().then(() => true, () => true),
4753
+ new Promise((resolve) => setTimeout(() => resolve(false), 1000))
4754
+ ]);
4755
+ if (!exited && entry.handle.isAlive())
4756
+ entry.handle.kill();
4757
+ live.delete(id);
4758
+ }));
4759
+ }
4760
+ };
4761
+ }
4762
+
4763
+ // src/application/command-event-broker.ts
4764
+ import { randomUUID as randomUUID8 } from "crypto";
4765
+ function safeDeliver(sink, msg) {
4766
+ try {
4767
+ sink(msg);
4768
+ } catch {}
4769
+ }
4770
+ function isCoveredBySnapshot(msg, snapshot) {
4771
+ if (msg.type === "output") {
4772
+ const out = msg;
4773
+ return out.endOffset <= snapshot.endOffset;
4774
+ }
4775
+ if (msg.type === "status") {
4776
+ const st = msg;
4777
+ const bufferedAt = st.command.updatedAt ?? "";
4778
+ const snapshotAt = snapshot.command.updatedAt ?? "";
4779
+ return bufferedAt <= snapshotAt;
4780
+ }
4781
+ return true;
4782
+ }
4783
+ function createCommandEventBroker(resolver) {
4784
+ let currentResolver = resolver;
4785
+ const subscribers = new Map;
4786
+ function entriesFor(commandID) {
4787
+ let m = subscribers.get(commandID);
4788
+ if (!m) {
4789
+ m = new Map;
4790
+ subscribers.set(commandID, m);
4791
+ }
4792
+ return m;
4793
+ }
4794
+ return {
4795
+ setResolver(next) {
4796
+ currentResolver = next;
4797
+ },
4798
+ async subscribe(commandID, ownerSessionID, sink) {
4799
+ if (!currentResolver)
4800
+ throw new Error("Command event broker has no resolver (service not wired).");
4801
+ if (!commandID)
4802
+ throw new Error("commandID is required.");
4803
+ if (!ownerSessionID)
4804
+ throw new Error("ownerSessionID is required.");
4805
+ if (typeof sink !== "function")
4806
+ throw new Error("sink must be a function.");
4807
+ const sinkID = randomUUID8();
4808
+ const entry = { sinkID, sink, state: "subscribing", buffer: [] };
4809
+ entriesFor(commandID).set(sinkID, entry);
4810
+ try {
4811
+ const session = await currentResolver.getSession(commandID, ownerSessionID);
4812
+ if (!session) {
4813
+ throw new Error("Command not found or not owned by this session (cross-owner subscribe rejected).");
4814
+ }
4815
+ await currentResolver.waitForQuiesce(commandID);
4816
+ const snap = await currentResolver.readSnapshot(commandID, ownerSessionID);
4817
+ if (!snap) {
4818
+ throw new Error("Command snapshot unavailable (removed or unreadable).");
4819
+ }
4820
+ const snapshotMsg = {
4821
+ type: "snapshot",
4822
+ command: snap.command,
4823
+ data: snap.data,
4824
+ startOffset: snap.startOffset,
4825
+ endOffset: snap.endOffset
4826
+ };
4827
+ const validated = validateCommandStreamMessage(snapshotMsg);
4828
+ if (!validated.ok) {
4829
+ throw new Error(`Invalid snapshot: ${validated.error}`);
4830
+ }
4831
+ const stillThere = subscribers.get(commandID)?.get(sinkID);
4832
+ if (!stillThere)
4833
+ throw new Error("Subscription cancelled during handshake.");
4834
+ const buffered = entry.buffer.splice(0);
4835
+ safeDeliver(entry.sink, validated.message);
4836
+ for (const msg of buffered) {
4837
+ if (isCoveredBySnapshot(msg, snapshotMsg))
4838
+ continue;
4839
+ safeDeliver(entry.sink, msg);
4840
+ }
4841
+ const extra = entry.buffer.splice(0);
4842
+ for (const msg of extra) {
4843
+ if (isCoveredBySnapshot(msg, snapshotMsg))
4844
+ continue;
4845
+ safeDeliver(entry.sink, msg);
4846
+ }
4847
+ entry.state = "live";
4848
+ return sinkID;
4849
+ } catch (error) {
4850
+ subscribers.get(commandID)?.delete(sinkID);
4851
+ if (subscribers.get(commandID)?.size === 0)
4852
+ subscribers.delete(commandID);
4853
+ throw error;
4854
+ }
4855
+ },
4856
+ unsubscribe(commandID, sinkID) {
4857
+ const m = subscribers.get(commandID);
4858
+ if (!m)
4859
+ return;
4860
+ m.delete(sinkID);
4861
+ if (m.size === 0)
4862
+ subscribers.delete(commandID);
4863
+ },
4864
+ publish(commandID, message) {
4865
+ let valid;
4866
+ try {
4867
+ const result = validateCommandStreamMessage(message);
4868
+ if (!result.ok)
4869
+ return false;
4870
+ valid = result.message;
4871
+ } catch {
4872
+ return false;
4873
+ }
4874
+ const m = subscribers.get(commandID);
4875
+ if (!m || m.size === 0)
4876
+ return false;
4877
+ let handled = false;
4878
+ for (const entry of m.values()) {
4879
+ if (entry.state === "subscribing") {
4880
+ entry.buffer.push(valid);
4881
+ handled = true;
4882
+ continue;
4883
+ }
4884
+ safeDeliver(entry.sink, valid);
4885
+ handled = true;
4886
+ }
4887
+ return handled;
4888
+ },
4889
+ subscriberCount(commandID) {
4890
+ return subscribers.get(commandID)?.size ?? 0;
4891
+ }
4892
+ };
4893
+ }
4894
+
4895
+ // src/server/command-stream-server.ts
4896
+ import { randomBytes, randomUUID as randomUUID9, timingSafeEqual } from "crypto";
4897
+ import { promises as fs4 } from "fs";
4898
+ import path4 from "path";
4899
+ function streamEndpointPath(directory) {
4900
+ return path4.join(directory, ".opencode", "loopd", "commands", ".stream-endpoint.json");
4901
+ }
4902
+ function isPidAlive(pid) {
4903
+ if (!Number.isInteger(pid) || pid <= 0)
4904
+ return false;
4905
+ try {
4906
+ process.kill(pid, 0);
4907
+ return true;
4908
+ } catch (error) {
4909
+ const code = error?.code;
4910
+ if (code === "EPERM")
4911
+ return true;
4912
+ return false;
4913
+ }
4914
+ }
4915
+ function tokensEqual(a, b) {
4916
+ const ab = Buffer.from(a);
4917
+ const bb = Buffer.from(b);
4918
+ if (ab.length !== bb.length)
4919
+ return false;
4920
+ try {
4921
+ return timingSafeEqual(ab, bb);
4922
+ } catch {
4923
+ return false;
4924
+ }
4925
+ }
4926
+ var MAX_BUFFERED_BYTES = 512 * 1024;
4927
+ function createCommandStreamServer(directory, commandService, broker) {
4928
+ const endpointPath = streamEndpointPath(directory);
4929
+ const token = randomBytes(32).toString("hex");
4930
+ const generation = randomUUID9();
4931
+ const startedAt = new Date().toISOString();
4932
+ let server;
4933
+ let serverURL;
4934
+ let started = false;
4935
+ let stopped = false;
4936
+ const sockets = new Set;
4937
+ const subsBySocket = new Map;
4938
+ const handshakeBySocket = new Map;
4939
+ function nextHandshake(ws, commandID) {
4940
+ let m = handshakeBySocket.get(ws);
4941
+ if (!m) {
4942
+ m = new Map;
4943
+ handshakeBySocket.set(ws, m);
4944
+ }
4945
+ const next = (m.get(commandID) ?? 0) + 1;
4946
+ m.set(commandID, next);
4947
+ return next;
4948
+ }
4949
+ function currentHandshake(ws, commandID) {
4950
+ return handshakeBySocket.get(ws)?.get(commandID);
4951
+ }
4952
+ function clearHandshake(ws, commandID, generation) {
4953
+ const m = handshakeBySocket.get(ws);
4954
+ if (m?.get(commandID) === generation)
4955
+ m.delete(commandID);
4956
+ if (m && m.size === 0)
4957
+ handshakeBySocket.delete(ws);
4958
+ }
4959
+ function sendError(ws, code, message) {
4960
+ try {
4961
+ ws.send(JSON.stringify({ type: "error", code, message }));
4962
+ } catch {}
4963
+ }
4964
+ function sendToSocket(ws, payload) {
4965
+ try {
4966
+ const buffered = ws.bufferedAmount ?? 0;
4967
+ if (buffered > MAX_BUFFERED_BYTES) {
4968
+ try {
4969
+ ws.close(1011, "backpressure overflow: resync");
4970
+ } catch {}
4971
+ return false;
4972
+ }
4973
+ ws.send(payload);
4974
+ return true;
4975
+ } catch {
4976
+ return false;
4977
+ }
4978
+ }
4979
+ function makeSink(ws) {
4980
+ return (msg) => {
4981
+ if (stopped || !sockets.has(ws))
4982
+ return;
4983
+ sendToSocket(ws, JSON.stringify(msg));
4984
+ };
4985
+ }
4986
+ function subsFor(ws) {
4987
+ let m = subsBySocket.get(ws);
4988
+ if (!m) {
4989
+ m = new Map;
4990
+ subsBySocket.set(ws, m);
4991
+ }
4992
+ return m;
4993
+ }
4994
+ function cleanupSocket(ws) {
4995
+ const subs = subsBySocket.get(ws);
4996
+ if (subs) {
4997
+ for (const [commandID, entry] of subs) {
4998
+ try {
4999
+ broker.unsubscribe(commandID, entry.sinkID);
5000
+ } catch {}
5001
+ }
5002
+ subsBySocket.delete(ws);
5003
+ }
5004
+ handshakeBySocket.delete(ws);
5005
+ sockets.delete(ws);
5006
+ }
5007
+ async function handleSubscribe(ws, commandID, ownerSessionID) {
5008
+ const generation = nextHandshake(ws, commandID);
5009
+ const subs = subsFor(ws);
5010
+ const previous = subs.get(commandID);
5011
+ if (previous) {
5012
+ try {
5013
+ broker.unsubscribe(commandID, previous.sinkID);
5014
+ } catch {}
5015
+ subs.delete(commandID);
5016
+ }
5017
+ const sink = makeSink(ws);
5018
+ let sinkID;
5019
+ try {
5020
+ sinkID = await broker.subscribe(commandID, ownerSessionID, sink);
5021
+ } catch (error) {
5022
+ if (currentHandshake(ws, commandID) !== generation)
5023
+ return;
5024
+ clearHandshake(ws, commandID, generation);
5025
+ sendError(ws, "subscribe-failed", error instanceof Error ? error.message : String(error));
5026
+ return;
5027
+ }
5028
+ if (currentHandshake(ws, commandID) !== generation) {
5029
+ try {
5030
+ broker.unsubscribe(commandID, sinkID);
5031
+ } catch {}
5032
+ return;
5033
+ }
5034
+ clearHandshake(ws, commandID, generation);
5035
+ if (!sockets.has(ws)) {
5036
+ try {
5037
+ broker.unsubscribe(commandID, sinkID);
5038
+ } catch {}
5039
+ return;
5040
+ }
5041
+ subs.set(commandID, { sinkID, ownerSessionID });
5042
+ }
5043
+ async function handleResync(ws, commandID) {
5044
+ const subs = subsFor(ws);
5045
+ const previous = subs.get(commandID);
5046
+ if (!previous) {
5047
+ sendError(ws, "not-subscribed", `No subscription for command ${commandID} on this socket; subscribe first.`);
5048
+ return;
5049
+ }
5050
+ await handleSubscribe(ws, commandID, previous.ownerSessionID);
5051
+ }
5052
+ function handleUnsubscribe(ws, commandID) {
5053
+ if (handshakeBySocket.get(ws)?.has(commandID))
5054
+ nextHandshake(ws, commandID);
5055
+ const subs = subsFor(ws);
5056
+ const previous = subs.get(commandID);
5057
+ if (previous) {
5058
+ try {
5059
+ broker.unsubscribe(commandID, previous.sinkID);
5060
+ } catch {}
5061
+ subs.delete(commandID);
5062
+ }
5063
+ }
5064
+ async function handleInput(ws, commandID, data) {
5065
+ const owner = subsFor(ws).get(commandID)?.ownerSessionID;
5066
+ if (!owner) {
5067
+ sendError(ws, "not-subscribed", `No subscription for command ${commandID} on this socket; subscribe first.`);
5068
+ return;
5069
+ }
5070
+ try {
5071
+ const result = await commandService.write(directory, commandID, owner, data);
5072
+ if (!result.ok)
5073
+ sendError(ws, "input-failed", result.message);
5074
+ } catch (error) {
5075
+ sendError(ws, "input-failed", error instanceof Error ? error.message : String(error));
5076
+ }
5077
+ }
5078
+ async function handleInterrupt(ws, commandID) {
5079
+ const owner = subsFor(ws).get(commandID)?.ownerSessionID;
5080
+ if (!owner) {
5081
+ sendError(ws, "not-subscribed", `No subscription for command ${commandID} on this socket; subscribe first.`);
5082
+ return;
5083
+ }
5084
+ try {
5085
+ const result = await commandService.interrupt(directory, commandID, owner);
5086
+ if (!result.ok)
5087
+ sendError(ws, "interrupt-failed", result.message);
5088
+ } catch (error) {
5089
+ sendError(ws, "interrupt-failed", error instanceof Error ? error.message : String(error));
5090
+ }
5091
+ }
5092
+ async function handleSocketMessage(ws, raw) {
5093
+ let parsed;
5094
+ try {
5095
+ const text = typeof raw === "string" ? raw : Buffer.from(raw).toString("utf8");
5096
+ parsed = JSON.parse(text);
5097
+ } catch {
5098
+ sendError(ws, "invalid-message", "Message must be JSON.");
5099
+ return;
5100
+ }
5101
+ let validated;
5102
+ try {
5103
+ validated = validateCommandStreamMessage(parsed);
5104
+ } catch (error) {
5105
+ sendError(ws, "invalid-message", `Validation failed: ${String(error)}`);
5106
+ return;
5107
+ }
5108
+ if (!validated.ok) {
5109
+ sendError(ws, "invalid-message", validated.error);
5110
+ return;
5111
+ }
5112
+ const msg = validated.message;
5113
+ try {
5114
+ switch (msg.type) {
5115
+ case "subscribe":
5116
+ await handleSubscribe(ws, msg.commandID, msg.ownerSessionID);
5117
+ break;
5118
+ case "resync":
5119
+ await handleResync(ws, msg.commandID);
5120
+ break;
5121
+ case "unsubscribe":
5122
+ handleUnsubscribe(ws, msg.commandID);
5123
+ break;
5124
+ case "input":
5125
+ await handleInput(ws, msg.commandID, msg.data);
5126
+ break;
5127
+ case "interrupt":
5128
+ await handleInterrupt(ws, msg.commandID);
5129
+ break;
5130
+ default:
5131
+ sendError(ws, "invalid-message", `Message type "${msg.type}" is not accepted inbound.`);
5132
+ break;
5133
+ }
5134
+ } catch (error) {
5135
+ sendError(ws, "internal-error", error instanceof Error ? error.message : String(error));
5136
+ }
5137
+ }
5138
+ async function writeEndpointFile(url) {
5139
+ const endpoint = {
5140
+ url,
5141
+ token,
5142
+ pid: process.pid,
5143
+ generation,
5144
+ startedAt
5145
+ };
5146
+ await fs4.mkdir(path4.dirname(endpointPath), { recursive: true });
5147
+ await fs4.writeFile(endpointPath, JSON.stringify(endpoint, null, 2) + `
5148
+ `, { mode: 384 });
5149
+ await fs4.chmod(endpointPath, 384);
5150
+ }
5151
+ return {
5152
+ get url() {
5153
+ return serverURL;
5154
+ },
5155
+ token,
5156
+ generation,
5157
+ endpointPath,
5158
+ async start() {
5159
+ if (started)
5160
+ return;
5161
+ started = true;
5162
+ try {
5163
+ const previous = await fs4.readFile(endpointPath, "utf8").then((text) => JSON.parse(text), () => {
5164
+ return;
5165
+ });
5166
+ if (previous && typeof previous.pid === "number" && isPidAlive(previous.pid) && previous.pid !== process.pid) {
5167
+ logServerEvent(directory, "command-stream.superseded-live-endpoint", { pid: previous.pid });
5168
+ }
5169
+ } catch {}
5170
+ const srv = Bun.serve({
5171
+ hostname: "127.0.0.1",
5172
+ port: 0,
5173
+ fetch(req, upgrading) {
5174
+ if (stopped)
5175
+ return new Response("Server is stopping.", { status: 503 });
5176
+ let url;
5177
+ try {
5178
+ url = new URL(req.url);
5179
+ } catch {
5180
+ return new Response("Bad request.", { status: 400 });
5181
+ }
5182
+ if (url.pathname !== "/" && url.pathname !== "/stream") {
5183
+ return new Response("Not found.", { status: 404 });
5184
+ }
5185
+ const presented = url.searchParams.get("token") ?? "";
5186
+ if (!presented || !tokensEqual(presented, token)) {
5187
+ return new Response("Forbidden: valid connection token required.", { status: 403 });
5188
+ }
5189
+ const ok = upgrading.upgrade(req, { data: undefined });
5190
+ if (!ok)
5191
+ return new Response("WebSocket upgrade required.", { status: 400 });
5192
+ return;
5193
+ },
5194
+ websocket: {
5195
+ open(ws) {
5196
+ sockets.add(ws);
5197
+ },
5198
+ message(ws, raw) {
5199
+ handleSocketMessage(ws, raw).catch((error) => {
5200
+ try {
5201
+ sendError(ws, "internal-error", describeError(error));
5202
+ } catch {}
5203
+ });
5204
+ },
5205
+ close(ws) {
5206
+ cleanupSocket(ws);
5207
+ }
5208
+ }
5209
+ });
5210
+ server = srv;
5211
+ const port = srv.port;
5212
+ serverURL = `ws://127.0.0.1:${port}/stream?token=${token}`;
5213
+ await writeEndpointFile(serverURL);
5214
+ logServerEvent(directory, "command-stream.started", { port, generation });
5215
+ },
5216
+ async stop() {
5217
+ stopped = true;
5218
+ for (const ws of [...sockets]) {
5219
+ try {
5220
+ ws.close(1001, "server shutting down");
5221
+ } catch {}
5222
+ cleanupSocket(ws);
5223
+ }
5224
+ sockets.clear();
5225
+ subsBySocket.clear();
5226
+ handshakeBySocket.clear();
5227
+ try {
5228
+ server?.stop(true);
5229
+ } catch {}
5230
+ server = undefined;
5231
+ serverURL = undefined;
5232
+ try {
5233
+ const text = await fs4.readFile(endpointPath, "utf8");
5234
+ const current = JSON.parse(text);
5235
+ if (current?.generation === generation) {
5236
+ await fs4.unlink(endpointPath);
5237
+ } else {
5238
+ logServerEvent(directory, "command-stream.keep-endpoint", {
5239
+ reason: "generation mismatch \u2014 another server owns the file"
5240
+ });
5241
+ }
5242
+ } catch {}
5243
+ logServerEvent(directory, "command-stream.stopped", { generation });
5244
+ }
5245
+ };
5246
+ }
5247
+
5248
+ // src/server/goal-tools.ts
5249
+ init_state_repository();
5250
+ import { randomUUID as randomUUID10 } from "crypto";
5251
+ import { tool } from "@opencode-ai/plugin/tool";
5252
+ // src/domain/verification.ts
5253
+ var MAX_RECENT_ATTEMPTS = 10;
5254
+ function appendVerificationAttempt(recent, attempt) {
5255
+ const next = [...recent, attempt];
5256
+ if (next.length > MAX_RECENT_ATTEMPTS) {
5257
+ return next.slice(next.length - MAX_RECENT_ATTEMPTS);
5258
+ }
5259
+ return next;
5260
+ }
5261
+
5262
+ // src/server/goal-tools.ts
5263
+ import { exec as execChild } from "child_process";
5264
+ import { promisify } from "util";
5265
+ var execAsync = promisify(execChild);
5266
+ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
5267
+ return {
5268
+ loopd_create_goal: tool({
5269
+ description: "Create a new background loop goal (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. " + "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]`). When omitted, both inherit the CALLING session\'s live agent/model (read at creation), then plugin defaultAgent/defaultModel. ' + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals are serialized (only one active writer) and require checks.",
5270
+ args: {
5271
+ name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
5272
+ objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
5273
+ agent: tool.schema.string().optional().describe(`Agent to run the worker as (e.g. "researcher", "smart-agent"). Optional \u2014 inherits the calling session's agent if omitted, else plugin defaultAgent.`),
5274
+ model: tool.schema.string().optional().describe(`Model to run the worker as, as "providerID/modelID" (e.g. "openai/gpt-5.6-sol", "ollama/qwen3.8:27b"). Optional \u2014 inherits the calling session's live model if omitted, else plugin defaultModel.`),
5275
+ costBudget: tool.schema.number().optional().describe("Max provider cost in dollars before the engine stops the goal as budget_limited (e.g. 0.5). Optional \u2014 unlimited if omitted."),
5276
+ checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
5277
+ checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
5278
+ 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."),
5279
+ progressFile: tool.schema.string().optional().describe("Markdown file the worker reads/writes as its transaction state."),
5280
+ maxTurns: tool.schema.number().optional().describe("Max turns before auto-block."),
5281
+ maxNoProgress: tool.schema.number().optional().describe("Block after N turns without progress."),
5282
+ maxFailures: tool.schema.number().optional().describe("Block after N consecutive failures."),
5283
+ compactEvery: tool.schema.number().optional().describe("Compact the worker session every N turns."),
5284
+ timeoutMs: tool.schema.number().optional().describe("Per-turn timeout in ms."),
5285
+ scheduleEveryMs: tool.schema.number().optional().describe("Interval in ms to auto-requeue the same goal after each completion. Minimum 1000. Enables repetitive dialogue reduction."),
5286
+ scheduleMaxRuns: tool.schema.number().optional().describe("Maximum total runs including the initial run. Undefined = unlimited. Requires scheduleEveryMs.")
5287
+ },
5288
+ execute: async (args, context) => {
5289
+ const sessionID = context?.sessionID || hostSessionID;
5290
+ if (!sessionID || sessionID === "main") {
5291
+ return {
5292
+ title: "Goal not created",
5293
+ output: JSON.stringify({
5294
+ ok: false,
5295
+ message: "A valid owner session is required. Run /goal from an active OpenCode session."
5296
+ })
5297
+ };
5298
+ }
5299
+ const config = {
5300
+ maxTurns: 50
5301
+ };
5302
+ if (args.agent)
5303
+ config.agent = args.agent;
5304
+ if (args.model)
5305
+ config.model = args.model;
5306
+ if (args.checks)
5307
+ config.checks = args.checks;
5308
+ if (args.checkCwd)
5309
+ config.checkCwd = args.checkCwd;
5310
+ if (args.workspaceWrite !== undefined)
5311
+ config.workspaceWrite = args.workspaceWrite;
5312
+ if (args.progressFile)
3345
5313
  config.progressFile = args.progressFile;
3346
5314
  if (args.maxTurns !== undefined)
3347
5315
  config.maxTurns = args.maxTurns;
@@ -3430,12 +5398,20 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
3430
5398
  })
3431
5399
  };
3432
5400
  } catch (error) {
5401
+ const startFailure = error instanceof GoalStartError ? error : undefined;
3433
5402
  return {
3434
5403
  title: "Goal creation failed",
3435
5404
  output: JSON.stringify({
3436
5405
  ok: false,
3437
5406
  name: args.name,
3438
5407
  message: error instanceof Error ? error.message : String(error),
5408
+ ...startFailure ? {
5409
+ goalID: startFailure.goalID,
5410
+ status: "blocked",
5411
+ failedStage: startFailure.failedStage,
5412
+ ...startFailure.workerSessionID ? { workerSessionID: startFailure.workerSessionID } : {},
5413
+ nextAction: `Resume the persisted goal with resume_goal (goal_id "${startFailure.goalID}") after fixing the cause \u2014 do not create another goal for the same task.`
5414
+ } : {},
3439
5415
  diagnostics: SERVER_LOG_FILE
3440
5416
  })
3441
5417
  };
@@ -3496,7 +5472,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
3496
5472
  await writeState(dir, state);
3497
5473
  const event = {
3498
5474
  version: 1,
3499
- eventID: randomUUID5(),
5475
+ eventID: randomUUID10(),
3500
5476
  goalID: goal.id,
3501
5477
  type: "goal.progress",
3502
5478
  summary: args.summary,
@@ -3561,7 +5537,7 @@ Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
3561
5537
  Working directory: ${cwd}
3562
5538
 
3563
5539
  ${failureDetails}`;
3564
- attemptID = randomUUID5();
5540
+ attemptID = randomUUID10();
3565
5541
  const verificationAttempt = {
3566
5542
  id: attemptID,
3567
5543
  sequence: runtime.evaluatorRejectionCount,
@@ -3606,7 +5582,7 @@ ${failureDetails.slice(0, 500)}`,
3606
5582
  if (attemptID) {
3607
5583
  await appendEvent(dir, {
3608
5584
  version: 1,
3609
- eventID: randomUUID5(),
5585
+ eventID: randomUUID10(),
3610
5586
  goalID: goal.id,
3611
5587
  type: "goal.completion_rejected",
3612
5588
  attemptID,
@@ -3624,7 +5600,7 @@ ${failureDetails.slice(0, 500)}`,
3624
5600
  if (blocked && rejectedGoal?.blocker) {
3625
5601
  await appendEvent(dir, {
3626
5602
  version: 1,
3627
- eventID: randomUUID5(),
5603
+ eventID: randomUUID10(),
3628
5604
  goalID: goal.id,
3629
5605
  type: "goal.blocked",
3630
5606
  reason: rejectedGoal.blocker.reason,
@@ -3684,7 +5660,7 @@ ${failureDetails.slice(0, 500)}`,
3684
5660
  }
3685
5661
  runtime.updatedAt = new Date().toISOString();
3686
5662
  }
3687
- const attemptID = randomUUID5();
5663
+ const attemptID = randomUUID10();
3688
5664
  const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
3689
5665
  const checks = (goal.config.checks || []).map((cmd) => ({
3690
5666
  command: cmd,
@@ -3708,7 +5684,7 @@ ${failureDetails.slice(0, 500)}`,
3708
5684
  await writeState(dir, state);
3709
5685
  const event = {
3710
5686
  version: 1,
3711
- eventID: randomUUID5(),
5687
+ eventID: randomUUID10(),
3712
5688
  goalID: goal.id,
3713
5689
  type: "goal.completed",
3714
5690
  summary: args.summary,
@@ -3768,7 +5744,7 @@ ${failureDetails.slice(0, 500)}`,
3768
5744
  await writeState(dir, state);
3769
5745
  const event = {
3770
5746
  version: 1,
3771
- eventID: randomUUID5(),
5747
+ eventID: randomUUID10(),
3772
5748
  goalID: goal.id,
3773
5749
  type: "goal.blocked",
3774
5750
  reason: args.reason,
@@ -3948,7 +5924,7 @@ function describeGoalState(status, phase) {
3948
5924
  }
3949
5925
 
3950
5926
  // src/server/owner-tools.ts
3951
- import { promises as fs3 } from "fs";
5927
+ import { promises as fs5 } from "fs";
3952
5928
  function withTimeout2(promise, ms) {
3953
5929
  return Promise.race([
3954
5930
  promise,
@@ -4030,9 +6006,24 @@ function ownerTools(options) {
4030
6006
  const ownerID = context?.sessionID;
4031
6007
  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");
4032
6008
  if (!goal) {
6009
+ if (!ownerID) {
6010
+ return {
6011
+ title: "No goal found",
6012
+ output: JSON.stringify({ ok: false, message: "No session context available, so goal ownership cannot be matched." })
6013
+ };
6014
+ }
6015
+ const ownedActive = state.goals.filter((g) => g.ownerSessionID === ownerID && g.status !== "complete");
6016
+ const ownedComplete = state.goals.filter((g) => g.ownerSessionID === ownerID && g.status === "complete").length;
6017
+ const othersActive = state.goals.filter((g) => g.ownerSessionID !== ownerID && g.status !== "complete").length;
6018
+ const message = ownedActive.length > 0 ? `No goal matched (tried ${args.goal_id ? `ID ${args.goal_id}` : "first active goal"}). This session owns ${ownedActive.length} active goal(s) \u2014 pass its goal_id explicitly.` : othersActive > 0 ? `No matching active goal for this session. ${othersActive} active goal(s) exist, all owned by other sessions \u2014 inspect from the owning session.` : ownedComplete > 0 ? "No matching active goal for this session. Your goals are all complete." : "No matching active goal for this session. No goals exist yet \u2014 create one with /goal.";
4033
6019
  return {
4034
6020
  title: "No goal found",
4035
- output: JSON.stringify({ ok: false, message: "No matching active goal for this session." })
6021
+ output: JSON.stringify({
6022
+ ok: false,
6023
+ message,
6024
+ ownedGoals: ownedActive.map((g) => ({ id: g.id, name: g.name, status: g.status })),
6025
+ ownedElsewhereActive: othersActive
6026
+ })
4036
6027
  };
4037
6028
  }
4038
6029
  const runtime = state.runtimes.find((r) => r.goalID === goal.id);
@@ -4046,7 +6037,7 @@ function ownerTools(options) {
4046
6037
  if (!dir)
4047
6038
  return;
4048
6039
  try {
4049
- const files = await fs3.readdir(dir);
6040
+ const files = await fs5.readdir(dir);
4050
6041
  return files.length ? `${files.length} file(s): ${files.slice(0, 8).join(", ")}` : "no artifacts yet";
4051
6042
  } catch {
4052
6043
  return "no artifacts yet";
@@ -4516,19 +6507,264 @@ function ownerTools(options) {
4516
6507
  };
4517
6508
  }
4518
6509
 
6510
+ // src/server/command-tools.ts
6511
+ init_command_await2();
6512
+ import { tool as tool3 } from "@opencode-ai/plugin/tool";
6513
+ function ownerID(context) {
6514
+ const id = context?.sessionID;
6515
+ return typeof id === "string" && id.length > 0 ? id : undefined;
6516
+ }
6517
+ function denied() {
6518
+ return {
6519
+ title: "No session",
6520
+ output: JSON.stringify({
6521
+ ok: false,
6522
+ message: "No session context available. Arbitrary commands require an owning session and are never auto-allowed.",
6523
+ errorCode: "no_session"
6524
+ })
6525
+ };
6526
+ }
6527
+ function summarize(c) {
6528
+ return {
6529
+ id: c.id,
6530
+ title: c.title,
6531
+ argv: [c.command, ...c.args],
6532
+ status: c.status,
6533
+ exitCode: c.exitCode,
6534
+ signal: c.signal,
6535
+ outputBytes: c.outputBytes,
6536
+ truncated: c.truncated,
6537
+ goalID: c.goalID,
6538
+ updatedAt: c.updatedAt
6539
+ };
6540
+ }
6541
+ function commandTools(options) {
6542
+ const { directory, commandService } = options;
6543
+ const capabilities = options.capabilities ?? COMMAND_HOST_CAPABILITIES;
6544
+ const sizeNote = capabilities.resize ? "applied live to the PTY winsize" : "stored; resize is unsupported by the pipe host";
6545
+ return {
6546
+ loopd_command_start: tool3({
6547
+ description: "Start a standalone interactive command session (arbitrary shell command) in the background. Returns an ID for write/read/interrupt/terminate/remove. Independent from goals: linking a goalID is display-only and never couples lifecycles.",
6548
+ args: {
6549
+ title: tool3.schema.string().describe("Short human label for the session."),
6550
+ command: tool3.schema.string().describe('Executable to spawn (e.g. "bun", "python3").'),
6551
+ args: tool3.schema.array(tool3.schema.string()).optional().describe("Arguments for the command."),
6552
+ cwd: tool3.schema.string().optional().describe("Working directory. Defaults to the project root."),
6553
+ goal_id: tool3.schema.string().optional().describe("Optional goal linkage (display only \u2014 no lifecycle coupling)."),
6554
+ cols: tool3.schema.number().optional().describe(`Requested terminal width (${sizeNote}).`),
6555
+ rows: tool3.schema.number().optional().describe(`Requested terminal height (${sizeNote}).`)
6556
+ },
6557
+ execute: async (args, context) => {
6558
+ const owner = ownerID(context);
6559
+ if (!owner)
6560
+ return denied();
6561
+ try {
6562
+ const argv = [args.command, ...args.args ?? []];
6563
+ await context.ask({
6564
+ permission: "bash",
6565
+ patterns: [argv.join(" ")],
6566
+ always: [args.command],
6567
+ metadata: { command: args.command, args: args.args ?? [], cwd: args.cwd ?? directory }
6568
+ });
6569
+ const session = await commandService.start(directory, {
6570
+ title: args.title,
6571
+ command: args.command,
6572
+ args: args.args ?? [],
6573
+ cwd: args.cwd,
6574
+ ownerSessionID: owner,
6575
+ goalID: args.goal_id,
6576
+ cols: args.cols,
6577
+ rows: args.rows
6578
+ });
6579
+ return {
6580
+ title: "Command started",
6581
+ output: JSON.stringify({ ok: true, command: summarize(session), capabilities }, null, 2)
6582
+ };
6583
+ } catch (error) {
6584
+ return {
6585
+ title: "Command not started",
6586
+ output: JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) })
6587
+ };
6588
+ }
6589
+ }
6590
+ }),
6591
+ loopd_command_list: tool3({
6592
+ description: "List standalone command sessions owned by this session.",
6593
+ args: {},
6594
+ execute: async (_args, context) => {
6595
+ const owner = ownerID(context);
6596
+ if (!owner)
6597
+ return denied();
6598
+ const sessions = await commandService.list(directory, owner);
6599
+ return {
6600
+ title: `${sessions.length} command session(s)`,
6601
+ output: JSON.stringify({ ok: true, commands: sessions.map((c) => summarize(c)), capabilities }, null, 2)
6602
+ };
6603
+ }
6604
+ }),
6605
+ loopd_command_get: tool3({
6606
+ description: "Get a command session's metadata plus a bounded output snapshot. Closing a view detaches; it never terminates.",
6607
+ args: {
6608
+ command_id: tool3.schema.string().describe("Command session ID."),
6609
+ offset_bytes: tool3.schema.number().optional().describe("Byte offset into the output log (paging)."),
6610
+ limit_bytes: tool3.schema.number().optional().describe("Max bytes to return (default 64KB, cap 256KB).")
6611
+ },
6612
+ execute: async (args, context) => {
6613
+ const owner = ownerID(context);
6614
+ if (!owner)
6615
+ return denied();
6616
+ const result = await commandService.read(directory, args.command_id, owner, {
6617
+ offsetBytes: args.offset_bytes,
6618
+ limitBytes: args.limit_bytes
6619
+ });
6620
+ if (!result) {
6621
+ return { title: "Not found", output: JSON.stringify({ ok: false, message: "Command not found for this session." }) };
6622
+ }
6623
+ return {
6624
+ title: `Command: ${result.session.title}`,
6625
+ output: JSON.stringify({
6626
+ ok: true,
6627
+ command: summarize(result.session),
6628
+ output: result.text,
6629
+ startByte: result.startByte,
6630
+ totalBytes: result.totalBytes,
6631
+ live: result.live
6632
+ }, null, 2)
6633
+ };
6634
+ }
6635
+ }),
6636
+ loopd_command_write: tool3({
6637
+ description: "Send raw input (stdin bytes) to a running command session.",
6638
+ args: {
6639
+ command_id: tool3.schema.string().describe("Command session ID."),
6640
+ input: tool3.schema.string().describe("Raw text to write to stdin (include trailing newline for line-buffered programs).")
6641
+ },
6642
+ execute: async (args, context) => {
6643
+ const owner = ownerID(context);
6644
+ if (!owner)
6645
+ return denied();
6646
+ const result = await commandService.write(directory, args.command_id, owner, args.input);
6647
+ return { title: result.ok ? "Input sent" : "Write failed", output: JSON.stringify({ ...result, command_id: args.command_id }) };
6648
+ }
6649
+ }),
6650
+ loopd_command_interrupt: tool3({
6651
+ description: "Deliver SIGINT (Ctrl+C) to a running command. The process may trap and continue \u2014 that is correct, not a failure. Never kills unconditionally.",
6652
+ args: {
6653
+ command_id: tool3.schema.string().describe("Command session ID.")
6654
+ },
6655
+ execute: async (args, context) => {
6656
+ const owner = ownerID(context);
6657
+ if (!owner)
6658
+ return denied();
6659
+ const result = await commandService.interrupt(directory, args.command_id, owner);
6660
+ return { title: result.ok ? "Interrupted" : "Interrupt failed", output: JSON.stringify({ ...result, command_id: args.command_id }) };
6661
+ }
6662
+ }),
6663
+ loopd_command_terminate: tool3({
6664
+ description: "Terminate a running command (SIGTERM, escalates to SIGKILL). Stopping a command never pauses/blocks any goal.",
6665
+ args: {
6666
+ command_id: tool3.schema.string().describe("Command session ID.")
6667
+ },
6668
+ execute: async (args, context) => {
6669
+ const owner = ownerID(context);
6670
+ if (!owner)
6671
+ return denied();
6672
+ const result = await commandService.terminate(directory, args.command_id, owner);
6673
+ return { title: result.ok ? "Terminated" : "Terminate failed", output: JSON.stringify({ ...result, command_id: args.command_id }) };
6674
+ }
6675
+ }),
6676
+ loopd_command_remove: tool3({
6677
+ description: "Remove a finished command session and its output log. Refuses while running (terminate \u2260 remove).",
6678
+ args: {
6679
+ command_id: tool3.schema.string().describe("Command session ID.")
6680
+ },
6681
+ execute: async (args, context) => {
6682
+ const owner = ownerID(context);
6683
+ if (!owner)
6684
+ return denied();
6685
+ const result = await commandService.remove(directory, args.command_id, owner);
6686
+ return { title: result.ok ? "Removed" : "Remove failed", output: JSON.stringify({ ...result, command_id: args.command_id }) };
6687
+ }
6688
+ }),
6689
+ loopd_command_await: tool3({
6690
+ description: "Opt in to a one-shot wake-up: the given goal wakes when the given command reaches a terminal status (exited/terminated/missing) with the exit code, signal, and last 4KB of output as evidence. Explicit opt-in only \u2014 a merely linked command never wakes its goal. Exactly-once: the await is consumed on fire, output chunks never fire, pausing/clearing the goal or removing the command cancels it.",
6691
+ args: {
6692
+ command_id: tool3.schema.string().describe("Command session ID to await."),
6693
+ goal_id: tool3.schema.string().describe("Goal ID to wake on exit. You must own both the goal and the command.")
6694
+ },
6695
+ execute: async (args, context) => {
6696
+ const owner = ownerID(context);
6697
+ if (!owner)
6698
+ return denied();
6699
+ const result = await requestCommandAwait(directory, {
6700
+ goalID: args.goal_id,
6701
+ commandID: args.command_id,
6702
+ ownerSessionID: owner
6703
+ });
6704
+ if (result.ok && result.fired && result.active && options.goalService) {
6705
+ await wakeGoalForAwait(directory, options.goalService, args.goal_id).catch(() => {});
6706
+ }
6707
+ return {
6708
+ title: result.ok ? "Await registered" : "Await failed",
6709
+ output: JSON.stringify({ ...result, command_id: args.command_id, goal_id: args.goal_id })
6710
+ };
6711
+ }
6712
+ }),
6713
+ loopd_command_resize: tool3({
6714
+ description: capabilities.resize ? "Apply a terminal size to a running command (live PTY winsize; the requested size is also stored)." : "Request a terminal size for a command. Honestly unsupported by the pipe host: size is stored, never applied.",
6715
+ args: {
6716
+ command_id: tool3.schema.string().describe("Command session ID."),
6717
+ cols: tool3.schema.number().describe("Requested width."),
6718
+ rows: tool3.schema.number().describe("Requested height.")
6719
+ },
6720
+ execute: async (args, context) => {
6721
+ const owner = ownerID(context);
6722
+ if (!owner)
6723
+ return denied();
6724
+ const result = await commandService.resize(directory, args.command_id, owner, args.cols, args.rows);
6725
+ return { title: "Resize", output: JSON.stringify({ ...result, command_id: args.command_id }) };
6726
+ }
6727
+ })
6728
+ };
6729
+ }
6730
+
4519
6731
  // src/server/plugin.ts
4520
6732
  init_state_repository();
6733
+ // package.json
6734
+ var version = "1.10.3";
6735
+
6736
+ // src/server/plugin.ts
4521
6737
  var PLUGIN_ID = "opencode-loopd.server";
4522
6738
  var server = async ({ client, directory }, pluginOptions) => {
4523
6739
  const defaults = parsePluginDefaults(pluginOptions);
4524
6740
  const host = createRealHost(client, directory);
6741
+ logServerEvent(directory, "plugin.loaded", { pluginID: PLUGIN_ID, host: "v1", version });
4525
6742
  return createServerHooks(directory, host, defaults);
4526
6743
  };
4527
6744
  function createServerHooks(directory, host, defaults) {
4528
6745
  const goalService = createGoalService(host);
6746
+ const commandBroker = createCommandEventBroker();
6747
+ const commandHost = createCommandHost();
6748
+ const commandService = createCommandService(commandHost, {
6749
+ broker: commandBroker,
6750
+ onAwaitFired: async (dir, fired) => {
6751
+ await Promise.resolve().then(() => init_command_await2());
6752
+ for (const f of fired) {
6753
+ if (!f.active)
6754
+ continue;
6755
+ await wakeGoalForAwait(dir, goalService, f.goalID).catch((error) => logServerEvent(dir, "command.await-wake-failed", {
6756
+ goalID: f.goalID,
6757
+ commandID: f.commandID,
6758
+ detail: describeError(error)
6759
+ }));
6760
+ }
6761
+ }
6762
+ });
6763
+ const commandStream = createCommandStreamServer(directory, commandService, commandBroker);
4529
6764
  const worker = createControlWorker({
4530
6765
  directory,
4531
6766
  goalService,
6767
+ commandService,
4532
6768
  pollIntervalMs: 1000,
4533
6769
  defaults
4534
6770
  });
@@ -4552,13 +6788,26 @@ function createServerHooks(directory, host, defaults) {
4552
6788
  engine.start();
4553
6789
  worker.start();
4554
6790
  scheduleWorker.start();
6791
+ commandStream.start().catch((error) => logServerEvent(directory, "command-stream.start-failed", { detail: describeError(error) }));
4555
6792
  }
4556
6793
  function reconcileInBackground() {
4557
6794
  if (reconciliationStarted)
4558
6795
  return;
4559
6796
  reconciliationStarted = true;
4560
6797
  logServerEvent(directory, "reconcile.started");
4561
- goalService.reconcile(directory).then(() => logServerEvent(directory, "reconcile.completed"), (error) => logServerEvent(directory, "reconcile.failed", { detail: describeError(error) }));
6798
+ goalService.reconcile(directory).then(() => commandService.reconcile(directory), (error) => logServerEvent(directory, "reconcile.failed", { detail: describeError(error) })).then(async () => {
6799
+ await Promise.resolve().then(() => init_command_await2());
6800
+ const fired = await reconcileCommandAwaits(directory);
6801
+ for (const f of fired) {
6802
+ if (!f.active)
6803
+ continue;
6804
+ await wakeGoalForAwait(directory, goalService, f.goalID).catch((error) => logServerEvent(directory, "command.await-wake-failed", {
6805
+ goalID: f.goalID,
6806
+ commandID: f.commandID,
6807
+ detail: describeError(error)
6808
+ }));
6809
+ }
6810
+ }, (error) => logServerEvent(directory, "reconcile.failed", { detail: describeError(error) })).then(() => logServerEvent(directory, "reconcile.completed"), (error) => logServerEvent(directory, "reconcile.failed", { detail: describeError(error) }));
4562
6811
  }
4563
6812
  return {
4564
6813
  event: async ({ event }) => {
@@ -4570,7 +6819,7 @@ function createServerHooks(directory, host, defaults) {
4570
6819
  if (type?.startsWith("session."))
4571
6820
  reconcileInBackground();
4572
6821
  },
4573
- tool: { ...goalTools(directory, goalService, undefined, defaults, host), ...ownerTools({ directory, host, goalService }) },
6822
+ tool: { ...goalTools(directory, goalService, undefined, defaults, host), ...ownerTools({ directory, host, goalService }), ...commandTools({ directory, commandService, capabilities: commandHost.capabilities, goalService }) },
4574
6823
  "tool.execute.before": async (input, _output) => {
4575
6824
  const activeWorkers = goalService.getActiveWorkers();
4576
6825
  let matchedGoalID;
@@ -4648,9 +6897,11 @@ function createServerHooks(directory, host, defaults) {
4648
6897
  }
4649
6898
  },
4650
6899
  dispose: async () => {
6900
+ await commandStream.stop().catch(() => {});
4651
6901
  engine.stop();
4652
6902
  await worker.stop();
4653
6903
  scheduleWorker.stop();
6904
+ await commandService.dispose(directory);
4654
6905
  }
4655
6906
  };
4656
6907
  }
@@ -4668,6 +6919,7 @@ var v2 = {
4668
6919
  id: PLUGIN_ID,
4669
6920
  async setup(context) {
4670
6921
  const directory = context.location.directory;
6922
+ logServerEvent(directory, "plugin.loaded", { pluginID: PLUGIN_ID, host: "v2", version });
4671
6923
  const statuses = new Map;
4672
6924
  const host = createV2Host(context, statuses);
4673
6925
  const hooks = createServerHooks(directory, host, parsePluginDefaults(context.options));
@@ -4729,6 +6981,7 @@ function toV2Tool(id, definition, directory) {
4729
6981
  return {
4730
6982
  name: id,
4731
6983
  description: definition.description,
6984
+ ...id === "loopd_command_start" ? { permission: "bash" } : {},
4732
6985
  input: v1Tool.schema.object(definition.args),
4733
6986
  async execute(input, context) {
4734
6987
  const result = await definition.execute(input, {
@@ -4774,6 +7027,17 @@ function normalizeV2Event(event) {
4774
7027
  return { type: "session.compacted", properties };
4775
7028
  if (event?.type === "session.execution.failed")
4776
7029
  return { type: "session.error", properties };
7030
+ if (event?.type === "session.execution.succeeded")
7031
+ return { type: "session.execution.succeeded", properties };
7032
+ if (event?.type === "session.inbox.delivered") {
7033
+ return {
7034
+ type: "message.updated",
7035
+ properties: {
7036
+ sessionID: properties.sessionID,
7037
+ info: { role: "user", id: properties.inboxID }
7038
+ }
7039
+ };
7040
+ }
4777
7041
  if (event?.type === "session.message.content.updated") {
4778
7042
  return {
4779
7043
  type: "message.part.updated",
@@ -4791,5 +7055,6 @@ var plugin_default = {
4791
7055
  setup: v2.setup
4792
7056
  };
4793
7057
  export {
4794
- plugin_default as default
7058
+ plugin_default as default,
7059
+ normalizeV2Event
4795
7060
  };