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