@bojackduy/opencode-loopd 1.10.1 → 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/README.md +13 -6
- package/commands/goal.md +2 -0
- package/dist/server.js +3921 -188
- package/dist/tui.js +12128 -2274
- package/package.json +5 -1
- package/skills/loopd/SKILL.md +171 -14
package/dist/server.js
CHANGED
|
@@ -130,7 +130,7 @@ import { promises as fs } from "fs";
|
|
|
130
130
|
import path from "path";
|
|
131
131
|
import os from "os";
|
|
132
132
|
function emptyState() {
|
|
133
|
-
return { version: CURRENT_VERSION, revision: 0, goals: [], runtimes: [], commandLedger: [] };
|
|
133
|
+
return { version: CURRENT_VERSION, revision: 0, goals: [], runtimes: [], commandLedger: [], commands: [] };
|
|
134
134
|
}
|
|
135
135
|
function loopDir(directory) {
|
|
136
136
|
return path.join(directory, ".opencode", "loopd");
|
|
@@ -316,6 +316,24 @@ function migrate(state) {
|
|
|
316
316
|
lastScheduleAt: rt.lastScheduleAt ?? undefined
|
|
317
317
|
}));
|
|
318
318
|
}
|
|
319
|
+
if (result.version < 7) {
|
|
320
|
+
result.version = 7;
|
|
321
|
+
if (!Array.isArray(result.commands))
|
|
322
|
+
result.commands = [];
|
|
323
|
+
}
|
|
324
|
+
if (result.version < 8) {
|
|
325
|
+
result.version = 8;
|
|
326
|
+
if (!Array.isArray(result.commandAwaits))
|
|
327
|
+
result.commandAwaits = [];
|
|
328
|
+
}
|
|
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
|
+
}
|
|
319
337
|
return result;
|
|
320
338
|
}
|
|
321
339
|
async function writeAtomic(target, contents) {
|
|
@@ -480,18 +498,740 @@ async function peekGoalInbox(directory, goalID) {
|
|
|
480
498
|
return [];
|
|
481
499
|
}
|
|
482
500
|
}
|
|
501
|
+
function commandLogFile(directory, commandID) {
|
|
502
|
+
return path.join(loopDir(directory), "commands", `${commandID}.log`);
|
|
503
|
+
}
|
|
504
|
+
async function appendCommandLog(directory, commandID, chunk) {
|
|
505
|
+
const file = commandLogFile(directory, commandID);
|
|
506
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
507
|
+
await fs.appendFile(file, chunk, "utf8");
|
|
508
|
+
}
|
|
509
|
+
async function readCommandLog(directory, commandID, opts) {
|
|
510
|
+
const file = commandLogFile(directory, commandID);
|
|
511
|
+
try {
|
|
512
|
+
const stat = await fs.stat(file);
|
|
513
|
+
const totalBytes = stat.size;
|
|
514
|
+
const requestedStart = Math.max(0, opts?.offsetBytes ?? 0);
|
|
515
|
+
if (requestedStart >= totalBytes)
|
|
516
|
+
return { text: "", totalBytes, startByte: requestedStart };
|
|
517
|
+
const fh = await fs.open(file, "r");
|
|
518
|
+
try {
|
|
519
|
+
const want = Math.min(opts?.limitBytes ?? 64 * 1024, totalBytes - requestedStart);
|
|
520
|
+
const buf = Buffer.alloc(want);
|
|
521
|
+
await fh.read(buf, 0, want, requestedStart);
|
|
522
|
+
const { text, startByte, endByte } = decodeUtf8Window(buf, requestedStart);
|
|
523
|
+
return { text, totalBytes, startByte };
|
|
524
|
+
} finally {
|
|
525
|
+
await fh.close();
|
|
526
|
+
}
|
|
527
|
+
} catch {
|
|
528
|
+
return { text: "", totalBytes: 0, startByte: 0 };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
function decodeUtf8Window(buf, windowStart) {
|
|
532
|
+
let start = 0;
|
|
533
|
+
while (start < buf.length && (buf[start] & 192) === 128 && start < 4)
|
|
534
|
+
start++;
|
|
535
|
+
let end = buf.length;
|
|
536
|
+
let leadIndex = end;
|
|
537
|
+
while (leadIndex > start && (buf[leadIndex - 1] & 192) === 128)
|
|
538
|
+
leadIndex--;
|
|
539
|
+
if (leadIndex > start) {
|
|
540
|
+
const lead = buf[leadIndex - 1];
|
|
541
|
+
let expected = 1;
|
|
542
|
+
if ((lead & 128) === 0)
|
|
543
|
+
expected = 1;
|
|
544
|
+
else if ((lead & 224) === 192)
|
|
545
|
+
expected = 2;
|
|
546
|
+
else if ((lead & 240) === 224)
|
|
547
|
+
expected = 3;
|
|
548
|
+
else if ((lead & 248) === 240)
|
|
549
|
+
expected = 4;
|
|
550
|
+
if (end - (leadIndex - 1) < expected)
|
|
551
|
+
end = leadIndex - 1;
|
|
552
|
+
} else if (leadIndex === start && start > 0 && end > start) {
|
|
553
|
+
end = start;
|
|
554
|
+
}
|
|
555
|
+
const slice = buf.subarray(start, end);
|
|
556
|
+
return { text: slice.toString("utf8"), startByte: windowStart + start, endByte: windowStart + end };
|
|
557
|
+
}
|
|
558
|
+
async function removeCommandLog(directory, commandID) {
|
|
559
|
+
try {
|
|
560
|
+
await fs.rm(commandLogFile(directory, commandID), { force: true });
|
|
561
|
+
} catch {}
|
|
562
|
+
}
|
|
483
563
|
function delay(ms) {
|
|
484
564
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
485
565
|
}
|
|
486
|
-
var CURRENT_VERSION =
|
|
566
|
+
var CURRENT_VERSION = 9, LOCK_STALE_MS = 1e4;
|
|
487
567
|
var init_state_repository = () => {};
|
|
488
568
|
|
|
569
|
+
// src/domain/command-await.ts
|
|
570
|
+
function isTerminalCommandStatus(status) {
|
|
571
|
+
return TERMINAL_COMMAND_STATUSES.includes(status);
|
|
572
|
+
}
|
|
573
|
+
function tailLastBytes(text, maxBytes = MAX_AWAIT_TAIL_BYTES) {
|
|
574
|
+
const buf = Buffer.from(text, "utf8");
|
|
575
|
+
if (buf.length <= maxBytes)
|
|
576
|
+
return text;
|
|
577
|
+
return buf.subarray(buf.length - maxBytes).toString("utf8");
|
|
578
|
+
}
|
|
579
|
+
function formatAwaitEvidence(input) {
|
|
580
|
+
const short = input.commandID.slice(0, 8);
|
|
581
|
+
const header = `[command "${input.title}" (${input.argv.join(" ") || input.title}) ${input.status}` + ` ${short}... exitCode=${input.exitCode ?? "unknown"} signal=${input.signal ?? "none"}]`;
|
|
582
|
+
const tail = tailLastBytes(input.tail);
|
|
583
|
+
return tail ? `${header}
|
|
584
|
+
${tail}` : header;
|
|
585
|
+
}
|
|
586
|
+
var TERMINAL_COMMAND_STATUSES, MAX_AWAIT_TAIL_BYTES;
|
|
587
|
+
var init_command_await = __esm(() => {
|
|
588
|
+
TERMINAL_COMMAND_STATUSES = [
|
|
589
|
+
"exited",
|
|
590
|
+
"terminated",
|
|
591
|
+
"missing"
|
|
592
|
+
];
|
|
593
|
+
MAX_AWAIT_TAIL_BYTES = 4 * 1024;
|
|
594
|
+
});
|
|
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
|
+
|
|
827
|
+
// src/application/command-await.ts
|
|
828
|
+
import { randomUUID } from "crypto";
|
|
829
|
+
function ledgerEvent(base) {
|
|
830
|
+
return {
|
|
831
|
+
version: 1,
|
|
832
|
+
eventID: randomUUID(),
|
|
833
|
+
timestamp: new Date().toISOString(),
|
|
834
|
+
revision: 0,
|
|
835
|
+
...base
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
async function requestCommandAwait(directory, input) {
|
|
839
|
+
const snapshot = await readState(directory);
|
|
840
|
+
const goal = snapshot.goals.find((g) => g.id === input.goalID);
|
|
841
|
+
if (!goal)
|
|
842
|
+
return { ok: false, message: "Goal not found." };
|
|
843
|
+
const command = (snapshot.commands ?? []).find((c) => c.id === input.commandID);
|
|
844
|
+
if (!command)
|
|
845
|
+
return { ok: false, message: "Command not found." };
|
|
846
|
+
if (input.ownerSessionID !== goal.ownerSessionID || input.ownerSessionID !== command.ownerSessionID) {
|
|
847
|
+
return { ok: false, message: "Owner mismatch: the await requester must own both the goal and the command." };
|
|
848
|
+
}
|
|
849
|
+
if (goal.ownerSessionID !== command.ownerSessionID) {
|
|
850
|
+
return { ok: false, message: "Owner mismatch: goal and command are owned by different sessions." };
|
|
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
|
+
}
|
|
864
|
+
const existing = (snapshot.commandAwaits ?? []).some((a) => a.goalID === input.goalID && a.commandID === input.commandID);
|
|
865
|
+
if (existing) {
|
|
866
|
+
return { ok: true, message: `Already awaiting "${command.title}" for goal "${goal.name}".` };
|
|
867
|
+
}
|
|
868
|
+
if (isTerminalCommandStatus(command.status)) {
|
|
869
|
+
await mutateState(directory, `cmd.await-request:${input.goalID}:${input.commandID}`, async (s) => {
|
|
870
|
+
s.commandAwaits = [...s.commandAwaits ?? [], {
|
|
871
|
+
goalID: input.goalID,
|
|
872
|
+
commandID: input.commandID,
|
|
873
|
+
ownerSessionID: input.ownerSessionID,
|
|
874
|
+
createdAt: new Date().toISOString(),
|
|
875
|
+
...until !== undefined ? { until, ignoreCase } : {}
|
|
876
|
+
}];
|
|
877
|
+
return s;
|
|
878
|
+
});
|
|
879
|
+
const fired = await fireCommandAwaits(directory, input.commandID);
|
|
880
|
+
const mine = fired.find((f) => f.goalID === input.goalID);
|
|
881
|
+
return {
|
|
882
|
+
ok: true,
|
|
883
|
+
message: `Command "${command.title}" already ${command.status}; wake-up delivered to goal "${goal.name}".`,
|
|
884
|
+
fired: true,
|
|
885
|
+
active: mine?.active ?? goal.status === "active"
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
let becameTerminal = false;
|
|
889
|
+
await mutateState(directory, `cmd.await-request:${input.goalID}:${input.commandID}`, async (s) => {
|
|
890
|
+
const dup = (s.commandAwaits ?? []).some((a) => a.goalID === input.goalID && a.commandID === input.commandID);
|
|
891
|
+
if (!dup) {
|
|
892
|
+
s.commandAwaits = [...s.commandAwaits ?? [], {
|
|
893
|
+
goalID: input.goalID,
|
|
894
|
+
commandID: input.commandID,
|
|
895
|
+
ownerSessionID: input.ownerSessionID,
|
|
896
|
+
createdAt: new Date().toISOString(),
|
|
897
|
+
...until !== undefined ? { until, ignoreCase } : {}
|
|
898
|
+
}];
|
|
899
|
+
}
|
|
900
|
+
const current = (s.commands ?? []).find((c) => c.id === input.commandID);
|
|
901
|
+
if (current && isTerminalCommandStatus(current.status)) {
|
|
902
|
+
becameTerminal = true;
|
|
903
|
+
}
|
|
904
|
+
return s;
|
|
905
|
+
});
|
|
906
|
+
if (becameTerminal) {
|
|
907
|
+
const fired = await fireCommandAwaits(directory, input.commandID);
|
|
908
|
+
const mine = fired.find((f) => f.goalID === input.goalID);
|
|
909
|
+
return {
|
|
910
|
+
ok: true,
|
|
911
|
+
message: `Command "${command.title}" exited while registering; wake-up delivered to goal "${goal.name}".`,
|
|
912
|
+
fired: true,
|
|
913
|
+
active: mine?.active ?? goal.status === "active"
|
|
914
|
+
};
|
|
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
|
+
}
|
|
942
|
+
await appendEvent(directory, ledgerEvent({
|
|
943
|
+
goalID: input.goalID,
|
|
944
|
+
commandID: input.commandID,
|
|
945
|
+
type: "command.await-requested",
|
|
946
|
+
...until !== undefined ? { until } : {}
|
|
947
|
+
})).catch(() => {});
|
|
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;
|
|
1043
|
+
}
|
|
1044
|
+
async function readBoundedTail(directory, commandID) {
|
|
1045
|
+
try {
|
|
1046
|
+
const probe = await readCommandLog(directory, commandID, { offsetBytes: 0, limitBytes: 0 });
|
|
1047
|
+
if (probe.totalBytes <= MAX_AWAIT_TAIL_BYTES) {
|
|
1048
|
+
const full = await readCommandLog(directory, commandID, {
|
|
1049
|
+
offsetBytes: 0,
|
|
1050
|
+
limitBytes: MAX_AWAIT_TAIL_BYTES
|
|
1051
|
+
});
|
|
1052
|
+
return full.text;
|
|
1053
|
+
}
|
|
1054
|
+
const tail = await readCommandLog(directory, commandID, {
|
|
1055
|
+
offsetBytes: Math.max(0, probe.totalBytes - MAX_AWAIT_TAIL_BYTES),
|
|
1056
|
+
limitBytes: MAX_AWAIT_TAIL_BYTES
|
|
1057
|
+
});
|
|
1058
|
+
return tail.text;
|
|
1059
|
+
} catch {
|
|
1060
|
+
return "";
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
async function fireCommandAwaits(directory, commandID) {
|
|
1064
|
+
let consumed = [];
|
|
1065
|
+
let command;
|
|
1066
|
+
let commandGone = false;
|
|
1067
|
+
await mutateState(directory, `cmd.await-consume:${commandID}`, async (s) => {
|
|
1068
|
+
const outstanding = (s.commandAwaits ?? []).filter((a) => a.commandID === commandID);
|
|
1069
|
+
if (outstanding.length === 0)
|
|
1070
|
+
return s;
|
|
1071
|
+
const current = (s.commands ?? []).find((c) => c.id === commandID);
|
|
1072
|
+
if (!current) {
|
|
1073
|
+
commandGone = true;
|
|
1074
|
+
consumed = outstanding;
|
|
1075
|
+
s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.commandID !== commandID);
|
|
1076
|
+
return s;
|
|
1077
|
+
}
|
|
1078
|
+
if (!isTerminalCommandStatus(current.status))
|
|
1079
|
+
return s;
|
|
1080
|
+
command = current;
|
|
1081
|
+
consumed = outstanding;
|
|
1082
|
+
s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.commandID !== commandID);
|
|
1083
|
+
return s;
|
|
1084
|
+
});
|
|
1085
|
+
if (consumed.length === 0)
|
|
1086
|
+
return [];
|
|
1087
|
+
if (commandGone || !command) {
|
|
1088
|
+
for (const a of consumed) {
|
|
1089
|
+
await appendEvent(directory, ledgerEvent({
|
|
1090
|
+
goalID: a.goalID,
|
|
1091
|
+
commandID,
|
|
1092
|
+
type: "command.await-discarded",
|
|
1093
|
+
reason: "command record gone"
|
|
1094
|
+
})).catch(() => {});
|
|
1095
|
+
}
|
|
1096
|
+
return [];
|
|
1097
|
+
}
|
|
1098
|
+
const tail = await readBoundedTail(directory, commandID);
|
|
1099
|
+
const fired = [];
|
|
1100
|
+
for (const a of consumed) {
|
|
1101
|
+
const fresh = await readState(directory);
|
|
1102
|
+
const goal = fresh.goals.find((g) => g.id === a.goalID);
|
|
1103
|
+
if (!goal) {
|
|
1104
|
+
await appendEvent(directory, ledgerEvent({
|
|
1105
|
+
goalID: a.goalID,
|
|
1106
|
+
commandID,
|
|
1107
|
+
type: "command.await-discarded",
|
|
1108
|
+
reason: "goal gone"
|
|
1109
|
+
})).catch(() => {});
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
const evidence = formatAwaitEvidence({
|
|
1113
|
+
title: command.title,
|
|
1114
|
+
argv: [command.command, ...command.args],
|
|
1115
|
+
commandID: command.id,
|
|
1116
|
+
status: command.status,
|
|
1117
|
+
exitCode: command.exitCode,
|
|
1118
|
+
signal: command.signal,
|
|
1119
|
+
tail
|
|
1120
|
+
});
|
|
1121
|
+
await appendGoalInbox(directory, a.goalID, "worker", evidence);
|
|
1122
|
+
await appendEvent(directory, ledgerEvent({
|
|
1123
|
+
goalID: a.goalID,
|
|
1124
|
+
commandID,
|
|
1125
|
+
type: "command.await-fired",
|
|
1126
|
+
status: command.status,
|
|
1127
|
+
exitCode: command.exitCode,
|
|
1128
|
+
signal: command.signal
|
|
1129
|
+
})).catch(() => {});
|
|
1130
|
+
fired.push({ goalID: a.goalID, commandID, active: goal.status === "active" });
|
|
1131
|
+
}
|
|
1132
|
+
return fired;
|
|
1133
|
+
}
|
|
1134
|
+
async function cancelAwaitsForGoal(directory, goalID, reason) {
|
|
1135
|
+
const snapshot = await readState(directory);
|
|
1136
|
+
const doomed = (snapshot.commandAwaits ?? []).filter((a) => a.goalID === goalID);
|
|
1137
|
+
if (doomed.length === 0)
|
|
1138
|
+
return 0;
|
|
1139
|
+
await mutateState(directory, `cmd.await-cancel-goal:${goalID}`, async (s) => {
|
|
1140
|
+
s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.goalID !== goalID);
|
|
1141
|
+
return s;
|
|
1142
|
+
});
|
|
1143
|
+
await appendEvent(directory, ledgerEvent({
|
|
1144
|
+
goalID,
|
|
1145
|
+
type: "command.await-cancelled",
|
|
1146
|
+
reason,
|
|
1147
|
+
count: doomed.length
|
|
1148
|
+
})).catch(() => {});
|
|
1149
|
+
return doomed.length;
|
|
1150
|
+
}
|
|
1151
|
+
async function clearAwaitsForCommand(directory, commandID, reason) {
|
|
1152
|
+
const snapshot = await readState(directory);
|
|
1153
|
+
const doomed = (snapshot.commandAwaits ?? []).filter((a) => a.commandID === commandID);
|
|
1154
|
+
if (doomed.length === 0)
|
|
1155
|
+
return 0;
|
|
1156
|
+
await mutateState(directory, `cmd.await-clear-command:${commandID}`, async (s) => {
|
|
1157
|
+
s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.commandID !== commandID);
|
|
1158
|
+
return s;
|
|
1159
|
+
});
|
|
1160
|
+
for (const a of doomed) {
|
|
1161
|
+
await appendEvent(directory, ledgerEvent({
|
|
1162
|
+
goalID: a.goalID,
|
|
1163
|
+
commandID,
|
|
1164
|
+
type: "command.await-cancelled",
|
|
1165
|
+
reason
|
|
1166
|
+
})).catch(() => {});
|
|
1167
|
+
}
|
|
1168
|
+
return doomed.length;
|
|
1169
|
+
}
|
|
1170
|
+
async function reconcileCommandAwaits(directory) {
|
|
1171
|
+
const snapshot = await readState(directory);
|
|
1172
|
+
const outstanding = [...snapshot.commandAwaits ?? []];
|
|
1173
|
+
const fired = [];
|
|
1174
|
+
for (const a of outstanding) {
|
|
1175
|
+
const fresh = await readState(directory);
|
|
1176
|
+
const still = (fresh.commandAwaits ?? []).some((x) => x.goalID === a.goalID && x.commandID === a.commandID);
|
|
1177
|
+
if (!still)
|
|
1178
|
+
continue;
|
|
1179
|
+
const command = (fresh.commands ?? []).find((c) => c.id === a.commandID);
|
|
1180
|
+
if (!command) {
|
|
1181
|
+
await mutateState(directory, `cmd.await-reconcile-discard:${a.commandID}`, async (s) => {
|
|
1182
|
+
s.commandAwaits = (s.commandAwaits ?? []).filter((x) => !(x.goalID === a.goalID && x.commandID === a.commandID));
|
|
1183
|
+
return s;
|
|
1184
|
+
});
|
|
1185
|
+
await appendEvent(directory, ledgerEvent({
|
|
1186
|
+
goalID: a.goalID,
|
|
1187
|
+
commandID: a.commandID,
|
|
1188
|
+
type: "command.await-discarded",
|
|
1189
|
+
reason: "command record gone at reconcile"
|
|
1190
|
+
})).catch(() => {});
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
if (!isTerminalCommandStatus(command.status))
|
|
1194
|
+
continue;
|
|
1195
|
+
const now = await fireCommandAwaits(directory, a.commandID);
|
|
1196
|
+
fired.push(...now.filter((f) => f.goalID === a.goalID));
|
|
1197
|
+
}
|
|
1198
|
+
return fired;
|
|
1199
|
+
}
|
|
1200
|
+
async function wakeGoalForAwait(directory, continuation, goalID) {
|
|
1201
|
+
const snapshot = await readState(directory);
|
|
1202
|
+
const goal = snapshot.goals.find((g) => g.id === goalID);
|
|
1203
|
+
if (!goal)
|
|
1204
|
+
return { woke: false, message: "Goal gone; wake skipped." };
|
|
1205
|
+
if (goal.status !== "active") {
|
|
1206
|
+
return { woke: false, message: `Goal is ${goal.status}; evidence waits in pendingInbox.` };
|
|
1207
|
+
}
|
|
1208
|
+
await mutateState(directory, `cmd.await-wake:${goalID}`, async (s) => {
|
|
1209
|
+
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
1210
|
+
if (!rt)
|
|
1211
|
+
return s;
|
|
1212
|
+
rt.phase = "idle";
|
|
1213
|
+
rt.activeRunID = undefined;
|
|
1214
|
+
rt.idleCandidateAt = undefined;
|
|
1215
|
+
rt.activePromptMessageID = undefined;
|
|
1216
|
+
rt.activeToolCallIDs = [];
|
|
1217
|
+
rt.updatedAt = new Date().toISOString();
|
|
1218
|
+
return s;
|
|
1219
|
+
});
|
|
1220
|
+
await continuation.continueTurn(directory, goalID, { force: true });
|
|
1221
|
+
return { woke: true, message: `Woke goal "${goal.name}" for command exit.` };
|
|
1222
|
+
}
|
|
1223
|
+
var init_command_await2 = __esm(() => {
|
|
1224
|
+
init_command_await();
|
|
1225
|
+
init_command_watch();
|
|
1226
|
+
init_state_repository();
|
|
1227
|
+
});
|
|
1228
|
+
|
|
489
1229
|
// src/server/plugin.ts
|
|
490
1230
|
import { tool as v1Tool } from "@opencode-ai/plugin/tool";
|
|
491
1231
|
|
|
492
1232
|
// src/application/control-worker.ts
|
|
493
1233
|
init_state_repository();
|
|
494
|
-
import { randomUUID } from "crypto";
|
|
1234
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
495
1235
|
|
|
496
1236
|
// src/application/goal-policy.ts
|
|
497
1237
|
function resolveGoalCreationConfig(input) {
|
|
@@ -600,6 +1340,7 @@ function errorReplacer(_key, value) {
|
|
|
600
1340
|
}
|
|
601
1341
|
|
|
602
1342
|
// src/application/control-worker.ts
|
|
1343
|
+
init_command_await2();
|
|
603
1344
|
var MAX_LEDGER_SIZE = 100;
|
|
604
1345
|
var RESPONSE_CLEANUP_AGE_MS = 60 * 60 * 1000;
|
|
605
1346
|
function createControlWorker(options) {
|
|
@@ -863,7 +1604,7 @@ function createControlWorker(options) {
|
|
|
863
1604
|
await writeState(directory, state);
|
|
864
1605
|
await appendEvent(directory, {
|
|
865
1606
|
version: 1,
|
|
866
|
-
eventID:
|
|
1607
|
+
eventID: randomUUID2(),
|
|
867
1608
|
goalID: goal.id,
|
|
868
1609
|
type: "goal.completed",
|
|
869
1610
|
summary: goal.completionEvidence.summary,
|
|
@@ -904,7 +1645,7 @@ function createControlWorker(options) {
|
|
|
904
1645
|
await writeState(directory, state);
|
|
905
1646
|
await appendEvent(directory, {
|
|
906
1647
|
version: 1,
|
|
907
|
-
eventID:
|
|
1648
|
+
eventID: randomUUID2(),
|
|
908
1649
|
goalID: goal.id,
|
|
909
1650
|
type: "goal.blocked",
|
|
910
1651
|
reason: goal.blocker.reason,
|
|
@@ -915,6 +1656,114 @@ function createControlWorker(options) {
|
|
|
915
1656
|
response = { ...base, message: `goal "${goal.name}" blocked`, stateRevision: state.revision };
|
|
916
1657
|
break;
|
|
917
1658
|
}
|
|
1659
|
+
case "cmd_await": {
|
|
1660
|
+
const args = request.args ?? {};
|
|
1661
|
+
const ownerSessionID = typeof args.ownerSessionID === "string" ? args.ownerSessionID : "";
|
|
1662
|
+
if (!ownerSessionID || ownerSessionID === "main") {
|
|
1663
|
+
response = { ...base, ok: false, message: "ownerSessionID is required for command operations", errorCode: "no_session" };
|
|
1664
|
+
break;
|
|
1665
|
+
}
|
|
1666
|
+
const goalID = typeof args.goalID === "string" ? args.goalID : "";
|
|
1667
|
+
const commandID = typeof args.commandID === "string" && args.commandID ? String(args.commandID) : String(request.goalID || "");
|
|
1668
|
+
if (!goalID || !commandID) {
|
|
1669
|
+
response = { ...base, ok: false, message: "goalID and commandID are required", errorCode: "bad_request" };
|
|
1670
|
+
break;
|
|
1671
|
+
}
|
|
1672
|
+
const result = await requestCommandAwait(directory, { goalID, commandID, ownerSessionID });
|
|
1673
|
+
if (result.ok && result.fired && result.active) {
|
|
1674
|
+
await wakeGoalForAwait(directory, goalSvc, goalID).catch(() => {});
|
|
1675
|
+
}
|
|
1676
|
+
const state = await readState(directory);
|
|
1677
|
+
response = {
|
|
1678
|
+
...base,
|
|
1679
|
+
ok: result.ok,
|
|
1680
|
+
message: result.message,
|
|
1681
|
+
stateRevision: state.revision,
|
|
1682
|
+
errorCode: result.ok ? undefined : "await_failed"
|
|
1683
|
+
};
|
|
1684
|
+
break;
|
|
1685
|
+
}
|
|
1686
|
+
case "cmd_start":
|
|
1687
|
+
case "cmd_write":
|
|
1688
|
+
case "cmd_interrupt":
|
|
1689
|
+
case "cmd_terminate":
|
|
1690
|
+
case "cmd_remove":
|
|
1691
|
+
case "cmd_resize":
|
|
1692
|
+
case "cmd_watch": {
|
|
1693
|
+
const cmdSvc = options.commandService;
|
|
1694
|
+
if (!cmdSvc) {
|
|
1695
|
+
response = {
|
|
1696
|
+
requestID: request.requestID,
|
|
1697
|
+
ok: false,
|
|
1698
|
+
message: `command "${request.command}" unavailable (command service not initialized)`,
|
|
1699
|
+
errorCode: "unavailable",
|
|
1700
|
+
completedAt: new Date().toISOString()
|
|
1701
|
+
};
|
|
1702
|
+
break;
|
|
1703
|
+
}
|
|
1704
|
+
const args = request.args ?? {};
|
|
1705
|
+
const ownerSessionID = typeof args.ownerSessionID === "string" ? args.ownerSessionID : "";
|
|
1706
|
+
if (!ownerSessionID || ownerSessionID === "main") {
|
|
1707
|
+
response = { ...base, ok: false, message: "ownerSessionID is required for command operations", errorCode: "no_session" };
|
|
1708
|
+
break;
|
|
1709
|
+
}
|
|
1710
|
+
try {
|
|
1711
|
+
if (request.command === "cmd_start") {
|
|
1712
|
+
const session = await cmdSvc.start(directory, {
|
|
1713
|
+
title: String(args.title || "command"),
|
|
1714
|
+
command: String(args.command || ""),
|
|
1715
|
+
args: Array.isArray(args.cmdArgs) ? args.cmdArgs : [],
|
|
1716
|
+
cwd: typeof args.cwd === "string" ? args.cwd : undefined,
|
|
1717
|
+
ownerSessionID,
|
|
1718
|
+
goalID: typeof args.goalID === "string" ? args.goalID : undefined,
|
|
1719
|
+
cols: typeof args.cols === "number" ? args.cols : undefined,
|
|
1720
|
+
rows: typeof args.rows === "number" ? args.rows : undefined
|
|
1721
|
+
});
|
|
1722
|
+
const state = await readState(directory);
|
|
1723
|
+
response = { ...base, message: `command "${session.title}" started (${session.id.slice(0, 8)}...)`, stateRevision: state.revision };
|
|
1724
|
+
} else {
|
|
1725
|
+
const id = String(args.commandID || request.goalID || "");
|
|
1726
|
+
if (!id) {
|
|
1727
|
+
response = { ...base, ok: false, message: "commandID is required", errorCode: "bad_request" };
|
|
1728
|
+
break;
|
|
1729
|
+
}
|
|
1730
|
+
if (request.command === "cmd_write") {
|
|
1731
|
+
const r = await cmdSvc.write(directory, id, ownerSessionID, String(args.input ?? ""));
|
|
1732
|
+
const state = await readState(directory);
|
|
1733
|
+
response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
|
|
1734
|
+
} else if (request.command === "cmd_interrupt") {
|
|
1735
|
+
const r = await cmdSvc.interrupt(directory, id, ownerSessionID);
|
|
1736
|
+
const state = await readState(directory);
|
|
1737
|
+
response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
|
|
1738
|
+
} else if (request.command === "cmd_terminate") {
|
|
1739
|
+
const r = await cmdSvc.terminate(directory, id, ownerSessionID);
|
|
1740
|
+
const state = await readState(directory);
|
|
1741
|
+
response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
|
|
1742
|
+
} else if (request.command === "cmd_remove") {
|
|
1743
|
+
const r = await cmdSvc.remove(directory, id, ownerSessionID);
|
|
1744
|
+
const state = await readState(directory);
|
|
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 };
|
|
1756
|
+
} else {
|
|
1757
|
+
const r = await cmdSvc.resize(directory, id, ownerSessionID, Number(args.cols), Number(args.rows));
|
|
1758
|
+
const state = await readState(directory);
|
|
1759
|
+
response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
} catch (error) {
|
|
1763
|
+
response = { ...base, ok: false, message: error instanceof Error ? error.message : String(error), errorCode: "command_failed" };
|
|
1764
|
+
}
|
|
1765
|
+
break;
|
|
1766
|
+
}
|
|
918
1767
|
default: {
|
|
919
1768
|
response = {
|
|
920
1769
|
requestID: request.requestID,
|
|
@@ -929,20 +1778,23 @@ function createControlWorker(options) {
|
|
|
929
1778
|
return response;
|
|
930
1779
|
}
|
|
931
1780
|
async function recordInLedger(directory, request) {
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1781
|
+
await mutateState(directory, `control.ledger:${request.requestID}`, async (state) => {
|
|
1782
|
+
if (!state.commandLedger)
|
|
1783
|
+
state.commandLedger = [];
|
|
1784
|
+
if (state.commandLedger.some((entry) => entry.requestID === request.requestID))
|
|
1785
|
+
return state;
|
|
1786
|
+
state.commandLedger.push({
|
|
1787
|
+
requestID: request.requestID,
|
|
1788
|
+
command: request.command,
|
|
1789
|
+
goalID: request.goalID,
|
|
1790
|
+
acceptedAt: request.requestedAt,
|
|
1791
|
+
completedAt: new Date().toISOString()
|
|
1792
|
+
});
|
|
1793
|
+
if (state.commandLedger.length > MAX_LEDGER_SIZE) {
|
|
1794
|
+
state.commandLedger = state.commandLedger.slice(-MAX_LEDGER_SIZE);
|
|
1795
|
+
}
|
|
1796
|
+
return state;
|
|
941
1797
|
});
|
|
942
|
-
if (state.commandLedger.length > MAX_LEDGER_SIZE) {
|
|
943
|
-
state.commandLedger = state.commandLedger.slice(-MAX_LEDGER_SIZE);
|
|
944
|
-
}
|
|
945
|
-
await writeState(directory, state);
|
|
946
1798
|
}
|
|
947
1799
|
return { start, stop: async () => {
|
|
948
1800
|
await stop();
|
|
@@ -951,7 +1803,7 @@ function createControlWorker(options) {
|
|
|
951
1803
|
|
|
952
1804
|
// src/application/loop-engine.ts
|
|
953
1805
|
init_state_repository();
|
|
954
|
-
import { randomUUID as
|
|
1806
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
955
1807
|
|
|
956
1808
|
// src/domain/goal.ts
|
|
957
1809
|
var MODEL_TRANSITIONS = {
|
|
@@ -996,6 +1848,7 @@ var HANDLED_EVENT_TYPES = new Set([
|
|
|
996
1848
|
"session.status",
|
|
997
1849
|
"session.error",
|
|
998
1850
|
"session.compacted",
|
|
1851
|
+
"session.execution.succeeded",
|
|
999
1852
|
"message.updated",
|
|
1000
1853
|
"message.part.updated"
|
|
1001
1854
|
]);
|
|
@@ -1100,6 +1953,8 @@ function createLoopEngine(options) {
|
|
|
1100
1953
|
case "message.updated":
|
|
1101
1954
|
case "message.part.updated":
|
|
1102
1955
|
return await handleMessageActivity(goal, event);
|
|
1956
|
+
case "session.execution.succeeded":
|
|
1957
|
+
return await handleExecutionSucceeded(goal);
|
|
1103
1958
|
default:
|
|
1104
1959
|
return false;
|
|
1105
1960
|
}
|
|
@@ -1140,6 +1995,19 @@ function createLoopEngine(options) {
|
|
|
1140
1995
|
});
|
|
1141
1996
|
return matched;
|
|
1142
1997
|
}
|
|
1998
|
+
async function handleExecutionSucceeded(goal) {
|
|
1999
|
+
let matched = false;
|
|
2000
|
+
await mutateState(directory, `execution-succeeded:${goal.id}`, async (s) => {
|
|
2001
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
2002
|
+
if (!rt || rt.phase !== "running" || !rt.activePromptMessageID)
|
|
2003
|
+
return s;
|
|
2004
|
+
Object.assign(rt, recordActivity(rt));
|
|
2005
|
+
rt.activeAssistantCompletedAt = new Date().toISOString();
|
|
2006
|
+
matched = true;
|
|
2007
|
+
return s;
|
|
2008
|
+
});
|
|
2009
|
+
return matched;
|
|
2010
|
+
}
|
|
1143
2011
|
async function handleSessionIdle(state, goal) {
|
|
1144
2012
|
const goalID = goal.id;
|
|
1145
2013
|
if (inflightContinuations.has(goalID))
|
|
@@ -1215,7 +2083,7 @@ function createLoopEngine(options) {
|
|
|
1215
2083
|
if (newlyStamped) {
|
|
1216
2084
|
await appendEvent(directory, {
|
|
1217
2085
|
version: 1,
|
|
1218
|
-
eventID:
|
|
2086
|
+
eventID: randomUUID3(),
|
|
1219
2087
|
goalID,
|
|
1220
2088
|
type: "idle.confirm-failed",
|
|
1221
2089
|
reason: failureReason,
|
|
@@ -1260,7 +2128,7 @@ function createLoopEngine(options) {
|
|
|
1260
2128
|
if (completedRunID) {
|
|
1261
2129
|
await appendEvent(directory, {
|
|
1262
2130
|
version: 1,
|
|
1263
|
-
eventID:
|
|
2131
|
+
eventID: randomUUID3(),
|
|
1264
2132
|
goalID,
|
|
1265
2133
|
type: "run.completed",
|
|
1266
2134
|
runID: completedRunID,
|
|
@@ -1325,7 +2193,7 @@ function createLoopEngine(options) {
|
|
|
1325
2193
|
});
|
|
1326
2194
|
await appendEvent(directory, {
|
|
1327
2195
|
version: 1,
|
|
1328
|
-
eventID:
|
|
2196
|
+
eventID: randomUUID3(),
|
|
1329
2197
|
goalID,
|
|
1330
2198
|
type: "goal.blocked",
|
|
1331
2199
|
reason: limitResult.reason + " (force-finish ignored)",
|
|
@@ -1349,7 +2217,7 @@ function createLoopEngine(options) {
|
|
|
1349
2217
|
});
|
|
1350
2218
|
await appendEvent(directory, {
|
|
1351
2219
|
version: 1,
|
|
1352
|
-
eventID:
|
|
2220
|
+
eventID: randomUUID3(),
|
|
1353
2221
|
goalID,
|
|
1354
2222
|
type: "goal.status_changed",
|
|
1355
2223
|
from: "active",
|
|
@@ -1451,7 +2319,7 @@ function createLoopEngine(options) {
|
|
|
1451
2319
|
});
|
|
1452
2320
|
await appendEvent(directory, {
|
|
1453
2321
|
version: 1,
|
|
1454
|
-
eventID:
|
|
2322
|
+
eventID: randomUUID3(),
|
|
1455
2323
|
goalID: goal.id,
|
|
1456
2324
|
type: "run.failed",
|
|
1457
2325
|
runID: runtime.activeRunID || "unknown",
|
|
@@ -1464,7 +2332,7 @@ function createLoopEngine(options) {
|
|
|
1464
2332
|
if (updatedGoal?.status === "blocked") {
|
|
1465
2333
|
await appendEvent(directory, {
|
|
1466
2334
|
version: 1,
|
|
1467
|
-
eventID:
|
|
2335
|
+
eventID: randomUUID3(),
|
|
1468
2336
|
goalID: goal.id,
|
|
1469
2337
|
type: "goal.blocked",
|
|
1470
2338
|
reason: `Failed ${runtime.consecutiveFailures + 1} times`,
|
|
@@ -1492,7 +2360,7 @@ function createLoopEngine(options) {
|
|
|
1492
2360
|
});
|
|
1493
2361
|
await appendEvent(directory, {
|
|
1494
2362
|
version: 1,
|
|
1495
|
-
eventID:
|
|
2363
|
+
eventID: randomUUID3(),
|
|
1496
2364
|
goalID: goal.id,
|
|
1497
2365
|
type: "compaction.completed",
|
|
1498
2366
|
timestamp: new Date().toISOString(),
|
|
@@ -1562,7 +2430,7 @@ function createLoopEngine(options) {
|
|
|
1562
2430
|
});
|
|
1563
2431
|
await appendEvent(directory, {
|
|
1564
2432
|
version: 1,
|
|
1565
|
-
eventID:
|
|
2433
|
+
eventID: randomUUID3(),
|
|
1566
2434
|
goalID: goal.id,
|
|
1567
2435
|
type: "compaction.started",
|
|
1568
2436
|
timestamp: new Date().toISOString(),
|
|
@@ -1620,7 +2488,7 @@ function createLoopEngine(options) {
|
|
|
1620
2488
|
return false;
|
|
1621
2489
|
await appendEvent(directory, {
|
|
1622
2490
|
version: 1,
|
|
1623
|
-
eventID:
|
|
2491
|
+
eventID: randomUUID3(),
|
|
1624
2492
|
goalID: goal.id,
|
|
1625
2493
|
type: "goal.status_changed",
|
|
1626
2494
|
from: "active",
|
|
@@ -1796,7 +2664,7 @@ function createLoopEngine(options) {
|
|
|
1796
2664
|
if (recovered) {
|
|
1797
2665
|
await appendEvent(directory, {
|
|
1798
2666
|
version: 1,
|
|
1799
|
-
eventID:
|
|
2667
|
+
eventID: randomUUID3(),
|
|
1800
2668
|
goalID: goal.id,
|
|
1801
2669
|
type: "run.recovered",
|
|
1802
2670
|
runID: stalledRunID ?? "unknown",
|
|
@@ -1830,7 +2698,7 @@ function createLoopEngine(options) {
|
|
|
1830
2698
|
const stuckSeconds = Math.floor((Date.now() - Date.parse(failedAt)) / 1000);
|
|
1831
2699
|
await appendEvent(directory, {
|
|
1832
2700
|
version: 1,
|
|
1833
|
-
eventID:
|
|
2701
|
+
eventID: randomUUID3(),
|
|
1834
2702
|
goalID: goal.id,
|
|
1835
2703
|
type: "run.stuck",
|
|
1836
2704
|
runID: runtime.activeRunID ?? "unknown",
|
|
@@ -1870,7 +2738,7 @@ function createLoopEngine(options) {
|
|
|
1870
2738
|
});
|
|
1871
2739
|
await appendEvent(directory, {
|
|
1872
2740
|
version: 1,
|
|
1873
|
-
eventID:
|
|
2741
|
+
eventID: randomUUID3(),
|
|
1874
2742
|
goalID: goal.id,
|
|
1875
2743
|
type: "run.stuck",
|
|
1876
2744
|
runID: runtime.activeRunID,
|
|
@@ -1893,28 +2761,138 @@ function createLoopEngine(options) {
|
|
|
1893
2761
|
}
|
|
1894
2762
|
|
|
1895
2763
|
// src/application/goal-service.ts
|
|
1896
|
-
import { randomUUID as
|
|
2764
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
1897
2765
|
init_state_repository();
|
|
2766
|
+
init_command_await2();
|
|
1898
2767
|
import * as path2 from "path";
|
|
1899
2768
|
import { promises as fs2 } from "fs";
|
|
1900
2769
|
|
|
1901
2770
|
// src/server/host-adapter.ts
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
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
|
|
2874
|
+
function parseModelRef(value) {
|
|
2875
|
+
if (value === undefined)
|
|
2876
|
+
return;
|
|
2877
|
+
const trimmed = value.trim();
|
|
2878
|
+
if (!trimmed)
|
|
2879
|
+
return;
|
|
2880
|
+
const slash = trimmed.indexOf("/");
|
|
2881
|
+
if (slash <= 0 || slash >= trimmed.length - 1) {
|
|
2882
|
+
throw new Error(`Invalid model "${value}". Use "providerID/modelID" (e.g. "openai/gpt-5.6-sol").`);
|
|
2883
|
+
}
|
|
2884
|
+
const providerID = trimmed.slice(0, slash).trim();
|
|
2885
|
+
const modelID = trimmed.slice(slash + 1).trim();
|
|
2886
|
+
if (!providerID || !modelID || /\s/.test(providerID) || /\s/.test(modelID)) {
|
|
2887
|
+
throw new Error(`Invalid model "${value}". Use "providerID/modelID" (e.g. "openai/gpt-5.6-sol").`);
|
|
2888
|
+
}
|
|
2889
|
+
return { providerID, modelID };
|
|
2890
|
+
}
|
|
2891
|
+
function newPromptMessageID() {
|
|
2892
|
+
return `msg_${randomUUID4()}`;
|
|
2893
|
+
}
|
|
2894
|
+
function isV2PromptMessageID(messageID) {
|
|
2895
|
+
return messageID.startsWith("msg_");
|
|
1918
2896
|
}
|
|
1919
2897
|
var recentParentNotifies = new Map;
|
|
1920
2898
|
function shouldDedupParentNotify(ownerSessionID, message) {
|
|
@@ -1962,8 +2940,7 @@ function createRealHost(client, directory) {
|
|
|
1962
2940
|
parts: [{ type: "text", text: prompt }]
|
|
1963
2941
|
};
|
|
1964
2942
|
if (messageID) {
|
|
1965
|
-
|
|
1966
|
-
body.messageID = collapsed.startsWith("msg-") ? collapsed : `msg-${messageID}`;
|
|
2943
|
+
body.messageID = /^msg[-_]/.test(messageID) ? messageID : `msg_${messageID}`;
|
|
1967
2944
|
}
|
|
1968
2945
|
if (model)
|
|
1969
2946
|
body.model = model;
|
|
@@ -2107,20 +3084,83 @@ function createRealHost(client, directory) {
|
|
|
2107
3084
|
}
|
|
2108
3085
|
};
|
|
2109
3086
|
}
|
|
2110
|
-
function
|
|
3087
|
+
function toWorkerCreation(result) {
|
|
3088
|
+
return typeof result === "string" ? { sessionID: result } : result;
|
|
3089
|
+
}
|
|
3090
|
+
function createV2Host(context, statuses, options = {}) {
|
|
2111
3091
|
const directory = context.location.directory;
|
|
2112
3092
|
return {
|
|
2113
|
-
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
|
+
}
|
|
2114
3149
|
const session = await context.session.create({
|
|
2115
3150
|
title,
|
|
2116
|
-
agent,
|
|
2117
|
-
model
|
|
3151
|
+
...agent !== undefined ? { agent } : {},
|
|
3152
|
+
...model !== undefined ? { model: { id: model.modelID, providerID: model.providerID } } : {},
|
|
2118
3153
|
location: { directory },
|
|
2119
3154
|
metadata: { "loopd.parentID": parentID }
|
|
2120
3155
|
});
|
|
2121
3156
|
statuses.set(session.id, "idle");
|
|
2122
|
-
await logServerEvent(directory, "worker.created", {
|
|
2123
|
-
|
|
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" };
|
|
2124
3164
|
},
|
|
2125
3165
|
async promptWorker({ sessionID, prompt, messageID, model, agent }) {
|
|
2126
3166
|
if (agent)
|
|
@@ -2131,6 +3171,9 @@ function createV2Host(context, statuses) {
|
|
|
2131
3171
|
model: { id: model.modelID, providerID: model.providerID }
|
|
2132
3172
|
});
|
|
2133
3173
|
}
|
|
3174
|
+
if (messageID !== undefined && !isV2PromptMessageID(messageID)) {
|
|
3175
|
+
throw new Error(`loopd prompt ID "${messageID}" is invalid for OpenCode v2: must start with "msg_". ` + `Generate IDs with newPromptMessageID() so persisted, delivered, and correlated IDs agree.`);
|
|
3176
|
+
}
|
|
2134
3177
|
const result = await context.session.prompt({
|
|
2135
3178
|
sessionID,
|
|
2136
3179
|
id: messageID,
|
|
@@ -2138,7 +3181,7 @@ function createV2Host(context, statuses) {
|
|
|
2138
3181
|
});
|
|
2139
3182
|
statuses.set(sessionID, "busy");
|
|
2140
3183
|
await logServerEvent(directory, "worker.prompted", { sessionID });
|
|
2141
|
-
return { messageID: result
|
|
3184
|
+
return { messageID: result?.id };
|
|
2142
3185
|
},
|
|
2143
3186
|
async readSession(sessionID) {
|
|
2144
3187
|
try {
|
|
@@ -2235,16 +3278,18 @@ async function withTimeout(promise, timeoutMs, operation) {
|
|
|
2235
3278
|
function createWorkerManager(host) {
|
|
2236
3279
|
return {
|
|
2237
3280
|
async createWorker(goal) {
|
|
2238
|
-
const
|
|
3281
|
+
const created = toWorkerCreation(await host.createWorker({
|
|
2239
3282
|
parentID: goal.ownerSessionID,
|
|
2240
3283
|
title: `loopd: ${goal.name}`,
|
|
2241
3284
|
agent: goal.config.agent,
|
|
2242
|
-
model: parseModelRef(goal.config.model)
|
|
2243
|
-
|
|
3285
|
+
model: parseModelRef(goal.config.model),
|
|
3286
|
+
goalID: goal.id
|
|
3287
|
+
}));
|
|
2244
3288
|
return {
|
|
2245
3289
|
goalID: goal.id,
|
|
2246
|
-
workerSessionID,
|
|
2247
|
-
startedAt: new Date().toISOString()
|
|
3290
|
+
workerSessionID: created.sessionID,
|
|
3291
|
+
startedAt: new Date().toISOString(),
|
|
3292
|
+
...created.topology ? { topology: created.topology, nativeParentID: created.nativeParentID } : {}
|
|
2248
3293
|
};
|
|
2249
3294
|
},
|
|
2250
3295
|
async continueWorker(worker, goal, runtime, context) {
|
|
@@ -2370,6 +3415,18 @@ function buildContinuationSteering(goal, runtime, context) {
|
|
|
2370
3415
|
}
|
|
2371
3416
|
|
|
2372
3417
|
// src/application/goal-service.ts
|
|
3418
|
+
class GoalStartError extends Error {
|
|
3419
|
+
goalID;
|
|
3420
|
+
workerSessionID;
|
|
3421
|
+
failedStage;
|
|
3422
|
+
constructor(message, info) {
|
|
3423
|
+
super(message);
|
|
3424
|
+
this.name = "GoalStartError";
|
|
3425
|
+
this.goalID = info.goalID;
|
|
3426
|
+
this.workerSessionID = info.workerSessionID;
|
|
3427
|
+
this.failedStage = info.failedStage;
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
2373
3430
|
function createGoalService(host) {
|
|
2374
3431
|
const workers = createWorkerManager(host);
|
|
2375
3432
|
const sessions = new Map;
|
|
@@ -2391,12 +3448,13 @@ function createGoalService(host) {
|
|
|
2391
3448
|
goalOperations.delete(goalID);
|
|
2392
3449
|
}
|
|
2393
3450
|
}
|
|
2394
|
-
function assertWorkspaceWriteAvailable(state, goal) {
|
|
3451
|
+
function assertWorkspaceWriteAvailable(state, goal, requesterSessionID) {
|
|
2395
3452
|
if (!goal.config.workspaceWrite)
|
|
2396
3453
|
return;
|
|
2397
3454
|
const activeWriter = state.goals.find((item) => item.id !== goal.id && item.status === "active" && item.config.workspaceWrite);
|
|
2398
3455
|
if (activeWriter) {
|
|
2399
|
-
|
|
3456
|
+
const ownedElsewhere = requesterSessionID !== undefined && activeWriter.ownerSessionID !== requesterSessionID;
|
|
3457
|
+
throw new Error(`Workspace-writing goal "${activeWriter.name}" (${activeWriter.id}) is already active` + (ownedElsewhere ? ` (owned by session ${activeWriter.ownerSessionID}, not this session)` : "") + ". Pause, block, complete, or clear it before activating another workspace-writing goal." + (ownedElsewhere ? " Note: list_background_goals shows only this session's goals; clear/pause it from its owning session." : ""));
|
|
2400
3458
|
}
|
|
2401
3459
|
}
|
|
2402
3460
|
async function recordPromptFailure(directory, goalID, error, blockImmediately = false) {
|
|
@@ -2436,7 +3494,7 @@ function createGoalService(host) {
|
|
|
2436
3494
|
});
|
|
2437
3495
|
await appendEvent(directory, {
|
|
2438
3496
|
version: 1,
|
|
2439
|
-
eventID:
|
|
3497
|
+
eventID: randomUUID5(),
|
|
2440
3498
|
goalID,
|
|
2441
3499
|
type: "run.failed",
|
|
2442
3500
|
runID,
|
|
@@ -2448,7 +3506,7 @@ function createGoalService(host) {
|
|
|
2448
3506
|
if (blocked) {
|
|
2449
3507
|
await appendEvent(directory, {
|
|
2450
3508
|
version: 1,
|
|
2451
|
-
eventID:
|
|
3509
|
+
eventID: randomUUID5(),
|
|
2452
3510
|
goalID,
|
|
2453
3511
|
type: "goal.blocked",
|
|
2454
3512
|
reason: `Worker prompt delivery failed: ${detail}`,
|
|
@@ -2475,14 +3533,19 @@ function createGoalService(host) {
|
|
|
2475
3533
|
sessions.set(goal.id, session);
|
|
2476
3534
|
await mutateState(directory, `goal.set-worker:${goal.id}`, async (s) => {
|
|
2477
3535
|
const persisted = s.goals.find((item) => item.id === goal.id);
|
|
2478
|
-
if (persisted)
|
|
3536
|
+
if (persisted) {
|
|
2479
3537
|
persisted.workerSessionID = session.workerSessionID;
|
|
3538
|
+
if (session.topology) {
|
|
3539
|
+
persisted.workerTopology = session.topology;
|
|
3540
|
+
persisted.nativeParentID = session.nativeParentID;
|
|
3541
|
+
}
|
|
3542
|
+
}
|
|
2480
3543
|
return s;
|
|
2481
3544
|
});
|
|
2482
3545
|
return session;
|
|
2483
3546
|
}
|
|
2484
3547
|
async function start(directory, input) {
|
|
2485
|
-
const id =
|
|
3548
|
+
const id = randomUUID5();
|
|
2486
3549
|
return withGoalOperation(id, () => startUnlocked(directory, input, id));
|
|
2487
3550
|
}
|
|
2488
3551
|
async function startUnlocked(directory, input, id) {
|
|
@@ -2521,7 +3584,7 @@ function createGoalService(host) {
|
|
|
2521
3584
|
goal.config.progressFile = path2.join(artifactDir, "progress.md");
|
|
2522
3585
|
await ensureGoalArtifactDir(directory, id);
|
|
2523
3586
|
const state1 = await mutateState(directory, `goal.create:${id}`, async (state) => {
|
|
2524
|
-
assertWorkspaceWriteAvailable(state, goal);
|
|
3587
|
+
assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
|
|
2525
3588
|
state.goals.push(goal);
|
|
2526
3589
|
const rt = createRuntimeState(id);
|
|
2527
3590
|
if (goal.config.schedule) {
|
|
@@ -2562,7 +3625,7 @@ function createGoalService(host) {
|
|
|
2562
3625
|
});
|
|
2563
3626
|
await appendEvent(directory, {
|
|
2564
3627
|
version: 1,
|
|
2565
|
-
eventID:
|
|
3628
|
+
eventID: randomUUID5(),
|
|
2566
3629
|
goalID: id,
|
|
2567
3630
|
type: "goal.blocked",
|
|
2568
3631
|
reason: detail,
|
|
@@ -2571,7 +3634,7 @@ function createGoalService(host) {
|
|
|
2571
3634
|
revision: blockedState.revision
|
|
2572
3635
|
});
|
|
2573
3636
|
await logServerEvent(directory, "goal.start.failed", { goalID: id, ownerSessionID: input.ownerSessionID, detail });
|
|
2574
|
-
throw
|
|
3637
|
+
throw new GoalStartError(detail, { goalID: id, failedStage: "worker_create" });
|
|
2575
3638
|
}
|
|
2576
3639
|
sessions.set(id, worker);
|
|
2577
3640
|
const state2 = await mutateState(directory, `goal.worker-assign:${id}`, async (state) => {
|
|
@@ -2580,11 +3643,17 @@ function createGoalService(host) {
|
|
|
2580
3643
|
return state;
|
|
2581
3644
|
g.workerSessionID = worker.workerSessionID;
|
|
2582
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
|
+
}
|
|
2583
3652
|
const rt = state.runtimes.find((item) => item.goalID === id);
|
|
2584
3653
|
if (rt) {
|
|
2585
3654
|
Object.assign(rt, acquireLease(rt, g.config.timeoutMs || 300000));
|
|
2586
|
-
rt.activeRunID =
|
|
2587
|
-
rt.activePromptMessageID =
|
|
3655
|
+
rt.activeRunID = randomUUID5();
|
|
3656
|
+
rt.activePromptMessageID = newPromptMessageID();
|
|
2588
3657
|
rt.runCount = 1;
|
|
2589
3658
|
rt.budgetTurnCount = 1;
|
|
2590
3659
|
rt.lastRunAt = new Date().toISOString();
|
|
@@ -2594,7 +3663,7 @@ function createGoalService(host) {
|
|
|
2594
3663
|
runtime = state2.runtimes.find((r) => r.goalID === id);
|
|
2595
3664
|
await appendEvent(directory, {
|
|
2596
3665
|
version: 1,
|
|
2597
|
-
eventID:
|
|
3666
|
+
eventID: randomUUID5(),
|
|
2598
3667
|
goalID: id,
|
|
2599
3668
|
type: "goal.created",
|
|
2600
3669
|
name: input.name,
|
|
@@ -2606,7 +3675,7 @@ function createGoalService(host) {
|
|
|
2606
3675
|
if (runtime) {
|
|
2607
3676
|
await appendEvent(directory, {
|
|
2608
3677
|
version: 1,
|
|
2609
|
-
eventID:
|
|
3678
|
+
eventID: randomUUID5(),
|
|
2610
3679
|
goalID: id,
|
|
2611
3680
|
type: "run.started",
|
|
2612
3681
|
runID: runtime.activeRunID,
|
|
@@ -2618,7 +3687,11 @@ function createGoalService(host) {
|
|
|
2618
3687
|
await workers.continueWorker(worker, goal, runtime);
|
|
2619
3688
|
} catch (error) {
|
|
2620
3689
|
await recordPromptFailure(directory, id, error, true);
|
|
2621
|
-
throw error
|
|
3690
|
+
throw new GoalStartError(describeError(error), {
|
|
3691
|
+
goalID: id,
|
|
3692
|
+
workerSessionID: worker.workerSessionID,
|
|
3693
|
+
failedStage: "prompt_delivery"
|
|
3694
|
+
});
|
|
2622
3695
|
}
|
|
2623
3696
|
}
|
|
2624
3697
|
return { goal, worker };
|
|
@@ -2715,8 +3788,8 @@ function createGoalService(host) {
|
|
|
2715
3788
|
return s;
|
|
2716
3789
|
const timeoutMs = g.config.timeoutMs || 300000;
|
|
2717
3790
|
Object.assign(rt, acquireLease(rt, timeoutMs));
|
|
2718
|
-
rt.activeRunID =
|
|
2719
|
-
rt.activePromptMessageID =
|
|
3791
|
+
rt.activeRunID = randomUUID5();
|
|
3792
|
+
rt.activePromptMessageID = newPromptMessageID();
|
|
2720
3793
|
rt.runCount += 1;
|
|
2721
3794
|
if (rt.freeRetryPending) {
|
|
2722
3795
|
rt.freeRetryPending = false;
|
|
@@ -2733,7 +3806,7 @@ function createGoalService(host) {
|
|
|
2733
3806
|
return;
|
|
2734
3807
|
await appendEvent(directory, {
|
|
2735
3808
|
version: 1,
|
|
2736
|
-
eventID:
|
|
3809
|
+
eventID: randomUUID5(),
|
|
2737
3810
|
goalID,
|
|
2738
3811
|
type: "run.started",
|
|
2739
3812
|
runID: freshRuntime.activeRunID,
|
|
@@ -2819,8 +3892,10 @@ function createGoalService(host) {
|
|
|
2819
3892
|
g.status = "paused";
|
|
2820
3893
|
g.updatedAt = new Date().toISOString();
|
|
2821
3894
|
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
2822
|
-
if (rt)
|
|
3895
|
+
if (rt) {
|
|
2823
3896
|
Object.assign(rt, releaseLease(rt));
|
|
3897
|
+
rt.activeRunID = undefined;
|
|
3898
|
+
}
|
|
2824
3899
|
return s;
|
|
2825
3900
|
});
|
|
2826
3901
|
const session = sessions.get(goalID) || (goal.workerSessionID ? {
|
|
@@ -2832,9 +3907,10 @@ function createGoalService(host) {
|
|
|
2832
3907
|
await workers.abortWorker(session.workerSessionID);
|
|
2833
3908
|
sessions.delete(goalID);
|
|
2834
3909
|
}
|
|
3910
|
+
await cancelAwaitsForGoal(directory, goalID, "goal paused").catch(() => {});
|
|
2835
3911
|
await appendEvent(directory, {
|
|
2836
3912
|
version: 1,
|
|
2837
|
-
eventID:
|
|
3913
|
+
eventID: randomUUID5(),
|
|
2838
3914
|
goalID,
|
|
2839
3915
|
type: "goal.status_changed",
|
|
2840
3916
|
from: "active",
|
|
@@ -2851,7 +3927,7 @@ function createGoalService(host) {
|
|
|
2851
3927
|
return state;
|
|
2852
3928
|
if (!canTransition(goal.status, "active", "user"))
|
|
2853
3929
|
return state;
|
|
2854
|
-
assertWorkspaceWriteAvailable(state, goal);
|
|
3930
|
+
assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
|
|
2855
3931
|
goal.status = "active";
|
|
2856
3932
|
goal.updatedAt = new Date().toISOString();
|
|
2857
3933
|
resumed = true;
|
|
@@ -2873,7 +3949,7 @@ function createGoalService(host) {
|
|
|
2873
3949
|
}
|
|
2874
3950
|
await appendEvent(directory, {
|
|
2875
3951
|
version: 1,
|
|
2876
|
-
eventID:
|
|
3952
|
+
eventID: randomUUID5(),
|
|
2877
3953
|
goalID,
|
|
2878
3954
|
type: "goal.status_changed",
|
|
2879
3955
|
from: "paused",
|
|
@@ -2889,7 +3965,7 @@ function createGoalService(host) {
|
|
|
2889
3965
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
2890
3966
|
if (!goal || goal.status !== "blocked")
|
|
2891
3967
|
return state;
|
|
2892
|
-
assertWorkspaceWriteAvailable(state, goal);
|
|
3968
|
+
assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
|
|
2893
3969
|
goal.status = "active";
|
|
2894
3970
|
goal.updatedAt = new Date().toISOString();
|
|
2895
3971
|
retried = true;
|
|
@@ -2925,7 +4001,7 @@ function createGoalService(host) {
|
|
|
2925
4001
|
}
|
|
2926
4002
|
await appendEvent(directory, {
|
|
2927
4003
|
version: 1,
|
|
2928
|
-
eventID:
|
|
4004
|
+
eventID: randomUUID5(),
|
|
2929
4005
|
goalID,
|
|
2930
4006
|
type: "goal.status_changed",
|
|
2931
4007
|
from: "blocked",
|
|
@@ -2952,11 +4028,12 @@ function createGoalService(host) {
|
|
|
2952
4028
|
await mutateState(directory, `goal.clear:${goalID}`, async (s) => {
|
|
2953
4029
|
s.goals = s.goals.filter((g) => g.id !== goalID);
|
|
2954
4030
|
s.runtimes = s.runtimes.filter((r) => r.goalID !== goalID);
|
|
4031
|
+
s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.goalID !== goalID);
|
|
2955
4032
|
return s;
|
|
2956
4033
|
});
|
|
2957
4034
|
await appendEvent(directory, {
|
|
2958
4035
|
version: 1,
|
|
2959
|
-
eventID:
|
|
4036
|
+
eventID: randomUUID5(),
|
|
2960
4037
|
goalID,
|
|
2961
4038
|
type: "goal.cleared",
|
|
2962
4039
|
timestamp: new Date().toISOString(),
|
|
@@ -3009,6 +4086,10 @@ function createGoalService(host) {
|
|
|
3009
4086
|
const g = s.goals.find((item) => item.id === goal.id);
|
|
3010
4087
|
if (g) {
|
|
3011
4088
|
g.workerSessionID = worker.workerSessionID;
|
|
4089
|
+
if (worker.topology) {
|
|
4090
|
+
g.workerTopology = worker.topology;
|
|
4091
|
+
g.nativeParentID = worker.nativeParentID;
|
|
4092
|
+
}
|
|
3012
4093
|
g.updatedAt = new Date().toISOString();
|
|
3013
4094
|
}
|
|
3014
4095
|
return s;
|
|
@@ -3164,7 +4245,7 @@ function createGoalService(host) {
|
|
|
3164
4245
|
|
|
3165
4246
|
// src/application/schedule-worker.ts
|
|
3166
4247
|
init_state_repository();
|
|
3167
|
-
import { randomUUID as
|
|
4248
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
3168
4249
|
function createScheduleWorker(options) {
|
|
3169
4250
|
const { directory, goalService } = options;
|
|
3170
4251
|
const intervalMs = options.intervalMs ?? 5000;
|
|
@@ -3184,102 +4265,2348 @@ function createScheduleWorker(options) {
|
|
|
3184
4265
|
clearInterval(timer);
|
|
3185
4266
|
timer = undefined;
|
|
3186
4267
|
}
|
|
3187
|
-
function isRunning() {
|
|
3188
|
-
return running;
|
|
4268
|
+
function isRunning() {
|
|
4269
|
+
return running;
|
|
4270
|
+
}
|
|
4271
|
+
async function tick() {
|
|
4272
|
+
const state = await readState(directory);
|
|
4273
|
+
let resurrected = 0;
|
|
4274
|
+
for (const goal of state.goals) {
|
|
4275
|
+
const schedule = goal.config.schedule;
|
|
4276
|
+
if (!schedule || typeof schedule.everyMs !== "number" || schedule.everyMs < 1000)
|
|
4277
|
+
continue;
|
|
4278
|
+
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
4279
|
+
if (!runtime)
|
|
4280
|
+
continue;
|
|
4281
|
+
if (goal.status !== "complete")
|
|
4282
|
+
continue;
|
|
4283
|
+
const count = runtime.scheduleRunCount ?? 0;
|
|
4284
|
+
const max = schedule.maxRuns;
|
|
4285
|
+
if (typeof max === "number" && count >= max)
|
|
4286
|
+
continue;
|
|
4287
|
+
const nextAt = runtime.nextRunAt;
|
|
4288
|
+
if (!nextAt)
|
|
4289
|
+
continue;
|
|
4290
|
+
if (Date.now() < Date.parse(nextAt))
|
|
4291
|
+
continue;
|
|
4292
|
+
const activeWriter = state.goals.find((g) => g.id !== goal.id && g.status === "active" && g.config.workspaceWrite);
|
|
4293
|
+
if (goal.config.workspaceWrite && activeWriter) {
|
|
4294
|
+
await logServerEvent(directory, "schedule.skipped-writer-active", {
|
|
4295
|
+
goalID: goal.id,
|
|
4296
|
+
activeWriter: activeWriter.id
|
|
4297
|
+
});
|
|
4298
|
+
continue;
|
|
4299
|
+
}
|
|
4300
|
+
if (runtime.phase === "running" || runtime.phase === "queued" || runtime.phase === "compacting")
|
|
4301
|
+
continue;
|
|
4302
|
+
if (leaseIsValid(runtime))
|
|
4303
|
+
continue;
|
|
4304
|
+
const didResurrect = await mutateState(directory, `schedule.tick:${goal.id}`, async (s) => {
|
|
4305
|
+
const g = s.goals.find((x) => x.id === goal.id);
|
|
4306
|
+
const rt = s.runtimes.find((x) => x.goalID === goal.id);
|
|
4307
|
+
if (!g || !rt)
|
|
4308
|
+
return s;
|
|
4309
|
+
if (g.status !== "complete")
|
|
4310
|
+
return s;
|
|
4311
|
+
const curCount = rt.scheduleRunCount ?? 0;
|
|
4312
|
+
if (typeof max === "number" && curCount >= max)
|
|
4313
|
+
return s;
|
|
4314
|
+
const curNext = rt.nextRunAt;
|
|
4315
|
+
if (!curNext || Date.now() < Date.parse(curNext))
|
|
4316
|
+
return s;
|
|
4317
|
+
g.status = "active";
|
|
4318
|
+
g.updatedAt = new Date().toISOString();
|
|
4319
|
+
g.blocker = undefined;
|
|
4320
|
+
Object.assign(rt, releaseLease(rt));
|
|
4321
|
+
rt.activeRunID = undefined;
|
|
4322
|
+
rt.consecutiveFailures = 0;
|
|
4323
|
+
rt.noProgressCount = 0;
|
|
4324
|
+
rt.progressDuringTurn = false;
|
|
4325
|
+
rt.forceFinishRequested = undefined;
|
|
4326
|
+
rt.lastError = undefined;
|
|
4327
|
+
rt.lastScheduleAt = new Date().toISOString();
|
|
4328
|
+
rt.updatedAt = new Date().toISOString();
|
|
4329
|
+
return s;
|
|
4330
|
+
});
|
|
4331
|
+
const after = didResurrect.goals.find((g) => g.id === goal.id);
|
|
4332
|
+
if (!after || after.status !== "active")
|
|
4333
|
+
continue;
|
|
4334
|
+
await appendEvent(directory, {
|
|
4335
|
+
version: 1,
|
|
4336
|
+
eventID: randomUUID6(),
|
|
4337
|
+
goalID: goal.id,
|
|
4338
|
+
type: "schedule.tick",
|
|
4339
|
+
scheduleRunCount: count,
|
|
4340
|
+
nextRunAt: nextAt,
|
|
4341
|
+
timestamp: new Date().toISOString(),
|
|
4342
|
+
revision: didResurrect.revision
|
|
4343
|
+
});
|
|
4344
|
+
await logServerEvent(directory, "schedule.resurrected", {
|
|
4345
|
+
goalID: goal.id,
|
|
4346
|
+
scheduleRunCount: count,
|
|
4347
|
+
nextRunAt: nextAt
|
|
4348
|
+
});
|
|
4349
|
+
const maxLabel = typeof max === "number" ? `/${max}` : "";
|
|
4350
|
+
await appendGoalInbox(directory, goal.id, "user", `Scheduled tick ${count + 1}${maxLabel} \u2014 re-execute the objective now. Previous completion: ${runtime.scheduleRunCount ?? 0} runs. Ensure artifact checks pass for this tick (e.g., append timestamp to tick.txt).`);
|
|
4351
|
+
try {
|
|
4352
|
+
await goalService.continueTurn(directory, goal.id);
|
|
4353
|
+
resurrected++;
|
|
4354
|
+
} catch {}
|
|
4355
|
+
}
|
|
4356
|
+
return resurrected;
|
|
4357
|
+
}
|
|
4358
|
+
return { start, stop, isRunning, tick };
|
|
4359
|
+
}
|
|
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
|
+
|
|
4523
|
+
// src/server/command-host.ts
|
|
4524
|
+
import { createRequire } from "module";
|
|
4525
|
+
var COMMAND_HOST_CAPABILITIES = {
|
|
4526
|
+
spawn: true,
|
|
4527
|
+
write: true,
|
|
4528
|
+
interruptSignal: true,
|
|
4529
|
+
terminate: true,
|
|
4530
|
+
resize: false,
|
|
4531
|
+
terminalEmulation: false
|
|
4532
|
+
};
|
|
4533
|
+
var PTY_HOST_CAPABILITIES = {
|
|
4534
|
+
spawn: true,
|
|
4535
|
+
write: true,
|
|
4536
|
+
interruptSignal: true,
|
|
4537
|
+
terminate: true,
|
|
4538
|
+
resize: true,
|
|
4539
|
+
terminalEmulation: false
|
|
4540
|
+
};
|
|
4541
|
+
function createLocalProcessHost() {
|
|
4542
|
+
return {
|
|
4543
|
+
capabilities: COMMAND_HOST_CAPABILITIES,
|
|
4544
|
+
spawn(opts, onOutput, onExit) {
|
|
4545
|
+
const proc = Bun.spawn([opts.command, ...opts.args ?? []], {
|
|
4546
|
+
cwd: opts.cwd,
|
|
4547
|
+
env: { ...process.env, ...opts.env ?? {} },
|
|
4548
|
+
stdin: "pipe",
|
|
4549
|
+
stdout: "pipe",
|
|
4550
|
+
stderr: "pipe"
|
|
4551
|
+
});
|
|
4552
|
+
let settled = false;
|
|
4553
|
+
let signal;
|
|
4554
|
+
const exitPromise = (async () => {
|
|
4555
|
+
const pumps = [pumpStream(proc.stdout, onOutput), pumpStream(proc.stderr, onOutput)];
|
|
4556
|
+
const code = await proc.exited;
|
|
4557
|
+
await Promise.allSettled(pumps);
|
|
4558
|
+
settled = true;
|
|
4559
|
+
const info = { exitCode: code ?? 0, signal };
|
|
4560
|
+
onExit(info);
|
|
4561
|
+
return info;
|
|
4562
|
+
})();
|
|
4563
|
+
return {
|
|
4564
|
+
pid: proc.pid,
|
|
4565
|
+
write(data) {
|
|
4566
|
+
try {
|
|
4567
|
+
proc.stdin.write(data);
|
|
4568
|
+
return true;
|
|
4569
|
+
} catch {
|
|
4570
|
+
return false;
|
|
4571
|
+
}
|
|
4572
|
+
},
|
|
4573
|
+
interrupt() {
|
|
4574
|
+
try {
|
|
4575
|
+
signal = "SIGINT";
|
|
4576
|
+
proc.kill("SIGINT");
|
|
4577
|
+
return true;
|
|
4578
|
+
} catch {
|
|
4579
|
+
return false;
|
|
4580
|
+
}
|
|
4581
|
+
},
|
|
4582
|
+
terminate() {
|
|
4583
|
+
try {
|
|
4584
|
+
if (!signal)
|
|
4585
|
+
signal = "SIGTERM";
|
|
4586
|
+
proc.kill("SIGTERM");
|
|
4587
|
+
return true;
|
|
4588
|
+
} catch {
|
|
4589
|
+
return false;
|
|
4590
|
+
}
|
|
4591
|
+
},
|
|
4592
|
+
kill() {
|
|
4593
|
+
try {
|
|
4594
|
+
signal = "SIGKILL";
|
|
4595
|
+
proc.kill("SIGKILL");
|
|
4596
|
+
return true;
|
|
4597
|
+
} catch {
|
|
4598
|
+
return false;
|
|
4599
|
+
}
|
|
4600
|
+
},
|
|
4601
|
+
resize() {
|
|
4602
|
+
return false;
|
|
4603
|
+
},
|
|
4604
|
+
isAlive() {
|
|
4605
|
+
if (settled)
|
|
4606
|
+
return false;
|
|
4607
|
+
try {
|
|
4608
|
+
process.kill(proc.pid, 0);
|
|
4609
|
+
return true;
|
|
4610
|
+
} catch {
|
|
4611
|
+
return false;
|
|
4612
|
+
}
|
|
4613
|
+
},
|
|
4614
|
+
exited() {
|
|
4615
|
+
return exitPromise;
|
|
4616
|
+
}
|
|
4617
|
+
};
|
|
4618
|
+
},
|
|
4619
|
+
livePids() {
|
|
4620
|
+
return new Set;
|
|
4621
|
+
}
|
|
4622
|
+
};
|
|
4623
|
+
}
|
|
4624
|
+
async function pumpStream(stream, onOutput) {
|
|
4625
|
+
if (!stream)
|
|
4626
|
+
return;
|
|
4627
|
+
const reader = stream.getReader();
|
|
4628
|
+
const decoder = new TextDecoder;
|
|
4629
|
+
try {
|
|
4630
|
+
for (;; ) {
|
|
4631
|
+
const { done, value } = await reader.read();
|
|
4632
|
+
if (done)
|
|
4633
|
+
break;
|
|
4634
|
+
if (value && value.length > 0)
|
|
4635
|
+
onOutput(decoder.decode(value, { stream: true }));
|
|
4636
|
+
}
|
|
4637
|
+
const rest = decoder.decode();
|
|
4638
|
+
if (rest)
|
|
4639
|
+
onOutput(rest);
|
|
4640
|
+
} catch {} finally {
|
|
4641
|
+
try {
|
|
4642
|
+
reader.releaseLock();
|
|
4643
|
+
} catch {}
|
|
4644
|
+
}
|
|
4645
|
+
}
|
|
4646
|
+
function loadBunPty() {
|
|
4647
|
+
return createRequire(import.meta.url)("bun-pty");
|
|
4648
|
+
}
|
|
4649
|
+
function asBunPtyModule(loaded) {
|
|
4650
|
+
const spawn = loaded?.spawn;
|
|
4651
|
+
if (typeof spawn !== "function")
|
|
4652
|
+
throw new Error("bun-pty module has no spawn() export");
|
|
4653
|
+
return { spawn };
|
|
4654
|
+
}
|
|
4655
|
+
function createPtyHost(load = loadBunPty) {
|
|
4656
|
+
const { spawn } = asBunPtyModule(load());
|
|
4657
|
+
return {
|
|
4658
|
+
capabilities: PTY_HOST_CAPABILITIES,
|
|
4659
|
+
spawn(opts, onOutput, onExit) {
|
|
4660
|
+
const pty = spawn(opts.command, opts.args ?? [], {
|
|
4661
|
+
name: "xterm-256color",
|
|
4662
|
+
cols: opts.cols ?? 80,
|
|
4663
|
+
rows: opts.rows ?? 24,
|
|
4664
|
+
cwd: opts.cwd,
|
|
4665
|
+
env: { ...process.env, ...opts.env ?? {}, TERM: "xterm-256color" }
|
|
4666
|
+
});
|
|
4667
|
+
let settled = false;
|
|
4668
|
+
let killedSignal;
|
|
4669
|
+
let resolveExit;
|
|
4670
|
+
const exitPromise = new Promise((r) => {
|
|
4671
|
+
resolveExit = r;
|
|
4672
|
+
});
|
|
4673
|
+
pty.onData((data) => {
|
|
4674
|
+
onOutput(data);
|
|
4675
|
+
});
|
|
4676
|
+
pty.onExit((event) => {
|
|
4677
|
+
if (settled)
|
|
4678
|
+
return;
|
|
4679
|
+
settled = true;
|
|
4680
|
+
const raw = event?.signal;
|
|
4681
|
+
const info = {
|
|
4682
|
+
exitCode: event?.exitCode ?? 0,
|
|
4683
|
+
signal: typeof raw === "string" ? raw : raw == null ? killedSignal : String(raw)
|
|
4684
|
+
};
|
|
4685
|
+
onExit(info);
|
|
4686
|
+
resolveExit(info);
|
|
4687
|
+
});
|
|
4688
|
+
return {
|
|
4689
|
+
pid: pty.pid,
|
|
4690
|
+
write(data) {
|
|
4691
|
+
if (settled)
|
|
4692
|
+
return false;
|
|
4693
|
+
try {
|
|
4694
|
+
pty.write(data);
|
|
4695
|
+
return true;
|
|
4696
|
+
} catch {
|
|
4697
|
+
return false;
|
|
4698
|
+
}
|
|
4699
|
+
},
|
|
4700
|
+
interrupt() {
|
|
4701
|
+
if (settled)
|
|
4702
|
+
return false;
|
|
4703
|
+
try {
|
|
4704
|
+
pty.write("\x03");
|
|
4705
|
+
return true;
|
|
4706
|
+
} catch {
|
|
4707
|
+
return false;
|
|
4708
|
+
}
|
|
4709
|
+
},
|
|
4710
|
+
terminate() {
|
|
4711
|
+
if (settled)
|
|
4712
|
+
return false;
|
|
4713
|
+
try {
|
|
4714
|
+
killedSignal ??= "SIGTERM";
|
|
4715
|
+
pty.kill();
|
|
4716
|
+
return true;
|
|
4717
|
+
} catch {
|
|
4718
|
+
return false;
|
|
4719
|
+
}
|
|
4720
|
+
},
|
|
4721
|
+
kill() {
|
|
4722
|
+
if (settled)
|
|
4723
|
+
return false;
|
|
4724
|
+
try {
|
|
4725
|
+
killedSignal = "SIGKILL";
|
|
4726
|
+
pty.kill("SIGKILL");
|
|
4727
|
+
return true;
|
|
4728
|
+
} catch {
|
|
4729
|
+
return false;
|
|
4730
|
+
}
|
|
4731
|
+
},
|
|
4732
|
+
resize(cols, rows) {
|
|
4733
|
+
if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols <= 0 || rows <= 0)
|
|
4734
|
+
return false;
|
|
4735
|
+
if (settled)
|
|
4736
|
+
return false;
|
|
4737
|
+
try {
|
|
4738
|
+
pty.resize(cols, rows);
|
|
4739
|
+
return true;
|
|
4740
|
+
} catch {
|
|
4741
|
+
return false;
|
|
4742
|
+
}
|
|
4743
|
+
},
|
|
4744
|
+
isAlive() {
|
|
4745
|
+
if (settled)
|
|
4746
|
+
return false;
|
|
4747
|
+
try {
|
|
4748
|
+
process.kill(pty.pid, 0);
|
|
4749
|
+
return true;
|
|
4750
|
+
} catch {
|
|
4751
|
+
return false;
|
|
4752
|
+
}
|
|
4753
|
+
},
|
|
4754
|
+
exited() {
|
|
4755
|
+
return exitPromise;
|
|
4756
|
+
}
|
|
4757
|
+
};
|
|
4758
|
+
},
|
|
4759
|
+
livePids() {
|
|
4760
|
+
return new Set;
|
|
4761
|
+
}
|
|
4762
|
+
};
|
|
4763
|
+
}
|
|
4764
|
+
function createCommandHost(opts) {
|
|
4765
|
+
try {
|
|
4766
|
+
return Object.assign(createPtyHost(opts?.load ?? loadBunPty), { backend: "pty" });
|
|
4767
|
+
} catch {
|
|
4768
|
+
return Object.assign(createLocalProcessHost(), { backend: "pipe" });
|
|
4769
|
+
}
|
|
4770
|
+
}
|
|
4771
|
+
|
|
4772
|
+
// src/application/command-service.ts
|
|
4773
|
+
import { randomUUID as randomUUID8 } from "crypto";
|
|
4774
|
+
import { promises as fs3 } from "fs";
|
|
4775
|
+
import path3 from "path";
|
|
4776
|
+
|
|
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
|
+
}
|
|
4789
|
+
function createCommandSession(input) {
|
|
4790
|
+
const now = new Date().toISOString();
|
|
4791
|
+
const timeout = normalizeTimeoutSeconds(input.timeoutSeconds);
|
|
4792
|
+
return {
|
|
4793
|
+
id: input.id,
|
|
4794
|
+
title: input.title,
|
|
4795
|
+
command: input.command,
|
|
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,
|
|
4807
|
+
cwd: input.cwd,
|
|
4808
|
+
ownerSessionID: input.ownerSessionID,
|
|
4809
|
+
goalID: input.goalID,
|
|
4810
|
+
notifyOnExit: input.notifyOnExit,
|
|
4811
|
+
status: "running",
|
|
4812
|
+
pid: input.pid,
|
|
4813
|
+
cols: input.cols,
|
|
4814
|
+
rows: input.rows,
|
|
4815
|
+
outputBytes: 0,
|
|
4816
|
+
streamBytes: 0,
|
|
4817
|
+
truncated: false,
|
|
4818
|
+
createdAt: now,
|
|
4819
|
+
updatedAt: now
|
|
4820
|
+
};
|
|
4821
|
+
}
|
|
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();
|
|
4852
|
+
|
|
4853
|
+
// src/domain/command-events.ts
|
|
4854
|
+
var _encoder = new TextEncoder;
|
|
4855
|
+
function utf8ByteLength(data) {
|
|
4856
|
+
return _encoder.encode(data).length;
|
|
4857
|
+
}
|
|
4858
|
+
function expectedNextEnd(startOffset, data) {
|
|
4859
|
+
return startOffset + utf8ByteLength(data);
|
|
4860
|
+
}
|
|
4861
|
+
var VALID_STATUSES = [
|
|
4862
|
+
"running",
|
|
4863
|
+
"exited",
|
|
4864
|
+
"terminated",
|
|
4865
|
+
"missing"
|
|
4866
|
+
];
|
|
4867
|
+
function isRecord(value) {
|
|
4868
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4869
|
+
}
|
|
4870
|
+
function isNonEmptyString(value) {
|
|
4871
|
+
return typeof value === "string" && value.length > 0;
|
|
4872
|
+
}
|
|
4873
|
+
function isNonNegativeInt(value) {
|
|
4874
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
4875
|
+
}
|
|
4876
|
+
function isCommandSessionLike(value) {
|
|
4877
|
+
if (!isRecord(value))
|
|
4878
|
+
return false;
|
|
4879
|
+
if (typeof value["id"] !== "string" || value["id"].length === 0)
|
|
4880
|
+
return false;
|
|
4881
|
+
if (typeof value["title"] !== "string")
|
|
4882
|
+
return false;
|
|
4883
|
+
if (typeof value["command"] !== "string")
|
|
4884
|
+
return false;
|
|
4885
|
+
if (typeof value["cwd"] !== "string")
|
|
4886
|
+
return false;
|
|
4887
|
+
if (typeof value["ownerSessionID"] !== "string")
|
|
4888
|
+
return false;
|
|
4889
|
+
if (typeof value["status"] !== "string")
|
|
4890
|
+
return false;
|
|
4891
|
+
if (!VALID_STATUSES.includes(value["status"]))
|
|
4892
|
+
return false;
|
|
4893
|
+
if (!isNonNegativeInt(value["outputBytes"]))
|
|
4894
|
+
return false;
|
|
4895
|
+
if (typeof value["truncated"] !== "boolean")
|
|
4896
|
+
return false;
|
|
4897
|
+
if (typeof value["createdAt"] !== "string")
|
|
4898
|
+
return false;
|
|
4899
|
+
if (typeof value["updatedAt"] !== "string")
|
|
4900
|
+
return false;
|
|
4901
|
+
if (value["args"] !== undefined && !Array.isArray(value["args"]))
|
|
4902
|
+
return false;
|
|
4903
|
+
if (value["streamBytes"] !== undefined && !isNonNegativeInt(value["streamBytes"]))
|
|
4904
|
+
return false;
|
|
4905
|
+
return true;
|
|
4906
|
+
}
|
|
4907
|
+
function checkOffsets(startOffset, endOffset, data) {
|
|
4908
|
+
if (typeof data !== "string")
|
|
4909
|
+
return "data must be a string";
|
|
4910
|
+
if (!isNonNegativeInt(startOffset))
|
|
4911
|
+
return "startOffset must be a non-negative integer";
|
|
4912
|
+
if (!isNonNegativeInt(endOffset))
|
|
4913
|
+
return "endOffset must be a non-negative integer";
|
|
4914
|
+
if (endOffset < startOffset)
|
|
4915
|
+
return "endOffset must be >= startOffset";
|
|
4916
|
+
const expected = expectedNextEnd(startOffset, data);
|
|
4917
|
+
if (endOffset !== expected)
|
|
4918
|
+
return `endOffset mismatch: expected ${expected} (startOffset + UTF-8 byte length ${expected - startOffset}), got ${endOffset}`;
|
|
4919
|
+
return null;
|
|
4920
|
+
}
|
|
4921
|
+
function validateCommandStreamMessage(value) {
|
|
4922
|
+
try {
|
|
4923
|
+
if (!isRecord(value))
|
|
4924
|
+
return { ok: false, error: "message must be an object" };
|
|
4925
|
+
const type = value["type"];
|
|
4926
|
+
if (typeof type !== "string")
|
|
4927
|
+
return { ok: false, error: "missing type field" };
|
|
4928
|
+
switch (type) {
|
|
4929
|
+
case "subscribe": {
|
|
4930
|
+
if (!isNonEmptyString(value["commandID"]))
|
|
4931
|
+
return { ok: false, error: "subscribe.commandID must be a non-empty string" };
|
|
4932
|
+
if (!isNonEmptyString(value["ownerSessionID"]))
|
|
4933
|
+
return { ok: false, error: "subscribe.ownerSessionID must be a non-empty string" };
|
|
4934
|
+
return {
|
|
4935
|
+
ok: true,
|
|
4936
|
+
message: {
|
|
4937
|
+
type: "subscribe",
|
|
4938
|
+
commandID: value["commandID"],
|
|
4939
|
+
ownerSessionID: value["ownerSessionID"]
|
|
4940
|
+
}
|
|
4941
|
+
};
|
|
4942
|
+
}
|
|
4943
|
+
case "snapshot": {
|
|
4944
|
+
if (!isCommandSessionLike(value["command"]))
|
|
4945
|
+
return { ok: false, error: "snapshot.command must be CommandSession metadata" };
|
|
4946
|
+
const offsetError = checkOffsets(value["startOffset"], value["endOffset"], value["data"]);
|
|
4947
|
+
if (offsetError)
|
|
4948
|
+
return { ok: false, error: `snapshot.${offsetError}` };
|
|
4949
|
+
return {
|
|
4950
|
+
ok: true,
|
|
4951
|
+
message: {
|
|
4952
|
+
type: "snapshot",
|
|
4953
|
+
command: value["command"],
|
|
4954
|
+
data: value["data"],
|
|
4955
|
+
startOffset: value["startOffset"],
|
|
4956
|
+
endOffset: value["endOffset"]
|
|
4957
|
+
}
|
|
4958
|
+
};
|
|
4959
|
+
}
|
|
4960
|
+
case "output": {
|
|
4961
|
+
if (!isNonEmptyString(value["commandID"]))
|
|
4962
|
+
return { ok: false, error: "output.commandID must be a non-empty string" };
|
|
4963
|
+
const offsetError = checkOffsets(value["startOffset"], value["endOffset"], value["data"]);
|
|
4964
|
+
if (offsetError)
|
|
4965
|
+
return { ok: false, error: `output.${offsetError}` };
|
|
4966
|
+
return {
|
|
4967
|
+
ok: true,
|
|
4968
|
+
message: {
|
|
4969
|
+
type: "output",
|
|
4970
|
+
commandID: value["commandID"],
|
|
4971
|
+
data: value["data"],
|
|
4972
|
+
startOffset: value["startOffset"],
|
|
4973
|
+
endOffset: value["endOffset"]
|
|
4974
|
+
}
|
|
4975
|
+
};
|
|
4976
|
+
}
|
|
4977
|
+
case "status": {
|
|
4978
|
+
if (!isCommandSessionLike(value["command"]))
|
|
4979
|
+
return { ok: false, error: "status.command must be CommandSession metadata" };
|
|
4980
|
+
return { ok: true, message: { type: "status", command: value["command"] } };
|
|
4981
|
+
}
|
|
4982
|
+
case "input": {
|
|
4983
|
+
if (!isNonEmptyString(value["commandID"]))
|
|
4984
|
+
return { ok: false, error: "input.commandID must be a non-empty string" };
|
|
4985
|
+
if (typeof value["data"] !== "string")
|
|
4986
|
+
return { ok: false, error: "input.data must be a string" };
|
|
4987
|
+
return {
|
|
4988
|
+
ok: true,
|
|
4989
|
+
message: { type: "input", commandID: value["commandID"], data: value["data"] }
|
|
4990
|
+
};
|
|
4991
|
+
}
|
|
4992
|
+
case "interrupt": {
|
|
4993
|
+
if (!isNonEmptyString(value["commandID"]))
|
|
4994
|
+
return { ok: false, error: "interrupt.commandID must be a non-empty string" };
|
|
4995
|
+
return { ok: true, message: { type: "interrupt", commandID: value["commandID"] } };
|
|
4996
|
+
}
|
|
4997
|
+
case "resync": {
|
|
4998
|
+
if (!isNonEmptyString(value["commandID"]))
|
|
4999
|
+
return { ok: false, error: "resync.commandID must be a non-empty string" };
|
|
5000
|
+
return { ok: true, message: { type: "resync", commandID: value["commandID"] } };
|
|
5001
|
+
}
|
|
5002
|
+
case "unsubscribe": {
|
|
5003
|
+
if (!isNonEmptyString(value["commandID"]))
|
|
5004
|
+
return { ok: false, error: "unsubscribe.commandID must be a non-empty string" };
|
|
5005
|
+
return { ok: true, message: { type: "unsubscribe", commandID: value["commandID"] } };
|
|
5006
|
+
}
|
|
5007
|
+
case "error": {
|
|
5008
|
+
if (!isNonEmptyString(value["code"]))
|
|
5009
|
+
return { ok: false, error: "error.code must be a non-empty string" };
|
|
5010
|
+
if (typeof value["message"] !== "string")
|
|
5011
|
+
return { ok: false, error: "error.message must be a string" };
|
|
5012
|
+
return {
|
|
5013
|
+
ok: true,
|
|
5014
|
+
message: { type: "error", code: value["code"], message: value["message"] }
|
|
5015
|
+
};
|
|
5016
|
+
}
|
|
5017
|
+
default:
|
|
5018
|
+
return { ok: false, error: `unknown message type: ${type}` };
|
|
5019
|
+
}
|
|
5020
|
+
} catch (err) {
|
|
5021
|
+
return { ok: false, error: `validation failed: ${String(err)}` };
|
|
5022
|
+
}
|
|
5023
|
+
}
|
|
5024
|
+
|
|
5025
|
+
// src/application/command-service.ts
|
|
5026
|
+
init_command_await2();
|
|
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
|
+
}
|
|
5059
|
+
function loopCommandsDir(directory) {
|
|
5060
|
+
return path3.join(directory, ".opencode", "loopd", "commands");
|
|
5061
|
+
}
|
|
5062
|
+
function createCommandService(host, opts) {
|
|
5063
|
+
const live = new Map;
|
|
5064
|
+
const operations = new Map;
|
|
5065
|
+
const broker = opts?.broker;
|
|
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
|
+
}
|
|
5173
|
+
function tailText(entry, limitBytes = 64 * 1024) {
|
|
5174
|
+
let want = limitBytes;
|
|
5175
|
+
const parts = [];
|
|
5176
|
+
for (let i = entry.buffers.length - 1;i >= 0 && want > 0; i--) {
|
|
5177
|
+
const chunk = entry.buffers[i];
|
|
5178
|
+
if (!chunk)
|
|
5179
|
+
continue;
|
|
5180
|
+
parts.unshift(chunk.subarray(Math.max(0, chunk.length - want)));
|
|
5181
|
+
want -= chunk.length;
|
|
5182
|
+
}
|
|
5183
|
+
return Buffer.concat(parts).toString("utf8");
|
|
5184
|
+
}
|
|
5185
|
+
function enqueue(id, operation) {
|
|
5186
|
+
const previous = operations.get(id) ?? Promise.resolve();
|
|
5187
|
+
const next = previous.then(operation, operation);
|
|
5188
|
+
operations.set(id, next);
|
|
5189
|
+
next.finally(() => {
|
|
5190
|
+
if (operations.get(id) === next)
|
|
5191
|
+
operations.delete(id);
|
|
5192
|
+
}).catch(() => {});
|
|
5193
|
+
return next;
|
|
5194
|
+
}
|
|
5195
|
+
async function waitForOperations(id) {
|
|
5196
|
+
await operations.get(id)?.catch(() => {});
|
|
5197
|
+
}
|
|
5198
|
+
function emitBroker(commandID, message) {
|
|
5199
|
+
if (!broker)
|
|
5200
|
+
return;
|
|
5201
|
+
try {
|
|
5202
|
+
broker.publish(commandID, message);
|
|
5203
|
+
} catch {}
|
|
5204
|
+
}
|
|
5205
|
+
function rememberDir(id, directory) {
|
|
5206
|
+
commandDirs.set(id, directory);
|
|
5207
|
+
}
|
|
5208
|
+
async function fireAwaits(directory, id) {
|
|
5209
|
+
try {
|
|
5210
|
+
const fired = await fireCommandAwaits(directory, id);
|
|
5211
|
+
if (fired.length === 0)
|
|
5212
|
+
return;
|
|
5213
|
+
await opts?.onAwaitFired?.(directory, fired);
|
|
5214
|
+
} catch {}
|
|
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
|
+
}
|
|
5341
|
+
async function readBrokerSnapshot(commandID, ownerSessionID) {
|
|
5342
|
+
const directory = commandDirs.get(commandID);
|
|
5343
|
+
if (!directory)
|
|
5344
|
+
return;
|
|
5345
|
+
const state = await readState(directory);
|
|
5346
|
+
const session = (state.commands ?? []).find((x) => x.id === commandID && x.ownerSessionID === ownerSessionID);
|
|
5347
|
+
if (!session)
|
|
5348
|
+
return;
|
|
5349
|
+
const log = await readCommandLog(directory, commandID, {
|
|
5350
|
+
offsetBytes: 0,
|
|
5351
|
+
limitBytes: MAX_COMMAND_OUTPUT_BYTES
|
|
5352
|
+
});
|
|
5353
|
+
const data = log.text;
|
|
5354
|
+
const byteLen = utf8ByteLength(data);
|
|
5355
|
+
const lifetime = session.streamBytes ?? 0;
|
|
5356
|
+
const endOffset = lifetime >= byteLen ? lifetime : byteLen;
|
|
5357
|
+
const startOffset = endOffset - byteLen;
|
|
5358
|
+
return { command: session, data, startOffset, endOffset };
|
|
5359
|
+
}
|
|
5360
|
+
if (broker) {
|
|
5361
|
+
broker.setResolver({
|
|
5362
|
+
async getSession(commandID, ownerSessionID) {
|
|
5363
|
+
const directory = commandDirs.get(commandID);
|
|
5364
|
+
if (!directory)
|
|
5365
|
+
return;
|
|
5366
|
+
const s = await readState(directory);
|
|
5367
|
+
const c = (s.commands ?? []).find((x) => x.id === commandID);
|
|
5368
|
+
return c && c.ownerSessionID === ownerSessionID ? c : undefined;
|
|
5369
|
+
},
|
|
5370
|
+
async waitForQuiesce(commandID) {
|
|
5371
|
+
await waitForOperations(commandID);
|
|
5372
|
+
},
|
|
5373
|
+
async readSnapshot(commandID, ownerSessionID) {
|
|
5374
|
+
return readBrokerSnapshot(commandID, ownerSessionID);
|
|
5375
|
+
}
|
|
5376
|
+
});
|
|
5377
|
+
}
|
|
5378
|
+
async function persistOutput(directory, id, chunk) {
|
|
5379
|
+
rememberDir(id, directory);
|
|
5380
|
+
const entry = live.get(id);
|
|
5381
|
+
if (entry) {
|
|
5382
|
+
const bytes = Buffer.from(chunk);
|
|
5383
|
+
entry.buffers.push(bytes);
|
|
5384
|
+
entry.bufferedBytes += bytes.length;
|
|
5385
|
+
while (entry.bufferedBytes > 128 * 1024 && entry.buffers.length > 1) {
|
|
5386
|
+
const dropped = entry.buffers.shift();
|
|
5387
|
+
entry.bufferedBytes -= dropped.length;
|
|
5388
|
+
}
|
|
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
|
+
}
|
|
5427
|
+
await appendCommandLog(directory, id, chunk);
|
|
5428
|
+
let retainedBytes = 0;
|
|
5429
|
+
let truncated = false;
|
|
5430
|
+
const file = path3.join(loopCommandsDir(directory), `${id}.log`);
|
|
5431
|
+
const stat = await fs3.stat(file);
|
|
5432
|
+
retainedBytes = stat.size;
|
|
5433
|
+
if (stat.size > MAX_COMMAND_OUTPUT_BYTES) {
|
|
5434
|
+
const fh = await fs3.open(file, "r");
|
|
5435
|
+
try {
|
|
5436
|
+
const buf = Buffer.alloc(MAX_COMMAND_OUTPUT_BYTES);
|
|
5437
|
+
await fh.read(buf, 0, buf.length, stat.size - buf.length);
|
|
5438
|
+
await fs3.writeFile(file, buf);
|
|
5439
|
+
} finally {
|
|
5440
|
+
await fh.close();
|
|
5441
|
+
}
|
|
5442
|
+
retainedBytes = MAX_COMMAND_OUTPUT_BYTES;
|
|
5443
|
+
truncated = true;
|
|
5444
|
+
}
|
|
5445
|
+
await mutateState(directory, `cmd.output:${id}`, async (s) => {
|
|
5446
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
5447
|
+
if (!c)
|
|
5448
|
+
return s;
|
|
5449
|
+
const byteLen = utf8ByteLength(chunk);
|
|
5450
|
+
const startOffset = c.streamBytes ?? 0;
|
|
5451
|
+
const endOffset = startOffset + byteLen;
|
|
5452
|
+
c.streamBytes = endOffset;
|
|
5453
|
+
c.outputBytes = retainedBytes;
|
|
5454
|
+
if (truncated)
|
|
5455
|
+
c.truncated = true;
|
|
5456
|
+
if (watcher)
|
|
5457
|
+
c.watchState = { ...watcher.state };
|
|
5458
|
+
c.updatedAt = new Date().toISOString();
|
|
5459
|
+
return s;
|
|
5460
|
+
});
|
|
5461
|
+
try {
|
|
5462
|
+
const fresh = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
|
|
5463
|
+
const endOffset = fresh?.streamBytes ?? 0;
|
|
5464
|
+
const startOffset = endOffset - utf8ByteLength(chunk);
|
|
5465
|
+
if (fresh && startOffset >= 0) {
|
|
5466
|
+
emitBroker(id, {
|
|
5467
|
+
type: "output",
|
|
5468
|
+
commandID: id,
|
|
5469
|
+
data: chunk,
|
|
5470
|
+
startOffset,
|
|
5471
|
+
endOffset
|
|
5472
|
+
});
|
|
5473
|
+
}
|
|
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 {}
|
|
5511
|
+
}
|
|
5512
|
+
async function persistExit(directory, id, info) {
|
|
5513
|
+
rememberDir(id, directory);
|
|
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);
|
|
5555
|
+
await mutateState(directory, `cmd.exit:${id}`, async (s) => {
|
|
5556
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
5557
|
+
if (!c || c.status !== "running")
|
|
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
|
+
}
|
|
5566
|
+
c.exitCode = info.exitCode;
|
|
5567
|
+
if (info.signal && (c.signal === "SIGKILL" || c.signal === "SIGTERM")) {
|
|
5568
|
+
c.status = "terminated";
|
|
5569
|
+
c.endReason = "terminate";
|
|
5570
|
+
} else {
|
|
5571
|
+
c.status = "exited";
|
|
5572
|
+
c.endReason = "exit";
|
|
5573
|
+
if (info.signal)
|
|
5574
|
+
c.signal = info.signal;
|
|
5575
|
+
}
|
|
5576
|
+
c.endedAt = new Date().toISOString();
|
|
5577
|
+
c.updatedAt = c.endedAt;
|
|
5578
|
+
return s;
|
|
5579
|
+
}).catch(() => {});
|
|
5580
|
+
await appendEvent(directory, {
|
|
5581
|
+
version: 1,
|
|
5582
|
+
eventID: randomUUID8(),
|
|
5583
|
+
commandID: id,
|
|
5584
|
+
type: "command.exited",
|
|
5585
|
+
exitCode: info.exitCode,
|
|
5586
|
+
timestamp: new Date().toISOString(),
|
|
5587
|
+
revision: 0
|
|
5588
|
+
}).catch(() => {});
|
|
5589
|
+
try {
|
|
5590
|
+
const fresh = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
|
|
5591
|
+
if (fresh)
|
|
5592
|
+
emitBroker(id, { type: "status", command: fresh });
|
|
5593
|
+
} catch {}
|
|
5594
|
+
await fireAwaits(directory, id);
|
|
5595
|
+
await notifyOwnerIfNeeded(directory, id);
|
|
5596
|
+
}
|
|
5597
|
+
function owned(cmd, ownerSessionID) {
|
|
5598
|
+
return !!cmd && cmd.ownerSessionID === ownerSessionID;
|
|
5599
|
+
}
|
|
5600
|
+
const service = {
|
|
5601
|
+
async start(directory, input) {
|
|
5602
|
+
const title = input.title.trim();
|
|
5603
|
+
const command = input.command.trim();
|
|
5604
|
+
if (!title)
|
|
5605
|
+
throw new Error("title is required");
|
|
5606
|
+
if (!command)
|
|
5607
|
+
throw new Error("command is required");
|
|
5608
|
+
if (!input.ownerSessionID || input.ownerSessionID === "main") {
|
|
5609
|
+
throw new Error("A valid owner session is required. Run from an active OpenCode session.");
|
|
5610
|
+
}
|
|
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();
|
|
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 };
|
|
5631
|
+
const pending = [];
|
|
5632
|
+
let ready = false;
|
|
5633
|
+
const handle = host.spawn(spawnOpts, (chunk) => {
|
|
5634
|
+
if (!ready)
|
|
5635
|
+
pending.push({ type: "output", chunk });
|
|
5636
|
+
else
|
|
5637
|
+
enqueue(id, () => persistOutput(directory, id, chunk)).catch(() => {});
|
|
5638
|
+
}, (info) => {
|
|
5639
|
+
if (!ready)
|
|
5640
|
+
pending.push({ type: "exit", info });
|
|
5641
|
+
else
|
|
5642
|
+
enqueue(id, () => persistExit(directory, id, info)).catch(() => {});
|
|
5643
|
+
});
|
|
5644
|
+
const session = createCommandSession({
|
|
5645
|
+
id,
|
|
5646
|
+
title,
|
|
5647
|
+
command,
|
|
5648
|
+
args: input.args ?? [],
|
|
5649
|
+
cwd,
|
|
5650
|
+
ownerSessionID: input.ownerSessionID,
|
|
5651
|
+
goalID: input.goalID,
|
|
5652
|
+
notifyOnExit: input.notifyOnExit,
|
|
5653
|
+
pid: handle.pid,
|
|
5654
|
+
cols: input.cols,
|
|
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
|
|
5665
|
+
});
|
|
5666
|
+
try {
|
|
5667
|
+
await mutateState(directory, `cmd.start:${id}`, async (s) => {
|
|
5668
|
+
s.commands = [...s.commands ?? [], session];
|
|
5669
|
+
return s;
|
|
5670
|
+
});
|
|
5671
|
+
} catch (error) {
|
|
5672
|
+
handle.terminate();
|
|
5673
|
+
const exited = await Promise.race([
|
|
5674
|
+
handle.exited().then(() => true, () => true),
|
|
5675
|
+
new Promise((resolve) => setTimeout(() => resolve(false), 1000))
|
|
5676
|
+
]);
|
|
5677
|
+
if (!exited && handle.isAlive())
|
|
5678
|
+
handle.kill();
|
|
5679
|
+
throw error;
|
|
5680
|
+
}
|
|
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);
|
|
5686
|
+
rememberDir(id, directory);
|
|
5687
|
+
await appendEvent(directory, {
|
|
5688
|
+
version: 1,
|
|
5689
|
+
eventID: randomUUID8(),
|
|
5690
|
+
...input.goalID ? { goalID: input.goalID } : {},
|
|
5691
|
+
commandID: id,
|
|
5692
|
+
type: "command.started",
|
|
5693
|
+
title,
|
|
5694
|
+
timestamp: new Date().toISOString(),
|
|
5695
|
+
revision: 0
|
|
5696
|
+
}).catch(() => {});
|
|
5697
|
+
try {
|
|
5698
|
+
const fresh = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
|
|
5699
|
+
if (fresh)
|
|
5700
|
+
emitBroker(id, { type: "status", command: fresh });
|
|
5701
|
+
} catch {}
|
|
5702
|
+
const initialEvents = pending.splice(0);
|
|
5703
|
+
ready = true;
|
|
5704
|
+
const initialOperations = [];
|
|
5705
|
+
for (const event of initialEvents) {
|
|
5706
|
+
initialOperations.push(event.type === "output" ? enqueue(id, () => persistOutput(directory, id, event.chunk)) : enqueue(id, () => persistExit(directory, id, event.info)));
|
|
5707
|
+
}
|
|
5708
|
+
await Promise.all(initialOperations);
|
|
5709
|
+
return await service.get(directory, id, input.ownerSessionID) ?? session;
|
|
5710
|
+
},
|
|
5711
|
+
async list(directory, ownerSessionID) {
|
|
5712
|
+
const s = await readState(directory);
|
|
5713
|
+
for (const c of s.commands ?? [])
|
|
5714
|
+
rememberDir(c.id, directory);
|
|
5715
|
+
return (s.commands ?? []).filter((c) => c.ownerSessionID === ownerSessionID);
|
|
5716
|
+
},
|
|
5717
|
+
async get(directory, id, ownerSessionID) {
|
|
5718
|
+
rememberDir(id, directory);
|
|
5719
|
+
const s = await readState(directory);
|
|
5720
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
5721
|
+
return owned(c, ownerSessionID) ? c : undefined;
|
|
5722
|
+
},
|
|
5723
|
+
async read(directory, id, ownerSessionID, opts) {
|
|
5724
|
+
await waitForOperations(id);
|
|
5725
|
+
const session = await service.get(directory, id, ownerSessionID);
|
|
5726
|
+
if (!session)
|
|
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
|
+
}
|
|
5771
|
+
const entry = live.get(id);
|
|
5772
|
+
const offset = opts?.offsetBytes ?? 0;
|
|
5773
|
+
const limit = Math.min(opts?.limitBytes ?? 64 * 1024, 256 * 1024);
|
|
5774
|
+
if (entry && offset === 0) {
|
|
5775
|
+
const mem = tailText(entry, limit);
|
|
5776
|
+
if (entry.bufferedBytes <= limit) {
|
|
5777
|
+
const file = await readCommandLog(directory, id, { offsetBytes: 0, limitBytes: 0 });
|
|
5778
|
+
const memBytes = Buffer.byteLength(mem);
|
|
5779
|
+
return { session, text: mem, totalBytes: session.outputBytes, startByte: Math.max(0, session.outputBytes - memBytes), live: session.status === "running" };
|
|
5780
|
+
}
|
|
5781
|
+
}
|
|
5782
|
+
const file = await readCommandLog(directory, id, { offsetBytes: offset, limitBytes: limit });
|
|
5783
|
+
return { session, text: file.text, totalBytes: session.outputBytes, startByte: file.startByte, live: session.status === "running" };
|
|
5784
|
+
},
|
|
5785
|
+
async write(directory, id, ownerSessionID, data) {
|
|
5786
|
+
const session = await service.get(directory, id, ownerSessionID);
|
|
5787
|
+
if (!session)
|
|
5788
|
+
return { ok: false, message: "Command not found." };
|
|
5789
|
+
if (session.status !== "running")
|
|
5790
|
+
return { ok: false, message: `Command is ${session.status}; only running commands accept input.` };
|
|
5791
|
+
const entry = live.get(id);
|
|
5792
|
+
const ok = entry ? entry.handle.write(data) : false;
|
|
5793
|
+
if (!ok)
|
|
5794
|
+
return { ok: false, message: "Process input unavailable (no live handle \u2014 host may have restarted; reconcile marks it honestly)." };
|
|
5795
|
+
return { ok: true, message: `Sent ${Buffer.byteLength(data)} byte(s) to "${session.title}".` };
|
|
5796
|
+
},
|
|
5797
|
+
async resize(directory, id, ownerSessionID, cols, rows) {
|
|
5798
|
+
const session = await service.get(directory, id, ownerSessionID);
|
|
5799
|
+
if (!session)
|
|
5800
|
+
return { ok: false, message: "Command not found." };
|
|
5801
|
+
if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols <= 0 || rows <= 0) {
|
|
5802
|
+
return { ok: false, message: `Invalid size ${cols}x${rows}: cols and rows must be positive integers.` };
|
|
5803
|
+
}
|
|
5804
|
+
const entry = live.get(id);
|
|
5805
|
+
let applied = false;
|
|
5806
|
+
if (entry) {
|
|
5807
|
+
try {
|
|
5808
|
+
applied = entry.handle.resize(cols, rows);
|
|
5809
|
+
} catch {
|
|
5810
|
+
applied = false;
|
|
5811
|
+
}
|
|
5812
|
+
}
|
|
5813
|
+
await mutateState(directory, `cmd.resize:${id}`, async (s) => {
|
|
5814
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
5815
|
+
if (c && c.ownerSessionID === ownerSessionID) {
|
|
5816
|
+
c.cols = cols;
|
|
5817
|
+
c.rows = rows;
|
|
5818
|
+
c.updatedAt = new Date().toISOString();
|
|
5819
|
+
}
|
|
5820
|
+
return s;
|
|
5821
|
+
});
|
|
5822
|
+
if (applied)
|
|
5823
|
+
return { ok: true, message: `Terminal resized to ${cols}x${rows} for "${session.title}".` };
|
|
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." };
|
|
5825
|
+
},
|
|
5826
|
+
async interrupt(directory, id, ownerSessionID) {
|
|
5827
|
+
const session = await service.get(directory, id, ownerSessionID);
|
|
5828
|
+
if (!session)
|
|
5829
|
+
return { ok: false, message: "Command not found." };
|
|
5830
|
+
if (session.status !== "running")
|
|
5831
|
+
return { ok: false, message: `Command is ${session.status}; nothing to interrupt.` };
|
|
5832
|
+
const entry = live.get(id);
|
|
5833
|
+
if (!entry)
|
|
5834
|
+
return { ok: false, message: "No live handle (host restarted?). Reconcile will mark it missing; use terminate/remove to clean up." };
|
|
5835
|
+
const delivered = entry.handle.interrupt();
|
|
5836
|
+
if (!delivered)
|
|
5837
|
+
return { ok: false, message: "Failed to deliver SIGINT." };
|
|
5838
|
+
await mutateState(directory, `cmd.interrupt:${id}`, async (s) => {
|
|
5839
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
5840
|
+
if (c && c.ownerSessionID === ownerSessionID) {
|
|
5841
|
+
c.signal = "SIGINT";
|
|
5842
|
+
c.updatedAt = new Date().toISOString();
|
|
5843
|
+
}
|
|
5844
|
+
return s;
|
|
5845
|
+
});
|
|
5846
|
+
return { ok: true, message: `SIGINT delivered to "${session.title}" (process may continue if it traps the signal).` };
|
|
5847
|
+
},
|
|
5848
|
+
async terminate(directory, id, ownerSessionID, opts) {
|
|
5849
|
+
rememberDir(id, directory);
|
|
5850
|
+
const session = await service.get(directory, id, ownerSessionID);
|
|
5851
|
+
if (!session)
|
|
5852
|
+
return { ok: false, message: "Command not found." };
|
|
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
|
+
}
|
|
5858
|
+
return { ok: false, message: `Command is ${session.status}; nothing to terminate.` };
|
|
5859
|
+
}
|
|
5860
|
+
await mutateState(directory, `cmd.terminate-claim:${id}`, async (s) => {
|
|
5861
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
5862
|
+
if (c && c.ownerSessionID === ownerSessionID && c.status === "running") {
|
|
5863
|
+
c.signal = "SIGTERM";
|
|
5864
|
+
c.updatedAt = new Date().toISOString();
|
|
5865
|
+
}
|
|
5866
|
+
return s;
|
|
5867
|
+
}).catch(() => {});
|
|
5868
|
+
try {
|
|
5869
|
+
const claimed = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
|
|
5870
|
+
if (claimed)
|
|
5871
|
+
emitBroker(id, { type: "status", command: claimed });
|
|
5872
|
+
} catch {}
|
|
5873
|
+
const entry = live.get(id);
|
|
5874
|
+
let status = "terminated";
|
|
5875
|
+
if (entry) {
|
|
5876
|
+
const aliveBefore = entry.handle.isAlive();
|
|
5877
|
+
if (!aliveBefore) {
|
|
5878
|
+
status = "exited";
|
|
5879
|
+
} else {
|
|
5880
|
+
entry.handle.terminate();
|
|
5881
|
+
const exited = await Promise.race([
|
|
5882
|
+
entry.handle.exited().then(() => true),
|
|
5883
|
+
new Promise((r) => setTimeout(() => r(false), 3000))
|
|
5884
|
+
]);
|
|
5885
|
+
if (!exited && entry.handle.isAlive())
|
|
5886
|
+
entry.handle.kill();
|
|
5887
|
+
try {
|
|
5888
|
+
await entry.handle.exited();
|
|
5889
|
+
} catch {}
|
|
5890
|
+
}
|
|
5891
|
+
live.delete(id);
|
|
5892
|
+
}
|
|
5893
|
+
const exitCode = status === "terminated" ? 143 : undefined;
|
|
5894
|
+
clearCommandTimeout(id);
|
|
5895
|
+
clearWatchTimer(id);
|
|
5896
|
+
await mutateState(directory, `cmd.terminate:${id}`, async (s) => {
|
|
5897
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
5898
|
+
if (!c || c.ownerSessionID !== ownerSessionID)
|
|
5899
|
+
return s;
|
|
5900
|
+
if (c.status === "running") {
|
|
5901
|
+
c.status = status;
|
|
5902
|
+
if (c.endReason !== "timeout")
|
|
5903
|
+
c.endReason = status === "terminated" ? "terminate" : "exit";
|
|
5904
|
+
if (exitCode !== undefined)
|
|
5905
|
+
c.exitCode = c.exitCode ?? exitCode;
|
|
5906
|
+
c.signal = c.signal ?? "SIGTERM";
|
|
5907
|
+
c.endedAt = new Date().toISOString();
|
|
5908
|
+
c.updatedAt = c.endedAt;
|
|
5909
|
+
} else if (c.status === "terminated" && !c.endedAt) {
|
|
5910
|
+
if (c.endReason !== "timeout" && !c.endReason)
|
|
5911
|
+
c.endReason = "terminate";
|
|
5912
|
+
if (exitCode !== undefined)
|
|
5913
|
+
c.exitCode = c.exitCode ?? exitCode;
|
|
5914
|
+
c.endedAt = new Date().toISOString();
|
|
5915
|
+
c.updatedAt = c.endedAt;
|
|
5916
|
+
}
|
|
5917
|
+
return s;
|
|
5918
|
+
});
|
|
5919
|
+
try {
|
|
5920
|
+
const fresh = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
|
|
5921
|
+
if (fresh)
|
|
5922
|
+
emitBroker(id, { type: "status", command: fresh });
|
|
5923
|
+
} catch {}
|
|
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
|
+
}
|
|
5932
|
+
return { ok: true, message: `Command "${session.title}" ${status}.` };
|
|
5933
|
+
},
|
|
5934
|
+
async remove(directory, id, ownerSessionID) {
|
|
5935
|
+
rememberDir(id, directory);
|
|
5936
|
+
const session = await service.get(directory, id, ownerSessionID);
|
|
5937
|
+
if (!session)
|
|
5938
|
+
return { ok: false, message: "Command not found." };
|
|
5939
|
+
if (session.status === "running") {
|
|
5940
|
+
return { ok: false, message: `Command "${session.title}" is still running \u2014 terminate it first (terminate \u2260 remove).` };
|
|
5941
|
+
}
|
|
5942
|
+
deleteWatch(id);
|
|
5943
|
+
const lastKnown = { ...session };
|
|
5944
|
+
await mutateState(directory, `cmd.remove:${id}`, async (s) => {
|
|
5945
|
+
s.commands = (s.commands ?? []).filter((x) => !(x.id === id && x.ownerSessionID === ownerSessionID));
|
|
5946
|
+
return s;
|
|
5947
|
+
});
|
|
5948
|
+
await removeCommandLog(directory, id);
|
|
5949
|
+
emitBroker(id, { type: "status", command: lastKnown });
|
|
5950
|
+
await clearAwaitsForCommand(directory, id, "command removed").catch(() => {});
|
|
5951
|
+
return { ok: true, message: `Command "${session.title}" removed.` };
|
|
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
|
+
},
|
|
6033
|
+
async reconcile(directory) {
|
|
6034
|
+
const state = await readState(directory);
|
|
6035
|
+
const cmds = state.commands ?? [];
|
|
6036
|
+
for (const c of cmds)
|
|
6037
|
+
rememberDir(c.id, directory);
|
|
6038
|
+
let markedMissing = 0;
|
|
6039
|
+
for (const c of cmds) {
|
|
6040
|
+
if (c.status !== "running")
|
|
6041
|
+
continue;
|
|
6042
|
+
const entry = live.get(c.id);
|
|
6043
|
+
if (entry) {
|
|
6044
|
+
if (!entry.handle.isAlive()) {
|
|
6045
|
+
live.delete(c.id);
|
|
6046
|
+
clearCommandTimeout(c.id);
|
|
6047
|
+
deleteWatch(c.id);
|
|
6048
|
+
await mutateState(directory, `cmd.reconcile-exit:${c.id}`, async (s) => {
|
|
6049
|
+
const x = (s.commands ?? []).find((y) => y.id === c.id);
|
|
6050
|
+
if (x && x.status === "running") {
|
|
6051
|
+
x.status = "exited";
|
|
6052
|
+
x.endReason = "exit";
|
|
6053
|
+
x.endedAt = new Date().toISOString();
|
|
6054
|
+
x.updatedAt = x.endedAt;
|
|
6055
|
+
x.lastError = x.lastError ?? "Process handle died without an exit event.";
|
|
6056
|
+
}
|
|
6057
|
+
return s;
|
|
6058
|
+
}).catch(() => {});
|
|
6059
|
+
try {
|
|
6060
|
+
const fresh = await readState(directory).then((st) => (st.commands ?? []).find((y) => y.id === c.id));
|
|
6061
|
+
if (fresh)
|
|
6062
|
+
emitBroker(c.id, { type: "status", command: fresh });
|
|
6063
|
+
} catch {}
|
|
6064
|
+
await fireAwaits(directory, c.id);
|
|
6065
|
+
await notifyOwnerIfNeeded(directory, c.id);
|
|
6066
|
+
}
|
|
6067
|
+
continue;
|
|
6068
|
+
}
|
|
6069
|
+
clearCommandTimeout(c.id);
|
|
6070
|
+
deleteWatch(c.id);
|
|
6071
|
+
const deadlinePassed = c.deadlineAt ? Date.parse(c.deadlineAt) <= Date.now() : false;
|
|
6072
|
+
await mutateState(directory, `cmd.reconcile-missing:${c.id}`, async (s) => {
|
|
6073
|
+
const x = (s.commands ?? []).find((y) => y.id === c.id);
|
|
6074
|
+
if (x && x.status === "running") {
|
|
6075
|
+
x.status = "missing";
|
|
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.";
|
|
6078
|
+
x.updatedAt = new Date().toISOString();
|
|
6079
|
+
markedMissing++;
|
|
6080
|
+
}
|
|
6081
|
+
return s;
|
|
6082
|
+
}).catch(() => {});
|
|
6083
|
+
try {
|
|
6084
|
+
const fresh = await readState(directory).then((st) => (st.commands ?? []).find((y) => y.id === c.id));
|
|
6085
|
+
if (fresh && fresh.status === "missing")
|
|
6086
|
+
emitBroker(c.id, { type: "status", command: fresh });
|
|
6087
|
+
} catch {}
|
|
6088
|
+
await fireAwaits(directory, c.id);
|
|
6089
|
+
await notifyOwnerIfNeeded(directory, c.id);
|
|
6090
|
+
}
|
|
6091
|
+
return { markedMissing };
|
|
6092
|
+
},
|
|
6093
|
+
async dispose(directory) {
|
|
6094
|
+
for (const id of [...watchTimers.keys()])
|
|
6095
|
+
clearWatchTimer(id);
|
|
6096
|
+
watchers.clear();
|
|
6097
|
+
awaitAsm.clear();
|
|
6098
|
+
const state = await readState(directory);
|
|
6099
|
+
const owned = new Map((state.commands ?? []).map((command) => [command.id, command.ownerSessionID]));
|
|
6100
|
+
await Promise.all([...live.entries()].map(async ([id, entry]) => {
|
|
6101
|
+
const owner = owned.get(id);
|
|
6102
|
+
if (owner) {
|
|
6103
|
+
await service.terminate(directory, id, owner).catch(() => {});
|
|
6104
|
+
return;
|
|
6105
|
+
}
|
|
6106
|
+
entry.handle.terminate();
|
|
6107
|
+
const exited = await Promise.race([
|
|
6108
|
+
entry.handle.exited().then(() => true, () => true),
|
|
6109
|
+
new Promise((resolve) => setTimeout(() => resolve(false), 1000))
|
|
6110
|
+
]);
|
|
6111
|
+
if (!exited && entry.handle.isAlive())
|
|
6112
|
+
entry.handle.kill();
|
|
6113
|
+
live.delete(id);
|
|
6114
|
+
}));
|
|
6115
|
+
for (const id of [...timeouts.keys()])
|
|
6116
|
+
clearCommandTimeout(id);
|
|
6117
|
+
}
|
|
6118
|
+
};
|
|
6119
|
+
return service;
|
|
6120
|
+
}
|
|
6121
|
+
|
|
6122
|
+
// src/application/command-event-broker.ts
|
|
6123
|
+
import { randomUUID as randomUUID9 } from "crypto";
|
|
6124
|
+
function safeDeliver(sink, msg) {
|
|
6125
|
+
try {
|
|
6126
|
+
sink(msg);
|
|
6127
|
+
} catch {}
|
|
6128
|
+
}
|
|
6129
|
+
function isCoveredBySnapshot(msg, snapshot) {
|
|
6130
|
+
if (msg.type === "output") {
|
|
6131
|
+
const out = msg;
|
|
6132
|
+
return out.endOffset <= snapshot.endOffset;
|
|
6133
|
+
}
|
|
6134
|
+
if (msg.type === "status") {
|
|
6135
|
+
const st = msg;
|
|
6136
|
+
const bufferedAt = st.command.updatedAt ?? "";
|
|
6137
|
+
const snapshotAt = snapshot.command.updatedAt ?? "";
|
|
6138
|
+
return bufferedAt <= snapshotAt;
|
|
6139
|
+
}
|
|
6140
|
+
return true;
|
|
6141
|
+
}
|
|
6142
|
+
function createCommandEventBroker(resolver) {
|
|
6143
|
+
let currentResolver = resolver;
|
|
6144
|
+
const subscribers = new Map;
|
|
6145
|
+
function entriesFor(commandID) {
|
|
6146
|
+
let m = subscribers.get(commandID);
|
|
6147
|
+
if (!m) {
|
|
6148
|
+
m = new Map;
|
|
6149
|
+
subscribers.set(commandID, m);
|
|
6150
|
+
}
|
|
6151
|
+
return m;
|
|
6152
|
+
}
|
|
6153
|
+
return {
|
|
6154
|
+
setResolver(next) {
|
|
6155
|
+
currentResolver = next;
|
|
6156
|
+
},
|
|
6157
|
+
async subscribe(commandID, ownerSessionID, sink) {
|
|
6158
|
+
if (!currentResolver)
|
|
6159
|
+
throw new Error("Command event broker has no resolver (service not wired).");
|
|
6160
|
+
if (!commandID)
|
|
6161
|
+
throw new Error("commandID is required.");
|
|
6162
|
+
if (!ownerSessionID)
|
|
6163
|
+
throw new Error("ownerSessionID is required.");
|
|
6164
|
+
if (typeof sink !== "function")
|
|
6165
|
+
throw new Error("sink must be a function.");
|
|
6166
|
+
const sinkID = randomUUID9();
|
|
6167
|
+
const entry = { sinkID, sink, state: "subscribing", buffer: [] };
|
|
6168
|
+
entriesFor(commandID).set(sinkID, entry);
|
|
6169
|
+
try {
|
|
6170
|
+
const session = await currentResolver.getSession(commandID, ownerSessionID);
|
|
6171
|
+
if (!session) {
|
|
6172
|
+
throw new Error("Command not found or not owned by this session (cross-owner subscribe rejected).");
|
|
6173
|
+
}
|
|
6174
|
+
await currentResolver.waitForQuiesce(commandID);
|
|
6175
|
+
const snap = await currentResolver.readSnapshot(commandID, ownerSessionID);
|
|
6176
|
+
if (!snap) {
|
|
6177
|
+
throw new Error("Command snapshot unavailable (removed or unreadable).");
|
|
6178
|
+
}
|
|
6179
|
+
const snapshotMsg = {
|
|
6180
|
+
type: "snapshot",
|
|
6181
|
+
command: snap.command,
|
|
6182
|
+
data: snap.data,
|
|
6183
|
+
startOffset: snap.startOffset,
|
|
6184
|
+
endOffset: snap.endOffset
|
|
6185
|
+
};
|
|
6186
|
+
const validated = validateCommandStreamMessage(snapshotMsg);
|
|
6187
|
+
if (!validated.ok) {
|
|
6188
|
+
throw new Error(`Invalid snapshot: ${validated.error}`);
|
|
6189
|
+
}
|
|
6190
|
+
const stillThere = subscribers.get(commandID)?.get(sinkID);
|
|
6191
|
+
if (!stillThere)
|
|
6192
|
+
throw new Error("Subscription cancelled during handshake.");
|
|
6193
|
+
const buffered = entry.buffer.splice(0);
|
|
6194
|
+
safeDeliver(entry.sink, validated.message);
|
|
6195
|
+
for (const msg of buffered) {
|
|
6196
|
+
if (isCoveredBySnapshot(msg, snapshotMsg))
|
|
6197
|
+
continue;
|
|
6198
|
+
safeDeliver(entry.sink, msg);
|
|
6199
|
+
}
|
|
6200
|
+
const extra = entry.buffer.splice(0);
|
|
6201
|
+
for (const msg of extra) {
|
|
6202
|
+
if (isCoveredBySnapshot(msg, snapshotMsg))
|
|
6203
|
+
continue;
|
|
6204
|
+
safeDeliver(entry.sink, msg);
|
|
6205
|
+
}
|
|
6206
|
+
entry.state = "live";
|
|
6207
|
+
return sinkID;
|
|
6208
|
+
} catch (error) {
|
|
6209
|
+
subscribers.get(commandID)?.delete(sinkID);
|
|
6210
|
+
if (subscribers.get(commandID)?.size === 0)
|
|
6211
|
+
subscribers.delete(commandID);
|
|
6212
|
+
throw error;
|
|
6213
|
+
}
|
|
6214
|
+
},
|
|
6215
|
+
unsubscribe(commandID, sinkID) {
|
|
6216
|
+
const m = subscribers.get(commandID);
|
|
6217
|
+
if (!m)
|
|
6218
|
+
return;
|
|
6219
|
+
m.delete(sinkID);
|
|
6220
|
+
if (m.size === 0)
|
|
6221
|
+
subscribers.delete(commandID);
|
|
6222
|
+
},
|
|
6223
|
+
publish(commandID, message) {
|
|
6224
|
+
let valid;
|
|
6225
|
+
try {
|
|
6226
|
+
const result = validateCommandStreamMessage(message);
|
|
6227
|
+
if (!result.ok)
|
|
6228
|
+
return false;
|
|
6229
|
+
valid = result.message;
|
|
6230
|
+
} catch {
|
|
6231
|
+
return false;
|
|
6232
|
+
}
|
|
6233
|
+
const m = subscribers.get(commandID);
|
|
6234
|
+
if (!m || m.size === 0)
|
|
6235
|
+
return false;
|
|
6236
|
+
let handled = false;
|
|
6237
|
+
for (const entry of m.values()) {
|
|
6238
|
+
if (entry.state === "subscribing") {
|
|
6239
|
+
entry.buffer.push(valid);
|
|
6240
|
+
handled = true;
|
|
6241
|
+
continue;
|
|
6242
|
+
}
|
|
6243
|
+
safeDeliver(entry.sink, valid);
|
|
6244
|
+
handled = true;
|
|
6245
|
+
}
|
|
6246
|
+
return handled;
|
|
6247
|
+
},
|
|
6248
|
+
subscriberCount(commandID) {
|
|
6249
|
+
return subscribers.get(commandID)?.size ?? 0;
|
|
6250
|
+
}
|
|
6251
|
+
};
|
|
6252
|
+
}
|
|
6253
|
+
|
|
6254
|
+
// src/server/command-stream-server.ts
|
|
6255
|
+
import { randomBytes, randomUUID as randomUUID10, timingSafeEqual } from "crypto";
|
|
6256
|
+
import { promises as fs4 } from "fs";
|
|
6257
|
+
import path4 from "path";
|
|
6258
|
+
function streamEndpointPath(directory) {
|
|
6259
|
+
return path4.join(directory, ".opencode", "loopd", "commands", ".stream-endpoint.json");
|
|
6260
|
+
}
|
|
6261
|
+
function isPidAlive(pid) {
|
|
6262
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
6263
|
+
return false;
|
|
6264
|
+
try {
|
|
6265
|
+
process.kill(pid, 0);
|
|
6266
|
+
return true;
|
|
6267
|
+
} catch (error) {
|
|
6268
|
+
const code = error?.code;
|
|
6269
|
+
if (code === "EPERM")
|
|
6270
|
+
return true;
|
|
6271
|
+
return false;
|
|
6272
|
+
}
|
|
6273
|
+
}
|
|
6274
|
+
function tokensEqual(a, b) {
|
|
6275
|
+
const ab = Buffer.from(a);
|
|
6276
|
+
const bb = Buffer.from(b);
|
|
6277
|
+
if (ab.length !== bb.length)
|
|
6278
|
+
return false;
|
|
6279
|
+
try {
|
|
6280
|
+
return timingSafeEqual(ab, bb);
|
|
6281
|
+
} catch {
|
|
6282
|
+
return false;
|
|
6283
|
+
}
|
|
6284
|
+
}
|
|
6285
|
+
var MAX_BUFFERED_BYTES = 512 * 1024;
|
|
6286
|
+
function createCommandStreamServer(directory, commandService, broker) {
|
|
6287
|
+
const endpointPath = streamEndpointPath(directory);
|
|
6288
|
+
const token = randomBytes(32).toString("hex");
|
|
6289
|
+
const generation = randomUUID10();
|
|
6290
|
+
const startedAt = new Date().toISOString();
|
|
6291
|
+
let server;
|
|
6292
|
+
let serverURL;
|
|
6293
|
+
let started = false;
|
|
6294
|
+
let stopped = false;
|
|
6295
|
+
const sockets = new Set;
|
|
6296
|
+
const subsBySocket = new Map;
|
|
6297
|
+
const handshakeBySocket = new Map;
|
|
6298
|
+
function nextHandshake(ws, commandID) {
|
|
6299
|
+
let m = handshakeBySocket.get(ws);
|
|
6300
|
+
if (!m) {
|
|
6301
|
+
m = new Map;
|
|
6302
|
+
handshakeBySocket.set(ws, m);
|
|
6303
|
+
}
|
|
6304
|
+
const next = (m.get(commandID) ?? 0) + 1;
|
|
6305
|
+
m.set(commandID, next);
|
|
6306
|
+
return next;
|
|
6307
|
+
}
|
|
6308
|
+
function currentHandshake(ws, commandID) {
|
|
6309
|
+
return handshakeBySocket.get(ws)?.get(commandID);
|
|
6310
|
+
}
|
|
6311
|
+
function clearHandshake(ws, commandID, generation) {
|
|
6312
|
+
const m = handshakeBySocket.get(ws);
|
|
6313
|
+
if (m?.get(commandID) === generation)
|
|
6314
|
+
m.delete(commandID);
|
|
6315
|
+
if (m && m.size === 0)
|
|
6316
|
+
handshakeBySocket.delete(ws);
|
|
6317
|
+
}
|
|
6318
|
+
function sendError(ws, code, message) {
|
|
6319
|
+
try {
|
|
6320
|
+
ws.send(JSON.stringify({ type: "error", code, message }));
|
|
6321
|
+
} catch {}
|
|
6322
|
+
}
|
|
6323
|
+
function sendToSocket(ws, payload) {
|
|
6324
|
+
try {
|
|
6325
|
+
const buffered = ws.bufferedAmount ?? 0;
|
|
6326
|
+
if (buffered > MAX_BUFFERED_BYTES) {
|
|
6327
|
+
try {
|
|
6328
|
+
ws.close(1011, "backpressure overflow: resync");
|
|
6329
|
+
} catch {}
|
|
6330
|
+
return false;
|
|
6331
|
+
}
|
|
6332
|
+
ws.send(payload);
|
|
6333
|
+
return true;
|
|
6334
|
+
} catch {
|
|
6335
|
+
return false;
|
|
6336
|
+
}
|
|
6337
|
+
}
|
|
6338
|
+
function makeSink(ws) {
|
|
6339
|
+
return (msg) => {
|
|
6340
|
+
if (stopped || !sockets.has(ws))
|
|
6341
|
+
return;
|
|
6342
|
+
sendToSocket(ws, JSON.stringify(msg));
|
|
6343
|
+
};
|
|
6344
|
+
}
|
|
6345
|
+
function subsFor(ws) {
|
|
6346
|
+
let m = subsBySocket.get(ws);
|
|
6347
|
+
if (!m) {
|
|
6348
|
+
m = new Map;
|
|
6349
|
+
subsBySocket.set(ws, m);
|
|
6350
|
+
}
|
|
6351
|
+
return m;
|
|
6352
|
+
}
|
|
6353
|
+
function cleanupSocket(ws) {
|
|
6354
|
+
const subs = subsBySocket.get(ws);
|
|
6355
|
+
if (subs) {
|
|
6356
|
+
for (const [commandID, entry] of subs) {
|
|
6357
|
+
try {
|
|
6358
|
+
broker.unsubscribe(commandID, entry.sinkID);
|
|
6359
|
+
} catch {}
|
|
6360
|
+
}
|
|
6361
|
+
subsBySocket.delete(ws);
|
|
6362
|
+
}
|
|
6363
|
+
handshakeBySocket.delete(ws);
|
|
6364
|
+
sockets.delete(ws);
|
|
6365
|
+
}
|
|
6366
|
+
async function handleSubscribe(ws, commandID, ownerSessionID) {
|
|
6367
|
+
const generation = nextHandshake(ws, commandID);
|
|
6368
|
+
const subs = subsFor(ws);
|
|
6369
|
+
const previous = subs.get(commandID);
|
|
6370
|
+
if (previous) {
|
|
6371
|
+
try {
|
|
6372
|
+
broker.unsubscribe(commandID, previous.sinkID);
|
|
6373
|
+
} catch {}
|
|
6374
|
+
subs.delete(commandID);
|
|
6375
|
+
}
|
|
6376
|
+
const sink = makeSink(ws);
|
|
6377
|
+
let sinkID;
|
|
6378
|
+
try {
|
|
6379
|
+
sinkID = await broker.subscribe(commandID, ownerSessionID, sink);
|
|
6380
|
+
} catch (error) {
|
|
6381
|
+
if (currentHandshake(ws, commandID) !== generation)
|
|
6382
|
+
return;
|
|
6383
|
+
clearHandshake(ws, commandID, generation);
|
|
6384
|
+
sendError(ws, "subscribe-failed", error instanceof Error ? error.message : String(error));
|
|
6385
|
+
return;
|
|
6386
|
+
}
|
|
6387
|
+
if (currentHandshake(ws, commandID) !== generation) {
|
|
6388
|
+
try {
|
|
6389
|
+
broker.unsubscribe(commandID, sinkID);
|
|
6390
|
+
} catch {}
|
|
6391
|
+
return;
|
|
6392
|
+
}
|
|
6393
|
+
clearHandshake(ws, commandID, generation);
|
|
6394
|
+
if (!sockets.has(ws)) {
|
|
6395
|
+
try {
|
|
6396
|
+
broker.unsubscribe(commandID, sinkID);
|
|
6397
|
+
} catch {}
|
|
6398
|
+
return;
|
|
6399
|
+
}
|
|
6400
|
+
subs.set(commandID, { sinkID, ownerSessionID });
|
|
6401
|
+
}
|
|
6402
|
+
async function handleResync(ws, commandID) {
|
|
6403
|
+
const subs = subsFor(ws);
|
|
6404
|
+
const previous = subs.get(commandID);
|
|
6405
|
+
if (!previous) {
|
|
6406
|
+
sendError(ws, "not-subscribed", `No subscription for command ${commandID} on this socket; subscribe first.`);
|
|
6407
|
+
return;
|
|
6408
|
+
}
|
|
6409
|
+
await handleSubscribe(ws, commandID, previous.ownerSessionID);
|
|
6410
|
+
}
|
|
6411
|
+
function handleUnsubscribe(ws, commandID) {
|
|
6412
|
+
if (handshakeBySocket.get(ws)?.has(commandID))
|
|
6413
|
+
nextHandshake(ws, commandID);
|
|
6414
|
+
const subs = subsFor(ws);
|
|
6415
|
+
const previous = subs.get(commandID);
|
|
6416
|
+
if (previous) {
|
|
6417
|
+
try {
|
|
6418
|
+
broker.unsubscribe(commandID, previous.sinkID);
|
|
6419
|
+
} catch {}
|
|
6420
|
+
subs.delete(commandID);
|
|
6421
|
+
}
|
|
6422
|
+
}
|
|
6423
|
+
async function handleInput(ws, commandID, data) {
|
|
6424
|
+
const owner = subsFor(ws).get(commandID)?.ownerSessionID;
|
|
6425
|
+
if (!owner) {
|
|
6426
|
+
sendError(ws, "not-subscribed", `No subscription for command ${commandID} on this socket; subscribe first.`);
|
|
6427
|
+
return;
|
|
6428
|
+
}
|
|
6429
|
+
try {
|
|
6430
|
+
const result = await commandService.write(directory, commandID, owner, data);
|
|
6431
|
+
if (!result.ok)
|
|
6432
|
+
sendError(ws, "input-failed", result.message);
|
|
6433
|
+
} catch (error) {
|
|
6434
|
+
sendError(ws, "input-failed", error instanceof Error ? error.message : String(error));
|
|
6435
|
+
}
|
|
6436
|
+
}
|
|
6437
|
+
async function handleInterrupt(ws, commandID) {
|
|
6438
|
+
const owner = subsFor(ws).get(commandID)?.ownerSessionID;
|
|
6439
|
+
if (!owner) {
|
|
6440
|
+
sendError(ws, "not-subscribed", `No subscription for command ${commandID} on this socket; subscribe first.`);
|
|
6441
|
+
return;
|
|
6442
|
+
}
|
|
6443
|
+
try {
|
|
6444
|
+
const result = await commandService.interrupt(directory, commandID, owner);
|
|
6445
|
+
if (!result.ok)
|
|
6446
|
+
sendError(ws, "interrupt-failed", result.message);
|
|
6447
|
+
} catch (error) {
|
|
6448
|
+
sendError(ws, "interrupt-failed", error instanceof Error ? error.message : String(error));
|
|
6449
|
+
}
|
|
6450
|
+
}
|
|
6451
|
+
async function handleSocketMessage(ws, raw) {
|
|
6452
|
+
let parsed;
|
|
6453
|
+
try {
|
|
6454
|
+
const text = typeof raw === "string" ? raw : Buffer.from(raw).toString("utf8");
|
|
6455
|
+
parsed = JSON.parse(text);
|
|
6456
|
+
} catch {
|
|
6457
|
+
sendError(ws, "invalid-message", "Message must be JSON.");
|
|
6458
|
+
return;
|
|
6459
|
+
}
|
|
6460
|
+
let validated;
|
|
6461
|
+
try {
|
|
6462
|
+
validated = validateCommandStreamMessage(parsed);
|
|
6463
|
+
} catch (error) {
|
|
6464
|
+
sendError(ws, "invalid-message", `Validation failed: ${String(error)}`);
|
|
6465
|
+
return;
|
|
6466
|
+
}
|
|
6467
|
+
if (!validated.ok) {
|
|
6468
|
+
sendError(ws, "invalid-message", validated.error);
|
|
6469
|
+
return;
|
|
6470
|
+
}
|
|
6471
|
+
const msg = validated.message;
|
|
6472
|
+
try {
|
|
6473
|
+
switch (msg.type) {
|
|
6474
|
+
case "subscribe":
|
|
6475
|
+
await handleSubscribe(ws, msg.commandID, msg.ownerSessionID);
|
|
6476
|
+
break;
|
|
6477
|
+
case "resync":
|
|
6478
|
+
await handleResync(ws, msg.commandID);
|
|
6479
|
+
break;
|
|
6480
|
+
case "unsubscribe":
|
|
6481
|
+
handleUnsubscribe(ws, msg.commandID);
|
|
6482
|
+
break;
|
|
6483
|
+
case "input":
|
|
6484
|
+
await handleInput(ws, msg.commandID, msg.data);
|
|
6485
|
+
break;
|
|
6486
|
+
case "interrupt":
|
|
6487
|
+
await handleInterrupt(ws, msg.commandID);
|
|
6488
|
+
break;
|
|
6489
|
+
default:
|
|
6490
|
+
sendError(ws, "invalid-message", `Message type "${msg.type}" is not accepted inbound.`);
|
|
6491
|
+
break;
|
|
6492
|
+
}
|
|
6493
|
+
} catch (error) {
|
|
6494
|
+
sendError(ws, "internal-error", error instanceof Error ? error.message : String(error));
|
|
6495
|
+
}
|
|
6496
|
+
}
|
|
6497
|
+
async function writeEndpointFile(url) {
|
|
6498
|
+
const endpoint = {
|
|
6499
|
+
url,
|
|
6500
|
+
token,
|
|
6501
|
+
pid: process.pid,
|
|
6502
|
+
generation,
|
|
6503
|
+
startedAt
|
|
6504
|
+
};
|
|
6505
|
+
await fs4.mkdir(path4.dirname(endpointPath), { recursive: true });
|
|
6506
|
+
await fs4.writeFile(endpointPath, JSON.stringify(endpoint, null, 2) + `
|
|
6507
|
+
`, { mode: 384 });
|
|
6508
|
+
await fs4.chmod(endpointPath, 384);
|
|
3189
6509
|
}
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
if (
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
if (typeof max === "number" && count >= max)
|
|
3205
|
-
continue;
|
|
3206
|
-
const nextAt = runtime.nextRunAt;
|
|
3207
|
-
if (!nextAt)
|
|
3208
|
-
continue;
|
|
3209
|
-
if (Date.now() < Date.parse(nextAt))
|
|
3210
|
-
continue;
|
|
3211
|
-
const activeWriter = state.goals.find((g) => g.id !== goal.id && g.status === "active" && g.config.workspaceWrite);
|
|
3212
|
-
if (goal.config.workspaceWrite && activeWriter) {
|
|
3213
|
-
await logServerEvent(directory, "schedule.skipped-writer-active", {
|
|
3214
|
-
goalID: goal.id,
|
|
3215
|
-
activeWriter: activeWriter.id
|
|
6510
|
+
return {
|
|
6511
|
+
get url() {
|
|
6512
|
+
return serverURL;
|
|
6513
|
+
},
|
|
6514
|
+
token,
|
|
6515
|
+
generation,
|
|
6516
|
+
endpointPath,
|
|
6517
|
+
async start() {
|
|
6518
|
+
if (started)
|
|
6519
|
+
return;
|
|
6520
|
+
started = true;
|
|
6521
|
+
try {
|
|
6522
|
+
const previous = await fs4.readFile(endpointPath, "utf8").then((text) => JSON.parse(text), () => {
|
|
6523
|
+
return;
|
|
3216
6524
|
});
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
timestamp: new Date().toISOString(),
|
|
3261
|
-
revision: didResurrect.revision
|
|
3262
|
-
});
|
|
3263
|
-
await logServerEvent(directory, "schedule.resurrected", {
|
|
3264
|
-
goalID: goal.id,
|
|
3265
|
-
scheduleRunCount: count,
|
|
3266
|
-
nextRunAt: nextAt
|
|
6525
|
+
if (previous && typeof previous.pid === "number" && isPidAlive(previous.pid) && previous.pid !== process.pid) {
|
|
6526
|
+
logServerEvent(directory, "command-stream.superseded-live-endpoint", { pid: previous.pid });
|
|
6527
|
+
}
|
|
6528
|
+
} catch {}
|
|
6529
|
+
const srv = Bun.serve({
|
|
6530
|
+
hostname: "127.0.0.1",
|
|
6531
|
+
port: 0,
|
|
6532
|
+
fetch(req, upgrading) {
|
|
6533
|
+
if (stopped)
|
|
6534
|
+
return new Response("Server is stopping.", { status: 503 });
|
|
6535
|
+
let url;
|
|
6536
|
+
try {
|
|
6537
|
+
url = new URL(req.url);
|
|
6538
|
+
} catch {
|
|
6539
|
+
return new Response("Bad request.", { status: 400 });
|
|
6540
|
+
}
|
|
6541
|
+
if (url.pathname !== "/" && url.pathname !== "/stream") {
|
|
6542
|
+
return new Response("Not found.", { status: 404 });
|
|
6543
|
+
}
|
|
6544
|
+
const presented = url.searchParams.get("token") ?? "";
|
|
6545
|
+
if (!presented || !tokensEqual(presented, token)) {
|
|
6546
|
+
return new Response("Forbidden: valid connection token required.", { status: 403 });
|
|
6547
|
+
}
|
|
6548
|
+
const ok = upgrading.upgrade(req, { data: undefined });
|
|
6549
|
+
if (!ok)
|
|
6550
|
+
return new Response("WebSocket upgrade required.", { status: 400 });
|
|
6551
|
+
return;
|
|
6552
|
+
},
|
|
6553
|
+
websocket: {
|
|
6554
|
+
open(ws) {
|
|
6555
|
+
sockets.add(ws);
|
|
6556
|
+
},
|
|
6557
|
+
message(ws, raw) {
|
|
6558
|
+
handleSocketMessage(ws, raw).catch((error) => {
|
|
6559
|
+
try {
|
|
6560
|
+
sendError(ws, "internal-error", describeError(error));
|
|
6561
|
+
} catch {}
|
|
6562
|
+
});
|
|
6563
|
+
},
|
|
6564
|
+
close(ws) {
|
|
6565
|
+
cleanupSocket(ws);
|
|
6566
|
+
}
|
|
6567
|
+
}
|
|
3267
6568
|
});
|
|
3268
|
-
|
|
3269
|
-
|
|
6569
|
+
server = srv;
|
|
6570
|
+
const port = srv.port;
|
|
6571
|
+
serverURL = `ws://127.0.0.1:${port}/stream?token=${token}`;
|
|
6572
|
+
await writeEndpointFile(serverURL);
|
|
6573
|
+
logServerEvent(directory, "command-stream.started", { port, generation });
|
|
6574
|
+
},
|
|
6575
|
+
async stop() {
|
|
6576
|
+
stopped = true;
|
|
6577
|
+
for (const ws of [...sockets]) {
|
|
6578
|
+
try {
|
|
6579
|
+
ws.close(1001, "server shutting down");
|
|
6580
|
+
} catch {}
|
|
6581
|
+
cleanupSocket(ws);
|
|
6582
|
+
}
|
|
6583
|
+
sockets.clear();
|
|
6584
|
+
subsBySocket.clear();
|
|
6585
|
+
handshakeBySocket.clear();
|
|
3270
6586
|
try {
|
|
3271
|
-
|
|
3272
|
-
resurrected++;
|
|
6587
|
+
server?.stop(true);
|
|
3273
6588
|
} catch {}
|
|
6589
|
+
server = undefined;
|
|
6590
|
+
serverURL = undefined;
|
|
6591
|
+
try {
|
|
6592
|
+
const text = await fs4.readFile(endpointPath, "utf8");
|
|
6593
|
+
const current = JSON.parse(text);
|
|
6594
|
+
if (current?.generation === generation) {
|
|
6595
|
+
await fs4.unlink(endpointPath);
|
|
6596
|
+
} else {
|
|
6597
|
+
logServerEvent(directory, "command-stream.keep-endpoint", {
|
|
6598
|
+
reason: "generation mismatch \u2014 another server owns the file"
|
|
6599
|
+
});
|
|
6600
|
+
}
|
|
6601
|
+
} catch {}
|
|
6602
|
+
logServerEvent(directory, "command-stream.stopped", { generation });
|
|
3274
6603
|
}
|
|
3275
|
-
|
|
3276
|
-
}
|
|
3277
|
-
return { start, stop, isRunning, tick };
|
|
6604
|
+
};
|
|
3278
6605
|
}
|
|
3279
6606
|
|
|
3280
6607
|
// src/server/goal-tools.ts
|
|
3281
6608
|
init_state_repository();
|
|
3282
|
-
import { randomUUID as
|
|
6609
|
+
import { randomUUID as randomUUID11 } from "crypto";
|
|
3283
6610
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
3284
6611
|
// src/domain/verification.ts
|
|
3285
6612
|
var MAX_RECENT_ATTEMPTS = 10;
|
|
@@ -3298,7 +6625,7 @@ var execAsync = promisify(execChild);
|
|
|
3298
6625
|
function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
3299
6626
|
return {
|
|
3300
6627
|
loopd_create_goal: tool({
|
|
3301
|
-
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).",
|
|
3302
6629
|
args: {
|
|
3303
6630
|
name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
|
|
3304
6631
|
objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
|
|
@@ -3418,6 +6745,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
3418
6745
|
ok: true,
|
|
3419
6746
|
goalID: goal.id,
|
|
3420
6747
|
workerSessionID: worker.workerSessionID,
|
|
6748
|
+
workerTopology: worker.topology,
|
|
6749
|
+
...worker.nativeParentID ? { nativeParentID: worker.nativeParentID } : {},
|
|
3421
6750
|
artifactDir: goal.config.artifactDir,
|
|
3422
6751
|
agent: resolution.config.agent,
|
|
3423
6752
|
model: resolution.config.model,
|
|
@@ -3430,12 +6759,20 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
3430
6759
|
})
|
|
3431
6760
|
};
|
|
3432
6761
|
} catch (error) {
|
|
6762
|
+
const startFailure = error instanceof GoalStartError ? error : undefined;
|
|
3433
6763
|
return {
|
|
3434
6764
|
title: "Goal creation failed",
|
|
3435
6765
|
output: JSON.stringify({
|
|
3436
6766
|
ok: false,
|
|
3437
6767
|
name: args.name,
|
|
3438
6768
|
message: error instanceof Error ? error.message : String(error),
|
|
6769
|
+
...startFailure ? {
|
|
6770
|
+
goalID: startFailure.goalID,
|
|
6771
|
+
status: "blocked",
|
|
6772
|
+
failedStage: startFailure.failedStage,
|
|
6773
|
+
...startFailure.workerSessionID ? { workerSessionID: startFailure.workerSessionID } : {},
|
|
6774
|
+
nextAction: `Resume the persisted goal with resume_goal (goal_id "${startFailure.goalID}") after fixing the cause \u2014 do not create another goal for the same task.`
|
|
6775
|
+
} : {},
|
|
3439
6776
|
diagnostics: SERVER_LOG_FILE
|
|
3440
6777
|
})
|
|
3441
6778
|
};
|
|
@@ -3496,7 +6833,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
3496
6833
|
await writeState(dir, state);
|
|
3497
6834
|
const event = {
|
|
3498
6835
|
version: 1,
|
|
3499
|
-
eventID:
|
|
6836
|
+
eventID: randomUUID11(),
|
|
3500
6837
|
goalID: goal.id,
|
|
3501
6838
|
type: "goal.progress",
|
|
3502
6839
|
summary: args.summary,
|
|
@@ -3561,7 +6898,7 @@ Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
|
|
|
3561
6898
|
Working directory: ${cwd}
|
|
3562
6899
|
|
|
3563
6900
|
${failureDetails}`;
|
|
3564
|
-
attemptID =
|
|
6901
|
+
attemptID = randomUUID11();
|
|
3565
6902
|
const verificationAttempt = {
|
|
3566
6903
|
id: attemptID,
|
|
3567
6904
|
sequence: runtime.evaluatorRejectionCount,
|
|
@@ -3606,7 +6943,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
3606
6943
|
if (attemptID) {
|
|
3607
6944
|
await appendEvent(dir, {
|
|
3608
6945
|
version: 1,
|
|
3609
|
-
eventID:
|
|
6946
|
+
eventID: randomUUID11(),
|
|
3610
6947
|
goalID: goal.id,
|
|
3611
6948
|
type: "goal.completion_rejected",
|
|
3612
6949
|
attemptID,
|
|
@@ -3624,7 +6961,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
3624
6961
|
if (blocked && rejectedGoal?.blocker) {
|
|
3625
6962
|
await appendEvent(dir, {
|
|
3626
6963
|
version: 1,
|
|
3627
|
-
eventID:
|
|
6964
|
+
eventID: randomUUID11(),
|
|
3628
6965
|
goalID: goal.id,
|
|
3629
6966
|
type: "goal.blocked",
|
|
3630
6967
|
reason: rejectedGoal.blocker.reason,
|
|
@@ -3684,7 +7021,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
3684
7021
|
}
|
|
3685
7022
|
runtime.updatedAt = new Date().toISOString();
|
|
3686
7023
|
}
|
|
3687
|
-
const attemptID =
|
|
7024
|
+
const attemptID = randomUUID11();
|
|
3688
7025
|
const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
|
|
3689
7026
|
const checks = (goal.config.checks || []).map((cmd) => ({
|
|
3690
7027
|
command: cmd,
|
|
@@ -3708,7 +7045,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
3708
7045
|
await writeState(dir, state);
|
|
3709
7046
|
const event = {
|
|
3710
7047
|
version: 1,
|
|
3711
|
-
eventID:
|
|
7048
|
+
eventID: randomUUID11(),
|
|
3712
7049
|
goalID: goal.id,
|
|
3713
7050
|
type: "goal.completed",
|
|
3714
7051
|
summary: args.summary,
|
|
@@ -3768,7 +7105,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
3768
7105
|
await writeState(dir, state);
|
|
3769
7106
|
const event = {
|
|
3770
7107
|
version: 1,
|
|
3771
|
-
eventID:
|
|
7108
|
+
eventID: randomUUID11(),
|
|
3772
7109
|
goalID: goal.id,
|
|
3773
7110
|
type: "goal.blocked",
|
|
3774
7111
|
reason: args.reason,
|
|
@@ -3948,7 +7285,7 @@ function describeGoalState(status, phase) {
|
|
|
3948
7285
|
}
|
|
3949
7286
|
|
|
3950
7287
|
// src/server/owner-tools.ts
|
|
3951
|
-
import { promises as
|
|
7288
|
+
import { promises as fs5 } from "fs";
|
|
3952
7289
|
function withTimeout2(promise, ms) {
|
|
3953
7290
|
return Promise.race([
|
|
3954
7291
|
promise,
|
|
@@ -4030,9 +7367,24 @@ function ownerTools(options) {
|
|
|
4030
7367
|
const ownerID = context?.sessionID;
|
|
4031
7368
|
const goal = args.goal_id ? state.goals.find((g) => g.id === args.goal_id && g.ownerSessionID === ownerID) : state.goals.find((g) => g.ownerSessionID === ownerID && g.status !== "complete");
|
|
4032
7369
|
if (!goal) {
|
|
7370
|
+
if (!ownerID) {
|
|
7371
|
+
return {
|
|
7372
|
+
title: "No goal found",
|
|
7373
|
+
output: JSON.stringify({ ok: false, message: "No session context available, so goal ownership cannot be matched." })
|
|
7374
|
+
};
|
|
7375
|
+
}
|
|
7376
|
+
const ownedActive = state.goals.filter((g) => g.ownerSessionID === ownerID && g.status !== "complete");
|
|
7377
|
+
const ownedComplete = state.goals.filter((g) => g.ownerSessionID === ownerID && g.status === "complete").length;
|
|
7378
|
+
const othersActive = state.goals.filter((g) => g.ownerSessionID !== ownerID && g.status !== "complete").length;
|
|
7379
|
+
const message = ownedActive.length > 0 ? `No goal matched (tried ${args.goal_id ? `ID ${args.goal_id}` : "first active goal"}). This session owns ${ownedActive.length} active goal(s) \u2014 pass its goal_id explicitly.` : othersActive > 0 ? `No matching active goal for this session. ${othersActive} active goal(s) exist, all owned by other sessions \u2014 inspect from the owning session.` : ownedComplete > 0 ? "No matching active goal for this session. Your goals are all complete." : "No matching active goal for this session. No goals exist yet \u2014 create one with /goal.";
|
|
4033
7380
|
return {
|
|
4034
7381
|
title: "No goal found",
|
|
4035
|
-
output: JSON.stringify({
|
|
7382
|
+
output: JSON.stringify({
|
|
7383
|
+
ok: false,
|
|
7384
|
+
message,
|
|
7385
|
+
ownedGoals: ownedActive.map((g) => ({ id: g.id, name: g.name, status: g.status })),
|
|
7386
|
+
ownedElsewhereActive: othersActive
|
|
7387
|
+
})
|
|
4036
7388
|
};
|
|
4037
7389
|
}
|
|
4038
7390
|
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
@@ -4046,7 +7398,7 @@ function ownerTools(options) {
|
|
|
4046
7398
|
if (!dir)
|
|
4047
7399
|
return;
|
|
4048
7400
|
try {
|
|
4049
|
-
const files = await
|
|
7401
|
+
const files = await fs5.readdir(dir);
|
|
4050
7402
|
return files.length ? `${files.length} file(s): ${files.slice(0, 8).join(", ")}` : "no artifacts yet";
|
|
4051
7403
|
} catch {
|
|
4052
7404
|
return "no artifacts yet";
|
|
@@ -4068,6 +7420,8 @@ function ownerTools(options) {
|
|
|
4068
7420
|
stateSummary: describeGoalState(goal.status, runtime?.phase),
|
|
4069
7421
|
ownerSessionID: goal.ownerSessionID,
|
|
4070
7422
|
workerSessionID: goal.workerSessionID,
|
|
7423
|
+
workerTopology: goal.workerTopology,
|
|
7424
|
+
...goal.nativeParentID ? { nativeParentID: goal.nativeParentID } : {},
|
|
4071
7425
|
config: {
|
|
4072
7426
|
maxTurns: goal.config.maxTurns,
|
|
4073
7427
|
maxFailures: goal.config.maxFailures,
|
|
@@ -4516,19 +7870,361 @@ function ownerTools(options) {
|
|
|
4516
7870
|
};
|
|
4517
7871
|
}
|
|
4518
7872
|
|
|
7873
|
+
// src/server/command-tools.ts
|
|
7874
|
+
init_command_await2();
|
|
7875
|
+
import { tool as tool3 } from "@opencode-ai/plugin/tool";
|
|
7876
|
+
function ownerID(context) {
|
|
7877
|
+
const id = context?.sessionID;
|
|
7878
|
+
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
7879
|
+
}
|
|
7880
|
+
function denied() {
|
|
7881
|
+
return {
|
|
7882
|
+
title: "No session",
|
|
7883
|
+
output: JSON.stringify({
|
|
7884
|
+
ok: false,
|
|
7885
|
+
message: "No session context available. Arbitrary commands require an owning session and are never auto-allowed.",
|
|
7886
|
+
errorCode: "no_session"
|
|
7887
|
+
})
|
|
7888
|
+
};
|
|
7889
|
+
}
|
|
7890
|
+
function summarize(c) {
|
|
7891
|
+
return {
|
|
7892
|
+
id: c.id,
|
|
7893
|
+
title: c.title,
|
|
7894
|
+
argv: [c.command, ...c.args],
|
|
7895
|
+
status: c.status,
|
|
7896
|
+
exitCode: c.exitCode,
|
|
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,
|
|
7908
|
+
outputBytes: c.outputBytes,
|
|
7909
|
+
truncated: c.truncated,
|
|
7910
|
+
goalID: c.goalID,
|
|
7911
|
+
notifyOnExit: c.notifyOnExit,
|
|
7912
|
+
updatedAt: c.updatedAt
|
|
7913
|
+
};
|
|
7914
|
+
}
|
|
7915
|
+
function commandTools(options) {
|
|
7916
|
+
const { directory, commandService } = options;
|
|
7917
|
+
const capabilities = options.capabilities ?? COMMAND_HOST_CAPABILITIES;
|
|
7918
|
+
const sizeNote = capabilities.resize ? "applied live to the PTY winsize" : "stored; resize is unsupported by the pipe host";
|
|
7919
|
+
return {
|
|
7920
|
+
loopd_command_start: tool3({
|
|
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.",
|
|
7922
|
+
args: {
|
|
7923
|
+
title: tool3.schema.string().describe("Short human label for the session."),
|
|
7924
|
+
command: tool3.schema.string().describe('Executable to spawn (e.g. "bun", "python3").'),
|
|
7925
|
+
args: tool3.schema.array(tool3.schema.string()).optional().describe("Arguments for the command."),
|
|
7926
|
+
cwd: tool3.schema.string().optional().describe("Working directory. Defaults to the project root."),
|
|
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)."),
|
|
7929
|
+
cols: tool3.schema.number().optional().describe(`Requested terminal width (${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).')
|
|
7938
|
+
},
|
|
7939
|
+
execute: async (args, context) => {
|
|
7940
|
+
const owner = ownerID(context);
|
|
7941
|
+
if (!owner)
|
|
7942
|
+
return denied();
|
|
7943
|
+
try {
|
|
7944
|
+
const argv = [args.command, ...args.args ?? []];
|
|
7945
|
+
await context.ask({
|
|
7946
|
+
permission: "bash",
|
|
7947
|
+
patterns: [argv.join(" ")],
|
|
7948
|
+
always: [args.command],
|
|
7949
|
+
metadata: { command: args.command, args: args.args ?? [], cwd: args.cwd ?? directory }
|
|
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
|
+
}
|
|
7954
|
+
const session = await commandService.start(directory, {
|
|
7955
|
+
title: args.title,
|
|
7956
|
+
command: args.command,
|
|
7957
|
+
args: args.args ?? [],
|
|
7958
|
+
cwd: args.cwd,
|
|
7959
|
+
ownerSessionID: owner,
|
|
7960
|
+
goalID: args.goal_id,
|
|
7961
|
+
notifyOnExit: args.notify_on_exit,
|
|
7962
|
+
cols: args.cols,
|
|
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
|
|
7971
|
+
});
|
|
7972
|
+
return {
|
|
7973
|
+
title: "Command started",
|
|
7974
|
+
output: JSON.stringify({ ok: true, command: summarize(session), capabilities }, null, 2)
|
|
7975
|
+
};
|
|
7976
|
+
} catch (error) {
|
|
7977
|
+
return {
|
|
7978
|
+
title: "Command not started",
|
|
7979
|
+
output: JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) })
|
|
7980
|
+
};
|
|
7981
|
+
}
|
|
7982
|
+
}
|
|
7983
|
+
}),
|
|
7984
|
+
loopd_command_list: tool3({
|
|
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.",
|
|
7986
|
+
args: {},
|
|
7987
|
+
execute: async (_args, context) => {
|
|
7988
|
+
const owner = ownerID(context);
|
|
7989
|
+
if (!owner)
|
|
7990
|
+
return denied();
|
|
7991
|
+
const sessions = await commandService.list(directory, owner);
|
|
7992
|
+
return {
|
|
7993
|
+
title: `${sessions.length} command session(s)`,
|
|
7994
|
+
output: JSON.stringify({ ok: true, commands: sessions.map((c) => summarize(c)), capabilities }, null, 2)
|
|
7995
|
+
};
|
|
7996
|
+
}
|
|
7997
|
+
}),
|
|
7998
|
+
loopd_command_get: tool3({
|
|
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).",
|
|
8000
|
+
args: {
|
|
8001
|
+
command_id: tool3.schema.string().describe("Command session ID."),
|
|
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).")
|
|
8006
|
+
},
|
|
8007
|
+
execute: async (args, context) => {
|
|
8008
|
+
const owner = ownerID(context);
|
|
8009
|
+
if (!owner)
|
|
8010
|
+
return denied();
|
|
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
|
+
}
|
|
8022
|
+
if (!result) {
|
|
8023
|
+
return { title: "Not found", output: JSON.stringify({ ok: false, message: "Command not found for this session." }) };
|
|
8024
|
+
}
|
|
8025
|
+
return {
|
|
8026
|
+
title: `Command: ${result.session.title}`,
|
|
8027
|
+
output: JSON.stringify({
|
|
8028
|
+
ok: true,
|
|
8029
|
+
command: summarize(result.session),
|
|
8030
|
+
output: result.text,
|
|
8031
|
+
startByte: result.startByte,
|
|
8032
|
+
totalBytes: result.totalBytes,
|
|
8033
|
+
live: result.live,
|
|
8034
|
+
...result.pattern !== undefined ? { pattern: result.pattern, totalMatches: result.totalMatches } : {}
|
|
8035
|
+
}, null, 2)
|
|
8036
|
+
};
|
|
8037
|
+
}
|
|
8038
|
+
}),
|
|
8039
|
+
loopd_command_write: tool3({
|
|
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.",
|
|
8041
|
+
args: {
|
|
8042
|
+
command_id: tool3.schema.string().describe("Command session ID."),
|
|
8043
|
+
input: tool3.schema.string().describe("Raw text to write to stdin (include trailing newline for line-buffered programs).")
|
|
8044
|
+
},
|
|
8045
|
+
execute: async (args, context) => {
|
|
8046
|
+
const owner = ownerID(context);
|
|
8047
|
+
if (!owner)
|
|
8048
|
+
return denied();
|
|
8049
|
+
const result = await commandService.write(directory, args.command_id, owner, args.input);
|
|
8050
|
+
return { title: result.ok ? "Input sent" : "Write failed", output: JSON.stringify({ ...result, command_id: args.command_id }) };
|
|
8051
|
+
}
|
|
8052
|
+
}),
|
|
8053
|
+
loopd_command_interrupt: tool3({
|
|
8054
|
+
description: "Deliver SIGINT (Ctrl+C) to a running command. The process may trap and continue \u2014 that is correct, not a failure. Never kills unconditionally.",
|
|
8055
|
+
args: {
|
|
8056
|
+
command_id: tool3.schema.string().describe("Command session ID.")
|
|
8057
|
+
},
|
|
8058
|
+
execute: async (args, context) => {
|
|
8059
|
+
const owner = ownerID(context);
|
|
8060
|
+
if (!owner)
|
|
8061
|
+
return denied();
|
|
8062
|
+
const result = await commandService.interrupt(directory, args.command_id, owner);
|
|
8063
|
+
return { title: result.ok ? "Interrupted" : "Interrupt failed", output: JSON.stringify({ ...result, command_id: args.command_id }) };
|
|
8064
|
+
}
|
|
8065
|
+
}),
|
|
8066
|
+
loopd_command_terminate: tool3({
|
|
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).",
|
|
8068
|
+
args: {
|
|
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).")
|
|
8071
|
+
},
|
|
8072
|
+
execute: async (args, context) => {
|
|
8073
|
+
const owner = ownerID(context);
|
|
8074
|
+
if (!owner)
|
|
8075
|
+
return denied();
|
|
8076
|
+
const result = await commandService.terminate(directory, args.command_id, owner, { remove: args.remove });
|
|
8077
|
+
return { title: result.ok ? "Terminated" : "Terminate failed", output: JSON.stringify({ ...result, command_id: args.command_id }) };
|
|
8078
|
+
}
|
|
8079
|
+
}),
|
|
8080
|
+
loopd_command_remove: tool3({
|
|
8081
|
+
description: "Remove a finished command session and its output log. Refuses while running (terminate \u2260 remove).",
|
|
8082
|
+
args: {
|
|
8083
|
+
command_id: tool3.schema.string().describe("Command session ID.")
|
|
8084
|
+
},
|
|
8085
|
+
execute: async (args, context) => {
|
|
8086
|
+
const owner = ownerID(context);
|
|
8087
|
+
if (!owner)
|
|
8088
|
+
return denied();
|
|
8089
|
+
const result = await commandService.remove(directory, args.command_id, owner);
|
|
8090
|
+
return { title: result.ok ? "Removed" : "Remove failed", output: JSON.stringify({ ...result, command_id: args.command_id }) };
|
|
8091
|
+
}
|
|
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
|
+
}),
|
|
8133
|
+
loopd_command_await: tool3({
|
|
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.",
|
|
8135
|
+
args: {
|
|
8136
|
+
command_id: tool3.schema.string().describe("Command session ID to await."),
|
|
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).")
|
|
8140
|
+
},
|
|
8141
|
+
execute: async (args, context) => {
|
|
8142
|
+
const owner = ownerID(context);
|
|
8143
|
+
if (!owner)
|
|
8144
|
+
return denied();
|
|
8145
|
+
const result = await requestCommandAwait(directory, {
|
|
8146
|
+
goalID: args.goal_id,
|
|
8147
|
+
commandID: args.command_id,
|
|
8148
|
+
ownerSessionID: owner,
|
|
8149
|
+
...args.until !== undefined ? { until: args.until } : {},
|
|
8150
|
+
...args.ignore_case !== undefined ? { ignoreCase: args.ignore_case } : {}
|
|
8151
|
+
});
|
|
8152
|
+
if (result.ok && result.fired && result.active && options.goalService) {
|
|
8153
|
+
await wakeGoalForAwait(directory, options.goalService, args.goal_id).catch(() => {});
|
|
8154
|
+
}
|
|
8155
|
+
return {
|
|
8156
|
+
title: result.ok ? "Await registered" : "Await failed",
|
|
8157
|
+
output: JSON.stringify({ ...result, command_id: args.command_id, goal_id: args.goal_id })
|
|
8158
|
+
};
|
|
8159
|
+
}
|
|
8160
|
+
}),
|
|
8161
|
+
loopd_command_resize: tool3({
|
|
8162
|
+
description: capabilities.resize ? "Apply a terminal size to a running command (live PTY winsize; the requested size is also stored)." : "Request a terminal size for a command. Honestly unsupported by the pipe host: size is stored, never applied.",
|
|
8163
|
+
args: {
|
|
8164
|
+
command_id: tool3.schema.string().describe("Command session ID."),
|
|
8165
|
+
cols: tool3.schema.number().describe("Requested width."),
|
|
8166
|
+
rows: tool3.schema.number().describe("Requested height.")
|
|
8167
|
+
},
|
|
8168
|
+
execute: async (args, context) => {
|
|
8169
|
+
const owner = ownerID(context);
|
|
8170
|
+
if (!owner)
|
|
8171
|
+
return denied();
|
|
8172
|
+
const result = await commandService.resize(directory, args.command_id, owner, args.cols, args.rows);
|
|
8173
|
+
return { title: "Resize", output: JSON.stringify({ ...result, command_id: args.command_id }) };
|
|
8174
|
+
}
|
|
8175
|
+
})
|
|
8176
|
+
};
|
|
8177
|
+
}
|
|
8178
|
+
|
|
4519
8179
|
// src/server/plugin.ts
|
|
4520
8180
|
init_state_repository();
|
|
8181
|
+
// package.json
|
|
8182
|
+
var version = "1.10.4";
|
|
8183
|
+
|
|
8184
|
+
// src/server/plugin.ts
|
|
4521
8185
|
var PLUGIN_ID = "opencode-loopd.server";
|
|
4522
8186
|
var server = async ({ client, directory }, pluginOptions) => {
|
|
4523
8187
|
const defaults = parsePluginDefaults(pluginOptions);
|
|
4524
8188
|
const host = createRealHost(client, directory);
|
|
8189
|
+
logServerEvent(directory, "plugin.loaded", { pluginID: PLUGIN_ID, host: "v1", version });
|
|
4525
8190
|
return createServerHooks(directory, host, defaults);
|
|
4526
8191
|
};
|
|
4527
8192
|
function createServerHooks(directory, host, defaults) {
|
|
4528
8193
|
const goalService = createGoalService(host);
|
|
8194
|
+
const commandBroker = createCommandEventBroker();
|
|
8195
|
+
const commandHost = createCommandHost();
|
|
8196
|
+
const commandService = createCommandService(commandHost, {
|
|
8197
|
+
broker: commandBroker,
|
|
8198
|
+
onAwaitFired: async (dir, fired) => {
|
|
8199
|
+
await Promise.resolve().then(() => init_command_await2());
|
|
8200
|
+
for (const f of fired) {
|
|
8201
|
+
if (!f.active)
|
|
8202
|
+
continue;
|
|
8203
|
+
await wakeGoalForAwait(dir, goalService, f.goalID).catch((error) => logServerEvent(dir, "command.await-wake-failed", {
|
|
8204
|
+
goalID: f.goalID,
|
|
8205
|
+
commandID: f.commandID,
|
|
8206
|
+
detail: describeError(error)
|
|
8207
|
+
}));
|
|
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
|
+
}));
|
|
8221
|
+
}
|
|
8222
|
+
});
|
|
8223
|
+
const commandStream = createCommandStreamServer(directory, commandService, commandBroker);
|
|
4529
8224
|
const worker = createControlWorker({
|
|
4530
8225
|
directory,
|
|
4531
8226
|
goalService,
|
|
8227
|
+
commandService,
|
|
4532
8228
|
pollIntervalMs: 1000,
|
|
4533
8229
|
defaults
|
|
4534
8230
|
});
|
|
@@ -4552,13 +8248,26 @@ function createServerHooks(directory, host, defaults) {
|
|
|
4552
8248
|
engine.start();
|
|
4553
8249
|
worker.start();
|
|
4554
8250
|
scheduleWorker.start();
|
|
8251
|
+
commandStream.start().catch((error) => logServerEvent(directory, "command-stream.start-failed", { detail: describeError(error) }));
|
|
4555
8252
|
}
|
|
4556
8253
|
function reconcileInBackground() {
|
|
4557
8254
|
if (reconciliationStarted)
|
|
4558
8255
|
return;
|
|
4559
8256
|
reconciliationStarted = true;
|
|
4560
8257
|
logServerEvent(directory, "reconcile.started");
|
|
4561
|
-
goalService.reconcile(directory).then(() =>
|
|
8258
|
+
goalService.reconcile(directory).then(() => commandService.reconcile(directory), (error) => logServerEvent(directory, "reconcile.failed", { detail: describeError(error) })).then(async () => {
|
|
8259
|
+
await Promise.resolve().then(() => init_command_await2());
|
|
8260
|
+
const fired = await reconcileCommandAwaits(directory);
|
|
8261
|
+
for (const f of fired) {
|
|
8262
|
+
if (!f.active)
|
|
8263
|
+
continue;
|
|
8264
|
+
await wakeGoalForAwait(directory, goalService, f.goalID).catch((error) => logServerEvent(directory, "command.await-wake-failed", {
|
|
8265
|
+
goalID: f.goalID,
|
|
8266
|
+
commandID: f.commandID,
|
|
8267
|
+
detail: describeError(error)
|
|
8268
|
+
}));
|
|
8269
|
+
}
|
|
8270
|
+
}, (error) => logServerEvent(directory, "reconcile.failed", { detail: describeError(error) })).then(() => logServerEvent(directory, "reconcile.completed"), (error) => logServerEvent(directory, "reconcile.failed", { detail: describeError(error) }));
|
|
4562
8271
|
}
|
|
4563
8272
|
return {
|
|
4564
8273
|
event: async ({ event }) => {
|
|
@@ -4570,7 +8279,7 @@ function createServerHooks(directory, host, defaults) {
|
|
|
4570
8279
|
if (type?.startsWith("session."))
|
|
4571
8280
|
reconcileInBackground();
|
|
4572
8281
|
},
|
|
4573
|
-
tool: { ...goalTools(directory, goalService, undefined, defaults, host), ...ownerTools({ directory, host, goalService }) },
|
|
8282
|
+
tool: { ...goalTools(directory, goalService, undefined, defaults, host), ...ownerTools({ directory, host, goalService }), ...commandTools({ directory, commandService, capabilities: commandHost.capabilities, goalService }) },
|
|
4574
8283
|
"tool.execute.before": async (input, _output) => {
|
|
4575
8284
|
const activeWorkers = goalService.getActiveWorkers();
|
|
4576
8285
|
let matchedGoalID;
|
|
@@ -4648,9 +8357,11 @@ function createServerHooks(directory, host, defaults) {
|
|
|
4648
8357
|
}
|
|
4649
8358
|
},
|
|
4650
8359
|
dispose: async () => {
|
|
8360
|
+
await commandStream.stop().catch(() => {});
|
|
4651
8361
|
engine.stop();
|
|
4652
8362
|
await worker.stop();
|
|
4653
8363
|
scheduleWorker.stop();
|
|
8364
|
+
await commandService.dispose(directory);
|
|
4654
8365
|
}
|
|
4655
8366
|
};
|
|
4656
8367
|
}
|
|
@@ -4668,10 +8379,19 @@ var v2 = {
|
|
|
4668
8379
|
id: PLUGIN_ID,
|
|
4669
8380
|
async setup(context) {
|
|
4670
8381
|
const directory = context.location.directory;
|
|
8382
|
+
logServerEvent(directory, "plugin.loaded", { pluginID: PLUGIN_ID, host: "v2", version });
|
|
4671
8383
|
const statuses = new Map;
|
|
4672
|
-
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);
|
|
4673
8391
|
const hooks = createServerHooks(directory, host, parsePluginDefaults(context.options));
|
|
4674
8392
|
const registrations = [];
|
|
8393
|
+
if (native)
|
|
8394
|
+
registrations.push({ dispose: () => native.dispose() });
|
|
4675
8395
|
const eventController = new AbortController;
|
|
4676
8396
|
let eventTask = Promise.resolve();
|
|
4677
8397
|
let disposed = false;
|
|
@@ -4729,6 +8449,7 @@ function toV2Tool(id, definition, directory) {
|
|
|
4729
8449
|
return {
|
|
4730
8450
|
name: id,
|
|
4731
8451
|
description: definition.description,
|
|
8452
|
+
...id === "loopd_command_start" ? { permission: "bash" } : {},
|
|
4732
8453
|
input: v1Tool.schema.object(definition.args),
|
|
4733
8454
|
async execute(input, context) {
|
|
4734
8455
|
const result = await definition.execute(input, {
|
|
@@ -4774,6 +8495,17 @@ function normalizeV2Event(event) {
|
|
|
4774
8495
|
return { type: "session.compacted", properties };
|
|
4775
8496
|
if (event?.type === "session.execution.failed")
|
|
4776
8497
|
return { type: "session.error", properties };
|
|
8498
|
+
if (event?.type === "session.execution.succeeded")
|
|
8499
|
+
return { type: "session.execution.succeeded", properties };
|
|
8500
|
+
if (event?.type === "session.inbox.delivered") {
|
|
8501
|
+
return {
|
|
8502
|
+
type: "message.updated",
|
|
8503
|
+
properties: {
|
|
8504
|
+
sessionID: properties.sessionID,
|
|
8505
|
+
info: { role: "user", id: properties.inboxID }
|
|
8506
|
+
}
|
|
8507
|
+
};
|
|
8508
|
+
}
|
|
4777
8509
|
if (event?.type === "session.message.content.updated") {
|
|
4778
8510
|
return {
|
|
4779
8511
|
type: "message.part.updated",
|
|
@@ -4791,5 +8523,6 @@ var plugin_default = {
|
|
|
4791
8523
|
setup: v2.setup
|
|
4792
8524
|
};
|
|
4793
8525
|
export {
|
|
4794
|
-
plugin_default as default
|
|
8526
|
+
plugin_default as default,
|
|
8527
|
+
normalizeV2Event
|
|
4795
8528
|
};
|