pi-agent-squad 0.8.4 → 0.8.5

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,27 @@ 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 {
40
+ boundTaskText,
41
+ isTerminalTaskStatus,
42
+ isRetryableTaskStatus,
43
+ reconstructTaskStates,
44
+ TASK_STATE_ENTRY,
45
+ TASK_STATE_LIMITS,
46
+ TASK_STATE_VERSION,
47
+ validateLaunchText,
48
+ type PersistedTaskState,
49
+ type PersistedTaskStatus,
50
+ } from "./task-state.ts";
51
+ import { planTaskRecovery } from "./task-recovery.ts";
52
+ import {
53
+ BACKGROUND_EVENT_TYPE,
54
+ branchHasResultDelivery,
55
+ RecoveryOutbox,
56
+ resultDeliveryId,
57
+ type BackgroundEventDetails,
58
+ } from "./task-delivery.ts";
59
+ import { formatTaskStatus, type LiveTaskView } from "./task-status.ts";
39
60
 
40
61
  const MESSAGE_ROOT_BASE = path.join(
41
62
  os.tmpdir(),
@@ -51,7 +72,6 @@ const RUNNING_WIDGET_TICK_MS = 1000;
51
72
  const RUNNING_WIDGET_NAV_DEBOUNCE_MS = 150;
52
73
  const RUNNING_WIDGET_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
53
74
  const INCOMING_MESSAGE_TYPE = "pi_message_request";
54
- const BACKGROUND_EVENT_TYPE = "pi_subagent_background_event";
55
75
 
56
76
  interface IncomingMessageDetails {
57
77
  id?: string;
@@ -61,15 +81,6 @@ interface IncomingMessageDetails {
61
81
  createdAt?: number;
62
82
  }
63
83
 
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
84
  type TranscriptEventKind = "message" | "done" | "error";
74
85
 
75
86
  function customMessageText(content: unknown): string {
@@ -351,7 +362,12 @@ interface AsyncTask {
351
362
  runId: string;
352
363
  agent: string;
353
364
  address: string;
365
+ task: string;
366
+ cwd: string;
367
+ readOnly: boolean;
354
368
  startedAt: number;
369
+ updatedAt: number;
370
+ timeoutAt: number;
355
371
  sessionGeneration: number;
356
372
  }
357
373
 
@@ -579,7 +595,14 @@ export class RunningSubagentWidgetController {
579
595
  }
580
596
  }
581
597
 
582
- export default function (pi: ExtensionAPI) {
598
+ export interface AgentSquadRuntimeDependencies {
599
+ spawnInteractiveSubagent: typeof spawnInteractiveSubagent;
600
+ }
601
+
602
+ export function createAgentSquadExtension(
603
+ pi: ExtensionAPI,
604
+ dependencies: AgentSquadRuntimeDependencies = { spawnInteractiveSubagent },
605
+ ) {
583
606
  const isChild = process.env[ENV_ROLE] === "child";
584
607
 
585
608
  if (isChild) {
@@ -589,6 +612,7 @@ export default function (pi: ExtensionAPI) {
589
612
  }
590
613
 
591
614
  // ===== main-agent mode =====
615
+ const ownerRuntimeId = randomUUID();
592
616
  pi.registerMessageRenderer<IncomingMessageDetails>(
593
617
  INCOMING_MESSAGE_TYPE,
594
618
  (message, options, theme) => {
@@ -650,13 +674,142 @@ export default function (pi: ExtensionAPI) {
650
674
  string,
651
675
  { release: () => void; timer?: ReturnType<typeof setTimeout> }
652
676
  >();
677
+ const persistedTasks = new Map<string, PersistedTaskState>();
653
678
  let sessionContext: any;
679
+ let runtimeContext: any;
680
+ let currentSessionToken: string | undefined;
681
+ let acceptingTasks = false;
682
+ let shutdownStarted = false;
683
+ let router: ReturnType<typeof createMessageRouter> | undefined;
654
684
  let terminalInputUnsubscribe: (() => void) | undefined;
655
685
  let sessionOverlayOpen = false;
656
686
  let closeSessionOverlay: (() => void) | undefined;
657
687
  let pendingOpenActivityId: string | undefined;
658
688
  let lastNavigation: { direction: -1 | 1; at: number } | undefined;
659
689
 
690
+ function appendTaskSnapshot(state: PersistedTaskState): PersistedTaskState {
691
+ pi.appendEntry(TASK_STATE_ENTRY, state);
692
+ persistedTasks.set(state.runId, state);
693
+ return state;
694
+ }
695
+
696
+ function transitionTask(
697
+ runId: string,
698
+ expected: readonly PersistedTaskStatus[],
699
+ patch: Partial<Omit<PersistedTaskState, "version" | "runId" | "updatedAt">>,
700
+ now = Date.now(),
701
+ ): PersistedTaskState | undefined {
702
+ const current = persistedTasks.get(runId);
703
+ if (!current || !expected.includes(current.status)) return undefined;
704
+ const next: PersistedTaskState = {
705
+ ...current,
706
+ ...patch,
707
+ version: TASK_STATE_VERSION,
708
+ runId,
709
+ updatedAt: now,
710
+ };
711
+ return appendTaskSnapshot(next);
712
+ }
713
+
714
+ function reportPersistenceFailure(error: unknown, prefix: string): void {
715
+ const message = `${prefix}: ${error instanceof Error ? error.message : String(error)}`;
716
+ try {
717
+ runtimeContext?.ui?.notify?.(message, "warning");
718
+ } catch {
719
+ /* UI may already be closing */
720
+ }
721
+ console.error(`[pi-agent-squad] ${message}`);
722
+ }
723
+
724
+ const recoveryOutbox = new RecoveryOutbox({
725
+ getSessionToken: () => currentSessionToken,
726
+ getBranch: () => runtimeContext?.sessionManager?.getBranch?.() ?? [],
727
+ getTaskState: (runId) => persistedTasks.get(runId),
728
+ send: (message, options) => pi.sendMessage(message, options),
729
+ markInjected: (state) => {
730
+ transitionTask(
731
+ state.runId,
732
+ ["completed"],
733
+ { status: "completed", resultInjected: true },
734
+ );
735
+ },
736
+ onError: (error, state) =>
737
+ reportPersistenceFailure(
738
+ error,
739
+ `Could not finish result delivery bookkeeping for run ${state.runId.slice(0, 8)}`,
740
+ ),
741
+ });
742
+
743
+ function refreshPersistedTasks(entries: readonly unknown[]): void {
744
+ persistedTasks.clear();
745
+ for (const [runId, state] of reconstructTaskStates(entries)) {
746
+ persistedTasks.set(runId, state);
747
+ }
748
+ }
749
+
750
+ function liveTaskRunIds(): Set<string> {
751
+ return new Set([
752
+ ...tasks.keys(),
753
+ ...activeRuns.list().map((run) => run.runId),
754
+ ]);
755
+ }
756
+
757
+ function reconcileCurrentBranch(): void {
758
+ const branch = runtimeContext?.sessionManager?.getBranch?.() ?? [];
759
+ const currentLiveIds = liveTaskRunIds();
760
+ const liveSnapshots = [...persistedTasks.values()].filter(
761
+ (state) =>
762
+ state.ownerRuntimeId === ownerRuntimeId &&
763
+ currentLiveIds.has(state.runId) &&
764
+ (state.status === "starting" || state.status === "running"),
765
+ );
766
+ refreshPersistedTasks(branch);
767
+ for (const state of liveSnapshots) {
768
+ if (!persistedTasks.has(state.runId)) persistedTasks.set(state.runId, state);
769
+ }
770
+ const plan = planTaskRecovery(
771
+ persistedTasks,
772
+ ownerRuntimeId,
773
+ currentLiveIds,
774
+ (deliveryId) => branchHasResultDelivery(branch, deliveryId),
775
+ );
776
+ for (const stale of plan.interrupt) {
777
+ const now = Date.now();
778
+ try {
779
+ transitionTask(
780
+ stale.runId,
781
+ ["starting", "running"],
782
+ {
783
+ status: "interrupted",
784
+ error: "Owning Pi runtime ended before the task reached a terminal state.",
785
+ endedAt: now,
786
+ },
787
+ now,
788
+ );
789
+ } catch (error) {
790
+ reportPersistenceFailure(
791
+ error,
792
+ `Could not mark stale run ${stale.runId.slice(0, 8)} interrupted`,
793
+ );
794
+ }
795
+ }
796
+ for (const delivered of plan.markInjected) {
797
+ try {
798
+ transitionTask(
799
+ delivered.runId,
800
+ ["completed"],
801
+ { status: "completed", resultInjected: true },
802
+ );
803
+ } catch (error) {
804
+ reportPersistenceFailure(
805
+ error,
806
+ `Could not repair delivery state for run ${delivered.runId.slice(0, 8)}`,
807
+ );
808
+ }
809
+ }
810
+ for (const pending of plan.deliver) recoveryOutbox.enqueue(pending);
811
+ }
812
+
660
813
  const bindPoolLifecycle = (): void => {
661
814
  pool.setProcessExitHandler((agentName, runId) => {
662
815
  const run = activeRuns.resolveExact(runId);
@@ -715,6 +868,9 @@ export default function (pi: ExtensionAPI) {
715
868
 
716
869
  pi.on("session_start", (event, ctx) => {
717
870
  sessionGeneration++;
871
+ shutdownStarted = false;
872
+ acceptingTasks = false;
873
+ recoveryOutbox.cancelAll();
718
874
  if (poolDisposed) {
719
875
  pool = new SubagentPool(messageRoot);
720
876
  bindPoolLifecycle();
@@ -725,6 +881,8 @@ export default function (pi: ExtensionAPI) {
725
881
  ctx.sessionManager?.getSessionId?.() ??
726
882
  (event as any).sessionId ??
727
883
  "ephemeral";
884
+ currentSessionToken = `${sessionId}:${sessionGeneration}`;
885
+ runtimeContext = ctx;
728
886
  messageRoot = sessionRoot(sessionId);
729
887
  ensureChannelRoot(MESSAGE_ROOT_BASE);
730
888
  sweepMessageRoots(MESSAGE_ROOT_BASE);
@@ -733,6 +891,8 @@ export default function (pi: ExtensionAPI) {
733
891
  pool.setWorkingDirectory(cwd);
734
892
  runningWidget.attach(ctx);
735
893
  sessionContext = ctx.mode === "tui" ? ctx : undefined;
894
+ reconcileCurrentBranch();
895
+ acceptingTasks = true;
736
896
  lastNavigation = undefined;
737
897
  terminalInputUnsubscribe?.();
738
898
  terminalInputUnsubscribe =
@@ -760,7 +920,42 @@ export default function (pi: ExtensionAPI) {
760
920
  : undefined;
761
921
  });
762
922
 
763
- pi.on("session_shutdown", () => {
923
+ pi.on("session_shutdown", (event, ctx) => {
924
+ if (shutdownStarted) return;
925
+ shutdownStarted = true;
926
+ acceptingTasks = false;
927
+ recoveryOutbox.cancelAll();
928
+ const now = Date.now();
929
+ for (const state of [...persistedTasks.values()]) {
930
+ if (
931
+ state.ownerRuntimeId !== ownerRuntimeId ||
932
+ (state.status !== "starting" && state.status !== "running")
933
+ ) {
934
+ continue;
935
+ }
936
+ const reason = boundTaskText(
937
+ [
938
+ "Owning Pi runtime shut down before the task reached a terminal state.",
939
+ event.reason ? `Reason: ${event.reason}.` : "",
940
+ ]
941
+ .filter(Boolean)
942
+ .join(" "),
943
+ TASK_STATE_LIMITS.error,
944
+ );
945
+ try {
946
+ transitionTask(
947
+ state.runId,
948
+ ["starting", "running"],
949
+ { status: "interrupted", error: reason, endedAt: now },
950
+ now,
951
+ );
952
+ } catch (error) {
953
+ reportPersistenceFailure(
954
+ error,
955
+ `Could not persist shutdown interruption for run ${state.runId.slice(0, 8)}`,
956
+ );
957
+ }
958
+ }
764
959
  sessionGeneration++;
765
960
  for (const controller of runControllers.values()) controller.abort();
766
961
  runControllers.clear();
@@ -772,16 +967,21 @@ export default function (pi: ExtensionAPI) {
772
967
  sessionContext = undefined;
773
968
  pendingOpenActivityId = undefined;
774
969
  sessionHandles.clear();
775
- activeRuns.clear();
776
970
  for (const pending of pendingMainReplyEdges.values()) {
777
971
  if (pending.timer) clearTimeout(pending.timer);
778
972
  pending.release();
779
973
  }
780
974
  pendingMainReplyEdges.clear();
781
975
  waitGraph.clear();
782
- runningWidget.shutdown();
783
976
  pool.dispose();
784
977
  poolDisposed = true;
978
+ router?.dispose();
979
+ router = undefined;
980
+ activeRuns.clear();
981
+ tasks.clear();
982
+ runningWidget.shutdown();
983
+ currentSessionToken = undefined;
984
+ runtimeContext = undefined;
785
985
  sweepMessageRoots(MESSAGE_ROOT_BASE);
786
986
  });
787
987
 
@@ -809,7 +1009,6 @@ export default function (pi: ExtensionAPI) {
809
1009
  // ---- message router: subagent -> main injects into main session; child
810
1010
  // messages resolve through the active-run registry before being delivered
811
1011
  // to a direct/background session or the resident pool. ----
812
- let router: ReturnType<typeof createMessageRouter> | undefined;
813
1012
  const routedTimeout = (msg: MessageRequest): number =>
814
1013
  Math.min(
815
1014
  MAX_SUBAGENT_TIMEOUT_SECONDS * 1000,
@@ -1009,11 +1208,6 @@ export default function (pi: ExtensionAPI) {
1009
1208
  router.start();
1010
1209
  });
1011
1210
 
1012
- pi.on("session_shutdown", () => {
1013
- router?.dispose();
1014
- router = undefined;
1015
- });
1016
-
1017
1211
  /** Write a reply back to the message sender */
1018
1212
  function writeReplyTo(msg: MessageRequest, content: string, root = messageRoot): boolean {
1019
1213
  try {
@@ -1045,20 +1239,13 @@ export default function (pi: ExtensionAPI) {
1045
1239
  return `${agent.name}#${runId.slice(0, 8)}`;
1046
1240
  }
1047
1241
 
1048
- function createDirectRun(
1242
+ function resolveDirectRunAddress(
1049
1243
  agent: AgentConfig,
1050
1244
  runId: string,
1051
- mode: "background" | "task",
1052
1245
  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
- } {
1246
+ ): string {
1061
1247
  const address = requestedAddress?.trim() || directRunAddress(agent, runId);
1248
+ validateLaunchText("address", address);
1062
1249
  if (
1063
1250
  (requestedAddress &&
1064
1251
  (!/^[A-Za-z0-9_.-]+$/.test(address) ||
@@ -1073,6 +1260,25 @@ export default function (pi: ExtensionAPI) {
1073
1260
  `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
1261
  );
1075
1262
  }
1263
+ return address;
1264
+ }
1265
+
1266
+ function createDirectRun(
1267
+ agent: AgentConfig,
1268
+ runId: string,
1269
+ mode: "background" | "task",
1270
+ address: string,
1271
+ readOnly = agent.readOnly === true,
1272
+ runCwd = cwd,
1273
+ ): {
1274
+ address: string;
1275
+ session: Promise<SubagentSessionHandle>;
1276
+ resolveSession: (session: SubagentSessionHandle) => void;
1277
+ rejectSession: (error: Error) => void;
1278
+ } {
1279
+ if (activeRuns.hasAddress(address)) {
1280
+ throw new Error(`Runtime address "${address}" became unavailable before launch.`);
1281
+ }
1076
1282
  let resolveSession!: (session: SubagentSessionHandle) => void;
1077
1283
  let rejectSession!: (error: Error) => void;
1078
1284
  const session = new Promise<SubagentSessionHandle>((resolve, reject) => {
@@ -1122,29 +1328,77 @@ export default function (pi: ExtensionAPI) {
1122
1328
  timeoutMs?: number,
1123
1329
  requestedAddress?: string,
1124
1330
  readOnly = agent.readOnly === true,
1331
+ retryOf?: string,
1125
1332
  ): { runId: string; address: string } {
1333
+ if (!acceptingTasks) throw new Error("This Pi session is shutting down and cannot accept new subagent tasks.");
1334
+ validateLaunchText("agent", agent.name);
1335
+ validateLaunchText("task", taskText, { allowEmpty: true });
1336
+ const runCwd = normalizeCwd(cwdOverride ?? cwd);
1337
+ validateLaunchText("cwd", runCwd);
1126
1338
  const runId = randomUUID();
1127
- const controller = new AbortController();
1128
- const direct = createDirectRun(
1129
- agent,
1339
+ const address = resolveDirectRunAddress(agent, runId, requestedAddress);
1340
+ const startedAt = Date.now();
1341
+ const effectiveTimeoutMs = Math.max(
1342
+ 1000,
1343
+ timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_SECONDS * 1000,
1344
+ );
1345
+ const timeoutAt = Math.min(Number.MAX_SAFE_INTEGER, startedAt + effectiveTimeoutMs);
1346
+ const starting: PersistedTaskState = {
1347
+ version: TASK_STATE_VERSION,
1130
1348
  runId,
1131
- "background",
1132
- requestedAddress,
1349
+ address,
1350
+ agent: agent.name,
1351
+ task: taskText,
1352
+ cwd: runCwd,
1133
1353
  readOnly,
1134
- cwdOverride ?? cwd,
1135
- );
1354
+ mode: "async",
1355
+ status: "starting",
1356
+ startedAt,
1357
+ updatedAt: startedAt,
1358
+ timeoutAt,
1359
+ ...(retryOf ? { retryOf } : {}),
1360
+ ownerRuntimeId,
1361
+ };
1362
+ appendTaskSnapshot(starting);
1363
+
1364
+ const controller = new AbortController();
1365
+ let direct: ReturnType<typeof createDirectRun>;
1366
+ try {
1367
+ direct = createDirectRun(agent, runId, "background", address, readOnly, runCwd);
1368
+ } catch (error) {
1369
+ const now = Date.now();
1370
+ transitionTask(
1371
+ runId,
1372
+ ["starting"],
1373
+ {
1374
+ status: "failed",
1375
+ error: boundTaskText(
1376
+ error instanceof Error ? error.message : String(error),
1377
+ TASK_STATE_LIMITS.error,
1378
+ ),
1379
+ endedAt: now,
1380
+ },
1381
+ now,
1382
+ );
1383
+ throw error;
1384
+ }
1136
1385
  runControllers.set(runId, controller);
1137
1386
  tasks.set(runId, {
1138
1387
  runId,
1139
1388
  agent: agent.name,
1140
- address: direct.address,
1141
- startedAt: Date.now(),
1389
+ address,
1390
+ task: taskText,
1391
+ cwd: runCwd,
1392
+ readOnly,
1393
+ startedAt,
1394
+ updatedAt: startedAt,
1395
+ timeoutAt,
1142
1396
  sessionGeneration,
1143
1397
  });
1144
1398
  runningWidget.start(runId, agent.name, taskText, "background");
1145
1399
 
1146
1400
  const start = async () => {
1147
- return await spawnInteractiveSubagent({
1401
+ return await dependencies.spawnInteractiveSubagent({
1148
1402
  agent,
1149
1403
  task: taskText,
1150
1404
  address: direct.address,
@@ -1153,7 +1407,29 @@ export default function (pi: ExtensionAPI) {
1153
1407
  runId,
1154
1408
  childIndex: 0,
1155
1409
  signal: controller.signal,
1156
- timeoutMs,
1410
+ timeoutMs: effectiveTimeoutMs,
1411
+ persistSession: true,
1412
+ onStarted: (childSessionFile) => {
1413
+ const now = Date.now();
1414
+ const running = transitionTask(
1415
+ runId,
1416
+ ["starting"],
1417
+ {
1418
+ status: "running",
1419
+ ...(childSessionFile
1420
+ ? {
1421
+ childSessionFile: boundTaskText(
1422
+ childSessionFile,
1423
+ TASK_STATE_LIMITS.childSessionFile,
1424
+ ),
1425
+ }
1426
+ : {}),
1427
+ },
1428
+ now,
1429
+ );
1430
+ const task = tasks.get(runId);
1431
+ if (task && running) task.updatedAt = running.updatedAt;
1432
+ },
1157
1433
  onSession: (session) => {
1158
1434
  direct.resolveSession(session);
1159
1435
  registerSessionHandle(runId, session);
@@ -1168,47 +1444,82 @@ export default function (pi: ExtensionAPI) {
1168
1444
  activeRuns.remove(runId);
1169
1445
  const task = tasks.get(runId);
1170
1446
  if (!task || task.sessionGeneration !== sessionGeneration) return;
1171
- const finalStatus: "done" | "error" =
1172
- result.exitCode === 0 && result.stopReason !== "error" ? "done" : "error";
1173
1447
  const output = getFinalOutput(result.messages) || "(no text output)";
1174
1448
  const failureReason =
1175
1449
  result.exitCode !== 0
1176
- ? result.stderr || result.errorMessage || `process exited with code ${result.exitCode}`
1450
+ ? result.errorMessage || result.stderr || `process exited with code ${result.exitCode}`
1177
1451
  : result.stopReason === "error"
1178
1452
  ? result.errorMessage || "subagent reported an error"
1179
1453
  : "";
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,
1454
+ const cancelled =
1455
+ result.exitCode === 124 ||
1456
+ result.stopReason === "aborted" ||
1457
+ /\b(?:timed out|cancelled|canceled|aborted)\b/i.test(result.errorMessage ?? "");
1458
+ const finalStatus: PersistedTaskStatus =
1459
+ result.exitCode === 0 && result.stopReason !== "error" && result.stopReason !== "aborted"
1460
+ ? "completed"
1461
+ : cancelled
1462
+ ? "cancelled"
1463
+ : "failed";
1464
+ const now = Date.now();
1465
+ try {
1466
+ if (finalStatus === "completed") {
1467
+ const completed = transitionTask(
1468
+ runId,
1469
+ ["starting", "running"],
1470
+ {
1471
+ status: "completed",
1472
+ resultSummary: boundTaskText(output, TASK_STATE_LIMITS.resultSummary),
1473
+ deliveryId: resultDeliveryId(runId),
1474
+ resultInjected: false,
1475
+ endedAt: now,
1476
+ },
1477
+ now,
1478
+ );
1479
+ if (completed) {
1480
+ recoveryOutbox.enqueue(completed, { body: output, content: output });
1481
+ }
1482
+ } else {
1483
+ const diagnostic = boundTaskText(
1484
+ failureReason || result.errorMessage || `Subagent ${finalStatus}.`,
1485
+ TASK_STATE_LIMITS.error,
1486
+ );
1487
+ const terminal = transitionTask(
1488
+ runId,
1489
+ ["starting", "running"],
1490
+ { status: finalStatus, error: diagnostic, endedAt: now },
1491
+ now,
1492
+ );
1493
+ if (terminal) {
1494
+ const displayBody = [
1495
+ diagnostic,
1189
1496
  output === "(no text output)" ? "" : `**Partial result**\n\n${output}`,
1190
1497
  ]
1191
1498
  .filter(Boolean)
1192
1499
  .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" },
1500
+ pi.sendMessage(
1501
+ {
1502
+ customType: BACKGROUND_EVENT_TYPE,
1503
+ content: `[background subagent error] ${agent.name} (run ${runId.slice(0, 8)}): ${diagnostic}`,
1504
+ display: true,
1505
+ details: {
1506
+ agent: agent.name,
1507
+ address,
1508
+ status: "error",
1509
+ body: displayBody,
1510
+ elapsedMs: now - task.startedAt,
1511
+ runId,
1512
+ },
1513
+ },
1514
+ { triggerTurn: true, deliverAs: "steer" },
1515
+ );
1516
+ }
1517
+ }
1518
+ } catch (error) {
1519
+ reportPersistenceFailure(
1520
+ error,
1521
+ `Could not finalize background run ${runId.slice(0, 8)}`,
1209
1522
  );
1210
- } catch {
1211
- /* session is closed */
1212
1523
  }
1213
1524
  })
1214
1525
  .catch((err) => {
@@ -1216,26 +1527,47 @@ export default function (pi: ExtensionAPI) {
1216
1527
  direct.rejectSession(err instanceof Error ? err : new Error(String(err)));
1217
1528
  const task = tasks.get(runId);
1218
1529
  const errorText = err instanceof Error ? err.message : String(err);
1530
+ const current = persistedTasks.get(runId);
1531
+ if (!current || isTerminalTaskStatus(current.status)) return;
1532
+ const cancelled =
1533
+ controller.signal.aborted ||
1534
+ /\b(?:timed out|cancelled|canceled|aborted)\b/i.test(errorText);
1535
+ const now = Date.now();
1219
1536
  try {
1220
1537
  if (task && task.sessionGeneration !== sessionGeneration) return;
1221
- pi.sendMessage(
1538
+ const terminal = transitionTask(
1539
+ runId,
1540
+ ["starting", "running"],
1222
1541
  {
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
- },
1542
+ status: cancelled ? "cancelled" : "failed",
1543
+ error: boundTaskText(errorText, TASK_STATE_LIMITS.error),
1544
+ endedAt: now,
1234
1545
  },
1235
- { triggerTurn: true, deliverAs: "steer" },
1546
+ now,
1547
+ );
1548
+ if (terminal) {
1549
+ pi.sendMessage(
1550
+ {
1551
+ customType: BACKGROUND_EVENT_TYPE,
1552
+ content: `[background subagent error] ${agent.name} (run ${runId.slice(0, 8)}): ${errorText}`,
1553
+ display: true,
1554
+ details: {
1555
+ agent: agent.name,
1556
+ address,
1557
+ status: "error",
1558
+ body: errorText,
1559
+ elapsedMs: task ? now - task.startedAt : undefined,
1560
+ runId,
1561
+ },
1562
+ },
1563
+ { triggerTurn: true, deliverAs: "steer" },
1564
+ );
1565
+ }
1566
+ } catch (error) {
1567
+ reportPersistenceFailure(
1568
+ error,
1569
+ `Could not record background failure for run ${runId.slice(0, 8)}`,
1236
1570
  );
1237
- } catch {
1238
- /* ignore */
1239
1571
  }
1240
1572
  })
1241
1573
  .finally(() => {
@@ -1247,7 +1579,7 @@ export default function (pi: ExtensionAPI) {
1247
1579
  runningWidget.finish(runId);
1248
1580
  });
1249
1581
 
1250
- return { runId, address: direct.address };
1582
+ return { runId, address };
1251
1583
  }
1252
1584
 
1253
1585
  // ---- adaptive orchestration prompt; `/orchestrate on` installs the opt-in prompt ----
@@ -1277,11 +1609,12 @@ export default function (pi: ExtensionAPI) {
1277
1609
  "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
1610
  ].join(" "),
1279
1611
  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" })),
1612
+ agent: Type.String({ maxLength: TASK_STATE_LIMITS.agent, description: "Subagent name" }),
1613
+ task: Type.String({ maxLength: TASK_STATE_LIMITS.task, description: "Task description for the subagent" }),
1614
+ cwd: Type.Optional(Type.String({ maxLength: TASK_STATE_LIMITS.cwd, description: "Working directory for the subagent" })),
1283
1615
  as: Type.Optional(
1284
1616
  Type.String({
1617
+ maxLength: TASK_STATE_LIMITS.address,
1285
1618
  description: "Optional unique runtime address for this run (for example actor-a); identity still comes from agent",
1286
1619
  }),
1287
1620
  ),
@@ -1301,6 +1634,12 @@ export default function (pi: ExtensionAPI) {
1301
1634
  }),
1302
1635
 
1303
1636
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1637
+ if (!acceptingTasks) {
1638
+ return {
1639
+ content: [{ type: "text", text: "This Pi session is shutting down and cannot accept new subagent tasks." }],
1640
+ details: undefined,
1641
+ };
1642
+ }
1304
1643
  const agents = discoverAgents(ctx.cwd);
1305
1644
  const agent = agents.find((a) => a.name === params.agent);
1306
1645
  if (!agent) {
@@ -1351,7 +1690,9 @@ export default function (pi: ExtensionAPI) {
1351
1690
  else signal?.addEventListener("abort", forwardAbort, { once: true });
1352
1691
  let direct: ReturnType<typeof createDirectRun>;
1353
1692
  try {
1354
- direct = createDirectRun(agent, runId, "task", params.as, readOnly, params.cwd ?? cwd);
1693
+ validateLaunchText("task", params.task, { allowEmpty: true });
1694
+ const address = resolveDirectRunAddress(agent, runId, params.as);
1695
+ direct = createDirectRun(agent, runId, "task", address, readOnly, params.cwd ?? cwd);
1355
1696
  } catch (error) {
1356
1697
  signal?.removeEventListener("abort", forwardAbort);
1357
1698
  runControllers.delete(runId);
@@ -1373,7 +1714,7 @@ export default function (pi: ExtensionAPI) {
1373
1714
  runningWidget.start(runId, agent.name, params.task, "task");
1374
1715
  let result: Awaited<ReturnType<typeof spawnInteractiveSubagent>>;
1375
1716
  try {
1376
- result = await spawnInteractiveSubagent({
1717
+ result = await dependencies.spawnInteractiveSubagent({
1377
1718
  agent,
1378
1719
  task: params.task,
1379
1720
  address: direct.address,
@@ -1509,25 +1850,122 @@ export default function (pi: ExtensionAPI) {
1509
1850
  },
1510
1851
  });
1511
1852
 
1853
+ pi.registerCommand("subagent-retry", {
1854
+ description: "Retry an interrupted, failed, or cancelled background task",
1855
+ getArgumentCompletions: (prefix) => {
1856
+ const items = [...persistedTasks.values()]
1857
+ .filter((state) => isRetryableTaskStatus(state.status))
1858
+ .sort((a, b) => b.updatedAt - a.updatedAt)
1859
+ .map((state) => ({
1860
+ value: state.runId,
1861
+ label: state.runId,
1862
+ description: `${state.address} · ${state.status}`,
1863
+ }))
1864
+ .filter((item) => item.value.startsWith(prefix.trim()));
1865
+ return items.length > 0 ? items : null;
1866
+ },
1867
+ handler: async (args, ctx) => {
1868
+ runtimeContext = ctx;
1869
+ reconcileCurrentBranch();
1870
+ const raw = Array.isArray(args) ? String(args[0] ?? "") : String(args ?? "");
1871
+ const oldRunId = raw.trim().split(/\s+/)[0] ?? "";
1872
+ if (!oldRunId) {
1873
+ ctx.ui.notify("Usage: /subagent-retry <runId>", "warning");
1874
+ return;
1875
+ }
1876
+ const previous = persistedTasks.get(oldRunId);
1877
+ if (!previous) {
1878
+ ctx.ui.notify(`No task "${oldRunId}" exists on the current branch.`, "error");
1879
+ return;
1880
+ }
1881
+ if (!isRetryableTaskStatus(previous.status)) {
1882
+ ctx.ui.notify(
1883
+ `Task ${oldRunId.slice(0, 8)} is ${previous.status} and cannot be retried. Only interrupted, failed, or cancelled tasks are retryable.`,
1884
+ "warning",
1885
+ );
1886
+ return;
1887
+ }
1888
+ const agent = discoverAgents(ctx.cwd).find((candidate) => candidate.name === previous.agent);
1889
+ if (!agent) {
1890
+ ctx.ui.notify(
1891
+ `Cannot retry run ${oldRunId.slice(0, 8)} because subagent "${previous.agent}" no longer exists.`,
1892
+ "error",
1893
+ );
1894
+ return;
1895
+ }
1896
+ const originalTimeoutMs =
1897
+ previous.timeoutAt !== undefined
1898
+ ? Math.max(
1899
+ MIN_SUBAGENT_TIMEOUT_SECONDS * 1000,
1900
+ Math.min(
1901
+ MAX_SUBAGENT_TIMEOUT_SECONDS * 1000,
1902
+ previous.timeoutAt - previous.startedAt,
1903
+ ),
1904
+ )
1905
+ : DEFAULT_SUBAGENT_TIMEOUT_SECONDS * 1000;
1906
+ try {
1907
+ const launched = launchBackground(
1908
+ agent,
1909
+ previous.task,
1910
+ previous.cwd,
1911
+ originalTimeoutMs,
1912
+ undefined,
1913
+ previous.readOnly,
1914
+ previous.runId,
1915
+ );
1916
+ ctx.ui.notify(
1917
+ `Retry started at ${launched.address} (new run ${launched.runId.slice(0, 8)}, retry of ${previous.runId.slice(0, 8)}).`,
1918
+ "info",
1919
+ );
1920
+ } catch (error) {
1921
+ ctx.ui.notify(
1922
+ `Failed to retry run ${oldRunId.slice(0, 8)}: ${error instanceof Error ? error.message : String(error)}`,
1923
+ "error",
1924
+ );
1925
+ }
1926
+ },
1927
+ });
1928
+
1512
1929
  pi.registerCommand("subagent-status", {
1513
- description: "Show active subagent runs and runtime addresses",
1930
+ description: "Show live subagent tasks and current-branch task history",
1514
1931
  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)`;
1932
+ runtimeContext = ctx;
1933
+ reconcileCurrentBranch();
1934
+ const live: LiveTaskView[] = [...tasks.values()].map((task) => {
1935
+ const persisted = persistedTasks.get(task.runId);
1936
+ return {
1937
+ runId: task.runId,
1938
+ address: task.address,
1939
+ agent: task.agent,
1940
+ task: task.task,
1941
+ status:
1942
+ persisted?.status === "starting" || persisted?.status === "running"
1943
+ ? persisted.status
1944
+ : "running",
1945
+ startedAt: task.startedAt,
1946
+ updatedAt: persisted?.updatedAt ?? task.updatedAt,
1947
+ };
1519
1948
  });
1520
- const active = activeRuns.list();
1521
- const activeLines = active.map(
1949
+ const taskRunIds = new Set(live.map((task) => task.runId));
1950
+ const otherActive = activeRuns
1951
+ .list()
1952
+ .filter((run) => !taskRunIds.has(run.runId))
1953
+ .slice(0, 10);
1954
+ const activeLines = otherActive.map(
1522
1955
  (run) => `- ${run.address} -> ${run.agent} [${run.mode}] (run ${run.runId.slice(0, 8)})`,
1523
1956
  );
1957
+ const status = formatTaskStatus(live, persistedTasks);
1524
1958
  ctx.ui.notify(
1525
1959
  [
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",
1960
+ status,
1961
+ activeLines.length
1962
+ ? `Other live processes:\n${activeLines.join("\n")}${activeRuns.list().length - taskRunIds.size > 10 ? "\n- … more live processes omitted" : ""}`
1963
+ : "",
1528
1964
  ].join("\n"),
1529
1965
  "info",
1530
1966
  );
1531
1967
  },
1532
1968
  });
1533
1969
  }
1970
+
1971
+ export default createAgentSquadExtension;