@bojackduy/opencode-loopd 1.10.3 → 1.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -326,6 +326,14 @@ function migrate(state) {
326
326
  if (!Array.isArray(result.commandAwaits))
327
327
  result.commandAwaits = [];
328
328
  }
329
+ if (result.version < 9) {
330
+ result.version = 9;
331
+ result.goals = result.goals.map((goal) => ({
332
+ ...goal,
333
+ workerTopology: goal.workerTopology ?? undefined,
334
+ nativeParentID: goal.nativeParentID ?? undefined
335
+ }));
336
+ }
329
337
  return result;
330
338
  }
331
339
  async function writeAtomic(target, contents) {
@@ -555,7 +563,7 @@ async function removeCommandLog(directory, commandID) {
555
563
  function delay(ms) {
556
564
  return new Promise((resolve) => setTimeout(resolve, ms));
557
565
  }
558
- var CURRENT_VERSION = 8, LOCK_STALE_MS = 1e4;
566
+ var CURRENT_VERSION = 9, LOCK_STALE_MS = 1e4;
559
567
  var init_state_repository = () => {};
560
568
 
561
569
  // src/domain/command-await.ts
@@ -568,10 +576,16 @@ function tailLastBytes(text, maxBytes = MAX_AWAIT_TAIL_BYTES) {
568
576
  return text;
569
577
  return buf.subarray(buf.length - maxBytes).toString("utf8");
570
578
  }
579
+ function safeCommandText(text) {
580
+ return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[PX^_][\s\S]*?\x1b\\|\x1b[\x20-\x2f]*[\x30-\x7e]/g, "").replace(/\r\n?/g, `
581
+ `).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "").replace(/\n{3,}/g, `
582
+
583
+ `);
584
+ }
571
585
  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);
586
+ const short = safeCommandText(input.commandID.slice(0, 8));
587
+ const header = `[command "${safeCommandText(input.title)}" (${safeCommandText(input.argv.join(" ") || input.title)}) ${input.status}` + ` ${short}... exitCode=${input.exitCode ?? "unknown"} signal=${safeCommandText(input.signal ?? "none")}]`;
588
+ const tail = tailLastBytes(safeCommandText(input.tail));
575
589
  return tail ? `${header}
576
590
  ${tail}` : header;
577
591
  }
@@ -585,6 +599,237 @@ var init_command_await = __esm(() => {
585
599
  MAX_AWAIT_TAIL_BYTES = 4 * 1024;
586
600
  });
587
601
 
602
+ // src/domain/command-watch.ts
603
+ function initialWatchState() {
604
+ return { state: "active", matches: 0, pushes: 0, droppedLines: 0 };
605
+ }
606
+ function compileWatchPattern(kind, pattern, ignoreCase = false) {
607
+ let compiled;
608
+ try {
609
+ compiled = new RegExp(pattern, ignoreCase ? "i" : "");
610
+ } catch (e) {
611
+ throw new Error(`Invalid watch ${kind} regex '${pattern}': ${e instanceof Error ? e.message : String(e)}`);
612
+ }
613
+ if (DANGEROUS_REGEXES.some((d) => d.test(pattern))) {
614
+ throw new Error(`Potentially dangerous watch ${kind} regex rejected: '${pattern}'. Please use a safer pattern.`);
615
+ }
616
+ return compiled;
617
+ }
618
+ function compileWatchSpec(spec) {
619
+ const ignoreCase = spec.ignoreCase === true;
620
+ const untilAction = spec.untilAction ?? "stop";
621
+ if (untilAction !== "stop" && untilAction !== "keep") {
622
+ throw new Error(`Invalid watch untilAction '${spec.untilAction}': must be "stop" or "keep".`);
623
+ }
624
+ return {
625
+ ...spec.filter !== undefined ? { filterRe: compileWatchPattern("filter", spec.filter, ignoreCase) } : {},
626
+ ...spec.until !== undefined ? { untilRe: compileWatchPattern("until", spec.until, ignoreCase) } : {},
627
+ ignoreCase,
628
+ untilAction
629
+ };
630
+ }
631
+ function stripAnsiForWatch(line) {
632
+ return line.replace(ANSI_PATTERN, "");
633
+ }
634
+ function cleanRawLine(raw) {
635
+ const stripped = stripAnsiForWatch(raw);
636
+ if (!stripped.includes("\r"))
637
+ return stripped;
638
+ const parts = stripped.split("\r");
639
+ return parts[parts.length - 1] ?? "";
640
+ }
641
+ function createLineAssembler() {
642
+ let carry = "";
643
+ return {
644
+ push(chunk) {
645
+ carry += chunk;
646
+ const parts = carry.replace(/\r\n/g, `
647
+ `).split(`
648
+ `);
649
+ carry = parts.pop() ?? "";
650
+ return parts.map(cleanRawLine);
651
+ },
652
+ flush() {
653
+ if (!carry)
654
+ return [];
655
+ const line = cleanRawLine(carry);
656
+ carry = "";
657
+ return [line];
658
+ }
659
+ };
660
+ }
661
+ function testFresh(re, line) {
662
+ const hit = re.test(line);
663
+ re.lastIndex = 0;
664
+ return hit;
665
+ }
666
+ function matchLine(compiled, line) {
667
+ if (compiled.filterRe && !testFresh(compiled.filterRe, line))
668
+ return { pass: false, until: false };
669
+ if (compiled.untilRe && testFresh(compiled.untilRe, line))
670
+ return { pass: true, until: true };
671
+ return { pass: true, until: false };
672
+ }
673
+ function byteLen(s) {
674
+ return Buffer.byteLength(s, "utf8");
675
+ }
676
+
677
+ class CommandWatcher {
678
+ compiled;
679
+ state = initialWatchState();
680
+ assembler = createLineAssembler();
681
+ pending = [];
682
+ pendingBytes = 0;
683
+ stamps = [];
684
+ constructor(spec) {
685
+ this.compiled = compileWatchSpec(spec);
686
+ }
687
+ get untilAction() {
688
+ return this.compiled.untilAction;
689
+ }
690
+ get pendingCount() {
691
+ return this.pending.length;
692
+ }
693
+ feedChunk(chunk, now) {
694
+ const completed = [];
695
+ const matched = [];
696
+ let untilLine = null;
697
+ let floodNow = false;
698
+ const suspended = this.state.state === "flood-suspended" || this.state.state === "budget-exhausted";
699
+ const lines = this.assembler.push(chunk);
700
+ for (const line of lines) {
701
+ if (line.trim() === "")
702
+ continue;
703
+ if (suspended) {
704
+ this.state.droppedLines++;
705
+ continue;
706
+ }
707
+ completed.push(line);
708
+ this.stamps.push(now);
709
+ while (this.stamps.length > 0 && (this.stamps[0] ?? now) < now - 1000)
710
+ this.stamps.shift();
711
+ if (this.stamps.length > WATCH_FLOOD_LINES_PER_SEC) {
712
+ this.state.state = "flood-suspended";
713
+ this.state.droppedLines++;
714
+ floodNow = true;
715
+ continue;
716
+ }
717
+ const m = matchLine(this.compiled, line);
718
+ if (!m.pass)
719
+ continue;
720
+ if (m.until && this.state.state === "active") {
721
+ this.state.state = "until-matched";
722
+ this.state.matches++;
723
+ this.buffer(line);
724
+ untilLine = line;
725
+ continue;
726
+ }
727
+ if (this.state.state === "until-matched" && this.untilAction === "stop") {
728
+ this.state.droppedLines++;
729
+ continue;
730
+ }
731
+ this.state.matches++;
732
+ this.buffer(line);
733
+ matched.push(line);
734
+ }
735
+ return { completed, matched, untilLine, floodNow };
736
+ }
737
+ finish(now) {
738
+ const trailing = this.assembler.flush();
739
+ if (trailing.length === 0)
740
+ return { completed: [], matched: [], untilLine: null, floodNow: false };
741
+ return this.feedChunk(trailing[0] + `
742
+ `, now);
743
+ }
744
+ buffer(line) {
745
+ this.pending.push(line);
746
+ this.pendingBytes += byteLen(line);
747
+ }
748
+ takePush() {
749
+ if (this.pending.length === 0)
750
+ return null;
751
+ if (this.state.state === "budget-exhausted") {
752
+ this.state.droppedLines += this.pending.length;
753
+ this.pending = [];
754
+ this.pendingBytes = 0;
755
+ return null;
756
+ }
757
+ if (this.state.pushes >= WATCH_MAX_PUSHES_PER_COMMAND) {
758
+ this.state.state = "budget-exhausted";
759
+ this.state.droppedLines += this.pending.length;
760
+ this.pending = [];
761
+ this.pendingBytes = 0;
762
+ return { lines: [], exhaustedNow: true };
763
+ }
764
+ const lines = [];
765
+ let bytes = 0;
766
+ while (this.pending.length > 0 && lines.length < WATCH_MAX_LINES_PER_PUSH) {
767
+ const next = this.pending[0];
768
+ const nextBytes = byteLen(next) + (lines.length > 0 ? 1 : 0);
769
+ if (bytes + nextBytes > WATCH_MAX_BYTES_PER_PUSH && lines.length > 0)
770
+ break;
771
+ this.pending.shift();
772
+ this.pendingBytes -= byteLen(next);
773
+ lines.push(next);
774
+ bytes += nextBytes;
775
+ }
776
+ this.state.pushes++;
777
+ return { lines, exhaustedNow: false };
778
+ }
779
+ takeUntilPush() {
780
+ const lines = [];
781
+ let bytes = 0;
782
+ while (this.pending.length > 0 && lines.length < WATCH_MAX_LINES_PER_PUSH) {
783
+ const next = this.pending.pop();
784
+ this.pendingBytes -= byteLen(next);
785
+ const nextBytes = byteLen(next) + (lines.length > 0 ? 1 : 0);
786
+ if (bytes + nextBytes > WATCH_MAX_BYTES_PER_PUSH && lines.length > 0) {
787
+ this.pending.push(next);
788
+ this.pendingBytes += byteLen(next);
789
+ break;
790
+ }
791
+ lines.unshift(next);
792
+ bytes += nextBytes;
793
+ }
794
+ this.state.droppedLines += this.pending.length;
795
+ this.pending = [];
796
+ this.pendingBytes = 0;
797
+ this.state.pushes++;
798
+ return lines;
799
+ }
800
+ }
801
+ function formatWatchMessage(title, lines, note) {
802
+ const header = `[watch "${title}"]`;
803
+ const tail = note ? [...lines, note].join(`
804
+ `) : lines.join(`
805
+ `);
806
+ if (!tail)
807
+ return header;
808
+ const room = Math.max(0, MAX_WATCH_MESSAGE_BYTES - byteLen(header) - 1);
809
+ return `${header}
810
+ ${tailLastBytes(tail, room)}`;
811
+ }
812
+ function formatWatchFloodMessage(title, dropped) {
813
+ return `[watch "${title}"] FLOOD: ${WATCH_FLOOD_LINES_PER_SEC}+ lines/sec exceeds the limit. ` + `Watch suspended (${dropped} line(s) dropped so far); the process keeps running.`;
814
+ }
815
+ function formatWatchBudgetMessage(title) {
816
+ return `[watch "${title}"] BUDGET: push limit (${WATCH_MAX_PUSHES_PER_COMMAND}) reached. ` + `Watch exhausted; further matches are dropped. The process keeps running.`;
817
+ }
818
+ var WATCH_COALESCE_WINDOW_MS = 2000, WATCH_MAX_LINES_PER_PUSH = 20, WATCH_MAX_BYTES_PER_PUSH, WATCH_MAX_PUSHES_PER_COMMAND = 30, WATCH_FLOOD_LINES_PER_SEC = 100, MAX_WATCH_MESSAGE_BYTES, DANGEROUS_REGEXES, ANSI_PATTERN;
819
+ var init_command_watch = __esm(() => {
820
+ init_command_await();
821
+ WATCH_MAX_BYTES_PER_PUSH = 4 * 1024;
822
+ MAX_WATCH_MESSAGE_BYTES = 4 * 1024;
823
+ DANGEROUS_REGEXES = [
824
+ /\(\?:.*\)\*.*\(\?:.*\)\*/,
825
+ /.*\(\.\*\?\)\{2,\}.*/,
826
+ /.*\(.*\|.*\)\{3,\}.*/,
827
+ /\([^()]*[+*][^()]*\)[+*]/,
828
+ /\([^()]*(\.\*.*\.\*|\.\+.*\.\+|\\w\+.*\\s\*|\\s\*.*\\w\+)[^()]*\)/
829
+ ];
830
+ ANSI_PATTERN = new RegExp("\x1B\\[[0-9;?]*[ -/]*[@-~]|\x1B\\][^\x07]*(?:\x07|\x1B\\\\)|\x1B[()][0-9A-Z]", "g");
831
+ });
832
+
588
833
  // src/application/command-await.ts
589
834
  import { randomUUID } from "crypto";
590
835
  function ledgerEvent(base) {
@@ -610,6 +855,18 @@ async function requestCommandAwait(directory, input) {
610
855
  if (goal.ownerSessionID !== command.ownerSessionID) {
611
856
  return { ok: false, message: "Owner mismatch: goal and command are owned by different sessions." };
612
857
  }
858
+ const until = input.until !== undefined && input.until !== "" ? input.until : undefined;
859
+ const ignoreCase = input.ignoreCase === true;
860
+ if (input.until !== undefined && until === undefined) {
861
+ return { ok: false, message: "Await 'until' must be a non-empty regex." };
862
+ }
863
+ if (until !== undefined) {
864
+ try {
865
+ compileWatchPattern("until", until, ignoreCase);
866
+ } catch (e) {
867
+ return { ok: false, message: e instanceof Error ? e.message : String(e) };
868
+ }
869
+ }
613
870
  const existing = (snapshot.commandAwaits ?? []).some((a) => a.goalID === input.goalID && a.commandID === input.commandID);
614
871
  if (existing) {
615
872
  return { ok: true, message: `Already awaiting "${command.title}" for goal "${goal.name}".` };
@@ -620,7 +877,8 @@ async function requestCommandAwait(directory, input) {
620
877
  goalID: input.goalID,
621
878
  commandID: input.commandID,
622
879
  ownerSessionID: input.ownerSessionID,
623
- createdAt: new Date().toISOString()
880
+ createdAt: new Date().toISOString(),
881
+ ...until !== undefined ? { until, ignoreCase } : {}
624
882
  }];
625
883
  return s;
626
884
  });
@@ -641,7 +899,8 @@ async function requestCommandAwait(directory, input) {
641
899
  goalID: input.goalID,
642
900
  commandID: input.commandID,
643
901
  ownerSessionID: input.ownerSessionID,
644
- createdAt: new Date().toISOString()
902
+ createdAt: new Date().toISOString(),
903
+ ...until !== undefined ? { until, ignoreCase } : {}
645
904
  }];
646
905
  }
647
906
  const current = (s.commands ?? []).find((c) => c.id === input.commandID);
@@ -660,12 +919,133 @@ async function requestCommandAwait(directory, input) {
660
919
  active: mine?.active ?? goal.status === "active"
661
920
  };
662
921
  }
922
+ if (until !== undefined) {
923
+ const retained = await readRetainedLines(directory, input.commandID);
924
+ const hit = retained.find((line) => testUntil(until, ignoreCase, line));
925
+ if (hit !== undefined) {
926
+ const fired = await fireUntilAwaits(directory, input.commandID, [hit]);
927
+ const mine = fired.find((f) => f.goalID === input.goalID);
928
+ if (mine) {
929
+ return {
930
+ ok: true,
931
+ message: `Pattern "${until}" already present in "${command.title}" output; wake-up delivered to goal "${goal.name}".`,
932
+ fired: true,
933
+ active: mine.active
934
+ };
935
+ }
936
+ const again = await fireCommandAwaits(directory, input.commandID);
937
+ const second = again.find((f) => f.goalID === input.goalID);
938
+ if (second) {
939
+ return {
940
+ ok: true,
941
+ message: `Command "${command.title}" exited while registering; wake-up delivered to goal "${goal.name}".`,
942
+ fired: true,
943
+ active: second.active
944
+ };
945
+ }
946
+ }
947
+ }
663
948
  await appendEvent(directory, ledgerEvent({
664
949
  goalID: input.goalID,
665
950
  commandID: input.commandID,
666
- type: "command.await-requested"
951
+ type: "command.await-requested",
952
+ ...until !== undefined ? { until } : {}
667
953
  })).catch(() => {});
668
- return { ok: true, message: `Goal "${goal.name}" now awaits "${command.title}" (fires once on exit).` };
954
+ return {
955
+ ok: true,
956
+ message: until !== undefined ? `Goal "${goal.name}" now awaits pattern "${until}" in "${command.title}" (fires once on first match; the command keeps running).` : `Goal "${goal.name}" now awaits "${command.title}" (fires once on exit).`
957
+ };
958
+ }
959
+ function testUntil(until, ignoreCase, line) {
960
+ try {
961
+ const re = new RegExp(until, ignoreCase ? "i" : "");
962
+ const hit = re.test(line);
963
+ re.lastIndex = 0;
964
+ return hit;
965
+ } catch {
966
+ return false;
967
+ }
968
+ }
969
+ async function readRetainedLines(directory, commandID) {
970
+ try {
971
+ const full = await readCommandLog(directory, commandID, {
972
+ offsetBytes: 0,
973
+ limitBytes: 512 * 1024
974
+ });
975
+ if (!full.text)
976
+ return [];
977
+ return full.text.split(`
978
+ `).filter((l) => l.trim() !== "");
979
+ } catch {
980
+ return [];
981
+ }
982
+ }
983
+ async function fireUntilAwaits(directory, commandID, lines) {
984
+ if (lines.length === 0)
985
+ return [];
986
+ let candidates = [];
987
+ let command;
988
+ await mutateState(directory, `cmd.await-until-consume:${commandID}`, async (s) => {
989
+ const outstanding = (s.commandAwaits ?? []).filter((a) => a.commandID === commandID && typeof a.until === "string" && a.until !== "");
990
+ if (outstanding.length === 0)
991
+ return s;
992
+ const current = (s.commands ?? []).find((c) => c.id === commandID);
993
+ if (!current)
994
+ return s;
995
+ command = current;
996
+ const hits = [];
997
+ for (const a of outstanding) {
998
+ const pattern = a.until;
999
+ if (lines.some((line) => testUntil(pattern, a.ignoreCase === true, line)))
1000
+ hits.push(a);
1001
+ }
1002
+ if (hits.length === 0)
1003
+ return s;
1004
+ const hitKeys = new Set(hits.map((h) => `${h.goalID}:${h.commandID}`));
1005
+ s.commandAwaits = (s.commandAwaits ?? []).filter((a) => !(a.commandID === commandID && hitKeys.has(`${a.goalID}:${a.commandID}`)));
1006
+ candidates = hits;
1007
+ return s;
1008
+ });
1009
+ if (candidates.length === 0 || !command)
1010
+ return [];
1011
+ const cmd = command;
1012
+ const tail = await readBoundedTail(directory, commandID);
1013
+ const fired = [];
1014
+ for (const a of candidates) {
1015
+ const pattern = a.until;
1016
+ const matched = lines.filter((line) => testUntil(pattern, a.ignoreCase === true, line));
1017
+ const fresh = await readState(directory);
1018
+ const goal = fresh.goals.find((g) => g.id === a.goalID);
1019
+ if (!goal) {
1020
+ await appendEvent(directory, ledgerEvent({
1021
+ goalID: a.goalID,
1022
+ commandID,
1023
+ type: "command.await-discarded",
1024
+ reason: "goal gone"
1025
+ })).catch(() => {});
1026
+ continue;
1027
+ }
1028
+ const short = commandID.slice(0, 8);
1029
+ const header = `[command "${safeCommandText(cmd.title)}" (${safeCommandText([cmd.command, ...cmd.args].join(" ") || cmd.title)}) ${cmd.status}` + ` ${short}... watch-until "${safeCommandText(pattern)}" matched]`;
1030
+ const matchedText = tailLastBytes(safeCommandText(matched.slice(0, 20).join(`
1031
+ `)), MAX_AWAIT_TAIL_BYTES);
1032
+ const evidence = tail ? `${header}
1033
+ ${matchedText}
1034
+ --- tail ---
1035
+ ${tailLastBytes(safeCommandText(tail))}` : `${header}
1036
+ ${matchedText}`;
1037
+ await appendGoalInbox(directory, a.goalID, "worker", evidence);
1038
+ await appendEvent(directory, ledgerEvent({
1039
+ goalID: a.goalID,
1040
+ commandID,
1041
+ type: "command.await-fired",
1042
+ until: pattern,
1043
+ matchedLines: matched.length,
1044
+ status: cmd.status
1045
+ })).catch(() => {});
1046
+ fired.push({ goalID: a.goalID, commandID, active: goal.status === "active" });
1047
+ }
1048
+ return fired;
669
1049
  }
670
1050
  async function readBoundedTail(directory, commandID) {
671
1051
  try {
@@ -848,6 +1228,7 @@ async function wakeGoalForAwait(directory, continuation, goalID) {
848
1228
  }
849
1229
  var init_command_await2 = __esm(() => {
850
1230
  init_command_await();
1231
+ init_command_watch();
851
1232
  init_state_repository();
852
1233
  });
853
1234
 
@@ -1312,8 +1693,11 @@ function createControlWorker(options) {
1312
1693
  case "cmd_write":
1313
1694
  case "cmd_interrupt":
1314
1695
  case "cmd_terminate":
1696
+ case "cmd_kill":
1697
+ case "cmd_restart":
1315
1698
  case "cmd_remove":
1316
- case "cmd_resize": {
1699
+ case "cmd_resize":
1700
+ case "cmd_watch": {
1317
1701
  const cmdSvc = options.commandService;
1318
1702
  if (!cmdSvc) {
1319
1703
  response = {
@@ -1363,10 +1747,28 @@ function createControlWorker(options) {
1363
1747
  const r = await cmdSvc.terminate(directory, id, ownerSessionID);
1364
1748
  const state = await readState(directory);
1365
1749
  response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1750
+ } else if (request.command === "cmd_kill") {
1751
+ const r = await cmdSvc.kill(directory, id, ownerSessionID);
1752
+ const state = await readState(directory);
1753
+ response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1754
+ } else if (request.command === "cmd_restart") {
1755
+ const r = await cmdSvc.restart(directory, id, ownerSessionID);
1756
+ const state = await readState(directory);
1757
+ response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1366
1758
  } else if (request.command === "cmd_remove") {
1367
1759
  const r = await cmdSvc.remove(directory, id, ownerSessionID);
1368
1760
  const state = await readState(directory);
1369
1761
  response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1762
+ } else if (request.command === "cmd_watch") {
1763
+ const r = await cmdSvc.watch(directory, id, ownerSessionID, {
1764
+ ...typeof args.watchFilter === "string" ? { filter: args.watchFilter } : {},
1765
+ ...typeof args.watchUntil === "string" ? { until: args.watchUntil } : {},
1766
+ ...typeof args.watchIgnoreCase === "boolean" ? { ignoreCase: args.watchIgnoreCase } : {},
1767
+ ...typeof args.watchUntilAction === "string" ? { untilAction: args.watchUntilAction } : {},
1768
+ ...typeof args.clear === "boolean" ? { clear: args.clear } : {}
1769
+ }).catch((error) => ({ ok: false, message: error instanceof Error ? error.message : String(error) }));
1770
+ const state = await readState(directory);
1771
+ response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1370
1772
  } else {
1371
1773
  const r = await cmdSvc.resize(directory, id, ownerSessionID, Number(args.cols), Number(args.rows));
1372
1774
  const state = await readState(directory);
@@ -2383,6 +2785,108 @@ import { promises as fs2 } from "fs";
2383
2785
 
2384
2786
  // src/server/host-adapter.ts
2385
2787
  import { randomUUID as randomUUID4 } from "crypto";
2788
+
2789
+ // src/v2/native-rpc.ts
2790
+ var NATIVE_RPC_ID = "loopd.native";
2791
+ var CLAIM_TIMEOUT_MS = 3000;
2792
+ var CLAIMED_TIMEOUT_MS = 15000;
2793
+ function resolveNativeParentID(child) {
2794
+ return child.fork?.sessionID ?? child.parentID ?? undefined;
2795
+ }
2796
+ var requestSchema = {
2797
+ type: "object",
2798
+ properties: {
2799
+ requestID: { type: "string" },
2800
+ goalID: { type: "string" },
2801
+ parentSessionID: { type: "string" },
2802
+ title: { type: "string" },
2803
+ agent: { type: "string" },
2804
+ model: {
2805
+ type: "object",
2806
+ properties: {
2807
+ id: { type: "string" },
2808
+ providerID: { type: "string" }
2809
+ },
2810
+ required: ["id", "providerID"],
2811
+ additionalProperties: false
2812
+ },
2813
+ permissions: {
2814
+ type: "array",
2815
+ items: {
2816
+ type: "object",
2817
+ properties: {
2818
+ action: { type: "string" },
2819
+ resource: { type: "string" },
2820
+ effect: { type: "string", enum: ["allow", "deny", "ask"] }
2821
+ },
2822
+ required: ["action", "resource", "effect"],
2823
+ additionalProperties: false
2824
+ }
2825
+ },
2826
+ directory: { type: "string" }
2827
+ },
2828
+ required: ["requestID", "goalID", "parentSessionID", "title"],
2829
+ additionalProperties: false
2830
+ };
2831
+ var claimResultSchema = {
2832
+ type: "object",
2833
+ properties: {
2834
+ requestID: { type: "string" },
2835
+ claimantID: { type: "string" }
2836
+ },
2837
+ required: ["requestID", "claimantID"],
2838
+ additionalProperties: false
2839
+ };
2840
+ var claimAckSchema = {
2841
+ type: "object",
2842
+ properties: {
2843
+ requestID: { type: "string" },
2844
+ claimantID: { type: "string" },
2845
+ won: { type: "boolean" }
2846
+ },
2847
+ required: ["requestID", "claimantID", "won"],
2848
+ additionalProperties: false
2849
+ };
2850
+ var createResultSchema = {
2851
+ type: "object",
2852
+ properties: {
2853
+ requestID: { type: "string" },
2854
+ childSessionID: { type: "string" },
2855
+ parentSessionID: { type: "string" },
2856
+ topology: { type: "string", const: "v2-native-child" }
2857
+ },
2858
+ required: ["requestID", "childSessionID", "parentSessionID", "topology"],
2859
+ additionalProperties: false
2860
+ };
2861
+ var failureSchema = {
2862
+ type: "object",
2863
+ properties: {
2864
+ requestID: { type: "string" },
2865
+ reason: { type: "string" },
2866
+ detail: { type: "string" },
2867
+ preCreation: { type: "boolean" }
2868
+ },
2869
+ required: ["requestID", "reason", "preCreation"],
2870
+ additionalProperties: false
2871
+ };
2872
+ var emptySchema = {
2873
+ type: "object",
2874
+ properties: {},
2875
+ additionalProperties: false
2876
+ };
2877
+ var nativeRpcDefinition = {
2878
+ id: NATIVE_RPC_ID,
2879
+ methods: {
2880
+ claimRequest: { input: claimResultSchema, output: claimAckSchema, errors: {} },
2881
+ completeWorkerCreate: { input: createResultSchema, output: emptySchema, errors: {} },
2882
+ failRequest: { input: failureSchema, output: emptySchema, errors: {} }
2883
+ },
2884
+ events: {
2885
+ workerCreateRequested: { schema: requestSchema }
2886
+ }
2887
+ };
2888
+
2889
+ // src/server/host-adapter.ts
2386
2890
  function parseModelRef(value) {
2387
2891
  if (value === undefined)
2388
2892
  return;
@@ -2413,7 +2917,6 @@ function shouldDedupParentNotify(ownerSessionID, message) {
2413
2917
  const last = recentParentNotifies.get(key);
2414
2918
  if (last !== undefined && now - last < 60000)
2415
2919
  return true;
2416
- recentParentNotifies.set(key, now);
2417
2920
  if (recentParentNotifies.size > 200) {
2418
2921
  for (const [k, t] of recentParentNotifies.entries())
2419
2922
  if (now - t > 60000)
@@ -2421,6 +2924,9 @@ function shouldDedupParentNotify(ownerSessionID, message) {
2421
2924
  }
2422
2925
  return false;
2423
2926
  }
2927
+ function markParentNotified2(ownerSessionID, message) {
2928
+ recentParentNotifies.set(`${ownerSessionID}:${message.slice(0, 200)}`, Date.now());
2929
+ }
2424
2930
  function createRealHost(client, directory) {
2425
2931
  return {
2426
2932
  async createWorker({ parentID, title, agent, model }) {
@@ -2566,7 +3072,7 @@ function createRealHost(client, directory) {
2566
3072
  async notifyOwner(ownerSessionID, message, agent) {
2567
3073
  if (shouldDedupParentNotify(ownerSessionID, message)) {
2568
3074
  await logServerEvent(directory, "parent.notify.deduped", { ownerSessionID, preview: message.slice(0, 160) });
2569
- return;
3075
+ return true;
2570
3076
  }
2571
3077
  let resolvedAgent = agent?.trim() || undefined;
2572
3078
  if (!resolvedAgent) {
@@ -2587,29 +3093,96 @@ function createRealHost(client, directory) {
2587
3093
  }), 1e4, "OpenCode parent notify");
2588
3094
  if (result?.error) {
2589
3095
  await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(result.error) });
3096
+ return false;
2590
3097
  } else {
3098
+ markParentNotified2(ownerSessionID, message);
2591
3099
  await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
3100
+ return true;
2592
3101
  }
2593
3102
  } catch (error) {
2594
3103
  await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
3104
+ return false;
2595
3105
  }
2596
3106
  }
2597
3107
  };
2598
3108
  }
2599
- function createV2Host(context, statuses) {
3109
+ function toWorkerCreation(result) {
3110
+ return typeof result === "string" ? { sessionID: result } : result;
3111
+ }
3112
+ function createV2Host(context, statuses, options = {}) {
2600
3113
  const directory = context.location.directory;
2601
3114
  return {
2602
- async createWorker({ parentID, title, agent, model }) {
3115
+ async createWorker({ parentID, title, agent, model, goalID }) {
3116
+ if (options.native) {
3117
+ let outcome;
3118
+ try {
3119
+ outcome = await options.native.requestWorker({
3120
+ goalID,
3121
+ parentSessionID: parentID,
3122
+ title,
3123
+ agent,
3124
+ model,
3125
+ directory
3126
+ });
3127
+ } catch (error) {
3128
+ await logServerEvent(directory, "worker.create.native-failed", {
3129
+ parentID,
3130
+ title,
3131
+ detail: describeError(error)
3132
+ });
3133
+ throw error;
3134
+ }
3135
+ if (outcome.kind === "native-child") {
3136
+ const childID = outcome.childSessionID;
3137
+ const child = await context.session.get({ sessionID: childID });
3138
+ const actualParent = resolveNativeParentID(child);
3139
+ if (actualParent !== parentID) {
3140
+ const detail = `resolved-parent=${JSON.stringify(actualParent)} expected=${JSON.stringify(parentID)}`;
3141
+ await logServerEvent(directory, "worker.create.parent-mismatch", { parentID, workerSessionID: childID, detail });
3142
+ throw new Error(`loopd native worker creation failed for parent "${parentID}" (parent-mismatch): ${detail}`);
3143
+ }
3144
+ if (agent)
3145
+ await context.session.switchAgent({ sessionID: childID, agent });
3146
+ if (model) {
3147
+ await context.session.switchModel({
3148
+ sessionID: childID,
3149
+ model: { id: model.modelID, providerID: model.providerID }
3150
+ });
3151
+ }
3152
+ const rename = context.session.update;
3153
+ if (rename)
3154
+ await rename({ sessionID: childID, title });
3155
+ statuses.set(childID, "idle");
3156
+ await logServerEvent(directory, "worker.created", {
3157
+ parentID,
3158
+ workerSessionID: childID,
3159
+ title,
3160
+ topology: "v2-native-child",
3161
+ nativeParentID: parentID
3162
+ });
3163
+ return { sessionID: childID, topology: "v2-native-child", nativeParentID: parentID };
3164
+ }
3165
+ await logServerEvent(directory, "worker.create.native-fallback", {
3166
+ parentID,
3167
+ title,
3168
+ reason: outcome.kind === "fallback-safe" ? outcome.reason : "unclaimed"
3169
+ });
3170
+ }
2603
3171
  const session = await context.session.create({
2604
3172
  title,
2605
- agent,
2606
- model: model ? { id: model.modelID, providerID: model.providerID } : undefined,
3173
+ ...agent !== undefined ? { agent } : {},
3174
+ ...model !== undefined ? { model: { id: model.modelID, providerID: model.providerID } } : {},
2607
3175
  location: { directory },
2608
3176
  metadata: { "loopd.parentID": parentID }
2609
3177
  });
2610
3178
  statuses.set(session.id, "idle");
2611
- await logServerEvent(directory, "worker.created", { parentID, workerSessionID: session.id, title });
2612
- return session.id;
3179
+ await logServerEvent(directory, "worker.created", {
3180
+ parentID,
3181
+ workerSessionID: session.id,
3182
+ title,
3183
+ topology: "v2-root-fallback"
3184
+ });
3185
+ return { sessionID: session.id, topology: "v2-root-fallback" };
2613
3186
  },
2614
3187
  async promptWorker({ sessionID, prompt, messageID, model, agent }) {
2615
3188
  if (agent)
@@ -2702,8 +3275,10 @@ function createV2Host(context, statuses) {
2702
3275
  try {
2703
3276
  await context.session.prompt({ sessionID: ownerSessionID, text: message });
2704
3277
  await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
3278
+ return true;
2705
3279
  } catch (error) {
2706
3280
  await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
3281
+ return false;
2707
3282
  }
2708
3283
  }
2709
3284
  };
@@ -2727,16 +3302,18 @@ async function withTimeout(promise, timeoutMs, operation) {
2727
3302
  function createWorkerManager(host) {
2728
3303
  return {
2729
3304
  async createWorker(goal) {
2730
- const workerSessionID = await host.createWorker({
3305
+ const created = toWorkerCreation(await host.createWorker({
2731
3306
  parentID: goal.ownerSessionID,
2732
3307
  title: `loopd: ${goal.name}`,
2733
3308
  agent: goal.config.agent,
2734
- model: parseModelRef(goal.config.model)
2735
- });
3309
+ model: parseModelRef(goal.config.model),
3310
+ goalID: goal.id
3311
+ }));
2736
3312
  return {
2737
3313
  goalID: goal.id,
2738
- workerSessionID,
2739
- startedAt: new Date().toISOString()
3314
+ workerSessionID: created.sessionID,
3315
+ startedAt: new Date().toISOString(),
3316
+ ...created.topology ? { topology: created.topology, nativeParentID: created.nativeParentID } : {}
2740
3317
  };
2741
3318
  },
2742
3319
  async continueWorker(worker, goal, runtime, context) {
@@ -2980,8 +3557,13 @@ function createGoalService(host) {
2980
3557
  sessions.set(goal.id, session);
2981
3558
  await mutateState(directory, `goal.set-worker:${goal.id}`, async (s) => {
2982
3559
  const persisted = s.goals.find((item) => item.id === goal.id);
2983
- if (persisted)
3560
+ if (persisted) {
2984
3561
  persisted.workerSessionID = session.workerSessionID;
3562
+ if (session.topology) {
3563
+ persisted.workerTopology = session.topology;
3564
+ persisted.nativeParentID = session.nativeParentID;
3565
+ }
3566
+ }
2985
3567
  return s;
2986
3568
  });
2987
3569
  return session;
@@ -3085,6 +3667,12 @@ function createGoalService(host) {
3085
3667
  return state;
3086
3668
  g.workerSessionID = worker.workerSessionID;
3087
3669
  goal.workerSessionID = worker.workerSessionID;
3670
+ if (worker.topology) {
3671
+ g.workerTopology = worker.topology;
3672
+ g.nativeParentID = worker.nativeParentID;
3673
+ goal.workerTopology = worker.topology;
3674
+ goal.nativeParentID = worker.nativeParentID;
3675
+ }
3088
3676
  const rt = state.runtimes.find((item) => item.goalID === id);
3089
3677
  if (rt) {
3090
3678
  Object.assign(rt, acquireLease(rt, g.config.timeoutMs || 300000));
@@ -3522,6 +4110,10 @@ function createGoalService(host) {
3522
4110
  const g = s.goals.find((item) => item.id === goal.id);
3523
4111
  if (g) {
3524
4112
  g.workerSessionID = worker.workerSessionID;
4113
+ if (worker.topology) {
4114
+ g.workerTopology = worker.topology;
4115
+ g.nativeParentID = worker.nativeParentID;
4116
+ }
3525
4117
  g.updatedAt = new Date().toISOString();
3526
4118
  }
3527
4119
  return s;
@@ -3790,6 +4382,168 @@ function createScheduleWorker(options) {
3790
4382
  return { start, stop, isRunning, tick };
3791
4383
  }
3792
4384
 
4385
+ // src/v2/native-server.ts
4386
+ import { randomUUID as randomUUID7 } from "crypto";
4387
+
4388
+ // src/v2/native-bridge.ts
4389
+ class NativeBridgeError extends Error {
4390
+ code;
4391
+ requestID;
4392
+ constructor(code, requestID, message) {
4393
+ super(message);
4394
+ this.name = "NativeBridgeError";
4395
+ this.code = code;
4396
+ this.requestID = requestID;
4397
+ }
4398
+ }
4399
+ function createNativeBridge(options = {}) {
4400
+ const claimTimeoutMs = options.claimTimeoutMs ?? CLAIM_TIMEOUT_MS;
4401
+ const claimedTimeoutMs = options.claimedTimeoutMs ?? CLAIMED_TIMEOUT_MS;
4402
+ const pending = new Map;
4403
+ let disposed = false;
4404
+ function settle(requestID, settleFn) {
4405
+ const entry = pending.get(requestID);
4406
+ if (!entry || entry.state === "settled")
4407
+ return false;
4408
+ entry.state = "settled";
4409
+ if (entry.claimTimer)
4410
+ clearTimeout(entry.claimTimer);
4411
+ if (entry.claimedTimer)
4412
+ clearTimeout(entry.claimedTimer);
4413
+ pending.delete(requestID);
4414
+ settleFn(entry);
4415
+ return true;
4416
+ }
4417
+ return {
4418
+ pendingCount: () => pending.size,
4419
+ isDisposed: () => disposed,
4420
+ requestWorker(request, emit) {
4421
+ if (disposed) {
4422
+ return Promise.reject(new NativeBridgeError("disposed", request.requestID, "native bridge is disposed"));
4423
+ }
4424
+ return new Promise((resolve, reject) => {
4425
+ const entry = { state: "awaiting-claim", resolve, reject };
4426
+ pending.set(request.requestID, entry);
4427
+ let emitResult;
4428
+ try {
4429
+ emitResult = emit(request);
4430
+ } catch {
4431
+ settle(request.requestID, (e) => e.resolve({ kind: "unclaimed" }));
4432
+ return;
4433
+ }
4434
+ const afterEmit = () => {
4435
+ const live = pending.get(request.requestID);
4436
+ if (!live || live.state !== "awaiting-claim")
4437
+ return;
4438
+ live.claimTimer = setTimeout(() => {
4439
+ settle(request.requestID, (e) => e.resolve({ kind: "unclaimed" }));
4440
+ }, claimTimeoutMs);
4441
+ if (typeof live.claimTimer?.unref === "function")
4442
+ live.claimTimer.unref();
4443
+ };
4444
+ if (emitResult && typeof emitResult.then === "function") {
4445
+ emitResult.then(afterEmit, () => {
4446
+ settle(request.requestID, (e) => e.resolve({ kind: "unclaimed" }));
4447
+ });
4448
+ } else {
4449
+ afterEmit();
4450
+ }
4451
+ });
4452
+ },
4453
+ handleClaim(claim) {
4454
+ const entry = pending.get(claim.requestID);
4455
+ if (!entry || entry.state !== "awaiting-claim")
4456
+ return { won: false };
4457
+ entry.state = "claimed";
4458
+ entry.claimantID = claim.claimantID;
4459
+ if (entry.claimTimer)
4460
+ clearTimeout(entry.claimTimer);
4461
+ entry.claimedTimer = setTimeout(() => {
4462
+ settle(claim.requestID, (e) => e.reject(new NativeBridgeError("claimed-timeout", claim.requestID, `claimant "${claim.claimantID}" vanished before completing worker creation; failing startup rather than duplicating`)));
4463
+ }, claimedTimeoutMs);
4464
+ if (typeof entry.claimedTimer?.unref === "function")
4465
+ entry.claimedTimer.unref();
4466
+ return { won: true };
4467
+ },
4468
+ handleComplete(result) {
4469
+ return settle(result.requestID, (entry) => entry.resolve({
4470
+ kind: "native-child",
4471
+ childSessionID: result.childSessionID,
4472
+ parentSessionID: result.parentSessionID,
4473
+ topology: "v2-native-child"
4474
+ }));
4475
+ },
4476
+ handleFailure(failure) {
4477
+ if (failure.preCreation) {
4478
+ return settle(failure.requestID, (entry) => entry.resolve({ kind: "fallback-safe", reason: failure.reason, detail: failure.detail }));
4479
+ }
4480
+ return settle(failure.requestID, (entry) => entry.reject(new NativeBridgeError(failure.reason === "parent-mismatch" ? "parent-mismatch" : "fork-failed", failure.requestID, `native worker creation failed post-creation (${failure.reason}): ${failure.detail ?? "no detail"}`)));
4481
+ },
4482
+ dispose() {
4483
+ disposed = true;
4484
+ const ids = [...pending.keys()];
4485
+ for (const id of ids) {
4486
+ settle(id, (entry) => entry.reject(new NativeBridgeError("disposed", id, "native bridge disposed with request pending")));
4487
+ }
4488
+ }
4489
+ };
4490
+ }
4491
+
4492
+ // src/v2/native-server.ts
4493
+ async function setupNativeServer(context) {
4494
+ if (!context || !context.rpc || typeof context.rpc.register !== "function")
4495
+ return;
4496
+ const bridge = createNativeBridge();
4497
+ let registration;
4498
+ try {
4499
+ registration = await context.rpc.register(nativeRpcDefinition, {
4500
+ claimRequest: async (input) => {
4501
+ const { won } = bridge.handleClaim(input);
4502
+ return { requestID: input.requestID, claimantID: input.claimantID, won };
4503
+ },
4504
+ completeWorkerCreate: async (input) => {
4505
+ bridge.handleComplete(input);
4506
+ return {};
4507
+ },
4508
+ failRequest: async (input) => {
4509
+ bridge.handleFailure(input);
4510
+ return {};
4511
+ }
4512
+ });
4513
+ } catch {
4514
+ bridge.dispose();
4515
+ return;
4516
+ }
4517
+ if (!registration || !registration.events || typeof registration.events.emit !== "function") {
4518
+ bridge.dispose();
4519
+ try {
4520
+ await registration?.dispose();
4521
+ } catch {}
4522
+ return;
4523
+ }
4524
+ return {
4525
+ bridge,
4526
+ requestWorker: async (input) => {
4527
+ const request = {
4528
+ requestID: `req_${randomUUID7()}`,
4529
+ goalID: input.goalID ?? "",
4530
+ parentSessionID: input.parentSessionID,
4531
+ title: input.title,
4532
+ ...input.agent ? { agent: input.agent } : {},
4533
+ ...input.model ? { model: { id: input.model.modelID, providerID: input.model.providerID } } : {},
4534
+ directory: input.directory
4535
+ };
4536
+ return bridge.requestWorker(request, (req) => registration.events.emit("workerCreateRequested", req));
4537
+ },
4538
+ dispose: async () => {
4539
+ bridge.dispose();
4540
+ try {
4541
+ await registration.dispose();
4542
+ } catch {}
4543
+ }
4544
+ };
4545
+ }
4546
+
3793
4547
  // src/server/command-host.ts
3794
4548
  import { createRequire } from "module";
3795
4549
  var COMMAND_HOST_CAPABILITIES = {
@@ -4040,21 +4794,45 @@ function createCommandHost(opts) {
4040
4794
  }
4041
4795
 
4042
4796
  // src/application/command-service.ts
4043
- import { randomUUID as randomUUID7 } from "crypto";
4797
+ import { randomUUID as randomUUID8 } from "crypto";
4044
4798
  import { promises as fs3 } from "fs";
4045
4799
  import path3 from "path";
4046
4800
 
4047
4801
  // src/domain/command-session.ts
4802
+ function normalizeTimeoutSeconds(timeoutSeconds) {
4803
+ if (timeoutSeconds === undefined)
4804
+ return;
4805
+ if (!Number.isInteger(timeoutSeconds) || timeoutSeconds <= 0) {
4806
+ throw new Error("timeoutSeconds must be a positive integer in seconds");
4807
+ }
4808
+ return timeoutSeconds;
4809
+ }
4810
+ function computeDeadlineAt(from, timeoutSeconds) {
4811
+ return new Date(from.getTime() + timeoutSeconds * 1000).toISOString();
4812
+ }
4048
4813
  function createCommandSession(input) {
4049
4814
  const now = new Date().toISOString();
4815
+ const timeout = normalizeTimeoutSeconds(input.timeoutSeconds);
4050
4816
  return {
4051
4817
  id: input.id,
4052
4818
  title: input.title,
4053
4819
  command: input.command,
4054
4820
  args: input.args ?? [],
4821
+ shell: input.shell,
4822
+ endReason: input.endReason,
4823
+ timeoutSeconds: timeout,
4824
+ deadlineAt: input.deadlineAt ?? (timeout !== undefined ? computeDeadlineAt(new Date(now), timeout) : undefined),
4825
+ envKeys: input.envKeys,
4826
+ watchFilter: input.watchFilter,
4827
+ watchUntil: input.watchUntil,
4828
+ watchIgnoreCase: input.watchIgnoreCase,
4829
+ watchUntilAction: input.watchUntilAction,
4830
+ watchState: input.watchState,
4055
4831
  cwd: input.cwd,
4056
4832
  ownerSessionID: input.ownerSessionID,
4057
4833
  goalID: input.goalID,
4834
+ notifyOnExit: input.notifyOnExit,
4835
+ notifySuccessfulExit: input.notifySuccessfulExit,
4058
4836
  status: "running",
4059
4837
  pid: input.pid,
4060
4838
  cols: input.cols,
@@ -4067,6 +4845,37 @@ function createCommandSession(input) {
4067
4845
  };
4068
4846
  }
4069
4847
  var MAX_COMMAND_OUTPUT_BYTES = 512 * 1024;
4848
+ var MAX_COMMAND_READ_LINES = 500;
4849
+ var NOTIFY_LONG_RUNNING_MS = 2 * 60 * 1000;
4850
+ function shouldNotifyOwnerOnExit(session) {
4851
+ if (session.endReason === "until")
4852
+ return false;
4853
+ if (session.notifyOnExit === false)
4854
+ return false;
4855
+ if (session.notifyOnExit === true)
4856
+ return true;
4857
+ if (session.endReason === "timeout")
4858
+ return true;
4859
+ if (session.status === "missing")
4860
+ return true;
4861
+ if (session.status === "terminated")
4862
+ return false;
4863
+ if (session.status !== "exited")
4864
+ return false;
4865
+ if (session.exitCode !== undefined && session.exitCode !== 0)
4866
+ return true;
4867
+ if (session.notifySuccessfulExit === true)
4868
+ return true;
4869
+ const started = Date.parse(session.createdAt);
4870
+ const ended = session.endedAt ? Date.parse(session.endedAt) : Date.now();
4871
+ if (!Number.isFinite(started) || !Number.isFinite(ended))
4872
+ return false;
4873
+ return ended - started >= NOTIFY_LONG_RUNNING_MS;
4874
+ }
4875
+
4876
+ // src/application/command-service.ts
4877
+ init_command_watch();
4878
+ init_command_await();
4070
4879
 
4071
4880
  // src/domain/command-events.ts
4072
4881
  var _encoder = new TextEncoder;
@@ -4243,14 +5052,173 @@ function validateCommandStreamMessage(value) {
4243
5052
  // src/application/command-service.ts
4244
5053
  init_command_await2();
4245
5054
  init_state_repository();
4246
- function loopCommandsDir(directory) {
4247
- return path3.join(directory, ".opencode", "loopd", "commands");
5055
+ var ANSI_PATTERN2 = new RegExp("\x1B\\[[0-9;?]*[ -/]*[@-~]|\x1B\\][^\x07]*(?:\x07|\x1B\\\\)|\x1B[()][0-9A-Z]", "g");
5056
+ function stripAnsiForMatch(line) {
5057
+ return line.replace(ANSI_PATTERN2, "");
4248
5058
  }
4249
- function createCommandService(host, opts) {
4250
- const live = new Map;
5059
+ var DANGEROUS_REGEXES2 = [
5060
+ /\(\?:.*\)\*.*\(\?:.*\)\*/,
5061
+ /.*\(\.\*\?\)\{2,\}.*/,
5062
+ /.*\(.*\|.*\)\{3,\}.*/,
5063
+ /\([^()]*[+*][^()]*\)[+*]/,
5064
+ /\([^()]*(\.\*.*\.\*|\.\+.*\.\+|\\w\+.*\\s\*|\\s\*.*\\w\+)[^()]*\)/
5065
+ ];
5066
+ function validateReadPattern(pattern) {
5067
+ let compiled;
5068
+ try {
5069
+ compiled = new RegExp(pattern);
5070
+ } catch (e) {
5071
+ return { error: `Invalid regex pattern '${pattern}': ${e instanceof Error ? e.message : String(e)}` };
5072
+ }
5073
+ if (DANGEROUS_REGEXES2.some((d) => d.test(pattern))) {
5074
+ return { error: `Potentially dangerous regex pattern rejected: '${pattern}'. Please use a safer pattern.` };
5075
+ }
5076
+ return compiled;
5077
+ }
5078
+ function shellQuote(word) {
5079
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(word))
5080
+ return word;
5081
+ return `'${word.replace(/'/g, `'\\''`)}'`;
5082
+ }
5083
+ function shellJoin(command, args) {
5084
+ return [command, ...args].map(shellQuote).join(" ");
5085
+ }
5086
+ function loopCommandsDir(directory) {
5087
+ return path3.join(directory, ".opencode", "loopd", "commands");
5088
+ }
5089
+ function createCommandService(host, opts) {
5090
+ const live = new Map;
4251
5091
  const operations = new Map;
4252
5092
  const broker = opts?.broker;
4253
5093
  const commandDirs = new Map;
5094
+ const timeouts = new Map;
5095
+ const timeoutOwners = new Map;
5096
+ const notifyInFlight = new Set;
5097
+ const notifyRetries = new Map;
5098
+ const notifyAttempts = new Map;
5099
+ function clearNotifyRetry(id) {
5100
+ const timer = notifyRetries.get(id);
5101
+ if (timer)
5102
+ clearTimeout(timer);
5103
+ notifyRetries.delete(id);
5104
+ notifyAttempts.delete(id);
5105
+ }
5106
+ function retryOwnerNotification(directory, id) {
5107
+ const attempt = (notifyAttempts.get(id) ?? 0) + 1;
5108
+ notifyAttempts.set(id, attempt);
5109
+ if (attempt > 3 || notifyRetries.has(id))
5110
+ return;
5111
+ const timer = setTimeout(() => {
5112
+ notifyRetries.delete(id);
5113
+ notifyOwnerIfNeeded(directory, id);
5114
+ }, 1000 * 2 ** (attempt - 1));
5115
+ timer.unref?.();
5116
+ notifyRetries.set(id, timer);
5117
+ }
5118
+ const watchers = new Map;
5119
+ const watchTimers = new Map;
5120
+ const awaitAsm = new Map;
5121
+ const watchCoalesceMs = opts?.watchCoalesceMs ?? WATCH_COALESCE_WINDOW_MS;
5122
+ function clearWatchTimer(id) {
5123
+ const t = watchTimers.get(id);
5124
+ if (t) {
5125
+ try {
5126
+ globalThis.clearTimeout(t);
5127
+ } catch {}
5128
+ }
5129
+ watchTimers.delete(id);
5130
+ }
5131
+ function deleteWatch(id) {
5132
+ clearWatchTimer(id);
5133
+ watchers.delete(id);
5134
+ awaitAsm.delete(id);
5135
+ }
5136
+ function scheduleWatchFlush(directory, id) {
5137
+ if (watchTimers.has(id))
5138
+ return;
5139
+ const handle = setTimeout(() => {
5140
+ watchTimers.delete(id);
5141
+ enqueue(id, () => flushWatch(directory, id)).catch(() => {});
5142
+ }, Math.max(0, watchCoalesceMs));
5143
+ try {
5144
+ handle.unref?.();
5145
+ } catch {}
5146
+ watchTimers.set(id, handle);
5147
+ }
5148
+ async function persistWatchState(directory, id, state) {
5149
+ await mutateState(directory, `cmd.watch:${id}`, async (s) => {
5150
+ const c = (s.commands ?? []).find((x) => x.id === id);
5151
+ if (c) {
5152
+ c.watchState = { ...state };
5153
+ c.updatedAt = new Date().toISOString();
5154
+ }
5155
+ return s;
5156
+ });
5157
+ }
5158
+ function watchLedger(directory, commandID, type, extra) {
5159
+ return appendEvent(directory, {
5160
+ version: 1,
5161
+ eventID: randomUUID8(),
5162
+ commandID,
5163
+ type,
5164
+ timestamp: new Date().toISOString(),
5165
+ revision: 0,
5166
+ ...extra
5167
+ }).catch(() => {});
5168
+ }
5169
+ function clearCommandTimeout(id) {
5170
+ const t = timeouts.get(id);
5171
+ if (t) {
5172
+ try {
5173
+ globalThis.clearTimeout(t);
5174
+ } catch {}
5175
+ }
5176
+ timeouts.delete(id);
5177
+ timeoutOwners.delete(id);
5178
+ }
5179
+ function scheduleTimeout(directory, id, ownerSessionID, timeoutSeconds) {
5180
+ clearCommandTimeout(id);
5181
+ const handle = setTimeout(() => {
5182
+ timeouts.delete(id);
5183
+ fireTimeout(directory, id).catch(() => {});
5184
+ }, timeoutSeconds * 1000);
5185
+ try {
5186
+ handle.unref?.();
5187
+ } catch {}
5188
+ timeouts.set(id, handle);
5189
+ timeoutOwners.set(id, { directory, ownerSessionID });
5190
+ }
5191
+ async function fireTimeout(directory, id) {
5192
+ const owner = timeoutOwners.get(id)?.ownerSessionID;
5193
+ timeoutOwners.delete(id);
5194
+ if (!owner)
5195
+ return;
5196
+ const before = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
5197
+ if (!before || before.status !== "running")
5198
+ return;
5199
+ await service.terminate(directory, id, owner).catch(() => {});
5200
+ await mutateState(directory, `cmd.timeout:${id}`, async (s) => {
5201
+ const c = (s.commands ?? []).find((x) => x.id === id);
5202
+ if (!c)
5203
+ return s;
5204
+ c.endReason = "timeout";
5205
+ c.lastError = c.lastError ?? `Command timed out after ${c.timeoutSeconds ?? "?"}s (timeoutSeconds deadline reached; terminated via the standard SIGTERM\u2192SIGKILL path).`;
5206
+ if (!c.endedAt) {
5207
+ c.endedAt = new Date().toISOString();
5208
+ c.updatedAt = c.endedAt;
5209
+ } else {
5210
+ c.updatedAt = new Date().toISOString();
5211
+ }
5212
+ return s;
5213
+ }).catch(() => {});
5214
+ try {
5215
+ const fresh = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
5216
+ if (fresh)
5217
+ emitBroker(id, { type: "status", command: fresh });
5218
+ } catch {}
5219
+ await fireAwaits(directory, id);
5220
+ await notifyOwnerIfNeeded(directory, id);
5221
+ }
4254
5222
  function tailText(entry, limitBytes = 64 * 1024) {
4255
5223
  let want = limitBytes;
4256
5224
  const parts = [];
@@ -4294,6 +5262,137 @@ function createCommandService(host, opts) {
4294
5262
  await opts?.onAwaitFired?.(directory, fired);
4295
5263
  } catch {}
4296
5264
  }
5265
+ async function notifyOwnerIfNeeded(directory, id) {
5266
+ if (!opts?.onOwnerNotify || notifyInFlight.has(id))
5267
+ return;
5268
+ notifyInFlight.add(id);
5269
+ try {
5270
+ const target = (await readState(directory)).commands?.find((c) => c.id === id);
5271
+ if (!target || target.ownerNotifiedAt || !isTerminalCommandStatus(target.status) || !shouldNotifyOwnerOnExit(target))
5272
+ return;
5273
+ const tail = await readBoundedTail(directory, id);
5274
+ const message = formatAwaitEvidence({
5275
+ title: target.title,
5276
+ argv: [target.command, ...target.args],
5277
+ commandID: target.id,
5278
+ status: target.status,
5279
+ exitCode: target.exitCode,
5280
+ signal: target.signal,
5281
+ tail
5282
+ });
5283
+ const accepted = await opts.onOwnerNotify(directory, target.ownerSessionID, message);
5284
+ if (accepted === false)
5285
+ throw new Error("host did not accept owner prompt");
5286
+ await mutateState(directory, `cmd.notify-delivered:${id}`, async (s) => {
5287
+ const c = (s.commands ?? []).find((x) => x.id === id);
5288
+ if (c && !c.ownerNotifiedAt)
5289
+ c.ownerNotifiedAt = new Date().toISOString();
5290
+ return s;
5291
+ });
5292
+ clearNotifyRetry(id);
5293
+ } catch (error) {
5294
+ await logServerEvent(directory, "command.owner-notify-failed", {
5295
+ commandID: id,
5296
+ detail: error instanceof Error ? error.message : String(error)
5297
+ }).catch(() => {});
5298
+ retryOwnerNotification(directory, id);
5299
+ } finally {
5300
+ notifyInFlight.delete(id);
5301
+ }
5302
+ }
5303
+ async function flushWatch(directory, id) {
5304
+ const watcher = watchers.get(id);
5305
+ if (!watcher || watcher.pendingCount === 0)
5306
+ return;
5307
+ let session;
5308
+ try {
5309
+ session = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
5310
+ } catch {
5311
+ return;
5312
+ }
5313
+ if (!session || session.status !== "running")
5314
+ return;
5315
+ const push = watcher.takePush();
5316
+ await persistWatchState(directory, id, watcher.state).catch(() => {});
5317
+ if (!push)
5318
+ return;
5319
+ if (push.exhaustedNow) {
5320
+ await watchLedger(directory, id, "command.watch-suspended", { reason: "budget", pushes: watcher.state.pushes });
5321
+ try {
5322
+ await opts?.onWatchNotify?.(directory, session.ownerSessionID, formatWatchBudgetMessage(session.title));
5323
+ } catch {}
5324
+ return;
5325
+ }
5326
+ await watchLedger(directory, id, "command.watch-matched", {
5327
+ lines: push.lines.length,
5328
+ matches: watcher.state.matches,
5329
+ pushes: watcher.state.pushes
5330
+ });
5331
+ try {
5332
+ await opts?.onWatchNotify?.(directory, session.ownerSessionID, formatWatchMessage(session.title, push.lines));
5333
+ } catch {}
5334
+ if (watcher.pendingCount > 0)
5335
+ scheduleWatchFlush(directory, id);
5336
+ }
5337
+ async function handleUntilMatch(directory, id) {
5338
+ const watcher = watchers.get(id);
5339
+ if (!watcher)
5340
+ return;
5341
+ clearWatchTimer(id);
5342
+ let session;
5343
+ try {
5344
+ session = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
5345
+ } catch {
5346
+ return;
5347
+ }
5348
+ if (!session)
5349
+ return;
5350
+ const action = watcher.untilAction;
5351
+ const lines = watcher.takeUntilPush();
5352
+ const snapshot = { ...watcher.state };
5353
+ const message = formatWatchMessage(session.title, lines, `-- until "${session.watchUntil ?? ""}" matched (action=${action})`);
5354
+ await mutateState(directory, `cmd.until-claim:${id}`, async (s) => {
5355
+ const c = (s.commands ?? []).find((x) => x.id === id);
5356
+ if (!c)
5357
+ return s;
5358
+ c.watchState = { ...snapshot };
5359
+ if (action === "stop" && !c.ownerNotifiedAt)
5360
+ c.ownerNotifiedAt = new Date().toISOString();
5361
+ c.updatedAt = new Date().toISOString();
5362
+ return s;
5363
+ }).catch(() => {});
5364
+ await watchLedger(directory, id, "command.watch-matched", {
5365
+ until: session.watchUntil ?? "",
5366
+ lines: lines.length,
5367
+ pushes: snapshot.pushes
5368
+ });
5369
+ try {
5370
+ await opts?.onWatchNotify?.(directory, session.ownerSessionID, message);
5371
+ } catch {}
5372
+ if (action !== "stop")
5373
+ return;
5374
+ if (session.status !== "running")
5375
+ return;
5376
+ await watchLedger(directory, id, "command.until-stopped", { until: session.watchUntil ?? "" });
5377
+ await service.terminate(directory, id, session.ownerSessionID).catch(() => {});
5378
+ await mutateState(directory, `cmd.until:${id}`, async (s) => {
5379
+ const c = (s.commands ?? []).find((x) => x.id === id);
5380
+ if (!c)
5381
+ return s;
5382
+ if (c.status === "terminated" && c.endReason !== "timeout") {
5383
+ c.endReason = "until";
5384
+ c.lastError = c.lastError ?? `Watch until pattern "${c.watchUntil ?? ""}" matched; command stopped (untilAction=stop).`;
5385
+ c.updatedAt = new Date().toISOString();
5386
+ }
5387
+ return s;
5388
+ }).catch(() => {});
5389
+ try {
5390
+ const fresh = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
5391
+ if (fresh)
5392
+ emitBroker(id, { type: "status", command: fresh });
5393
+ } catch {}
5394
+ await fireAwaits(directory, id);
5395
+ }
4297
5396
  async function readBrokerSnapshot(commandID, ownerSessionID) {
4298
5397
  const directory = commandDirs.get(commandID);
4299
5398
  if (!directory)
@@ -4343,6 +5442,43 @@ function createCommandService(host, opts) {
4343
5442
  entry.bufferedBytes -= dropped.length;
4344
5443
  }
4345
5444
  }
5445
+ let watcher;
5446
+ let feed;
5447
+ let bareLines = [];
5448
+ try {
5449
+ watcher = watchers.get(id);
5450
+ if (!watcher) {
5451
+ const spec = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id)).catch(() => {
5452
+ return;
5453
+ });
5454
+ if (spec && spec.status === "running" && (spec.watchFilter !== undefined || spec.watchUntil !== undefined)) {
5455
+ try {
5456
+ watcher = new CommandWatcher({
5457
+ ...spec.watchFilter !== undefined ? { filter: spec.watchFilter } : {},
5458
+ ...spec.watchUntil !== undefined ? { until: spec.watchUntil } : {},
5459
+ ...spec.watchIgnoreCase !== undefined ? { ignoreCase: spec.watchIgnoreCase } : {},
5460
+ ...spec.watchUntilAction !== undefined ? { untilAction: spec.watchUntilAction } : {}
5461
+ });
5462
+ watchers.set(id, watcher);
5463
+ } catch {
5464
+ watcher = undefined;
5465
+ }
5466
+ }
5467
+ }
5468
+ if (watcher) {
5469
+ feed = watcher.feedChunk(chunk, Date.now());
5470
+ } else {
5471
+ let asm = awaitAsm.get(id);
5472
+ if (!asm) {
5473
+ asm = createLineAssembler();
5474
+ awaitAsm.set(id, asm);
5475
+ }
5476
+ bareLines = asm.push(chunk).filter((l) => l.trim() !== "");
5477
+ }
5478
+ } catch {
5479
+ watcher = undefined;
5480
+ feed = undefined;
5481
+ }
4346
5482
  await appendCommandLog(directory, id, chunk);
4347
5483
  let retainedBytes = 0;
4348
5484
  let truncated = false;
@@ -4372,6 +5508,8 @@ function createCommandService(host, opts) {
4372
5508
  c.outputBytes = retainedBytes;
4373
5509
  if (truncated)
4374
5510
  c.truncated = true;
5511
+ if (watcher)
5512
+ c.watchState = { ...watcher.state };
4375
5513
  c.updatedAt = new Date().toISOString();
4376
5514
  return s;
4377
5515
  });
@@ -4389,19 +5527,104 @@ function createCommandService(host, opts) {
4389
5527
  });
4390
5528
  }
4391
5529
  } catch {}
5530
+ try {
5531
+ if (watcher && feed) {
5532
+ if (feed.floodNow) {
5533
+ await persistWatchState(directory, id, watcher.state).catch(() => {});
5534
+ const dropped = watcher.state.droppedLines;
5535
+ let owner;
5536
+ let title = id;
5537
+ try {
5538
+ const snap = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
5539
+ owner = snap?.ownerSessionID;
5540
+ title = snap?.title ?? id;
5541
+ } catch {}
5542
+ await watchLedger(directory, id, "command.watch-suspended", { reason: "flood", dropped });
5543
+ if (owner) {
5544
+ try {
5545
+ await opts?.onWatchNotify?.(directory, owner, formatWatchFloodMessage(title, dropped));
5546
+ } catch {}
5547
+ }
5548
+ }
5549
+ if (watcher.pendingCount > 0)
5550
+ scheduleWatchFlush(directory, id);
5551
+ if (feed.untilLine) {
5552
+ clearWatchTimer(id);
5553
+ enqueue(id, () => handleUntilMatch(directory, id)).catch(() => {});
5554
+ }
5555
+ }
5556
+ } catch {}
5557
+ try {
5558
+ const completed = feed ? feed.completed : bareLines;
5559
+ if (completed.length > 0) {
5560
+ const fired = await fireUntilAwaits(directory, id, completed);
5561
+ if (fired.length > 0) {
5562
+ await opts?.onAwaitFired?.(directory, fired);
5563
+ }
5564
+ }
5565
+ } catch {}
4392
5566
  }
4393
5567
  async function persistExit(directory, id, info) {
4394
5568
  rememberDir(id, directory);
4395
5569
  live.delete(id);
5570
+ clearCommandTimeout(id);
5571
+ const exitWatcher = watchers.get(id);
5572
+ if (exitWatcher) {
5573
+ try {
5574
+ const handledStop = exitWatcher.state.state === "until-matched" && exitWatcher.untilAction === "stop";
5575
+ exitWatcher.finish(Date.now());
5576
+ let snap;
5577
+ try {
5578
+ snap = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
5579
+ } catch {
5580
+ snap = undefined;
5581
+ }
5582
+ if (snap && !handledStop && exitWatcher.pendingCount > 0) {
5583
+ const push = exitWatcher.takePush();
5584
+ await persistWatchState(directory, id, exitWatcher.state).catch(() => {});
5585
+ if (push && !push.exhaustedNow && push.lines.length > 0) {
5586
+ await watchLedger(directory, id, "command.watch-matched", {
5587
+ lines: push.lines.length,
5588
+ atExit: true,
5589
+ pushes: exitWatcher.state.pushes
5590
+ });
5591
+ try {
5592
+ await opts?.onWatchNotify?.(directory, snap.ownerSessionID, formatWatchMessage(snap.title, push.lines));
5593
+ } catch {}
5594
+ } else if (push?.exhaustedNow) {
5595
+ await watchLedger(directory, id, "command.watch-suspended", {
5596
+ reason: "budget",
5597
+ atExit: true,
5598
+ pushes: exitWatcher.state.pushes
5599
+ });
5600
+ try {
5601
+ await opts?.onWatchNotify?.(directory, snap.ownerSessionID, formatWatchBudgetMessage(snap.title));
5602
+ } catch {}
5603
+ }
5604
+ } else {
5605
+ await persistWatchState(directory, id, exitWatcher.state).catch(() => {});
5606
+ }
5607
+ } catch {}
5608
+ }
5609
+ deleteWatch(id);
4396
5610
  await mutateState(directory, `cmd.exit:${id}`, async (s) => {
4397
5611
  const c = (s.commands ?? []).find((x) => x.id === id);
4398
5612
  if (!c || c.status !== "running")
4399
5613
  return s;
5614
+ if (c.endReason === "timeout") {
5615
+ if (!c.endedAt) {
5616
+ c.endedAt = new Date().toISOString();
5617
+ c.updatedAt = c.endedAt;
5618
+ }
5619
+ return s;
5620
+ }
4400
5621
  c.exitCode = info.exitCode;
4401
5622
  if (info.signal && (c.signal === "SIGKILL" || c.signal === "SIGTERM")) {
4402
5623
  c.status = "terminated";
5624
+ c.endReason = "terminate";
4403
5625
  } else {
4404
5626
  c.status = "exited";
5627
+ c.endReason = "exit";
4405
5628
  if (info.signal)
4406
5629
  c.signal = info.signal;
4407
5630
  }
@@ -4411,7 +5634,7 @@ function createCommandService(host, opts) {
4411
5634
  }).catch(() => {});
4412
5635
  await appendEvent(directory, {
4413
5636
  version: 1,
4414
- eventID: randomUUID7(),
5637
+ eventID: randomUUID8(),
4415
5638
  commandID: id,
4416
5639
  type: "command.exited",
4417
5640
  exitCode: info.exitCode,
@@ -4424,11 +5647,12 @@ function createCommandService(host, opts) {
4424
5647
  emitBroker(id, { type: "status", command: fresh });
4425
5648
  } catch {}
4426
5649
  await fireAwaits(directory, id);
5650
+ await notifyOwnerIfNeeded(directory, id);
4427
5651
  }
4428
5652
  function owned(cmd, ownerSessionID) {
4429
5653
  return !!cmd && cmd.ownerSessionID === ownerSessionID;
4430
5654
  }
4431
- return {
5655
+ const service = {
4432
5656
  async start(directory, input) {
4433
5657
  const title = input.title.trim();
4434
5658
  const command = input.command.trim();
@@ -4439,11 +5663,29 @@ function createCommandService(host, opts) {
4439
5663
  if (!input.ownerSessionID || input.ownerSessionID === "main") {
4440
5664
  throw new Error("A valid owner session is required. Run from an active OpenCode session.");
4441
5665
  }
4442
- const id = randomUUID7();
5666
+ const timeoutSeconds = normalizeTimeoutSeconds(input.timeoutSeconds);
5667
+ const watchFilter = input.watchFilter !== undefined && input.watchFilter !== "" ? input.watchFilter : undefined;
5668
+ const watchUntil = input.watchUntil !== undefined && input.watchUntil !== "" ? input.watchUntil : undefined;
5669
+ const hasWatch = watchFilter !== undefined || watchUntil !== undefined;
5670
+ let watcher;
5671
+ let watchState;
5672
+ if (hasWatch) {
5673
+ watcher = new CommandWatcher({
5674
+ ...watchFilter !== undefined ? { filter: watchFilter } : {},
5675
+ ...watchUntil !== undefined ? { until: watchUntil } : {},
5676
+ ...input.watchIgnoreCase !== undefined ? { ignoreCase: input.watchIgnoreCase } : {},
5677
+ ...input.watchUntilAction !== undefined ? { untilAction: input.watchUntilAction } : {}
5678
+ });
5679
+ watchState = initialWatchState();
5680
+ }
5681
+ const startedAt = new Date;
5682
+ const id = randomUUID8();
4443
5683
  const cwd = input.cwd || directory;
5684
+ const useShell = input.shell === true;
5685
+ const spawnOpts = useShell ? { command: "/bin/sh", args: ["-c", shellJoin(command, input.args ?? [])], cwd, cols: input.cols, rows: input.rows, env: input.env } : { command, args: input.args ?? [], cwd, cols: input.cols, rows: input.rows, env: input.env };
4444
5686
  const pending = [];
4445
5687
  let ready = false;
4446
- const handle = host.spawn({ command, args: input.args ?? [], cwd, cols: input.cols, rows: input.rows }, (chunk) => {
5688
+ const handle = host.spawn(spawnOpts, (chunk) => {
4447
5689
  if (!ready)
4448
5690
  pending.push({ type: "output", chunk });
4449
5691
  else
@@ -4462,9 +5704,20 @@ function createCommandService(host, opts) {
4462
5704
  cwd,
4463
5705
  ownerSessionID: input.ownerSessionID,
4464
5706
  goalID: input.goalID,
5707
+ notifyOnExit: input.notifyOnExit,
5708
+ notifySuccessfulExit: true,
4465
5709
  pid: handle.pid,
4466
5710
  cols: input.cols,
4467
- rows: input.rows
5711
+ rows: input.rows,
5712
+ shell: useShell ? true : undefined,
5713
+ timeoutSeconds,
5714
+ deadlineAt: timeoutSeconds !== undefined ? computeDeadlineAt(startedAt, timeoutSeconds) : undefined,
5715
+ envKeys: input.env ? Object.keys(input.env) : undefined,
5716
+ watchFilter,
5717
+ watchUntil,
5718
+ watchIgnoreCase: input.watchIgnoreCase,
5719
+ watchUntilAction: watcher?.untilAction,
5720
+ watchState
4468
5721
  });
4469
5722
  try {
4470
5723
  await mutateState(directory, `cmd.start:${id}`, async (s) => {
@@ -4482,10 +5735,14 @@ function createCommandService(host, opts) {
4482
5735
  throw error;
4483
5736
  }
4484
5737
  live.set(id, { handle, buffers: [], bufferedBytes: 0 });
5738
+ if (watcher)
5739
+ watchers.set(id, watcher);
5740
+ if (timeoutSeconds !== undefined)
5741
+ scheduleTimeout(directory, id, input.ownerSessionID, timeoutSeconds);
4485
5742
  rememberDir(id, directory);
4486
5743
  await appendEvent(directory, {
4487
5744
  version: 1,
4488
- eventID: randomUUID7(),
5745
+ eventID: randomUUID8(),
4489
5746
  ...input.goalID ? { goalID: input.goalID } : {},
4490
5747
  commandID: id,
4491
5748
  type: "command.started",
@@ -4505,7 +5762,7 @@ function createCommandService(host, opts) {
4505
5762
  initialOperations.push(event.type === "output" ? enqueue(id, () => persistOutput(directory, id, event.chunk)) : enqueue(id, () => persistExit(directory, id, event.info)));
4506
5763
  }
4507
5764
  await Promise.all(initialOperations);
4508
- return await this.get(directory, id, input.ownerSessionID) ?? session;
5765
+ return await service.get(directory, id, input.ownerSessionID) ?? session;
4509
5766
  },
4510
5767
  async list(directory, ownerSessionID) {
4511
5768
  const s = await readState(directory);
@@ -4521,9 +5778,52 @@ function createCommandService(host, opts) {
4521
5778
  },
4522
5779
  async read(directory, id, ownerSessionID, opts) {
4523
5780
  await waitForOperations(id);
4524
- const session = await this.get(directory, id, ownerSessionID);
5781
+ const session = await service.get(directory, id, ownerSessionID);
4525
5782
  if (!session)
4526
5783
  return;
5784
+ if (opts?.pattern !== undefined) {
5785
+ const flags = opts.ignoreCase ? "i" : "";
5786
+ const checked = validateReadPattern(opts.pattern);
5787
+ if (typeof checked !== "object" || !(checked instanceof RegExp)) {
5788
+ throw new Error(checked.error);
5789
+ }
5790
+ const regex = new RegExp(opts.pattern, flags);
5791
+ const full = await readCommandLog(directory, id, { offsetBytes: 0, limitBytes: MAX_COMMAND_OUTPUT_BYTES });
5792
+ const lines = full.text.length === 0 ? [] : full.text.split(`
5793
+ `);
5794
+ const matches = [];
5795
+ for (const line of lines) {
5796
+ let stripped;
5797
+ try {
5798
+ stripped = stripAnsiForMatch(line);
5799
+ } catch {
5800
+ stripped = line;
5801
+ }
5802
+ let hit = false;
5803
+ try {
5804
+ hit = regex.test(stripped);
5805
+ } catch {
5806
+ throw new Error(`Invalid regex pattern '${opts.pattern}'.`);
5807
+ }
5808
+ regex.lastIndex = 0;
5809
+ if (hit)
5810
+ matches.push(line);
5811
+ }
5812
+ const skip = Math.max(0, opts.offsetBytes ?? 0);
5813
+ const take = Math.min(opts.limitBytes ?? MAX_COMMAND_READ_LINES, MAX_COMMAND_READ_LINES);
5814
+ const page = matches.slice(skip, skip + Math.max(0, take));
5815
+ return {
5816
+ session,
5817
+ text: page.join(`
5818
+ `) + (page.length > 0 ? `
5819
+ ` : ""),
5820
+ totalBytes: matches.length,
5821
+ startByte: skip,
5822
+ live: session.status === "running",
5823
+ pattern: opts.pattern,
5824
+ totalMatches: matches.length
5825
+ };
5826
+ }
4527
5827
  const entry = live.get(id);
4528
5828
  const offset = opts?.offsetBytes ?? 0;
4529
5829
  const limit = Math.min(opts?.limitBytes ?? 64 * 1024, 256 * 1024);
@@ -4539,7 +5839,7 @@ function createCommandService(host, opts) {
4539
5839
  return { session, text: file.text, totalBytes: session.outputBytes, startByte: file.startByte, live: session.status === "running" };
4540
5840
  },
4541
5841
  async write(directory, id, ownerSessionID, data) {
4542
- const session = await this.get(directory, id, ownerSessionID);
5842
+ const session = await service.get(directory, id, ownerSessionID);
4543
5843
  if (!session)
4544
5844
  return { ok: false, message: "Command not found." };
4545
5845
  if (session.status !== "running")
@@ -4551,7 +5851,7 @@ function createCommandService(host, opts) {
4551
5851
  return { ok: true, message: `Sent ${Buffer.byteLength(data)} byte(s) to "${session.title}".` };
4552
5852
  },
4553
5853
  async resize(directory, id, ownerSessionID, cols, rows) {
4554
- const session = await this.get(directory, id, ownerSessionID);
5854
+ const session = await service.get(directory, id, ownerSessionID);
4555
5855
  if (!session)
4556
5856
  return { ok: false, message: "Command not found." };
4557
5857
  if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols <= 0 || rows <= 0) {
@@ -4580,7 +5880,7 @@ function createCommandService(host, opts) {
4580
5880
  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
5881
  },
4582
5882
  async interrupt(directory, id, ownerSessionID) {
4583
- const session = await this.get(directory, id, ownerSessionID);
5883
+ const session = await service.get(directory, id, ownerSessionID);
4584
5884
  if (!session)
4585
5885
  return { ok: false, message: "Command not found." };
4586
5886
  if (session.status !== "running")
@@ -4601,13 +5901,18 @@ function createCommandService(host, opts) {
4601
5901
  });
4602
5902
  return { ok: true, message: `SIGINT delivered to "${session.title}" (process may continue if it traps the signal).` };
4603
5903
  },
4604
- async terminate(directory, id, ownerSessionID) {
5904
+ async terminate(directory, id, ownerSessionID, opts) {
4605
5905
  rememberDir(id, directory);
4606
- const session = await this.get(directory, id, ownerSessionID);
5906
+ const session = await service.get(directory, id, ownerSessionID);
4607
5907
  if (!session)
4608
5908
  return { ok: false, message: "Command not found." };
4609
- if (session.status !== "running")
5909
+ if (session.status !== "running") {
5910
+ if (opts?.remove) {
5911
+ const removed = await service.remove(directory, id, ownerSessionID);
5912
+ return removed.ok ? { ok: true, message: `Command was already ${session.status}; removed.`, removed: true } : { ok: false, message: `Command is ${session.status}; remove failed: ${removed.message}` };
5913
+ }
4610
5914
  return { ok: false, message: `Command is ${session.status}; nothing to terminate.` };
5915
+ }
4611
5916
  await mutateState(directory, `cmd.terminate-claim:${id}`, async (s) => {
4612
5917
  const c = (s.commands ?? []).find((x) => x.id === id);
4613
5918
  if (c && c.ownerSessionID === ownerSessionID && c.status === "running") {
@@ -4642,18 +5947,24 @@ function createCommandService(host, opts) {
4642
5947
  live.delete(id);
4643
5948
  }
4644
5949
  const exitCode = status === "terminated" ? 143 : undefined;
5950
+ clearCommandTimeout(id);
5951
+ clearWatchTimer(id);
4645
5952
  await mutateState(directory, `cmd.terminate:${id}`, async (s) => {
4646
5953
  const c = (s.commands ?? []).find((x) => x.id === id);
4647
5954
  if (!c || c.ownerSessionID !== ownerSessionID)
4648
5955
  return s;
4649
5956
  if (c.status === "running") {
4650
5957
  c.status = status;
5958
+ if (c.endReason !== "timeout")
5959
+ c.endReason = status === "terminated" ? "terminate" : "exit";
4651
5960
  if (exitCode !== undefined)
4652
5961
  c.exitCode = c.exitCode ?? exitCode;
4653
5962
  c.signal = c.signal ?? "SIGTERM";
4654
5963
  c.endedAt = new Date().toISOString();
4655
5964
  c.updatedAt = c.endedAt;
4656
5965
  } else if (c.status === "terminated" && !c.endedAt) {
5966
+ if (c.endReason !== "timeout" && !c.endReason)
5967
+ c.endReason = "terminate";
4657
5968
  if (exitCode !== undefined)
4658
5969
  c.exitCode = c.exitCode ?? exitCode;
4659
5970
  c.endedAt = new Date().toISOString();
@@ -4667,16 +5978,123 @@ function createCommandService(host, opts) {
4667
5978
  emitBroker(id, { type: "status", command: fresh });
4668
5979
  } catch {}
4669
5980
  await fireAwaits(directory, id);
5981
+ await notifyOwnerIfNeeded(directory, id);
5982
+ if (opts?.remove) {
5983
+ const removed = await service.remove(directory, id, ownerSessionID);
5984
+ if (removed.ok)
5985
+ return { ok: true, message: `Command "${session.title}" ${status}; removed.`, removed: true };
5986
+ return { ok: true, message: `Command "${session.title}" ${status}; remove failed: ${removed.message}` };
5987
+ }
4670
5988
  return { ok: true, message: `Command "${session.title}" ${status}.` };
4671
5989
  },
5990
+ async kill(directory, id, ownerSessionID) {
5991
+ rememberDir(id, directory);
5992
+ const session = await service.get(directory, id, ownerSessionID);
5993
+ if (!session)
5994
+ return { ok: false, message: "Command not found." };
5995
+ if (session.status !== "running") {
5996
+ return { ok: false, message: `Command is ${session.status}; nothing to kill.` };
5997
+ }
5998
+ await mutateState(directory, `cmd.kill-claim:${id}`, async (s) => {
5999
+ const c = (s.commands ?? []).find((x) => x.id === id);
6000
+ if (c && c.ownerSessionID === ownerSessionID && c.status === "running") {
6001
+ c.signal = "SIGKILL";
6002
+ c.updatedAt = new Date().toISOString();
6003
+ }
6004
+ return s;
6005
+ }).catch(() => {});
6006
+ try {
6007
+ const claimed = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
6008
+ if (claimed)
6009
+ emitBroker(id, { type: "status", command: claimed });
6010
+ } catch {}
6011
+ const entry = live.get(id);
6012
+ if (entry) {
6013
+ if (entry.handle.isAlive())
6014
+ entry.handle.kill();
6015
+ try {
6016
+ await entry.handle.exited();
6017
+ } catch {}
6018
+ live.delete(id);
6019
+ }
6020
+ clearCommandTimeout(id);
6021
+ clearWatchTimer(id);
6022
+ await mutateState(directory, `cmd.kill:${id}`, async (s) => {
6023
+ const c = (s.commands ?? []).find((x) => x.id === id);
6024
+ if (!c || c.ownerSessionID !== ownerSessionID)
6025
+ return s;
6026
+ if (c.status === "running") {
6027
+ c.status = "terminated";
6028
+ if (c.endReason !== "timeout")
6029
+ c.endReason = "terminate";
6030
+ c.exitCode = c.exitCode ?? 137;
6031
+ c.signal = c.signal ?? "SIGKILL";
6032
+ c.endedAt = new Date().toISOString();
6033
+ c.updatedAt = c.endedAt;
6034
+ } else if (c.status === "terminated" && !c.endedAt) {
6035
+ if (c.endReason !== "timeout" && !c.endReason)
6036
+ c.endReason = "terminate";
6037
+ c.exitCode = c.exitCode ?? 137;
6038
+ c.endedAt = new Date().toISOString();
6039
+ c.updatedAt = c.endedAt;
6040
+ }
6041
+ return s;
6042
+ });
6043
+ try {
6044
+ const fresh = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
6045
+ if (fresh)
6046
+ emitBroker(id, { type: "status", command: fresh });
6047
+ } catch {}
6048
+ await fireAwaits(directory, id);
6049
+ await notifyOwnerIfNeeded(directory, id);
6050
+ return { ok: true, message: `Command "${session.title}" killed (SIGKILL).` };
6051
+ },
6052
+ async restart(directory, id, ownerSessionID) {
6053
+ rememberDir(id, directory);
6054
+ const session = await service.get(directory, id, ownerSessionID);
6055
+ if (!session)
6056
+ return { ok: false, message: "Command not found." };
6057
+ const spec = {
6058
+ title: session.title,
6059
+ command: session.command,
6060
+ args: [...session.args],
6061
+ cwd: session.cwd,
6062
+ ownerSessionID,
6063
+ ...session.goalID !== undefined ? { goalID: session.goalID } : {},
6064
+ ...session.notifyOnExit !== undefined ? { notifyOnExit: session.notifyOnExit } : {},
6065
+ ...session.cols !== undefined ? { cols: session.cols } : {},
6066
+ ...session.rows !== undefined ? { rows: session.rows } : {},
6067
+ ...session.shell !== undefined ? { shell: session.shell } : {},
6068
+ ...session.timeoutSeconds !== undefined ? { timeoutSeconds: session.timeoutSeconds } : {},
6069
+ ...session.watchFilter !== undefined ? { watchFilter: session.watchFilter } : {},
6070
+ ...session.watchUntil !== undefined ? { watchUntil: session.watchUntil } : {},
6071
+ ...session.watchIgnoreCase !== undefined ? { watchIgnoreCase: session.watchIgnoreCase } : {},
6072
+ ...session.watchUntilAction !== undefined ? { watchUntilAction: session.watchUntilAction } : {}
6073
+ };
6074
+ if (session.status === "running") {
6075
+ const stopped = await service.terminate(directory, id, ownerSessionID);
6076
+ if (!stopped.ok) {
6077
+ return { ok: false, message: `Restart aborted: could not stop "${session.title}": ${stopped.message}` };
6078
+ }
6079
+ }
6080
+ const next = await service.start(directory, spec);
6081
+ const envNote = (session.envKeys?.length ?? 0) > 0 ? ` (env ${session.envKeys.join(",")} NOT restored \u2014 values are never persisted)` : "";
6082
+ return {
6083
+ ok: true,
6084
+ message: `Command "${session.title}" restarted as ${next.id.slice(0, 8)}...${envNote}`,
6085
+ command: next
6086
+ };
6087
+ },
4672
6088
  async remove(directory, id, ownerSessionID) {
4673
6089
  rememberDir(id, directory);
4674
- const session = await this.get(directory, id, ownerSessionID);
6090
+ const session = await service.get(directory, id, ownerSessionID);
4675
6091
  if (!session)
4676
6092
  return { ok: false, message: "Command not found." };
4677
6093
  if (session.status === "running") {
4678
6094
  return { ok: false, message: `Command "${session.title}" is still running \u2014 terminate it first (terminate \u2260 remove).` };
4679
6095
  }
6096
+ clearNotifyRetry(id);
6097
+ deleteWatch(id);
4680
6098
  const lastKnown = { ...session };
4681
6099
  await mutateState(directory, `cmd.remove:${id}`, async (s) => {
4682
6100
  s.commands = (s.commands ?? []).filter((x) => !(x.id === id && x.ownerSessionID === ownerSessionID));
@@ -4687,6 +6105,86 @@ function createCommandService(host, opts) {
4687
6105
  await clearAwaitsForCommand(directory, id, "command removed").catch(() => {});
4688
6106
  return { ok: true, message: `Command "${session.title}" removed.` };
4689
6107
  },
6108
+ async watch(directory, id, ownerSessionID, opts) {
6109
+ rememberDir(id, directory);
6110
+ const session = await service.get(directory, id, ownerSessionID);
6111
+ if (!session)
6112
+ return { ok: false, message: "Command not found." };
6113
+ if (session.status !== "running") {
6114
+ return { ok: false, message: `Command is ${session.status}; watch is meaningless after exit.` };
6115
+ }
6116
+ if (opts?.untilAction !== undefined && opts.untilAction !== "stop" && opts.untilAction !== "keep") {
6117
+ throw new Error(`Invalid watch untilAction '${opts.untilAction}': must be "stop" or "keep".`);
6118
+ }
6119
+ if (opts?.clear === true) {
6120
+ deleteWatch(id);
6121
+ await mutateState(directory, `cmd.watch-clear:${id}`, async (s) => {
6122
+ const c = (s.commands ?? []).find((x) => x.id === id);
6123
+ if (c && c.ownerSessionID === ownerSessionID) {
6124
+ delete c.watchFilter;
6125
+ delete c.watchUntil;
6126
+ delete c.watchIgnoreCase;
6127
+ delete c.watchUntilAction;
6128
+ delete c.watchState;
6129
+ c.updatedAt = new Date().toISOString();
6130
+ }
6131
+ return s;
6132
+ });
6133
+ await watchLedger(directory, id, "command.watch-suspended", { reason: "cleared" }).catch(() => {});
6134
+ try {
6135
+ const fresh = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
6136
+ if (fresh)
6137
+ emitBroker(id, { type: "status", command: fresh });
6138
+ } catch {}
6139
+ const cleared = await service.get(directory, id, ownerSessionID);
6140
+ return { ok: true, message: `Watch cleared for "${session.title}".`, command: cleared };
6141
+ }
6142
+ const filter = opts?.filter !== undefined && opts.filter !== "" ? opts.filter : undefined;
6143
+ const until = opts?.until !== undefined && opts.until !== "" ? opts.until : undefined;
6144
+ const watcher = new CommandWatcher({
6145
+ ...filter !== undefined ? { filter } : {},
6146
+ ...until !== undefined ? { until } : {},
6147
+ ...opts?.ignoreCase !== undefined ? { ignoreCase: opts.ignoreCase } : {},
6148
+ ...opts?.untilAction !== undefined ? { untilAction: opts.untilAction } : {}
6149
+ });
6150
+ clearWatchTimer(id);
6151
+ watchers.set(id, watcher);
6152
+ awaitAsm.delete(id);
6153
+ const freshState = initialWatchState();
6154
+ await mutateState(directory, `cmd.watch:${id}`, async (s) => {
6155
+ const c = (s.commands ?? []).find((x) => x.id === id);
6156
+ if (c && c.ownerSessionID === ownerSessionID) {
6157
+ if (filter !== undefined)
6158
+ c.watchFilter = filter;
6159
+ else
6160
+ delete c.watchFilter;
6161
+ if (until !== undefined)
6162
+ c.watchUntil = until;
6163
+ else
6164
+ delete c.watchUntil;
6165
+ if (opts?.ignoreCase !== undefined)
6166
+ c.watchIgnoreCase = opts.ignoreCase;
6167
+ else
6168
+ delete c.watchIgnoreCase;
6169
+ c.watchUntilAction = watcher.untilAction;
6170
+ c.watchState = { ...freshState };
6171
+ c.updatedAt = new Date().toISOString();
6172
+ }
6173
+ return s;
6174
+ });
6175
+ try {
6176
+ const fresh = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
6177
+ if (fresh)
6178
+ emitBroker(id, { type: "status", command: fresh });
6179
+ } catch {}
6180
+ const updated = await service.get(directory, id, ownerSessionID);
6181
+ const desc = [
6182
+ filter !== undefined ? `filter="${filter}"` : "filter=\u2014",
6183
+ until !== undefined ? `until="${until}"` : "until=\u2014",
6184
+ `action=${watcher.untilAction}`
6185
+ ].join(" ");
6186
+ return { ok: true, message: `Watch set for "${session.title}" (${desc}; counters reset to active).`, command: updated };
6187
+ },
4690
6188
  async reconcile(directory) {
4691
6189
  const state = await readState(directory);
4692
6190
  const cmds = state.commands ?? [];
@@ -4694,16 +6192,22 @@ function createCommandService(host, opts) {
4694
6192
  rememberDir(c.id, directory);
4695
6193
  let markedMissing = 0;
4696
6194
  for (const c of cmds) {
4697
- if (c.status !== "running")
6195
+ if (c.status !== "running") {
6196
+ if ((c.notifySuccessfulExit === true || c.notifyOnExit === true) && !c.ownerNotifiedAt)
6197
+ await notifyOwnerIfNeeded(directory, c.id);
4698
6198
  continue;
6199
+ }
4699
6200
  const entry = live.get(c.id);
4700
6201
  if (entry) {
4701
6202
  if (!entry.handle.isAlive()) {
4702
6203
  live.delete(c.id);
6204
+ clearCommandTimeout(c.id);
6205
+ deleteWatch(c.id);
4703
6206
  await mutateState(directory, `cmd.reconcile-exit:${c.id}`, async (s) => {
4704
6207
  const x = (s.commands ?? []).find((y) => y.id === c.id);
4705
6208
  if (x && x.status === "running") {
4706
6209
  x.status = "exited";
6210
+ x.endReason = "exit";
4707
6211
  x.endedAt = new Date().toISOString();
4708
6212
  x.updatedAt = x.endedAt;
4709
6213
  x.lastError = x.lastError ?? "Process handle died without an exit event.";
@@ -4716,14 +6220,19 @@ function createCommandService(host, opts) {
4716
6220
  emitBroker(c.id, { type: "status", command: fresh });
4717
6221
  } catch {}
4718
6222
  await fireAwaits(directory, c.id);
6223
+ await notifyOwnerIfNeeded(directory, c.id);
4719
6224
  }
4720
6225
  continue;
4721
6226
  }
6227
+ clearCommandTimeout(c.id);
6228
+ deleteWatch(c.id);
6229
+ const deadlinePassed = c.deadlineAt ? Date.parse(c.deadlineAt) <= Date.now() : false;
4722
6230
  await mutateState(directory, `cmd.reconcile-missing:${c.id}`, async (s) => {
4723
6231
  const x = (s.commands ?? []).find((y) => y.id === c.id);
4724
6232
  if (x && x.status === "running") {
4725
6233
  x.status = "missing";
4726
- x.lastError = "Host restarted or handle lost \u2014 no live execution found. Output log retained; remove to clean up.";
6234
+ x.endReason = "missing";
6235
+ x.lastError = deadlinePassed ? `Host restarted or handle lost \u2014 no live execution found (timeoutSeconds=${x.timeoutSeconds ?? "?"}s deadline ${x.deadlineAt} already passed; treated as missing, timeout elapsed). Output log retained; remove to clean up.` : "Host restarted or handle lost \u2014 no live execution found. Output log retained; remove to clean up.";
4727
6236
  x.updatedAt = new Date().toISOString();
4728
6237
  markedMissing++;
4729
6238
  }
@@ -4735,16 +6244,23 @@ function createCommandService(host, opts) {
4735
6244
  emitBroker(c.id, { type: "status", command: fresh });
4736
6245
  } catch {}
4737
6246
  await fireAwaits(directory, c.id);
6247
+ await notifyOwnerIfNeeded(directory, c.id);
4738
6248
  }
4739
6249
  return { markedMissing };
4740
6250
  },
4741
6251
  async dispose(directory) {
6252
+ for (const id of [...notifyRetries.keys()])
6253
+ clearNotifyRetry(id);
6254
+ for (const id of [...watchTimers.keys()])
6255
+ clearWatchTimer(id);
6256
+ watchers.clear();
6257
+ awaitAsm.clear();
4742
6258
  const state = await readState(directory);
4743
6259
  const owned = new Map((state.commands ?? []).map((command) => [command.id, command.ownerSessionID]));
4744
6260
  await Promise.all([...live.entries()].map(async ([id, entry]) => {
4745
6261
  const owner = owned.get(id);
4746
6262
  if (owner) {
4747
- await this.terminate(directory, id, owner).catch(() => {});
6263
+ await service.terminate(directory, id, owner).catch(() => {});
4748
6264
  return;
4749
6265
  }
4750
6266
  entry.handle.terminate();
@@ -4756,12 +6272,17 @@ function createCommandService(host, opts) {
4756
6272
  entry.handle.kill();
4757
6273
  live.delete(id);
4758
6274
  }));
6275
+ for (const id of [...timeouts.keys()])
6276
+ clearCommandTimeout(id);
6277
+ for (const id of [...notifyRetries.keys()])
6278
+ clearNotifyRetry(id);
4759
6279
  }
4760
6280
  };
6281
+ return service;
4761
6282
  }
4762
6283
 
4763
6284
  // src/application/command-event-broker.ts
4764
- import { randomUUID as randomUUID8 } from "crypto";
6285
+ import { randomUUID as randomUUID9 } from "crypto";
4765
6286
  function safeDeliver(sink, msg) {
4766
6287
  try {
4767
6288
  sink(msg);
@@ -4804,7 +6325,7 @@ function createCommandEventBroker(resolver) {
4804
6325
  throw new Error("ownerSessionID is required.");
4805
6326
  if (typeof sink !== "function")
4806
6327
  throw new Error("sink must be a function.");
4807
- const sinkID = randomUUID8();
6328
+ const sinkID = randomUUID9();
4808
6329
  const entry = { sinkID, sink, state: "subscribing", buffer: [] };
4809
6330
  entriesFor(commandID).set(sinkID, entry);
4810
6331
  try {
@@ -4893,7 +6414,7 @@ function createCommandEventBroker(resolver) {
4893
6414
  }
4894
6415
 
4895
6416
  // src/server/command-stream-server.ts
4896
- import { randomBytes, randomUUID as randomUUID9, timingSafeEqual } from "crypto";
6417
+ import { randomBytes, randomUUID as randomUUID10, timingSafeEqual } from "crypto";
4897
6418
  import { promises as fs4 } from "fs";
4898
6419
  import path4 from "path";
4899
6420
  function streamEndpointPath(directory) {
@@ -4927,7 +6448,7 @@ var MAX_BUFFERED_BYTES = 512 * 1024;
4927
6448
  function createCommandStreamServer(directory, commandService, broker) {
4928
6449
  const endpointPath = streamEndpointPath(directory);
4929
6450
  const token = randomBytes(32).toString("hex");
4930
- const generation = randomUUID9();
6451
+ const generation = randomUUID10();
4931
6452
  const startedAt = new Date().toISOString();
4932
6453
  let server;
4933
6454
  let serverURL;
@@ -5247,7 +6768,7 @@ function createCommandStreamServer(directory, commandService, broker) {
5247
6768
 
5248
6769
  // src/server/goal-tools.ts
5249
6770
  init_state_repository();
5250
- import { randomUUID as randomUUID10 } from "crypto";
6771
+ import { randomUUID as randomUUID11 } from "crypto";
5251
6772
  import { tool } from "@opencode-ai/plugin/tool";
5252
6773
  // src/domain/verification.ts
5253
6774
  var MAX_RECENT_ATTEMPTS = 10;
@@ -5266,12 +6787,12 @@ var execAsync = promisify(execChild);
5266
6787
  function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
5267
6788
  return {
5268
6789
  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.",
6790
+ description: "Create a new background loop GOAL: an autonomous AI worker that loops turn-by-turn on a multi-step objective until deterministic checks pass or it needs you (contract: objective + checks + agent/model + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Use this for AI reasoning work spanning multiple turns (implement a feature, fix a failing suite, research and write a report) \u2014 NOT for running a single process you just want to start, watch, and type into. " + "For that (dev servers, `npm test --watch`, REPLs, log tails, one-off scripts, interactive shells with a fullscreen terminal UI), use loopd_command_start instead: it is lighter-weight, has no agent/checks/turn loop, and is a raw OS process, not an AI worker. " + "Call this after clarifying the contract with the user. " + "Worker identity is free-form: agent is any OpenCode agent name (built-in, ~/.config/opencode/agents/*.md, or opencode.jsonc agent.* \u2014 discover with `opencode agent list`), " + 'model is any "providerID/modelID" (discover with `opencode models [provider]`). Omit both by default \u2014 when omitted, both inherit the CALLING session\'s live agent/model (read at creation), then plugin defaultAgent/defaultModel. ' + "Only pass agent/model when the caller explicitly requests a different identity or the task needs it; explicit values freeze identity and break session upgrades. " + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals are serialized (only one active writer) and require checks. " + "Monitor with the /loop dashboard's Goals tab (Tab/h to switch there if Commands is focused).",
5270
6791
  args: {
5271
6792
  name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
5272
6793
  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.`),
6794
+ agent: tool.schema.string().optional().describe(`Agent to run the worker as (e.g. "researcher", "smart-agent"). Prefer omit \u2014 inherits the calling session's agent if omitted, else plugin defaultAgent. Only pass when explicitly requested.`),
6795
+ 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"). Prefer omit \u2014 inherits the calling session's live model if omitted, else plugin defaultModel. Only pass when explicitly requested.`),
5275
6796
  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
6797
  checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
5277
6798
  checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
@@ -5386,6 +6907,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
5386
6907
  ok: true,
5387
6908
  goalID: goal.id,
5388
6909
  workerSessionID: worker.workerSessionID,
6910
+ workerTopology: worker.topology,
6911
+ ...worker.nativeParentID ? { nativeParentID: worker.nativeParentID } : {},
5389
6912
  artifactDir: goal.config.artifactDir,
5390
6913
  agent: resolution.config.agent,
5391
6914
  model: resolution.config.model,
@@ -5472,7 +6995,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
5472
6995
  await writeState(dir, state);
5473
6996
  const event = {
5474
6997
  version: 1,
5475
- eventID: randomUUID10(),
6998
+ eventID: randomUUID11(),
5476
6999
  goalID: goal.id,
5477
7000
  type: "goal.progress",
5478
7001
  summary: args.summary,
@@ -5537,7 +7060,7 @@ Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
5537
7060
  Working directory: ${cwd}
5538
7061
 
5539
7062
  ${failureDetails}`;
5540
- attemptID = randomUUID10();
7063
+ attemptID = randomUUID11();
5541
7064
  const verificationAttempt = {
5542
7065
  id: attemptID,
5543
7066
  sequence: runtime.evaluatorRejectionCount,
@@ -5582,7 +7105,7 @@ ${failureDetails.slice(0, 500)}`,
5582
7105
  if (attemptID) {
5583
7106
  await appendEvent(dir, {
5584
7107
  version: 1,
5585
- eventID: randomUUID10(),
7108
+ eventID: randomUUID11(),
5586
7109
  goalID: goal.id,
5587
7110
  type: "goal.completion_rejected",
5588
7111
  attemptID,
@@ -5600,7 +7123,7 @@ ${failureDetails.slice(0, 500)}`,
5600
7123
  if (blocked && rejectedGoal?.blocker) {
5601
7124
  await appendEvent(dir, {
5602
7125
  version: 1,
5603
- eventID: randomUUID10(),
7126
+ eventID: randomUUID11(),
5604
7127
  goalID: goal.id,
5605
7128
  type: "goal.blocked",
5606
7129
  reason: rejectedGoal.blocker.reason,
@@ -5660,7 +7183,7 @@ ${failureDetails.slice(0, 500)}`,
5660
7183
  }
5661
7184
  runtime.updatedAt = new Date().toISOString();
5662
7185
  }
5663
- const attemptID = randomUUID10();
7186
+ const attemptID = randomUUID11();
5664
7187
  const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
5665
7188
  const checks = (goal.config.checks || []).map((cmd) => ({
5666
7189
  command: cmd,
@@ -5684,7 +7207,7 @@ ${failureDetails.slice(0, 500)}`,
5684
7207
  await writeState(dir, state);
5685
7208
  const event = {
5686
7209
  version: 1,
5687
- eventID: randomUUID10(),
7210
+ eventID: randomUUID11(),
5688
7211
  goalID: goal.id,
5689
7212
  type: "goal.completed",
5690
7213
  summary: args.summary,
@@ -5744,7 +7267,7 @@ ${failureDetails.slice(0, 500)}`,
5744
7267
  await writeState(dir, state);
5745
7268
  const event = {
5746
7269
  version: 1,
5747
- eventID: randomUUID10(),
7270
+ eventID: randomUUID11(),
5748
7271
  goalID: goal.id,
5749
7272
  type: "goal.blocked",
5750
7273
  reason: args.reason,
@@ -6059,6 +7582,8 @@ function ownerTools(options) {
6059
7582
  stateSummary: describeGoalState(goal.status, runtime?.phase),
6060
7583
  ownerSessionID: goal.ownerSessionID,
6061
7584
  workerSessionID: goal.workerSessionID,
7585
+ workerTopology: goal.workerTopology,
7586
+ ...goal.nativeParentID ? { nativeParentID: goal.nativeParentID } : {},
6062
7587
  config: {
6063
7588
  maxTurns: goal.config.maxTurns,
6064
7589
  maxFailures: goal.config.maxFailures,
@@ -6532,9 +8057,20 @@ function summarize(c) {
6532
8057
  status: c.status,
6533
8058
  exitCode: c.exitCode,
6534
8059
  signal: c.signal,
8060
+ endReason: c.endReason,
8061
+ timeoutSeconds: c.timeoutSeconds,
8062
+ deadlineAt: c.deadlineAt,
8063
+ envKeys: c.envKeys,
8064
+ shell: c.shell,
8065
+ watchFilter: c.watchFilter,
8066
+ watchUntil: c.watchUntil,
8067
+ watchIgnoreCase: c.watchIgnoreCase,
8068
+ watchUntilAction: c.watchUntilAction,
8069
+ watchState: c.watchState,
6535
8070
  outputBytes: c.outputBytes,
6536
8071
  truncated: c.truncated,
6537
8072
  goalID: c.goalID,
8073
+ notifyOnExit: c.notifyOnExit,
6538
8074
  updatedAt: c.updatedAt
6539
8075
  };
6540
8076
  }
@@ -6544,15 +8080,23 @@ function commandTools(options) {
6544
8080
  const sizeNote = capabilities.resize ? "applied live to the PTY winsize" : "stored; resize is unsupported by the pipe host";
6545
8081
  return {
6546
8082
  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.",
8083
+ description: "PREFER THIS over the built-in shell/bash tool whenever a command might take a while or never return: installs (npm/pip/brew/cargo...), builds, test suites, downloads, migrations, dev servers, watchers, log tails, REPLs, or anything interactive. The built-in shell BLOCKS your whole turn until the process exits \u2014 a long install freezes you (and can time out); this returns immediately with a command_id and pushes you a message on exit or pattern match, so you stay responsive and can do other work meanwhile. Only use the built-in shell for quick one-shots (ls, git status, a single fast test) that finish in seconds. " + "Start a standalone interactive OS process (arbitrary shell command) in the background \u2014 a dev server, `npm test --watch`, a REPL, a log tail, a build, or a one-off script. This is a raw process, NOT an AI worker: no agent, no checks, no turn loop. For multi-turn autonomous AI work with completion criteria, use loopd_create_goal instead. " + "Returns a command_id for loopd_command_get (read output)/loopd_command_write (send stdin)/loopd_command_interrupt (Ctrl+C)/loopd_command_terminate (kill)/loopd_command_remove (delete). " + "The user can also open it live: /loop or /commands \u2192 Tab/l to the Commands tab \u2192 select it \u2192 `o` opens a fullscreen interactive terminal page (type directly, Ctrl+C interrupts, Ctrl+] detaches without stopping it). " + "Independent from goals: an optional goal_id is display-only metadata and never couples lifecycles \u2014 pausing/clearing a goal never touches the command, and terminating a command never touches the goal. " + "To make a specific goal wake up when this command finishes, call loopd_command_await separately after starting it (linking alone does not wake anything). " + "The OWNER session gets a notification on natural exit (including code 0), missing process, or timeout \u2014 no polling required for completion. Manual terminate stays silent by default. Raw PTY output stays in the log; any short tail included in the notification is sanitized for chat. Set notify_on_exit:false to suppress owner exit notifications.",
6548
8084
  args: {
6549
8085
  title: tool3.schema.string().describe("Short human label for the session."),
6550
8086
  command: tool3.schema.string().describe('Executable to spawn (e.g. "bun", "python3").'),
6551
8087
  args: tool3.schema.array(tool3.schema.string()).optional().describe("Arguments for the command."),
6552
8088
  cwd: tool3.schema.string().optional().describe("Working directory. Defaults to the project root."),
6553
8089
  goal_id: tool3.schema.string().optional().describe("Optional goal linkage (display only \u2014 no lifecycle coupling)."),
8090
+ notify_on_exit: tool3.schema.boolean().optional().describe("Owner-exit-notification override. Omit to notify on natural exits (including quick success), missing or timeout; manual terminate stays silent. true also notifies on manual terminate; false disables owner exit notifications."),
6554
8091
  cols: tool3.schema.number().optional().describe(`Requested terminal width (${sizeNote}).`),
6555
- rows: tool3.schema.number().optional().describe(`Requested terminal height (${sizeNote}).`)
8092
+ rows: tool3.schema.number().optional().describe(`Requested terminal height (${sizeNote}).`),
8093
+ env: tool3.schema.record(tool3.schema.string(), tool3.schema.string()).optional().describe("Extra environment variables for the child. Values are passed to the host but never persisted \u2014 only names appear as envKeys in summaries."),
8094
+ timeout_seconds: tool3.schema.number().optional().describe("Per-command timeout in seconds (positive integer). The command is terminated via the standard SIGTERM\u2192SIGKILL path when the deadline passes; endReason becomes 'timeout' and the owner is always notified (auto policy). In-memory only \u2014 never resurrected across restarts."),
8095
+ shell: tool3.schema.boolean().optional().describe("When true, spawn via /bin/sh -c with command+args joined into one shell string (POSIX single-quote escaping). Shell metacharacters are interpreted; prefer argv form for untrusted input."),
8096
+ watch_filter: tool3.schema.string().optional().describe("Watch line filter (regex on ANSI-stripped lines): only matching lines buffer toward a coalesced owner push (one push per ~2s window, \u226420 lines/\u22644KB each, \u226430 pushes per command; 100+ lines/sec suspends with one notice). For never-exiting processes (dev servers, log tails). Adjust later via loopd_command_watch."),
8097
+ watch_until: tool3.schema.string().optional().describe("Watch until pattern (regex on the filter-surviving stream): first match notifies the owner immediately with the matched lines. With watch_until_action=stop (default) the command is terminated with endReason=until (exactly one owner message total); with keep it keeps running (until fires once, filter stream continues)."),
8098
+ watch_ignore_case: tool3.schema.boolean().optional().describe("Case-insensitive watch matching (default false)."),
8099
+ watch_until_action: tool3.schema.string().optional().describe('Until action: "stop" (default) terminates the command on first until-match; "keep" notifies but keeps running (until fires once, filter stream continues).')
6556
8100
  },
6557
8101
  execute: async (args, context) => {
6558
8102
  const owner = ownerID(context);
@@ -6566,6 +8110,9 @@ function commandTools(options) {
6566
8110
  always: [args.command],
6567
8111
  metadata: { command: args.command, args: args.args ?? [], cwd: args.cwd ?? directory }
6568
8112
  });
8113
+ if (args.watch_until_action !== undefined && args.watch_until_action !== "stop" && args.watch_until_action !== "keep") {
8114
+ throw new Error(`Invalid watch_until_action '${args.watch_until_action}': must be "stop" or "keep".`);
8115
+ }
6569
8116
  const session = await commandService.start(directory, {
6570
8117
  title: args.title,
6571
8118
  command: args.command,
@@ -6573,8 +8120,16 @@ function commandTools(options) {
6573
8120
  cwd: args.cwd,
6574
8121
  ownerSessionID: owner,
6575
8122
  goalID: args.goal_id,
8123
+ notifyOnExit: args.notify_on_exit,
6576
8124
  cols: args.cols,
6577
- rows: args.rows
8125
+ rows: args.rows,
8126
+ env: args.env,
8127
+ timeoutSeconds: args.timeout_seconds,
8128
+ shell: args.shell,
8129
+ watchFilter: args.watch_filter,
8130
+ watchUntil: args.watch_until,
8131
+ watchIgnoreCase: args.watch_ignore_case,
8132
+ watchUntilAction: args.watch_until_action
6578
8133
  });
6579
8134
  return {
6580
8135
  title: "Command started",
@@ -6589,7 +8144,7 @@ function commandTools(options) {
6589
8144
  }
6590
8145
  }),
6591
8146
  loopd_command_list: tool3({
6592
- description: "List standalone command sessions owned by this session.",
8147
+ description: "List standalone command sessions owned by this session (same set the TUI Commands tab shows for this session). Use before reading/writing to find a command's ID, or to check if a dev server/watcher you started earlier is still running.",
6593
8148
  args: {},
6594
8149
  execute: async (_args, context) => {
6595
8150
  const owner = ownerID(context);
@@ -6603,20 +8158,29 @@ function commandTools(options) {
6603
8158
  }
6604
8159
  }),
6605
8160
  loopd_command_get: tool3({
6606
- description: "Get a command session's metadata plus a bounded output snapshot. Closing a view detaches; it never terminates.",
8161
+ description: "Read a command session's status plus its output so far (bounded snapshot; page with offset_bytes for more). For progress checks on plain one-shot commands, read once or twice as needed \u2014 but for never-exiting processes (dev servers, log tails, watch mode) prefer watch_filter/watch_until at start (or loopd_command_watch on a running command) and loopd_command_await for goal wakes: the owner gets pushed on match/exit with no repeated reads. Never terminates the command; detach/inspect is always read-only. With pattern, only matching lines return (regex on ANSI-stripped text, original lines kept; dangerous nested-quantifier patterns are rejected) and offset_bytes/limit_bytes page over MATCHES (match index + max matched lines, default 500).",
6607
8162
  args: {
6608
8163
  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).")
8164
+ offset_bytes: tool3.schema.number().optional().describe("Byte offset into the output log (paging). With pattern: number of matching lines to skip."),
8165
+ limit_bytes: tool3.schema.number().optional().describe("Max bytes to return (default 64KB, cap 256KB). With pattern: max matching lines (default 500)."),
8166
+ pattern: tool3.schema.string().optional().describe("Regex to filter lines (matched against ANSI-stripped text; original lines returned). Dangerous nested-quantifier patterns are rejected."),
8167
+ ignore_case: tool3.schema.boolean().optional().describe("Case-insensitive pattern matching (default false).")
6611
8168
  },
6612
8169
  execute: async (args, context) => {
6613
8170
  const owner = ownerID(context);
6614
8171
  if (!owner)
6615
8172
  return denied();
6616
- const result = await commandService.read(directory, args.command_id, owner, {
6617
- offsetBytes: args.offset_bytes,
6618
- limitBytes: args.limit_bytes
6619
- });
8173
+ let result;
8174
+ try {
8175
+ result = await commandService.read(directory, args.command_id, owner, {
8176
+ offsetBytes: args.offset_bytes,
8177
+ limitBytes: args.limit_bytes,
8178
+ pattern: args.pattern,
8179
+ ignoreCase: args.ignore_case
8180
+ });
8181
+ } catch (error) {
8182
+ return { title: "Read failed", output: JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) }) };
8183
+ }
6620
8184
  if (!result) {
6621
8185
  return { title: "Not found", output: JSON.stringify({ ok: false, message: "Command not found for this session." }) };
6622
8186
  }
@@ -6628,13 +8192,14 @@ function commandTools(options) {
6628
8192
  output: result.text,
6629
8193
  startByte: result.startByte,
6630
8194
  totalBytes: result.totalBytes,
6631
- live: result.live
8195
+ live: result.live,
8196
+ ...result.pattern !== undefined ? { pattern: result.pattern, totalMatches: result.totalMatches } : {}
6632
8197
  }, null, 2)
6633
8198
  };
6634
8199
  }
6635
8200
  }),
6636
8201
  loopd_command_write: tool3({
6637
- description: "Send raw input (stdin bytes) to a running command session.",
8202
+ description: "Send raw input (stdin bytes) to a running command session \u2014 e.g. answer a REPL prompt, confirm a y/n, or type a command into an interactive shell you started. Include a trailing newline yourself if the program is line-buffered.",
6638
8203
  args: {
6639
8204
  command_id: tool3.schema.string().describe("Command session ID."),
6640
8205
  input: tool3.schema.string().describe("Raw text to write to stdin (include trailing newline for line-buffered programs).")
@@ -6661,15 +8226,16 @@ function commandTools(options) {
6661
8226
  }
6662
8227
  }),
6663
8228
  loopd_command_terminate: tool3({
6664
- description: "Terminate a running command (SIGTERM, escalates to SIGKILL). Stopping a command never pauses/blocks any goal.",
8229
+ description: "Terminate a running command (SIGTERM, escalates to SIGKILL). Stopping a command never pauses/blocks any goal. With remove:true, the record+log are deleted in the same call (atomic from the caller's perspective; proceeds to remove even when the command is already terminal).",
6665
8230
  args: {
6666
- command_id: tool3.schema.string().describe("Command session ID.")
8231
+ command_id: tool3.schema.string().describe("Command session ID."),
8232
+ remove: tool3.schema.boolean().optional().describe("Also remove the record+log after terminating (or when already terminal).")
6667
8233
  },
6668
8234
  execute: async (args, context) => {
6669
8235
  const owner = ownerID(context);
6670
8236
  if (!owner)
6671
8237
  return denied();
6672
- const result = await commandService.terminate(directory, args.command_id, owner);
8238
+ const result = await commandService.terminate(directory, args.command_id, owner, { remove: args.remove });
6673
8239
  return { title: result.ok ? "Terminated" : "Terminate failed", output: JSON.stringify({ ...result, command_id: args.command_id }) };
6674
8240
  }
6675
8241
  }),
@@ -6686,11 +8252,53 @@ function commandTools(options) {
6686
8252
  return { title: result.ok ? "Removed" : "Remove failed", output: JSON.stringify({ ...result, command_id: args.command_id }) };
6687
8253
  }
6688
8254
  }),
8255
+ loopd_command_watch: tool3({
8256
+ description: 'Set, replace, or clear a watch on an ALREADY-RUNNING command \u2014 line-level filter/until notifications for never-exiting processes (dev servers, log tails, watch mode). watch_filter buffers only matching lines toward a coalesced owner push (one push per ~2s window, \u226420 lines/\u22644KB each, \u226430 pushes per command); watch_until fires once on the first matching line of the filter-surviving stream with an immediate owner notice. watch_until_action "stop" (default) terminates the command on first until-match (endReason=until); "keep" notifies but keeps running (until fires once, the filter stream continues). Replacing the spec resets counters/state to active. Fail closed: invalid or dangerous regexes are rejected and the previous watch (if any) is left untouched; watching a terminal command returns ok:false (watch is meaningless after exit).',
8257
+ args: {
8258
+ command_id: tool3.schema.string().describe("Command session ID (must be running)."),
8259
+ watch_filter: tool3.schema.string().optional().describe("Watch line filter (regex on ANSI-stripped lines): only matching lines buffer toward a coalesced owner push. Omit (or pair with clear) to remove."),
8260
+ watch_until: tool3.schema.string().optional().describe("Watch until pattern (regex on the filter-surviving stream): first match notifies the owner immediately. With watch_until_action=stop (default) the command is terminated with endReason=until; with keep it keeps running."),
8261
+ watch_ignore_case: tool3.schema.boolean().optional().describe("Case-insensitive watch matching (default false)."),
8262
+ watch_until_action: tool3.schema.string().optional().describe('Until action: "stop" (default) terminates the command on first until-match; "keep" notifies but keeps running (until fires once, filter stream continues).'),
8263
+ clear: tool3.schema.boolean().optional().describe("When true, drop the watch entirely (spec + counters + in-memory watcher) instead of setting one.")
8264
+ },
8265
+ execute: async (args, context) => {
8266
+ const owner = ownerID(context);
8267
+ if (!owner)
8268
+ return denied();
8269
+ try {
8270
+ if (args.watch_until_action !== undefined && args.watch_until_action !== "stop" && args.watch_until_action !== "keep") {
8271
+ throw new Error(`Invalid watch_until_action '${args.watch_until_action}': must be "stop" or "keep".`);
8272
+ }
8273
+ const result = await commandService.watch(directory, args.command_id, owner, {
8274
+ ...args.watch_filter !== undefined ? { filter: args.watch_filter } : {},
8275
+ ...args.watch_until !== undefined ? { until: args.watch_until } : {},
8276
+ ...args.watch_ignore_case !== undefined ? { ignoreCase: args.watch_ignore_case } : {},
8277
+ ...args.watch_until_action !== undefined ? { untilAction: args.watch_until_action } : {},
8278
+ ...args.clear !== undefined ? { clear: args.clear } : {}
8279
+ });
8280
+ if (!result.ok) {
8281
+ return { title: "Watch not set", output: JSON.stringify({ ...result, command_id: args.command_id }) };
8282
+ }
8283
+ return {
8284
+ title: "Watch updated",
8285
+ output: JSON.stringify({ ok: true, message: result.message, command: summarize(result.command), command_id: args.command_id }, null, 2)
8286
+ };
8287
+ } catch (error) {
8288
+ return {
8289
+ title: "Watch not set",
8290
+ output: JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error), command_id: args.command_id })
8291
+ };
8292
+ }
8293
+ }
8294
+ }),
6689
8295
  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.",
8296
+ 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. With 'until', the goal instead wakes ONCE on the first output line matching the regex (evidence: matched lines + bounded tail) and the command keeps running \u2014 use this to have a GOAL worker block until a never-exiting process prints something (e.g. 'wait until the dev server prints ready') without polling. Explicit opt-in only \u2014 a merely linked command (goal_id passed to loopd_command_start) never wakes its goal on its own; this call is required. Exactly-once: the await is consumed on fire, output chunks never fire exit-awaits, pausing/clearing the goal or removing the command cancels it.",
6691
8297
  args: {
6692
8298
  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.")
8299
+ goal_id: tool3.schema.string().describe("Goal ID to wake on exit. You must own both the goal and the command."),
8300
+ until: tool3.schema.string().optional().describe("Regex to wake on: fires once on the first matching output line (matched lines + bounded tail as evidence) without stopping the command. Dangerous nested-quantifier patterns are rejected."),
8301
+ ignore_case: tool3.schema.boolean().optional().describe("Case-insensitive until matching (default false).")
6694
8302
  },
6695
8303
  execute: async (args, context) => {
6696
8304
  const owner = ownerID(context);
@@ -6699,7 +8307,9 @@ function commandTools(options) {
6699
8307
  const result = await requestCommandAwait(directory, {
6700
8308
  goalID: args.goal_id,
6701
8309
  commandID: args.command_id,
6702
- ownerSessionID: owner
8310
+ ownerSessionID: owner,
8311
+ ...args.until !== undefined ? { until: args.until } : {},
8312
+ ...args.ignore_case !== undefined ? { ignoreCase: args.ignore_case } : {}
6703
8313
  });
6704
8314
  if (result.ok && result.fired && result.active && options.goalService) {
6705
8315
  await wakeGoalForAwait(directory, options.goalService, args.goal_id).catch(() => {});
@@ -6731,7 +8341,7 @@ function commandTools(options) {
6731
8341
  // src/server/plugin.ts
6732
8342
  init_state_repository();
6733
8343
  // package.json
6734
- var version = "1.10.3";
8344
+ var version = "1.10.5";
6735
8345
 
6736
8346
  // src/server/plugin.ts
6737
8347
  var PLUGIN_ID = "opencode-loopd.server";
@@ -6758,6 +8368,23 @@ function createServerHooks(directory, host, defaults) {
6758
8368
  detail: describeError(error)
6759
8369
  }));
6760
8370
  }
8371
+ },
8372
+ onOwnerNotify: async (dir, ownerSessionID, message) => {
8373
+ try {
8374
+ return await host.notifyOwner(ownerSessionID, message) !== false;
8375
+ } catch (error) {
8376
+ await logServerEvent(dir, "command.owner-notify-failed", {
8377
+ ownerSessionID,
8378
+ detail: describeError(error)
8379
+ });
8380
+ return false;
8381
+ }
8382
+ },
8383
+ onWatchNotify: async (dir, ownerSessionID, message) => {
8384
+ await host.notifyOwner(ownerSessionID, message).catch((error) => logServerEvent(dir, "command.watch-notify-failed", {
8385
+ ownerSessionID,
8386
+ detail: describeError(error)
8387
+ }));
6761
8388
  }
6762
8389
  });
6763
8390
  const commandStream = createCommandStreamServer(directory, commandService, commandBroker);
@@ -6921,9 +8548,17 @@ var v2 = {
6921
8548
  const directory = context.location.directory;
6922
8549
  logServerEvent(directory, "plugin.loaded", { pluginID: PLUGIN_ID, host: "v2", version });
6923
8550
  const statuses = new Map;
6924
- const host = createV2Host(context, statuses);
8551
+ const native = await setupNativeServer(context);
8552
+ if (native) {
8553
+ logServerEvent(directory, "native-bridge.ready", { pluginID: PLUGIN_ID });
8554
+ } else {
8555
+ logServerEvent(directory, "native-bridge.unsupported", { pluginID: PLUGIN_ID });
8556
+ }
8557
+ const host = createV2Host(context, statuses, native ? { native } : undefined);
6925
8558
  const hooks = createServerHooks(directory, host, parsePluginDefaults(context.options));
6926
8559
  const registrations = [];
8560
+ if (native)
8561
+ registrations.push({ dispose: () => native.dispose() });
6927
8562
  const eventController = new AbortController;
6928
8563
  let eventTask = Promise.resolve();
6929
8564
  let disposed = false;