@bojackduy/opencode-loopd 1.10.0 → 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 +2515 -174
- package/dist/tui.js +11536 -1964
- 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",
|
|
@@ -1639,6 +2121,9 @@ function createLoopEngine(options) {
|
|
|
1639
2121
|
}
|
|
1640
2122
|
return true;
|
|
1641
2123
|
}
|
|
2124
|
+
function hasExecutionLeak(runtime) {
|
|
2125
|
+
return runtime.phase !== "idle" || Boolean(runtime.activeRunID) || Boolean(runtime.leaseExpiresAt) || Boolean(runtime.turnStartedAt) || Boolean(runtime.activePromptMessageID) || Boolean(runtime.activePromptObservedAt) || Boolean(runtime.activeAssistantMessageID) || Boolean(runtime.activeAssistantCompletedAt) || Boolean(runtime.idleCandidateAt) || (runtime.activeToolCallIDs?.length ?? 0) > 0;
|
|
2126
|
+
}
|
|
1642
2127
|
async function maintenance() {
|
|
1643
2128
|
syncWorkerSessionsFromService();
|
|
1644
2129
|
if (knownWorkerSessions.size === 0)
|
|
@@ -1650,10 +2135,11 @@ function createLoopEngine(options) {
|
|
|
1650
2135
|
const rt = state.runtimes.find((r) => r.goalID === goal.id);
|
|
1651
2136
|
if (!rt)
|
|
1652
2137
|
continue;
|
|
1653
|
-
if (
|
|
2138
|
+
if (hasExecutionLeak(rt)) {
|
|
1654
2139
|
await mutateState(directory, `maintenance.clear-terminal-leak:${goal.id}`, async (s) => {
|
|
1655
2140
|
const r = s.runtimes.find((x) => x.goalID === goal.id);
|
|
1656
|
-
|
|
2141
|
+
const g = s.goals.find((item) => item.id === goal.id);
|
|
2142
|
+
if (g?.status !== "active" && r && hasExecutionLeak(r)) {
|
|
1657
2143
|
Object.assign(r, releaseLease(r));
|
|
1658
2144
|
r.activeRunID = undefined;
|
|
1659
2145
|
r.unknownStatusCount = 0;
|
|
@@ -1792,7 +2278,7 @@ function createLoopEngine(options) {
|
|
|
1792
2278
|
if (recovered) {
|
|
1793
2279
|
await appendEvent(directory, {
|
|
1794
2280
|
version: 1,
|
|
1795
|
-
eventID:
|
|
2281
|
+
eventID: randomUUID3(),
|
|
1796
2282
|
goalID: goal.id,
|
|
1797
2283
|
type: "run.recovered",
|
|
1798
2284
|
runID: stalledRunID ?? "unknown",
|
|
@@ -1826,7 +2312,7 @@ function createLoopEngine(options) {
|
|
|
1826
2312
|
const stuckSeconds = Math.floor((Date.now() - Date.parse(failedAt)) / 1000);
|
|
1827
2313
|
await appendEvent(directory, {
|
|
1828
2314
|
version: 1,
|
|
1829
|
-
eventID:
|
|
2315
|
+
eventID: randomUUID3(),
|
|
1830
2316
|
goalID: goal.id,
|
|
1831
2317
|
type: "run.stuck",
|
|
1832
2318
|
runID: runtime.activeRunID ?? "unknown",
|
|
@@ -1866,7 +2352,7 @@ function createLoopEngine(options) {
|
|
|
1866
2352
|
});
|
|
1867
2353
|
await appendEvent(directory, {
|
|
1868
2354
|
version: 1,
|
|
1869
|
-
eventID:
|
|
2355
|
+
eventID: randomUUID3(),
|
|
1870
2356
|
goalID: goal.id,
|
|
1871
2357
|
type: "run.stuck",
|
|
1872
2358
|
runID: runtime.activeRunID,
|
|
@@ -1889,12 +2375,14 @@ function createLoopEngine(options) {
|
|
|
1889
2375
|
}
|
|
1890
2376
|
|
|
1891
2377
|
// src/application/goal-service.ts
|
|
1892
|
-
import { randomUUID as
|
|
2378
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
1893
2379
|
init_state_repository();
|
|
2380
|
+
init_command_await2();
|
|
1894
2381
|
import * as path2 from "path";
|
|
1895
2382
|
import { promises as fs2 } from "fs";
|
|
1896
2383
|
|
|
1897
2384
|
// src/server/host-adapter.ts
|
|
2385
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
1898
2386
|
function parseModelRef(value) {
|
|
1899
2387
|
if (value === undefined)
|
|
1900
2388
|
return;
|
|
@@ -1912,6 +2400,12 @@ function parseModelRef(value) {
|
|
|
1912
2400
|
}
|
|
1913
2401
|
return { providerID, modelID };
|
|
1914
2402
|
}
|
|
2403
|
+
function newPromptMessageID() {
|
|
2404
|
+
return `msg_${randomUUID4()}`;
|
|
2405
|
+
}
|
|
2406
|
+
function isV2PromptMessageID(messageID) {
|
|
2407
|
+
return messageID.startsWith("msg_");
|
|
2408
|
+
}
|
|
1915
2409
|
var recentParentNotifies = new Map;
|
|
1916
2410
|
function shouldDedupParentNotify(ownerSessionID, message) {
|
|
1917
2411
|
const key = `${ownerSessionID}:${message.slice(0, 200)}`;
|
|
@@ -1958,8 +2452,7 @@ function createRealHost(client, directory) {
|
|
|
1958
2452
|
parts: [{ type: "text", text: prompt }]
|
|
1959
2453
|
};
|
|
1960
2454
|
if (messageID) {
|
|
1961
|
-
|
|
1962
|
-
body.messageID = collapsed.startsWith("msg-") ? collapsed : `msg-${messageID}`;
|
|
2455
|
+
body.messageID = /^msg[-_]/.test(messageID) ? messageID : `msg_${messageID}`;
|
|
1963
2456
|
}
|
|
1964
2457
|
if (model)
|
|
1965
2458
|
body.model = model;
|
|
@@ -2127,6 +2620,9 @@ function createV2Host(context, statuses) {
|
|
|
2127
2620
|
model: { id: model.modelID, providerID: model.providerID }
|
|
2128
2621
|
});
|
|
2129
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
|
+
}
|
|
2130
2626
|
const result = await context.session.prompt({
|
|
2131
2627
|
sessionID,
|
|
2132
2628
|
id: messageID,
|
|
@@ -2134,7 +2630,7 @@ function createV2Host(context, statuses) {
|
|
|
2134
2630
|
});
|
|
2135
2631
|
statuses.set(sessionID, "busy");
|
|
2136
2632
|
await logServerEvent(directory, "worker.prompted", { sessionID });
|
|
2137
|
-
return { messageID: result
|
|
2633
|
+
return { messageID: result?.id };
|
|
2138
2634
|
},
|
|
2139
2635
|
async readSession(sessionID) {
|
|
2140
2636
|
try {
|
|
@@ -2366,6 +2862,18 @@ function buildContinuationSteering(goal, runtime, context) {
|
|
|
2366
2862
|
}
|
|
2367
2863
|
|
|
2368
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
|
+
}
|
|
2369
2877
|
function createGoalService(host) {
|
|
2370
2878
|
const workers = createWorkerManager(host);
|
|
2371
2879
|
const sessions = new Map;
|
|
@@ -2387,12 +2895,13 @@ function createGoalService(host) {
|
|
|
2387
2895
|
goalOperations.delete(goalID);
|
|
2388
2896
|
}
|
|
2389
2897
|
}
|
|
2390
|
-
function assertWorkspaceWriteAvailable(state, goal) {
|
|
2898
|
+
function assertWorkspaceWriteAvailable(state, goal, requesterSessionID) {
|
|
2391
2899
|
if (!goal.config.workspaceWrite)
|
|
2392
2900
|
return;
|
|
2393
2901
|
const activeWriter = state.goals.find((item) => item.id !== goal.id && item.status === "active" && item.config.workspaceWrite);
|
|
2394
2902
|
if (activeWriter) {
|
|
2395
|
-
|
|
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." : ""));
|
|
2396
2905
|
}
|
|
2397
2906
|
}
|
|
2398
2907
|
async function recordPromptFailure(directory, goalID, error, blockImmediately = false) {
|
|
@@ -2432,7 +2941,7 @@ function createGoalService(host) {
|
|
|
2432
2941
|
});
|
|
2433
2942
|
await appendEvent(directory, {
|
|
2434
2943
|
version: 1,
|
|
2435
|
-
eventID:
|
|
2944
|
+
eventID: randomUUID5(),
|
|
2436
2945
|
goalID,
|
|
2437
2946
|
type: "run.failed",
|
|
2438
2947
|
runID,
|
|
@@ -2444,7 +2953,7 @@ function createGoalService(host) {
|
|
|
2444
2953
|
if (blocked) {
|
|
2445
2954
|
await appendEvent(directory, {
|
|
2446
2955
|
version: 1,
|
|
2447
|
-
eventID:
|
|
2956
|
+
eventID: randomUUID5(),
|
|
2448
2957
|
goalID,
|
|
2449
2958
|
type: "goal.blocked",
|
|
2450
2959
|
reason: `Worker prompt delivery failed: ${detail}`,
|
|
@@ -2478,7 +2987,7 @@ function createGoalService(host) {
|
|
|
2478
2987
|
return session;
|
|
2479
2988
|
}
|
|
2480
2989
|
async function start(directory, input) {
|
|
2481
|
-
const id =
|
|
2990
|
+
const id = randomUUID5();
|
|
2482
2991
|
return withGoalOperation(id, () => startUnlocked(directory, input, id));
|
|
2483
2992
|
}
|
|
2484
2993
|
async function startUnlocked(directory, input, id) {
|
|
@@ -2517,7 +3026,7 @@ function createGoalService(host) {
|
|
|
2517
3026
|
goal.config.progressFile = path2.join(artifactDir, "progress.md");
|
|
2518
3027
|
await ensureGoalArtifactDir(directory, id);
|
|
2519
3028
|
const state1 = await mutateState(directory, `goal.create:${id}`, async (state) => {
|
|
2520
|
-
assertWorkspaceWriteAvailable(state, goal);
|
|
3029
|
+
assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
|
|
2521
3030
|
state.goals.push(goal);
|
|
2522
3031
|
const rt = createRuntimeState(id);
|
|
2523
3032
|
if (goal.config.schedule) {
|
|
@@ -2558,7 +3067,7 @@ function createGoalService(host) {
|
|
|
2558
3067
|
});
|
|
2559
3068
|
await appendEvent(directory, {
|
|
2560
3069
|
version: 1,
|
|
2561
|
-
eventID:
|
|
3070
|
+
eventID: randomUUID5(),
|
|
2562
3071
|
goalID: id,
|
|
2563
3072
|
type: "goal.blocked",
|
|
2564
3073
|
reason: detail,
|
|
@@ -2567,7 +3076,7 @@ function createGoalService(host) {
|
|
|
2567
3076
|
revision: blockedState.revision
|
|
2568
3077
|
});
|
|
2569
3078
|
await logServerEvent(directory, "goal.start.failed", { goalID: id, ownerSessionID: input.ownerSessionID, detail });
|
|
2570
|
-
throw
|
|
3079
|
+
throw new GoalStartError(detail, { goalID: id, failedStage: "worker_create" });
|
|
2571
3080
|
}
|
|
2572
3081
|
sessions.set(id, worker);
|
|
2573
3082
|
const state2 = await mutateState(directory, `goal.worker-assign:${id}`, async (state) => {
|
|
@@ -2579,8 +3088,8 @@ function createGoalService(host) {
|
|
|
2579
3088
|
const rt = state.runtimes.find((item) => item.goalID === id);
|
|
2580
3089
|
if (rt) {
|
|
2581
3090
|
Object.assign(rt, acquireLease(rt, g.config.timeoutMs || 300000));
|
|
2582
|
-
rt.activeRunID =
|
|
2583
|
-
rt.activePromptMessageID =
|
|
3091
|
+
rt.activeRunID = randomUUID5();
|
|
3092
|
+
rt.activePromptMessageID = newPromptMessageID();
|
|
2584
3093
|
rt.runCount = 1;
|
|
2585
3094
|
rt.budgetTurnCount = 1;
|
|
2586
3095
|
rt.lastRunAt = new Date().toISOString();
|
|
@@ -2590,7 +3099,7 @@ function createGoalService(host) {
|
|
|
2590
3099
|
runtime = state2.runtimes.find((r) => r.goalID === id);
|
|
2591
3100
|
await appendEvent(directory, {
|
|
2592
3101
|
version: 1,
|
|
2593
|
-
eventID:
|
|
3102
|
+
eventID: randomUUID5(),
|
|
2594
3103
|
goalID: id,
|
|
2595
3104
|
type: "goal.created",
|
|
2596
3105
|
name: input.name,
|
|
@@ -2602,7 +3111,7 @@ function createGoalService(host) {
|
|
|
2602
3111
|
if (runtime) {
|
|
2603
3112
|
await appendEvent(directory, {
|
|
2604
3113
|
version: 1,
|
|
2605
|
-
eventID:
|
|
3114
|
+
eventID: randomUUID5(),
|
|
2606
3115
|
goalID: id,
|
|
2607
3116
|
type: "run.started",
|
|
2608
3117
|
runID: runtime.activeRunID,
|
|
@@ -2614,7 +3123,11 @@ function createGoalService(host) {
|
|
|
2614
3123
|
await workers.continueWorker(worker, goal, runtime);
|
|
2615
3124
|
} catch (error) {
|
|
2616
3125
|
await recordPromptFailure(directory, id, error, true);
|
|
2617
|
-
throw error
|
|
3126
|
+
throw new GoalStartError(describeError(error), {
|
|
3127
|
+
goalID: id,
|
|
3128
|
+
workerSessionID: worker.workerSessionID,
|
|
3129
|
+
failedStage: "prompt_delivery"
|
|
3130
|
+
});
|
|
2618
3131
|
}
|
|
2619
3132
|
}
|
|
2620
3133
|
return { goal, worker };
|
|
@@ -2711,8 +3224,8 @@ function createGoalService(host) {
|
|
|
2711
3224
|
return s;
|
|
2712
3225
|
const timeoutMs = g.config.timeoutMs || 300000;
|
|
2713
3226
|
Object.assign(rt, acquireLease(rt, timeoutMs));
|
|
2714
|
-
rt.activeRunID =
|
|
2715
|
-
rt.activePromptMessageID =
|
|
3227
|
+
rt.activeRunID = randomUUID5();
|
|
3228
|
+
rt.activePromptMessageID = newPromptMessageID();
|
|
2716
3229
|
rt.runCount += 1;
|
|
2717
3230
|
if (rt.freeRetryPending) {
|
|
2718
3231
|
rt.freeRetryPending = false;
|
|
@@ -2729,7 +3242,7 @@ function createGoalService(host) {
|
|
|
2729
3242
|
return;
|
|
2730
3243
|
await appendEvent(directory, {
|
|
2731
3244
|
version: 1,
|
|
2732
|
-
eventID:
|
|
3245
|
+
eventID: randomUUID5(),
|
|
2733
3246
|
goalID,
|
|
2734
3247
|
type: "run.started",
|
|
2735
3248
|
runID: freshRuntime.activeRunID,
|
|
@@ -2815,8 +3328,10 @@ function createGoalService(host) {
|
|
|
2815
3328
|
g.status = "paused";
|
|
2816
3329
|
g.updatedAt = new Date().toISOString();
|
|
2817
3330
|
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
2818
|
-
if (rt)
|
|
3331
|
+
if (rt) {
|
|
2819
3332
|
Object.assign(rt, releaseLease(rt));
|
|
3333
|
+
rt.activeRunID = undefined;
|
|
3334
|
+
}
|
|
2820
3335
|
return s;
|
|
2821
3336
|
});
|
|
2822
3337
|
const session = sessions.get(goalID) || (goal.workerSessionID ? {
|
|
@@ -2828,9 +3343,10 @@ function createGoalService(host) {
|
|
|
2828
3343
|
await workers.abortWorker(session.workerSessionID);
|
|
2829
3344
|
sessions.delete(goalID);
|
|
2830
3345
|
}
|
|
3346
|
+
await cancelAwaitsForGoal(directory, goalID, "goal paused").catch(() => {});
|
|
2831
3347
|
await appendEvent(directory, {
|
|
2832
3348
|
version: 1,
|
|
2833
|
-
eventID:
|
|
3349
|
+
eventID: randomUUID5(),
|
|
2834
3350
|
goalID,
|
|
2835
3351
|
type: "goal.status_changed",
|
|
2836
3352
|
from: "active",
|
|
@@ -2847,7 +3363,7 @@ function createGoalService(host) {
|
|
|
2847
3363
|
return state;
|
|
2848
3364
|
if (!canTransition(goal.status, "active", "user"))
|
|
2849
3365
|
return state;
|
|
2850
|
-
assertWorkspaceWriteAvailable(state, goal);
|
|
3366
|
+
assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
|
|
2851
3367
|
goal.status = "active";
|
|
2852
3368
|
goal.updatedAt = new Date().toISOString();
|
|
2853
3369
|
resumed = true;
|
|
@@ -2869,7 +3385,7 @@ function createGoalService(host) {
|
|
|
2869
3385
|
}
|
|
2870
3386
|
await appendEvent(directory, {
|
|
2871
3387
|
version: 1,
|
|
2872
|
-
eventID:
|
|
3388
|
+
eventID: randomUUID5(),
|
|
2873
3389
|
goalID,
|
|
2874
3390
|
type: "goal.status_changed",
|
|
2875
3391
|
from: "paused",
|
|
@@ -2885,7 +3401,7 @@ function createGoalService(host) {
|
|
|
2885
3401
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
2886
3402
|
if (!goal || goal.status !== "blocked")
|
|
2887
3403
|
return state;
|
|
2888
|
-
assertWorkspaceWriteAvailable(state, goal);
|
|
3404
|
+
assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
|
|
2889
3405
|
goal.status = "active";
|
|
2890
3406
|
goal.updatedAt = new Date().toISOString();
|
|
2891
3407
|
retried = true;
|
|
@@ -2894,9 +3410,13 @@ function createGoalService(host) {
|
|
|
2894
3410
|
runtime.consecutiveFailures = 0;
|
|
2895
3411
|
runtime.lastError = undefined;
|
|
2896
3412
|
runtime.forceFinishRequested = undefined;
|
|
3413
|
+
runtime.evaluatorRejectionCount = 0;
|
|
3414
|
+
runtime.lastRejectionDetails = undefined;
|
|
3415
|
+
runtime.freeRetryPending = false;
|
|
2897
3416
|
runtime.lastParentNotifiedAt = undefined;
|
|
2898
3417
|
runtime.lastParentNotifiedFor = undefined;
|
|
2899
|
-
runtime
|
|
3418
|
+
Object.assign(runtime, releaseLease(runtime));
|
|
3419
|
+
runtime.activeRunID = undefined;
|
|
2900
3420
|
runtime.updatedAt = new Date().toISOString();
|
|
2901
3421
|
}
|
|
2902
3422
|
return state;
|
|
@@ -2917,7 +3437,7 @@ function createGoalService(host) {
|
|
|
2917
3437
|
}
|
|
2918
3438
|
await appendEvent(directory, {
|
|
2919
3439
|
version: 1,
|
|
2920
|
-
eventID:
|
|
3440
|
+
eventID: randomUUID5(),
|
|
2921
3441
|
goalID,
|
|
2922
3442
|
type: "goal.status_changed",
|
|
2923
3443
|
from: "blocked",
|
|
@@ -2944,11 +3464,12 @@ function createGoalService(host) {
|
|
|
2944
3464
|
await mutateState(directory, `goal.clear:${goalID}`, async (s) => {
|
|
2945
3465
|
s.goals = s.goals.filter((g) => g.id !== goalID);
|
|
2946
3466
|
s.runtimes = s.runtimes.filter((r) => r.goalID !== goalID);
|
|
3467
|
+
s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.goalID !== goalID);
|
|
2947
3468
|
return s;
|
|
2948
3469
|
});
|
|
2949
3470
|
await appendEvent(directory, {
|
|
2950
3471
|
version: 1,
|
|
2951
|
-
eventID:
|
|
3472
|
+
eventID: randomUUID5(),
|
|
2952
3473
|
goalID,
|
|
2953
3474
|
type: "goal.cleared",
|
|
2954
3475
|
timestamp: new Date().toISOString(),
|
|
@@ -3156,7 +3677,7 @@ function createGoalService(host) {
|
|
|
3156
3677
|
|
|
3157
3678
|
// src/application/schedule-worker.ts
|
|
3158
3679
|
init_state_repository();
|
|
3159
|
-
import { randomUUID as
|
|
3680
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
3160
3681
|
function createScheduleWorker(options) {
|
|
3161
3682
|
const { directory, goalService } = options;
|
|
3162
3683
|
const intervalMs = options.intervalMs ?? 5000;
|
|
@@ -3244,7 +3765,7 @@ function createScheduleWorker(options) {
|
|
|
3244
3765
|
continue;
|
|
3245
3766
|
await appendEvent(directory, {
|
|
3246
3767
|
version: 1,
|
|
3247
|
-
eventID:
|
|
3768
|
+
eventID: randomUUID6(),
|
|
3248
3769
|
goalID: goal.id,
|
|
3249
3770
|
type: "schedule.tick",
|
|
3250
3771
|
scheduleRunCount: count,
|
|
@@ -3269,69 +3790,1524 @@ function createScheduleWorker(options) {
|
|
|
3269
3790
|
return { start, stop, isRunning, tick };
|
|
3270
3791
|
}
|
|
3271
3792
|
|
|
3272
|
-
// src/server/
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
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 {}
|
|
3282
3914
|
}
|
|
3283
|
-
return next;
|
|
3284
3915
|
}
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
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());
|
|
3291
3927
|
return {
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
args
|
|
3295
|
-
name:
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
ok: false,
|
|
3319
|
-
message: "A valid owner session is required. Run /goal from an active OpenCode session."
|
|
3320
|
-
})
|
|
3321
|
-
};
|
|
3322
|
-
}
|
|
3323
|
-
const config = {
|
|
3324
|
-
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)
|
|
3325
3954
|
};
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
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)
|
|
3335
5311
|
config.workspaceWrite = args.workspaceWrite;
|
|
3336
5312
|
if (args.progressFile)
|
|
3337
5313
|
config.progressFile = args.progressFile;
|
|
@@ -3422,12 +5398,20 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
3422
5398
|
})
|
|
3423
5399
|
};
|
|
3424
5400
|
} catch (error) {
|
|
5401
|
+
const startFailure = error instanceof GoalStartError ? error : undefined;
|
|
3425
5402
|
return {
|
|
3426
5403
|
title: "Goal creation failed",
|
|
3427
5404
|
output: JSON.stringify({
|
|
3428
5405
|
ok: false,
|
|
3429
5406
|
name: args.name,
|
|
3430
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
|
+
} : {},
|
|
3431
5415
|
diagnostics: SERVER_LOG_FILE
|
|
3432
5416
|
})
|
|
3433
5417
|
};
|
|
@@ -3488,7 +5472,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
3488
5472
|
await writeState(dir, state);
|
|
3489
5473
|
const event = {
|
|
3490
5474
|
version: 1,
|
|
3491
|
-
eventID:
|
|
5475
|
+
eventID: randomUUID10(),
|
|
3492
5476
|
goalID: goal.id,
|
|
3493
5477
|
type: "goal.progress",
|
|
3494
5478
|
summary: args.summary,
|
|
@@ -3528,25 +5512,32 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
3528
5512
|
const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
|
|
3529
5513
|
const checkResults = await runCompletionChecks(goal.config.checks, cwd);
|
|
3530
5514
|
if (!checkResults.passed) {
|
|
3531
|
-
const
|
|
3532
|
-
|
|
3533
|
-
runtime.evaluatorRejectionCount = (runtime.evaluatorRejectionCount || 0) + 1;
|
|
3534
|
-
const failureDetails = checkResults.failures.map((f) => {
|
|
3535
|
-
const stdoutSnippet = f.stdout ? `
|
|
5515
|
+
const failureDetails = checkResults.failures.map((f) => {
|
|
5516
|
+
const stdoutSnippet = f.stdout ? `
|
|
3536
5517
|
Stdout: ${f.stdout.slice(0, 500)}` : "";
|
|
3537
|
-
|
|
5518
|
+
const stderrSnippet = f.stderr ? `
|
|
3538
5519
|
Stderr: ${f.stderr.slice(0, 500)}` : "";
|
|
3539
|
-
|
|
5520
|
+
return `Command: ${f.command}
|
|
3540
5521
|
Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
|
|
3541
|
-
|
|
5522
|
+
}).join(`
|
|
3542
5523
|
|
|
3543
5524
|
`);
|
|
5525
|
+
let rejectionCount = 0;
|
|
5526
|
+
let blocked = false;
|
|
5527
|
+
let attemptID = "";
|
|
5528
|
+
const rejectionState = await mutateState(dir, `goal.completion-rejected:${goal.id}`, async (current) => {
|
|
5529
|
+
const currentGoal = current.goals.find((item) => item.id === goal.id);
|
|
5530
|
+
const runtime = current.runtimes.find((r) => r.goalID === goal.id);
|
|
5531
|
+
if (!currentGoal || !runtime || currentGoal.status !== "active")
|
|
5532
|
+
return current;
|
|
5533
|
+
runtime.evaluatorRejectionCount = (runtime.evaluatorRejectionCount || 0) + 1;
|
|
5534
|
+
rejectionCount = runtime.evaluatorRejectionCount;
|
|
3544
5535
|
runtime.lastRejectionDetails = `Rejection #${runtime.evaluatorRejectionCount} at ${new Date().toISOString()}
|
|
3545
5536
|
|
|
3546
5537
|
Working directory: ${cwd}
|
|
3547
5538
|
|
|
3548
5539
|
${failureDetails}`;
|
|
3549
|
-
|
|
5540
|
+
attemptID = randomUUID10();
|
|
3550
5541
|
const verificationAttempt = {
|
|
3551
5542
|
id: attemptID,
|
|
3552
5543
|
sequence: runtime.evaluatorRejectionCount,
|
|
@@ -3566,55 +5557,71 @@ ${failureDetails}`;
|
|
|
3566
5557
|
};
|
|
3567
5558
|
runtime.lastVerificationAttempt = verificationAttempt;
|
|
3568
5559
|
runtime.recentVerificationAttempts = appendVerificationAttempt(runtime.recentVerificationAttempts || [], verificationAttempt);
|
|
3569
|
-
const
|
|
3570
|
-
version: 1,
|
|
3571
|
-
eventID: randomUUID5(),
|
|
3572
|
-
goalID: goal.id,
|
|
3573
|
-
type: "goal.completion_rejected",
|
|
3574
|
-
attemptID,
|
|
3575
|
-
rejectionCount: runtime.evaluatorRejectionCount,
|
|
3576
|
-
failedCheckCount: checkResults.failures.length,
|
|
3577
|
-
failureSummary: failureDetails.slice(0, 500),
|
|
3578
|
-
timestamp: new Date().toISOString(),
|
|
3579
|
-
revision: state.revision
|
|
3580
|
-
};
|
|
3581
|
-
await appendEvent(dir, rejectEvent);
|
|
3582
|
-
const maxRejections = goal.config.maxEvaluatorRejections || 3;
|
|
5560
|
+
const maxRejections = currentGoal.config.maxEvaluatorRejections || 3;
|
|
3583
5561
|
if (runtime.evaluatorRejectionCount >= maxRejections) {
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
5562
|
+
blocked = true;
|
|
5563
|
+
currentGoal.status = "blocked";
|
|
5564
|
+
currentGoal.updatedAt = new Date().toISOString();
|
|
5565
|
+
currentGoal.blocker = {
|
|
3587
5566
|
reason: `Evaluator rejected ${runtime.evaluatorRejectionCount} time(s). Last failure:
|
|
3588
5567
|
${failureDetails.slice(0, 500)}`,
|
|
3589
5568
|
needed: "Fix the failing checks and retry the goal.",
|
|
3590
5569
|
at: new Date().toISOString()
|
|
3591
5570
|
};
|
|
5571
|
+
Object.assign(runtime, releaseLease(runtime));
|
|
5572
|
+
runtime.activeRunID = undefined;
|
|
3592
5573
|
runtime.forceFinishRequested = undefined;
|
|
3593
|
-
|
|
3594
|
-
version: 1,
|
|
3595
|
-
eventID: randomUUID5(),
|
|
3596
|
-
goalID: goal.id,
|
|
3597
|
-
type: "goal.blocked",
|
|
3598
|
-
reason: goal.blocker.reason,
|
|
3599
|
-
needed: goal.blocker.needed,
|
|
3600
|
-
timestamp: new Date().toISOString(),
|
|
3601
|
-
revision: state.revision
|
|
3602
|
-
});
|
|
5574
|
+
runtime.freeRetryPending = false;
|
|
3603
5575
|
} else {
|
|
3604
5576
|
runtime.forceFinishRequested = false;
|
|
3605
5577
|
runtime.freeRetryPending = true;
|
|
3606
5578
|
}
|
|
3607
5579
|
runtime.updatedAt = new Date().toISOString();
|
|
3608
|
-
|
|
5580
|
+
return current;
|
|
5581
|
+
});
|
|
5582
|
+
if (attemptID) {
|
|
5583
|
+
await appendEvent(dir, {
|
|
5584
|
+
version: 1,
|
|
5585
|
+
eventID: randomUUID10(),
|
|
5586
|
+
goalID: goal.id,
|
|
5587
|
+
type: "goal.completion_rejected",
|
|
5588
|
+
attemptID,
|
|
5589
|
+
rejectionCount,
|
|
5590
|
+
failedCheckCount: checkResults.failures.length,
|
|
5591
|
+
failureSummary: failureDetails.slice(0, 500),
|
|
5592
|
+
timestamp: new Date().toISOString(),
|
|
5593
|
+
revision: rejectionState.revision
|
|
5594
|
+
});
|
|
5595
|
+
}
|
|
5596
|
+
const rejectedGoal = rejectionState.goals.find((item) => item.id === goal.id);
|
|
5597
|
+
const rejectedRuntime = rejectionState.runtimes.find((item) => item.goalID === goal.id);
|
|
5598
|
+
rejectionCount = rejectedRuntime?.evaluatorRejectionCount ?? rejectionCount;
|
|
5599
|
+
const goalIsBlocked = rejectedGoal?.status === "blocked";
|
|
5600
|
+
if (blocked && rejectedGoal?.blocker) {
|
|
5601
|
+
await appendEvent(dir, {
|
|
5602
|
+
version: 1,
|
|
5603
|
+
eventID: randomUUID10(),
|
|
5604
|
+
goalID: goal.id,
|
|
5605
|
+
type: "goal.blocked",
|
|
5606
|
+
reason: rejectedGoal.blocker.reason,
|
|
5607
|
+
needed: rejectedGoal.blocker.needed,
|
|
5608
|
+
timestamp: new Date().toISOString(),
|
|
5609
|
+
revision: rejectionState.revision
|
|
5610
|
+
});
|
|
5611
|
+
await goalService.accountUsage(dir, goal.id).catch(() => {
|
|
5612
|
+
return;
|
|
5613
|
+
});
|
|
3609
5614
|
}
|
|
3610
5615
|
return {
|
|
3611
|
-
title: "Completion rejected \u2014 keep working",
|
|
5616
|
+
title: goalIsBlocked ? "Completion rejected \u2014 goal blocked" : "Completion rejected \u2014 keep working",
|
|
3612
5617
|
output: JSON.stringify({
|
|
5618
|
+
goalID: goal.id,
|
|
5619
|
+
goalName: goal.name,
|
|
3613
5620
|
passed: false,
|
|
3614
5621
|
failedChecks: checkResults.failures,
|
|
3615
|
-
message: "Evaluator rejected completion. Fix the issues above and try again.",
|
|
3616
|
-
rejectionCount
|
|
3617
|
-
status: goal.status
|
|
5622
|
+
message: goalIsBlocked ? "Evaluator rejection limit reached. Goal blocked; owner retry required." : "Evaluator rejected completion. Fix the issues above and try again.",
|
|
5623
|
+
rejectionCount,
|
|
5624
|
+
status: rejectedGoal?.status ?? goal.status
|
|
3618
5625
|
})
|
|
3619
5626
|
};
|
|
3620
5627
|
}
|
|
@@ -3653,7 +5660,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
3653
5660
|
}
|
|
3654
5661
|
runtime.updatedAt = new Date().toISOString();
|
|
3655
5662
|
}
|
|
3656
|
-
const attemptID =
|
|
5663
|
+
const attemptID = randomUUID10();
|
|
3657
5664
|
const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
|
|
3658
5665
|
const checks = (goal.config.checks || []).map((cmd) => ({
|
|
3659
5666
|
command: cmd,
|
|
@@ -3677,7 +5684,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
3677
5684
|
await writeState(dir, state);
|
|
3678
5685
|
const event = {
|
|
3679
5686
|
version: 1,
|
|
3680
|
-
eventID:
|
|
5687
|
+
eventID: randomUUID10(),
|
|
3681
5688
|
goalID: goal.id,
|
|
3682
5689
|
type: "goal.completed",
|
|
3683
5690
|
summary: args.summary,
|
|
@@ -3737,7 +5744,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
3737
5744
|
await writeState(dir, state);
|
|
3738
5745
|
const event = {
|
|
3739
5746
|
version: 1,
|
|
3740
|
-
eventID:
|
|
5747
|
+
eventID: randomUUID10(),
|
|
3741
5748
|
goalID: goal.id,
|
|
3742
5749
|
type: "goal.blocked",
|
|
3743
5750
|
reason: args.reason,
|
|
@@ -3881,7 +5888,43 @@ async function runCompletionChecks(checks, cwd) {
|
|
|
3881
5888
|
// src/server/owner-tools.ts
|
|
3882
5889
|
init_state_repository();
|
|
3883
5890
|
import { tool as tool2 } from "@opencode-ai/plugin/tool";
|
|
3884
|
-
|
|
5891
|
+
|
|
5892
|
+
// src/domain/status-labels.ts
|
|
5893
|
+
var GOAL_STATUS_META = {
|
|
5894
|
+
active: { short: "Active", hint: "loopd owns it" },
|
|
5895
|
+
paused: { short: "Paused", hint: "stopped by you" },
|
|
5896
|
+
blocked: { short: "Blocked", hint: "needs you" },
|
|
5897
|
+
budget_limited: { short: "Out of budget", hint: "resume to spend" },
|
|
5898
|
+
usage_limited: { short: "Waiting for capacity", hint: "auto-resumes" },
|
|
5899
|
+
complete: { short: "Done", hint: "verified" }
|
|
5900
|
+
};
|
|
5901
|
+
var PHASE_META = {
|
|
5902
|
+
idle: { short: "Idle", hint: "between turns" },
|
|
5903
|
+
queued: { short: "Queued", hint: "waiting to start" },
|
|
5904
|
+
running: { short: "Running", hint: "worker acting now" },
|
|
5905
|
+
compacting: { short: "Compacting", hint: "summarizing context" },
|
|
5906
|
+
waiting_retry: { short: "Retrying", hint: "backing off" },
|
|
5907
|
+
stopping: { short: "Stopping", hint: "abort in flight" }
|
|
5908
|
+
};
|
|
5909
|
+
function goalStatusLabel(status) {
|
|
5910
|
+
return GOAL_STATUS_META[status] ?? { short: status, hint: "" };
|
|
5911
|
+
}
|
|
5912
|
+
function phaseLabel(phase) {
|
|
5913
|
+
return PHASE_META[phase] ?? { short: phase, hint: "" };
|
|
5914
|
+
}
|
|
5915
|
+
function describeGoalState(status, phase) {
|
|
5916
|
+
const goal = goalStatusLabel(status);
|
|
5917
|
+
const activity = phase ? phaseLabel(phase) : undefined;
|
|
5918
|
+
const workerClause = activity ? `worker is ${activity.hint || activity.short.toLowerCase()}` : "worker state unknown";
|
|
5919
|
+
if (status === "active")
|
|
5920
|
+
return `Loopd owns this; ${workerClause}.`;
|
|
5921
|
+
if (status === "complete")
|
|
5922
|
+
return "Done \u2014 verified; worker is stopped.";
|
|
5923
|
+
return `Parked \u2014 ${goal.hint || goal.short.toLowerCase()}; ${workerClause}.`;
|
|
5924
|
+
}
|
|
5925
|
+
|
|
5926
|
+
// src/server/owner-tools.ts
|
|
5927
|
+
import { promises as fs5 } from "fs";
|
|
3885
5928
|
function withTimeout2(promise, ms) {
|
|
3886
5929
|
return Promise.race([
|
|
3887
5930
|
promise,
|
|
@@ -3916,11 +5959,17 @@ function ownerTools(options) {
|
|
|
3916
5959
|
}
|
|
3917
5960
|
const summaries = goals.map((g) => {
|
|
3918
5961
|
const runtime = state.runtimes.find((r) => r.goalID === g.id);
|
|
5962
|
+
const phase = runtime?.phase ?? "unknown";
|
|
5963
|
+
const goalLabel = goalStatusLabel(g.status);
|
|
5964
|
+
const activityLabel = phaseLabel(phase);
|
|
3919
5965
|
return {
|
|
3920
5966
|
id: g.id,
|
|
3921
5967
|
name: g.name,
|
|
3922
5968
|
status: g.status,
|
|
3923
|
-
|
|
5969
|
+
statusDisplay: `${goalLabel.short} \u2014 ${goalLabel.hint}`,
|
|
5970
|
+
phase,
|
|
5971
|
+
activityDisplay: `${activityLabel.short} \u2014 ${activityLabel.hint}`,
|
|
5972
|
+
stateSummary: describeGoalState(g.status, runtime?.phase),
|
|
3924
5973
|
turn: runtime?.runCount ?? 0,
|
|
3925
5974
|
budgetTurnCount: runtime?.budgetTurnCount ?? 0,
|
|
3926
5975
|
maxTurns: g.config.maxTurns,
|
|
@@ -3957,9 +6006,24 @@ function ownerTools(options) {
|
|
|
3957
6006
|
const ownerID = context?.sessionID;
|
|
3958
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");
|
|
3959
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.";
|
|
3960
6019
|
return {
|
|
3961
6020
|
title: "No goal found",
|
|
3962
|
-
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
|
+
})
|
|
3963
6027
|
};
|
|
3964
6028
|
}
|
|
3965
6029
|
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
@@ -3973,7 +6037,7 @@ function ownerTools(options) {
|
|
|
3973
6037
|
if (!dir)
|
|
3974
6038
|
return;
|
|
3975
6039
|
try {
|
|
3976
|
-
const files = await
|
|
6040
|
+
const files = await fs5.readdir(dir);
|
|
3977
6041
|
return files.length ? `${files.length} file(s): ${files.slice(0, 8).join(", ")}` : "no artifacts yet";
|
|
3978
6042
|
} catch {
|
|
3979
6043
|
return "no artifacts yet";
|
|
@@ -3990,6 +6054,9 @@ function ownerTools(options) {
|
|
|
3990
6054
|
name: goal.name,
|
|
3991
6055
|
objective: goal.objective,
|
|
3992
6056
|
status: goal.status,
|
|
6057
|
+
statusDisplay: `${goalStatusLabel(goal.status).short} \u2014 ${goalStatusLabel(goal.status).hint}`,
|
|
6058
|
+
activityDisplay: runtime?.phase ? `${phaseLabel(runtime.phase).short} \u2014 ${phaseLabel(runtime.phase).hint}` : undefined,
|
|
6059
|
+
stateSummary: describeGoalState(goal.status, runtime?.phase),
|
|
3993
6060
|
ownerSessionID: goal.ownerSessionID,
|
|
3994
6061
|
workerSessionID: goal.workerSessionID,
|
|
3995
6062
|
config: {
|
|
@@ -4440,19 +6507,264 @@ function ownerTools(options) {
|
|
|
4440
6507
|
};
|
|
4441
6508
|
}
|
|
4442
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
|
+
|
|
4443
6731
|
// src/server/plugin.ts
|
|
4444
6732
|
init_state_repository();
|
|
6733
|
+
// package.json
|
|
6734
|
+
var version = "1.10.3";
|
|
6735
|
+
|
|
6736
|
+
// src/server/plugin.ts
|
|
4445
6737
|
var PLUGIN_ID = "opencode-loopd.server";
|
|
4446
6738
|
var server = async ({ client, directory }, pluginOptions) => {
|
|
4447
6739
|
const defaults = parsePluginDefaults(pluginOptions);
|
|
4448
6740
|
const host = createRealHost(client, directory);
|
|
6741
|
+
logServerEvent(directory, "plugin.loaded", { pluginID: PLUGIN_ID, host: "v1", version });
|
|
4449
6742
|
return createServerHooks(directory, host, defaults);
|
|
4450
6743
|
};
|
|
4451
6744
|
function createServerHooks(directory, host, defaults) {
|
|
4452
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);
|
|
4453
6764
|
const worker = createControlWorker({
|
|
4454
6765
|
directory,
|
|
4455
6766
|
goalService,
|
|
6767
|
+
commandService,
|
|
4456
6768
|
pollIntervalMs: 1000,
|
|
4457
6769
|
defaults
|
|
4458
6770
|
});
|
|
@@ -4476,13 +6788,26 @@ function createServerHooks(directory, host, defaults) {
|
|
|
4476
6788
|
engine.start();
|
|
4477
6789
|
worker.start();
|
|
4478
6790
|
scheduleWorker.start();
|
|
6791
|
+
commandStream.start().catch((error) => logServerEvent(directory, "command-stream.start-failed", { detail: describeError(error) }));
|
|
4479
6792
|
}
|
|
4480
6793
|
function reconcileInBackground() {
|
|
4481
6794
|
if (reconciliationStarted)
|
|
4482
6795
|
return;
|
|
4483
6796
|
reconciliationStarted = true;
|
|
4484
6797
|
logServerEvent(directory, "reconcile.started");
|
|
4485
|
-
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) }));
|
|
4486
6811
|
}
|
|
4487
6812
|
return {
|
|
4488
6813
|
event: async ({ event }) => {
|
|
@@ -4494,7 +6819,7 @@ function createServerHooks(directory, host, defaults) {
|
|
|
4494
6819
|
if (type?.startsWith("session."))
|
|
4495
6820
|
reconcileInBackground();
|
|
4496
6821
|
},
|
|
4497
|
-
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 }) },
|
|
4498
6823
|
"tool.execute.before": async (input, _output) => {
|
|
4499
6824
|
const activeWorkers = goalService.getActiveWorkers();
|
|
4500
6825
|
let matchedGoalID;
|
|
@@ -4572,9 +6897,11 @@ function createServerHooks(directory, host, defaults) {
|
|
|
4572
6897
|
}
|
|
4573
6898
|
},
|
|
4574
6899
|
dispose: async () => {
|
|
6900
|
+
await commandStream.stop().catch(() => {});
|
|
4575
6901
|
engine.stop();
|
|
4576
6902
|
await worker.stop();
|
|
4577
6903
|
scheduleWorker.stop();
|
|
6904
|
+
await commandService.dispose(directory);
|
|
4578
6905
|
}
|
|
4579
6906
|
};
|
|
4580
6907
|
}
|
|
@@ -4592,6 +6919,7 @@ var v2 = {
|
|
|
4592
6919
|
id: PLUGIN_ID,
|
|
4593
6920
|
async setup(context) {
|
|
4594
6921
|
const directory = context.location.directory;
|
|
6922
|
+
logServerEvent(directory, "plugin.loaded", { pluginID: PLUGIN_ID, host: "v2", version });
|
|
4595
6923
|
const statuses = new Map;
|
|
4596
6924
|
const host = createV2Host(context, statuses);
|
|
4597
6925
|
const hooks = createServerHooks(directory, host, parsePluginDefaults(context.options));
|
|
@@ -4653,6 +6981,7 @@ function toV2Tool(id, definition, directory) {
|
|
|
4653
6981
|
return {
|
|
4654
6982
|
name: id,
|
|
4655
6983
|
description: definition.description,
|
|
6984
|
+
...id === "loopd_command_start" ? { permission: "bash" } : {},
|
|
4656
6985
|
input: v1Tool.schema.object(definition.args),
|
|
4657
6986
|
async execute(input, context) {
|
|
4658
6987
|
const result = await definition.execute(input, {
|
|
@@ -4698,6 +7027,17 @@ function normalizeV2Event(event) {
|
|
|
4698
7027
|
return { type: "session.compacted", properties };
|
|
4699
7028
|
if (event?.type === "session.execution.failed")
|
|
4700
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
|
+
}
|
|
4701
7041
|
if (event?.type === "session.message.content.updated") {
|
|
4702
7042
|
return {
|
|
4703
7043
|
type: "message.part.updated",
|
|
@@ -4715,5 +7055,6 @@ var plugin_default = {
|
|
|
4715
7055
|
setup: v2.setup
|
|
4716
7056
|
};
|
|
4717
7057
|
export {
|
|
4718
|
-
plugin_default as default
|
|
7058
|
+
plugin_default as default,
|
|
7059
|
+
normalizeV2Event
|
|
4719
7060
|
};
|