pi-agent-squad 0.8.4 → 0.9.0
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 +47 -1
- package/cypher-status.ts +401 -0
- package/index.ts +614 -103
- package/package.json +18 -1
- package/session.ts +2 -0
- package/spawn.ts +86 -39
- package/task-delivery.ts +201 -0
- package/task-recovery.ts +41 -0
- package/task-state.ts +286 -0
- package/task-status.ts +107 -0
package/index.ts
CHANGED
|
@@ -36,6 +36,28 @@ import { openSubagentSessionOverlay } from "./session-ui.ts";
|
|
|
36
36
|
import { getFinalOutput, spawnInteractiveSubagent } from "./spawn.ts";
|
|
37
37
|
import { deadlockMessage, MessageWaitGraph } from "./wait-graph.ts";
|
|
38
38
|
import { ActiveRunRegistry, type ActiveRun } from "./active-runs.ts";
|
|
39
|
+
import { CypherStatusPublisher } from "./cypher-status.ts";
|
|
40
|
+
import {
|
|
41
|
+
boundTaskText,
|
|
42
|
+
isTerminalTaskStatus,
|
|
43
|
+
isRetryableTaskStatus,
|
|
44
|
+
reconstructTaskStates,
|
|
45
|
+
TASK_STATE_ENTRY,
|
|
46
|
+
TASK_STATE_LIMITS,
|
|
47
|
+
TASK_STATE_VERSION,
|
|
48
|
+
validateLaunchText,
|
|
49
|
+
type PersistedTaskState,
|
|
50
|
+
type PersistedTaskStatus,
|
|
51
|
+
} from "./task-state.ts";
|
|
52
|
+
import { planTaskRecovery } from "./task-recovery.ts";
|
|
53
|
+
import {
|
|
54
|
+
BACKGROUND_EVENT_TYPE,
|
|
55
|
+
branchHasResultDelivery,
|
|
56
|
+
RecoveryOutbox,
|
|
57
|
+
resultDeliveryId,
|
|
58
|
+
type BackgroundEventDetails,
|
|
59
|
+
} from "./task-delivery.ts";
|
|
60
|
+
import { formatTaskStatus, type LiveTaskView } from "./task-status.ts";
|
|
39
61
|
|
|
40
62
|
const MESSAGE_ROOT_BASE = path.join(
|
|
41
63
|
os.tmpdir(),
|
|
@@ -51,7 +73,6 @@ const RUNNING_WIDGET_TICK_MS = 1000;
|
|
|
51
73
|
const RUNNING_WIDGET_NAV_DEBOUNCE_MS = 150;
|
|
52
74
|
const RUNNING_WIDGET_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
53
75
|
const INCOMING_MESSAGE_TYPE = "pi_message_request";
|
|
54
|
-
const BACKGROUND_EVENT_TYPE = "pi_subagent_background_event";
|
|
55
76
|
|
|
56
77
|
interface IncomingMessageDetails {
|
|
57
78
|
id?: string;
|
|
@@ -61,15 +82,6 @@ interface IncomingMessageDetails {
|
|
|
61
82
|
createdAt?: number;
|
|
62
83
|
}
|
|
63
84
|
|
|
64
|
-
interface BackgroundEventDetails {
|
|
65
|
-
agent?: string;
|
|
66
|
-
address?: string;
|
|
67
|
-
status?: "done" | "error";
|
|
68
|
-
body?: string;
|
|
69
|
-
elapsedMs?: number;
|
|
70
|
-
runId?: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
85
|
type TranscriptEventKind = "message" | "done" | "error";
|
|
74
86
|
|
|
75
87
|
function customMessageText(content: unknown): string {
|
|
@@ -351,7 +363,12 @@ interface AsyncTask {
|
|
|
351
363
|
runId: string;
|
|
352
364
|
agent: string;
|
|
353
365
|
address: string;
|
|
366
|
+
task: string;
|
|
367
|
+
cwd: string;
|
|
368
|
+
readOnly: boolean;
|
|
354
369
|
startedAt: number;
|
|
370
|
+
updatedAt: number;
|
|
371
|
+
timeoutAt: number;
|
|
355
372
|
sessionGeneration: number;
|
|
356
373
|
}
|
|
357
374
|
|
|
@@ -579,7 +596,14 @@ export class RunningSubagentWidgetController {
|
|
|
579
596
|
}
|
|
580
597
|
}
|
|
581
598
|
|
|
582
|
-
export
|
|
599
|
+
export interface AgentSquadRuntimeDependencies {
|
|
600
|
+
spawnInteractiveSubagent: typeof spawnInteractiveSubagent;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function createAgentSquadExtension(
|
|
604
|
+
pi: ExtensionAPI,
|
|
605
|
+
dependencies: AgentSquadRuntimeDependencies = { spawnInteractiveSubagent },
|
|
606
|
+
) {
|
|
583
607
|
const isChild = process.env[ENV_ROLE] === "child";
|
|
584
608
|
|
|
585
609
|
if (isChild) {
|
|
@@ -589,6 +613,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
589
613
|
}
|
|
590
614
|
|
|
591
615
|
// ===== main-agent mode =====
|
|
616
|
+
const ownerRuntimeId = randomUUID();
|
|
592
617
|
pi.registerMessageRenderer<IncomingMessageDetails>(
|
|
593
618
|
INCOMING_MESSAGE_TYPE,
|
|
594
619
|
(message, options, theme) => {
|
|
@@ -644,19 +669,151 @@ export default function (pi: ExtensionAPI) {
|
|
|
644
669
|
let poolDisposed = false;
|
|
645
670
|
const activeRuns = new ActiveRunRegistry();
|
|
646
671
|
const runningWidget = new RunningSubagentWidgetController();
|
|
672
|
+
// The same live runs as the TUI widget, published as the structured
|
|
673
|
+
// `cypher.subagents.v1` projection when Pi runs under Cypher (RPC mode).
|
|
674
|
+
const cypherStatus = new CypherStatusPublisher();
|
|
647
675
|
const sessionHandles = new Map<string, SubagentSessionHandle>();
|
|
648
676
|
const waitGraph = new MessageWaitGraph();
|
|
649
677
|
const pendingMainReplyEdges = new Map<
|
|
650
678
|
string,
|
|
651
679
|
{ release: () => void; timer?: ReturnType<typeof setTimeout> }
|
|
652
680
|
>();
|
|
681
|
+
const persistedTasks = new Map<string, PersistedTaskState>();
|
|
653
682
|
let sessionContext: any;
|
|
683
|
+
let runtimeContext: any;
|
|
684
|
+
let currentSessionToken: string | undefined;
|
|
685
|
+
let acceptingTasks = false;
|
|
686
|
+
let shutdownStarted = false;
|
|
687
|
+
let router: ReturnType<typeof createMessageRouter> | undefined;
|
|
654
688
|
let terminalInputUnsubscribe: (() => void) | undefined;
|
|
655
689
|
let sessionOverlayOpen = false;
|
|
656
690
|
let closeSessionOverlay: (() => void) | undefined;
|
|
657
691
|
let pendingOpenActivityId: string | undefined;
|
|
658
692
|
let lastNavigation: { direction: -1 | 1; at: number } | undefined;
|
|
659
693
|
|
|
694
|
+
function appendTaskSnapshot(state: PersistedTaskState): PersistedTaskState {
|
|
695
|
+
pi.appendEntry(TASK_STATE_ENTRY, state);
|
|
696
|
+
persistedTasks.set(state.runId, state);
|
|
697
|
+
return state;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function transitionTask(
|
|
701
|
+
runId: string,
|
|
702
|
+
expected: readonly PersistedTaskStatus[],
|
|
703
|
+
patch: Partial<Omit<PersistedTaskState, "version" | "runId" | "updatedAt">>,
|
|
704
|
+
now = Date.now(),
|
|
705
|
+
): PersistedTaskState | undefined {
|
|
706
|
+
const current = persistedTasks.get(runId);
|
|
707
|
+
if (!current || !expected.includes(current.status)) return undefined;
|
|
708
|
+
const next: PersistedTaskState = {
|
|
709
|
+
...current,
|
|
710
|
+
...patch,
|
|
711
|
+
version: TASK_STATE_VERSION,
|
|
712
|
+
runId,
|
|
713
|
+
updatedAt: now,
|
|
714
|
+
};
|
|
715
|
+
return appendTaskSnapshot(next);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function reportPersistenceFailure(error: unknown, prefix: string): void {
|
|
719
|
+
const message = `${prefix}: ${error instanceof Error ? error.message : String(error)}`;
|
|
720
|
+
try {
|
|
721
|
+
runtimeContext?.ui?.notify?.(message, "warning");
|
|
722
|
+
} catch {
|
|
723
|
+
/* UI may already be closing */
|
|
724
|
+
}
|
|
725
|
+
console.error(`[pi-agent-squad] ${message}`);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const recoveryOutbox = new RecoveryOutbox({
|
|
729
|
+
getSessionToken: () => currentSessionToken,
|
|
730
|
+
getBranch: () => runtimeContext?.sessionManager?.getBranch?.() ?? [],
|
|
731
|
+
getTaskState: (runId) => persistedTasks.get(runId),
|
|
732
|
+
send: (message, options) => pi.sendMessage(message, options),
|
|
733
|
+
markInjected: (state) => {
|
|
734
|
+
transitionTask(
|
|
735
|
+
state.runId,
|
|
736
|
+
["completed"],
|
|
737
|
+
{ status: "completed", resultInjected: true },
|
|
738
|
+
);
|
|
739
|
+
},
|
|
740
|
+
onError: (error, state) =>
|
|
741
|
+
reportPersistenceFailure(
|
|
742
|
+
error,
|
|
743
|
+
`Could not finish result delivery bookkeeping for run ${state.runId.slice(0, 8)}`,
|
|
744
|
+
),
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
function refreshPersistedTasks(entries: readonly unknown[]): void {
|
|
748
|
+
persistedTasks.clear();
|
|
749
|
+
for (const [runId, state] of reconstructTaskStates(entries)) {
|
|
750
|
+
persistedTasks.set(runId, state);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function liveTaskRunIds(): Set<string> {
|
|
755
|
+
return new Set([
|
|
756
|
+
...tasks.keys(),
|
|
757
|
+
...activeRuns.list().map((run) => run.runId),
|
|
758
|
+
]);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function reconcileCurrentBranch(): void {
|
|
762
|
+
const branch = runtimeContext?.sessionManager?.getBranch?.() ?? [];
|
|
763
|
+
const currentLiveIds = liveTaskRunIds();
|
|
764
|
+
const liveSnapshots = [...persistedTasks.values()].filter(
|
|
765
|
+
(state) =>
|
|
766
|
+
state.ownerRuntimeId === ownerRuntimeId &&
|
|
767
|
+
currentLiveIds.has(state.runId) &&
|
|
768
|
+
(state.status === "starting" || state.status === "running"),
|
|
769
|
+
);
|
|
770
|
+
refreshPersistedTasks(branch);
|
|
771
|
+
for (const state of liveSnapshots) {
|
|
772
|
+
if (!persistedTasks.has(state.runId)) persistedTasks.set(state.runId, state);
|
|
773
|
+
}
|
|
774
|
+
const plan = planTaskRecovery(
|
|
775
|
+
persistedTasks,
|
|
776
|
+
ownerRuntimeId,
|
|
777
|
+
currentLiveIds,
|
|
778
|
+
(deliveryId) => branchHasResultDelivery(branch, deliveryId),
|
|
779
|
+
);
|
|
780
|
+
for (const stale of plan.interrupt) {
|
|
781
|
+
const now = Date.now();
|
|
782
|
+
try {
|
|
783
|
+
transitionTask(
|
|
784
|
+
stale.runId,
|
|
785
|
+
["starting", "running"],
|
|
786
|
+
{
|
|
787
|
+
status: "interrupted",
|
|
788
|
+
error: "Owning Pi runtime ended before the task reached a terminal state.",
|
|
789
|
+
endedAt: now,
|
|
790
|
+
},
|
|
791
|
+
now,
|
|
792
|
+
);
|
|
793
|
+
} catch (error) {
|
|
794
|
+
reportPersistenceFailure(
|
|
795
|
+
error,
|
|
796
|
+
`Could not mark stale run ${stale.runId.slice(0, 8)} interrupted`,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
for (const delivered of plan.markInjected) {
|
|
801
|
+
try {
|
|
802
|
+
transitionTask(
|
|
803
|
+
delivered.runId,
|
|
804
|
+
["completed"],
|
|
805
|
+
{ status: "completed", resultInjected: true },
|
|
806
|
+
);
|
|
807
|
+
} catch (error) {
|
|
808
|
+
reportPersistenceFailure(
|
|
809
|
+
error,
|
|
810
|
+
`Could not repair delivery state for run ${delivered.runId.slice(0, 8)}`,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
for (const pending of plan.deliver) recoveryOutbox.enqueue(pending);
|
|
815
|
+
}
|
|
816
|
+
|
|
660
817
|
const bindPoolLifecycle = (): void => {
|
|
661
818
|
pool.setProcessExitHandler((agentName, runId) => {
|
|
662
819
|
const run = activeRuns.resolveExact(runId);
|
|
@@ -715,6 +872,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
715
872
|
|
|
716
873
|
pi.on("session_start", (event, ctx) => {
|
|
717
874
|
sessionGeneration++;
|
|
875
|
+
shutdownStarted = false;
|
|
876
|
+
acceptingTasks = false;
|
|
877
|
+
recoveryOutbox.cancelAll();
|
|
718
878
|
if (poolDisposed) {
|
|
719
879
|
pool = new SubagentPool(messageRoot);
|
|
720
880
|
bindPoolLifecycle();
|
|
@@ -725,6 +885,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
725
885
|
ctx.sessionManager?.getSessionId?.() ??
|
|
726
886
|
(event as any).sessionId ??
|
|
727
887
|
"ephemeral";
|
|
888
|
+
currentSessionToken = `${sessionId}:${sessionGeneration}`;
|
|
889
|
+
runtimeContext = ctx;
|
|
728
890
|
messageRoot = sessionRoot(sessionId);
|
|
729
891
|
ensureChannelRoot(MESSAGE_ROOT_BASE);
|
|
730
892
|
sweepMessageRoots(MESSAGE_ROOT_BASE);
|
|
@@ -732,7 +894,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
732
894
|
pool.setIntercomRoot(messageRoot);
|
|
733
895
|
pool.setWorkingDirectory(cwd);
|
|
734
896
|
runningWidget.attach(ctx);
|
|
897
|
+
cypherStatus.attach(ctx);
|
|
735
898
|
sessionContext = ctx.mode === "tui" ? ctx : undefined;
|
|
899
|
+
reconcileCurrentBranch();
|
|
900
|
+
acceptingTasks = true;
|
|
736
901
|
lastNavigation = undefined;
|
|
737
902
|
terminalInputUnsubscribe?.();
|
|
738
903
|
terminalInputUnsubscribe =
|
|
@@ -760,7 +925,42 @@ export default function (pi: ExtensionAPI) {
|
|
|
760
925
|
: undefined;
|
|
761
926
|
});
|
|
762
927
|
|
|
763
|
-
pi.on("session_shutdown", () => {
|
|
928
|
+
pi.on("session_shutdown", (event, ctx) => {
|
|
929
|
+
if (shutdownStarted) return;
|
|
930
|
+
shutdownStarted = true;
|
|
931
|
+
acceptingTasks = false;
|
|
932
|
+
recoveryOutbox.cancelAll();
|
|
933
|
+
const now = Date.now();
|
|
934
|
+
for (const state of [...persistedTasks.values()]) {
|
|
935
|
+
if (
|
|
936
|
+
state.ownerRuntimeId !== ownerRuntimeId ||
|
|
937
|
+
(state.status !== "starting" && state.status !== "running")
|
|
938
|
+
) {
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
const reason = boundTaskText(
|
|
942
|
+
[
|
|
943
|
+
"Owning Pi runtime shut down before the task reached a terminal state.",
|
|
944
|
+
event.reason ? `Reason: ${event.reason}.` : "",
|
|
945
|
+
]
|
|
946
|
+
.filter(Boolean)
|
|
947
|
+
.join(" "),
|
|
948
|
+
TASK_STATE_LIMITS.error,
|
|
949
|
+
);
|
|
950
|
+
try {
|
|
951
|
+
transitionTask(
|
|
952
|
+
state.runId,
|
|
953
|
+
["starting", "running"],
|
|
954
|
+
{ status: "interrupted", error: reason, endedAt: now },
|
|
955
|
+
now,
|
|
956
|
+
);
|
|
957
|
+
} catch (error) {
|
|
958
|
+
reportPersistenceFailure(
|
|
959
|
+
error,
|
|
960
|
+
`Could not persist shutdown interruption for run ${state.runId.slice(0, 8)}`,
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
764
964
|
sessionGeneration++;
|
|
765
965
|
for (const controller of runControllers.values()) controller.abort();
|
|
766
966
|
runControllers.clear();
|
|
@@ -772,16 +972,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
772
972
|
sessionContext = undefined;
|
|
773
973
|
pendingOpenActivityId = undefined;
|
|
774
974
|
sessionHandles.clear();
|
|
775
|
-
activeRuns.clear();
|
|
776
975
|
for (const pending of pendingMainReplyEdges.values()) {
|
|
777
976
|
if (pending.timer) clearTimeout(pending.timer);
|
|
778
977
|
pending.release();
|
|
779
978
|
}
|
|
780
979
|
pendingMainReplyEdges.clear();
|
|
781
980
|
waitGraph.clear();
|
|
782
|
-
runningWidget.shutdown();
|
|
783
981
|
pool.dispose();
|
|
784
982
|
poolDisposed = true;
|
|
983
|
+
router?.dispose();
|
|
984
|
+
router = undefined;
|
|
985
|
+
activeRuns.clear();
|
|
986
|
+
tasks.clear();
|
|
987
|
+
runningWidget.shutdown();
|
|
988
|
+
cypherStatus.shutdown();
|
|
989
|
+
currentSessionToken = undefined;
|
|
990
|
+
runtimeContext = undefined;
|
|
785
991
|
sweepMessageRoots(MESSAGE_ROOT_BASE);
|
|
786
992
|
});
|
|
787
993
|
|
|
@@ -809,7 +1015,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
809
1015
|
// ---- message router: subagent -> main injects into main session; child
|
|
810
1016
|
// messages resolve through the active-run registry before being delivered
|
|
811
1017
|
// to a direct/background session or the resident pool. ----
|
|
812
|
-
let router: ReturnType<typeof createMessageRouter> | undefined;
|
|
813
1018
|
const routedTimeout = (msg: MessageRequest): number =>
|
|
814
1019
|
Math.min(
|
|
815
1020
|
MAX_SUBAGENT_TIMEOUT_SECONDS * 1000,
|
|
@@ -981,6 +1186,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
981
1186
|
}
|
|
982
1187
|
const activityId = `message:${msg.id}`;
|
|
983
1188
|
runningWidget.start(activityId, resolvedTargetRun.address, msg.content, "message");
|
|
1189
|
+
// Subagent-to-subagent traffic has no parent tool call; Cypher
|
|
1190
|
+
// renders it as its own `message` row.
|
|
1191
|
+
cypherStatus.start({
|
|
1192
|
+
runId: activityId,
|
|
1193
|
+
agent: resolvedTargetRun.address,
|
|
1194
|
+
task: msg.content,
|
|
1195
|
+
mode: "message",
|
|
1196
|
+
});
|
|
984
1197
|
try {
|
|
985
1198
|
if (resolvedTargetRun.session) {
|
|
986
1199
|
const session = await resolvedTargetRun.session;
|
|
@@ -993,6 +1206,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
993
1206
|
return replyText;
|
|
994
1207
|
} catch (e) {
|
|
995
1208
|
const reply = `Target subagent ${resolvedTargetRun.address} failed to process the message: ${e instanceof Error ? e.message : String(e)}`;
|
|
1209
|
+
cypherStatus.settleIfLive(activityId, "error", e instanceof Error ? e.message : String(e));
|
|
996
1210
|
completeRoutedMessage(msg, reply, routeRoot);
|
|
997
1211
|
if (msg.from === MAIN_AGENT) throw new Error(reply);
|
|
998
1212
|
return reply;
|
|
@@ -1001,6 +1215,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1001
1215
|
if (pendingOpenActivityId === activityId) pendingOpenActivityId = undefined;
|
|
1002
1216
|
sessionHandles.delete(activityId);
|
|
1003
1217
|
runningWidget.finish(activityId);
|
|
1218
|
+
cypherStatus.settleIfLive(activityId, "done");
|
|
1004
1219
|
}
|
|
1005
1220
|
},
|
|
1006
1221
|
onMessageReplied: (msg: MessageRequest) => releaseMainReplyEdge(msg.id),
|
|
@@ -1009,11 +1224,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1009
1224
|
router.start();
|
|
1010
1225
|
});
|
|
1011
1226
|
|
|
1012
|
-
pi.on("session_shutdown", () => {
|
|
1013
|
-
router?.dispose();
|
|
1014
|
-
router = undefined;
|
|
1015
|
-
});
|
|
1016
|
-
|
|
1017
1227
|
/** Write a reply back to the message sender */
|
|
1018
1228
|
function writeReplyTo(msg: MessageRequest, content: string, root = messageRoot): boolean {
|
|
1019
1229
|
try {
|
|
@@ -1045,20 +1255,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1045
1255
|
return `${agent.name}#${runId.slice(0, 8)}`;
|
|
1046
1256
|
}
|
|
1047
1257
|
|
|
1048
|
-
function
|
|
1258
|
+
function resolveDirectRunAddress(
|
|
1049
1259
|
agent: AgentConfig,
|
|
1050
1260
|
runId: string,
|
|
1051
|
-
mode: "background" | "task",
|
|
1052
1261
|
requestedAddress?: string,
|
|
1053
|
-
|
|
1054
|
-
runCwd = cwd,
|
|
1055
|
-
): {
|
|
1056
|
-
address: string;
|
|
1057
|
-
session: Promise<SubagentSessionHandle>;
|
|
1058
|
-
resolveSession: (session: SubagentSessionHandle) => void;
|
|
1059
|
-
rejectSession: (error: Error) => void;
|
|
1060
|
-
} {
|
|
1262
|
+
): string {
|
|
1061
1263
|
const address = requestedAddress?.trim() || directRunAddress(agent, runId);
|
|
1264
|
+
validateLaunchText("address", address);
|
|
1062
1265
|
if (
|
|
1063
1266
|
(requestedAddress &&
|
|
1064
1267
|
(!/^[A-Za-z0-9_.-]+$/.test(address) ||
|
|
@@ -1073,6 +1276,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
1073
1276
|
`Invalid or unavailable runtime address "${address}". Use a unique name matching [A-Za-z0-9_.-] that is not a logical agent name or "main".`,
|
|
1074
1277
|
);
|
|
1075
1278
|
}
|
|
1279
|
+
return address;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function createDirectRun(
|
|
1283
|
+
agent: AgentConfig,
|
|
1284
|
+
runId: string,
|
|
1285
|
+
mode: "background" | "task",
|
|
1286
|
+
address: string,
|
|
1287
|
+
readOnly = agent.readOnly === true,
|
|
1288
|
+
runCwd = cwd,
|
|
1289
|
+
): {
|
|
1290
|
+
address: string;
|
|
1291
|
+
session: Promise<SubagentSessionHandle>;
|
|
1292
|
+
resolveSession: (session: SubagentSessionHandle) => void;
|
|
1293
|
+
rejectSession: (error: Error) => void;
|
|
1294
|
+
} {
|
|
1295
|
+
if (activeRuns.hasAddress(address)) {
|
|
1296
|
+
throw new Error(`Runtime address "${address}" became unavailable before launch.`);
|
|
1297
|
+
}
|
|
1076
1298
|
let resolveSession!: (session: SubagentSessionHandle) => void;
|
|
1077
1299
|
let rejectSession!: (error: Error) => void;
|
|
1078
1300
|
const session = new Promise<SubagentSessionHandle>((resolve, reject) => {
|
|
@@ -1122,29 +1344,89 @@ export default function (pi: ExtensionAPI) {
|
|
|
1122
1344
|
timeoutMs?: number,
|
|
1123
1345
|
requestedAddress?: string,
|
|
1124
1346
|
readOnly = agent.readOnly === true,
|
|
1347
|
+
retryOf?: string,
|
|
1348
|
+
toolCallId?: string,
|
|
1125
1349
|
): { runId: string; address: string } {
|
|
1350
|
+
if (!acceptingTasks) throw new Error("This Pi session is shutting down and cannot accept new subagent tasks.");
|
|
1351
|
+
validateLaunchText("agent", agent.name);
|
|
1352
|
+
validateLaunchText("task", taskText, { allowEmpty: true });
|
|
1353
|
+
const runCwd = normalizeCwd(cwdOverride ?? cwd);
|
|
1354
|
+
validateLaunchText("cwd", runCwd);
|
|
1126
1355
|
const runId = randomUUID();
|
|
1127
|
-
const
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1356
|
+
const address = resolveDirectRunAddress(agent, runId, requestedAddress);
|
|
1357
|
+
const startedAt = Date.now();
|
|
1358
|
+
const effectiveTimeoutMs = Math.max(
|
|
1359
|
+
1000,
|
|
1360
|
+
timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_SECONDS * 1000,
|
|
1361
|
+
);
|
|
1362
|
+
const timeoutAt = Math.min(Number.MAX_SAFE_INTEGER, startedAt + effectiveTimeoutMs);
|
|
1363
|
+
const starting: PersistedTaskState = {
|
|
1364
|
+
version: TASK_STATE_VERSION,
|
|
1130
1365
|
runId,
|
|
1131
|
-
|
|
1132
|
-
|
|
1366
|
+
address,
|
|
1367
|
+
agent: agent.name,
|
|
1368
|
+
task: taskText,
|
|
1369
|
+
cwd: runCwd,
|
|
1133
1370
|
readOnly,
|
|
1134
|
-
|
|
1135
|
-
|
|
1371
|
+
mode: "async",
|
|
1372
|
+
status: "starting",
|
|
1373
|
+
startedAt,
|
|
1374
|
+
updatedAt: startedAt,
|
|
1375
|
+
timeoutAt,
|
|
1376
|
+
...(retryOf ? { retryOf } : {}),
|
|
1377
|
+
ownerRuntimeId,
|
|
1378
|
+
};
|
|
1379
|
+
appendTaskSnapshot(starting);
|
|
1380
|
+
|
|
1381
|
+
const controller = new AbortController();
|
|
1382
|
+
let direct: ReturnType<typeof createDirectRun>;
|
|
1383
|
+
try {
|
|
1384
|
+
direct = createDirectRun(agent, runId, "background", address, readOnly, runCwd);
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
const now = Date.now();
|
|
1387
|
+
transitionTask(
|
|
1388
|
+
runId,
|
|
1389
|
+
["starting"],
|
|
1390
|
+
{
|
|
1391
|
+
status: "failed",
|
|
1392
|
+
error: boundTaskText(
|
|
1393
|
+
error instanceof Error ? error.message : String(error),
|
|
1394
|
+
TASK_STATE_LIMITS.error,
|
|
1395
|
+
),
|
|
1396
|
+
endedAt: now,
|
|
1397
|
+
},
|
|
1398
|
+
now,
|
|
1399
|
+
);
|
|
1400
|
+
throw error;
|
|
1401
|
+
}
|
|
1136
1402
|
runControllers.set(runId, controller);
|
|
1137
1403
|
tasks.set(runId, {
|
|
1138
1404
|
runId,
|
|
1139
1405
|
agent: agent.name,
|
|
1140
|
-
address
|
|
1141
|
-
|
|
1406
|
+
address,
|
|
1407
|
+
task: taskText,
|
|
1408
|
+
cwd: runCwd,
|
|
1409
|
+
readOnly,
|
|
1410
|
+
startedAt,
|
|
1411
|
+
updatedAt: startedAt,
|
|
1412
|
+
timeoutAt,
|
|
1142
1413
|
sessionGeneration,
|
|
1143
1414
|
});
|
|
1144
1415
|
runningWidget.start(runId, agent.name, taskText, "background");
|
|
1416
|
+
// Async: the tool call returns a launch ack immediately, so the snapshot
|
|
1417
|
+
// is the ONLY thing that keeps Cypher's row alive until the child ends.
|
|
1418
|
+
cypherStatus.start({
|
|
1419
|
+
runId,
|
|
1420
|
+
toolCallId,
|
|
1421
|
+
agent: agent.name,
|
|
1422
|
+
task: taskText,
|
|
1423
|
+
mode: "async",
|
|
1424
|
+
model: agent.model,
|
|
1425
|
+
startedAt,
|
|
1426
|
+
});
|
|
1145
1427
|
|
|
1146
1428
|
const start = async () => {
|
|
1147
|
-
return await spawnInteractiveSubagent({
|
|
1429
|
+
return await dependencies.spawnInteractiveSubagent({
|
|
1148
1430
|
agent,
|
|
1149
1431
|
task: taskText,
|
|
1150
1432
|
address: direct.address,
|
|
@@ -1153,7 +1435,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
1153
1435
|
runId,
|
|
1154
1436
|
childIndex: 0,
|
|
1155
1437
|
signal: controller.signal,
|
|
1156
|
-
timeoutMs,
|
|
1438
|
+
timeoutMs: effectiveTimeoutMs,
|
|
1439
|
+
persistSession: true,
|
|
1440
|
+
onEvent: (event) => cypherStatus.observeChildEvent(runId, event),
|
|
1441
|
+
onStarted: (childSessionFile) => {
|
|
1442
|
+
const now = Date.now();
|
|
1443
|
+
const running = transitionTask(
|
|
1444
|
+
runId,
|
|
1445
|
+
["starting"],
|
|
1446
|
+
{
|
|
1447
|
+
status: "running",
|
|
1448
|
+
...(childSessionFile
|
|
1449
|
+
? {
|
|
1450
|
+
childSessionFile: boundTaskText(
|
|
1451
|
+
childSessionFile,
|
|
1452
|
+
TASK_STATE_LIMITS.childSessionFile,
|
|
1453
|
+
),
|
|
1454
|
+
}
|
|
1455
|
+
: {}),
|
|
1456
|
+
},
|
|
1457
|
+
now,
|
|
1458
|
+
);
|
|
1459
|
+
const task = tasks.get(runId);
|
|
1460
|
+
if (task && running) task.updatedAt = running.updatedAt;
|
|
1461
|
+
},
|
|
1157
1462
|
onSession: (session) => {
|
|
1158
1463
|
direct.resolveSession(session);
|
|
1159
1464
|
registerSessionHandle(runId, session);
|
|
@@ -1168,47 +1473,88 @@ export default function (pi: ExtensionAPI) {
|
|
|
1168
1473
|
activeRuns.remove(runId);
|
|
1169
1474
|
const task = tasks.get(runId);
|
|
1170
1475
|
if (!task || task.sessionGeneration !== sessionGeneration) return;
|
|
1171
|
-
const finalStatus: "done" | "error" =
|
|
1172
|
-
result.exitCode === 0 && result.stopReason !== "error" ? "done" : "error";
|
|
1173
1476
|
const output = getFinalOutput(result.messages) || "(no text output)";
|
|
1174
1477
|
const failureReason =
|
|
1175
1478
|
result.exitCode !== 0
|
|
1176
|
-
? result.
|
|
1479
|
+
? result.errorMessage || result.stderr || `process exited with code ${result.exitCode}`
|
|
1177
1480
|
: result.stopReason === "error"
|
|
1178
1481
|
? result.errorMessage || "subagent reported an error"
|
|
1179
1482
|
: "";
|
|
1180
|
-
const
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
const
|
|
1185
|
-
|
|
1186
|
-
?
|
|
1187
|
-
:
|
|
1188
|
-
|
|
1483
|
+
const cancelled =
|
|
1484
|
+
result.exitCode === 124 ||
|
|
1485
|
+
result.stopReason === "aborted" ||
|
|
1486
|
+
/\b(?:timed out|cancelled|canceled|aborted)\b/i.test(result.errorMessage ?? "");
|
|
1487
|
+
const finalStatus: PersistedTaskStatus =
|
|
1488
|
+
result.exitCode === 0 && result.stopReason !== "error" && result.stopReason !== "aborted"
|
|
1489
|
+
? "completed"
|
|
1490
|
+
: cancelled
|
|
1491
|
+
? "cancelled"
|
|
1492
|
+
: "failed";
|
|
1493
|
+
const now = Date.now();
|
|
1494
|
+
if (result.model) cypherStatus.update(runId, { model: result.model });
|
|
1495
|
+
cypherStatus.settleIfLive(
|
|
1496
|
+
runId,
|
|
1497
|
+
finalStatus === "completed" ? "done" : "error",
|
|
1498
|
+
finalStatus === "completed" ? undefined : failureReason || finalStatus,
|
|
1499
|
+
);
|
|
1500
|
+
try {
|
|
1501
|
+
if (finalStatus === "completed") {
|
|
1502
|
+
const completed = transitionTask(
|
|
1503
|
+
runId,
|
|
1504
|
+
["starting", "running"],
|
|
1505
|
+
{
|
|
1506
|
+
status: "completed",
|
|
1507
|
+
resultSummary: boundTaskText(output, TASK_STATE_LIMITS.resultSummary),
|
|
1508
|
+
deliveryId: resultDeliveryId(runId),
|
|
1509
|
+
resultInjected: false,
|
|
1510
|
+
endedAt: now,
|
|
1511
|
+
},
|
|
1512
|
+
now,
|
|
1513
|
+
);
|
|
1514
|
+
if (completed) {
|
|
1515
|
+
recoveryOutbox.enqueue(completed, { body: output, content: output });
|
|
1516
|
+
}
|
|
1517
|
+
} else {
|
|
1518
|
+
const diagnostic = boundTaskText(
|
|
1519
|
+
failureReason || result.errorMessage || `Subagent ${finalStatus}.`,
|
|
1520
|
+
TASK_STATE_LIMITS.error,
|
|
1521
|
+
);
|
|
1522
|
+
const terminal = transitionTask(
|
|
1523
|
+
runId,
|
|
1524
|
+
["starting", "running"],
|
|
1525
|
+
{ status: finalStatus, error: diagnostic, endedAt: now },
|
|
1526
|
+
now,
|
|
1527
|
+
);
|
|
1528
|
+
if (terminal) {
|
|
1529
|
+
const displayBody = [
|
|
1530
|
+
diagnostic,
|
|
1189
1531
|
output === "(no text output)" ? "" : `**Partial result**\n\n${output}`,
|
|
1190
1532
|
]
|
|
1191
1533
|
.filter(Boolean)
|
|
1192
1534
|
.join("\n\n");
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1535
|
+
pi.sendMessage(
|
|
1536
|
+
{
|
|
1537
|
+
customType: BACKGROUND_EVENT_TYPE,
|
|
1538
|
+
content: `[background subagent error] ${agent.name} (run ${runId.slice(0, 8)}): ${diagnostic}`,
|
|
1539
|
+
display: true,
|
|
1540
|
+
details: {
|
|
1541
|
+
agent: agent.name,
|
|
1542
|
+
address,
|
|
1543
|
+
status: "error",
|
|
1544
|
+
body: displayBody,
|
|
1545
|
+
elapsedMs: now - task.startedAt,
|
|
1546
|
+
runId,
|
|
1547
|
+
},
|
|
1548
|
+
},
|
|
1549
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
reportPersistenceFailure(
|
|
1555
|
+
error,
|
|
1556
|
+
`Could not finalize background run ${runId.slice(0, 8)}`,
|
|
1209
1557
|
);
|
|
1210
|
-
} catch {
|
|
1211
|
-
/* session is closed */
|
|
1212
1558
|
}
|
|
1213
1559
|
})
|
|
1214
1560
|
.catch((err) => {
|
|
@@ -1216,26 +1562,48 @@ export default function (pi: ExtensionAPI) {
|
|
|
1216
1562
|
direct.rejectSession(err instanceof Error ? err : new Error(String(err)));
|
|
1217
1563
|
const task = tasks.get(runId);
|
|
1218
1564
|
const errorText = err instanceof Error ? err.message : String(err);
|
|
1565
|
+
cypherStatus.settleIfLive(runId, "error", errorText);
|
|
1566
|
+
const current = persistedTasks.get(runId);
|
|
1567
|
+
if (!current || isTerminalTaskStatus(current.status)) return;
|
|
1568
|
+
const cancelled =
|
|
1569
|
+
controller.signal.aborted ||
|
|
1570
|
+
/\b(?:timed out|cancelled|canceled|aborted)\b/i.test(errorText);
|
|
1571
|
+
const now = Date.now();
|
|
1219
1572
|
try {
|
|
1220
1573
|
if (task && task.sessionGeneration !== sessionGeneration) return;
|
|
1221
|
-
|
|
1574
|
+
const terminal = transitionTask(
|
|
1575
|
+
runId,
|
|
1576
|
+
["starting", "running"],
|
|
1222
1577
|
{
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
details: {
|
|
1227
|
-
agent: agent.name,
|
|
1228
|
-
address: direct.address,
|
|
1229
|
-
status: "error",
|
|
1230
|
-
body: errorText,
|
|
1231
|
-
elapsedMs: task ? Date.now() - task.startedAt : undefined,
|
|
1232
|
-
runId,
|
|
1233
|
-
},
|
|
1578
|
+
status: cancelled ? "cancelled" : "failed",
|
|
1579
|
+
error: boundTaskText(errorText, TASK_STATE_LIMITS.error),
|
|
1580
|
+
endedAt: now,
|
|
1234
1581
|
},
|
|
1235
|
-
|
|
1582
|
+
now,
|
|
1583
|
+
);
|
|
1584
|
+
if (terminal) {
|
|
1585
|
+
pi.sendMessage(
|
|
1586
|
+
{
|
|
1587
|
+
customType: BACKGROUND_EVENT_TYPE,
|
|
1588
|
+
content: `[background subagent error] ${agent.name} (run ${runId.slice(0, 8)}): ${errorText}`,
|
|
1589
|
+
display: true,
|
|
1590
|
+
details: {
|
|
1591
|
+
agent: agent.name,
|
|
1592
|
+
address,
|
|
1593
|
+
status: "error",
|
|
1594
|
+
body: errorText,
|
|
1595
|
+
elapsedMs: task ? now - task.startedAt : undefined,
|
|
1596
|
+
runId,
|
|
1597
|
+
},
|
|
1598
|
+
},
|
|
1599
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
} catch (error) {
|
|
1603
|
+
reportPersistenceFailure(
|
|
1604
|
+
error,
|
|
1605
|
+
`Could not record background failure for run ${runId.slice(0, 8)}`,
|
|
1236
1606
|
);
|
|
1237
|
-
} catch {
|
|
1238
|
-
/* ignore */
|
|
1239
1607
|
}
|
|
1240
1608
|
})
|
|
1241
1609
|
.finally(() => {
|
|
@@ -1245,9 +1613,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1245
1613
|
if (pendingOpenActivityId === runId) pendingOpenActivityId = undefined;
|
|
1246
1614
|
sessionHandles.delete(runId);
|
|
1247
1615
|
runningWidget.finish(runId);
|
|
1616
|
+
// Belt and braces: an early return above (a stale session
|
|
1617
|
+
// generation) must never leave a runner published forever.
|
|
1618
|
+
cypherStatus.settleIfLive(runId, "done");
|
|
1248
1619
|
});
|
|
1249
1620
|
|
|
1250
|
-
return { runId, address
|
|
1621
|
+
return { runId, address };
|
|
1251
1622
|
}
|
|
1252
1623
|
|
|
1253
1624
|
// ---- adaptive orchestration prompt; `/orchestrate on` installs the opt-in prompt ----
|
|
@@ -1277,11 +1648,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1277
1648
|
"While running, a subagent may send_message (to=main) to reach you, or contact other subagents — reply promptly with reply_message. Synchronous tasks must use wait=false when contacting main.",
|
|
1278
1649
|
].join(" "),
|
|
1279
1650
|
parameters: Type.Object({
|
|
1280
|
-
agent: Type.String({ description: "Subagent name" }),
|
|
1281
|
-
task: Type.String({ description: "Task description for the subagent" }),
|
|
1282
|
-
cwd: Type.Optional(Type.String({ description: "Working directory for the subagent" })),
|
|
1651
|
+
agent: Type.String({ maxLength: TASK_STATE_LIMITS.agent, description: "Subagent name" }),
|
|
1652
|
+
task: Type.String({ maxLength: TASK_STATE_LIMITS.task, description: "Task description for the subagent" }),
|
|
1653
|
+
cwd: Type.Optional(Type.String({ maxLength: TASK_STATE_LIMITS.cwd, description: "Working directory for the subagent" })),
|
|
1283
1654
|
as: Type.Optional(
|
|
1284
1655
|
Type.String({
|
|
1656
|
+
maxLength: TASK_STATE_LIMITS.address,
|
|
1285
1657
|
description: "Optional unique runtime address for this run (for example actor-a); identity still comes from agent",
|
|
1286
1658
|
}),
|
|
1287
1659
|
),
|
|
@@ -1300,7 +1672,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1300
1672
|
),
|
|
1301
1673
|
}),
|
|
1302
1674
|
|
|
1303
|
-
async execute(
|
|
1675
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1676
|
+
if (!acceptingTasks) {
|
|
1677
|
+
return {
|
|
1678
|
+
content: [{ type: "text", text: "This Pi session is shutting down and cannot accept new subagent tasks." }],
|
|
1679
|
+
details: undefined,
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1304
1682
|
const agents = discoverAgents(ctx.cwd);
|
|
1305
1683
|
const agent = agents.find((a) => a.name === params.agent);
|
|
1306
1684
|
if (!agent) {
|
|
@@ -1319,7 +1697,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1319
1697
|
if (params.async) {
|
|
1320
1698
|
try {
|
|
1321
1699
|
const readOnly = params.readonly ?? agent.readOnly === true;
|
|
1322
|
-
const launched = launchBackground(
|
|
1700
|
+
const launched = launchBackground(
|
|
1701
|
+
agent,
|
|
1702
|
+
params.task,
|
|
1703
|
+
params.cwd,
|
|
1704
|
+
timeoutMs,
|
|
1705
|
+
params.as,
|
|
1706
|
+
readOnly,
|
|
1707
|
+
undefined,
|
|
1708
|
+
toolCallId,
|
|
1709
|
+
);
|
|
1323
1710
|
const { runId, address } = launched;
|
|
1324
1711
|
return {
|
|
1325
1712
|
content: [
|
|
@@ -1351,7 +1738,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1351
1738
|
else signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
1352
1739
|
let direct: ReturnType<typeof createDirectRun>;
|
|
1353
1740
|
try {
|
|
1354
|
-
|
|
1741
|
+
validateLaunchText("task", params.task, { allowEmpty: true });
|
|
1742
|
+
const address = resolveDirectRunAddress(agent, runId, params.as);
|
|
1743
|
+
direct = createDirectRun(agent, runId, "task", address, readOnly, params.cwd ?? cwd);
|
|
1355
1744
|
} catch (error) {
|
|
1356
1745
|
signal?.removeEventListener("abort", forwardAbort);
|
|
1357
1746
|
runControllers.delete(runId);
|
|
@@ -1371,9 +1760,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
1371
1760
|
};
|
|
1372
1761
|
}
|
|
1373
1762
|
runningWidget.start(runId, agent.name, params.task, "task");
|
|
1763
|
+
cypherStatus.start({
|
|
1764
|
+
runId,
|
|
1765
|
+
toolCallId,
|
|
1766
|
+
agent: agent.name,
|
|
1767
|
+
task: params.task,
|
|
1768
|
+
mode: "sync",
|
|
1769
|
+
model: agent.model,
|
|
1770
|
+
});
|
|
1374
1771
|
let result: Awaited<ReturnType<typeof spawnInteractiveSubagent>>;
|
|
1375
1772
|
try {
|
|
1376
|
-
result = await spawnInteractiveSubagent({
|
|
1773
|
+
result = await dependencies.spawnInteractiveSubagent({
|
|
1377
1774
|
agent,
|
|
1378
1775
|
task: params.task,
|
|
1379
1776
|
address: direct.address,
|
|
@@ -1383,6 +1780,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1383
1780
|
childIndex: 0,
|
|
1384
1781
|
signal: runController.signal,
|
|
1385
1782
|
timeoutMs,
|
|
1783
|
+
onEvent: (event) => cypherStatus.observeChildEvent(runId, event),
|
|
1386
1784
|
onSession: (session) => {
|
|
1387
1785
|
direct.resolveSession(session);
|
|
1388
1786
|
registerSessionHandle(runId, session);
|
|
@@ -1390,6 +1788,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1390
1788
|
});
|
|
1391
1789
|
} catch (error) {
|
|
1392
1790
|
direct.rejectSession(error instanceof Error ? error : new Error(String(error)));
|
|
1791
|
+
cypherStatus.settleIfLive(
|
|
1792
|
+
runId,
|
|
1793
|
+
"error",
|
|
1794
|
+
error instanceof Error ? error.message : String(error),
|
|
1795
|
+
);
|
|
1393
1796
|
throw error;
|
|
1394
1797
|
} finally {
|
|
1395
1798
|
signal?.removeEventListener("abort", forwardAbort);
|
|
@@ -1401,6 +1804,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
1401
1804
|
runningWidget.finish(runId);
|
|
1402
1805
|
}
|
|
1403
1806
|
const output = getFinalOutput(result.messages);
|
|
1807
|
+
// Terminal truth for the panel: the tool result Cypher folds into the
|
|
1808
|
+
// transcript settles the row too, but only the snapshot carries the
|
|
1809
|
+
// model, the reason and the end time.
|
|
1810
|
+
if (result.model) cypherStatus.update(runId, { model: result.model });
|
|
1811
|
+
const failure =
|
|
1812
|
+
result.exitCode !== 0
|
|
1813
|
+
? `exit ${result.exitCode}: ${result.stderr || result.errorMessage || "unknown error"}`
|
|
1814
|
+
: result.stopReason === "error"
|
|
1815
|
+
? result.errorMessage || "subagent reported an error"
|
|
1816
|
+
: "";
|
|
1817
|
+
cypherStatus.settleIfLive(runId, failure ? "error" : "done", failure || undefined);
|
|
1404
1818
|
|
|
1405
1819
|
if (result.exitCode !== 0) {
|
|
1406
1820
|
return {
|
|
@@ -1509,25 +1923,122 @@ export default function (pi: ExtensionAPI) {
|
|
|
1509
1923
|
},
|
|
1510
1924
|
});
|
|
1511
1925
|
|
|
1926
|
+
pi.registerCommand("subagent-retry", {
|
|
1927
|
+
description: "Retry an interrupted, failed, or cancelled background task",
|
|
1928
|
+
getArgumentCompletions: (prefix) => {
|
|
1929
|
+
const items = [...persistedTasks.values()]
|
|
1930
|
+
.filter((state) => isRetryableTaskStatus(state.status))
|
|
1931
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
1932
|
+
.map((state) => ({
|
|
1933
|
+
value: state.runId,
|
|
1934
|
+
label: state.runId,
|
|
1935
|
+
description: `${state.address} · ${state.status}`,
|
|
1936
|
+
}))
|
|
1937
|
+
.filter((item) => item.value.startsWith(prefix.trim()));
|
|
1938
|
+
return items.length > 0 ? items : null;
|
|
1939
|
+
},
|
|
1940
|
+
handler: async (args, ctx) => {
|
|
1941
|
+
runtimeContext = ctx;
|
|
1942
|
+
reconcileCurrentBranch();
|
|
1943
|
+
const raw = Array.isArray(args) ? String(args[0] ?? "") : String(args ?? "");
|
|
1944
|
+
const oldRunId = raw.trim().split(/\s+/)[0] ?? "";
|
|
1945
|
+
if (!oldRunId) {
|
|
1946
|
+
ctx.ui.notify("Usage: /subagent-retry <runId>", "warning");
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
const previous = persistedTasks.get(oldRunId);
|
|
1950
|
+
if (!previous) {
|
|
1951
|
+
ctx.ui.notify(`No task "${oldRunId}" exists on the current branch.`, "error");
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
if (!isRetryableTaskStatus(previous.status)) {
|
|
1955
|
+
ctx.ui.notify(
|
|
1956
|
+
`Task ${oldRunId.slice(0, 8)} is ${previous.status} and cannot be retried. Only interrupted, failed, or cancelled tasks are retryable.`,
|
|
1957
|
+
"warning",
|
|
1958
|
+
);
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
const agent = discoverAgents(ctx.cwd).find((candidate) => candidate.name === previous.agent);
|
|
1962
|
+
if (!agent) {
|
|
1963
|
+
ctx.ui.notify(
|
|
1964
|
+
`Cannot retry run ${oldRunId.slice(0, 8)} because subagent "${previous.agent}" no longer exists.`,
|
|
1965
|
+
"error",
|
|
1966
|
+
);
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
const originalTimeoutMs =
|
|
1970
|
+
previous.timeoutAt !== undefined
|
|
1971
|
+
? Math.max(
|
|
1972
|
+
MIN_SUBAGENT_TIMEOUT_SECONDS * 1000,
|
|
1973
|
+
Math.min(
|
|
1974
|
+
MAX_SUBAGENT_TIMEOUT_SECONDS * 1000,
|
|
1975
|
+
previous.timeoutAt - previous.startedAt,
|
|
1976
|
+
),
|
|
1977
|
+
)
|
|
1978
|
+
: DEFAULT_SUBAGENT_TIMEOUT_SECONDS * 1000;
|
|
1979
|
+
try {
|
|
1980
|
+
const launched = launchBackground(
|
|
1981
|
+
agent,
|
|
1982
|
+
previous.task,
|
|
1983
|
+
previous.cwd,
|
|
1984
|
+
originalTimeoutMs,
|
|
1985
|
+
undefined,
|
|
1986
|
+
previous.readOnly,
|
|
1987
|
+
previous.runId,
|
|
1988
|
+
);
|
|
1989
|
+
ctx.ui.notify(
|
|
1990
|
+
`Retry started at ${launched.address} (new run ${launched.runId.slice(0, 8)}, retry of ${previous.runId.slice(0, 8)}).`,
|
|
1991
|
+
"info",
|
|
1992
|
+
);
|
|
1993
|
+
} catch (error) {
|
|
1994
|
+
ctx.ui.notify(
|
|
1995
|
+
`Failed to retry run ${oldRunId.slice(0, 8)}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1996
|
+
"error",
|
|
1997
|
+
);
|
|
1998
|
+
}
|
|
1999
|
+
},
|
|
2000
|
+
});
|
|
2001
|
+
|
|
1512
2002
|
pi.registerCommand("subagent-status", {
|
|
1513
|
-
description: "Show
|
|
2003
|
+
description: "Show live subagent tasks and current-branch task history",
|
|
1514
2004
|
handler: async (_args, ctx) => {
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
2005
|
+
runtimeContext = ctx;
|
|
2006
|
+
reconcileCurrentBranch();
|
|
2007
|
+
const live: LiveTaskView[] = [...tasks.values()].map((task) => {
|
|
2008
|
+
const persisted = persistedTasks.get(task.runId);
|
|
2009
|
+
return {
|
|
2010
|
+
runId: task.runId,
|
|
2011
|
+
address: task.address,
|
|
2012
|
+
agent: task.agent,
|
|
2013
|
+
task: task.task,
|
|
2014
|
+
status:
|
|
2015
|
+
persisted?.status === "starting" || persisted?.status === "running"
|
|
2016
|
+
? persisted.status
|
|
2017
|
+
: "running",
|
|
2018
|
+
startedAt: task.startedAt,
|
|
2019
|
+
updatedAt: persisted?.updatedAt ?? task.updatedAt,
|
|
2020
|
+
};
|
|
1519
2021
|
});
|
|
1520
|
-
const
|
|
1521
|
-
const
|
|
2022
|
+
const taskRunIds = new Set(live.map((task) => task.runId));
|
|
2023
|
+
const otherActive = activeRuns
|
|
2024
|
+
.list()
|
|
2025
|
+
.filter((run) => !taskRunIds.has(run.runId))
|
|
2026
|
+
.slice(0, 10);
|
|
2027
|
+
const activeLines = otherActive.map(
|
|
1522
2028
|
(run) => `- ${run.address} -> ${run.agent} [${run.mode}] (run ${run.runId.slice(0, 8)})`,
|
|
1523
2029
|
);
|
|
2030
|
+
const status = formatTaskStatus(live, persistedTasks);
|
|
1524
2031
|
ctx.ui.notify(
|
|
1525
2032
|
[
|
|
1526
|
-
|
|
1527
|
-
activeLines.length
|
|
2033
|
+
status,
|
|
2034
|
+
activeLines.length
|
|
2035
|
+
? `Other live processes:\n${activeLines.join("\n")}${activeRuns.list().length - taskRunIds.size > 10 ? "\n- … more live processes omitted" : ""}`
|
|
2036
|
+
: "",
|
|
1528
2037
|
].join("\n"),
|
|
1529
2038
|
"info",
|
|
1530
2039
|
);
|
|
1531
2040
|
},
|
|
1532
2041
|
});
|
|
1533
2042
|
}
|
|
2043
|
+
|
|
2044
|
+
export default createAgentSquadExtension;
|