@bojackduy/opencode-loopd 1.10.3 → 1.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -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
@@ -585,6 +593,237 @@ var init_command_await = __esm(() => {
585
593
  MAX_AWAIT_TAIL_BYTES = 4 * 1024;
586
594
  });
587
595
 
596
+ // src/domain/command-watch.ts
597
+ function initialWatchState() {
598
+ return { state: "active", matches: 0, pushes: 0, droppedLines: 0 };
599
+ }
600
+ function compileWatchPattern(kind, pattern, ignoreCase = false) {
601
+ let compiled;
602
+ try {
603
+ compiled = new RegExp(pattern, ignoreCase ? "i" : "");
604
+ } catch (e) {
605
+ throw new Error(`Invalid watch ${kind} regex '${pattern}': ${e instanceof Error ? e.message : String(e)}`);
606
+ }
607
+ if (DANGEROUS_REGEXES.some((d) => d.test(pattern))) {
608
+ throw new Error(`Potentially dangerous watch ${kind} regex rejected: '${pattern}'. Please use a safer pattern.`);
609
+ }
610
+ return compiled;
611
+ }
612
+ function compileWatchSpec(spec) {
613
+ const ignoreCase = spec.ignoreCase === true;
614
+ const untilAction = spec.untilAction ?? "stop";
615
+ if (untilAction !== "stop" && untilAction !== "keep") {
616
+ throw new Error(`Invalid watch untilAction '${spec.untilAction}': must be "stop" or "keep".`);
617
+ }
618
+ return {
619
+ ...spec.filter !== undefined ? { filterRe: compileWatchPattern("filter", spec.filter, ignoreCase) } : {},
620
+ ...spec.until !== undefined ? { untilRe: compileWatchPattern("until", spec.until, ignoreCase) } : {},
621
+ ignoreCase,
622
+ untilAction
623
+ };
624
+ }
625
+ function stripAnsiForWatch(line) {
626
+ return line.replace(ANSI_PATTERN, "");
627
+ }
628
+ function cleanRawLine(raw) {
629
+ const stripped = stripAnsiForWatch(raw);
630
+ if (!stripped.includes("\r"))
631
+ return stripped;
632
+ const parts = stripped.split("\r");
633
+ return parts[parts.length - 1] ?? "";
634
+ }
635
+ function createLineAssembler() {
636
+ let carry = "";
637
+ return {
638
+ push(chunk) {
639
+ carry += chunk;
640
+ const parts = carry.replace(/\r\n/g, `
641
+ `).split(`
642
+ `);
643
+ carry = parts.pop() ?? "";
644
+ return parts.map(cleanRawLine);
645
+ },
646
+ flush() {
647
+ if (!carry)
648
+ return [];
649
+ const line = cleanRawLine(carry);
650
+ carry = "";
651
+ return [line];
652
+ }
653
+ };
654
+ }
655
+ function testFresh(re, line) {
656
+ const hit = re.test(line);
657
+ re.lastIndex = 0;
658
+ return hit;
659
+ }
660
+ function matchLine(compiled, line) {
661
+ if (compiled.filterRe && !testFresh(compiled.filterRe, line))
662
+ return { pass: false, until: false };
663
+ if (compiled.untilRe && testFresh(compiled.untilRe, line))
664
+ return { pass: true, until: true };
665
+ return { pass: true, until: false };
666
+ }
667
+ function byteLen(s) {
668
+ return Buffer.byteLength(s, "utf8");
669
+ }
670
+
671
+ class CommandWatcher {
672
+ compiled;
673
+ state = initialWatchState();
674
+ assembler = createLineAssembler();
675
+ pending = [];
676
+ pendingBytes = 0;
677
+ stamps = [];
678
+ constructor(spec) {
679
+ this.compiled = compileWatchSpec(spec);
680
+ }
681
+ get untilAction() {
682
+ return this.compiled.untilAction;
683
+ }
684
+ get pendingCount() {
685
+ return this.pending.length;
686
+ }
687
+ feedChunk(chunk, now) {
688
+ const completed = [];
689
+ const matched = [];
690
+ let untilLine = null;
691
+ let floodNow = false;
692
+ const suspended = this.state.state === "flood-suspended" || this.state.state === "budget-exhausted";
693
+ const lines = this.assembler.push(chunk);
694
+ for (const line of lines) {
695
+ if (line.trim() === "")
696
+ continue;
697
+ if (suspended) {
698
+ this.state.droppedLines++;
699
+ continue;
700
+ }
701
+ completed.push(line);
702
+ this.stamps.push(now);
703
+ while (this.stamps.length > 0 && (this.stamps[0] ?? now) < now - 1000)
704
+ this.stamps.shift();
705
+ if (this.stamps.length > WATCH_FLOOD_LINES_PER_SEC) {
706
+ this.state.state = "flood-suspended";
707
+ this.state.droppedLines++;
708
+ floodNow = true;
709
+ continue;
710
+ }
711
+ const m = matchLine(this.compiled, line);
712
+ if (!m.pass)
713
+ continue;
714
+ if (m.until && this.state.state === "active") {
715
+ this.state.state = "until-matched";
716
+ this.state.matches++;
717
+ this.buffer(line);
718
+ untilLine = line;
719
+ continue;
720
+ }
721
+ if (this.state.state === "until-matched" && this.untilAction === "stop") {
722
+ this.state.droppedLines++;
723
+ continue;
724
+ }
725
+ this.state.matches++;
726
+ this.buffer(line);
727
+ matched.push(line);
728
+ }
729
+ return { completed, matched, untilLine, floodNow };
730
+ }
731
+ finish(now) {
732
+ const trailing = this.assembler.flush();
733
+ if (trailing.length === 0)
734
+ return { completed: [], matched: [], untilLine: null, floodNow: false };
735
+ return this.feedChunk(trailing[0] + `
736
+ `, now);
737
+ }
738
+ buffer(line) {
739
+ this.pending.push(line);
740
+ this.pendingBytes += byteLen(line);
741
+ }
742
+ takePush() {
743
+ if (this.pending.length === 0)
744
+ return null;
745
+ if (this.state.state === "budget-exhausted") {
746
+ this.state.droppedLines += this.pending.length;
747
+ this.pending = [];
748
+ this.pendingBytes = 0;
749
+ return null;
750
+ }
751
+ if (this.state.pushes >= WATCH_MAX_PUSHES_PER_COMMAND) {
752
+ this.state.state = "budget-exhausted";
753
+ this.state.droppedLines += this.pending.length;
754
+ this.pending = [];
755
+ this.pendingBytes = 0;
756
+ return { lines: [], exhaustedNow: true };
757
+ }
758
+ const lines = [];
759
+ let bytes = 0;
760
+ while (this.pending.length > 0 && lines.length < WATCH_MAX_LINES_PER_PUSH) {
761
+ const next = this.pending[0];
762
+ const nextBytes = byteLen(next) + (lines.length > 0 ? 1 : 0);
763
+ if (bytes + nextBytes > WATCH_MAX_BYTES_PER_PUSH && lines.length > 0)
764
+ break;
765
+ this.pending.shift();
766
+ this.pendingBytes -= byteLen(next);
767
+ lines.push(next);
768
+ bytes += nextBytes;
769
+ }
770
+ this.state.pushes++;
771
+ return { lines, exhaustedNow: false };
772
+ }
773
+ takeUntilPush() {
774
+ const lines = [];
775
+ let bytes = 0;
776
+ while (this.pending.length > 0 && lines.length < WATCH_MAX_LINES_PER_PUSH) {
777
+ const next = this.pending.pop();
778
+ this.pendingBytes -= byteLen(next);
779
+ const nextBytes = byteLen(next) + (lines.length > 0 ? 1 : 0);
780
+ if (bytes + nextBytes > WATCH_MAX_BYTES_PER_PUSH && lines.length > 0) {
781
+ this.pending.push(next);
782
+ this.pendingBytes += byteLen(next);
783
+ break;
784
+ }
785
+ lines.unshift(next);
786
+ bytes += nextBytes;
787
+ }
788
+ this.state.droppedLines += this.pending.length;
789
+ this.pending = [];
790
+ this.pendingBytes = 0;
791
+ this.state.pushes++;
792
+ return lines;
793
+ }
794
+ }
795
+ function formatWatchMessage(title, lines, note) {
796
+ const header = `[watch "${title}"]`;
797
+ const tail = note ? [...lines, note].join(`
798
+ `) : lines.join(`
799
+ `);
800
+ if (!tail)
801
+ return header;
802
+ const room = Math.max(0, MAX_WATCH_MESSAGE_BYTES - byteLen(header) - 1);
803
+ return `${header}
804
+ ${tailLastBytes(tail, room)}`;
805
+ }
806
+ function formatWatchFloodMessage(title, dropped) {
807
+ 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.`;
808
+ }
809
+ function formatWatchBudgetMessage(title) {
810
+ return `[watch "${title}"] BUDGET: push limit (${WATCH_MAX_PUSHES_PER_COMMAND}) reached. ` + `Watch exhausted; further matches are dropped. The process keeps running.`;
811
+ }
812
+ 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;
813
+ var init_command_watch = __esm(() => {
814
+ init_command_await();
815
+ WATCH_MAX_BYTES_PER_PUSH = 4 * 1024;
816
+ MAX_WATCH_MESSAGE_BYTES = 4 * 1024;
817
+ DANGEROUS_REGEXES = [
818
+ /\(\?:.*\)\*.*\(\?:.*\)\*/,
819
+ /.*\(\.\*\?\)\{2,\}.*/,
820
+ /.*\(.*\|.*\)\{3,\}.*/,
821
+ /\([^()]*[+*][^()]*\)[+*]/,
822
+ /\([^()]*(\.\*.*\.\*|\.\+.*\.\+|\\w\+.*\\s\*|\\s\*.*\\w\+)[^()]*\)/
823
+ ];
824
+ ANSI_PATTERN = new RegExp("\x1B\\[[0-9;?]*[ -/]*[@-~]|\x1B\\][^\x07]*(?:\x07|\x1B\\\\)|\x1B[()][0-9A-Z]", "g");
825
+ });
826
+
588
827
  // src/application/command-await.ts
589
828
  import { randomUUID } from "crypto";
590
829
  function ledgerEvent(base) {
@@ -610,6 +849,18 @@ async function requestCommandAwait(directory, input) {
610
849
  if (goal.ownerSessionID !== command.ownerSessionID) {
611
850
  return { ok: false, message: "Owner mismatch: goal and command are owned by different sessions." };
612
851
  }
852
+ const until = input.until !== undefined && input.until !== "" ? input.until : undefined;
853
+ const ignoreCase = input.ignoreCase === true;
854
+ if (input.until !== undefined && until === undefined) {
855
+ return { ok: false, message: "Await 'until' must be a non-empty regex." };
856
+ }
857
+ if (until !== undefined) {
858
+ try {
859
+ compileWatchPattern("until", until, ignoreCase);
860
+ } catch (e) {
861
+ return { ok: false, message: e instanceof Error ? e.message : String(e) };
862
+ }
863
+ }
613
864
  const existing = (snapshot.commandAwaits ?? []).some((a) => a.goalID === input.goalID && a.commandID === input.commandID);
614
865
  if (existing) {
615
866
  return { ok: true, message: `Already awaiting "${command.title}" for goal "${goal.name}".` };
@@ -620,7 +871,8 @@ async function requestCommandAwait(directory, input) {
620
871
  goalID: input.goalID,
621
872
  commandID: input.commandID,
622
873
  ownerSessionID: input.ownerSessionID,
623
- createdAt: new Date().toISOString()
874
+ createdAt: new Date().toISOString(),
875
+ ...until !== undefined ? { until, ignoreCase } : {}
624
876
  }];
625
877
  return s;
626
878
  });
@@ -641,7 +893,8 @@ async function requestCommandAwait(directory, input) {
641
893
  goalID: input.goalID,
642
894
  commandID: input.commandID,
643
895
  ownerSessionID: input.ownerSessionID,
644
- createdAt: new Date().toISOString()
896
+ createdAt: new Date().toISOString(),
897
+ ...until !== undefined ? { until, ignoreCase } : {}
645
898
  }];
646
899
  }
647
900
  const current = (s.commands ?? []).find((c) => c.id === input.commandID);
@@ -660,12 +913,133 @@ async function requestCommandAwait(directory, input) {
660
913
  active: mine?.active ?? goal.status === "active"
661
914
  };
662
915
  }
916
+ if (until !== undefined) {
917
+ const retained = await readRetainedLines(directory, input.commandID);
918
+ const hit = retained.find((line) => testUntil(until, ignoreCase, line));
919
+ if (hit !== undefined) {
920
+ const fired = await fireUntilAwaits(directory, input.commandID, [hit]);
921
+ const mine = fired.find((f) => f.goalID === input.goalID);
922
+ if (mine) {
923
+ return {
924
+ ok: true,
925
+ message: `Pattern "${until}" already present in "${command.title}" output; wake-up delivered to goal "${goal.name}".`,
926
+ fired: true,
927
+ active: mine.active
928
+ };
929
+ }
930
+ const again = await fireCommandAwaits(directory, input.commandID);
931
+ const second = again.find((f) => f.goalID === input.goalID);
932
+ if (second) {
933
+ return {
934
+ ok: true,
935
+ message: `Command "${command.title}" exited while registering; wake-up delivered to goal "${goal.name}".`,
936
+ fired: true,
937
+ active: second.active
938
+ };
939
+ }
940
+ }
941
+ }
663
942
  await appendEvent(directory, ledgerEvent({
664
943
  goalID: input.goalID,
665
944
  commandID: input.commandID,
666
- type: "command.await-requested"
945
+ type: "command.await-requested",
946
+ ...until !== undefined ? { until } : {}
667
947
  })).catch(() => {});
668
- return { ok: true, message: `Goal "${goal.name}" now awaits "${command.title}" (fires once on exit).` };
948
+ return {
949
+ ok: true,
950
+ 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).`
951
+ };
952
+ }
953
+ function testUntil(until, ignoreCase, line) {
954
+ try {
955
+ const re = new RegExp(until, ignoreCase ? "i" : "");
956
+ const hit = re.test(line);
957
+ re.lastIndex = 0;
958
+ return hit;
959
+ } catch {
960
+ return false;
961
+ }
962
+ }
963
+ async function readRetainedLines(directory, commandID) {
964
+ try {
965
+ const full = await readCommandLog(directory, commandID, {
966
+ offsetBytes: 0,
967
+ limitBytes: 512 * 1024
968
+ });
969
+ if (!full.text)
970
+ return [];
971
+ return full.text.split(`
972
+ `).filter((l) => l.trim() !== "");
973
+ } catch {
974
+ return [];
975
+ }
976
+ }
977
+ async function fireUntilAwaits(directory, commandID, lines) {
978
+ if (lines.length === 0)
979
+ return [];
980
+ let candidates = [];
981
+ let command;
982
+ await mutateState(directory, `cmd.await-until-consume:${commandID}`, async (s) => {
983
+ const outstanding = (s.commandAwaits ?? []).filter((a) => a.commandID === commandID && typeof a.until === "string" && a.until !== "");
984
+ if (outstanding.length === 0)
985
+ return s;
986
+ const current = (s.commands ?? []).find((c) => c.id === commandID);
987
+ if (!current)
988
+ return s;
989
+ command = current;
990
+ const hits = [];
991
+ for (const a of outstanding) {
992
+ const pattern = a.until;
993
+ if (lines.some((line) => testUntil(pattern, a.ignoreCase === true, line)))
994
+ hits.push(a);
995
+ }
996
+ if (hits.length === 0)
997
+ return s;
998
+ const hitKeys = new Set(hits.map((h) => `${h.goalID}:${h.commandID}`));
999
+ s.commandAwaits = (s.commandAwaits ?? []).filter((a) => !(a.commandID === commandID && hitKeys.has(`${a.goalID}:${a.commandID}`)));
1000
+ candidates = hits;
1001
+ return s;
1002
+ });
1003
+ if (candidates.length === 0 || !command)
1004
+ return [];
1005
+ const cmd = command;
1006
+ const tail = await readBoundedTail(directory, commandID);
1007
+ const fired = [];
1008
+ for (const a of candidates) {
1009
+ const pattern = a.until;
1010
+ const matched = lines.filter((line) => testUntil(pattern, a.ignoreCase === true, line));
1011
+ const fresh = await readState(directory);
1012
+ const goal = fresh.goals.find((g) => g.id === a.goalID);
1013
+ if (!goal) {
1014
+ await appendEvent(directory, ledgerEvent({
1015
+ goalID: a.goalID,
1016
+ commandID,
1017
+ type: "command.await-discarded",
1018
+ reason: "goal gone"
1019
+ })).catch(() => {});
1020
+ continue;
1021
+ }
1022
+ const short = commandID.slice(0, 8);
1023
+ const header = `[command "${cmd.title}" (${[cmd.command, ...cmd.args].join(" ") || cmd.title}) ${cmd.status}` + ` ${short}... watch-until "${pattern}" matched]`;
1024
+ const matchedText = tailLastBytes(matched.slice(0, 20).join(`
1025
+ `), MAX_AWAIT_TAIL_BYTES);
1026
+ const evidence = tail ? `${header}
1027
+ ${matchedText}
1028
+ --- tail ---
1029
+ ${tail}` : `${header}
1030
+ ${matchedText}`;
1031
+ await appendGoalInbox(directory, a.goalID, "worker", evidence);
1032
+ await appendEvent(directory, ledgerEvent({
1033
+ goalID: a.goalID,
1034
+ commandID,
1035
+ type: "command.await-fired",
1036
+ until: pattern,
1037
+ matchedLines: matched.length,
1038
+ status: cmd.status
1039
+ })).catch(() => {});
1040
+ fired.push({ goalID: a.goalID, commandID, active: goal.status === "active" });
1041
+ }
1042
+ return fired;
669
1043
  }
670
1044
  async function readBoundedTail(directory, commandID) {
671
1045
  try {
@@ -848,6 +1222,7 @@ async function wakeGoalForAwait(directory, continuation, goalID) {
848
1222
  }
849
1223
  var init_command_await2 = __esm(() => {
850
1224
  init_command_await();
1225
+ init_command_watch();
851
1226
  init_state_repository();
852
1227
  });
853
1228
 
@@ -1313,7 +1688,8 @@ function createControlWorker(options) {
1313
1688
  case "cmd_interrupt":
1314
1689
  case "cmd_terminate":
1315
1690
  case "cmd_remove":
1316
- case "cmd_resize": {
1691
+ case "cmd_resize":
1692
+ case "cmd_watch": {
1317
1693
  const cmdSvc = options.commandService;
1318
1694
  if (!cmdSvc) {
1319
1695
  response = {
@@ -1367,6 +1743,16 @@ function createControlWorker(options) {
1367
1743
  const r = await cmdSvc.remove(directory, id, ownerSessionID);
1368
1744
  const state = await readState(directory);
1369
1745
  response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1746
+ } else if (request.command === "cmd_watch") {
1747
+ const r = await cmdSvc.watch(directory, id, ownerSessionID, {
1748
+ ...typeof args.watchFilter === "string" ? { filter: args.watchFilter } : {},
1749
+ ...typeof args.watchUntil === "string" ? { until: args.watchUntil } : {},
1750
+ ...typeof args.watchIgnoreCase === "boolean" ? { ignoreCase: args.watchIgnoreCase } : {},
1751
+ ...typeof args.watchUntilAction === "string" ? { untilAction: args.watchUntilAction } : {},
1752
+ ...typeof args.clear === "boolean" ? { clear: args.clear } : {}
1753
+ }).catch((error) => ({ ok: false, message: error instanceof Error ? error.message : String(error) }));
1754
+ const state = await readState(directory);
1755
+ response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
1370
1756
  } else {
1371
1757
  const r = await cmdSvc.resize(directory, id, ownerSessionID, Number(args.cols), Number(args.rows));
1372
1758
  const state = await readState(directory);
@@ -2383,6 +2769,108 @@ import { promises as fs2 } from "fs";
2383
2769
 
2384
2770
  // src/server/host-adapter.ts
2385
2771
  import { randomUUID as randomUUID4 } from "crypto";
2772
+
2773
+ // src/v2/native-rpc.ts
2774
+ var NATIVE_RPC_ID = "loopd.native";
2775
+ var CLAIM_TIMEOUT_MS = 3000;
2776
+ var CLAIMED_TIMEOUT_MS = 15000;
2777
+ function resolveNativeParentID(child) {
2778
+ return child.fork?.sessionID ?? child.parentID ?? undefined;
2779
+ }
2780
+ var requestSchema = {
2781
+ type: "object",
2782
+ properties: {
2783
+ requestID: { type: "string" },
2784
+ goalID: { type: "string" },
2785
+ parentSessionID: { type: "string" },
2786
+ title: { type: "string" },
2787
+ agent: { type: "string" },
2788
+ model: {
2789
+ type: "object",
2790
+ properties: {
2791
+ id: { type: "string" },
2792
+ providerID: { type: "string" }
2793
+ },
2794
+ required: ["id", "providerID"],
2795
+ additionalProperties: false
2796
+ },
2797
+ permissions: {
2798
+ type: "array",
2799
+ items: {
2800
+ type: "object",
2801
+ properties: {
2802
+ action: { type: "string" },
2803
+ resource: { type: "string" },
2804
+ effect: { type: "string", enum: ["allow", "deny", "ask"] }
2805
+ },
2806
+ required: ["action", "resource", "effect"],
2807
+ additionalProperties: false
2808
+ }
2809
+ },
2810
+ directory: { type: "string" }
2811
+ },
2812
+ required: ["requestID", "goalID", "parentSessionID", "title"],
2813
+ additionalProperties: false
2814
+ };
2815
+ var claimResultSchema = {
2816
+ type: "object",
2817
+ properties: {
2818
+ requestID: { type: "string" },
2819
+ claimantID: { type: "string" }
2820
+ },
2821
+ required: ["requestID", "claimantID"],
2822
+ additionalProperties: false
2823
+ };
2824
+ var claimAckSchema = {
2825
+ type: "object",
2826
+ properties: {
2827
+ requestID: { type: "string" },
2828
+ claimantID: { type: "string" },
2829
+ won: { type: "boolean" }
2830
+ },
2831
+ required: ["requestID", "claimantID", "won"],
2832
+ additionalProperties: false
2833
+ };
2834
+ var createResultSchema = {
2835
+ type: "object",
2836
+ properties: {
2837
+ requestID: { type: "string" },
2838
+ childSessionID: { type: "string" },
2839
+ parentSessionID: { type: "string" },
2840
+ topology: { type: "string", const: "v2-native-child" }
2841
+ },
2842
+ required: ["requestID", "childSessionID", "parentSessionID", "topology"],
2843
+ additionalProperties: false
2844
+ };
2845
+ var failureSchema = {
2846
+ type: "object",
2847
+ properties: {
2848
+ requestID: { type: "string" },
2849
+ reason: { type: "string" },
2850
+ detail: { type: "string" },
2851
+ preCreation: { type: "boolean" }
2852
+ },
2853
+ required: ["requestID", "reason", "preCreation"],
2854
+ additionalProperties: false
2855
+ };
2856
+ var emptySchema = {
2857
+ type: "object",
2858
+ properties: {},
2859
+ additionalProperties: false
2860
+ };
2861
+ var nativeRpcDefinition = {
2862
+ id: NATIVE_RPC_ID,
2863
+ methods: {
2864
+ claimRequest: { input: claimResultSchema, output: claimAckSchema, errors: {} },
2865
+ completeWorkerCreate: { input: createResultSchema, output: emptySchema, errors: {} },
2866
+ failRequest: { input: failureSchema, output: emptySchema, errors: {} }
2867
+ },
2868
+ events: {
2869
+ workerCreateRequested: { schema: requestSchema }
2870
+ }
2871
+ };
2872
+
2873
+ // src/server/host-adapter.ts
2386
2874
  function parseModelRef(value) {
2387
2875
  if (value === undefined)
2388
2876
  return;
@@ -2596,20 +3084,83 @@ function createRealHost(client, directory) {
2596
3084
  }
2597
3085
  };
2598
3086
  }
2599
- function createV2Host(context, statuses) {
3087
+ function toWorkerCreation(result) {
3088
+ return typeof result === "string" ? { sessionID: result } : result;
3089
+ }
3090
+ function createV2Host(context, statuses, options = {}) {
2600
3091
  const directory = context.location.directory;
2601
3092
  return {
2602
- async createWorker({ parentID, title, agent, model }) {
3093
+ async createWorker({ parentID, title, agent, model, goalID }) {
3094
+ if (options.native) {
3095
+ let outcome;
3096
+ try {
3097
+ outcome = await options.native.requestWorker({
3098
+ goalID,
3099
+ parentSessionID: parentID,
3100
+ title,
3101
+ agent,
3102
+ model,
3103
+ directory
3104
+ });
3105
+ } catch (error) {
3106
+ await logServerEvent(directory, "worker.create.native-failed", {
3107
+ parentID,
3108
+ title,
3109
+ detail: describeError(error)
3110
+ });
3111
+ throw error;
3112
+ }
3113
+ if (outcome.kind === "native-child") {
3114
+ const childID = outcome.childSessionID;
3115
+ const child = await context.session.get({ sessionID: childID });
3116
+ const actualParent = resolveNativeParentID(child);
3117
+ if (actualParent !== parentID) {
3118
+ const detail = `resolved-parent=${JSON.stringify(actualParent)} expected=${JSON.stringify(parentID)}`;
3119
+ await logServerEvent(directory, "worker.create.parent-mismatch", { parentID, workerSessionID: childID, detail });
3120
+ throw new Error(`loopd native worker creation failed for parent "${parentID}" (parent-mismatch): ${detail}`);
3121
+ }
3122
+ if (agent)
3123
+ await context.session.switchAgent({ sessionID: childID, agent });
3124
+ if (model) {
3125
+ await context.session.switchModel({
3126
+ sessionID: childID,
3127
+ model: { id: model.modelID, providerID: model.providerID }
3128
+ });
3129
+ }
3130
+ const rename = context.session.update;
3131
+ if (rename)
3132
+ await rename({ sessionID: childID, title });
3133
+ statuses.set(childID, "idle");
3134
+ await logServerEvent(directory, "worker.created", {
3135
+ parentID,
3136
+ workerSessionID: childID,
3137
+ title,
3138
+ topology: "v2-native-child",
3139
+ nativeParentID: parentID
3140
+ });
3141
+ return { sessionID: childID, topology: "v2-native-child", nativeParentID: parentID };
3142
+ }
3143
+ await logServerEvent(directory, "worker.create.native-fallback", {
3144
+ parentID,
3145
+ title,
3146
+ reason: outcome.kind === "fallback-safe" ? outcome.reason : "unclaimed"
3147
+ });
3148
+ }
2603
3149
  const session = await context.session.create({
2604
3150
  title,
2605
- agent,
2606
- model: model ? { id: model.modelID, providerID: model.providerID } : undefined,
3151
+ ...agent !== undefined ? { agent } : {},
3152
+ ...model !== undefined ? { model: { id: model.modelID, providerID: model.providerID } } : {},
2607
3153
  location: { directory },
2608
3154
  metadata: { "loopd.parentID": parentID }
2609
3155
  });
2610
3156
  statuses.set(session.id, "idle");
2611
- await logServerEvent(directory, "worker.created", { parentID, workerSessionID: session.id, title });
2612
- return session.id;
3157
+ await logServerEvent(directory, "worker.created", {
3158
+ parentID,
3159
+ workerSessionID: session.id,
3160
+ title,
3161
+ topology: "v2-root-fallback"
3162
+ });
3163
+ return { sessionID: session.id, topology: "v2-root-fallback" };
2613
3164
  },
2614
3165
  async promptWorker({ sessionID, prompt, messageID, model, agent }) {
2615
3166
  if (agent)
@@ -2727,16 +3278,18 @@ async function withTimeout(promise, timeoutMs, operation) {
2727
3278
  function createWorkerManager(host) {
2728
3279
  return {
2729
3280
  async createWorker(goal) {
2730
- const workerSessionID = await host.createWorker({
3281
+ const created = toWorkerCreation(await host.createWorker({
2731
3282
  parentID: goal.ownerSessionID,
2732
3283
  title: `loopd: ${goal.name}`,
2733
3284
  agent: goal.config.agent,
2734
- model: parseModelRef(goal.config.model)
2735
- });
3285
+ model: parseModelRef(goal.config.model),
3286
+ goalID: goal.id
3287
+ }));
2736
3288
  return {
2737
3289
  goalID: goal.id,
2738
- workerSessionID,
2739
- startedAt: new Date().toISOString()
3290
+ workerSessionID: created.sessionID,
3291
+ startedAt: new Date().toISOString(),
3292
+ ...created.topology ? { topology: created.topology, nativeParentID: created.nativeParentID } : {}
2740
3293
  };
2741
3294
  },
2742
3295
  async continueWorker(worker, goal, runtime, context) {
@@ -2980,8 +3533,13 @@ function createGoalService(host) {
2980
3533
  sessions.set(goal.id, session);
2981
3534
  await mutateState(directory, `goal.set-worker:${goal.id}`, async (s) => {
2982
3535
  const persisted = s.goals.find((item) => item.id === goal.id);
2983
- if (persisted)
3536
+ if (persisted) {
2984
3537
  persisted.workerSessionID = session.workerSessionID;
3538
+ if (session.topology) {
3539
+ persisted.workerTopology = session.topology;
3540
+ persisted.nativeParentID = session.nativeParentID;
3541
+ }
3542
+ }
2985
3543
  return s;
2986
3544
  });
2987
3545
  return session;
@@ -3085,6 +3643,12 @@ function createGoalService(host) {
3085
3643
  return state;
3086
3644
  g.workerSessionID = worker.workerSessionID;
3087
3645
  goal.workerSessionID = worker.workerSessionID;
3646
+ if (worker.topology) {
3647
+ g.workerTopology = worker.topology;
3648
+ g.nativeParentID = worker.nativeParentID;
3649
+ goal.workerTopology = worker.topology;
3650
+ goal.nativeParentID = worker.nativeParentID;
3651
+ }
3088
3652
  const rt = state.runtimes.find((item) => item.goalID === id);
3089
3653
  if (rt) {
3090
3654
  Object.assign(rt, acquireLease(rt, g.config.timeoutMs || 300000));
@@ -3522,6 +4086,10 @@ function createGoalService(host) {
3522
4086
  const g = s.goals.find((item) => item.id === goal.id);
3523
4087
  if (g) {
3524
4088
  g.workerSessionID = worker.workerSessionID;
4089
+ if (worker.topology) {
4090
+ g.workerTopology = worker.topology;
4091
+ g.nativeParentID = worker.nativeParentID;
4092
+ }
3525
4093
  g.updatedAt = new Date().toISOString();
3526
4094
  }
3527
4095
  return s;
@@ -3790,6 +4358,168 @@ function createScheduleWorker(options) {
3790
4358
  return { start, stop, isRunning, tick };
3791
4359
  }
3792
4360
 
4361
+ // src/v2/native-server.ts
4362
+ import { randomUUID as randomUUID7 } from "crypto";
4363
+
4364
+ // src/v2/native-bridge.ts
4365
+ class NativeBridgeError extends Error {
4366
+ code;
4367
+ requestID;
4368
+ constructor(code, requestID, message) {
4369
+ super(message);
4370
+ this.name = "NativeBridgeError";
4371
+ this.code = code;
4372
+ this.requestID = requestID;
4373
+ }
4374
+ }
4375
+ function createNativeBridge(options = {}) {
4376
+ const claimTimeoutMs = options.claimTimeoutMs ?? CLAIM_TIMEOUT_MS;
4377
+ const claimedTimeoutMs = options.claimedTimeoutMs ?? CLAIMED_TIMEOUT_MS;
4378
+ const pending = new Map;
4379
+ let disposed = false;
4380
+ function settle(requestID, settleFn) {
4381
+ const entry = pending.get(requestID);
4382
+ if (!entry || entry.state === "settled")
4383
+ return false;
4384
+ entry.state = "settled";
4385
+ if (entry.claimTimer)
4386
+ clearTimeout(entry.claimTimer);
4387
+ if (entry.claimedTimer)
4388
+ clearTimeout(entry.claimedTimer);
4389
+ pending.delete(requestID);
4390
+ settleFn(entry);
4391
+ return true;
4392
+ }
4393
+ return {
4394
+ pendingCount: () => pending.size,
4395
+ isDisposed: () => disposed,
4396
+ requestWorker(request, emit) {
4397
+ if (disposed) {
4398
+ return Promise.reject(new NativeBridgeError("disposed", request.requestID, "native bridge is disposed"));
4399
+ }
4400
+ return new Promise((resolve, reject) => {
4401
+ const entry = { state: "awaiting-claim", resolve, reject };
4402
+ pending.set(request.requestID, entry);
4403
+ let emitResult;
4404
+ try {
4405
+ emitResult = emit(request);
4406
+ } catch {
4407
+ settle(request.requestID, (e) => e.resolve({ kind: "unclaimed" }));
4408
+ return;
4409
+ }
4410
+ const afterEmit = () => {
4411
+ const live = pending.get(request.requestID);
4412
+ if (!live || live.state !== "awaiting-claim")
4413
+ return;
4414
+ live.claimTimer = setTimeout(() => {
4415
+ settle(request.requestID, (e) => e.resolve({ kind: "unclaimed" }));
4416
+ }, claimTimeoutMs);
4417
+ if (typeof live.claimTimer?.unref === "function")
4418
+ live.claimTimer.unref();
4419
+ };
4420
+ if (emitResult && typeof emitResult.then === "function") {
4421
+ emitResult.then(afterEmit, () => {
4422
+ settle(request.requestID, (e) => e.resolve({ kind: "unclaimed" }));
4423
+ });
4424
+ } else {
4425
+ afterEmit();
4426
+ }
4427
+ });
4428
+ },
4429
+ handleClaim(claim) {
4430
+ const entry = pending.get(claim.requestID);
4431
+ if (!entry || entry.state !== "awaiting-claim")
4432
+ return { won: false };
4433
+ entry.state = "claimed";
4434
+ entry.claimantID = claim.claimantID;
4435
+ if (entry.claimTimer)
4436
+ clearTimeout(entry.claimTimer);
4437
+ entry.claimedTimer = setTimeout(() => {
4438
+ 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`)));
4439
+ }, claimedTimeoutMs);
4440
+ if (typeof entry.claimedTimer?.unref === "function")
4441
+ entry.claimedTimer.unref();
4442
+ return { won: true };
4443
+ },
4444
+ handleComplete(result) {
4445
+ return settle(result.requestID, (entry) => entry.resolve({
4446
+ kind: "native-child",
4447
+ childSessionID: result.childSessionID,
4448
+ parentSessionID: result.parentSessionID,
4449
+ topology: "v2-native-child"
4450
+ }));
4451
+ },
4452
+ handleFailure(failure) {
4453
+ if (failure.preCreation) {
4454
+ return settle(failure.requestID, (entry) => entry.resolve({ kind: "fallback-safe", reason: failure.reason, detail: failure.detail }));
4455
+ }
4456
+ 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"}`)));
4457
+ },
4458
+ dispose() {
4459
+ disposed = true;
4460
+ const ids = [...pending.keys()];
4461
+ for (const id of ids) {
4462
+ settle(id, (entry) => entry.reject(new NativeBridgeError("disposed", id, "native bridge disposed with request pending")));
4463
+ }
4464
+ }
4465
+ };
4466
+ }
4467
+
4468
+ // src/v2/native-server.ts
4469
+ async function setupNativeServer(context) {
4470
+ if (!context || !context.rpc || typeof context.rpc.register !== "function")
4471
+ return;
4472
+ const bridge = createNativeBridge();
4473
+ let registration;
4474
+ try {
4475
+ registration = await context.rpc.register(nativeRpcDefinition, {
4476
+ claimRequest: async (input) => {
4477
+ const { won } = bridge.handleClaim(input);
4478
+ return { requestID: input.requestID, claimantID: input.claimantID, won };
4479
+ },
4480
+ completeWorkerCreate: async (input) => {
4481
+ bridge.handleComplete(input);
4482
+ return {};
4483
+ },
4484
+ failRequest: async (input) => {
4485
+ bridge.handleFailure(input);
4486
+ return {};
4487
+ }
4488
+ });
4489
+ } catch {
4490
+ bridge.dispose();
4491
+ return;
4492
+ }
4493
+ if (!registration || !registration.events || typeof registration.events.emit !== "function") {
4494
+ bridge.dispose();
4495
+ try {
4496
+ await registration?.dispose();
4497
+ } catch {}
4498
+ return;
4499
+ }
4500
+ return {
4501
+ bridge,
4502
+ requestWorker: async (input) => {
4503
+ const request = {
4504
+ requestID: `req_${randomUUID7()}`,
4505
+ goalID: input.goalID ?? "",
4506
+ parentSessionID: input.parentSessionID,
4507
+ title: input.title,
4508
+ ...input.agent ? { agent: input.agent } : {},
4509
+ ...input.model ? { model: { id: input.model.modelID, providerID: input.model.providerID } } : {},
4510
+ directory: input.directory
4511
+ };
4512
+ return bridge.requestWorker(request, (req) => registration.events.emit("workerCreateRequested", req));
4513
+ },
4514
+ dispose: async () => {
4515
+ bridge.dispose();
4516
+ try {
4517
+ await registration.dispose();
4518
+ } catch {}
4519
+ }
4520
+ };
4521
+ }
4522
+
3793
4523
  // src/server/command-host.ts
3794
4524
  import { createRequire } from "module";
3795
4525
  var COMMAND_HOST_CAPABILITIES = {
@@ -4040,21 +4770,44 @@ function createCommandHost(opts) {
4040
4770
  }
4041
4771
 
4042
4772
  // src/application/command-service.ts
4043
- import { randomUUID as randomUUID7 } from "crypto";
4773
+ import { randomUUID as randomUUID8 } from "crypto";
4044
4774
  import { promises as fs3 } from "fs";
4045
4775
  import path3 from "path";
4046
4776
 
4047
4777
  // src/domain/command-session.ts
4778
+ function normalizeTimeoutSeconds(timeoutSeconds) {
4779
+ if (timeoutSeconds === undefined)
4780
+ return;
4781
+ if (!Number.isInteger(timeoutSeconds) || timeoutSeconds <= 0) {
4782
+ throw new Error("timeoutSeconds must be a positive integer in seconds");
4783
+ }
4784
+ return timeoutSeconds;
4785
+ }
4786
+ function computeDeadlineAt(from, timeoutSeconds) {
4787
+ return new Date(from.getTime() + timeoutSeconds * 1000).toISOString();
4788
+ }
4048
4789
  function createCommandSession(input) {
4049
4790
  const now = new Date().toISOString();
4791
+ const timeout = normalizeTimeoutSeconds(input.timeoutSeconds);
4050
4792
  return {
4051
4793
  id: input.id,
4052
4794
  title: input.title,
4053
4795
  command: input.command,
4054
4796
  args: input.args ?? [],
4797
+ shell: input.shell,
4798
+ endReason: input.endReason,
4799
+ timeoutSeconds: timeout,
4800
+ deadlineAt: input.deadlineAt ?? (timeout !== undefined ? computeDeadlineAt(new Date(now), timeout) : undefined),
4801
+ envKeys: input.envKeys,
4802
+ watchFilter: input.watchFilter,
4803
+ watchUntil: input.watchUntil,
4804
+ watchIgnoreCase: input.watchIgnoreCase,
4805
+ watchUntilAction: input.watchUntilAction,
4806
+ watchState: input.watchState,
4055
4807
  cwd: input.cwd,
4056
4808
  ownerSessionID: input.ownerSessionID,
4057
4809
  goalID: input.goalID,
4810
+ notifyOnExit: input.notifyOnExit,
4058
4811
  status: "running",
4059
4812
  pid: input.pid,
4060
4813
  cols: input.cols,
@@ -4067,6 +4820,35 @@ function createCommandSession(input) {
4067
4820
  };
4068
4821
  }
4069
4822
  var MAX_COMMAND_OUTPUT_BYTES = 512 * 1024;
4823
+ var MAX_COMMAND_READ_LINES = 500;
4824
+ var NOTIFY_LONG_RUNNING_MS = 2 * 60 * 1000;
4825
+ function shouldNotifyOwnerOnExit(session) {
4826
+ if (session.endReason === "until")
4827
+ return false;
4828
+ if (session.notifyOnExit === false)
4829
+ return false;
4830
+ if (session.notifyOnExit === true)
4831
+ return true;
4832
+ if (session.endReason === "timeout")
4833
+ return true;
4834
+ if (session.status === "missing")
4835
+ return true;
4836
+ if (session.status === "terminated")
4837
+ return false;
4838
+ if (session.status !== "exited")
4839
+ return false;
4840
+ if (session.exitCode !== undefined && session.exitCode !== 0)
4841
+ return true;
4842
+ const started = Date.parse(session.createdAt);
4843
+ const ended = session.endedAt ? Date.parse(session.endedAt) : Date.now();
4844
+ if (!Number.isFinite(started) || !Number.isFinite(ended))
4845
+ return false;
4846
+ return ended - started >= NOTIFY_LONG_RUNNING_MS;
4847
+ }
4848
+
4849
+ // src/application/command-service.ts
4850
+ init_command_watch();
4851
+ init_command_await();
4070
4852
 
4071
4853
  // src/domain/command-events.ts
4072
4854
  var _encoder = new TextEncoder;
@@ -4243,6 +5025,37 @@ function validateCommandStreamMessage(value) {
4243
5025
  // src/application/command-service.ts
4244
5026
  init_command_await2();
4245
5027
  init_state_repository();
5028
+ var ANSI_PATTERN2 = new RegExp("\x1B\\[[0-9;?]*[ -/]*[@-~]|\x1B\\][^\x07]*(?:\x07|\x1B\\\\)|\x1B[()][0-9A-Z]", "g");
5029
+ function stripAnsiForMatch(line) {
5030
+ return line.replace(ANSI_PATTERN2, "");
5031
+ }
5032
+ var DANGEROUS_REGEXES2 = [
5033
+ /\(\?:.*\)\*.*\(\?:.*\)\*/,
5034
+ /.*\(\.\*\?\)\{2,\}.*/,
5035
+ /.*\(.*\|.*\)\{3,\}.*/,
5036
+ /\([^()]*[+*][^()]*\)[+*]/,
5037
+ /\([^()]*(\.\*.*\.\*|\.\+.*\.\+|\\w\+.*\\s\*|\\s\*.*\\w\+)[^()]*\)/
5038
+ ];
5039
+ function validateReadPattern(pattern) {
5040
+ let compiled;
5041
+ try {
5042
+ compiled = new RegExp(pattern);
5043
+ } catch (e) {
5044
+ return { error: `Invalid regex pattern '${pattern}': ${e instanceof Error ? e.message : String(e)}` };
5045
+ }
5046
+ if (DANGEROUS_REGEXES2.some((d) => d.test(pattern))) {
5047
+ return { error: `Potentially dangerous regex pattern rejected: '${pattern}'. Please use a safer pattern.` };
5048
+ }
5049
+ return compiled;
5050
+ }
5051
+ function shellQuote(word) {
5052
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(word))
5053
+ return word;
5054
+ return `'${word.replace(/'/g, `'\\''`)}'`;
5055
+ }
5056
+ function shellJoin(command, args) {
5057
+ return [command, ...args].map(shellQuote).join(" ");
5058
+ }
4246
5059
  function loopCommandsDir(directory) {
4247
5060
  return path3.join(directory, ".opencode", "loopd", "commands");
4248
5061
  }
@@ -4251,6 +5064,112 @@ function createCommandService(host, opts) {
4251
5064
  const operations = new Map;
4252
5065
  const broker = opts?.broker;
4253
5066
  const commandDirs = new Map;
5067
+ const timeouts = new Map;
5068
+ const timeoutOwners = new Map;
5069
+ const watchers = new Map;
5070
+ const watchTimers = new Map;
5071
+ const awaitAsm = new Map;
5072
+ const watchCoalesceMs = opts?.watchCoalesceMs ?? WATCH_COALESCE_WINDOW_MS;
5073
+ function clearWatchTimer(id) {
5074
+ const t = watchTimers.get(id);
5075
+ if (t) {
5076
+ try {
5077
+ globalThis.clearTimeout(t);
5078
+ } catch {}
5079
+ }
5080
+ watchTimers.delete(id);
5081
+ }
5082
+ function deleteWatch(id) {
5083
+ clearWatchTimer(id);
5084
+ watchers.delete(id);
5085
+ awaitAsm.delete(id);
5086
+ }
5087
+ function scheduleWatchFlush(directory, id) {
5088
+ if (watchTimers.has(id))
5089
+ return;
5090
+ const handle = setTimeout(() => {
5091
+ watchTimers.delete(id);
5092
+ enqueue(id, () => flushWatch(directory, id)).catch(() => {});
5093
+ }, Math.max(0, watchCoalesceMs));
5094
+ try {
5095
+ handle.unref?.();
5096
+ } catch {}
5097
+ watchTimers.set(id, handle);
5098
+ }
5099
+ async function persistWatchState(directory, id, state) {
5100
+ await mutateState(directory, `cmd.watch:${id}`, async (s) => {
5101
+ const c = (s.commands ?? []).find((x) => x.id === id);
5102
+ if (c) {
5103
+ c.watchState = { ...state };
5104
+ c.updatedAt = new Date().toISOString();
5105
+ }
5106
+ return s;
5107
+ });
5108
+ }
5109
+ function watchLedger(directory, commandID, type, extra) {
5110
+ return appendEvent(directory, {
5111
+ version: 1,
5112
+ eventID: randomUUID8(),
5113
+ commandID,
5114
+ type,
5115
+ timestamp: new Date().toISOString(),
5116
+ revision: 0,
5117
+ ...extra
5118
+ }).catch(() => {});
5119
+ }
5120
+ function clearCommandTimeout(id) {
5121
+ const t = timeouts.get(id);
5122
+ if (t) {
5123
+ try {
5124
+ globalThis.clearTimeout(t);
5125
+ } catch {}
5126
+ }
5127
+ timeouts.delete(id);
5128
+ timeoutOwners.delete(id);
5129
+ }
5130
+ function scheduleTimeout(directory, id, ownerSessionID, timeoutSeconds) {
5131
+ clearCommandTimeout(id);
5132
+ const handle = setTimeout(() => {
5133
+ timeouts.delete(id);
5134
+ fireTimeout(directory, id).catch(() => {});
5135
+ }, timeoutSeconds * 1000);
5136
+ try {
5137
+ handle.unref?.();
5138
+ } catch {}
5139
+ timeouts.set(id, handle);
5140
+ timeoutOwners.set(id, { directory, ownerSessionID });
5141
+ }
5142
+ async function fireTimeout(directory, id) {
5143
+ const owner = timeoutOwners.get(id)?.ownerSessionID;
5144
+ timeoutOwners.delete(id);
5145
+ if (!owner)
5146
+ return;
5147
+ const before = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
5148
+ if (!before || before.status !== "running")
5149
+ return;
5150
+ await service.terminate(directory, id, owner).catch(() => {});
5151
+ await mutateState(directory, `cmd.timeout:${id}`, async (s) => {
5152
+ const c = (s.commands ?? []).find((x) => x.id === id);
5153
+ if (!c)
5154
+ return s;
5155
+ c.endReason = "timeout";
5156
+ c.lastError = c.lastError ?? `Command timed out after ${c.timeoutSeconds ?? "?"}s (timeoutSeconds deadline reached; terminated via the standard SIGTERM\u2192SIGKILL path).`;
5157
+ if (!c.endedAt) {
5158
+ c.endedAt = new Date().toISOString();
5159
+ c.updatedAt = c.endedAt;
5160
+ } else {
5161
+ c.updatedAt = new Date().toISOString();
5162
+ }
5163
+ return s;
5164
+ }).catch(() => {});
5165
+ try {
5166
+ const fresh = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
5167
+ if (fresh)
5168
+ emitBroker(id, { type: "status", command: fresh });
5169
+ } catch {}
5170
+ await fireAwaits(directory, id);
5171
+ await notifyOwnerIfNeeded(directory, id);
5172
+ }
4254
5173
  function tailText(entry, limitBytes = 64 * 1024) {
4255
5174
  let want = limitBytes;
4256
5175
  const parts = [];
@@ -4294,6 +5213,131 @@ function createCommandService(host, opts) {
4294
5213
  await opts?.onAwaitFired?.(directory, fired);
4295
5214
  } catch {}
4296
5215
  }
5216
+ async function notifyOwnerIfNeeded(directory, id) {
5217
+ try {
5218
+ let target;
5219
+ await mutateState(directory, `cmd.notify-claim:${id}`, async (s) => {
5220
+ const c = (s.commands ?? []).find((x) => x.id === id);
5221
+ if (!c)
5222
+ return s;
5223
+ if (c.ownerNotifiedAt)
5224
+ return s;
5225
+ if (!isTerminalCommandStatus(c.status))
5226
+ return s;
5227
+ if (!shouldNotifyOwnerOnExit(c))
5228
+ return s;
5229
+ c.ownerNotifiedAt = new Date().toISOString();
5230
+ target = { ...c };
5231
+ return s;
5232
+ });
5233
+ if (!target)
5234
+ return;
5235
+ const tail = await readBoundedTail(directory, id);
5236
+ const message = formatAwaitEvidence({
5237
+ title: target.title,
5238
+ argv: [target.command, ...target.args],
5239
+ commandID: target.id,
5240
+ status: target.status,
5241
+ exitCode: target.exitCode,
5242
+ signal: target.signal,
5243
+ tail
5244
+ });
5245
+ await opts?.onOwnerNotify?.(directory, target.ownerSessionID, message);
5246
+ } catch {}
5247
+ }
5248
+ async function flushWatch(directory, id) {
5249
+ const watcher = watchers.get(id);
5250
+ if (!watcher || watcher.pendingCount === 0)
5251
+ return;
5252
+ let session;
5253
+ try {
5254
+ session = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
5255
+ } catch {
5256
+ return;
5257
+ }
5258
+ if (!session || session.status !== "running")
5259
+ return;
5260
+ const push = watcher.takePush();
5261
+ await persistWatchState(directory, id, watcher.state).catch(() => {});
5262
+ if (!push)
5263
+ return;
5264
+ if (push.exhaustedNow) {
5265
+ await watchLedger(directory, id, "command.watch-suspended", { reason: "budget", pushes: watcher.state.pushes });
5266
+ try {
5267
+ await opts?.onWatchNotify?.(directory, session.ownerSessionID, formatWatchBudgetMessage(session.title));
5268
+ } catch {}
5269
+ return;
5270
+ }
5271
+ await watchLedger(directory, id, "command.watch-matched", {
5272
+ lines: push.lines.length,
5273
+ matches: watcher.state.matches,
5274
+ pushes: watcher.state.pushes
5275
+ });
5276
+ try {
5277
+ await opts?.onWatchNotify?.(directory, session.ownerSessionID, formatWatchMessage(session.title, push.lines));
5278
+ } catch {}
5279
+ if (watcher.pendingCount > 0)
5280
+ scheduleWatchFlush(directory, id);
5281
+ }
5282
+ async function handleUntilMatch(directory, id) {
5283
+ const watcher = watchers.get(id);
5284
+ if (!watcher)
5285
+ return;
5286
+ clearWatchTimer(id);
5287
+ let session;
5288
+ try {
5289
+ session = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
5290
+ } catch {
5291
+ return;
5292
+ }
5293
+ if (!session)
5294
+ return;
5295
+ const action = watcher.untilAction;
5296
+ const lines = watcher.takeUntilPush();
5297
+ const snapshot = { ...watcher.state };
5298
+ const message = formatWatchMessage(session.title, lines, `-- until "${session.watchUntil ?? ""}" matched (action=${action})`);
5299
+ await mutateState(directory, `cmd.until-claim:${id}`, async (s) => {
5300
+ const c = (s.commands ?? []).find((x) => x.id === id);
5301
+ if (!c)
5302
+ return s;
5303
+ c.watchState = { ...snapshot };
5304
+ if (action === "stop" && !c.ownerNotifiedAt)
5305
+ c.ownerNotifiedAt = new Date().toISOString();
5306
+ c.updatedAt = new Date().toISOString();
5307
+ return s;
5308
+ }).catch(() => {});
5309
+ await watchLedger(directory, id, "command.watch-matched", {
5310
+ until: session.watchUntil ?? "",
5311
+ lines: lines.length,
5312
+ pushes: snapshot.pushes
5313
+ });
5314
+ try {
5315
+ await opts?.onWatchNotify?.(directory, session.ownerSessionID, message);
5316
+ } catch {}
5317
+ if (action !== "stop")
5318
+ return;
5319
+ if (session.status !== "running")
5320
+ return;
5321
+ await watchLedger(directory, id, "command.until-stopped", { until: session.watchUntil ?? "" });
5322
+ await service.terminate(directory, id, session.ownerSessionID).catch(() => {});
5323
+ await mutateState(directory, `cmd.until:${id}`, async (s) => {
5324
+ const c = (s.commands ?? []).find((x) => x.id === id);
5325
+ if (!c)
5326
+ return s;
5327
+ if (c.status === "terminated" && c.endReason !== "timeout") {
5328
+ c.endReason = "until";
5329
+ c.lastError = c.lastError ?? `Watch until pattern "${c.watchUntil ?? ""}" matched; command stopped (untilAction=stop).`;
5330
+ c.updatedAt = new Date().toISOString();
5331
+ }
5332
+ return s;
5333
+ }).catch(() => {});
5334
+ try {
5335
+ const fresh = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
5336
+ if (fresh)
5337
+ emitBroker(id, { type: "status", command: fresh });
5338
+ } catch {}
5339
+ await fireAwaits(directory, id);
5340
+ }
4297
5341
  async function readBrokerSnapshot(commandID, ownerSessionID) {
4298
5342
  const directory = commandDirs.get(commandID);
4299
5343
  if (!directory)
@@ -4343,6 +5387,43 @@ function createCommandService(host, opts) {
4343
5387
  entry.bufferedBytes -= dropped.length;
4344
5388
  }
4345
5389
  }
5390
+ let watcher;
5391
+ let feed;
5392
+ let bareLines = [];
5393
+ try {
5394
+ watcher = watchers.get(id);
5395
+ if (!watcher) {
5396
+ const spec = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id)).catch(() => {
5397
+ return;
5398
+ });
5399
+ if (spec && spec.status === "running" && (spec.watchFilter !== undefined || spec.watchUntil !== undefined)) {
5400
+ try {
5401
+ watcher = new CommandWatcher({
5402
+ ...spec.watchFilter !== undefined ? { filter: spec.watchFilter } : {},
5403
+ ...spec.watchUntil !== undefined ? { until: spec.watchUntil } : {},
5404
+ ...spec.watchIgnoreCase !== undefined ? { ignoreCase: spec.watchIgnoreCase } : {},
5405
+ ...spec.watchUntilAction !== undefined ? { untilAction: spec.watchUntilAction } : {}
5406
+ });
5407
+ watchers.set(id, watcher);
5408
+ } catch {
5409
+ watcher = undefined;
5410
+ }
5411
+ }
5412
+ }
5413
+ if (watcher) {
5414
+ feed = watcher.feedChunk(chunk, Date.now());
5415
+ } else {
5416
+ let asm = awaitAsm.get(id);
5417
+ if (!asm) {
5418
+ asm = createLineAssembler();
5419
+ awaitAsm.set(id, asm);
5420
+ }
5421
+ bareLines = asm.push(chunk).filter((l) => l.trim() !== "");
5422
+ }
5423
+ } catch {
5424
+ watcher = undefined;
5425
+ feed = undefined;
5426
+ }
4346
5427
  await appendCommandLog(directory, id, chunk);
4347
5428
  let retainedBytes = 0;
4348
5429
  let truncated = false;
@@ -4372,6 +5453,8 @@ function createCommandService(host, opts) {
4372
5453
  c.outputBytes = retainedBytes;
4373
5454
  if (truncated)
4374
5455
  c.truncated = true;
5456
+ if (watcher)
5457
+ c.watchState = { ...watcher.state };
4375
5458
  c.updatedAt = new Date().toISOString();
4376
5459
  return s;
4377
5460
  });
@@ -4389,19 +5472,104 @@ function createCommandService(host, opts) {
4389
5472
  });
4390
5473
  }
4391
5474
  } catch {}
5475
+ try {
5476
+ if (watcher && feed) {
5477
+ if (feed.floodNow) {
5478
+ await persistWatchState(directory, id, watcher.state).catch(() => {});
5479
+ const dropped = watcher.state.droppedLines;
5480
+ let owner;
5481
+ let title = id;
5482
+ try {
5483
+ const snap = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
5484
+ owner = snap?.ownerSessionID;
5485
+ title = snap?.title ?? id;
5486
+ } catch {}
5487
+ await watchLedger(directory, id, "command.watch-suspended", { reason: "flood", dropped });
5488
+ if (owner) {
5489
+ try {
5490
+ await opts?.onWatchNotify?.(directory, owner, formatWatchFloodMessage(title, dropped));
5491
+ } catch {}
5492
+ }
5493
+ }
5494
+ if (watcher.pendingCount > 0)
5495
+ scheduleWatchFlush(directory, id);
5496
+ if (feed.untilLine) {
5497
+ clearWatchTimer(id);
5498
+ enqueue(id, () => handleUntilMatch(directory, id)).catch(() => {});
5499
+ }
5500
+ }
5501
+ } catch {}
5502
+ try {
5503
+ const completed = feed ? feed.completed : bareLines;
5504
+ if (completed.length > 0) {
5505
+ const fired = await fireUntilAwaits(directory, id, completed);
5506
+ if (fired.length > 0) {
5507
+ await opts?.onAwaitFired?.(directory, fired);
5508
+ }
5509
+ }
5510
+ } catch {}
4392
5511
  }
4393
5512
  async function persistExit(directory, id, info) {
4394
5513
  rememberDir(id, directory);
4395
5514
  live.delete(id);
5515
+ clearCommandTimeout(id);
5516
+ const exitWatcher = watchers.get(id);
5517
+ if (exitWatcher) {
5518
+ try {
5519
+ const handledStop = exitWatcher.state.state === "until-matched" && exitWatcher.untilAction === "stop";
5520
+ exitWatcher.finish(Date.now());
5521
+ let snap;
5522
+ try {
5523
+ snap = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
5524
+ } catch {
5525
+ snap = undefined;
5526
+ }
5527
+ if (snap && !handledStop && exitWatcher.pendingCount > 0) {
5528
+ const push = exitWatcher.takePush();
5529
+ await persistWatchState(directory, id, exitWatcher.state).catch(() => {});
5530
+ if (push && !push.exhaustedNow && push.lines.length > 0) {
5531
+ await watchLedger(directory, id, "command.watch-matched", {
5532
+ lines: push.lines.length,
5533
+ atExit: true,
5534
+ pushes: exitWatcher.state.pushes
5535
+ });
5536
+ try {
5537
+ await opts?.onWatchNotify?.(directory, snap.ownerSessionID, formatWatchMessage(snap.title, push.lines));
5538
+ } catch {}
5539
+ } else if (push?.exhaustedNow) {
5540
+ await watchLedger(directory, id, "command.watch-suspended", {
5541
+ reason: "budget",
5542
+ atExit: true,
5543
+ pushes: exitWatcher.state.pushes
5544
+ });
5545
+ try {
5546
+ await opts?.onWatchNotify?.(directory, snap.ownerSessionID, formatWatchBudgetMessage(snap.title));
5547
+ } catch {}
5548
+ }
5549
+ } else {
5550
+ await persistWatchState(directory, id, exitWatcher.state).catch(() => {});
5551
+ }
5552
+ } catch {}
5553
+ }
5554
+ deleteWatch(id);
4396
5555
  await mutateState(directory, `cmd.exit:${id}`, async (s) => {
4397
5556
  const c = (s.commands ?? []).find((x) => x.id === id);
4398
5557
  if (!c || c.status !== "running")
4399
5558
  return s;
5559
+ if (c.endReason === "timeout") {
5560
+ if (!c.endedAt) {
5561
+ c.endedAt = new Date().toISOString();
5562
+ c.updatedAt = c.endedAt;
5563
+ }
5564
+ return s;
5565
+ }
4400
5566
  c.exitCode = info.exitCode;
4401
5567
  if (info.signal && (c.signal === "SIGKILL" || c.signal === "SIGTERM")) {
4402
5568
  c.status = "terminated";
5569
+ c.endReason = "terminate";
4403
5570
  } else {
4404
5571
  c.status = "exited";
5572
+ c.endReason = "exit";
4405
5573
  if (info.signal)
4406
5574
  c.signal = info.signal;
4407
5575
  }
@@ -4411,7 +5579,7 @@ function createCommandService(host, opts) {
4411
5579
  }).catch(() => {});
4412
5580
  await appendEvent(directory, {
4413
5581
  version: 1,
4414
- eventID: randomUUID7(),
5582
+ eventID: randomUUID8(),
4415
5583
  commandID: id,
4416
5584
  type: "command.exited",
4417
5585
  exitCode: info.exitCode,
@@ -4424,11 +5592,12 @@ function createCommandService(host, opts) {
4424
5592
  emitBroker(id, { type: "status", command: fresh });
4425
5593
  } catch {}
4426
5594
  await fireAwaits(directory, id);
5595
+ await notifyOwnerIfNeeded(directory, id);
4427
5596
  }
4428
5597
  function owned(cmd, ownerSessionID) {
4429
5598
  return !!cmd && cmd.ownerSessionID === ownerSessionID;
4430
5599
  }
4431
- return {
5600
+ const service = {
4432
5601
  async start(directory, input) {
4433
5602
  const title = input.title.trim();
4434
5603
  const command = input.command.trim();
@@ -4439,11 +5608,29 @@ function createCommandService(host, opts) {
4439
5608
  if (!input.ownerSessionID || input.ownerSessionID === "main") {
4440
5609
  throw new Error("A valid owner session is required. Run from an active OpenCode session.");
4441
5610
  }
4442
- const id = randomUUID7();
5611
+ const timeoutSeconds = normalizeTimeoutSeconds(input.timeoutSeconds);
5612
+ const watchFilter = input.watchFilter !== undefined && input.watchFilter !== "" ? input.watchFilter : undefined;
5613
+ const watchUntil = input.watchUntil !== undefined && input.watchUntil !== "" ? input.watchUntil : undefined;
5614
+ const hasWatch = watchFilter !== undefined || watchUntil !== undefined;
5615
+ let watcher;
5616
+ let watchState;
5617
+ if (hasWatch) {
5618
+ watcher = new CommandWatcher({
5619
+ ...watchFilter !== undefined ? { filter: watchFilter } : {},
5620
+ ...watchUntil !== undefined ? { until: watchUntil } : {},
5621
+ ...input.watchIgnoreCase !== undefined ? { ignoreCase: input.watchIgnoreCase } : {},
5622
+ ...input.watchUntilAction !== undefined ? { untilAction: input.watchUntilAction } : {}
5623
+ });
5624
+ watchState = initialWatchState();
5625
+ }
5626
+ const startedAt = new Date;
5627
+ const id = randomUUID8();
4443
5628
  const cwd = input.cwd || directory;
5629
+ const useShell = input.shell === true;
5630
+ 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
5631
  const pending = [];
4445
5632
  let ready = false;
4446
- const handle = host.spawn({ command, args: input.args ?? [], cwd, cols: input.cols, rows: input.rows }, (chunk) => {
5633
+ const handle = host.spawn(spawnOpts, (chunk) => {
4447
5634
  if (!ready)
4448
5635
  pending.push({ type: "output", chunk });
4449
5636
  else
@@ -4462,9 +5649,19 @@ function createCommandService(host, opts) {
4462
5649
  cwd,
4463
5650
  ownerSessionID: input.ownerSessionID,
4464
5651
  goalID: input.goalID,
5652
+ notifyOnExit: input.notifyOnExit,
4465
5653
  pid: handle.pid,
4466
5654
  cols: input.cols,
4467
- rows: input.rows
5655
+ rows: input.rows,
5656
+ shell: useShell ? true : undefined,
5657
+ timeoutSeconds,
5658
+ deadlineAt: timeoutSeconds !== undefined ? computeDeadlineAt(startedAt, timeoutSeconds) : undefined,
5659
+ envKeys: input.env ? Object.keys(input.env) : undefined,
5660
+ watchFilter,
5661
+ watchUntil,
5662
+ watchIgnoreCase: input.watchIgnoreCase,
5663
+ watchUntilAction: watcher?.untilAction,
5664
+ watchState
4468
5665
  });
4469
5666
  try {
4470
5667
  await mutateState(directory, `cmd.start:${id}`, async (s) => {
@@ -4482,10 +5679,14 @@ function createCommandService(host, opts) {
4482
5679
  throw error;
4483
5680
  }
4484
5681
  live.set(id, { handle, buffers: [], bufferedBytes: 0 });
5682
+ if (watcher)
5683
+ watchers.set(id, watcher);
5684
+ if (timeoutSeconds !== undefined)
5685
+ scheduleTimeout(directory, id, input.ownerSessionID, timeoutSeconds);
4485
5686
  rememberDir(id, directory);
4486
5687
  await appendEvent(directory, {
4487
5688
  version: 1,
4488
- eventID: randomUUID7(),
5689
+ eventID: randomUUID8(),
4489
5690
  ...input.goalID ? { goalID: input.goalID } : {},
4490
5691
  commandID: id,
4491
5692
  type: "command.started",
@@ -4505,7 +5706,7 @@ function createCommandService(host, opts) {
4505
5706
  initialOperations.push(event.type === "output" ? enqueue(id, () => persistOutput(directory, id, event.chunk)) : enqueue(id, () => persistExit(directory, id, event.info)));
4506
5707
  }
4507
5708
  await Promise.all(initialOperations);
4508
- return await this.get(directory, id, input.ownerSessionID) ?? session;
5709
+ return await service.get(directory, id, input.ownerSessionID) ?? session;
4509
5710
  },
4510
5711
  async list(directory, ownerSessionID) {
4511
5712
  const s = await readState(directory);
@@ -4521,9 +5722,52 @@ function createCommandService(host, opts) {
4521
5722
  },
4522
5723
  async read(directory, id, ownerSessionID, opts) {
4523
5724
  await waitForOperations(id);
4524
- const session = await this.get(directory, id, ownerSessionID);
5725
+ const session = await service.get(directory, id, ownerSessionID);
4525
5726
  if (!session)
4526
5727
  return;
5728
+ if (opts?.pattern !== undefined) {
5729
+ const flags = opts.ignoreCase ? "i" : "";
5730
+ const checked = validateReadPattern(opts.pattern);
5731
+ if (typeof checked !== "object" || !(checked instanceof RegExp)) {
5732
+ throw new Error(checked.error);
5733
+ }
5734
+ const regex = new RegExp(opts.pattern, flags);
5735
+ const full = await readCommandLog(directory, id, { offsetBytes: 0, limitBytes: MAX_COMMAND_OUTPUT_BYTES });
5736
+ const lines = full.text.length === 0 ? [] : full.text.split(`
5737
+ `);
5738
+ const matches = [];
5739
+ for (const line of lines) {
5740
+ let stripped;
5741
+ try {
5742
+ stripped = stripAnsiForMatch(line);
5743
+ } catch {
5744
+ stripped = line;
5745
+ }
5746
+ let hit = false;
5747
+ try {
5748
+ hit = regex.test(stripped);
5749
+ } catch {
5750
+ throw new Error(`Invalid regex pattern '${opts.pattern}'.`);
5751
+ }
5752
+ regex.lastIndex = 0;
5753
+ if (hit)
5754
+ matches.push(line);
5755
+ }
5756
+ const skip = Math.max(0, opts.offsetBytes ?? 0);
5757
+ const take = Math.min(opts.limitBytes ?? MAX_COMMAND_READ_LINES, MAX_COMMAND_READ_LINES);
5758
+ const page = matches.slice(skip, skip + Math.max(0, take));
5759
+ return {
5760
+ session,
5761
+ text: page.join(`
5762
+ `) + (page.length > 0 ? `
5763
+ ` : ""),
5764
+ totalBytes: matches.length,
5765
+ startByte: skip,
5766
+ live: session.status === "running",
5767
+ pattern: opts.pattern,
5768
+ totalMatches: matches.length
5769
+ };
5770
+ }
4527
5771
  const entry = live.get(id);
4528
5772
  const offset = opts?.offsetBytes ?? 0;
4529
5773
  const limit = Math.min(opts?.limitBytes ?? 64 * 1024, 256 * 1024);
@@ -4539,7 +5783,7 @@ function createCommandService(host, opts) {
4539
5783
  return { session, text: file.text, totalBytes: session.outputBytes, startByte: file.startByte, live: session.status === "running" };
4540
5784
  },
4541
5785
  async write(directory, id, ownerSessionID, data) {
4542
- const session = await this.get(directory, id, ownerSessionID);
5786
+ const session = await service.get(directory, id, ownerSessionID);
4543
5787
  if (!session)
4544
5788
  return { ok: false, message: "Command not found." };
4545
5789
  if (session.status !== "running")
@@ -4551,7 +5795,7 @@ function createCommandService(host, opts) {
4551
5795
  return { ok: true, message: `Sent ${Buffer.byteLength(data)} byte(s) to "${session.title}".` };
4552
5796
  },
4553
5797
  async resize(directory, id, ownerSessionID, cols, rows) {
4554
- const session = await this.get(directory, id, ownerSessionID);
5798
+ const session = await service.get(directory, id, ownerSessionID);
4555
5799
  if (!session)
4556
5800
  return { ok: false, message: "Command not found." };
4557
5801
  if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols <= 0 || rows <= 0) {
@@ -4580,7 +5824,7 @@ function createCommandService(host, opts) {
4580
5824
  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
5825
  },
4582
5826
  async interrupt(directory, id, ownerSessionID) {
4583
- const session = await this.get(directory, id, ownerSessionID);
5827
+ const session = await service.get(directory, id, ownerSessionID);
4584
5828
  if (!session)
4585
5829
  return { ok: false, message: "Command not found." };
4586
5830
  if (session.status !== "running")
@@ -4601,13 +5845,18 @@ function createCommandService(host, opts) {
4601
5845
  });
4602
5846
  return { ok: true, message: `SIGINT delivered to "${session.title}" (process may continue if it traps the signal).` };
4603
5847
  },
4604
- async terminate(directory, id, ownerSessionID) {
5848
+ async terminate(directory, id, ownerSessionID, opts) {
4605
5849
  rememberDir(id, directory);
4606
- const session = await this.get(directory, id, ownerSessionID);
5850
+ const session = await service.get(directory, id, ownerSessionID);
4607
5851
  if (!session)
4608
5852
  return { ok: false, message: "Command not found." };
4609
- if (session.status !== "running")
5853
+ if (session.status !== "running") {
5854
+ if (opts?.remove) {
5855
+ const removed = await service.remove(directory, id, ownerSessionID);
5856
+ 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}` };
5857
+ }
4610
5858
  return { ok: false, message: `Command is ${session.status}; nothing to terminate.` };
5859
+ }
4611
5860
  await mutateState(directory, `cmd.terminate-claim:${id}`, async (s) => {
4612
5861
  const c = (s.commands ?? []).find((x) => x.id === id);
4613
5862
  if (c && c.ownerSessionID === ownerSessionID && c.status === "running") {
@@ -4642,18 +5891,24 @@ function createCommandService(host, opts) {
4642
5891
  live.delete(id);
4643
5892
  }
4644
5893
  const exitCode = status === "terminated" ? 143 : undefined;
5894
+ clearCommandTimeout(id);
5895
+ clearWatchTimer(id);
4645
5896
  await mutateState(directory, `cmd.terminate:${id}`, async (s) => {
4646
5897
  const c = (s.commands ?? []).find((x) => x.id === id);
4647
5898
  if (!c || c.ownerSessionID !== ownerSessionID)
4648
5899
  return s;
4649
5900
  if (c.status === "running") {
4650
5901
  c.status = status;
5902
+ if (c.endReason !== "timeout")
5903
+ c.endReason = status === "terminated" ? "terminate" : "exit";
4651
5904
  if (exitCode !== undefined)
4652
5905
  c.exitCode = c.exitCode ?? exitCode;
4653
5906
  c.signal = c.signal ?? "SIGTERM";
4654
5907
  c.endedAt = new Date().toISOString();
4655
5908
  c.updatedAt = c.endedAt;
4656
5909
  } else if (c.status === "terminated" && !c.endedAt) {
5910
+ if (c.endReason !== "timeout" && !c.endReason)
5911
+ c.endReason = "terminate";
4657
5912
  if (exitCode !== undefined)
4658
5913
  c.exitCode = c.exitCode ?? exitCode;
4659
5914
  c.endedAt = new Date().toISOString();
@@ -4667,16 +5922,24 @@ function createCommandService(host, opts) {
4667
5922
  emitBroker(id, { type: "status", command: fresh });
4668
5923
  } catch {}
4669
5924
  await fireAwaits(directory, id);
5925
+ await notifyOwnerIfNeeded(directory, id);
5926
+ if (opts?.remove) {
5927
+ const removed = await service.remove(directory, id, ownerSessionID);
5928
+ if (removed.ok)
5929
+ return { ok: true, message: `Command "${session.title}" ${status}; removed.`, removed: true };
5930
+ return { ok: true, message: `Command "${session.title}" ${status}; remove failed: ${removed.message}` };
5931
+ }
4670
5932
  return { ok: true, message: `Command "${session.title}" ${status}.` };
4671
5933
  },
4672
5934
  async remove(directory, id, ownerSessionID) {
4673
5935
  rememberDir(id, directory);
4674
- const session = await this.get(directory, id, ownerSessionID);
5936
+ const session = await service.get(directory, id, ownerSessionID);
4675
5937
  if (!session)
4676
5938
  return { ok: false, message: "Command not found." };
4677
5939
  if (session.status === "running") {
4678
5940
  return { ok: false, message: `Command "${session.title}" is still running \u2014 terminate it first (terminate \u2260 remove).` };
4679
5941
  }
5942
+ deleteWatch(id);
4680
5943
  const lastKnown = { ...session };
4681
5944
  await mutateState(directory, `cmd.remove:${id}`, async (s) => {
4682
5945
  s.commands = (s.commands ?? []).filter((x) => !(x.id === id && x.ownerSessionID === ownerSessionID));
@@ -4687,6 +5950,86 @@ function createCommandService(host, opts) {
4687
5950
  await clearAwaitsForCommand(directory, id, "command removed").catch(() => {});
4688
5951
  return { ok: true, message: `Command "${session.title}" removed.` };
4689
5952
  },
5953
+ async watch(directory, id, ownerSessionID, opts) {
5954
+ rememberDir(id, directory);
5955
+ const session = await service.get(directory, id, ownerSessionID);
5956
+ if (!session)
5957
+ return { ok: false, message: "Command not found." };
5958
+ if (session.status !== "running") {
5959
+ return { ok: false, message: `Command is ${session.status}; watch is meaningless after exit.` };
5960
+ }
5961
+ if (opts?.untilAction !== undefined && opts.untilAction !== "stop" && opts.untilAction !== "keep") {
5962
+ throw new Error(`Invalid watch untilAction '${opts.untilAction}': must be "stop" or "keep".`);
5963
+ }
5964
+ if (opts?.clear === true) {
5965
+ deleteWatch(id);
5966
+ await mutateState(directory, `cmd.watch-clear:${id}`, async (s) => {
5967
+ const c = (s.commands ?? []).find((x) => x.id === id);
5968
+ if (c && c.ownerSessionID === ownerSessionID) {
5969
+ delete c.watchFilter;
5970
+ delete c.watchUntil;
5971
+ delete c.watchIgnoreCase;
5972
+ delete c.watchUntilAction;
5973
+ delete c.watchState;
5974
+ c.updatedAt = new Date().toISOString();
5975
+ }
5976
+ return s;
5977
+ });
5978
+ await watchLedger(directory, id, "command.watch-suspended", { reason: "cleared" }).catch(() => {});
5979
+ try {
5980
+ const fresh = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
5981
+ if (fresh)
5982
+ emitBroker(id, { type: "status", command: fresh });
5983
+ } catch {}
5984
+ const cleared = await service.get(directory, id, ownerSessionID);
5985
+ return { ok: true, message: `Watch cleared for "${session.title}".`, command: cleared };
5986
+ }
5987
+ const filter = opts?.filter !== undefined && opts.filter !== "" ? opts.filter : undefined;
5988
+ const until = opts?.until !== undefined && opts.until !== "" ? opts.until : undefined;
5989
+ const watcher = new CommandWatcher({
5990
+ ...filter !== undefined ? { filter } : {},
5991
+ ...until !== undefined ? { until } : {},
5992
+ ...opts?.ignoreCase !== undefined ? { ignoreCase: opts.ignoreCase } : {},
5993
+ ...opts?.untilAction !== undefined ? { untilAction: opts.untilAction } : {}
5994
+ });
5995
+ clearWatchTimer(id);
5996
+ watchers.set(id, watcher);
5997
+ awaitAsm.delete(id);
5998
+ const freshState = initialWatchState();
5999
+ await mutateState(directory, `cmd.watch:${id}`, async (s) => {
6000
+ const c = (s.commands ?? []).find((x) => x.id === id);
6001
+ if (c && c.ownerSessionID === ownerSessionID) {
6002
+ if (filter !== undefined)
6003
+ c.watchFilter = filter;
6004
+ else
6005
+ delete c.watchFilter;
6006
+ if (until !== undefined)
6007
+ c.watchUntil = until;
6008
+ else
6009
+ delete c.watchUntil;
6010
+ if (opts?.ignoreCase !== undefined)
6011
+ c.watchIgnoreCase = opts.ignoreCase;
6012
+ else
6013
+ delete c.watchIgnoreCase;
6014
+ c.watchUntilAction = watcher.untilAction;
6015
+ c.watchState = { ...freshState };
6016
+ c.updatedAt = new Date().toISOString();
6017
+ }
6018
+ return s;
6019
+ });
6020
+ try {
6021
+ const fresh = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
6022
+ if (fresh)
6023
+ emitBroker(id, { type: "status", command: fresh });
6024
+ } catch {}
6025
+ const updated = await service.get(directory, id, ownerSessionID);
6026
+ const desc = [
6027
+ filter !== undefined ? `filter="${filter}"` : "filter=\u2014",
6028
+ until !== undefined ? `until="${until}"` : "until=\u2014",
6029
+ `action=${watcher.untilAction}`
6030
+ ].join(" ");
6031
+ return { ok: true, message: `Watch set for "${session.title}" (${desc}; counters reset to active).`, command: updated };
6032
+ },
4690
6033
  async reconcile(directory) {
4691
6034
  const state = await readState(directory);
4692
6035
  const cmds = state.commands ?? [];
@@ -4700,10 +6043,13 @@ function createCommandService(host, opts) {
4700
6043
  if (entry) {
4701
6044
  if (!entry.handle.isAlive()) {
4702
6045
  live.delete(c.id);
6046
+ clearCommandTimeout(c.id);
6047
+ deleteWatch(c.id);
4703
6048
  await mutateState(directory, `cmd.reconcile-exit:${c.id}`, async (s) => {
4704
6049
  const x = (s.commands ?? []).find((y) => y.id === c.id);
4705
6050
  if (x && x.status === "running") {
4706
6051
  x.status = "exited";
6052
+ x.endReason = "exit";
4707
6053
  x.endedAt = new Date().toISOString();
4708
6054
  x.updatedAt = x.endedAt;
4709
6055
  x.lastError = x.lastError ?? "Process handle died without an exit event.";
@@ -4716,14 +6062,19 @@ function createCommandService(host, opts) {
4716
6062
  emitBroker(c.id, { type: "status", command: fresh });
4717
6063
  } catch {}
4718
6064
  await fireAwaits(directory, c.id);
6065
+ await notifyOwnerIfNeeded(directory, c.id);
4719
6066
  }
4720
6067
  continue;
4721
6068
  }
6069
+ clearCommandTimeout(c.id);
6070
+ deleteWatch(c.id);
6071
+ const deadlinePassed = c.deadlineAt ? Date.parse(c.deadlineAt) <= Date.now() : false;
4722
6072
  await mutateState(directory, `cmd.reconcile-missing:${c.id}`, async (s) => {
4723
6073
  const x = (s.commands ?? []).find((y) => y.id === c.id);
4724
6074
  if (x && x.status === "running") {
4725
6075
  x.status = "missing";
4726
- x.lastError = "Host restarted or handle lost \u2014 no live execution found. Output log retained; remove to clean up.";
6076
+ x.endReason = "missing";
6077
+ 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
6078
  x.updatedAt = new Date().toISOString();
4728
6079
  markedMissing++;
4729
6080
  }
@@ -4735,16 +6086,21 @@ function createCommandService(host, opts) {
4735
6086
  emitBroker(c.id, { type: "status", command: fresh });
4736
6087
  } catch {}
4737
6088
  await fireAwaits(directory, c.id);
6089
+ await notifyOwnerIfNeeded(directory, c.id);
4738
6090
  }
4739
6091
  return { markedMissing };
4740
6092
  },
4741
6093
  async dispose(directory) {
6094
+ for (const id of [...watchTimers.keys()])
6095
+ clearWatchTimer(id);
6096
+ watchers.clear();
6097
+ awaitAsm.clear();
4742
6098
  const state = await readState(directory);
4743
6099
  const owned = new Map((state.commands ?? []).map((command) => [command.id, command.ownerSessionID]));
4744
6100
  await Promise.all([...live.entries()].map(async ([id, entry]) => {
4745
6101
  const owner = owned.get(id);
4746
6102
  if (owner) {
4747
- await this.terminate(directory, id, owner).catch(() => {});
6103
+ await service.terminate(directory, id, owner).catch(() => {});
4748
6104
  return;
4749
6105
  }
4750
6106
  entry.handle.terminate();
@@ -4756,12 +6112,15 @@ function createCommandService(host, opts) {
4756
6112
  entry.handle.kill();
4757
6113
  live.delete(id);
4758
6114
  }));
6115
+ for (const id of [...timeouts.keys()])
6116
+ clearCommandTimeout(id);
4759
6117
  }
4760
6118
  };
6119
+ return service;
4761
6120
  }
4762
6121
 
4763
6122
  // src/application/command-event-broker.ts
4764
- import { randomUUID as randomUUID8 } from "crypto";
6123
+ import { randomUUID as randomUUID9 } from "crypto";
4765
6124
  function safeDeliver(sink, msg) {
4766
6125
  try {
4767
6126
  sink(msg);
@@ -4804,7 +6163,7 @@ function createCommandEventBroker(resolver) {
4804
6163
  throw new Error("ownerSessionID is required.");
4805
6164
  if (typeof sink !== "function")
4806
6165
  throw new Error("sink must be a function.");
4807
- const sinkID = randomUUID8();
6166
+ const sinkID = randomUUID9();
4808
6167
  const entry = { sinkID, sink, state: "subscribing", buffer: [] };
4809
6168
  entriesFor(commandID).set(sinkID, entry);
4810
6169
  try {
@@ -4893,7 +6252,7 @@ function createCommandEventBroker(resolver) {
4893
6252
  }
4894
6253
 
4895
6254
  // src/server/command-stream-server.ts
4896
- import { randomBytes, randomUUID as randomUUID9, timingSafeEqual } from "crypto";
6255
+ import { randomBytes, randomUUID as randomUUID10, timingSafeEqual } from "crypto";
4897
6256
  import { promises as fs4 } from "fs";
4898
6257
  import path4 from "path";
4899
6258
  function streamEndpointPath(directory) {
@@ -4927,7 +6286,7 @@ var MAX_BUFFERED_BYTES = 512 * 1024;
4927
6286
  function createCommandStreamServer(directory, commandService, broker) {
4928
6287
  const endpointPath = streamEndpointPath(directory);
4929
6288
  const token = randomBytes(32).toString("hex");
4930
- const generation = randomUUID9();
6289
+ const generation = randomUUID10();
4931
6290
  const startedAt = new Date().toISOString();
4932
6291
  let server;
4933
6292
  let serverURL;
@@ -5247,7 +6606,7 @@ function createCommandStreamServer(directory, commandService, broker) {
5247
6606
 
5248
6607
  // src/server/goal-tools.ts
5249
6608
  init_state_repository();
5250
- import { randomUUID as randomUUID10 } from "crypto";
6609
+ import { randomUUID as randomUUID11 } from "crypto";
5251
6610
  import { tool } from "@opencode-ai/plugin/tool";
5252
6611
  // src/domain/verification.ts
5253
6612
  var MAX_RECENT_ATTEMPTS = 10;
@@ -5266,7 +6625,7 @@ var execAsync = promisify(execChild);
5266
6625
  function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
5267
6626
  return {
5268
6627
  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.",
6628
+ 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]`). 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. " + "Monitor with the /loop dashboard's Goals tab (Tab/h to switch there if Commands is focused).",
5270
6629
  args: {
5271
6630
  name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
5272
6631
  objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
@@ -5386,6 +6745,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
5386
6745
  ok: true,
5387
6746
  goalID: goal.id,
5388
6747
  workerSessionID: worker.workerSessionID,
6748
+ workerTopology: worker.topology,
6749
+ ...worker.nativeParentID ? { nativeParentID: worker.nativeParentID } : {},
5389
6750
  artifactDir: goal.config.artifactDir,
5390
6751
  agent: resolution.config.agent,
5391
6752
  model: resolution.config.model,
@@ -5472,7 +6833,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
5472
6833
  await writeState(dir, state);
5473
6834
  const event = {
5474
6835
  version: 1,
5475
- eventID: randomUUID10(),
6836
+ eventID: randomUUID11(),
5476
6837
  goalID: goal.id,
5477
6838
  type: "goal.progress",
5478
6839
  summary: args.summary,
@@ -5537,7 +6898,7 @@ Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
5537
6898
  Working directory: ${cwd}
5538
6899
 
5539
6900
  ${failureDetails}`;
5540
- attemptID = randomUUID10();
6901
+ attemptID = randomUUID11();
5541
6902
  const verificationAttempt = {
5542
6903
  id: attemptID,
5543
6904
  sequence: runtime.evaluatorRejectionCount,
@@ -5582,7 +6943,7 @@ ${failureDetails.slice(0, 500)}`,
5582
6943
  if (attemptID) {
5583
6944
  await appendEvent(dir, {
5584
6945
  version: 1,
5585
- eventID: randomUUID10(),
6946
+ eventID: randomUUID11(),
5586
6947
  goalID: goal.id,
5587
6948
  type: "goal.completion_rejected",
5588
6949
  attemptID,
@@ -5600,7 +6961,7 @@ ${failureDetails.slice(0, 500)}`,
5600
6961
  if (blocked && rejectedGoal?.blocker) {
5601
6962
  await appendEvent(dir, {
5602
6963
  version: 1,
5603
- eventID: randomUUID10(),
6964
+ eventID: randomUUID11(),
5604
6965
  goalID: goal.id,
5605
6966
  type: "goal.blocked",
5606
6967
  reason: rejectedGoal.blocker.reason,
@@ -5660,7 +7021,7 @@ ${failureDetails.slice(0, 500)}`,
5660
7021
  }
5661
7022
  runtime.updatedAt = new Date().toISOString();
5662
7023
  }
5663
- const attemptID = randomUUID10();
7024
+ const attemptID = randomUUID11();
5664
7025
  const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
5665
7026
  const checks = (goal.config.checks || []).map((cmd) => ({
5666
7027
  command: cmd,
@@ -5684,7 +7045,7 @@ ${failureDetails.slice(0, 500)}`,
5684
7045
  await writeState(dir, state);
5685
7046
  const event = {
5686
7047
  version: 1,
5687
- eventID: randomUUID10(),
7048
+ eventID: randomUUID11(),
5688
7049
  goalID: goal.id,
5689
7050
  type: "goal.completed",
5690
7051
  summary: args.summary,
@@ -5744,7 +7105,7 @@ ${failureDetails.slice(0, 500)}`,
5744
7105
  await writeState(dir, state);
5745
7106
  const event = {
5746
7107
  version: 1,
5747
- eventID: randomUUID10(),
7108
+ eventID: randomUUID11(),
5748
7109
  goalID: goal.id,
5749
7110
  type: "goal.blocked",
5750
7111
  reason: args.reason,
@@ -6059,6 +7420,8 @@ function ownerTools(options) {
6059
7420
  stateSummary: describeGoalState(goal.status, runtime?.phase),
6060
7421
  ownerSessionID: goal.ownerSessionID,
6061
7422
  workerSessionID: goal.workerSessionID,
7423
+ workerTopology: goal.workerTopology,
7424
+ ...goal.nativeParentID ? { nativeParentID: goal.nativeParentID } : {},
6062
7425
  config: {
6063
7426
  maxTurns: goal.config.maxTurns,
6064
7427
  maxFailures: goal.config.maxFailures,
@@ -6532,9 +7895,20 @@ function summarize(c) {
6532
7895
  status: c.status,
6533
7896
  exitCode: c.exitCode,
6534
7897
  signal: c.signal,
7898
+ endReason: c.endReason,
7899
+ timeoutSeconds: c.timeoutSeconds,
7900
+ deadlineAt: c.deadlineAt,
7901
+ envKeys: c.envKeys,
7902
+ shell: c.shell,
7903
+ watchFilter: c.watchFilter,
7904
+ watchUntil: c.watchUntil,
7905
+ watchIgnoreCase: c.watchIgnoreCase,
7906
+ watchUntilAction: c.watchUntilAction,
7907
+ watchState: c.watchState,
6535
7908
  outputBytes: c.outputBytes,
6536
7909
  truncated: c.truncated,
6537
7910
  goalID: c.goalID,
7911
+ notifyOnExit: c.notifyOnExit,
6538
7912
  updatedAt: c.updatedAt
6539
7913
  };
6540
7914
  }
@@ -6544,15 +7918,23 @@ function commandTools(options) {
6544
7918
  const sizeNote = capabilities.resize ? "applied live to the PTY winsize" : "stored; resize is unsupported by the pipe host";
6545
7919
  return {
6546
7920
  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.",
7921
+ description: "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 (you) gets pushed a real message when the command reaches a terminal status \u2014 no polling required to find out: by default (auto) that fires on a non-zero exit, on 'missing' (host restarted mid-run), on 'timeout' (timeout_seconds deadline reached \u2014 always notifies), or once total runtime crosses ~2 minutes (the long-running/monitor case); quick successful commands stay silent. Override with notify_on_exit.",
6548
7922
  args: {
6549
7923
  title: tool3.schema.string().describe("Short human label for the session."),
6550
7924
  command: tool3.schema.string().describe('Executable to spawn (e.g. "bun", "python3").'),
6551
7925
  args: tool3.schema.array(tool3.schema.string()).optional().describe("Arguments for the command."),
6552
7926
  cwd: tool3.schema.string().optional().describe("Working directory. Defaults to the project root."),
6553
7927
  goal_id: tool3.schema.string().optional().describe("Optional goal linkage (display only \u2014 no lifecycle coupling)."),
7928
+ notify_on_exit: tool3.schema.boolean().optional().describe("Owner-exit-notification override. true = always push a message to you when this command finishes. false = never (dashboard/loopd_command_get only). Omit for auto (failure, lost-host, timeout, or long-running success)."),
6554
7929
  cols: tool3.schema.number().optional().describe(`Requested terminal width (${sizeNote}).`),
6555
- rows: tool3.schema.number().optional().describe(`Requested terminal height (${sizeNote}).`)
7930
+ rows: tool3.schema.number().optional().describe(`Requested terminal height (${sizeNote}).`),
7931
+ 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."),
7932
+ 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."),
7933
+ 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."),
7934
+ 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."),
7935
+ 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)."),
7936
+ watch_ignore_case: tool3.schema.boolean().optional().describe("Case-insensitive watch matching (default false)."),
7937
+ 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
7938
  },
6557
7939
  execute: async (args, context) => {
6558
7940
  const owner = ownerID(context);
@@ -6566,6 +7948,9 @@ function commandTools(options) {
6566
7948
  always: [args.command],
6567
7949
  metadata: { command: args.command, args: args.args ?? [], cwd: args.cwd ?? directory }
6568
7950
  });
7951
+ if (args.watch_until_action !== undefined && args.watch_until_action !== "stop" && args.watch_until_action !== "keep") {
7952
+ throw new Error(`Invalid watch_until_action '${args.watch_until_action}': must be "stop" or "keep".`);
7953
+ }
6569
7954
  const session = await commandService.start(directory, {
6570
7955
  title: args.title,
6571
7956
  command: args.command,
@@ -6573,8 +7958,16 @@ function commandTools(options) {
6573
7958
  cwd: args.cwd,
6574
7959
  ownerSessionID: owner,
6575
7960
  goalID: args.goal_id,
7961
+ notifyOnExit: args.notify_on_exit,
6576
7962
  cols: args.cols,
6577
- rows: args.rows
7963
+ rows: args.rows,
7964
+ env: args.env,
7965
+ timeoutSeconds: args.timeout_seconds,
7966
+ shell: args.shell,
7967
+ watchFilter: args.watch_filter,
7968
+ watchUntil: args.watch_until,
7969
+ watchIgnoreCase: args.watch_ignore_case,
7970
+ watchUntilAction: args.watch_until_action
6578
7971
  });
6579
7972
  return {
6580
7973
  title: "Command started",
@@ -6589,7 +7982,7 @@ function commandTools(options) {
6589
7982
  }
6590
7983
  }),
6591
7984
  loopd_command_list: tool3({
6592
- description: "List standalone command sessions owned by this session.",
7985
+ 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
7986
  args: {},
6594
7987
  execute: async (_args, context) => {
6595
7988
  const owner = ownerID(context);
@@ -6603,20 +7996,29 @@ function commandTools(options) {
6603
7996
  }
6604
7997
  }),
6605
7998
  loopd_command_get: tool3({
6606
- description: "Get a command session's metadata plus a bounded output snapshot. Closing a view detaches; it never terminates.",
7999
+ 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
8000
  args: {
6608
8001
  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).")
8002
+ offset_bytes: tool3.schema.number().optional().describe("Byte offset into the output log (paging). With pattern: number of matching lines to skip."),
8003
+ limit_bytes: tool3.schema.number().optional().describe("Max bytes to return (default 64KB, cap 256KB). With pattern: max matching lines (default 500)."),
8004
+ pattern: tool3.schema.string().optional().describe("Regex to filter lines (matched against ANSI-stripped text; original lines returned). Dangerous nested-quantifier patterns are rejected."),
8005
+ ignore_case: tool3.schema.boolean().optional().describe("Case-insensitive pattern matching (default false).")
6611
8006
  },
6612
8007
  execute: async (args, context) => {
6613
8008
  const owner = ownerID(context);
6614
8009
  if (!owner)
6615
8010
  return denied();
6616
- const result = await commandService.read(directory, args.command_id, owner, {
6617
- offsetBytes: args.offset_bytes,
6618
- limitBytes: args.limit_bytes
6619
- });
8011
+ let result;
8012
+ try {
8013
+ result = await commandService.read(directory, args.command_id, owner, {
8014
+ offsetBytes: args.offset_bytes,
8015
+ limitBytes: args.limit_bytes,
8016
+ pattern: args.pattern,
8017
+ ignoreCase: args.ignore_case
8018
+ });
8019
+ } catch (error) {
8020
+ return { title: "Read failed", output: JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) }) };
8021
+ }
6620
8022
  if (!result) {
6621
8023
  return { title: "Not found", output: JSON.stringify({ ok: false, message: "Command not found for this session." }) };
6622
8024
  }
@@ -6628,13 +8030,14 @@ function commandTools(options) {
6628
8030
  output: result.text,
6629
8031
  startByte: result.startByte,
6630
8032
  totalBytes: result.totalBytes,
6631
- live: result.live
8033
+ live: result.live,
8034
+ ...result.pattern !== undefined ? { pattern: result.pattern, totalMatches: result.totalMatches } : {}
6632
8035
  }, null, 2)
6633
8036
  };
6634
8037
  }
6635
8038
  }),
6636
8039
  loopd_command_write: tool3({
6637
- description: "Send raw input (stdin bytes) to a running command session.",
8040
+ 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
8041
  args: {
6639
8042
  command_id: tool3.schema.string().describe("Command session ID."),
6640
8043
  input: tool3.schema.string().describe("Raw text to write to stdin (include trailing newline for line-buffered programs).")
@@ -6661,15 +8064,16 @@ function commandTools(options) {
6661
8064
  }
6662
8065
  }),
6663
8066
  loopd_command_terminate: tool3({
6664
- description: "Terminate a running command (SIGTERM, escalates to SIGKILL). Stopping a command never pauses/blocks any goal.",
8067
+ 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
8068
  args: {
6666
- command_id: tool3.schema.string().describe("Command session ID.")
8069
+ command_id: tool3.schema.string().describe("Command session ID."),
8070
+ remove: tool3.schema.boolean().optional().describe("Also remove the record+log after terminating (or when already terminal).")
6667
8071
  },
6668
8072
  execute: async (args, context) => {
6669
8073
  const owner = ownerID(context);
6670
8074
  if (!owner)
6671
8075
  return denied();
6672
- const result = await commandService.terminate(directory, args.command_id, owner);
8076
+ const result = await commandService.terminate(directory, args.command_id, owner, { remove: args.remove });
6673
8077
  return { title: result.ok ? "Terminated" : "Terminate failed", output: JSON.stringify({ ...result, command_id: args.command_id }) };
6674
8078
  }
6675
8079
  }),
@@ -6686,11 +8090,53 @@ function commandTools(options) {
6686
8090
  return { title: result.ok ? "Removed" : "Remove failed", output: JSON.stringify({ ...result, command_id: args.command_id }) };
6687
8091
  }
6688
8092
  }),
8093
+ loopd_command_watch: tool3({
8094
+ 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).',
8095
+ args: {
8096
+ command_id: tool3.schema.string().describe("Command session ID (must be running)."),
8097
+ 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."),
8098
+ 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."),
8099
+ watch_ignore_case: tool3.schema.boolean().optional().describe("Case-insensitive watch matching (default false)."),
8100
+ 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).'),
8101
+ clear: tool3.schema.boolean().optional().describe("When true, drop the watch entirely (spec + counters + in-memory watcher) instead of setting one.")
8102
+ },
8103
+ execute: async (args, context) => {
8104
+ const owner = ownerID(context);
8105
+ if (!owner)
8106
+ return denied();
8107
+ try {
8108
+ if (args.watch_until_action !== undefined && args.watch_until_action !== "stop" && args.watch_until_action !== "keep") {
8109
+ throw new Error(`Invalid watch_until_action '${args.watch_until_action}': must be "stop" or "keep".`);
8110
+ }
8111
+ const result = await commandService.watch(directory, args.command_id, owner, {
8112
+ ...args.watch_filter !== undefined ? { filter: args.watch_filter } : {},
8113
+ ...args.watch_until !== undefined ? { until: args.watch_until } : {},
8114
+ ...args.watch_ignore_case !== undefined ? { ignoreCase: args.watch_ignore_case } : {},
8115
+ ...args.watch_until_action !== undefined ? { untilAction: args.watch_until_action } : {},
8116
+ ...args.clear !== undefined ? { clear: args.clear } : {}
8117
+ });
8118
+ if (!result.ok) {
8119
+ return { title: "Watch not set", output: JSON.stringify({ ...result, command_id: args.command_id }) };
8120
+ }
8121
+ return {
8122
+ title: "Watch updated",
8123
+ output: JSON.stringify({ ok: true, message: result.message, command: summarize(result.command), command_id: args.command_id }, null, 2)
8124
+ };
8125
+ } catch (error) {
8126
+ return {
8127
+ title: "Watch not set",
8128
+ output: JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error), command_id: args.command_id })
8129
+ };
8130
+ }
8131
+ }
8132
+ }),
6689
8133
  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.",
8134
+ 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
8135
  args: {
6692
8136
  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.")
8137
+ goal_id: tool3.schema.string().describe("Goal ID to wake on exit. You must own both the goal and the command."),
8138
+ 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."),
8139
+ ignore_case: tool3.schema.boolean().optional().describe("Case-insensitive until matching (default false).")
6694
8140
  },
6695
8141
  execute: async (args, context) => {
6696
8142
  const owner = ownerID(context);
@@ -6699,7 +8145,9 @@ function commandTools(options) {
6699
8145
  const result = await requestCommandAwait(directory, {
6700
8146
  goalID: args.goal_id,
6701
8147
  commandID: args.command_id,
6702
- ownerSessionID: owner
8148
+ ownerSessionID: owner,
8149
+ ...args.until !== undefined ? { until: args.until } : {},
8150
+ ...args.ignore_case !== undefined ? { ignoreCase: args.ignore_case } : {}
6703
8151
  });
6704
8152
  if (result.ok && result.fired && result.active && options.goalService) {
6705
8153
  await wakeGoalForAwait(directory, options.goalService, args.goal_id).catch(() => {});
@@ -6731,7 +8179,7 @@ function commandTools(options) {
6731
8179
  // src/server/plugin.ts
6732
8180
  init_state_repository();
6733
8181
  // package.json
6734
- var version = "1.10.3";
8182
+ var version = "1.10.4";
6735
8183
 
6736
8184
  // src/server/plugin.ts
6737
8185
  var PLUGIN_ID = "opencode-loopd.server";
@@ -6758,6 +8206,18 @@ function createServerHooks(directory, host, defaults) {
6758
8206
  detail: describeError(error)
6759
8207
  }));
6760
8208
  }
8209
+ },
8210
+ onOwnerNotify: async (dir, ownerSessionID, message) => {
8211
+ await host.notifyOwner(ownerSessionID, message).catch((error) => logServerEvent(dir, "command.owner-notify-failed", {
8212
+ ownerSessionID,
8213
+ detail: describeError(error)
8214
+ }));
8215
+ },
8216
+ onWatchNotify: async (dir, ownerSessionID, message) => {
8217
+ await host.notifyOwner(ownerSessionID, message).catch((error) => logServerEvent(dir, "command.watch-notify-failed", {
8218
+ ownerSessionID,
8219
+ detail: describeError(error)
8220
+ }));
6761
8221
  }
6762
8222
  });
6763
8223
  const commandStream = createCommandStreamServer(directory, commandService, commandBroker);
@@ -6921,9 +8381,17 @@ var v2 = {
6921
8381
  const directory = context.location.directory;
6922
8382
  logServerEvent(directory, "plugin.loaded", { pluginID: PLUGIN_ID, host: "v2", version });
6923
8383
  const statuses = new Map;
6924
- const host = createV2Host(context, statuses);
8384
+ const native = await setupNativeServer(context);
8385
+ if (native) {
8386
+ logServerEvent(directory, "native-bridge.ready", { pluginID: PLUGIN_ID });
8387
+ } else {
8388
+ logServerEvent(directory, "native-bridge.unsupported", { pluginID: PLUGIN_ID });
8389
+ }
8390
+ const host = createV2Host(context, statuses, native ? { native } : undefined);
6925
8391
  const hooks = createServerHooks(directory, host, parsePluginDefaults(context.options));
6926
8392
  const registrations = [];
8393
+ if (native)
8394
+ registrations.push({ dispose: () => native.dispose() });
6927
8395
  const eventController = new AbortController;
6928
8396
  let eventTask = Promise.resolve();
6929
8397
  let disposed = false;