pi-agent-squad 0.8.3 → 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 {
@@ -101,6 +112,8 @@ function formatEventDuration(elapsedMs: number): string {
101
112
  }
102
113
 
103
114
  class SubagentTranscriptEventComponent implements Component {
115
+ private static readonly COLLAPSED_MAX_BODY_LINES = 5;
116
+
104
117
  private readonly markdown: Markdown | undefined;
105
118
 
106
119
  constructor(
@@ -110,6 +123,7 @@ class SubagentTranscriptEventComponent implements Component {
110
123
  private readonly theme: any,
111
124
  private readonly replyRequested = false,
112
125
  private readonly elapsedMs?: number,
126
+ private readonly expanded = false,
113
127
  ) {
114
128
  const content = body.trim();
115
129
  if (content) {
@@ -153,6 +167,25 @@ class SubagentTranscriptEventComponent implements Component {
153
167
  return truncateToWidth(header, Math.max(1, width), "…");
154
168
  }
155
169
 
170
+ /**
171
+ * When collapsed, keep at most COLLAPSED_MAX_BODY_LINES body lines: the first
172
+ * two, a dim "hidden" marker, and the last two. ctrl+o (tool output expand)
173
+ * toggles the full body via the renderer's expanded option.
174
+ */
175
+ private clampBodyLines(bodyLines: string[]): string[] {
176
+ const shown = Math.max(2, SubagentTranscriptEventComponent.COLLAPSED_MAX_BODY_LINES);
177
+ if (this.expanded || bodyLines.length <= shown) return bodyLines;
178
+ const kept = Math.max(1, Math.floor((shown - 1) / 2));
179
+ const head = bodyLines.slice(0, kept);
180
+ const tail = bodyLines.slice(-kept);
181
+ const hidden = bodyLines.length - head.length - tail.length;
182
+ const marker = this.theme?.fg?.(
183
+ "dim",
184
+ `⋯ ${hidden} hidden line${hidden === 1 ? "" : "s"} · ctrl+o expand`,
185
+ ) ?? `⋯ ${hidden} hidden lines · ctrl+o expand`;
186
+ return [...head, marker, ...tail];
187
+ }
188
+
156
189
  render(width: number): string[] {
157
190
  const padding = " ".repeat(Math.min(1, Math.max(0, width - 1)));
158
191
  const contentWidth = Math.max(1, width - padding.length);
@@ -161,10 +194,12 @@ class SubagentTranscriptEventComponent implements Component {
161
194
  const railWidth = contentWidth >= 2 ? 1 : 0;
162
195
  const railGap = contentWidth >= 3 ? 1 : 0;
163
196
  const bodyWidth = Math.max(1, contentWidth - railWidth - railGap);
164
- const bodyLines = normalizeCompactCodeBlockLines(
165
- this.markdown.render(bodyWidth),
166
- bodyWidth,
167
- 0,
197
+ const bodyLines = this.clampBodyLines(
198
+ normalizeCompactCodeBlockLines(
199
+ this.markdown.render(bodyWidth),
200
+ bodyWidth,
201
+ 0,
202
+ ),
168
203
  );
169
204
  for (let index = 0; index < bodyLines.length; index++) {
170
205
  const connector = index === bodyLines.length - 1 ? "└" : "│";
@@ -327,7 +362,12 @@ interface AsyncTask {
327
362
  runId: string;
328
363
  agent: string;
329
364
  address: string;
365
+ task: string;
366
+ cwd: string;
367
+ readOnly: boolean;
330
368
  startedAt: number;
369
+ updatedAt: number;
370
+ timeoutAt: number;
331
371
  sessionGeneration: number;
332
372
  }
333
373
 
@@ -555,7 +595,14 @@ export class RunningSubagentWidgetController {
555
595
  }
556
596
  }
557
597
 
558
- 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
+ ) {
559
606
  const isChild = process.env[ENV_ROLE] === "child";
560
607
 
561
608
  if (isChild) {
@@ -565,9 +612,10 @@ export default function (pi: ExtensionAPI) {
565
612
  }
566
613
 
567
614
  // ===== main-agent mode =====
615
+ const ownerRuntimeId = randomUUID();
568
616
  pi.registerMessageRenderer<IncomingMessageDetails>(
569
617
  INCOMING_MESSAGE_TYPE,
570
- (message, _options, theme) => {
618
+ (message, options, theme) => {
571
619
  const details = message.details ?? {};
572
620
  const agent = String(details.from || "subagent");
573
621
  const body =
@@ -580,12 +628,14 @@ export default function (pi: ExtensionAPI) {
580
628
  body,
581
629
  theme,
582
630
  Boolean(details.expectsReply),
631
+ undefined,
632
+ options.expanded,
583
633
  );
584
634
  },
585
635
  );
586
636
  pi.registerMessageRenderer<BackgroundEventDetails>(
587
637
  BACKGROUND_EVENT_TYPE,
588
- (message, _options, theme) => {
638
+ (message, options, theme) => {
589
639
  const details = message.details ?? {};
590
640
  const kind: TranscriptEventKind = details.status === "error" ? "error" : "done";
591
641
  const agent = String(details.agent || "subagent");
@@ -604,6 +654,7 @@ export default function (pi: ExtensionAPI) {
604
654
  theme,
605
655
  false,
606
656
  elapsedMs,
657
+ options.expanded,
607
658
  );
608
659
  },
609
660
  );
@@ -623,13 +674,142 @@ export default function (pi: ExtensionAPI) {
623
674
  string,
624
675
  { release: () => void; timer?: ReturnType<typeof setTimeout> }
625
676
  >();
677
+ const persistedTasks = new Map<string, PersistedTaskState>();
626
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;
627
684
  let terminalInputUnsubscribe: (() => void) | undefined;
628
685
  let sessionOverlayOpen = false;
629
686
  let closeSessionOverlay: (() => void) | undefined;
630
687
  let pendingOpenActivityId: string | undefined;
631
688
  let lastNavigation: { direction: -1 | 1; at: number } | undefined;
632
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
+
633
813
  const bindPoolLifecycle = (): void => {
634
814
  pool.setProcessExitHandler((agentName, runId) => {
635
815
  const run = activeRuns.resolveExact(runId);
@@ -688,6 +868,9 @@ export default function (pi: ExtensionAPI) {
688
868
 
689
869
  pi.on("session_start", (event, ctx) => {
690
870
  sessionGeneration++;
871
+ shutdownStarted = false;
872
+ acceptingTasks = false;
873
+ recoveryOutbox.cancelAll();
691
874
  if (poolDisposed) {
692
875
  pool = new SubagentPool(messageRoot);
693
876
  bindPoolLifecycle();
@@ -698,6 +881,8 @@ export default function (pi: ExtensionAPI) {
698
881
  ctx.sessionManager?.getSessionId?.() ??
699
882
  (event as any).sessionId ??
700
883
  "ephemeral";
884
+ currentSessionToken = `${sessionId}:${sessionGeneration}`;
885
+ runtimeContext = ctx;
701
886
  messageRoot = sessionRoot(sessionId);
702
887
  ensureChannelRoot(MESSAGE_ROOT_BASE);
703
888
  sweepMessageRoots(MESSAGE_ROOT_BASE);
@@ -706,6 +891,8 @@ export default function (pi: ExtensionAPI) {
706
891
  pool.setWorkingDirectory(cwd);
707
892
  runningWidget.attach(ctx);
708
893
  sessionContext = ctx.mode === "tui" ? ctx : undefined;
894
+ reconcileCurrentBranch();
895
+ acceptingTasks = true;
709
896
  lastNavigation = undefined;
710
897
  terminalInputUnsubscribe?.();
711
898
  terminalInputUnsubscribe =
@@ -733,7 +920,42 @@ export default function (pi: ExtensionAPI) {
733
920
  : undefined;
734
921
  });
735
922
 
736
- 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
+ }
737
959
  sessionGeneration++;
738
960
  for (const controller of runControllers.values()) controller.abort();
739
961
  runControllers.clear();
@@ -745,16 +967,21 @@ export default function (pi: ExtensionAPI) {
745
967
  sessionContext = undefined;
746
968
  pendingOpenActivityId = undefined;
747
969
  sessionHandles.clear();
748
- activeRuns.clear();
749
970
  for (const pending of pendingMainReplyEdges.values()) {
750
971
  if (pending.timer) clearTimeout(pending.timer);
751
972
  pending.release();
752
973
  }
753
974
  pendingMainReplyEdges.clear();
754
975
  waitGraph.clear();
755
- runningWidget.shutdown();
756
976
  pool.dispose();
757
977
  poolDisposed = true;
978
+ router?.dispose();
979
+ router = undefined;
980
+ activeRuns.clear();
981
+ tasks.clear();
982
+ runningWidget.shutdown();
983
+ currentSessionToken = undefined;
984
+ runtimeContext = undefined;
758
985
  sweepMessageRoots(MESSAGE_ROOT_BASE);
759
986
  });
760
987
 
@@ -782,7 +1009,6 @@ export default function (pi: ExtensionAPI) {
782
1009
  // ---- message router: subagent -> main injects into main session; child
783
1010
  // messages resolve through the active-run registry before being delivered
784
1011
  // to a direct/background session or the resident pool. ----
785
- let router: ReturnType<typeof createMessageRouter> | undefined;
786
1012
  const routedTimeout = (msg: MessageRequest): number =>
787
1013
  Math.min(
788
1014
  MAX_SUBAGENT_TIMEOUT_SECONDS * 1000,
@@ -982,11 +1208,6 @@ export default function (pi: ExtensionAPI) {
982
1208
  router.start();
983
1209
  });
984
1210
 
985
- pi.on("session_shutdown", () => {
986
- router?.dispose();
987
- router = undefined;
988
- });
989
-
990
1211
  /** Write a reply back to the message sender */
991
1212
  function writeReplyTo(msg: MessageRequest, content: string, root = messageRoot): boolean {
992
1213
  try {
@@ -1018,20 +1239,13 @@ export default function (pi: ExtensionAPI) {
1018
1239
  return `${agent.name}#${runId.slice(0, 8)}`;
1019
1240
  }
1020
1241
 
1021
- function createDirectRun(
1242
+ function resolveDirectRunAddress(
1022
1243
  agent: AgentConfig,
1023
1244
  runId: string,
1024
- mode: "background" | "task",
1025
1245
  requestedAddress?: string,
1026
- readOnly = agent.readOnly === true,
1027
- runCwd = cwd,
1028
- ): {
1029
- address: string;
1030
- session: Promise<SubagentSessionHandle>;
1031
- resolveSession: (session: SubagentSessionHandle) => void;
1032
- rejectSession: (error: Error) => void;
1033
- } {
1246
+ ): string {
1034
1247
  const address = requestedAddress?.trim() || directRunAddress(agent, runId);
1248
+ validateLaunchText("address", address);
1035
1249
  if (
1036
1250
  (requestedAddress &&
1037
1251
  (!/^[A-Za-z0-9_.-]+$/.test(address) ||
@@ -1046,6 +1260,25 @@ export default function (pi: ExtensionAPI) {
1046
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".`,
1047
1261
  );
1048
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
+ }
1049
1282
  let resolveSession!: (session: SubagentSessionHandle) => void;
1050
1283
  let rejectSession!: (error: Error) => void;
1051
1284
  const session = new Promise<SubagentSessionHandle>((resolve, reject) => {
@@ -1095,29 +1328,77 @@ export default function (pi: ExtensionAPI) {
1095
1328
  timeoutMs?: number,
1096
1329
  requestedAddress?: string,
1097
1330
  readOnly = agent.readOnly === true,
1331
+ retryOf?: string,
1098
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);
1099
1338
  const runId = randomUUID();
1100
- const controller = new AbortController();
1101
- const direct = createDirectRun(
1102
- 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,
1103
1348
  runId,
1104
- "background",
1105
- requestedAddress,
1349
+ address,
1350
+ agent: agent.name,
1351
+ task: taskText,
1352
+ cwd: runCwd,
1106
1353
  readOnly,
1107
- cwdOverride ?? cwd,
1108
- );
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
+ }
1109
1385
  runControllers.set(runId, controller);
1110
1386
  tasks.set(runId, {
1111
1387
  runId,
1112
1388
  agent: agent.name,
1113
- address: direct.address,
1114
- startedAt: Date.now(),
1389
+ address,
1390
+ task: taskText,
1391
+ cwd: runCwd,
1392
+ readOnly,
1393
+ startedAt,
1394
+ updatedAt: startedAt,
1395
+ timeoutAt,
1115
1396
  sessionGeneration,
1116
1397
  });
1117
1398
  runningWidget.start(runId, agent.name, taskText, "background");
1118
1399
 
1119
1400
  const start = async () => {
1120
- return await spawnInteractiveSubagent({
1401
+ return await dependencies.spawnInteractiveSubagent({
1121
1402
  agent,
1122
1403
  task: taskText,
1123
1404
  address: direct.address,
@@ -1126,7 +1407,29 @@ export default function (pi: ExtensionAPI) {
1126
1407
  runId,
1127
1408
  childIndex: 0,
1128
1409
  signal: controller.signal,
1129
- 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
+ },
1130
1433
  onSession: (session) => {
1131
1434
  direct.resolveSession(session);
1132
1435
  registerSessionHandle(runId, session);
@@ -1141,47 +1444,82 @@ export default function (pi: ExtensionAPI) {
1141
1444
  activeRuns.remove(runId);
1142
1445
  const task = tasks.get(runId);
1143
1446
  if (!task || task.sessionGeneration !== sessionGeneration) return;
1144
- const finalStatus: "done" | "error" =
1145
- result.exitCode === 0 && result.stopReason !== "error" ? "done" : "error";
1146
1447
  const output = getFinalOutput(result.messages) || "(no text output)";
1147
1448
  const failureReason =
1148
1449
  result.exitCode !== 0
1149
- ? result.stderr || result.errorMessage || `process exited with code ${result.exitCode}`
1450
+ ? result.errorMessage || result.stderr || `process exited with code ${result.exitCode}`
1150
1451
  : result.stopReason === "error"
1151
1452
  ? result.errorMessage || "subagent reported an error"
1152
1453
  : "";
1153
- const head =
1154
- finalStatus === "done"
1155
- ? `Subagent ${agent.name} done (run ${runId.slice(0, 8)})`
1156
- : `Subagent ${agent.name} failed: ${failureReason}`;
1157
- const displayBody =
1158
- finalStatus === "done"
1159
- ? output
1160
- : [
1161
- 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,
1162
1496
  output === "(no text output)" ? "" : `**Partial result**\n\n${output}`,
1163
1497
  ]
1164
1498
  .filter(Boolean)
1165
1499
  .join("\n\n");
1166
- try {
1167
- pi.sendMessage(
1168
- {
1169
- customType: BACKGROUND_EVENT_TYPE,
1170
- content: `[background subagent ${finalStatus}] ${head}\n\n--- result ---\n${output}`,
1171
- display: true,
1172
- details: {
1173
- agent: agent.name,
1174
- address: direct.address,
1175
- status: finalStatus,
1176
- body: displayBody,
1177
- elapsedMs: Date.now() - task.startedAt,
1178
- runId,
1179
- },
1180
- },
1181
- { 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)}`,
1182
1522
  );
1183
- } catch {
1184
- /* session is closed */
1185
1523
  }
1186
1524
  })
1187
1525
  .catch((err) => {
@@ -1189,26 +1527,47 @@ export default function (pi: ExtensionAPI) {
1189
1527
  direct.rejectSession(err instanceof Error ? err : new Error(String(err)));
1190
1528
  const task = tasks.get(runId);
1191
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();
1192
1536
  try {
1193
1537
  if (task && task.sessionGeneration !== sessionGeneration) return;
1194
- pi.sendMessage(
1538
+ const terminal = transitionTask(
1539
+ runId,
1540
+ ["starting", "running"],
1195
1541
  {
1196
- customType: BACKGROUND_EVENT_TYPE,
1197
- content: `[background subagent error] ${agent.name} (run ${runId.slice(0, 8)}): ${errorText}`,
1198
- display: true,
1199
- details: {
1200
- agent: agent.name,
1201
- address: direct.address,
1202
- status: "error",
1203
- body: errorText,
1204
- elapsedMs: task ? Date.now() - task.startedAt : undefined,
1205
- runId,
1206
- },
1542
+ status: cancelled ? "cancelled" : "failed",
1543
+ error: boundTaskText(errorText, TASK_STATE_LIMITS.error),
1544
+ endedAt: now,
1207
1545
  },
1208
- { 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)}`,
1209
1570
  );
1210
- } catch {
1211
- /* ignore */
1212
1571
  }
1213
1572
  })
1214
1573
  .finally(() => {
@@ -1220,7 +1579,7 @@ export default function (pi: ExtensionAPI) {
1220
1579
  runningWidget.finish(runId);
1221
1580
  });
1222
1581
 
1223
- return { runId, address: direct.address };
1582
+ return { runId, address };
1224
1583
  }
1225
1584
 
1226
1585
  // ---- adaptive orchestration prompt; `/orchestrate on` installs the opt-in prompt ----
@@ -1250,11 +1609,12 @@ export default function (pi: ExtensionAPI) {
1250
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.",
1251
1610
  ].join(" "),
1252
1611
  parameters: Type.Object({
1253
- agent: Type.String({ description: "Subagent name" }),
1254
- task: Type.String({ description: "Task description for the subagent" }),
1255
- 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" })),
1256
1615
  as: Type.Optional(
1257
1616
  Type.String({
1617
+ maxLength: TASK_STATE_LIMITS.address,
1258
1618
  description: "Optional unique runtime address for this run (for example actor-a); identity still comes from agent",
1259
1619
  }),
1260
1620
  ),
@@ -1274,6 +1634,12 @@ export default function (pi: ExtensionAPI) {
1274
1634
  }),
1275
1635
 
1276
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
+ }
1277
1643
  const agents = discoverAgents(ctx.cwd);
1278
1644
  const agent = agents.find((a) => a.name === params.agent);
1279
1645
  if (!agent) {
@@ -1324,7 +1690,9 @@ export default function (pi: ExtensionAPI) {
1324
1690
  else signal?.addEventListener("abort", forwardAbort, { once: true });
1325
1691
  let direct: ReturnType<typeof createDirectRun>;
1326
1692
  try {
1327
- 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);
1328
1696
  } catch (error) {
1329
1697
  signal?.removeEventListener("abort", forwardAbort);
1330
1698
  runControllers.delete(runId);
@@ -1346,7 +1714,7 @@ export default function (pi: ExtensionAPI) {
1346
1714
  runningWidget.start(runId, agent.name, params.task, "task");
1347
1715
  let result: Awaited<ReturnType<typeof spawnInteractiveSubagent>>;
1348
1716
  try {
1349
- result = await spawnInteractiveSubagent({
1717
+ result = await dependencies.spawnInteractiveSubagent({
1350
1718
  agent,
1351
1719
  task: params.task,
1352
1720
  address: direct.address,
@@ -1482,25 +1850,122 @@ export default function (pi: ExtensionAPI) {
1482
1850
  },
1483
1851
  });
1484
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
+
1485
1929
  pi.registerCommand("subagent-status", {
1486
- description: "Show active subagent runs and runtime addresses",
1930
+ description: "Show live subagent tasks and current-branch task history",
1487
1931
  handler: async (_args, ctx) => {
1488
- const list = [...tasks.values()];
1489
- const lines = list.map((t) => {
1490
- const age = Math.round((Date.now() - t.startedAt) / 1000);
1491
- 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
+ };
1492
1948
  });
1493
- const active = activeRuns.list();
1494
- 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(
1495
1955
  (run) => `- ${run.address} -> ${run.agent} [${run.mode}] (run ${run.runId.slice(0, 8)})`,
1496
1956
  );
1957
+ const status = formatTaskStatus(live, persistedTasks);
1497
1958
  ctx.ui.notify(
1498
1959
  [
1499
- lines.length ? `Background tasks:\n${lines.join("\n")}` : "Background tasks: none",
1500
- 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
+ : "",
1501
1964
  ].join("\n"),
1502
1965
  "info",
1503
1966
  );
1504
1967
  },
1505
1968
  });
1506
1969
  }
1970
+
1971
+ export default createAgentSquadExtension;