@bojackduy/opencode-loopd 1.10.1 → 1.10.3
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/README.md +13 -6
- package/dist/server.js +2399 -134
- package/dist/tui.js +11436 -1917
- package/package.json +5 -1
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,16 @@ 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
|
+
}
|
|
319
329
|
return result;
|
|
320
330
|
}
|
|
321
331
|
async function writeAtomic(target, contents) {
|
|
@@ -480,18 +490,373 @@ async function peekGoalInbox(directory, goalID) {
|
|
|
480
490
|
return [];
|
|
481
491
|
}
|
|
482
492
|
}
|
|
493
|
+
function commandLogFile(directory, commandID) {
|
|
494
|
+
return path.join(loopDir(directory), "commands", `${commandID}.log`);
|
|
495
|
+
}
|
|
496
|
+
async function appendCommandLog(directory, commandID, chunk) {
|
|
497
|
+
const file = commandLogFile(directory, commandID);
|
|
498
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
499
|
+
await fs.appendFile(file, chunk, "utf8");
|
|
500
|
+
}
|
|
501
|
+
async function readCommandLog(directory, commandID, opts) {
|
|
502
|
+
const file = commandLogFile(directory, commandID);
|
|
503
|
+
try {
|
|
504
|
+
const stat = await fs.stat(file);
|
|
505
|
+
const totalBytes = stat.size;
|
|
506
|
+
const requestedStart = Math.max(0, opts?.offsetBytes ?? 0);
|
|
507
|
+
if (requestedStart >= totalBytes)
|
|
508
|
+
return { text: "", totalBytes, startByte: requestedStart };
|
|
509
|
+
const fh = await fs.open(file, "r");
|
|
510
|
+
try {
|
|
511
|
+
const want = Math.min(opts?.limitBytes ?? 64 * 1024, totalBytes - requestedStart);
|
|
512
|
+
const buf = Buffer.alloc(want);
|
|
513
|
+
await fh.read(buf, 0, want, requestedStart);
|
|
514
|
+
const { text, startByte, endByte } = decodeUtf8Window(buf, requestedStart);
|
|
515
|
+
return { text, totalBytes, startByte };
|
|
516
|
+
} finally {
|
|
517
|
+
await fh.close();
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
return { text: "", totalBytes: 0, startByte: 0 };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function decodeUtf8Window(buf, windowStart) {
|
|
524
|
+
let start = 0;
|
|
525
|
+
while (start < buf.length && (buf[start] & 192) === 128 && start < 4)
|
|
526
|
+
start++;
|
|
527
|
+
let end = buf.length;
|
|
528
|
+
let leadIndex = end;
|
|
529
|
+
while (leadIndex > start && (buf[leadIndex - 1] & 192) === 128)
|
|
530
|
+
leadIndex--;
|
|
531
|
+
if (leadIndex > start) {
|
|
532
|
+
const lead = buf[leadIndex - 1];
|
|
533
|
+
let expected = 1;
|
|
534
|
+
if ((lead & 128) === 0)
|
|
535
|
+
expected = 1;
|
|
536
|
+
else if ((lead & 224) === 192)
|
|
537
|
+
expected = 2;
|
|
538
|
+
else if ((lead & 240) === 224)
|
|
539
|
+
expected = 3;
|
|
540
|
+
else if ((lead & 248) === 240)
|
|
541
|
+
expected = 4;
|
|
542
|
+
if (end - (leadIndex - 1) < expected)
|
|
543
|
+
end = leadIndex - 1;
|
|
544
|
+
} else if (leadIndex === start && start > 0 && end > start) {
|
|
545
|
+
end = start;
|
|
546
|
+
}
|
|
547
|
+
const slice = buf.subarray(start, end);
|
|
548
|
+
return { text: slice.toString("utf8"), startByte: windowStart + start, endByte: windowStart + end };
|
|
549
|
+
}
|
|
550
|
+
async function removeCommandLog(directory, commandID) {
|
|
551
|
+
try {
|
|
552
|
+
await fs.rm(commandLogFile(directory, commandID), { force: true });
|
|
553
|
+
} catch {}
|
|
554
|
+
}
|
|
483
555
|
function delay(ms) {
|
|
484
556
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
485
557
|
}
|
|
486
|
-
var CURRENT_VERSION =
|
|
558
|
+
var CURRENT_VERSION = 8, LOCK_STALE_MS = 1e4;
|
|
487
559
|
var init_state_repository = () => {};
|
|
488
560
|
|
|
561
|
+
// src/domain/command-await.ts
|
|
562
|
+
function isTerminalCommandStatus(status) {
|
|
563
|
+
return TERMINAL_COMMAND_STATUSES.includes(status);
|
|
564
|
+
}
|
|
565
|
+
function tailLastBytes(text, maxBytes = MAX_AWAIT_TAIL_BYTES) {
|
|
566
|
+
const buf = Buffer.from(text, "utf8");
|
|
567
|
+
if (buf.length <= maxBytes)
|
|
568
|
+
return text;
|
|
569
|
+
return buf.subarray(buf.length - maxBytes).toString("utf8");
|
|
570
|
+
}
|
|
571
|
+
function formatAwaitEvidence(input) {
|
|
572
|
+
const short = input.commandID.slice(0, 8);
|
|
573
|
+
const header = `[command "${input.title}" (${input.argv.join(" ") || input.title}) ${input.status}` + ` ${short}... exitCode=${input.exitCode ?? "unknown"} signal=${input.signal ?? "none"}]`;
|
|
574
|
+
const tail = tailLastBytes(input.tail);
|
|
575
|
+
return tail ? `${header}
|
|
576
|
+
${tail}` : header;
|
|
577
|
+
}
|
|
578
|
+
var TERMINAL_COMMAND_STATUSES, MAX_AWAIT_TAIL_BYTES;
|
|
579
|
+
var init_command_await = __esm(() => {
|
|
580
|
+
TERMINAL_COMMAND_STATUSES = [
|
|
581
|
+
"exited",
|
|
582
|
+
"terminated",
|
|
583
|
+
"missing"
|
|
584
|
+
];
|
|
585
|
+
MAX_AWAIT_TAIL_BYTES = 4 * 1024;
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// src/application/command-await.ts
|
|
589
|
+
import { randomUUID } from "crypto";
|
|
590
|
+
function ledgerEvent(base) {
|
|
591
|
+
return {
|
|
592
|
+
version: 1,
|
|
593
|
+
eventID: randomUUID(),
|
|
594
|
+
timestamp: new Date().toISOString(),
|
|
595
|
+
revision: 0,
|
|
596
|
+
...base
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
async function requestCommandAwait(directory, input) {
|
|
600
|
+
const snapshot = await readState(directory);
|
|
601
|
+
const goal = snapshot.goals.find((g) => g.id === input.goalID);
|
|
602
|
+
if (!goal)
|
|
603
|
+
return { ok: false, message: "Goal not found." };
|
|
604
|
+
const command = (snapshot.commands ?? []).find((c) => c.id === input.commandID);
|
|
605
|
+
if (!command)
|
|
606
|
+
return { ok: false, message: "Command not found." };
|
|
607
|
+
if (input.ownerSessionID !== goal.ownerSessionID || input.ownerSessionID !== command.ownerSessionID) {
|
|
608
|
+
return { ok: false, message: "Owner mismatch: the await requester must own both the goal and the command." };
|
|
609
|
+
}
|
|
610
|
+
if (goal.ownerSessionID !== command.ownerSessionID) {
|
|
611
|
+
return { ok: false, message: "Owner mismatch: goal and command are owned by different sessions." };
|
|
612
|
+
}
|
|
613
|
+
const existing = (snapshot.commandAwaits ?? []).some((a) => a.goalID === input.goalID && a.commandID === input.commandID);
|
|
614
|
+
if (existing) {
|
|
615
|
+
return { ok: true, message: `Already awaiting "${command.title}" for goal "${goal.name}".` };
|
|
616
|
+
}
|
|
617
|
+
if (isTerminalCommandStatus(command.status)) {
|
|
618
|
+
await mutateState(directory, `cmd.await-request:${input.goalID}:${input.commandID}`, async (s) => {
|
|
619
|
+
s.commandAwaits = [...s.commandAwaits ?? [], {
|
|
620
|
+
goalID: input.goalID,
|
|
621
|
+
commandID: input.commandID,
|
|
622
|
+
ownerSessionID: input.ownerSessionID,
|
|
623
|
+
createdAt: new Date().toISOString()
|
|
624
|
+
}];
|
|
625
|
+
return s;
|
|
626
|
+
});
|
|
627
|
+
const fired = await fireCommandAwaits(directory, input.commandID);
|
|
628
|
+
const mine = fired.find((f) => f.goalID === input.goalID);
|
|
629
|
+
return {
|
|
630
|
+
ok: true,
|
|
631
|
+
message: `Command "${command.title}" already ${command.status}; wake-up delivered to goal "${goal.name}".`,
|
|
632
|
+
fired: true,
|
|
633
|
+
active: mine?.active ?? goal.status === "active"
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
let becameTerminal = false;
|
|
637
|
+
await mutateState(directory, `cmd.await-request:${input.goalID}:${input.commandID}`, async (s) => {
|
|
638
|
+
const dup = (s.commandAwaits ?? []).some((a) => a.goalID === input.goalID && a.commandID === input.commandID);
|
|
639
|
+
if (!dup) {
|
|
640
|
+
s.commandAwaits = [...s.commandAwaits ?? [], {
|
|
641
|
+
goalID: input.goalID,
|
|
642
|
+
commandID: input.commandID,
|
|
643
|
+
ownerSessionID: input.ownerSessionID,
|
|
644
|
+
createdAt: new Date().toISOString()
|
|
645
|
+
}];
|
|
646
|
+
}
|
|
647
|
+
const current = (s.commands ?? []).find((c) => c.id === input.commandID);
|
|
648
|
+
if (current && isTerminalCommandStatus(current.status)) {
|
|
649
|
+
becameTerminal = true;
|
|
650
|
+
}
|
|
651
|
+
return s;
|
|
652
|
+
});
|
|
653
|
+
if (becameTerminal) {
|
|
654
|
+
const fired = await fireCommandAwaits(directory, input.commandID);
|
|
655
|
+
const mine = fired.find((f) => f.goalID === input.goalID);
|
|
656
|
+
return {
|
|
657
|
+
ok: true,
|
|
658
|
+
message: `Command "${command.title}" exited while registering; wake-up delivered to goal "${goal.name}".`,
|
|
659
|
+
fired: true,
|
|
660
|
+
active: mine?.active ?? goal.status === "active"
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
await appendEvent(directory, ledgerEvent({
|
|
664
|
+
goalID: input.goalID,
|
|
665
|
+
commandID: input.commandID,
|
|
666
|
+
type: "command.await-requested"
|
|
667
|
+
})).catch(() => {});
|
|
668
|
+
return { ok: true, message: `Goal "${goal.name}" now awaits "${command.title}" (fires once on exit).` };
|
|
669
|
+
}
|
|
670
|
+
async function readBoundedTail(directory, commandID) {
|
|
671
|
+
try {
|
|
672
|
+
const probe = await readCommandLog(directory, commandID, { offsetBytes: 0, limitBytes: 0 });
|
|
673
|
+
if (probe.totalBytes <= MAX_AWAIT_TAIL_BYTES) {
|
|
674
|
+
const full = await readCommandLog(directory, commandID, {
|
|
675
|
+
offsetBytes: 0,
|
|
676
|
+
limitBytes: MAX_AWAIT_TAIL_BYTES
|
|
677
|
+
});
|
|
678
|
+
return full.text;
|
|
679
|
+
}
|
|
680
|
+
const tail = await readCommandLog(directory, commandID, {
|
|
681
|
+
offsetBytes: Math.max(0, probe.totalBytes - MAX_AWAIT_TAIL_BYTES),
|
|
682
|
+
limitBytes: MAX_AWAIT_TAIL_BYTES
|
|
683
|
+
});
|
|
684
|
+
return tail.text;
|
|
685
|
+
} catch {
|
|
686
|
+
return "";
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
async function fireCommandAwaits(directory, commandID) {
|
|
690
|
+
let consumed = [];
|
|
691
|
+
let command;
|
|
692
|
+
let commandGone = false;
|
|
693
|
+
await mutateState(directory, `cmd.await-consume:${commandID}`, async (s) => {
|
|
694
|
+
const outstanding = (s.commandAwaits ?? []).filter((a) => a.commandID === commandID);
|
|
695
|
+
if (outstanding.length === 0)
|
|
696
|
+
return s;
|
|
697
|
+
const current = (s.commands ?? []).find((c) => c.id === commandID);
|
|
698
|
+
if (!current) {
|
|
699
|
+
commandGone = true;
|
|
700
|
+
consumed = outstanding;
|
|
701
|
+
s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.commandID !== commandID);
|
|
702
|
+
return s;
|
|
703
|
+
}
|
|
704
|
+
if (!isTerminalCommandStatus(current.status))
|
|
705
|
+
return s;
|
|
706
|
+
command = current;
|
|
707
|
+
consumed = outstanding;
|
|
708
|
+
s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.commandID !== commandID);
|
|
709
|
+
return s;
|
|
710
|
+
});
|
|
711
|
+
if (consumed.length === 0)
|
|
712
|
+
return [];
|
|
713
|
+
if (commandGone || !command) {
|
|
714
|
+
for (const a of consumed) {
|
|
715
|
+
await appendEvent(directory, ledgerEvent({
|
|
716
|
+
goalID: a.goalID,
|
|
717
|
+
commandID,
|
|
718
|
+
type: "command.await-discarded",
|
|
719
|
+
reason: "command record gone"
|
|
720
|
+
})).catch(() => {});
|
|
721
|
+
}
|
|
722
|
+
return [];
|
|
723
|
+
}
|
|
724
|
+
const tail = await readBoundedTail(directory, commandID);
|
|
725
|
+
const fired = [];
|
|
726
|
+
for (const a of consumed) {
|
|
727
|
+
const fresh = await readState(directory);
|
|
728
|
+
const goal = fresh.goals.find((g) => g.id === a.goalID);
|
|
729
|
+
if (!goal) {
|
|
730
|
+
await appendEvent(directory, ledgerEvent({
|
|
731
|
+
goalID: a.goalID,
|
|
732
|
+
commandID,
|
|
733
|
+
type: "command.await-discarded",
|
|
734
|
+
reason: "goal gone"
|
|
735
|
+
})).catch(() => {});
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
const evidence = formatAwaitEvidence({
|
|
739
|
+
title: command.title,
|
|
740
|
+
argv: [command.command, ...command.args],
|
|
741
|
+
commandID: command.id,
|
|
742
|
+
status: command.status,
|
|
743
|
+
exitCode: command.exitCode,
|
|
744
|
+
signal: command.signal,
|
|
745
|
+
tail
|
|
746
|
+
});
|
|
747
|
+
await appendGoalInbox(directory, a.goalID, "worker", evidence);
|
|
748
|
+
await appendEvent(directory, ledgerEvent({
|
|
749
|
+
goalID: a.goalID,
|
|
750
|
+
commandID,
|
|
751
|
+
type: "command.await-fired",
|
|
752
|
+
status: command.status,
|
|
753
|
+
exitCode: command.exitCode,
|
|
754
|
+
signal: command.signal
|
|
755
|
+
})).catch(() => {});
|
|
756
|
+
fired.push({ goalID: a.goalID, commandID, active: goal.status === "active" });
|
|
757
|
+
}
|
|
758
|
+
return fired;
|
|
759
|
+
}
|
|
760
|
+
async function cancelAwaitsForGoal(directory, goalID, reason) {
|
|
761
|
+
const snapshot = await readState(directory);
|
|
762
|
+
const doomed = (snapshot.commandAwaits ?? []).filter((a) => a.goalID === goalID);
|
|
763
|
+
if (doomed.length === 0)
|
|
764
|
+
return 0;
|
|
765
|
+
await mutateState(directory, `cmd.await-cancel-goal:${goalID}`, async (s) => {
|
|
766
|
+
s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.goalID !== goalID);
|
|
767
|
+
return s;
|
|
768
|
+
});
|
|
769
|
+
await appendEvent(directory, ledgerEvent({
|
|
770
|
+
goalID,
|
|
771
|
+
type: "command.await-cancelled",
|
|
772
|
+
reason,
|
|
773
|
+
count: doomed.length
|
|
774
|
+
})).catch(() => {});
|
|
775
|
+
return doomed.length;
|
|
776
|
+
}
|
|
777
|
+
async function clearAwaitsForCommand(directory, commandID, reason) {
|
|
778
|
+
const snapshot = await readState(directory);
|
|
779
|
+
const doomed = (snapshot.commandAwaits ?? []).filter((a) => a.commandID === commandID);
|
|
780
|
+
if (doomed.length === 0)
|
|
781
|
+
return 0;
|
|
782
|
+
await mutateState(directory, `cmd.await-clear-command:${commandID}`, async (s) => {
|
|
783
|
+
s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.commandID !== commandID);
|
|
784
|
+
return s;
|
|
785
|
+
});
|
|
786
|
+
for (const a of doomed) {
|
|
787
|
+
await appendEvent(directory, ledgerEvent({
|
|
788
|
+
goalID: a.goalID,
|
|
789
|
+
commandID,
|
|
790
|
+
type: "command.await-cancelled",
|
|
791
|
+
reason
|
|
792
|
+
})).catch(() => {});
|
|
793
|
+
}
|
|
794
|
+
return doomed.length;
|
|
795
|
+
}
|
|
796
|
+
async function reconcileCommandAwaits(directory) {
|
|
797
|
+
const snapshot = await readState(directory);
|
|
798
|
+
const outstanding = [...snapshot.commandAwaits ?? []];
|
|
799
|
+
const fired = [];
|
|
800
|
+
for (const a of outstanding) {
|
|
801
|
+
const fresh = await readState(directory);
|
|
802
|
+
const still = (fresh.commandAwaits ?? []).some((x) => x.goalID === a.goalID && x.commandID === a.commandID);
|
|
803
|
+
if (!still)
|
|
804
|
+
continue;
|
|
805
|
+
const command = (fresh.commands ?? []).find((c) => c.id === a.commandID);
|
|
806
|
+
if (!command) {
|
|
807
|
+
await mutateState(directory, `cmd.await-reconcile-discard:${a.commandID}`, async (s) => {
|
|
808
|
+
s.commandAwaits = (s.commandAwaits ?? []).filter((x) => !(x.goalID === a.goalID && x.commandID === a.commandID));
|
|
809
|
+
return s;
|
|
810
|
+
});
|
|
811
|
+
await appendEvent(directory, ledgerEvent({
|
|
812
|
+
goalID: a.goalID,
|
|
813
|
+
commandID: a.commandID,
|
|
814
|
+
type: "command.await-discarded",
|
|
815
|
+
reason: "command record gone at reconcile"
|
|
816
|
+
})).catch(() => {});
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
if (!isTerminalCommandStatus(command.status))
|
|
820
|
+
continue;
|
|
821
|
+
const now = await fireCommandAwaits(directory, a.commandID);
|
|
822
|
+
fired.push(...now.filter((f) => f.goalID === a.goalID));
|
|
823
|
+
}
|
|
824
|
+
return fired;
|
|
825
|
+
}
|
|
826
|
+
async function wakeGoalForAwait(directory, continuation, goalID) {
|
|
827
|
+
const snapshot = await readState(directory);
|
|
828
|
+
const goal = snapshot.goals.find((g) => g.id === goalID);
|
|
829
|
+
if (!goal)
|
|
830
|
+
return { woke: false, message: "Goal gone; wake skipped." };
|
|
831
|
+
if (goal.status !== "active") {
|
|
832
|
+
return { woke: false, message: `Goal is ${goal.status}; evidence waits in pendingInbox.` };
|
|
833
|
+
}
|
|
834
|
+
await mutateState(directory, `cmd.await-wake:${goalID}`, async (s) => {
|
|
835
|
+
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
836
|
+
if (!rt)
|
|
837
|
+
return s;
|
|
838
|
+
rt.phase = "idle";
|
|
839
|
+
rt.activeRunID = undefined;
|
|
840
|
+
rt.idleCandidateAt = undefined;
|
|
841
|
+
rt.activePromptMessageID = undefined;
|
|
842
|
+
rt.activeToolCallIDs = [];
|
|
843
|
+
rt.updatedAt = new Date().toISOString();
|
|
844
|
+
return s;
|
|
845
|
+
});
|
|
846
|
+
await continuation.continueTurn(directory, goalID, { force: true });
|
|
847
|
+
return { woke: true, message: `Woke goal "${goal.name}" for command exit.` };
|
|
848
|
+
}
|
|
849
|
+
var init_command_await2 = __esm(() => {
|
|
850
|
+
init_command_await();
|
|
851
|
+
init_state_repository();
|
|
852
|
+
});
|
|
853
|
+
|
|
489
854
|
// src/server/plugin.ts
|
|
490
855
|
import { tool as v1Tool } from "@opencode-ai/plugin/tool";
|
|
491
856
|
|
|
492
857
|
// src/application/control-worker.ts
|
|
493
858
|
init_state_repository();
|
|
494
|
-
import { randomUUID } from "crypto";
|
|
859
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
495
860
|
|
|
496
861
|
// src/application/goal-policy.ts
|
|
497
862
|
function resolveGoalCreationConfig(input) {
|
|
@@ -600,6 +965,7 @@ function errorReplacer(_key, value) {
|
|
|
600
965
|
}
|
|
601
966
|
|
|
602
967
|
// src/application/control-worker.ts
|
|
968
|
+
init_command_await2();
|
|
603
969
|
var MAX_LEDGER_SIZE = 100;
|
|
604
970
|
var RESPONSE_CLEANUP_AGE_MS = 60 * 60 * 1000;
|
|
605
971
|
function createControlWorker(options) {
|
|
@@ -863,7 +1229,7 @@ function createControlWorker(options) {
|
|
|
863
1229
|
await writeState(directory, state);
|
|
864
1230
|
await appendEvent(directory, {
|
|
865
1231
|
version: 1,
|
|
866
|
-
eventID:
|
|
1232
|
+
eventID: randomUUID2(),
|
|
867
1233
|
goalID: goal.id,
|
|
868
1234
|
type: "goal.completed",
|
|
869
1235
|
summary: goal.completionEvidence.summary,
|
|
@@ -904,7 +1270,7 @@ function createControlWorker(options) {
|
|
|
904
1270
|
await writeState(directory, state);
|
|
905
1271
|
await appendEvent(directory, {
|
|
906
1272
|
version: 1,
|
|
907
|
-
eventID:
|
|
1273
|
+
eventID: randomUUID2(),
|
|
908
1274
|
goalID: goal.id,
|
|
909
1275
|
type: "goal.blocked",
|
|
910
1276
|
reason: goal.blocker.reason,
|
|
@@ -915,6 +1281,103 @@ function createControlWorker(options) {
|
|
|
915
1281
|
response = { ...base, message: `goal "${goal.name}" blocked`, stateRevision: state.revision };
|
|
916
1282
|
break;
|
|
917
1283
|
}
|
|
1284
|
+
case "cmd_await": {
|
|
1285
|
+
const args = request.args ?? {};
|
|
1286
|
+
const ownerSessionID = typeof args.ownerSessionID === "string" ? args.ownerSessionID : "";
|
|
1287
|
+
if (!ownerSessionID || ownerSessionID === "main") {
|
|
1288
|
+
response = { ...base, ok: false, message: "ownerSessionID is required for command operations", errorCode: "no_session" };
|
|
1289
|
+
break;
|
|
1290
|
+
}
|
|
1291
|
+
const goalID = typeof args.goalID === "string" ? args.goalID : "";
|
|
1292
|
+
const commandID = typeof args.commandID === "string" && args.commandID ? String(args.commandID) : String(request.goalID || "");
|
|
1293
|
+
if (!goalID || !commandID) {
|
|
1294
|
+
response = { ...base, ok: false, message: "goalID and commandID are required", errorCode: "bad_request" };
|
|
1295
|
+
break;
|
|
1296
|
+
}
|
|
1297
|
+
const result = await requestCommandAwait(directory, { goalID, commandID, ownerSessionID });
|
|
1298
|
+
if (result.ok && result.fired && result.active) {
|
|
1299
|
+
await wakeGoalForAwait(directory, goalSvc, goalID).catch(() => {});
|
|
1300
|
+
}
|
|
1301
|
+
const state = await readState(directory);
|
|
1302
|
+
response = {
|
|
1303
|
+
...base,
|
|
1304
|
+
ok: result.ok,
|
|
1305
|
+
message: result.message,
|
|
1306
|
+
stateRevision: state.revision,
|
|
1307
|
+
errorCode: result.ok ? undefined : "await_failed"
|
|
1308
|
+
};
|
|
1309
|
+
break;
|
|
1310
|
+
}
|
|
1311
|
+
case "cmd_start":
|
|
1312
|
+
case "cmd_write":
|
|
1313
|
+
case "cmd_interrupt":
|
|
1314
|
+
case "cmd_terminate":
|
|
1315
|
+
case "cmd_remove":
|
|
1316
|
+
case "cmd_resize": {
|
|
1317
|
+
const cmdSvc = options.commandService;
|
|
1318
|
+
if (!cmdSvc) {
|
|
1319
|
+
response = {
|
|
1320
|
+
requestID: request.requestID,
|
|
1321
|
+
ok: false,
|
|
1322
|
+
message: `command "${request.command}" unavailable (command service not initialized)`,
|
|
1323
|
+
errorCode: "unavailable",
|
|
1324
|
+
completedAt: new Date().toISOString()
|
|
1325
|
+
};
|
|
1326
|
+
break;
|
|
1327
|
+
}
|
|
1328
|
+
const args = request.args ?? {};
|
|
1329
|
+
const ownerSessionID = typeof args.ownerSessionID === "string" ? args.ownerSessionID : "";
|
|
1330
|
+
if (!ownerSessionID || ownerSessionID === "main") {
|
|
1331
|
+
response = { ...base, ok: false, message: "ownerSessionID is required for command operations", errorCode: "no_session" };
|
|
1332
|
+
break;
|
|
1333
|
+
}
|
|
1334
|
+
try {
|
|
1335
|
+
if (request.command === "cmd_start") {
|
|
1336
|
+
const session = await cmdSvc.start(directory, {
|
|
1337
|
+
title: String(args.title || "command"),
|
|
1338
|
+
command: String(args.command || ""),
|
|
1339
|
+
args: Array.isArray(args.cmdArgs) ? args.cmdArgs : [],
|
|
1340
|
+
cwd: typeof args.cwd === "string" ? args.cwd : undefined,
|
|
1341
|
+
ownerSessionID,
|
|
1342
|
+
goalID: typeof args.goalID === "string" ? args.goalID : undefined,
|
|
1343
|
+
cols: typeof args.cols === "number" ? args.cols : undefined,
|
|
1344
|
+
rows: typeof args.rows === "number" ? args.rows : undefined
|
|
1345
|
+
});
|
|
1346
|
+
const state = await readState(directory);
|
|
1347
|
+
response = { ...base, message: `command "${session.title}" started (${session.id.slice(0, 8)}...)`, stateRevision: state.revision };
|
|
1348
|
+
} else {
|
|
1349
|
+
const id = String(args.commandID || request.goalID || "");
|
|
1350
|
+
if (!id) {
|
|
1351
|
+
response = { ...base, ok: false, message: "commandID is required", errorCode: "bad_request" };
|
|
1352
|
+
break;
|
|
1353
|
+
}
|
|
1354
|
+
if (request.command === "cmd_write") {
|
|
1355
|
+
const r = await cmdSvc.write(directory, id, ownerSessionID, String(args.input ?? ""));
|
|
1356
|
+
const state = await readState(directory);
|
|
1357
|
+
response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
|
|
1358
|
+
} else if (request.command === "cmd_interrupt") {
|
|
1359
|
+
const r = await cmdSvc.interrupt(directory, id, ownerSessionID);
|
|
1360
|
+
const state = await readState(directory);
|
|
1361
|
+
response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
|
|
1362
|
+
} else if (request.command === "cmd_terminate") {
|
|
1363
|
+
const r = await cmdSvc.terminate(directory, id, ownerSessionID);
|
|
1364
|
+
const state = await readState(directory);
|
|
1365
|
+
response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
|
|
1366
|
+
} else if (request.command === "cmd_remove") {
|
|
1367
|
+
const r = await cmdSvc.remove(directory, id, ownerSessionID);
|
|
1368
|
+
const state = await readState(directory);
|
|
1369
|
+
response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
|
|
1370
|
+
} else {
|
|
1371
|
+
const r = await cmdSvc.resize(directory, id, ownerSessionID, Number(args.cols), Number(args.rows));
|
|
1372
|
+
const state = await readState(directory);
|
|
1373
|
+
response = { ...base, ok: r.ok, message: r.message, stateRevision: state.revision };
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
} catch (error) {
|
|
1377
|
+
response = { ...base, ok: false, message: error instanceof Error ? error.message : String(error), errorCode: "command_failed" };
|
|
1378
|
+
}
|
|
1379
|
+
break;
|
|
1380
|
+
}
|
|
918
1381
|
default: {
|
|
919
1382
|
response = {
|
|
920
1383
|
requestID: request.requestID,
|
|
@@ -929,20 +1392,23 @@ function createControlWorker(options) {
|
|
|
929
1392
|
return response;
|
|
930
1393
|
}
|
|
931
1394
|
async function recordInLedger(directory, request) {
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1395
|
+
await mutateState(directory, `control.ledger:${request.requestID}`, async (state) => {
|
|
1396
|
+
if (!state.commandLedger)
|
|
1397
|
+
state.commandLedger = [];
|
|
1398
|
+
if (state.commandLedger.some((entry) => entry.requestID === request.requestID))
|
|
1399
|
+
return state;
|
|
1400
|
+
state.commandLedger.push({
|
|
1401
|
+
requestID: request.requestID,
|
|
1402
|
+
command: request.command,
|
|
1403
|
+
goalID: request.goalID,
|
|
1404
|
+
acceptedAt: request.requestedAt,
|
|
1405
|
+
completedAt: new Date().toISOString()
|
|
1406
|
+
});
|
|
1407
|
+
if (state.commandLedger.length > MAX_LEDGER_SIZE) {
|
|
1408
|
+
state.commandLedger = state.commandLedger.slice(-MAX_LEDGER_SIZE);
|
|
1409
|
+
}
|
|
1410
|
+
return state;
|
|
941
1411
|
});
|
|
942
|
-
if (state.commandLedger.length > MAX_LEDGER_SIZE) {
|
|
943
|
-
state.commandLedger = state.commandLedger.slice(-MAX_LEDGER_SIZE);
|
|
944
|
-
}
|
|
945
|
-
await writeState(directory, state);
|
|
946
1412
|
}
|
|
947
1413
|
return { start, stop: async () => {
|
|
948
1414
|
await stop();
|
|
@@ -951,7 +1417,7 @@ function createControlWorker(options) {
|
|
|
951
1417
|
|
|
952
1418
|
// src/application/loop-engine.ts
|
|
953
1419
|
init_state_repository();
|
|
954
|
-
import { randomUUID as
|
|
1420
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
955
1421
|
|
|
956
1422
|
// src/domain/goal.ts
|
|
957
1423
|
var MODEL_TRANSITIONS = {
|
|
@@ -996,6 +1462,7 @@ var HANDLED_EVENT_TYPES = new Set([
|
|
|
996
1462
|
"session.status",
|
|
997
1463
|
"session.error",
|
|
998
1464
|
"session.compacted",
|
|
1465
|
+
"session.execution.succeeded",
|
|
999
1466
|
"message.updated",
|
|
1000
1467
|
"message.part.updated"
|
|
1001
1468
|
]);
|
|
@@ -1100,6 +1567,8 @@ function createLoopEngine(options) {
|
|
|
1100
1567
|
case "message.updated":
|
|
1101
1568
|
case "message.part.updated":
|
|
1102
1569
|
return await handleMessageActivity(goal, event);
|
|
1570
|
+
case "session.execution.succeeded":
|
|
1571
|
+
return await handleExecutionSucceeded(goal);
|
|
1103
1572
|
default:
|
|
1104
1573
|
return false;
|
|
1105
1574
|
}
|
|
@@ -1140,6 +1609,19 @@ function createLoopEngine(options) {
|
|
|
1140
1609
|
});
|
|
1141
1610
|
return matched;
|
|
1142
1611
|
}
|
|
1612
|
+
async function handleExecutionSucceeded(goal) {
|
|
1613
|
+
let matched = false;
|
|
1614
|
+
await mutateState(directory, `execution-succeeded:${goal.id}`, async (s) => {
|
|
1615
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1616
|
+
if (!rt || rt.phase !== "running" || !rt.activePromptMessageID)
|
|
1617
|
+
return s;
|
|
1618
|
+
Object.assign(rt, recordActivity(rt));
|
|
1619
|
+
rt.activeAssistantCompletedAt = new Date().toISOString();
|
|
1620
|
+
matched = true;
|
|
1621
|
+
return s;
|
|
1622
|
+
});
|
|
1623
|
+
return matched;
|
|
1624
|
+
}
|
|
1143
1625
|
async function handleSessionIdle(state, goal) {
|
|
1144
1626
|
const goalID = goal.id;
|
|
1145
1627
|
if (inflightContinuations.has(goalID))
|
|
@@ -1215,7 +1697,7 @@ function createLoopEngine(options) {
|
|
|
1215
1697
|
if (newlyStamped) {
|
|
1216
1698
|
await appendEvent(directory, {
|
|
1217
1699
|
version: 1,
|
|
1218
|
-
eventID:
|
|
1700
|
+
eventID: randomUUID3(),
|
|
1219
1701
|
goalID,
|
|
1220
1702
|
type: "idle.confirm-failed",
|
|
1221
1703
|
reason: failureReason,
|
|
@@ -1260,7 +1742,7 @@ function createLoopEngine(options) {
|
|
|
1260
1742
|
if (completedRunID) {
|
|
1261
1743
|
await appendEvent(directory, {
|
|
1262
1744
|
version: 1,
|
|
1263
|
-
eventID:
|
|
1745
|
+
eventID: randomUUID3(),
|
|
1264
1746
|
goalID,
|
|
1265
1747
|
type: "run.completed",
|
|
1266
1748
|
runID: completedRunID,
|
|
@@ -1325,7 +1807,7 @@ function createLoopEngine(options) {
|
|
|
1325
1807
|
});
|
|
1326
1808
|
await appendEvent(directory, {
|
|
1327
1809
|
version: 1,
|
|
1328
|
-
eventID:
|
|
1810
|
+
eventID: randomUUID3(),
|
|
1329
1811
|
goalID,
|
|
1330
1812
|
type: "goal.blocked",
|
|
1331
1813
|
reason: limitResult.reason + " (force-finish ignored)",
|
|
@@ -1349,7 +1831,7 @@ function createLoopEngine(options) {
|
|
|
1349
1831
|
});
|
|
1350
1832
|
await appendEvent(directory, {
|
|
1351
1833
|
version: 1,
|
|
1352
|
-
eventID:
|
|
1834
|
+
eventID: randomUUID3(),
|
|
1353
1835
|
goalID,
|
|
1354
1836
|
type: "goal.status_changed",
|
|
1355
1837
|
from: "active",
|
|
@@ -1451,7 +1933,7 @@ function createLoopEngine(options) {
|
|
|
1451
1933
|
});
|
|
1452
1934
|
await appendEvent(directory, {
|
|
1453
1935
|
version: 1,
|
|
1454
|
-
eventID:
|
|
1936
|
+
eventID: randomUUID3(),
|
|
1455
1937
|
goalID: goal.id,
|
|
1456
1938
|
type: "run.failed",
|
|
1457
1939
|
runID: runtime.activeRunID || "unknown",
|
|
@@ -1464,7 +1946,7 @@ function createLoopEngine(options) {
|
|
|
1464
1946
|
if (updatedGoal?.status === "blocked") {
|
|
1465
1947
|
await appendEvent(directory, {
|
|
1466
1948
|
version: 1,
|
|
1467
|
-
eventID:
|
|
1949
|
+
eventID: randomUUID3(),
|
|
1468
1950
|
goalID: goal.id,
|
|
1469
1951
|
type: "goal.blocked",
|
|
1470
1952
|
reason: `Failed ${runtime.consecutiveFailures + 1} times`,
|
|
@@ -1492,7 +1974,7 @@ function createLoopEngine(options) {
|
|
|
1492
1974
|
});
|
|
1493
1975
|
await appendEvent(directory, {
|
|
1494
1976
|
version: 1,
|
|
1495
|
-
eventID:
|
|
1977
|
+
eventID: randomUUID3(),
|
|
1496
1978
|
goalID: goal.id,
|
|
1497
1979
|
type: "compaction.completed",
|
|
1498
1980
|
timestamp: new Date().toISOString(),
|
|
@@ -1562,7 +2044,7 @@ function createLoopEngine(options) {
|
|
|
1562
2044
|
});
|
|
1563
2045
|
await appendEvent(directory, {
|
|
1564
2046
|
version: 1,
|
|
1565
|
-
eventID:
|
|
2047
|
+
eventID: randomUUID3(),
|
|
1566
2048
|
goalID: goal.id,
|
|
1567
2049
|
type: "compaction.started",
|
|
1568
2050
|
timestamp: new Date().toISOString(),
|
|
@@ -1620,7 +2102,7 @@ function createLoopEngine(options) {
|
|
|
1620
2102
|
return false;
|
|
1621
2103
|
await appendEvent(directory, {
|
|
1622
2104
|
version: 1,
|
|
1623
|
-
eventID:
|
|
2105
|
+
eventID: randomUUID3(),
|
|
1624
2106
|
goalID: goal.id,
|
|
1625
2107
|
type: "goal.status_changed",
|
|
1626
2108
|
from: "active",
|
|
@@ -1796,7 +2278,7 @@ function createLoopEngine(options) {
|
|
|
1796
2278
|
if (recovered) {
|
|
1797
2279
|
await appendEvent(directory, {
|
|
1798
2280
|
version: 1,
|
|
1799
|
-
eventID:
|
|
2281
|
+
eventID: randomUUID3(),
|
|
1800
2282
|
goalID: goal.id,
|
|
1801
2283
|
type: "run.recovered",
|
|
1802
2284
|
runID: stalledRunID ?? "unknown",
|
|
@@ -1830,7 +2312,7 @@ function createLoopEngine(options) {
|
|
|
1830
2312
|
const stuckSeconds = Math.floor((Date.now() - Date.parse(failedAt)) / 1000);
|
|
1831
2313
|
await appendEvent(directory, {
|
|
1832
2314
|
version: 1,
|
|
1833
|
-
eventID:
|
|
2315
|
+
eventID: randomUUID3(),
|
|
1834
2316
|
goalID: goal.id,
|
|
1835
2317
|
type: "run.stuck",
|
|
1836
2318
|
runID: runtime.activeRunID ?? "unknown",
|
|
@@ -1870,7 +2352,7 @@ function createLoopEngine(options) {
|
|
|
1870
2352
|
});
|
|
1871
2353
|
await appendEvent(directory, {
|
|
1872
2354
|
version: 1,
|
|
1873
|
-
eventID:
|
|
2355
|
+
eventID: randomUUID3(),
|
|
1874
2356
|
goalID: goal.id,
|
|
1875
2357
|
type: "run.stuck",
|
|
1876
2358
|
runID: runtime.activeRunID,
|
|
@@ -1893,12 +2375,14 @@ function createLoopEngine(options) {
|
|
|
1893
2375
|
}
|
|
1894
2376
|
|
|
1895
2377
|
// src/application/goal-service.ts
|
|
1896
|
-
import { randomUUID as
|
|
2378
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
1897
2379
|
init_state_repository();
|
|
2380
|
+
init_command_await2();
|
|
1898
2381
|
import * as path2 from "path";
|
|
1899
2382
|
import { promises as fs2 } from "fs";
|
|
1900
2383
|
|
|
1901
2384
|
// src/server/host-adapter.ts
|
|
2385
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
1902
2386
|
function parseModelRef(value) {
|
|
1903
2387
|
if (value === undefined)
|
|
1904
2388
|
return;
|
|
@@ -1916,6 +2400,12 @@ function parseModelRef(value) {
|
|
|
1916
2400
|
}
|
|
1917
2401
|
return { providerID, modelID };
|
|
1918
2402
|
}
|
|
2403
|
+
function newPromptMessageID() {
|
|
2404
|
+
return `msg_${randomUUID4()}`;
|
|
2405
|
+
}
|
|
2406
|
+
function isV2PromptMessageID(messageID) {
|
|
2407
|
+
return messageID.startsWith("msg_");
|
|
2408
|
+
}
|
|
1919
2409
|
var recentParentNotifies = new Map;
|
|
1920
2410
|
function shouldDedupParentNotify(ownerSessionID, message) {
|
|
1921
2411
|
const key = `${ownerSessionID}:${message.slice(0, 200)}`;
|
|
@@ -1962,8 +2452,7 @@ function createRealHost(client, directory) {
|
|
|
1962
2452
|
parts: [{ type: "text", text: prompt }]
|
|
1963
2453
|
};
|
|
1964
2454
|
if (messageID) {
|
|
1965
|
-
|
|
1966
|
-
body.messageID = collapsed.startsWith("msg-") ? collapsed : `msg-${messageID}`;
|
|
2455
|
+
body.messageID = /^msg[-_]/.test(messageID) ? messageID : `msg_${messageID}`;
|
|
1967
2456
|
}
|
|
1968
2457
|
if (model)
|
|
1969
2458
|
body.model = model;
|
|
@@ -2131,6 +2620,9 @@ function createV2Host(context, statuses) {
|
|
|
2131
2620
|
model: { id: model.modelID, providerID: model.providerID }
|
|
2132
2621
|
});
|
|
2133
2622
|
}
|
|
2623
|
+
if (messageID !== undefined && !isV2PromptMessageID(messageID)) {
|
|
2624
|
+
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.`);
|
|
2625
|
+
}
|
|
2134
2626
|
const result = await context.session.prompt({
|
|
2135
2627
|
sessionID,
|
|
2136
2628
|
id: messageID,
|
|
@@ -2138,7 +2630,7 @@ function createV2Host(context, statuses) {
|
|
|
2138
2630
|
});
|
|
2139
2631
|
statuses.set(sessionID, "busy");
|
|
2140
2632
|
await logServerEvent(directory, "worker.prompted", { sessionID });
|
|
2141
|
-
return { messageID: result
|
|
2633
|
+
return { messageID: result?.id };
|
|
2142
2634
|
},
|
|
2143
2635
|
async readSession(sessionID) {
|
|
2144
2636
|
try {
|
|
@@ -2370,6 +2862,18 @@ function buildContinuationSteering(goal, runtime, context) {
|
|
|
2370
2862
|
}
|
|
2371
2863
|
|
|
2372
2864
|
// src/application/goal-service.ts
|
|
2865
|
+
class GoalStartError extends Error {
|
|
2866
|
+
goalID;
|
|
2867
|
+
workerSessionID;
|
|
2868
|
+
failedStage;
|
|
2869
|
+
constructor(message, info) {
|
|
2870
|
+
super(message);
|
|
2871
|
+
this.name = "GoalStartError";
|
|
2872
|
+
this.goalID = info.goalID;
|
|
2873
|
+
this.workerSessionID = info.workerSessionID;
|
|
2874
|
+
this.failedStage = info.failedStage;
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2373
2877
|
function createGoalService(host) {
|
|
2374
2878
|
const workers = createWorkerManager(host);
|
|
2375
2879
|
const sessions = new Map;
|
|
@@ -2391,12 +2895,13 @@ function createGoalService(host) {
|
|
|
2391
2895
|
goalOperations.delete(goalID);
|
|
2392
2896
|
}
|
|
2393
2897
|
}
|
|
2394
|
-
function assertWorkspaceWriteAvailable(state, goal) {
|
|
2898
|
+
function assertWorkspaceWriteAvailable(state, goal, requesterSessionID) {
|
|
2395
2899
|
if (!goal.config.workspaceWrite)
|
|
2396
2900
|
return;
|
|
2397
2901
|
const activeWriter = state.goals.find((item) => item.id !== goal.id && item.status === "active" && item.config.workspaceWrite);
|
|
2398
2902
|
if (activeWriter) {
|
|
2399
|
-
|
|
2903
|
+
const ownedElsewhere = requesterSessionID !== undefined && activeWriter.ownerSessionID !== requesterSessionID;
|
|
2904
|
+
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
2905
|
}
|
|
2401
2906
|
}
|
|
2402
2907
|
async function recordPromptFailure(directory, goalID, error, blockImmediately = false) {
|
|
@@ -2436,7 +2941,7 @@ function createGoalService(host) {
|
|
|
2436
2941
|
});
|
|
2437
2942
|
await appendEvent(directory, {
|
|
2438
2943
|
version: 1,
|
|
2439
|
-
eventID:
|
|
2944
|
+
eventID: randomUUID5(),
|
|
2440
2945
|
goalID,
|
|
2441
2946
|
type: "run.failed",
|
|
2442
2947
|
runID,
|
|
@@ -2448,7 +2953,7 @@ function createGoalService(host) {
|
|
|
2448
2953
|
if (blocked) {
|
|
2449
2954
|
await appendEvent(directory, {
|
|
2450
2955
|
version: 1,
|
|
2451
|
-
eventID:
|
|
2956
|
+
eventID: randomUUID5(),
|
|
2452
2957
|
goalID,
|
|
2453
2958
|
type: "goal.blocked",
|
|
2454
2959
|
reason: `Worker prompt delivery failed: ${detail}`,
|
|
@@ -2482,7 +2987,7 @@ function createGoalService(host) {
|
|
|
2482
2987
|
return session;
|
|
2483
2988
|
}
|
|
2484
2989
|
async function start(directory, input) {
|
|
2485
|
-
const id =
|
|
2990
|
+
const id = randomUUID5();
|
|
2486
2991
|
return withGoalOperation(id, () => startUnlocked(directory, input, id));
|
|
2487
2992
|
}
|
|
2488
2993
|
async function startUnlocked(directory, input, id) {
|
|
@@ -2521,7 +3026,7 @@ function createGoalService(host) {
|
|
|
2521
3026
|
goal.config.progressFile = path2.join(artifactDir, "progress.md");
|
|
2522
3027
|
await ensureGoalArtifactDir(directory, id);
|
|
2523
3028
|
const state1 = await mutateState(directory, `goal.create:${id}`, async (state) => {
|
|
2524
|
-
assertWorkspaceWriteAvailable(state, goal);
|
|
3029
|
+
assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
|
|
2525
3030
|
state.goals.push(goal);
|
|
2526
3031
|
const rt = createRuntimeState(id);
|
|
2527
3032
|
if (goal.config.schedule) {
|
|
@@ -2562,7 +3067,7 @@ function createGoalService(host) {
|
|
|
2562
3067
|
});
|
|
2563
3068
|
await appendEvent(directory, {
|
|
2564
3069
|
version: 1,
|
|
2565
|
-
eventID:
|
|
3070
|
+
eventID: randomUUID5(),
|
|
2566
3071
|
goalID: id,
|
|
2567
3072
|
type: "goal.blocked",
|
|
2568
3073
|
reason: detail,
|
|
@@ -2571,7 +3076,7 @@ function createGoalService(host) {
|
|
|
2571
3076
|
revision: blockedState.revision
|
|
2572
3077
|
});
|
|
2573
3078
|
await logServerEvent(directory, "goal.start.failed", { goalID: id, ownerSessionID: input.ownerSessionID, detail });
|
|
2574
|
-
throw
|
|
3079
|
+
throw new GoalStartError(detail, { goalID: id, failedStage: "worker_create" });
|
|
2575
3080
|
}
|
|
2576
3081
|
sessions.set(id, worker);
|
|
2577
3082
|
const state2 = await mutateState(directory, `goal.worker-assign:${id}`, async (state) => {
|
|
@@ -2583,8 +3088,8 @@ function createGoalService(host) {
|
|
|
2583
3088
|
const rt = state.runtimes.find((item) => item.goalID === id);
|
|
2584
3089
|
if (rt) {
|
|
2585
3090
|
Object.assign(rt, acquireLease(rt, g.config.timeoutMs || 300000));
|
|
2586
|
-
rt.activeRunID =
|
|
2587
|
-
rt.activePromptMessageID =
|
|
3091
|
+
rt.activeRunID = randomUUID5();
|
|
3092
|
+
rt.activePromptMessageID = newPromptMessageID();
|
|
2588
3093
|
rt.runCount = 1;
|
|
2589
3094
|
rt.budgetTurnCount = 1;
|
|
2590
3095
|
rt.lastRunAt = new Date().toISOString();
|
|
@@ -2594,7 +3099,7 @@ function createGoalService(host) {
|
|
|
2594
3099
|
runtime = state2.runtimes.find((r) => r.goalID === id);
|
|
2595
3100
|
await appendEvent(directory, {
|
|
2596
3101
|
version: 1,
|
|
2597
|
-
eventID:
|
|
3102
|
+
eventID: randomUUID5(),
|
|
2598
3103
|
goalID: id,
|
|
2599
3104
|
type: "goal.created",
|
|
2600
3105
|
name: input.name,
|
|
@@ -2606,7 +3111,7 @@ function createGoalService(host) {
|
|
|
2606
3111
|
if (runtime) {
|
|
2607
3112
|
await appendEvent(directory, {
|
|
2608
3113
|
version: 1,
|
|
2609
|
-
eventID:
|
|
3114
|
+
eventID: randomUUID5(),
|
|
2610
3115
|
goalID: id,
|
|
2611
3116
|
type: "run.started",
|
|
2612
3117
|
runID: runtime.activeRunID,
|
|
@@ -2618,7 +3123,11 @@ function createGoalService(host) {
|
|
|
2618
3123
|
await workers.continueWorker(worker, goal, runtime);
|
|
2619
3124
|
} catch (error) {
|
|
2620
3125
|
await recordPromptFailure(directory, id, error, true);
|
|
2621
|
-
throw error
|
|
3126
|
+
throw new GoalStartError(describeError(error), {
|
|
3127
|
+
goalID: id,
|
|
3128
|
+
workerSessionID: worker.workerSessionID,
|
|
3129
|
+
failedStage: "prompt_delivery"
|
|
3130
|
+
});
|
|
2622
3131
|
}
|
|
2623
3132
|
}
|
|
2624
3133
|
return { goal, worker };
|
|
@@ -2715,8 +3224,8 @@ function createGoalService(host) {
|
|
|
2715
3224
|
return s;
|
|
2716
3225
|
const timeoutMs = g.config.timeoutMs || 300000;
|
|
2717
3226
|
Object.assign(rt, acquireLease(rt, timeoutMs));
|
|
2718
|
-
rt.activeRunID =
|
|
2719
|
-
rt.activePromptMessageID =
|
|
3227
|
+
rt.activeRunID = randomUUID5();
|
|
3228
|
+
rt.activePromptMessageID = newPromptMessageID();
|
|
2720
3229
|
rt.runCount += 1;
|
|
2721
3230
|
if (rt.freeRetryPending) {
|
|
2722
3231
|
rt.freeRetryPending = false;
|
|
@@ -2733,7 +3242,7 @@ function createGoalService(host) {
|
|
|
2733
3242
|
return;
|
|
2734
3243
|
await appendEvent(directory, {
|
|
2735
3244
|
version: 1,
|
|
2736
|
-
eventID:
|
|
3245
|
+
eventID: randomUUID5(),
|
|
2737
3246
|
goalID,
|
|
2738
3247
|
type: "run.started",
|
|
2739
3248
|
runID: freshRuntime.activeRunID,
|
|
@@ -2819,8 +3328,10 @@ function createGoalService(host) {
|
|
|
2819
3328
|
g.status = "paused";
|
|
2820
3329
|
g.updatedAt = new Date().toISOString();
|
|
2821
3330
|
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
2822
|
-
if (rt)
|
|
3331
|
+
if (rt) {
|
|
2823
3332
|
Object.assign(rt, releaseLease(rt));
|
|
3333
|
+
rt.activeRunID = undefined;
|
|
3334
|
+
}
|
|
2824
3335
|
return s;
|
|
2825
3336
|
});
|
|
2826
3337
|
const session = sessions.get(goalID) || (goal.workerSessionID ? {
|
|
@@ -2832,9 +3343,10 @@ function createGoalService(host) {
|
|
|
2832
3343
|
await workers.abortWorker(session.workerSessionID);
|
|
2833
3344
|
sessions.delete(goalID);
|
|
2834
3345
|
}
|
|
3346
|
+
await cancelAwaitsForGoal(directory, goalID, "goal paused").catch(() => {});
|
|
2835
3347
|
await appendEvent(directory, {
|
|
2836
3348
|
version: 1,
|
|
2837
|
-
eventID:
|
|
3349
|
+
eventID: randomUUID5(),
|
|
2838
3350
|
goalID,
|
|
2839
3351
|
type: "goal.status_changed",
|
|
2840
3352
|
from: "active",
|
|
@@ -2851,7 +3363,7 @@ function createGoalService(host) {
|
|
|
2851
3363
|
return state;
|
|
2852
3364
|
if (!canTransition(goal.status, "active", "user"))
|
|
2853
3365
|
return state;
|
|
2854
|
-
assertWorkspaceWriteAvailable(state, goal);
|
|
3366
|
+
assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
|
|
2855
3367
|
goal.status = "active";
|
|
2856
3368
|
goal.updatedAt = new Date().toISOString();
|
|
2857
3369
|
resumed = true;
|
|
@@ -2873,7 +3385,7 @@ function createGoalService(host) {
|
|
|
2873
3385
|
}
|
|
2874
3386
|
await appendEvent(directory, {
|
|
2875
3387
|
version: 1,
|
|
2876
|
-
eventID:
|
|
3388
|
+
eventID: randomUUID5(),
|
|
2877
3389
|
goalID,
|
|
2878
3390
|
type: "goal.status_changed",
|
|
2879
3391
|
from: "paused",
|
|
@@ -2889,7 +3401,7 @@ function createGoalService(host) {
|
|
|
2889
3401
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
2890
3402
|
if (!goal || goal.status !== "blocked")
|
|
2891
3403
|
return state;
|
|
2892
|
-
assertWorkspaceWriteAvailable(state, goal);
|
|
3404
|
+
assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
|
|
2893
3405
|
goal.status = "active";
|
|
2894
3406
|
goal.updatedAt = new Date().toISOString();
|
|
2895
3407
|
retried = true;
|
|
@@ -2925,7 +3437,7 @@ function createGoalService(host) {
|
|
|
2925
3437
|
}
|
|
2926
3438
|
await appendEvent(directory, {
|
|
2927
3439
|
version: 1,
|
|
2928
|
-
eventID:
|
|
3440
|
+
eventID: randomUUID5(),
|
|
2929
3441
|
goalID,
|
|
2930
3442
|
type: "goal.status_changed",
|
|
2931
3443
|
from: "blocked",
|
|
@@ -2952,11 +3464,12 @@ function createGoalService(host) {
|
|
|
2952
3464
|
await mutateState(directory, `goal.clear:${goalID}`, async (s) => {
|
|
2953
3465
|
s.goals = s.goals.filter((g) => g.id !== goalID);
|
|
2954
3466
|
s.runtimes = s.runtimes.filter((r) => r.goalID !== goalID);
|
|
3467
|
+
s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.goalID !== goalID);
|
|
2955
3468
|
return s;
|
|
2956
3469
|
});
|
|
2957
3470
|
await appendEvent(directory, {
|
|
2958
3471
|
version: 1,
|
|
2959
|
-
eventID:
|
|
3472
|
+
eventID: randomUUID5(),
|
|
2960
3473
|
goalID,
|
|
2961
3474
|
type: "goal.cleared",
|
|
2962
3475
|
timestamp: new Date().toISOString(),
|
|
@@ -3164,7 +3677,7 @@ function createGoalService(host) {
|
|
|
3164
3677
|
|
|
3165
3678
|
// src/application/schedule-worker.ts
|
|
3166
3679
|
init_state_repository();
|
|
3167
|
-
import { randomUUID as
|
|
3680
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
3168
3681
|
function createScheduleWorker(options) {
|
|
3169
3682
|
const { directory, goalService } = options;
|
|
3170
3683
|
const intervalMs = options.intervalMs ?? 5000;
|
|
@@ -3252,7 +3765,7 @@ function createScheduleWorker(options) {
|
|
|
3252
3765
|
continue;
|
|
3253
3766
|
await appendEvent(directory, {
|
|
3254
3767
|
version: 1,
|
|
3255
|
-
eventID:
|
|
3768
|
+
eventID: randomUUID6(),
|
|
3256
3769
|
goalID: goal.id,
|
|
3257
3770
|
type: "schedule.tick",
|
|
3258
3771
|
scheduleRunCount: count,
|
|
@@ -3277,71 +3790,1526 @@ function createScheduleWorker(options) {
|
|
|
3277
3790
|
return { start, stop, isRunning, tick };
|
|
3278
3791
|
}
|
|
3279
3792
|
|
|
3280
|
-
// src/server/
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3793
|
+
// src/server/command-host.ts
|
|
3794
|
+
import { createRequire } from "module";
|
|
3795
|
+
var COMMAND_HOST_CAPABILITIES = {
|
|
3796
|
+
spawn: true,
|
|
3797
|
+
write: true,
|
|
3798
|
+
interruptSignal: true,
|
|
3799
|
+
terminate: true,
|
|
3800
|
+
resize: false,
|
|
3801
|
+
terminalEmulation: false
|
|
3802
|
+
};
|
|
3803
|
+
var PTY_HOST_CAPABILITIES = {
|
|
3804
|
+
spawn: true,
|
|
3805
|
+
write: true,
|
|
3806
|
+
interruptSignal: true,
|
|
3807
|
+
terminate: true,
|
|
3808
|
+
resize: true,
|
|
3809
|
+
terminalEmulation: false
|
|
3810
|
+
};
|
|
3811
|
+
function createLocalProcessHost() {
|
|
3812
|
+
return {
|
|
3813
|
+
capabilities: COMMAND_HOST_CAPABILITIES,
|
|
3814
|
+
spawn(opts, onOutput, onExit) {
|
|
3815
|
+
const proc = Bun.spawn([opts.command, ...opts.args ?? []], {
|
|
3816
|
+
cwd: opts.cwd,
|
|
3817
|
+
env: { ...process.env, ...opts.env ?? {} },
|
|
3818
|
+
stdin: "pipe",
|
|
3819
|
+
stdout: "pipe",
|
|
3820
|
+
stderr: "pipe"
|
|
3821
|
+
});
|
|
3822
|
+
let settled = false;
|
|
3823
|
+
let signal;
|
|
3824
|
+
const exitPromise = (async () => {
|
|
3825
|
+
const pumps = [pumpStream(proc.stdout, onOutput), pumpStream(proc.stderr, onOutput)];
|
|
3826
|
+
const code = await proc.exited;
|
|
3827
|
+
await Promise.allSettled(pumps);
|
|
3828
|
+
settled = true;
|
|
3829
|
+
const info = { exitCode: code ?? 0, signal };
|
|
3830
|
+
onExit(info);
|
|
3831
|
+
return info;
|
|
3832
|
+
})();
|
|
3833
|
+
return {
|
|
3834
|
+
pid: proc.pid,
|
|
3835
|
+
write(data) {
|
|
3836
|
+
try {
|
|
3837
|
+
proc.stdin.write(data);
|
|
3838
|
+
return true;
|
|
3839
|
+
} catch {
|
|
3840
|
+
return false;
|
|
3841
|
+
}
|
|
3842
|
+
},
|
|
3843
|
+
interrupt() {
|
|
3844
|
+
try {
|
|
3845
|
+
signal = "SIGINT";
|
|
3846
|
+
proc.kill("SIGINT");
|
|
3847
|
+
return true;
|
|
3848
|
+
} catch {
|
|
3849
|
+
return false;
|
|
3850
|
+
}
|
|
3851
|
+
},
|
|
3852
|
+
terminate() {
|
|
3853
|
+
try {
|
|
3854
|
+
if (!signal)
|
|
3855
|
+
signal = "SIGTERM";
|
|
3856
|
+
proc.kill("SIGTERM");
|
|
3857
|
+
return true;
|
|
3858
|
+
} catch {
|
|
3859
|
+
return false;
|
|
3860
|
+
}
|
|
3861
|
+
},
|
|
3862
|
+
kill() {
|
|
3863
|
+
try {
|
|
3864
|
+
signal = "SIGKILL";
|
|
3865
|
+
proc.kill("SIGKILL");
|
|
3866
|
+
return true;
|
|
3867
|
+
} catch {
|
|
3868
|
+
return false;
|
|
3869
|
+
}
|
|
3870
|
+
},
|
|
3871
|
+
resize() {
|
|
3872
|
+
return false;
|
|
3873
|
+
},
|
|
3874
|
+
isAlive() {
|
|
3875
|
+
if (settled)
|
|
3876
|
+
return false;
|
|
3877
|
+
try {
|
|
3878
|
+
process.kill(proc.pid, 0);
|
|
3879
|
+
return true;
|
|
3880
|
+
} catch {
|
|
3881
|
+
return false;
|
|
3882
|
+
}
|
|
3883
|
+
},
|
|
3884
|
+
exited() {
|
|
3885
|
+
return exitPromise;
|
|
3886
|
+
}
|
|
3887
|
+
};
|
|
3888
|
+
},
|
|
3889
|
+
livePids() {
|
|
3890
|
+
return new Set;
|
|
3891
|
+
}
|
|
3892
|
+
};
|
|
3893
|
+
}
|
|
3894
|
+
async function pumpStream(stream, onOutput) {
|
|
3895
|
+
if (!stream)
|
|
3896
|
+
return;
|
|
3897
|
+
const reader = stream.getReader();
|
|
3898
|
+
const decoder = new TextDecoder;
|
|
3899
|
+
try {
|
|
3900
|
+
for (;; ) {
|
|
3901
|
+
const { done, value } = await reader.read();
|
|
3902
|
+
if (done)
|
|
3903
|
+
break;
|
|
3904
|
+
if (value && value.length > 0)
|
|
3905
|
+
onOutput(decoder.decode(value, { stream: true }));
|
|
3906
|
+
}
|
|
3907
|
+
const rest = decoder.decode();
|
|
3908
|
+
if (rest)
|
|
3909
|
+
onOutput(rest);
|
|
3910
|
+
} catch {} finally {
|
|
3911
|
+
try {
|
|
3912
|
+
reader.releaseLock();
|
|
3913
|
+
} catch {}
|
|
3290
3914
|
}
|
|
3291
|
-
return next;
|
|
3292
3915
|
}
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3916
|
+
function loadBunPty() {
|
|
3917
|
+
return createRequire(import.meta.url)("bun-pty");
|
|
3918
|
+
}
|
|
3919
|
+
function asBunPtyModule(loaded) {
|
|
3920
|
+
const spawn = loaded?.spawn;
|
|
3921
|
+
if (typeof spawn !== "function")
|
|
3922
|
+
throw new Error("bun-pty module has no spawn() export");
|
|
3923
|
+
return { spawn };
|
|
3924
|
+
}
|
|
3925
|
+
function createPtyHost(load = loadBunPty) {
|
|
3926
|
+
const { spawn } = asBunPtyModule(load());
|
|
3299
3927
|
return {
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
args
|
|
3303
|
-
name:
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
ok: false,
|
|
3327
|
-
message: "A valid owner session is required. Run /goal from an active OpenCode session."
|
|
3328
|
-
})
|
|
3329
|
-
};
|
|
3330
|
-
}
|
|
3331
|
-
const config = {
|
|
3332
|
-
maxTurns: 50
|
|
3928
|
+
capabilities: PTY_HOST_CAPABILITIES,
|
|
3929
|
+
spawn(opts, onOutput, onExit) {
|
|
3930
|
+
const pty = spawn(opts.command, opts.args ?? [], {
|
|
3931
|
+
name: "xterm-256color",
|
|
3932
|
+
cols: opts.cols ?? 80,
|
|
3933
|
+
rows: opts.rows ?? 24,
|
|
3934
|
+
cwd: opts.cwd,
|
|
3935
|
+
env: { ...process.env, ...opts.env ?? {}, TERM: "xterm-256color" }
|
|
3936
|
+
});
|
|
3937
|
+
let settled = false;
|
|
3938
|
+
let killedSignal;
|
|
3939
|
+
let resolveExit;
|
|
3940
|
+
const exitPromise = new Promise((r) => {
|
|
3941
|
+
resolveExit = r;
|
|
3942
|
+
});
|
|
3943
|
+
pty.onData((data) => {
|
|
3944
|
+
onOutput(data);
|
|
3945
|
+
});
|
|
3946
|
+
pty.onExit((event) => {
|
|
3947
|
+
if (settled)
|
|
3948
|
+
return;
|
|
3949
|
+
settled = true;
|
|
3950
|
+
const raw = event?.signal;
|
|
3951
|
+
const info = {
|
|
3952
|
+
exitCode: event?.exitCode ?? 0,
|
|
3953
|
+
signal: typeof raw === "string" ? raw : raw == null ? killedSignal : String(raw)
|
|
3333
3954
|
};
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3955
|
+
onExit(info);
|
|
3956
|
+
resolveExit(info);
|
|
3957
|
+
});
|
|
3958
|
+
return {
|
|
3959
|
+
pid: pty.pid,
|
|
3960
|
+
write(data) {
|
|
3961
|
+
if (settled)
|
|
3962
|
+
return false;
|
|
3963
|
+
try {
|
|
3964
|
+
pty.write(data);
|
|
3965
|
+
return true;
|
|
3966
|
+
} catch {
|
|
3967
|
+
return false;
|
|
3968
|
+
}
|
|
3969
|
+
},
|
|
3970
|
+
interrupt() {
|
|
3971
|
+
if (settled)
|
|
3972
|
+
return false;
|
|
3973
|
+
try {
|
|
3974
|
+
pty.write("\x03");
|
|
3975
|
+
return true;
|
|
3976
|
+
} catch {
|
|
3977
|
+
return false;
|
|
3978
|
+
}
|
|
3979
|
+
},
|
|
3980
|
+
terminate() {
|
|
3981
|
+
if (settled)
|
|
3982
|
+
return false;
|
|
3983
|
+
try {
|
|
3984
|
+
killedSignal ??= "SIGTERM";
|
|
3985
|
+
pty.kill();
|
|
3986
|
+
return true;
|
|
3987
|
+
} catch {
|
|
3988
|
+
return false;
|
|
3989
|
+
}
|
|
3990
|
+
},
|
|
3991
|
+
kill() {
|
|
3992
|
+
if (settled)
|
|
3993
|
+
return false;
|
|
3994
|
+
try {
|
|
3995
|
+
killedSignal = "SIGKILL";
|
|
3996
|
+
pty.kill("SIGKILL");
|
|
3997
|
+
return true;
|
|
3998
|
+
} catch {
|
|
3999
|
+
return false;
|
|
4000
|
+
}
|
|
4001
|
+
},
|
|
4002
|
+
resize(cols, rows) {
|
|
4003
|
+
if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols <= 0 || rows <= 0)
|
|
4004
|
+
return false;
|
|
4005
|
+
if (settled)
|
|
4006
|
+
return false;
|
|
4007
|
+
try {
|
|
4008
|
+
pty.resize(cols, rows);
|
|
4009
|
+
return true;
|
|
4010
|
+
} catch {
|
|
4011
|
+
return false;
|
|
4012
|
+
}
|
|
4013
|
+
},
|
|
4014
|
+
isAlive() {
|
|
4015
|
+
if (settled)
|
|
4016
|
+
return false;
|
|
4017
|
+
try {
|
|
4018
|
+
process.kill(pty.pid, 0);
|
|
4019
|
+
return true;
|
|
4020
|
+
} catch {
|
|
4021
|
+
return false;
|
|
4022
|
+
}
|
|
4023
|
+
},
|
|
4024
|
+
exited() {
|
|
4025
|
+
return exitPromise;
|
|
4026
|
+
}
|
|
4027
|
+
};
|
|
4028
|
+
},
|
|
4029
|
+
livePids() {
|
|
4030
|
+
return new Set;
|
|
4031
|
+
}
|
|
4032
|
+
};
|
|
4033
|
+
}
|
|
4034
|
+
function createCommandHost(opts) {
|
|
4035
|
+
try {
|
|
4036
|
+
return Object.assign(createPtyHost(opts?.load ?? loadBunPty), { backend: "pty" });
|
|
4037
|
+
} catch {
|
|
4038
|
+
return Object.assign(createLocalProcessHost(), { backend: "pipe" });
|
|
4039
|
+
}
|
|
4040
|
+
}
|
|
4041
|
+
|
|
4042
|
+
// src/application/command-service.ts
|
|
4043
|
+
import { randomUUID as randomUUID7 } from "crypto";
|
|
4044
|
+
import { promises as fs3 } from "fs";
|
|
4045
|
+
import path3 from "path";
|
|
4046
|
+
|
|
4047
|
+
// src/domain/command-session.ts
|
|
4048
|
+
function createCommandSession(input) {
|
|
4049
|
+
const now = new Date().toISOString();
|
|
4050
|
+
return {
|
|
4051
|
+
id: input.id,
|
|
4052
|
+
title: input.title,
|
|
4053
|
+
command: input.command,
|
|
4054
|
+
args: input.args ?? [],
|
|
4055
|
+
cwd: input.cwd,
|
|
4056
|
+
ownerSessionID: input.ownerSessionID,
|
|
4057
|
+
goalID: input.goalID,
|
|
4058
|
+
status: "running",
|
|
4059
|
+
pid: input.pid,
|
|
4060
|
+
cols: input.cols,
|
|
4061
|
+
rows: input.rows,
|
|
4062
|
+
outputBytes: 0,
|
|
4063
|
+
streamBytes: 0,
|
|
4064
|
+
truncated: false,
|
|
4065
|
+
createdAt: now,
|
|
4066
|
+
updatedAt: now
|
|
4067
|
+
};
|
|
4068
|
+
}
|
|
4069
|
+
var MAX_COMMAND_OUTPUT_BYTES = 512 * 1024;
|
|
4070
|
+
|
|
4071
|
+
// src/domain/command-events.ts
|
|
4072
|
+
var _encoder = new TextEncoder;
|
|
4073
|
+
function utf8ByteLength(data) {
|
|
4074
|
+
return _encoder.encode(data).length;
|
|
4075
|
+
}
|
|
4076
|
+
function expectedNextEnd(startOffset, data) {
|
|
4077
|
+
return startOffset + utf8ByteLength(data);
|
|
4078
|
+
}
|
|
4079
|
+
var VALID_STATUSES = [
|
|
4080
|
+
"running",
|
|
4081
|
+
"exited",
|
|
4082
|
+
"terminated",
|
|
4083
|
+
"missing"
|
|
4084
|
+
];
|
|
4085
|
+
function isRecord(value) {
|
|
4086
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4087
|
+
}
|
|
4088
|
+
function isNonEmptyString(value) {
|
|
4089
|
+
return typeof value === "string" && value.length > 0;
|
|
4090
|
+
}
|
|
4091
|
+
function isNonNegativeInt(value) {
|
|
4092
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
4093
|
+
}
|
|
4094
|
+
function isCommandSessionLike(value) {
|
|
4095
|
+
if (!isRecord(value))
|
|
4096
|
+
return false;
|
|
4097
|
+
if (typeof value["id"] !== "string" || value["id"].length === 0)
|
|
4098
|
+
return false;
|
|
4099
|
+
if (typeof value["title"] !== "string")
|
|
4100
|
+
return false;
|
|
4101
|
+
if (typeof value["command"] !== "string")
|
|
4102
|
+
return false;
|
|
4103
|
+
if (typeof value["cwd"] !== "string")
|
|
4104
|
+
return false;
|
|
4105
|
+
if (typeof value["ownerSessionID"] !== "string")
|
|
4106
|
+
return false;
|
|
4107
|
+
if (typeof value["status"] !== "string")
|
|
4108
|
+
return false;
|
|
4109
|
+
if (!VALID_STATUSES.includes(value["status"]))
|
|
4110
|
+
return false;
|
|
4111
|
+
if (!isNonNegativeInt(value["outputBytes"]))
|
|
4112
|
+
return false;
|
|
4113
|
+
if (typeof value["truncated"] !== "boolean")
|
|
4114
|
+
return false;
|
|
4115
|
+
if (typeof value["createdAt"] !== "string")
|
|
4116
|
+
return false;
|
|
4117
|
+
if (typeof value["updatedAt"] !== "string")
|
|
4118
|
+
return false;
|
|
4119
|
+
if (value["args"] !== undefined && !Array.isArray(value["args"]))
|
|
4120
|
+
return false;
|
|
4121
|
+
if (value["streamBytes"] !== undefined && !isNonNegativeInt(value["streamBytes"]))
|
|
4122
|
+
return false;
|
|
4123
|
+
return true;
|
|
4124
|
+
}
|
|
4125
|
+
function checkOffsets(startOffset, endOffset, data) {
|
|
4126
|
+
if (typeof data !== "string")
|
|
4127
|
+
return "data must be a string";
|
|
4128
|
+
if (!isNonNegativeInt(startOffset))
|
|
4129
|
+
return "startOffset must be a non-negative integer";
|
|
4130
|
+
if (!isNonNegativeInt(endOffset))
|
|
4131
|
+
return "endOffset must be a non-negative integer";
|
|
4132
|
+
if (endOffset < startOffset)
|
|
4133
|
+
return "endOffset must be >= startOffset";
|
|
4134
|
+
const expected = expectedNextEnd(startOffset, data);
|
|
4135
|
+
if (endOffset !== expected)
|
|
4136
|
+
return `endOffset mismatch: expected ${expected} (startOffset + UTF-8 byte length ${expected - startOffset}), got ${endOffset}`;
|
|
4137
|
+
return null;
|
|
4138
|
+
}
|
|
4139
|
+
function validateCommandStreamMessage(value) {
|
|
4140
|
+
try {
|
|
4141
|
+
if (!isRecord(value))
|
|
4142
|
+
return { ok: false, error: "message must be an object" };
|
|
4143
|
+
const type = value["type"];
|
|
4144
|
+
if (typeof type !== "string")
|
|
4145
|
+
return { ok: false, error: "missing type field" };
|
|
4146
|
+
switch (type) {
|
|
4147
|
+
case "subscribe": {
|
|
4148
|
+
if (!isNonEmptyString(value["commandID"]))
|
|
4149
|
+
return { ok: false, error: "subscribe.commandID must be a non-empty string" };
|
|
4150
|
+
if (!isNonEmptyString(value["ownerSessionID"]))
|
|
4151
|
+
return { ok: false, error: "subscribe.ownerSessionID must be a non-empty string" };
|
|
4152
|
+
return {
|
|
4153
|
+
ok: true,
|
|
4154
|
+
message: {
|
|
4155
|
+
type: "subscribe",
|
|
4156
|
+
commandID: value["commandID"],
|
|
4157
|
+
ownerSessionID: value["ownerSessionID"]
|
|
4158
|
+
}
|
|
4159
|
+
};
|
|
4160
|
+
}
|
|
4161
|
+
case "snapshot": {
|
|
4162
|
+
if (!isCommandSessionLike(value["command"]))
|
|
4163
|
+
return { ok: false, error: "snapshot.command must be CommandSession metadata" };
|
|
4164
|
+
const offsetError = checkOffsets(value["startOffset"], value["endOffset"], value["data"]);
|
|
4165
|
+
if (offsetError)
|
|
4166
|
+
return { ok: false, error: `snapshot.${offsetError}` };
|
|
4167
|
+
return {
|
|
4168
|
+
ok: true,
|
|
4169
|
+
message: {
|
|
4170
|
+
type: "snapshot",
|
|
4171
|
+
command: value["command"],
|
|
4172
|
+
data: value["data"],
|
|
4173
|
+
startOffset: value["startOffset"],
|
|
4174
|
+
endOffset: value["endOffset"]
|
|
4175
|
+
}
|
|
4176
|
+
};
|
|
4177
|
+
}
|
|
4178
|
+
case "output": {
|
|
4179
|
+
if (!isNonEmptyString(value["commandID"]))
|
|
4180
|
+
return { ok: false, error: "output.commandID must be a non-empty string" };
|
|
4181
|
+
const offsetError = checkOffsets(value["startOffset"], value["endOffset"], value["data"]);
|
|
4182
|
+
if (offsetError)
|
|
4183
|
+
return { ok: false, error: `output.${offsetError}` };
|
|
4184
|
+
return {
|
|
4185
|
+
ok: true,
|
|
4186
|
+
message: {
|
|
4187
|
+
type: "output",
|
|
4188
|
+
commandID: value["commandID"],
|
|
4189
|
+
data: value["data"],
|
|
4190
|
+
startOffset: value["startOffset"],
|
|
4191
|
+
endOffset: value["endOffset"]
|
|
4192
|
+
}
|
|
4193
|
+
};
|
|
4194
|
+
}
|
|
4195
|
+
case "status": {
|
|
4196
|
+
if (!isCommandSessionLike(value["command"]))
|
|
4197
|
+
return { ok: false, error: "status.command must be CommandSession metadata" };
|
|
4198
|
+
return { ok: true, message: { type: "status", command: value["command"] } };
|
|
4199
|
+
}
|
|
4200
|
+
case "input": {
|
|
4201
|
+
if (!isNonEmptyString(value["commandID"]))
|
|
4202
|
+
return { ok: false, error: "input.commandID must be a non-empty string" };
|
|
4203
|
+
if (typeof value["data"] !== "string")
|
|
4204
|
+
return { ok: false, error: "input.data must be a string" };
|
|
4205
|
+
return {
|
|
4206
|
+
ok: true,
|
|
4207
|
+
message: { type: "input", commandID: value["commandID"], data: value["data"] }
|
|
4208
|
+
};
|
|
4209
|
+
}
|
|
4210
|
+
case "interrupt": {
|
|
4211
|
+
if (!isNonEmptyString(value["commandID"]))
|
|
4212
|
+
return { ok: false, error: "interrupt.commandID must be a non-empty string" };
|
|
4213
|
+
return { ok: true, message: { type: "interrupt", commandID: value["commandID"] } };
|
|
4214
|
+
}
|
|
4215
|
+
case "resync": {
|
|
4216
|
+
if (!isNonEmptyString(value["commandID"]))
|
|
4217
|
+
return { ok: false, error: "resync.commandID must be a non-empty string" };
|
|
4218
|
+
return { ok: true, message: { type: "resync", commandID: value["commandID"] } };
|
|
4219
|
+
}
|
|
4220
|
+
case "unsubscribe": {
|
|
4221
|
+
if (!isNonEmptyString(value["commandID"]))
|
|
4222
|
+
return { ok: false, error: "unsubscribe.commandID must be a non-empty string" };
|
|
4223
|
+
return { ok: true, message: { type: "unsubscribe", commandID: value["commandID"] } };
|
|
4224
|
+
}
|
|
4225
|
+
case "error": {
|
|
4226
|
+
if (!isNonEmptyString(value["code"]))
|
|
4227
|
+
return { ok: false, error: "error.code must be a non-empty string" };
|
|
4228
|
+
if (typeof value["message"] !== "string")
|
|
4229
|
+
return { ok: false, error: "error.message must be a string" };
|
|
4230
|
+
return {
|
|
4231
|
+
ok: true,
|
|
4232
|
+
message: { type: "error", code: value["code"], message: value["message"] }
|
|
4233
|
+
};
|
|
4234
|
+
}
|
|
4235
|
+
default:
|
|
4236
|
+
return { ok: false, error: `unknown message type: ${type}` };
|
|
4237
|
+
}
|
|
4238
|
+
} catch (err) {
|
|
4239
|
+
return { ok: false, error: `validation failed: ${String(err)}` };
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
|
|
4243
|
+
// src/application/command-service.ts
|
|
4244
|
+
init_command_await2();
|
|
4245
|
+
init_state_repository();
|
|
4246
|
+
function loopCommandsDir(directory) {
|
|
4247
|
+
return path3.join(directory, ".opencode", "loopd", "commands");
|
|
4248
|
+
}
|
|
4249
|
+
function createCommandService(host, opts) {
|
|
4250
|
+
const live = new Map;
|
|
4251
|
+
const operations = new Map;
|
|
4252
|
+
const broker = opts?.broker;
|
|
4253
|
+
const commandDirs = new Map;
|
|
4254
|
+
function tailText(entry, limitBytes = 64 * 1024) {
|
|
4255
|
+
let want = limitBytes;
|
|
4256
|
+
const parts = [];
|
|
4257
|
+
for (let i = entry.buffers.length - 1;i >= 0 && want > 0; i--) {
|
|
4258
|
+
const chunk = entry.buffers[i];
|
|
4259
|
+
if (!chunk)
|
|
4260
|
+
continue;
|
|
4261
|
+
parts.unshift(chunk.subarray(Math.max(0, chunk.length - want)));
|
|
4262
|
+
want -= chunk.length;
|
|
4263
|
+
}
|
|
4264
|
+
return Buffer.concat(parts).toString("utf8");
|
|
4265
|
+
}
|
|
4266
|
+
function enqueue(id, operation) {
|
|
4267
|
+
const previous = operations.get(id) ?? Promise.resolve();
|
|
4268
|
+
const next = previous.then(operation, operation);
|
|
4269
|
+
operations.set(id, next);
|
|
4270
|
+
next.finally(() => {
|
|
4271
|
+
if (operations.get(id) === next)
|
|
4272
|
+
operations.delete(id);
|
|
4273
|
+
}).catch(() => {});
|
|
4274
|
+
return next;
|
|
4275
|
+
}
|
|
4276
|
+
async function waitForOperations(id) {
|
|
4277
|
+
await operations.get(id)?.catch(() => {});
|
|
4278
|
+
}
|
|
4279
|
+
function emitBroker(commandID, message) {
|
|
4280
|
+
if (!broker)
|
|
4281
|
+
return;
|
|
4282
|
+
try {
|
|
4283
|
+
broker.publish(commandID, message);
|
|
4284
|
+
} catch {}
|
|
4285
|
+
}
|
|
4286
|
+
function rememberDir(id, directory) {
|
|
4287
|
+
commandDirs.set(id, directory);
|
|
4288
|
+
}
|
|
4289
|
+
async function fireAwaits(directory, id) {
|
|
4290
|
+
try {
|
|
4291
|
+
const fired = await fireCommandAwaits(directory, id);
|
|
4292
|
+
if (fired.length === 0)
|
|
4293
|
+
return;
|
|
4294
|
+
await opts?.onAwaitFired?.(directory, fired);
|
|
4295
|
+
} catch {}
|
|
4296
|
+
}
|
|
4297
|
+
async function readBrokerSnapshot(commandID, ownerSessionID) {
|
|
4298
|
+
const directory = commandDirs.get(commandID);
|
|
4299
|
+
if (!directory)
|
|
4300
|
+
return;
|
|
4301
|
+
const state = await readState(directory);
|
|
4302
|
+
const session = (state.commands ?? []).find((x) => x.id === commandID && x.ownerSessionID === ownerSessionID);
|
|
4303
|
+
if (!session)
|
|
4304
|
+
return;
|
|
4305
|
+
const log = await readCommandLog(directory, commandID, {
|
|
4306
|
+
offsetBytes: 0,
|
|
4307
|
+
limitBytes: MAX_COMMAND_OUTPUT_BYTES
|
|
4308
|
+
});
|
|
4309
|
+
const data = log.text;
|
|
4310
|
+
const byteLen = utf8ByteLength(data);
|
|
4311
|
+
const lifetime = session.streamBytes ?? 0;
|
|
4312
|
+
const endOffset = lifetime >= byteLen ? lifetime : byteLen;
|
|
4313
|
+
const startOffset = endOffset - byteLen;
|
|
4314
|
+
return { command: session, data, startOffset, endOffset };
|
|
4315
|
+
}
|
|
4316
|
+
if (broker) {
|
|
4317
|
+
broker.setResolver({
|
|
4318
|
+
async getSession(commandID, ownerSessionID) {
|
|
4319
|
+
const directory = commandDirs.get(commandID);
|
|
4320
|
+
if (!directory)
|
|
4321
|
+
return;
|
|
4322
|
+
const s = await readState(directory);
|
|
4323
|
+
const c = (s.commands ?? []).find((x) => x.id === commandID);
|
|
4324
|
+
return c && c.ownerSessionID === ownerSessionID ? c : undefined;
|
|
4325
|
+
},
|
|
4326
|
+
async waitForQuiesce(commandID) {
|
|
4327
|
+
await waitForOperations(commandID);
|
|
4328
|
+
},
|
|
4329
|
+
async readSnapshot(commandID, ownerSessionID) {
|
|
4330
|
+
return readBrokerSnapshot(commandID, ownerSessionID);
|
|
4331
|
+
}
|
|
4332
|
+
});
|
|
4333
|
+
}
|
|
4334
|
+
async function persistOutput(directory, id, chunk) {
|
|
4335
|
+
rememberDir(id, directory);
|
|
4336
|
+
const entry = live.get(id);
|
|
4337
|
+
if (entry) {
|
|
4338
|
+
const bytes = Buffer.from(chunk);
|
|
4339
|
+
entry.buffers.push(bytes);
|
|
4340
|
+
entry.bufferedBytes += bytes.length;
|
|
4341
|
+
while (entry.bufferedBytes > 128 * 1024 && entry.buffers.length > 1) {
|
|
4342
|
+
const dropped = entry.buffers.shift();
|
|
4343
|
+
entry.bufferedBytes -= dropped.length;
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
4346
|
+
await appendCommandLog(directory, id, chunk);
|
|
4347
|
+
let retainedBytes = 0;
|
|
4348
|
+
let truncated = false;
|
|
4349
|
+
const file = path3.join(loopCommandsDir(directory), `${id}.log`);
|
|
4350
|
+
const stat = await fs3.stat(file);
|
|
4351
|
+
retainedBytes = stat.size;
|
|
4352
|
+
if (stat.size > MAX_COMMAND_OUTPUT_BYTES) {
|
|
4353
|
+
const fh = await fs3.open(file, "r");
|
|
4354
|
+
try {
|
|
4355
|
+
const buf = Buffer.alloc(MAX_COMMAND_OUTPUT_BYTES);
|
|
4356
|
+
await fh.read(buf, 0, buf.length, stat.size - buf.length);
|
|
4357
|
+
await fs3.writeFile(file, buf);
|
|
4358
|
+
} finally {
|
|
4359
|
+
await fh.close();
|
|
4360
|
+
}
|
|
4361
|
+
retainedBytes = MAX_COMMAND_OUTPUT_BYTES;
|
|
4362
|
+
truncated = true;
|
|
4363
|
+
}
|
|
4364
|
+
await mutateState(directory, `cmd.output:${id}`, async (s) => {
|
|
4365
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
4366
|
+
if (!c)
|
|
4367
|
+
return s;
|
|
4368
|
+
const byteLen = utf8ByteLength(chunk);
|
|
4369
|
+
const startOffset = c.streamBytes ?? 0;
|
|
4370
|
+
const endOffset = startOffset + byteLen;
|
|
4371
|
+
c.streamBytes = endOffset;
|
|
4372
|
+
c.outputBytes = retainedBytes;
|
|
4373
|
+
if (truncated)
|
|
4374
|
+
c.truncated = true;
|
|
4375
|
+
c.updatedAt = new Date().toISOString();
|
|
4376
|
+
return s;
|
|
4377
|
+
});
|
|
4378
|
+
try {
|
|
4379
|
+
const fresh = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
|
|
4380
|
+
const endOffset = fresh?.streamBytes ?? 0;
|
|
4381
|
+
const startOffset = endOffset - utf8ByteLength(chunk);
|
|
4382
|
+
if (fresh && startOffset >= 0) {
|
|
4383
|
+
emitBroker(id, {
|
|
4384
|
+
type: "output",
|
|
4385
|
+
commandID: id,
|
|
4386
|
+
data: chunk,
|
|
4387
|
+
startOffset,
|
|
4388
|
+
endOffset
|
|
4389
|
+
});
|
|
4390
|
+
}
|
|
4391
|
+
} catch {}
|
|
4392
|
+
}
|
|
4393
|
+
async function persistExit(directory, id, info) {
|
|
4394
|
+
rememberDir(id, directory);
|
|
4395
|
+
live.delete(id);
|
|
4396
|
+
await mutateState(directory, `cmd.exit:${id}`, async (s) => {
|
|
4397
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
4398
|
+
if (!c || c.status !== "running")
|
|
4399
|
+
return s;
|
|
4400
|
+
c.exitCode = info.exitCode;
|
|
4401
|
+
if (info.signal && (c.signal === "SIGKILL" || c.signal === "SIGTERM")) {
|
|
4402
|
+
c.status = "terminated";
|
|
4403
|
+
} else {
|
|
4404
|
+
c.status = "exited";
|
|
4405
|
+
if (info.signal)
|
|
4406
|
+
c.signal = info.signal;
|
|
4407
|
+
}
|
|
4408
|
+
c.endedAt = new Date().toISOString();
|
|
4409
|
+
c.updatedAt = c.endedAt;
|
|
4410
|
+
return s;
|
|
4411
|
+
}).catch(() => {});
|
|
4412
|
+
await appendEvent(directory, {
|
|
4413
|
+
version: 1,
|
|
4414
|
+
eventID: randomUUID7(),
|
|
4415
|
+
commandID: id,
|
|
4416
|
+
type: "command.exited",
|
|
4417
|
+
exitCode: info.exitCode,
|
|
4418
|
+
timestamp: new Date().toISOString(),
|
|
4419
|
+
revision: 0
|
|
4420
|
+
}).catch(() => {});
|
|
4421
|
+
try {
|
|
4422
|
+
const fresh = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
|
|
4423
|
+
if (fresh)
|
|
4424
|
+
emitBroker(id, { type: "status", command: fresh });
|
|
4425
|
+
} catch {}
|
|
4426
|
+
await fireAwaits(directory, id);
|
|
4427
|
+
}
|
|
4428
|
+
function owned(cmd, ownerSessionID) {
|
|
4429
|
+
return !!cmd && cmd.ownerSessionID === ownerSessionID;
|
|
4430
|
+
}
|
|
4431
|
+
return {
|
|
4432
|
+
async start(directory, input) {
|
|
4433
|
+
const title = input.title.trim();
|
|
4434
|
+
const command = input.command.trim();
|
|
4435
|
+
if (!title)
|
|
4436
|
+
throw new Error("title is required");
|
|
4437
|
+
if (!command)
|
|
4438
|
+
throw new Error("command is required");
|
|
4439
|
+
if (!input.ownerSessionID || input.ownerSessionID === "main") {
|
|
4440
|
+
throw new Error("A valid owner session is required. Run from an active OpenCode session.");
|
|
4441
|
+
}
|
|
4442
|
+
const id = randomUUID7();
|
|
4443
|
+
const cwd = input.cwd || directory;
|
|
4444
|
+
const pending = [];
|
|
4445
|
+
let ready = false;
|
|
4446
|
+
const handle = host.spawn({ command, args: input.args ?? [], cwd, cols: input.cols, rows: input.rows }, (chunk) => {
|
|
4447
|
+
if (!ready)
|
|
4448
|
+
pending.push({ type: "output", chunk });
|
|
4449
|
+
else
|
|
4450
|
+
enqueue(id, () => persistOutput(directory, id, chunk)).catch(() => {});
|
|
4451
|
+
}, (info) => {
|
|
4452
|
+
if (!ready)
|
|
4453
|
+
pending.push({ type: "exit", info });
|
|
4454
|
+
else
|
|
4455
|
+
enqueue(id, () => persistExit(directory, id, info)).catch(() => {});
|
|
4456
|
+
});
|
|
4457
|
+
const session = createCommandSession({
|
|
4458
|
+
id,
|
|
4459
|
+
title,
|
|
4460
|
+
command,
|
|
4461
|
+
args: input.args ?? [],
|
|
4462
|
+
cwd,
|
|
4463
|
+
ownerSessionID: input.ownerSessionID,
|
|
4464
|
+
goalID: input.goalID,
|
|
4465
|
+
pid: handle.pid,
|
|
4466
|
+
cols: input.cols,
|
|
4467
|
+
rows: input.rows
|
|
4468
|
+
});
|
|
4469
|
+
try {
|
|
4470
|
+
await mutateState(directory, `cmd.start:${id}`, async (s) => {
|
|
4471
|
+
s.commands = [...s.commands ?? [], session];
|
|
4472
|
+
return s;
|
|
4473
|
+
});
|
|
4474
|
+
} catch (error) {
|
|
4475
|
+
handle.terminate();
|
|
4476
|
+
const exited = await Promise.race([
|
|
4477
|
+
handle.exited().then(() => true, () => true),
|
|
4478
|
+
new Promise((resolve) => setTimeout(() => resolve(false), 1000))
|
|
4479
|
+
]);
|
|
4480
|
+
if (!exited && handle.isAlive())
|
|
4481
|
+
handle.kill();
|
|
4482
|
+
throw error;
|
|
4483
|
+
}
|
|
4484
|
+
live.set(id, { handle, buffers: [], bufferedBytes: 0 });
|
|
4485
|
+
rememberDir(id, directory);
|
|
4486
|
+
await appendEvent(directory, {
|
|
4487
|
+
version: 1,
|
|
4488
|
+
eventID: randomUUID7(),
|
|
4489
|
+
...input.goalID ? { goalID: input.goalID } : {},
|
|
4490
|
+
commandID: id,
|
|
4491
|
+
type: "command.started",
|
|
4492
|
+
title,
|
|
4493
|
+
timestamp: new Date().toISOString(),
|
|
4494
|
+
revision: 0
|
|
4495
|
+
}).catch(() => {});
|
|
4496
|
+
try {
|
|
4497
|
+
const fresh = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
|
|
4498
|
+
if (fresh)
|
|
4499
|
+
emitBroker(id, { type: "status", command: fresh });
|
|
4500
|
+
} catch {}
|
|
4501
|
+
const initialEvents = pending.splice(0);
|
|
4502
|
+
ready = true;
|
|
4503
|
+
const initialOperations = [];
|
|
4504
|
+
for (const event of initialEvents) {
|
|
4505
|
+
initialOperations.push(event.type === "output" ? enqueue(id, () => persistOutput(directory, id, event.chunk)) : enqueue(id, () => persistExit(directory, id, event.info)));
|
|
4506
|
+
}
|
|
4507
|
+
await Promise.all(initialOperations);
|
|
4508
|
+
return await this.get(directory, id, input.ownerSessionID) ?? session;
|
|
4509
|
+
},
|
|
4510
|
+
async list(directory, ownerSessionID) {
|
|
4511
|
+
const s = await readState(directory);
|
|
4512
|
+
for (const c of s.commands ?? [])
|
|
4513
|
+
rememberDir(c.id, directory);
|
|
4514
|
+
return (s.commands ?? []).filter((c) => c.ownerSessionID === ownerSessionID);
|
|
4515
|
+
},
|
|
4516
|
+
async get(directory, id, ownerSessionID) {
|
|
4517
|
+
rememberDir(id, directory);
|
|
4518
|
+
const s = await readState(directory);
|
|
4519
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
4520
|
+
return owned(c, ownerSessionID) ? c : undefined;
|
|
4521
|
+
},
|
|
4522
|
+
async read(directory, id, ownerSessionID, opts) {
|
|
4523
|
+
await waitForOperations(id);
|
|
4524
|
+
const session = await this.get(directory, id, ownerSessionID);
|
|
4525
|
+
if (!session)
|
|
4526
|
+
return;
|
|
4527
|
+
const entry = live.get(id);
|
|
4528
|
+
const offset = opts?.offsetBytes ?? 0;
|
|
4529
|
+
const limit = Math.min(opts?.limitBytes ?? 64 * 1024, 256 * 1024);
|
|
4530
|
+
if (entry && offset === 0) {
|
|
4531
|
+
const mem = tailText(entry, limit);
|
|
4532
|
+
if (entry.bufferedBytes <= limit) {
|
|
4533
|
+
const file = await readCommandLog(directory, id, { offsetBytes: 0, limitBytes: 0 });
|
|
4534
|
+
const memBytes = Buffer.byteLength(mem);
|
|
4535
|
+
return { session, text: mem, totalBytes: session.outputBytes, startByte: Math.max(0, session.outputBytes - memBytes), live: session.status === "running" };
|
|
4536
|
+
}
|
|
4537
|
+
}
|
|
4538
|
+
const file = await readCommandLog(directory, id, { offsetBytes: offset, limitBytes: limit });
|
|
4539
|
+
return { session, text: file.text, totalBytes: session.outputBytes, startByte: file.startByte, live: session.status === "running" };
|
|
4540
|
+
},
|
|
4541
|
+
async write(directory, id, ownerSessionID, data) {
|
|
4542
|
+
const session = await this.get(directory, id, ownerSessionID);
|
|
4543
|
+
if (!session)
|
|
4544
|
+
return { ok: false, message: "Command not found." };
|
|
4545
|
+
if (session.status !== "running")
|
|
4546
|
+
return { ok: false, message: `Command is ${session.status}; only running commands accept input.` };
|
|
4547
|
+
const entry = live.get(id);
|
|
4548
|
+
const ok = entry ? entry.handle.write(data) : false;
|
|
4549
|
+
if (!ok)
|
|
4550
|
+
return { ok: false, message: "Process input unavailable (no live handle \u2014 host may have restarted; reconcile marks it honestly)." };
|
|
4551
|
+
return { ok: true, message: `Sent ${Buffer.byteLength(data)} byte(s) to "${session.title}".` };
|
|
4552
|
+
},
|
|
4553
|
+
async resize(directory, id, ownerSessionID, cols, rows) {
|
|
4554
|
+
const session = await this.get(directory, id, ownerSessionID);
|
|
4555
|
+
if (!session)
|
|
4556
|
+
return { ok: false, message: "Command not found." };
|
|
4557
|
+
if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols <= 0 || rows <= 0) {
|
|
4558
|
+
return { ok: false, message: `Invalid size ${cols}x${rows}: cols and rows must be positive integers.` };
|
|
4559
|
+
}
|
|
4560
|
+
const entry = live.get(id);
|
|
4561
|
+
let applied = false;
|
|
4562
|
+
if (entry) {
|
|
4563
|
+
try {
|
|
4564
|
+
applied = entry.handle.resize(cols, rows);
|
|
4565
|
+
} catch {
|
|
4566
|
+
applied = false;
|
|
4567
|
+
}
|
|
4568
|
+
}
|
|
4569
|
+
await mutateState(directory, `cmd.resize:${id}`, async (s) => {
|
|
4570
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
4571
|
+
if (c && c.ownerSessionID === ownerSessionID) {
|
|
4572
|
+
c.cols = cols;
|
|
4573
|
+
c.rows = rows;
|
|
4574
|
+
c.updatedAt = new Date().toISOString();
|
|
4575
|
+
}
|
|
4576
|
+
return s;
|
|
4577
|
+
});
|
|
4578
|
+
if (applied)
|
|
4579
|
+
return { ok: true, message: `Terminal resized to ${cols}x${rows} for "${session.title}".` };
|
|
4580
|
+
return { ok: false, unsupported: true, message: "Resize is not supported by the active pipe host (pipes have no tty winsize; the PTY backend was unavailable). Size stored; output remains a byte stream." };
|
|
4581
|
+
},
|
|
4582
|
+
async interrupt(directory, id, ownerSessionID) {
|
|
4583
|
+
const session = await this.get(directory, id, ownerSessionID);
|
|
4584
|
+
if (!session)
|
|
4585
|
+
return { ok: false, message: "Command not found." };
|
|
4586
|
+
if (session.status !== "running")
|
|
4587
|
+
return { ok: false, message: `Command is ${session.status}; nothing to interrupt.` };
|
|
4588
|
+
const entry = live.get(id);
|
|
4589
|
+
if (!entry)
|
|
4590
|
+
return { ok: false, message: "No live handle (host restarted?). Reconcile will mark it missing; use terminate/remove to clean up." };
|
|
4591
|
+
const delivered = entry.handle.interrupt();
|
|
4592
|
+
if (!delivered)
|
|
4593
|
+
return { ok: false, message: "Failed to deliver SIGINT." };
|
|
4594
|
+
await mutateState(directory, `cmd.interrupt:${id}`, async (s) => {
|
|
4595
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
4596
|
+
if (c && c.ownerSessionID === ownerSessionID) {
|
|
4597
|
+
c.signal = "SIGINT";
|
|
4598
|
+
c.updatedAt = new Date().toISOString();
|
|
4599
|
+
}
|
|
4600
|
+
return s;
|
|
4601
|
+
});
|
|
4602
|
+
return { ok: true, message: `SIGINT delivered to "${session.title}" (process may continue if it traps the signal).` };
|
|
4603
|
+
},
|
|
4604
|
+
async terminate(directory, id, ownerSessionID) {
|
|
4605
|
+
rememberDir(id, directory);
|
|
4606
|
+
const session = await this.get(directory, id, ownerSessionID);
|
|
4607
|
+
if (!session)
|
|
4608
|
+
return { ok: false, message: "Command not found." };
|
|
4609
|
+
if (session.status !== "running")
|
|
4610
|
+
return { ok: false, message: `Command is ${session.status}; nothing to terminate.` };
|
|
4611
|
+
await mutateState(directory, `cmd.terminate-claim:${id}`, async (s) => {
|
|
4612
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
4613
|
+
if (c && c.ownerSessionID === ownerSessionID && c.status === "running") {
|
|
4614
|
+
c.signal = "SIGTERM";
|
|
4615
|
+
c.updatedAt = new Date().toISOString();
|
|
4616
|
+
}
|
|
4617
|
+
return s;
|
|
4618
|
+
}).catch(() => {});
|
|
4619
|
+
try {
|
|
4620
|
+
const claimed = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
|
|
4621
|
+
if (claimed)
|
|
4622
|
+
emitBroker(id, { type: "status", command: claimed });
|
|
4623
|
+
} catch {}
|
|
4624
|
+
const entry = live.get(id);
|
|
4625
|
+
let status = "terminated";
|
|
4626
|
+
if (entry) {
|
|
4627
|
+
const aliveBefore = entry.handle.isAlive();
|
|
4628
|
+
if (!aliveBefore) {
|
|
4629
|
+
status = "exited";
|
|
4630
|
+
} else {
|
|
4631
|
+
entry.handle.terminate();
|
|
4632
|
+
const exited = await Promise.race([
|
|
4633
|
+
entry.handle.exited().then(() => true),
|
|
4634
|
+
new Promise((r) => setTimeout(() => r(false), 3000))
|
|
4635
|
+
]);
|
|
4636
|
+
if (!exited && entry.handle.isAlive())
|
|
4637
|
+
entry.handle.kill();
|
|
4638
|
+
try {
|
|
4639
|
+
await entry.handle.exited();
|
|
4640
|
+
} catch {}
|
|
4641
|
+
}
|
|
4642
|
+
live.delete(id);
|
|
4643
|
+
}
|
|
4644
|
+
const exitCode = status === "terminated" ? 143 : undefined;
|
|
4645
|
+
await mutateState(directory, `cmd.terminate:${id}`, async (s) => {
|
|
4646
|
+
const c = (s.commands ?? []).find((x) => x.id === id);
|
|
4647
|
+
if (!c || c.ownerSessionID !== ownerSessionID)
|
|
4648
|
+
return s;
|
|
4649
|
+
if (c.status === "running") {
|
|
4650
|
+
c.status = status;
|
|
4651
|
+
if (exitCode !== undefined)
|
|
4652
|
+
c.exitCode = c.exitCode ?? exitCode;
|
|
4653
|
+
c.signal = c.signal ?? "SIGTERM";
|
|
4654
|
+
c.endedAt = new Date().toISOString();
|
|
4655
|
+
c.updatedAt = c.endedAt;
|
|
4656
|
+
} else if (c.status === "terminated" && !c.endedAt) {
|
|
4657
|
+
if (exitCode !== undefined)
|
|
4658
|
+
c.exitCode = c.exitCode ?? exitCode;
|
|
4659
|
+
c.endedAt = new Date().toISOString();
|
|
4660
|
+
c.updatedAt = c.endedAt;
|
|
4661
|
+
}
|
|
4662
|
+
return s;
|
|
4663
|
+
});
|
|
4664
|
+
try {
|
|
4665
|
+
const fresh = await readState(directory).then((st) => (st.commands ?? []).find((x) => x.id === id));
|
|
4666
|
+
if (fresh)
|
|
4667
|
+
emitBroker(id, { type: "status", command: fresh });
|
|
4668
|
+
} catch {}
|
|
4669
|
+
await fireAwaits(directory, id);
|
|
4670
|
+
return { ok: true, message: `Command "${session.title}" ${status}.` };
|
|
4671
|
+
},
|
|
4672
|
+
async remove(directory, id, ownerSessionID) {
|
|
4673
|
+
rememberDir(id, directory);
|
|
4674
|
+
const session = await this.get(directory, id, ownerSessionID);
|
|
4675
|
+
if (!session)
|
|
4676
|
+
return { ok: false, message: "Command not found." };
|
|
4677
|
+
if (session.status === "running") {
|
|
4678
|
+
return { ok: false, message: `Command "${session.title}" is still running \u2014 terminate it first (terminate \u2260 remove).` };
|
|
4679
|
+
}
|
|
4680
|
+
const lastKnown = { ...session };
|
|
4681
|
+
await mutateState(directory, `cmd.remove:${id}`, async (s) => {
|
|
4682
|
+
s.commands = (s.commands ?? []).filter((x) => !(x.id === id && x.ownerSessionID === ownerSessionID));
|
|
4683
|
+
return s;
|
|
4684
|
+
});
|
|
4685
|
+
await removeCommandLog(directory, id);
|
|
4686
|
+
emitBroker(id, { type: "status", command: lastKnown });
|
|
4687
|
+
await clearAwaitsForCommand(directory, id, "command removed").catch(() => {});
|
|
4688
|
+
return { ok: true, message: `Command "${session.title}" removed.` };
|
|
4689
|
+
},
|
|
4690
|
+
async reconcile(directory) {
|
|
4691
|
+
const state = await readState(directory);
|
|
4692
|
+
const cmds = state.commands ?? [];
|
|
4693
|
+
for (const c of cmds)
|
|
4694
|
+
rememberDir(c.id, directory);
|
|
4695
|
+
let markedMissing = 0;
|
|
4696
|
+
for (const c of cmds) {
|
|
4697
|
+
if (c.status !== "running")
|
|
4698
|
+
continue;
|
|
4699
|
+
const entry = live.get(c.id);
|
|
4700
|
+
if (entry) {
|
|
4701
|
+
if (!entry.handle.isAlive()) {
|
|
4702
|
+
live.delete(c.id);
|
|
4703
|
+
await mutateState(directory, `cmd.reconcile-exit:${c.id}`, async (s) => {
|
|
4704
|
+
const x = (s.commands ?? []).find((y) => y.id === c.id);
|
|
4705
|
+
if (x && x.status === "running") {
|
|
4706
|
+
x.status = "exited";
|
|
4707
|
+
x.endedAt = new Date().toISOString();
|
|
4708
|
+
x.updatedAt = x.endedAt;
|
|
4709
|
+
x.lastError = x.lastError ?? "Process handle died without an exit event.";
|
|
4710
|
+
}
|
|
4711
|
+
return s;
|
|
4712
|
+
}).catch(() => {});
|
|
4713
|
+
try {
|
|
4714
|
+
const fresh = await readState(directory).then((st) => (st.commands ?? []).find((y) => y.id === c.id));
|
|
4715
|
+
if (fresh)
|
|
4716
|
+
emitBroker(c.id, { type: "status", command: fresh });
|
|
4717
|
+
} catch {}
|
|
4718
|
+
await fireAwaits(directory, c.id);
|
|
4719
|
+
}
|
|
4720
|
+
continue;
|
|
4721
|
+
}
|
|
4722
|
+
await mutateState(directory, `cmd.reconcile-missing:${c.id}`, async (s) => {
|
|
4723
|
+
const x = (s.commands ?? []).find((y) => y.id === c.id);
|
|
4724
|
+
if (x && x.status === "running") {
|
|
4725
|
+
x.status = "missing";
|
|
4726
|
+
x.lastError = "Host restarted or handle lost \u2014 no live execution found. Output log retained; remove to clean up.";
|
|
4727
|
+
x.updatedAt = new Date().toISOString();
|
|
4728
|
+
markedMissing++;
|
|
4729
|
+
}
|
|
4730
|
+
return s;
|
|
4731
|
+
}).catch(() => {});
|
|
4732
|
+
try {
|
|
4733
|
+
const fresh = await readState(directory).then((st) => (st.commands ?? []).find((y) => y.id === c.id));
|
|
4734
|
+
if (fresh && fresh.status === "missing")
|
|
4735
|
+
emitBroker(c.id, { type: "status", command: fresh });
|
|
4736
|
+
} catch {}
|
|
4737
|
+
await fireAwaits(directory, c.id);
|
|
4738
|
+
}
|
|
4739
|
+
return { markedMissing };
|
|
4740
|
+
},
|
|
4741
|
+
async dispose(directory) {
|
|
4742
|
+
const state = await readState(directory);
|
|
4743
|
+
const owned = new Map((state.commands ?? []).map((command) => [command.id, command.ownerSessionID]));
|
|
4744
|
+
await Promise.all([...live.entries()].map(async ([id, entry]) => {
|
|
4745
|
+
const owner = owned.get(id);
|
|
4746
|
+
if (owner) {
|
|
4747
|
+
await this.terminate(directory, id, owner).catch(() => {});
|
|
4748
|
+
return;
|
|
4749
|
+
}
|
|
4750
|
+
entry.handle.terminate();
|
|
4751
|
+
const exited = await Promise.race([
|
|
4752
|
+
entry.handle.exited().then(() => true, () => true),
|
|
4753
|
+
new Promise((resolve) => setTimeout(() => resolve(false), 1000))
|
|
4754
|
+
]);
|
|
4755
|
+
if (!exited && entry.handle.isAlive())
|
|
4756
|
+
entry.handle.kill();
|
|
4757
|
+
live.delete(id);
|
|
4758
|
+
}));
|
|
4759
|
+
}
|
|
4760
|
+
};
|
|
4761
|
+
}
|
|
4762
|
+
|
|
4763
|
+
// src/application/command-event-broker.ts
|
|
4764
|
+
import { randomUUID as randomUUID8 } from "crypto";
|
|
4765
|
+
function safeDeliver(sink, msg) {
|
|
4766
|
+
try {
|
|
4767
|
+
sink(msg);
|
|
4768
|
+
} catch {}
|
|
4769
|
+
}
|
|
4770
|
+
function isCoveredBySnapshot(msg, snapshot) {
|
|
4771
|
+
if (msg.type === "output") {
|
|
4772
|
+
const out = msg;
|
|
4773
|
+
return out.endOffset <= snapshot.endOffset;
|
|
4774
|
+
}
|
|
4775
|
+
if (msg.type === "status") {
|
|
4776
|
+
const st = msg;
|
|
4777
|
+
const bufferedAt = st.command.updatedAt ?? "";
|
|
4778
|
+
const snapshotAt = snapshot.command.updatedAt ?? "";
|
|
4779
|
+
return bufferedAt <= snapshotAt;
|
|
4780
|
+
}
|
|
4781
|
+
return true;
|
|
4782
|
+
}
|
|
4783
|
+
function createCommandEventBroker(resolver) {
|
|
4784
|
+
let currentResolver = resolver;
|
|
4785
|
+
const subscribers = new Map;
|
|
4786
|
+
function entriesFor(commandID) {
|
|
4787
|
+
let m = subscribers.get(commandID);
|
|
4788
|
+
if (!m) {
|
|
4789
|
+
m = new Map;
|
|
4790
|
+
subscribers.set(commandID, m);
|
|
4791
|
+
}
|
|
4792
|
+
return m;
|
|
4793
|
+
}
|
|
4794
|
+
return {
|
|
4795
|
+
setResolver(next) {
|
|
4796
|
+
currentResolver = next;
|
|
4797
|
+
},
|
|
4798
|
+
async subscribe(commandID, ownerSessionID, sink) {
|
|
4799
|
+
if (!currentResolver)
|
|
4800
|
+
throw new Error("Command event broker has no resolver (service not wired).");
|
|
4801
|
+
if (!commandID)
|
|
4802
|
+
throw new Error("commandID is required.");
|
|
4803
|
+
if (!ownerSessionID)
|
|
4804
|
+
throw new Error("ownerSessionID is required.");
|
|
4805
|
+
if (typeof sink !== "function")
|
|
4806
|
+
throw new Error("sink must be a function.");
|
|
4807
|
+
const sinkID = randomUUID8();
|
|
4808
|
+
const entry = { sinkID, sink, state: "subscribing", buffer: [] };
|
|
4809
|
+
entriesFor(commandID).set(sinkID, entry);
|
|
4810
|
+
try {
|
|
4811
|
+
const session = await currentResolver.getSession(commandID, ownerSessionID);
|
|
4812
|
+
if (!session) {
|
|
4813
|
+
throw new Error("Command not found or not owned by this session (cross-owner subscribe rejected).");
|
|
4814
|
+
}
|
|
4815
|
+
await currentResolver.waitForQuiesce(commandID);
|
|
4816
|
+
const snap = await currentResolver.readSnapshot(commandID, ownerSessionID);
|
|
4817
|
+
if (!snap) {
|
|
4818
|
+
throw new Error("Command snapshot unavailable (removed or unreadable).");
|
|
4819
|
+
}
|
|
4820
|
+
const snapshotMsg = {
|
|
4821
|
+
type: "snapshot",
|
|
4822
|
+
command: snap.command,
|
|
4823
|
+
data: snap.data,
|
|
4824
|
+
startOffset: snap.startOffset,
|
|
4825
|
+
endOffset: snap.endOffset
|
|
4826
|
+
};
|
|
4827
|
+
const validated = validateCommandStreamMessage(snapshotMsg);
|
|
4828
|
+
if (!validated.ok) {
|
|
4829
|
+
throw new Error(`Invalid snapshot: ${validated.error}`);
|
|
4830
|
+
}
|
|
4831
|
+
const stillThere = subscribers.get(commandID)?.get(sinkID);
|
|
4832
|
+
if (!stillThere)
|
|
4833
|
+
throw new Error("Subscription cancelled during handshake.");
|
|
4834
|
+
const buffered = entry.buffer.splice(0);
|
|
4835
|
+
safeDeliver(entry.sink, validated.message);
|
|
4836
|
+
for (const msg of buffered) {
|
|
4837
|
+
if (isCoveredBySnapshot(msg, snapshotMsg))
|
|
4838
|
+
continue;
|
|
4839
|
+
safeDeliver(entry.sink, msg);
|
|
4840
|
+
}
|
|
4841
|
+
const extra = entry.buffer.splice(0);
|
|
4842
|
+
for (const msg of extra) {
|
|
4843
|
+
if (isCoveredBySnapshot(msg, snapshotMsg))
|
|
4844
|
+
continue;
|
|
4845
|
+
safeDeliver(entry.sink, msg);
|
|
4846
|
+
}
|
|
4847
|
+
entry.state = "live";
|
|
4848
|
+
return sinkID;
|
|
4849
|
+
} catch (error) {
|
|
4850
|
+
subscribers.get(commandID)?.delete(sinkID);
|
|
4851
|
+
if (subscribers.get(commandID)?.size === 0)
|
|
4852
|
+
subscribers.delete(commandID);
|
|
4853
|
+
throw error;
|
|
4854
|
+
}
|
|
4855
|
+
},
|
|
4856
|
+
unsubscribe(commandID, sinkID) {
|
|
4857
|
+
const m = subscribers.get(commandID);
|
|
4858
|
+
if (!m)
|
|
4859
|
+
return;
|
|
4860
|
+
m.delete(sinkID);
|
|
4861
|
+
if (m.size === 0)
|
|
4862
|
+
subscribers.delete(commandID);
|
|
4863
|
+
},
|
|
4864
|
+
publish(commandID, message) {
|
|
4865
|
+
let valid;
|
|
4866
|
+
try {
|
|
4867
|
+
const result = validateCommandStreamMessage(message);
|
|
4868
|
+
if (!result.ok)
|
|
4869
|
+
return false;
|
|
4870
|
+
valid = result.message;
|
|
4871
|
+
} catch {
|
|
4872
|
+
return false;
|
|
4873
|
+
}
|
|
4874
|
+
const m = subscribers.get(commandID);
|
|
4875
|
+
if (!m || m.size === 0)
|
|
4876
|
+
return false;
|
|
4877
|
+
let handled = false;
|
|
4878
|
+
for (const entry of m.values()) {
|
|
4879
|
+
if (entry.state === "subscribing") {
|
|
4880
|
+
entry.buffer.push(valid);
|
|
4881
|
+
handled = true;
|
|
4882
|
+
continue;
|
|
4883
|
+
}
|
|
4884
|
+
safeDeliver(entry.sink, valid);
|
|
4885
|
+
handled = true;
|
|
4886
|
+
}
|
|
4887
|
+
return handled;
|
|
4888
|
+
},
|
|
4889
|
+
subscriberCount(commandID) {
|
|
4890
|
+
return subscribers.get(commandID)?.size ?? 0;
|
|
4891
|
+
}
|
|
4892
|
+
};
|
|
4893
|
+
}
|
|
4894
|
+
|
|
4895
|
+
// src/server/command-stream-server.ts
|
|
4896
|
+
import { randomBytes, randomUUID as randomUUID9, timingSafeEqual } from "crypto";
|
|
4897
|
+
import { promises as fs4 } from "fs";
|
|
4898
|
+
import path4 from "path";
|
|
4899
|
+
function streamEndpointPath(directory) {
|
|
4900
|
+
return path4.join(directory, ".opencode", "loopd", "commands", ".stream-endpoint.json");
|
|
4901
|
+
}
|
|
4902
|
+
function isPidAlive(pid) {
|
|
4903
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
4904
|
+
return false;
|
|
4905
|
+
try {
|
|
4906
|
+
process.kill(pid, 0);
|
|
4907
|
+
return true;
|
|
4908
|
+
} catch (error) {
|
|
4909
|
+
const code = error?.code;
|
|
4910
|
+
if (code === "EPERM")
|
|
4911
|
+
return true;
|
|
4912
|
+
return false;
|
|
4913
|
+
}
|
|
4914
|
+
}
|
|
4915
|
+
function tokensEqual(a, b) {
|
|
4916
|
+
const ab = Buffer.from(a);
|
|
4917
|
+
const bb = Buffer.from(b);
|
|
4918
|
+
if (ab.length !== bb.length)
|
|
4919
|
+
return false;
|
|
4920
|
+
try {
|
|
4921
|
+
return timingSafeEqual(ab, bb);
|
|
4922
|
+
} catch {
|
|
4923
|
+
return false;
|
|
4924
|
+
}
|
|
4925
|
+
}
|
|
4926
|
+
var MAX_BUFFERED_BYTES = 512 * 1024;
|
|
4927
|
+
function createCommandStreamServer(directory, commandService, broker) {
|
|
4928
|
+
const endpointPath = streamEndpointPath(directory);
|
|
4929
|
+
const token = randomBytes(32).toString("hex");
|
|
4930
|
+
const generation = randomUUID9();
|
|
4931
|
+
const startedAt = new Date().toISOString();
|
|
4932
|
+
let server;
|
|
4933
|
+
let serverURL;
|
|
4934
|
+
let started = false;
|
|
4935
|
+
let stopped = false;
|
|
4936
|
+
const sockets = new Set;
|
|
4937
|
+
const subsBySocket = new Map;
|
|
4938
|
+
const handshakeBySocket = new Map;
|
|
4939
|
+
function nextHandshake(ws, commandID) {
|
|
4940
|
+
let m = handshakeBySocket.get(ws);
|
|
4941
|
+
if (!m) {
|
|
4942
|
+
m = new Map;
|
|
4943
|
+
handshakeBySocket.set(ws, m);
|
|
4944
|
+
}
|
|
4945
|
+
const next = (m.get(commandID) ?? 0) + 1;
|
|
4946
|
+
m.set(commandID, next);
|
|
4947
|
+
return next;
|
|
4948
|
+
}
|
|
4949
|
+
function currentHandshake(ws, commandID) {
|
|
4950
|
+
return handshakeBySocket.get(ws)?.get(commandID);
|
|
4951
|
+
}
|
|
4952
|
+
function clearHandshake(ws, commandID, generation) {
|
|
4953
|
+
const m = handshakeBySocket.get(ws);
|
|
4954
|
+
if (m?.get(commandID) === generation)
|
|
4955
|
+
m.delete(commandID);
|
|
4956
|
+
if (m && m.size === 0)
|
|
4957
|
+
handshakeBySocket.delete(ws);
|
|
4958
|
+
}
|
|
4959
|
+
function sendError(ws, code, message) {
|
|
4960
|
+
try {
|
|
4961
|
+
ws.send(JSON.stringify({ type: "error", code, message }));
|
|
4962
|
+
} catch {}
|
|
4963
|
+
}
|
|
4964
|
+
function sendToSocket(ws, payload) {
|
|
4965
|
+
try {
|
|
4966
|
+
const buffered = ws.bufferedAmount ?? 0;
|
|
4967
|
+
if (buffered > MAX_BUFFERED_BYTES) {
|
|
4968
|
+
try {
|
|
4969
|
+
ws.close(1011, "backpressure overflow: resync");
|
|
4970
|
+
} catch {}
|
|
4971
|
+
return false;
|
|
4972
|
+
}
|
|
4973
|
+
ws.send(payload);
|
|
4974
|
+
return true;
|
|
4975
|
+
} catch {
|
|
4976
|
+
return false;
|
|
4977
|
+
}
|
|
4978
|
+
}
|
|
4979
|
+
function makeSink(ws) {
|
|
4980
|
+
return (msg) => {
|
|
4981
|
+
if (stopped || !sockets.has(ws))
|
|
4982
|
+
return;
|
|
4983
|
+
sendToSocket(ws, JSON.stringify(msg));
|
|
4984
|
+
};
|
|
4985
|
+
}
|
|
4986
|
+
function subsFor(ws) {
|
|
4987
|
+
let m = subsBySocket.get(ws);
|
|
4988
|
+
if (!m) {
|
|
4989
|
+
m = new Map;
|
|
4990
|
+
subsBySocket.set(ws, m);
|
|
4991
|
+
}
|
|
4992
|
+
return m;
|
|
4993
|
+
}
|
|
4994
|
+
function cleanupSocket(ws) {
|
|
4995
|
+
const subs = subsBySocket.get(ws);
|
|
4996
|
+
if (subs) {
|
|
4997
|
+
for (const [commandID, entry] of subs) {
|
|
4998
|
+
try {
|
|
4999
|
+
broker.unsubscribe(commandID, entry.sinkID);
|
|
5000
|
+
} catch {}
|
|
5001
|
+
}
|
|
5002
|
+
subsBySocket.delete(ws);
|
|
5003
|
+
}
|
|
5004
|
+
handshakeBySocket.delete(ws);
|
|
5005
|
+
sockets.delete(ws);
|
|
5006
|
+
}
|
|
5007
|
+
async function handleSubscribe(ws, commandID, ownerSessionID) {
|
|
5008
|
+
const generation = nextHandshake(ws, commandID);
|
|
5009
|
+
const subs = subsFor(ws);
|
|
5010
|
+
const previous = subs.get(commandID);
|
|
5011
|
+
if (previous) {
|
|
5012
|
+
try {
|
|
5013
|
+
broker.unsubscribe(commandID, previous.sinkID);
|
|
5014
|
+
} catch {}
|
|
5015
|
+
subs.delete(commandID);
|
|
5016
|
+
}
|
|
5017
|
+
const sink = makeSink(ws);
|
|
5018
|
+
let sinkID;
|
|
5019
|
+
try {
|
|
5020
|
+
sinkID = await broker.subscribe(commandID, ownerSessionID, sink);
|
|
5021
|
+
} catch (error) {
|
|
5022
|
+
if (currentHandshake(ws, commandID) !== generation)
|
|
5023
|
+
return;
|
|
5024
|
+
clearHandshake(ws, commandID, generation);
|
|
5025
|
+
sendError(ws, "subscribe-failed", error instanceof Error ? error.message : String(error));
|
|
5026
|
+
return;
|
|
5027
|
+
}
|
|
5028
|
+
if (currentHandshake(ws, commandID) !== generation) {
|
|
5029
|
+
try {
|
|
5030
|
+
broker.unsubscribe(commandID, sinkID);
|
|
5031
|
+
} catch {}
|
|
5032
|
+
return;
|
|
5033
|
+
}
|
|
5034
|
+
clearHandshake(ws, commandID, generation);
|
|
5035
|
+
if (!sockets.has(ws)) {
|
|
5036
|
+
try {
|
|
5037
|
+
broker.unsubscribe(commandID, sinkID);
|
|
5038
|
+
} catch {}
|
|
5039
|
+
return;
|
|
5040
|
+
}
|
|
5041
|
+
subs.set(commandID, { sinkID, ownerSessionID });
|
|
5042
|
+
}
|
|
5043
|
+
async function handleResync(ws, commandID) {
|
|
5044
|
+
const subs = subsFor(ws);
|
|
5045
|
+
const previous = subs.get(commandID);
|
|
5046
|
+
if (!previous) {
|
|
5047
|
+
sendError(ws, "not-subscribed", `No subscription for command ${commandID} on this socket; subscribe first.`);
|
|
5048
|
+
return;
|
|
5049
|
+
}
|
|
5050
|
+
await handleSubscribe(ws, commandID, previous.ownerSessionID);
|
|
5051
|
+
}
|
|
5052
|
+
function handleUnsubscribe(ws, commandID) {
|
|
5053
|
+
if (handshakeBySocket.get(ws)?.has(commandID))
|
|
5054
|
+
nextHandshake(ws, commandID);
|
|
5055
|
+
const subs = subsFor(ws);
|
|
5056
|
+
const previous = subs.get(commandID);
|
|
5057
|
+
if (previous) {
|
|
5058
|
+
try {
|
|
5059
|
+
broker.unsubscribe(commandID, previous.sinkID);
|
|
5060
|
+
} catch {}
|
|
5061
|
+
subs.delete(commandID);
|
|
5062
|
+
}
|
|
5063
|
+
}
|
|
5064
|
+
async function handleInput(ws, commandID, data) {
|
|
5065
|
+
const owner = subsFor(ws).get(commandID)?.ownerSessionID;
|
|
5066
|
+
if (!owner) {
|
|
5067
|
+
sendError(ws, "not-subscribed", `No subscription for command ${commandID} on this socket; subscribe first.`);
|
|
5068
|
+
return;
|
|
5069
|
+
}
|
|
5070
|
+
try {
|
|
5071
|
+
const result = await commandService.write(directory, commandID, owner, data);
|
|
5072
|
+
if (!result.ok)
|
|
5073
|
+
sendError(ws, "input-failed", result.message);
|
|
5074
|
+
} catch (error) {
|
|
5075
|
+
sendError(ws, "input-failed", error instanceof Error ? error.message : String(error));
|
|
5076
|
+
}
|
|
5077
|
+
}
|
|
5078
|
+
async function handleInterrupt(ws, commandID) {
|
|
5079
|
+
const owner = subsFor(ws).get(commandID)?.ownerSessionID;
|
|
5080
|
+
if (!owner) {
|
|
5081
|
+
sendError(ws, "not-subscribed", `No subscription for command ${commandID} on this socket; subscribe first.`);
|
|
5082
|
+
return;
|
|
5083
|
+
}
|
|
5084
|
+
try {
|
|
5085
|
+
const result = await commandService.interrupt(directory, commandID, owner);
|
|
5086
|
+
if (!result.ok)
|
|
5087
|
+
sendError(ws, "interrupt-failed", result.message);
|
|
5088
|
+
} catch (error) {
|
|
5089
|
+
sendError(ws, "interrupt-failed", error instanceof Error ? error.message : String(error));
|
|
5090
|
+
}
|
|
5091
|
+
}
|
|
5092
|
+
async function handleSocketMessage(ws, raw) {
|
|
5093
|
+
let parsed;
|
|
5094
|
+
try {
|
|
5095
|
+
const text = typeof raw === "string" ? raw : Buffer.from(raw).toString("utf8");
|
|
5096
|
+
parsed = JSON.parse(text);
|
|
5097
|
+
} catch {
|
|
5098
|
+
sendError(ws, "invalid-message", "Message must be JSON.");
|
|
5099
|
+
return;
|
|
5100
|
+
}
|
|
5101
|
+
let validated;
|
|
5102
|
+
try {
|
|
5103
|
+
validated = validateCommandStreamMessage(parsed);
|
|
5104
|
+
} catch (error) {
|
|
5105
|
+
sendError(ws, "invalid-message", `Validation failed: ${String(error)}`);
|
|
5106
|
+
return;
|
|
5107
|
+
}
|
|
5108
|
+
if (!validated.ok) {
|
|
5109
|
+
sendError(ws, "invalid-message", validated.error);
|
|
5110
|
+
return;
|
|
5111
|
+
}
|
|
5112
|
+
const msg = validated.message;
|
|
5113
|
+
try {
|
|
5114
|
+
switch (msg.type) {
|
|
5115
|
+
case "subscribe":
|
|
5116
|
+
await handleSubscribe(ws, msg.commandID, msg.ownerSessionID);
|
|
5117
|
+
break;
|
|
5118
|
+
case "resync":
|
|
5119
|
+
await handleResync(ws, msg.commandID);
|
|
5120
|
+
break;
|
|
5121
|
+
case "unsubscribe":
|
|
5122
|
+
handleUnsubscribe(ws, msg.commandID);
|
|
5123
|
+
break;
|
|
5124
|
+
case "input":
|
|
5125
|
+
await handleInput(ws, msg.commandID, msg.data);
|
|
5126
|
+
break;
|
|
5127
|
+
case "interrupt":
|
|
5128
|
+
await handleInterrupt(ws, msg.commandID);
|
|
5129
|
+
break;
|
|
5130
|
+
default:
|
|
5131
|
+
sendError(ws, "invalid-message", `Message type "${msg.type}" is not accepted inbound.`);
|
|
5132
|
+
break;
|
|
5133
|
+
}
|
|
5134
|
+
} catch (error) {
|
|
5135
|
+
sendError(ws, "internal-error", error instanceof Error ? error.message : String(error));
|
|
5136
|
+
}
|
|
5137
|
+
}
|
|
5138
|
+
async function writeEndpointFile(url) {
|
|
5139
|
+
const endpoint = {
|
|
5140
|
+
url,
|
|
5141
|
+
token,
|
|
5142
|
+
pid: process.pid,
|
|
5143
|
+
generation,
|
|
5144
|
+
startedAt
|
|
5145
|
+
};
|
|
5146
|
+
await fs4.mkdir(path4.dirname(endpointPath), { recursive: true });
|
|
5147
|
+
await fs4.writeFile(endpointPath, JSON.stringify(endpoint, null, 2) + `
|
|
5148
|
+
`, { mode: 384 });
|
|
5149
|
+
await fs4.chmod(endpointPath, 384);
|
|
5150
|
+
}
|
|
5151
|
+
return {
|
|
5152
|
+
get url() {
|
|
5153
|
+
return serverURL;
|
|
5154
|
+
},
|
|
5155
|
+
token,
|
|
5156
|
+
generation,
|
|
5157
|
+
endpointPath,
|
|
5158
|
+
async start() {
|
|
5159
|
+
if (started)
|
|
5160
|
+
return;
|
|
5161
|
+
started = true;
|
|
5162
|
+
try {
|
|
5163
|
+
const previous = await fs4.readFile(endpointPath, "utf8").then((text) => JSON.parse(text), () => {
|
|
5164
|
+
return;
|
|
5165
|
+
});
|
|
5166
|
+
if (previous && typeof previous.pid === "number" && isPidAlive(previous.pid) && previous.pid !== process.pid) {
|
|
5167
|
+
logServerEvent(directory, "command-stream.superseded-live-endpoint", { pid: previous.pid });
|
|
5168
|
+
}
|
|
5169
|
+
} catch {}
|
|
5170
|
+
const srv = Bun.serve({
|
|
5171
|
+
hostname: "127.0.0.1",
|
|
5172
|
+
port: 0,
|
|
5173
|
+
fetch(req, upgrading) {
|
|
5174
|
+
if (stopped)
|
|
5175
|
+
return new Response("Server is stopping.", { status: 503 });
|
|
5176
|
+
let url;
|
|
5177
|
+
try {
|
|
5178
|
+
url = new URL(req.url);
|
|
5179
|
+
} catch {
|
|
5180
|
+
return new Response("Bad request.", { status: 400 });
|
|
5181
|
+
}
|
|
5182
|
+
if (url.pathname !== "/" && url.pathname !== "/stream") {
|
|
5183
|
+
return new Response("Not found.", { status: 404 });
|
|
5184
|
+
}
|
|
5185
|
+
const presented = url.searchParams.get("token") ?? "";
|
|
5186
|
+
if (!presented || !tokensEqual(presented, token)) {
|
|
5187
|
+
return new Response("Forbidden: valid connection token required.", { status: 403 });
|
|
5188
|
+
}
|
|
5189
|
+
const ok = upgrading.upgrade(req, { data: undefined });
|
|
5190
|
+
if (!ok)
|
|
5191
|
+
return new Response("WebSocket upgrade required.", { status: 400 });
|
|
5192
|
+
return;
|
|
5193
|
+
},
|
|
5194
|
+
websocket: {
|
|
5195
|
+
open(ws) {
|
|
5196
|
+
sockets.add(ws);
|
|
5197
|
+
},
|
|
5198
|
+
message(ws, raw) {
|
|
5199
|
+
handleSocketMessage(ws, raw).catch((error) => {
|
|
5200
|
+
try {
|
|
5201
|
+
sendError(ws, "internal-error", describeError(error));
|
|
5202
|
+
} catch {}
|
|
5203
|
+
});
|
|
5204
|
+
},
|
|
5205
|
+
close(ws) {
|
|
5206
|
+
cleanupSocket(ws);
|
|
5207
|
+
}
|
|
5208
|
+
}
|
|
5209
|
+
});
|
|
5210
|
+
server = srv;
|
|
5211
|
+
const port = srv.port;
|
|
5212
|
+
serverURL = `ws://127.0.0.1:${port}/stream?token=${token}`;
|
|
5213
|
+
await writeEndpointFile(serverURL);
|
|
5214
|
+
logServerEvent(directory, "command-stream.started", { port, generation });
|
|
5215
|
+
},
|
|
5216
|
+
async stop() {
|
|
5217
|
+
stopped = true;
|
|
5218
|
+
for (const ws of [...sockets]) {
|
|
5219
|
+
try {
|
|
5220
|
+
ws.close(1001, "server shutting down");
|
|
5221
|
+
} catch {}
|
|
5222
|
+
cleanupSocket(ws);
|
|
5223
|
+
}
|
|
5224
|
+
sockets.clear();
|
|
5225
|
+
subsBySocket.clear();
|
|
5226
|
+
handshakeBySocket.clear();
|
|
5227
|
+
try {
|
|
5228
|
+
server?.stop(true);
|
|
5229
|
+
} catch {}
|
|
5230
|
+
server = undefined;
|
|
5231
|
+
serverURL = undefined;
|
|
5232
|
+
try {
|
|
5233
|
+
const text = await fs4.readFile(endpointPath, "utf8");
|
|
5234
|
+
const current = JSON.parse(text);
|
|
5235
|
+
if (current?.generation === generation) {
|
|
5236
|
+
await fs4.unlink(endpointPath);
|
|
5237
|
+
} else {
|
|
5238
|
+
logServerEvent(directory, "command-stream.keep-endpoint", {
|
|
5239
|
+
reason: "generation mismatch \u2014 another server owns the file"
|
|
5240
|
+
});
|
|
5241
|
+
}
|
|
5242
|
+
} catch {}
|
|
5243
|
+
logServerEvent(directory, "command-stream.stopped", { generation });
|
|
5244
|
+
}
|
|
5245
|
+
};
|
|
5246
|
+
}
|
|
5247
|
+
|
|
5248
|
+
// src/server/goal-tools.ts
|
|
5249
|
+
init_state_repository();
|
|
5250
|
+
import { randomUUID as randomUUID10 } from "crypto";
|
|
5251
|
+
import { tool } from "@opencode-ai/plugin/tool";
|
|
5252
|
+
// src/domain/verification.ts
|
|
5253
|
+
var MAX_RECENT_ATTEMPTS = 10;
|
|
5254
|
+
function appendVerificationAttempt(recent, attempt) {
|
|
5255
|
+
const next = [...recent, attempt];
|
|
5256
|
+
if (next.length > MAX_RECENT_ATTEMPTS) {
|
|
5257
|
+
return next.slice(next.length - MAX_RECENT_ATTEMPTS);
|
|
5258
|
+
}
|
|
5259
|
+
return next;
|
|
5260
|
+
}
|
|
5261
|
+
|
|
5262
|
+
// src/server/goal-tools.ts
|
|
5263
|
+
import { exec as execChild } from "child_process";
|
|
5264
|
+
import { promisify } from "util";
|
|
5265
|
+
var execAsync = promisify(execChild);
|
|
5266
|
+
function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
5267
|
+
return {
|
|
5268
|
+
loopd_create_goal: tool({
|
|
5269
|
+
description: "Create a new background loop goal (contract: objective + checks + agent/model + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Call this after clarifying the contract with the user. " + "Worker identity is free-form: agent is any OpenCode agent name (built-in, ~/.config/opencode/agents/*.md, or opencode.jsonc agent.* \u2014 discover with `opencode agent list`), " + 'model is any "providerID/modelID" (discover with `opencode models [provider]`). When omitted, both inherit the CALLING session\'s live agent/model (read at creation), then plugin defaultAgent/defaultModel. ' + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals are serialized (only one active writer) and require checks.",
|
|
5270
|
+
args: {
|
|
5271
|
+
name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
|
|
5272
|
+
objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
|
|
5273
|
+
agent: tool.schema.string().optional().describe(`Agent to run the worker as (e.g. "researcher", "smart-agent"). Optional \u2014 inherits the calling session's agent if omitted, else plugin defaultAgent.`),
|
|
5274
|
+
model: tool.schema.string().optional().describe(`Model to run the worker as, as "providerID/modelID" (e.g. "openai/gpt-5.6-sol", "ollama/qwen3.8:27b"). Optional \u2014 inherits the calling session's live model if omitted, else plugin defaultModel.`),
|
|
5275
|
+
costBudget: tool.schema.number().optional().describe("Max provider cost in dollars before the engine stops the goal as budget_limited (e.g. 0.5). Optional \u2014 unlimited if omitted."),
|
|
5276
|
+
checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
|
|
5277
|
+
checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
|
|
5278
|
+
workspaceWrite: tool.schema.boolean().optional().describe("Whether this goal edits the shared project workspace. Defaults to true; explicitly set false for artifact-only/read-only work."),
|
|
5279
|
+
progressFile: tool.schema.string().optional().describe("Markdown file the worker reads/writes as its transaction state."),
|
|
5280
|
+
maxTurns: tool.schema.number().optional().describe("Max turns before auto-block."),
|
|
5281
|
+
maxNoProgress: tool.schema.number().optional().describe("Block after N turns without progress."),
|
|
5282
|
+
maxFailures: tool.schema.number().optional().describe("Block after N consecutive failures."),
|
|
5283
|
+
compactEvery: tool.schema.number().optional().describe("Compact the worker session every N turns."),
|
|
5284
|
+
timeoutMs: tool.schema.number().optional().describe("Per-turn timeout in ms."),
|
|
5285
|
+
scheduleEveryMs: tool.schema.number().optional().describe("Interval in ms to auto-requeue the same goal after each completion. Minimum 1000. Enables repetitive dialogue reduction."),
|
|
5286
|
+
scheduleMaxRuns: tool.schema.number().optional().describe("Maximum total runs including the initial run. Undefined = unlimited. Requires scheduleEveryMs.")
|
|
5287
|
+
},
|
|
5288
|
+
execute: async (args, context) => {
|
|
5289
|
+
const sessionID = context?.sessionID || hostSessionID;
|
|
5290
|
+
if (!sessionID || sessionID === "main") {
|
|
5291
|
+
return {
|
|
5292
|
+
title: "Goal not created",
|
|
5293
|
+
output: JSON.stringify({
|
|
5294
|
+
ok: false,
|
|
5295
|
+
message: "A valid owner session is required. Run /goal from an active OpenCode session."
|
|
5296
|
+
})
|
|
5297
|
+
};
|
|
5298
|
+
}
|
|
5299
|
+
const config = {
|
|
5300
|
+
maxTurns: 50
|
|
5301
|
+
};
|
|
5302
|
+
if (args.agent)
|
|
5303
|
+
config.agent = args.agent;
|
|
5304
|
+
if (args.model)
|
|
5305
|
+
config.model = args.model;
|
|
5306
|
+
if (args.checks)
|
|
5307
|
+
config.checks = args.checks;
|
|
5308
|
+
if (args.checkCwd)
|
|
5309
|
+
config.checkCwd = args.checkCwd;
|
|
5310
|
+
if (args.workspaceWrite !== undefined)
|
|
5311
|
+
config.workspaceWrite = args.workspaceWrite;
|
|
5312
|
+
if (args.progressFile)
|
|
3345
5313
|
config.progressFile = args.progressFile;
|
|
3346
5314
|
if (args.maxTurns !== undefined)
|
|
3347
5315
|
config.maxTurns = args.maxTurns;
|
|
@@ -3430,12 +5398,20 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
3430
5398
|
})
|
|
3431
5399
|
};
|
|
3432
5400
|
} catch (error) {
|
|
5401
|
+
const startFailure = error instanceof GoalStartError ? error : undefined;
|
|
3433
5402
|
return {
|
|
3434
5403
|
title: "Goal creation failed",
|
|
3435
5404
|
output: JSON.stringify({
|
|
3436
5405
|
ok: false,
|
|
3437
5406
|
name: args.name,
|
|
3438
5407
|
message: error instanceof Error ? error.message : String(error),
|
|
5408
|
+
...startFailure ? {
|
|
5409
|
+
goalID: startFailure.goalID,
|
|
5410
|
+
status: "blocked",
|
|
5411
|
+
failedStage: startFailure.failedStage,
|
|
5412
|
+
...startFailure.workerSessionID ? { workerSessionID: startFailure.workerSessionID } : {},
|
|
5413
|
+
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.`
|
|
5414
|
+
} : {},
|
|
3439
5415
|
diagnostics: SERVER_LOG_FILE
|
|
3440
5416
|
})
|
|
3441
5417
|
};
|
|
@@ -3496,7 +5472,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
3496
5472
|
await writeState(dir, state);
|
|
3497
5473
|
const event = {
|
|
3498
5474
|
version: 1,
|
|
3499
|
-
eventID:
|
|
5475
|
+
eventID: randomUUID10(),
|
|
3500
5476
|
goalID: goal.id,
|
|
3501
5477
|
type: "goal.progress",
|
|
3502
5478
|
summary: args.summary,
|
|
@@ -3561,7 +5537,7 @@ Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
|
|
|
3561
5537
|
Working directory: ${cwd}
|
|
3562
5538
|
|
|
3563
5539
|
${failureDetails}`;
|
|
3564
|
-
attemptID =
|
|
5540
|
+
attemptID = randomUUID10();
|
|
3565
5541
|
const verificationAttempt = {
|
|
3566
5542
|
id: attemptID,
|
|
3567
5543
|
sequence: runtime.evaluatorRejectionCount,
|
|
@@ -3606,7 +5582,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
3606
5582
|
if (attemptID) {
|
|
3607
5583
|
await appendEvent(dir, {
|
|
3608
5584
|
version: 1,
|
|
3609
|
-
eventID:
|
|
5585
|
+
eventID: randomUUID10(),
|
|
3610
5586
|
goalID: goal.id,
|
|
3611
5587
|
type: "goal.completion_rejected",
|
|
3612
5588
|
attemptID,
|
|
@@ -3624,7 +5600,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
3624
5600
|
if (blocked && rejectedGoal?.blocker) {
|
|
3625
5601
|
await appendEvent(dir, {
|
|
3626
5602
|
version: 1,
|
|
3627
|
-
eventID:
|
|
5603
|
+
eventID: randomUUID10(),
|
|
3628
5604
|
goalID: goal.id,
|
|
3629
5605
|
type: "goal.blocked",
|
|
3630
5606
|
reason: rejectedGoal.blocker.reason,
|
|
@@ -3684,7 +5660,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
3684
5660
|
}
|
|
3685
5661
|
runtime.updatedAt = new Date().toISOString();
|
|
3686
5662
|
}
|
|
3687
|
-
const attemptID =
|
|
5663
|
+
const attemptID = randomUUID10();
|
|
3688
5664
|
const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
|
|
3689
5665
|
const checks = (goal.config.checks || []).map((cmd) => ({
|
|
3690
5666
|
command: cmd,
|
|
@@ -3708,7 +5684,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
3708
5684
|
await writeState(dir, state);
|
|
3709
5685
|
const event = {
|
|
3710
5686
|
version: 1,
|
|
3711
|
-
eventID:
|
|
5687
|
+
eventID: randomUUID10(),
|
|
3712
5688
|
goalID: goal.id,
|
|
3713
5689
|
type: "goal.completed",
|
|
3714
5690
|
summary: args.summary,
|
|
@@ -3768,7 +5744,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
3768
5744
|
await writeState(dir, state);
|
|
3769
5745
|
const event = {
|
|
3770
5746
|
version: 1,
|
|
3771
|
-
eventID:
|
|
5747
|
+
eventID: randomUUID10(),
|
|
3772
5748
|
goalID: goal.id,
|
|
3773
5749
|
type: "goal.blocked",
|
|
3774
5750
|
reason: args.reason,
|
|
@@ -3948,7 +5924,7 @@ function describeGoalState(status, phase) {
|
|
|
3948
5924
|
}
|
|
3949
5925
|
|
|
3950
5926
|
// src/server/owner-tools.ts
|
|
3951
|
-
import { promises as
|
|
5927
|
+
import { promises as fs5 } from "fs";
|
|
3952
5928
|
function withTimeout2(promise, ms) {
|
|
3953
5929
|
return Promise.race([
|
|
3954
5930
|
promise,
|
|
@@ -4030,9 +6006,24 @@ function ownerTools(options) {
|
|
|
4030
6006
|
const ownerID = context?.sessionID;
|
|
4031
6007
|
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
6008
|
if (!goal) {
|
|
6009
|
+
if (!ownerID) {
|
|
6010
|
+
return {
|
|
6011
|
+
title: "No goal found",
|
|
6012
|
+
output: JSON.stringify({ ok: false, message: "No session context available, so goal ownership cannot be matched." })
|
|
6013
|
+
};
|
|
6014
|
+
}
|
|
6015
|
+
const ownedActive = state.goals.filter((g) => g.ownerSessionID === ownerID && g.status !== "complete");
|
|
6016
|
+
const ownedComplete = state.goals.filter((g) => g.ownerSessionID === ownerID && g.status === "complete").length;
|
|
6017
|
+
const othersActive = state.goals.filter((g) => g.ownerSessionID !== ownerID && g.status !== "complete").length;
|
|
6018
|
+
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
6019
|
return {
|
|
4034
6020
|
title: "No goal found",
|
|
4035
|
-
output: JSON.stringify({
|
|
6021
|
+
output: JSON.stringify({
|
|
6022
|
+
ok: false,
|
|
6023
|
+
message,
|
|
6024
|
+
ownedGoals: ownedActive.map((g) => ({ id: g.id, name: g.name, status: g.status })),
|
|
6025
|
+
ownedElsewhereActive: othersActive
|
|
6026
|
+
})
|
|
4036
6027
|
};
|
|
4037
6028
|
}
|
|
4038
6029
|
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
@@ -4046,7 +6037,7 @@ function ownerTools(options) {
|
|
|
4046
6037
|
if (!dir)
|
|
4047
6038
|
return;
|
|
4048
6039
|
try {
|
|
4049
|
-
const files = await
|
|
6040
|
+
const files = await fs5.readdir(dir);
|
|
4050
6041
|
return files.length ? `${files.length} file(s): ${files.slice(0, 8).join(", ")}` : "no artifacts yet";
|
|
4051
6042
|
} catch {
|
|
4052
6043
|
return "no artifacts yet";
|
|
@@ -4516,19 +6507,264 @@ function ownerTools(options) {
|
|
|
4516
6507
|
};
|
|
4517
6508
|
}
|
|
4518
6509
|
|
|
6510
|
+
// src/server/command-tools.ts
|
|
6511
|
+
init_command_await2();
|
|
6512
|
+
import { tool as tool3 } from "@opencode-ai/plugin/tool";
|
|
6513
|
+
function ownerID(context) {
|
|
6514
|
+
const id = context?.sessionID;
|
|
6515
|
+
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
6516
|
+
}
|
|
6517
|
+
function denied() {
|
|
6518
|
+
return {
|
|
6519
|
+
title: "No session",
|
|
6520
|
+
output: JSON.stringify({
|
|
6521
|
+
ok: false,
|
|
6522
|
+
message: "No session context available. Arbitrary commands require an owning session and are never auto-allowed.",
|
|
6523
|
+
errorCode: "no_session"
|
|
6524
|
+
})
|
|
6525
|
+
};
|
|
6526
|
+
}
|
|
6527
|
+
function summarize(c) {
|
|
6528
|
+
return {
|
|
6529
|
+
id: c.id,
|
|
6530
|
+
title: c.title,
|
|
6531
|
+
argv: [c.command, ...c.args],
|
|
6532
|
+
status: c.status,
|
|
6533
|
+
exitCode: c.exitCode,
|
|
6534
|
+
signal: c.signal,
|
|
6535
|
+
outputBytes: c.outputBytes,
|
|
6536
|
+
truncated: c.truncated,
|
|
6537
|
+
goalID: c.goalID,
|
|
6538
|
+
updatedAt: c.updatedAt
|
|
6539
|
+
};
|
|
6540
|
+
}
|
|
6541
|
+
function commandTools(options) {
|
|
6542
|
+
const { directory, commandService } = options;
|
|
6543
|
+
const capabilities = options.capabilities ?? COMMAND_HOST_CAPABILITIES;
|
|
6544
|
+
const sizeNote = capabilities.resize ? "applied live to the PTY winsize" : "stored; resize is unsupported by the pipe host";
|
|
6545
|
+
return {
|
|
6546
|
+
loopd_command_start: tool3({
|
|
6547
|
+
description: "Start a standalone interactive command session (arbitrary shell command) in the background. Returns an ID for write/read/interrupt/terminate/remove. Independent from goals: linking a goalID is display-only and never couples lifecycles.",
|
|
6548
|
+
args: {
|
|
6549
|
+
title: tool3.schema.string().describe("Short human label for the session."),
|
|
6550
|
+
command: tool3.schema.string().describe('Executable to spawn (e.g. "bun", "python3").'),
|
|
6551
|
+
args: tool3.schema.array(tool3.schema.string()).optional().describe("Arguments for the command."),
|
|
6552
|
+
cwd: tool3.schema.string().optional().describe("Working directory. Defaults to the project root."),
|
|
6553
|
+
goal_id: tool3.schema.string().optional().describe("Optional goal linkage (display only \u2014 no lifecycle coupling)."),
|
|
6554
|
+
cols: tool3.schema.number().optional().describe(`Requested terminal width (${sizeNote}).`),
|
|
6555
|
+
rows: tool3.schema.number().optional().describe(`Requested terminal height (${sizeNote}).`)
|
|
6556
|
+
},
|
|
6557
|
+
execute: async (args, context) => {
|
|
6558
|
+
const owner = ownerID(context);
|
|
6559
|
+
if (!owner)
|
|
6560
|
+
return denied();
|
|
6561
|
+
try {
|
|
6562
|
+
const argv = [args.command, ...args.args ?? []];
|
|
6563
|
+
await context.ask({
|
|
6564
|
+
permission: "bash",
|
|
6565
|
+
patterns: [argv.join(" ")],
|
|
6566
|
+
always: [args.command],
|
|
6567
|
+
metadata: { command: args.command, args: args.args ?? [], cwd: args.cwd ?? directory }
|
|
6568
|
+
});
|
|
6569
|
+
const session = await commandService.start(directory, {
|
|
6570
|
+
title: args.title,
|
|
6571
|
+
command: args.command,
|
|
6572
|
+
args: args.args ?? [],
|
|
6573
|
+
cwd: args.cwd,
|
|
6574
|
+
ownerSessionID: owner,
|
|
6575
|
+
goalID: args.goal_id,
|
|
6576
|
+
cols: args.cols,
|
|
6577
|
+
rows: args.rows
|
|
6578
|
+
});
|
|
6579
|
+
return {
|
|
6580
|
+
title: "Command started",
|
|
6581
|
+
output: JSON.stringify({ ok: true, command: summarize(session), capabilities }, null, 2)
|
|
6582
|
+
};
|
|
6583
|
+
} catch (error) {
|
|
6584
|
+
return {
|
|
6585
|
+
title: "Command not started",
|
|
6586
|
+
output: JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) })
|
|
6587
|
+
};
|
|
6588
|
+
}
|
|
6589
|
+
}
|
|
6590
|
+
}),
|
|
6591
|
+
loopd_command_list: tool3({
|
|
6592
|
+
description: "List standalone command sessions owned by this session.",
|
|
6593
|
+
args: {},
|
|
6594
|
+
execute: async (_args, context) => {
|
|
6595
|
+
const owner = ownerID(context);
|
|
6596
|
+
if (!owner)
|
|
6597
|
+
return denied();
|
|
6598
|
+
const sessions = await commandService.list(directory, owner);
|
|
6599
|
+
return {
|
|
6600
|
+
title: `${sessions.length} command session(s)`,
|
|
6601
|
+
output: JSON.stringify({ ok: true, commands: sessions.map((c) => summarize(c)), capabilities }, null, 2)
|
|
6602
|
+
};
|
|
6603
|
+
}
|
|
6604
|
+
}),
|
|
6605
|
+
loopd_command_get: tool3({
|
|
6606
|
+
description: "Get a command session's metadata plus a bounded output snapshot. Closing a view detaches; it never terminates.",
|
|
6607
|
+
args: {
|
|
6608
|
+
command_id: tool3.schema.string().describe("Command session ID."),
|
|
6609
|
+
offset_bytes: tool3.schema.number().optional().describe("Byte offset into the output log (paging)."),
|
|
6610
|
+
limit_bytes: tool3.schema.number().optional().describe("Max bytes to return (default 64KB, cap 256KB).")
|
|
6611
|
+
},
|
|
6612
|
+
execute: async (args, context) => {
|
|
6613
|
+
const owner = ownerID(context);
|
|
6614
|
+
if (!owner)
|
|
6615
|
+
return denied();
|
|
6616
|
+
const result = await commandService.read(directory, args.command_id, owner, {
|
|
6617
|
+
offsetBytes: args.offset_bytes,
|
|
6618
|
+
limitBytes: args.limit_bytes
|
|
6619
|
+
});
|
|
6620
|
+
if (!result) {
|
|
6621
|
+
return { title: "Not found", output: JSON.stringify({ ok: false, message: "Command not found for this session." }) };
|
|
6622
|
+
}
|
|
6623
|
+
return {
|
|
6624
|
+
title: `Command: ${result.session.title}`,
|
|
6625
|
+
output: JSON.stringify({
|
|
6626
|
+
ok: true,
|
|
6627
|
+
command: summarize(result.session),
|
|
6628
|
+
output: result.text,
|
|
6629
|
+
startByte: result.startByte,
|
|
6630
|
+
totalBytes: result.totalBytes,
|
|
6631
|
+
live: result.live
|
|
6632
|
+
}, null, 2)
|
|
6633
|
+
};
|
|
6634
|
+
}
|
|
6635
|
+
}),
|
|
6636
|
+
loopd_command_write: tool3({
|
|
6637
|
+
description: "Send raw input (stdin bytes) to a running command session.",
|
|
6638
|
+
args: {
|
|
6639
|
+
command_id: tool3.schema.string().describe("Command session ID."),
|
|
6640
|
+
input: tool3.schema.string().describe("Raw text to write to stdin (include trailing newline for line-buffered programs).")
|
|
6641
|
+
},
|
|
6642
|
+
execute: async (args, context) => {
|
|
6643
|
+
const owner = ownerID(context);
|
|
6644
|
+
if (!owner)
|
|
6645
|
+
return denied();
|
|
6646
|
+
const result = await commandService.write(directory, args.command_id, owner, args.input);
|
|
6647
|
+
return { title: result.ok ? "Input sent" : "Write failed", output: JSON.stringify({ ...result, command_id: args.command_id }) };
|
|
6648
|
+
}
|
|
6649
|
+
}),
|
|
6650
|
+
loopd_command_interrupt: tool3({
|
|
6651
|
+
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.",
|
|
6652
|
+
args: {
|
|
6653
|
+
command_id: tool3.schema.string().describe("Command session ID.")
|
|
6654
|
+
},
|
|
6655
|
+
execute: async (args, context) => {
|
|
6656
|
+
const owner = ownerID(context);
|
|
6657
|
+
if (!owner)
|
|
6658
|
+
return denied();
|
|
6659
|
+
const result = await commandService.interrupt(directory, args.command_id, owner);
|
|
6660
|
+
return { title: result.ok ? "Interrupted" : "Interrupt failed", output: JSON.stringify({ ...result, command_id: args.command_id }) };
|
|
6661
|
+
}
|
|
6662
|
+
}),
|
|
6663
|
+
loopd_command_terminate: tool3({
|
|
6664
|
+
description: "Terminate a running command (SIGTERM, escalates to SIGKILL). Stopping a command never pauses/blocks any goal.",
|
|
6665
|
+
args: {
|
|
6666
|
+
command_id: tool3.schema.string().describe("Command session ID.")
|
|
6667
|
+
},
|
|
6668
|
+
execute: async (args, context) => {
|
|
6669
|
+
const owner = ownerID(context);
|
|
6670
|
+
if (!owner)
|
|
6671
|
+
return denied();
|
|
6672
|
+
const result = await commandService.terminate(directory, args.command_id, owner);
|
|
6673
|
+
return { title: result.ok ? "Terminated" : "Terminate failed", output: JSON.stringify({ ...result, command_id: args.command_id }) };
|
|
6674
|
+
}
|
|
6675
|
+
}),
|
|
6676
|
+
loopd_command_remove: tool3({
|
|
6677
|
+
description: "Remove a finished command session and its output log. Refuses while running (terminate \u2260 remove).",
|
|
6678
|
+
args: {
|
|
6679
|
+
command_id: tool3.schema.string().describe("Command session ID.")
|
|
6680
|
+
},
|
|
6681
|
+
execute: async (args, context) => {
|
|
6682
|
+
const owner = ownerID(context);
|
|
6683
|
+
if (!owner)
|
|
6684
|
+
return denied();
|
|
6685
|
+
const result = await commandService.remove(directory, args.command_id, owner);
|
|
6686
|
+
return { title: result.ok ? "Removed" : "Remove failed", output: JSON.stringify({ ...result, command_id: args.command_id }) };
|
|
6687
|
+
}
|
|
6688
|
+
}),
|
|
6689
|
+
loopd_command_await: tool3({
|
|
6690
|
+
description: "Opt in to a one-shot wake-up: the given goal wakes when the given command reaches a terminal status (exited/terminated/missing) with the exit code, signal, and last 4KB of output as evidence. Explicit opt-in only \u2014 a merely linked command never wakes its goal. Exactly-once: the await is consumed on fire, output chunks never fire, pausing/clearing the goal or removing the command cancels it.",
|
|
6691
|
+
args: {
|
|
6692
|
+
command_id: tool3.schema.string().describe("Command session ID to await."),
|
|
6693
|
+
goal_id: tool3.schema.string().describe("Goal ID to wake on exit. You must own both the goal and the command.")
|
|
6694
|
+
},
|
|
6695
|
+
execute: async (args, context) => {
|
|
6696
|
+
const owner = ownerID(context);
|
|
6697
|
+
if (!owner)
|
|
6698
|
+
return denied();
|
|
6699
|
+
const result = await requestCommandAwait(directory, {
|
|
6700
|
+
goalID: args.goal_id,
|
|
6701
|
+
commandID: args.command_id,
|
|
6702
|
+
ownerSessionID: owner
|
|
6703
|
+
});
|
|
6704
|
+
if (result.ok && result.fired && result.active && options.goalService) {
|
|
6705
|
+
await wakeGoalForAwait(directory, options.goalService, args.goal_id).catch(() => {});
|
|
6706
|
+
}
|
|
6707
|
+
return {
|
|
6708
|
+
title: result.ok ? "Await registered" : "Await failed",
|
|
6709
|
+
output: JSON.stringify({ ...result, command_id: args.command_id, goal_id: args.goal_id })
|
|
6710
|
+
};
|
|
6711
|
+
}
|
|
6712
|
+
}),
|
|
6713
|
+
loopd_command_resize: tool3({
|
|
6714
|
+
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.",
|
|
6715
|
+
args: {
|
|
6716
|
+
command_id: tool3.schema.string().describe("Command session ID."),
|
|
6717
|
+
cols: tool3.schema.number().describe("Requested width."),
|
|
6718
|
+
rows: tool3.schema.number().describe("Requested height.")
|
|
6719
|
+
},
|
|
6720
|
+
execute: async (args, context) => {
|
|
6721
|
+
const owner = ownerID(context);
|
|
6722
|
+
if (!owner)
|
|
6723
|
+
return denied();
|
|
6724
|
+
const result = await commandService.resize(directory, args.command_id, owner, args.cols, args.rows);
|
|
6725
|
+
return { title: "Resize", output: JSON.stringify({ ...result, command_id: args.command_id }) };
|
|
6726
|
+
}
|
|
6727
|
+
})
|
|
6728
|
+
};
|
|
6729
|
+
}
|
|
6730
|
+
|
|
4519
6731
|
// src/server/plugin.ts
|
|
4520
6732
|
init_state_repository();
|
|
6733
|
+
// package.json
|
|
6734
|
+
var version = "1.10.3";
|
|
6735
|
+
|
|
6736
|
+
// src/server/plugin.ts
|
|
4521
6737
|
var PLUGIN_ID = "opencode-loopd.server";
|
|
4522
6738
|
var server = async ({ client, directory }, pluginOptions) => {
|
|
4523
6739
|
const defaults = parsePluginDefaults(pluginOptions);
|
|
4524
6740
|
const host = createRealHost(client, directory);
|
|
6741
|
+
logServerEvent(directory, "plugin.loaded", { pluginID: PLUGIN_ID, host: "v1", version });
|
|
4525
6742
|
return createServerHooks(directory, host, defaults);
|
|
4526
6743
|
};
|
|
4527
6744
|
function createServerHooks(directory, host, defaults) {
|
|
4528
6745
|
const goalService = createGoalService(host);
|
|
6746
|
+
const commandBroker = createCommandEventBroker();
|
|
6747
|
+
const commandHost = createCommandHost();
|
|
6748
|
+
const commandService = createCommandService(commandHost, {
|
|
6749
|
+
broker: commandBroker,
|
|
6750
|
+
onAwaitFired: async (dir, fired) => {
|
|
6751
|
+
await Promise.resolve().then(() => init_command_await2());
|
|
6752
|
+
for (const f of fired) {
|
|
6753
|
+
if (!f.active)
|
|
6754
|
+
continue;
|
|
6755
|
+
await wakeGoalForAwait(dir, goalService, f.goalID).catch((error) => logServerEvent(dir, "command.await-wake-failed", {
|
|
6756
|
+
goalID: f.goalID,
|
|
6757
|
+
commandID: f.commandID,
|
|
6758
|
+
detail: describeError(error)
|
|
6759
|
+
}));
|
|
6760
|
+
}
|
|
6761
|
+
}
|
|
6762
|
+
});
|
|
6763
|
+
const commandStream = createCommandStreamServer(directory, commandService, commandBroker);
|
|
4529
6764
|
const worker = createControlWorker({
|
|
4530
6765
|
directory,
|
|
4531
6766
|
goalService,
|
|
6767
|
+
commandService,
|
|
4532
6768
|
pollIntervalMs: 1000,
|
|
4533
6769
|
defaults
|
|
4534
6770
|
});
|
|
@@ -4552,13 +6788,26 @@ function createServerHooks(directory, host, defaults) {
|
|
|
4552
6788
|
engine.start();
|
|
4553
6789
|
worker.start();
|
|
4554
6790
|
scheduleWorker.start();
|
|
6791
|
+
commandStream.start().catch((error) => logServerEvent(directory, "command-stream.start-failed", { detail: describeError(error) }));
|
|
4555
6792
|
}
|
|
4556
6793
|
function reconcileInBackground() {
|
|
4557
6794
|
if (reconciliationStarted)
|
|
4558
6795
|
return;
|
|
4559
6796
|
reconciliationStarted = true;
|
|
4560
6797
|
logServerEvent(directory, "reconcile.started");
|
|
4561
|
-
goalService.reconcile(directory).then(() =>
|
|
6798
|
+
goalService.reconcile(directory).then(() => commandService.reconcile(directory), (error) => logServerEvent(directory, "reconcile.failed", { detail: describeError(error) })).then(async () => {
|
|
6799
|
+
await Promise.resolve().then(() => init_command_await2());
|
|
6800
|
+
const fired = await reconcileCommandAwaits(directory);
|
|
6801
|
+
for (const f of fired) {
|
|
6802
|
+
if (!f.active)
|
|
6803
|
+
continue;
|
|
6804
|
+
await wakeGoalForAwait(directory, goalService, f.goalID).catch((error) => logServerEvent(directory, "command.await-wake-failed", {
|
|
6805
|
+
goalID: f.goalID,
|
|
6806
|
+
commandID: f.commandID,
|
|
6807
|
+
detail: describeError(error)
|
|
6808
|
+
}));
|
|
6809
|
+
}
|
|
6810
|
+
}, (error) => logServerEvent(directory, "reconcile.failed", { detail: describeError(error) })).then(() => logServerEvent(directory, "reconcile.completed"), (error) => logServerEvent(directory, "reconcile.failed", { detail: describeError(error) }));
|
|
4562
6811
|
}
|
|
4563
6812
|
return {
|
|
4564
6813
|
event: async ({ event }) => {
|
|
@@ -4570,7 +6819,7 @@ function createServerHooks(directory, host, defaults) {
|
|
|
4570
6819
|
if (type?.startsWith("session."))
|
|
4571
6820
|
reconcileInBackground();
|
|
4572
6821
|
},
|
|
4573
|
-
tool: { ...goalTools(directory, goalService, undefined, defaults, host), ...ownerTools({ directory, host, goalService }) },
|
|
6822
|
+
tool: { ...goalTools(directory, goalService, undefined, defaults, host), ...ownerTools({ directory, host, goalService }), ...commandTools({ directory, commandService, capabilities: commandHost.capabilities, goalService }) },
|
|
4574
6823
|
"tool.execute.before": async (input, _output) => {
|
|
4575
6824
|
const activeWorkers = goalService.getActiveWorkers();
|
|
4576
6825
|
let matchedGoalID;
|
|
@@ -4648,9 +6897,11 @@ function createServerHooks(directory, host, defaults) {
|
|
|
4648
6897
|
}
|
|
4649
6898
|
},
|
|
4650
6899
|
dispose: async () => {
|
|
6900
|
+
await commandStream.stop().catch(() => {});
|
|
4651
6901
|
engine.stop();
|
|
4652
6902
|
await worker.stop();
|
|
4653
6903
|
scheduleWorker.stop();
|
|
6904
|
+
await commandService.dispose(directory);
|
|
4654
6905
|
}
|
|
4655
6906
|
};
|
|
4656
6907
|
}
|
|
@@ -4668,6 +6919,7 @@ var v2 = {
|
|
|
4668
6919
|
id: PLUGIN_ID,
|
|
4669
6920
|
async setup(context) {
|
|
4670
6921
|
const directory = context.location.directory;
|
|
6922
|
+
logServerEvent(directory, "plugin.loaded", { pluginID: PLUGIN_ID, host: "v2", version });
|
|
4671
6923
|
const statuses = new Map;
|
|
4672
6924
|
const host = createV2Host(context, statuses);
|
|
4673
6925
|
const hooks = createServerHooks(directory, host, parsePluginDefaults(context.options));
|
|
@@ -4729,6 +6981,7 @@ function toV2Tool(id, definition, directory) {
|
|
|
4729
6981
|
return {
|
|
4730
6982
|
name: id,
|
|
4731
6983
|
description: definition.description,
|
|
6984
|
+
...id === "loopd_command_start" ? { permission: "bash" } : {},
|
|
4732
6985
|
input: v1Tool.schema.object(definition.args),
|
|
4733
6986
|
async execute(input, context) {
|
|
4734
6987
|
const result = await definition.execute(input, {
|
|
@@ -4774,6 +7027,17 @@ function normalizeV2Event(event) {
|
|
|
4774
7027
|
return { type: "session.compacted", properties };
|
|
4775
7028
|
if (event?.type === "session.execution.failed")
|
|
4776
7029
|
return { type: "session.error", properties };
|
|
7030
|
+
if (event?.type === "session.execution.succeeded")
|
|
7031
|
+
return { type: "session.execution.succeeded", properties };
|
|
7032
|
+
if (event?.type === "session.inbox.delivered") {
|
|
7033
|
+
return {
|
|
7034
|
+
type: "message.updated",
|
|
7035
|
+
properties: {
|
|
7036
|
+
sessionID: properties.sessionID,
|
|
7037
|
+
info: { role: "user", id: properties.inboxID }
|
|
7038
|
+
}
|
|
7039
|
+
};
|
|
7040
|
+
}
|
|
4777
7041
|
if (event?.type === "session.message.content.updated") {
|
|
4778
7042
|
return {
|
|
4779
7043
|
type: "message.part.updated",
|
|
@@ -4791,5 +7055,6 @@ var plugin_default = {
|
|
|
4791
7055
|
setup: v2.setup
|
|
4792
7056
|
};
|
|
4793
7057
|
export {
|
|
4794
|
-
plugin_default as default
|
|
7058
|
+
plugin_default as default,
|
|
7059
|
+
normalizeV2Event
|
|
4795
7060
|
};
|