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/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 default function (pi: ExtensionAPI) {
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 createDirectRun(
1258
+ function resolveDirectRunAddress(
1049
1259
  agent: AgentConfig,
1050
1260
  runId: string,
1051
- mode: "background" | "task",
1052
1261
  requestedAddress?: string,
1053
- readOnly = agent.readOnly === true,
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 controller = new AbortController();
1128
- const direct = createDirectRun(
1129
- agent,
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
- "background",
1132
- requestedAddress,
1366
+ address,
1367
+ agent: agent.name,
1368
+ task: taskText,
1369
+ cwd: runCwd,
1133
1370
  readOnly,
1134
- cwdOverride ?? cwd,
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: direct.address,
1141
- startedAt: Date.now(),
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.stderr || result.errorMessage || `process exited with code ${result.exitCode}`
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 head =
1181
- finalStatus === "done"
1182
- ? `Subagent ${agent.name} done (run ${runId.slice(0, 8)})`
1183
- : `Subagent ${agent.name} failed: ${failureReason}`;
1184
- const displayBody =
1185
- finalStatus === "done"
1186
- ? output
1187
- : [
1188
- failureReason,
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
- try {
1194
- pi.sendMessage(
1195
- {
1196
- customType: BACKGROUND_EVENT_TYPE,
1197
- content: `[background subagent ${finalStatus}] ${head}\n\n--- result ---\n${output}`,
1198
- display: true,
1199
- details: {
1200
- agent: agent.name,
1201
- address: direct.address,
1202
- status: finalStatus,
1203
- body: displayBody,
1204
- elapsedMs: Date.now() - task.startedAt,
1205
- runId,
1206
- },
1207
- },
1208
- { triggerTurn: true, deliverAs: "steer" },
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
- pi.sendMessage(
1574
+ const terminal = transitionTask(
1575
+ runId,
1576
+ ["starting", "running"],
1222
1577
  {
1223
- customType: BACKGROUND_EVENT_TYPE,
1224
- content: `[background subagent error] ${agent.name} (run ${runId.slice(0, 8)}): ${errorText}`,
1225
- display: true,
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
- { triggerTurn: true, deliverAs: "steer" },
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: direct.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(_toolCallId, params, signal, onUpdate, ctx) {
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(agent, params.task, params.cwd, timeoutMs, params.as, readOnly);
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
- direct = createDirectRun(agent, runId, "task", params.as, readOnly, params.cwd ?? cwd);
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 active subagent runs and runtime addresses",
2003
+ description: "Show live subagent tasks and current-branch task history",
1514
2004
  handler: async (_args, ctx) => {
1515
- const list = [...tasks.values()];
1516
- const lines = list.map((t) => {
1517
- const age = Math.round((Date.now() - t.startedAt) / 1000);
1518
- return `- ${t.address} run ${t.runId.slice(0, 8)} ${t.agent} running (${age}s ago)`;
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 active = activeRuns.list();
1521
- const activeLines = active.map(
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
- lines.length ? `Background tasks:\n${lines.join("\n")}` : "Background tasks: none",
1527
- activeLines.length ? `Active run registry:\n${activeLines.join("\n")}` : "Active run registry: none",
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;