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