pi-extended-teams 2.3.0 → 2.3.2

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.
@@ -51,6 +51,20 @@ function hasPersistedTeamSettings(ctx: any): boolean {
51
51
  && fs.existsSync(projectSettingsPath(ctx.cwd));
52
52
  }
53
53
 
54
+ export function registerAgentReportRenderer(pi: any): void {
55
+ pi.registerMessageRenderer?.("pi-extended-teams-report", (message: any, _renderOptions: any, theme: any) => {
56
+ const d = message.details || {};
57
+ const meta = [
58
+ d.elapsedMs ? formatElapsed(d.elapsedMs) : "",
59
+ typeof d.tokens === "number" ? `${formatTokenCount(d.tokens)} tok` : "",
60
+ ].filter(Boolean).join(" · ");
61
+ const mark = d.ok === false ? theme.fg("warning", "✗") : theme.fg("success", "✓");
62
+ const headline = `${mark} ${d.name || "agent"} reported${meta ? ` · ${meta}` : ""}`;
63
+ const body = typeof message.content === "string" ? message.content : "";
64
+ return new Text(`${theme.bold(headline)}\n\n${body}`, 0, 0);
65
+ });
66
+ }
67
+
54
68
  export function registerExtensionEvents(pi: any, options: RegisterEventsOptions): void {
55
69
  let teammateWakeIfUnread: (() => Promise<void>) | null = null;
56
70
  let teammatePendingInboxWake = false;
@@ -131,17 +145,7 @@ export function registerExtensionEvents(pi: any, options: RegisterEventsOptions)
131
145
  }
132
146
  };
133
147
 
134
- pi.registerMessageRenderer?.("pi-extended-teams-report", (message: any, _renderOptions: any, theme: any) => {
135
- const d = message.details || {};
136
- const meta = [
137
- d.elapsedMs ? formatElapsed(d.elapsedMs) : "",
138
- typeof d.tokens === "number" ? `${formatTokenCount(d.tokens)} tok` : "",
139
- ].filter(Boolean).join(" · ");
140
- const mark = d.ok === false ? theme.fg("warning", "✗") : theme.fg("success", "✓");
141
- const headline = `${mark} ${d.name || "agent"} reported${meta ? ` · ${meta}` : ""}`;
142
- const body = typeof message.content === "string" ? message.content : "";
143
- return new Text(`${theme.bold(headline)}\n\n${body}`, 0, 0);
144
- });
148
+ registerAgentReportRenderer(pi);
145
149
 
146
150
  pi.on("session_start", async (_event: any, ctx: any) => {
147
151
  paths.ensureDirs();
@@ -25,6 +25,8 @@ import { parseQualifiedModel } from "../src/utils/model-resolution";
25
25
  import { formatAnimatedProgress, formatContextUsage, formatElapsed, formatModelLabel } from "./ui/renderers.js";
26
26
  import type { CompletedAgentReport, RunningReadAgent } from "./runtime/types.js";
27
27
  import { createPendingChildController } from "./runtime/pending-child-controller.js";
28
+ import { createTeamHost } from "./runtime/team-host";
29
+ import { registerLeadAttachment, type TeamExecutionOwner } from "./runtime/lead-attachment";
28
30
  import { createActiveAgentSleepController, runWithActiveAgentSleepAssertion } from "./runtime/active-agent-sleep.js";
29
31
  import { createTeammateInterrupter } from "./runtime/teammate-interrupt.js";
30
32
  export { panelBgFill, framePanel, frameWidget, frameWidgetFullWidth, logWindowStart } from "./ui/frame.js";
@@ -39,9 +41,17 @@ import type { Member } from "../src/utils/models";
39
41
  import { projectAgentStatus, projectLifecycleFence } from "../src/orchestration/status-projection";
40
42
  import { getTerminalAdapter } from "../src/adapters/terminal-registry";
41
43
  import { createSpawnResourcePlan, parentProjectTrustForSpawn } from "./resources/spawn-resource-plan.js";
42
- import { generateExtensionInstanceId, listLifecycleTombstones, onLifecycleTombstoneCleared, readLifecycleTombstone } from "../src/utils/lifecycle-tombstone";
44
+ import { generateExtensionInstanceId, generateLifecycleRunId, listLifecycleTombstones, onLifecycleTombstoneCleared, readLifecycleTombstone } from "../src/utils/lifecycle-tombstone";
43
45
 
44
- export default function (pi: ExtensionAPI) {
46
+ export default function (pi: ExtensionAPI): void {
47
+ if (process.env.PI_AGENT_NAME) { createExecutionRuntime(pi); return; }
48
+ registerLeadAttachment(pi, () => {
49
+ const host = createTeamHost();
50
+ return createExecutionRuntime(host.api as unknown as ExtensionAPI, host);
51
+ });
52
+ }
53
+
54
+ function createExecutionRuntime(pi: ExtensionAPI, host?: ReturnType<typeof createTeamHost>): TeamExecutionOwner {
45
55
  const isTeammate = !!process.env.PI_AGENT_NAME;
46
56
  const agentName = process.env.PI_AGENT_NAME || "team-lead";
47
57
  const extensionInstanceId = generateExtensionInstanceId();
@@ -67,6 +77,14 @@ export default function (pi: ExtensionAPI) {
67
77
  let readHelperQueueDrainPending = false;
68
78
  let sessionCtx: any = null;
69
79
  let extensionShuttingDown = false;
80
+ let executionClosing = false;
81
+ const pendingRootAdmissions = new Set<Promise<void>>();
82
+ function trackRootAdmission(): () => void {
83
+ let settle!: () => void;
84
+ const pending = new Promise<void>(resolve => { settle = resolve; });
85
+ pendingRootAdmissions.add(pending);
86
+ return () => { pendingRootAdmissions.delete(pending); settle(); };
87
+ }
70
88
  const lifecycleFenceClearUnsubscribe = onLifecycleTombstoneCleared((clearedTeamName) => {
71
89
  if (clearedTeamName === teamName) void drainReadHelperQueueOnce();
72
90
  });
@@ -82,6 +100,7 @@ export default function (pi: ExtensionAPI) {
82
100
  let teamActivityStatusSnapshotSignature: string | null = null;
83
101
  let teamActivityWidgetMounted = false;
84
102
  let teamActivityWidgetTui: { requestRender?: () => void } | null = null;
103
+ let presentationGeneration = 0;
85
104
  let teamActivityRenderInFlight = false;
86
105
  let teamActivityRenderDirty = false;
87
106
  let teamActivityRenderTimer: NodeJS.Timeout | null = null;
@@ -93,8 +112,10 @@ export default function (pi: ExtensionAPI) {
93
112
  // instead of unread count prevents a read message being replaced by a new one
94
113
  // at the same count from silently suppressing the new wake.
95
114
  const leadWakeNotifiedMessageKeys = new Set<string>();
96
- const createCurrentSpawnResourcePlan = (input: { cwd: string; projectTrusted: boolean }) => {
97
- return createSpawnResourcePlan({ ...input, pi });
115
+ const createCurrentSpawnResourcePlan = async (input: { cwd: string; projectTrusted: boolean }) => {
116
+ while (host && !host.ready) await host.whenAttached();
117
+ const projectTrusted = host ? parentProjectTrustForSpawn(sessionCtx, input.cwd) : input.projectTrusted;
118
+ return createSpawnResourcePlan({ ...input, projectTrusted, pi });
98
119
  };
99
120
  let teamToolsRuntime: TeamToolsRuntime | undefined;
100
121
  const pendingChildController = createPendingChildController();
@@ -447,9 +468,12 @@ export default function (pi: ExtensionAPI) {
447
468
  }
448
469
 
449
470
  async function notifyLeadOfInboxReports(targetTeamName: string): Promise<void> {
450
- if (isTeammate) return;
471
+ if (isTeammate || extensionShuttingDown) return;
472
+ const generation = presentationGeneration;
451
473
  const unread = await messaging.peekInbox(targetTeamName, agentName, true);
474
+ if (generation !== presentationGeneration) return;
452
475
  await renderLeadInboxStatus();
476
+ if (generation !== presentationGeneration) return;
453
477
  await wakeLeadForInboxReports(unread);
454
478
  }
455
479
 
@@ -530,6 +554,7 @@ export default function (pi: ExtensionAPI) {
530
554
  return;
531
555
  }
532
556
 
557
+ const generation = presentationGeneration;
533
558
  teamActivityRenderInFlight = true;
534
559
  let renderError: unknown;
535
560
  try {
@@ -538,6 +563,7 @@ export default function (pi: ExtensionAPI) {
538
563
  } catch (error) {
539
564
  renderError = error;
540
565
  } finally {
566
+ if (generation !== presentationGeneration) return;
541
567
  teamActivityRenderInFlight = false;
542
568
  if (renderError) {
543
569
  teamActivityRenderDirty = false;
@@ -602,6 +628,8 @@ export default function (pi: ExtensionAPI) {
602
628
 
603
629
  async function renderTeamActivityStatusNow() {
604
630
  if (!sessionCtx?.ui) return;
631
+ const generation = presentationGeneration;
632
+ const isCurrent = () => generation === presentationGeneration && !extensionShuttingDown;
605
633
 
606
634
  const now = Date.now();
607
635
  const activityTeamName = teamName || null;
@@ -630,6 +658,7 @@ export default function (pi: ExtensionAPI) {
630
658
  runtimeStatus: await runtime.readRuntimeStatus(activityTeamName, member.name).catch(() => null),
631
659
  })))
632
660
  : [];
661
+ if (!isCurrent()) return;
633
662
  const runtimeOnlyMembers = runtimeOnlyCandidates
634
663
  .filter((entry): entry is { member: Member; runtimeStatus: runtime.AgentRuntimeStatus } => isVisibleRuntimeOnlyMember(entry.member, entry.runtimeStatus, now))
635
664
  .filter(({ member, runtimeStatus }) => member.lifecycleRunId === runtimeStatus.lifecycleRunId);
@@ -678,6 +707,7 @@ export default function (pi: ExtensionAPI) {
678
707
  }
679
708
  }
680
709
  const unreadLeadMessages = activityTeamName ? await messaging.readInbox(activityTeamName, agentName, true, false).catch(() => []) : [];
710
+ if (!isCurrent()) return;
681
711
  leadInboxUnreadCount = unreadLeadMessages.length;
682
712
  clearLeadInboxWidgetOnce();
683
713
 
@@ -746,6 +776,7 @@ export default function (pi: ExtensionAPI) {
746
776
  joinedAt: member.joinedAt,
747
777
  });
748
778
  const runtimeStatus = await runtime.readRuntimeStatus(activityTeamName!, member.name).catch(() => null);
779
+ if (!isCurrent()) return;
749
780
  const elapsed = formatElapsed(now - (runtimeStatus?.startedAt || member.joinedAt));
750
781
  const modelLabel = formatModelLabel(member.model, member.thinking);
751
782
  const contextUsage = formatRunningContextUsage(runtimeStatus?.contextUsage);
@@ -870,12 +901,13 @@ export default function (pi: ExtensionAPI) {
870
901
  }
871
902
 
872
903
  async function drainReadHelperQueueOnce() {
873
- if (isTeammate || !sessionCtx || !teamName) return;
904
+ if (isTeammate || !sessionCtx || !teamName || (host && !host.ready)) return;
874
905
  if (readHelperQueueDraining) {
875
906
  readHelperQueueDrainPending = true;
876
907
  return;
877
908
  }
878
909
  readHelperQueueDraining = true;
910
+ const settled = trackRootAdmission();
879
911
  try {
880
912
  for (let drained = 0; drained < 10 && teamName; drained++) {
881
913
  const [nextQueued] = await listReadHelperQueue(teamName);
@@ -886,6 +918,8 @@ export default function (pi: ExtensionAPI) {
886
918
 
887
919
  try {
888
920
  const config = await teams.readConfig(queued.teamName);
921
+ const launchContext = sessionCtx;
922
+ if (!launchContext || (host && !host.ready)) return;
889
923
  if (config.members.some(member => member.name === queued.name)) {
890
924
  throw new Error(`Teammate ${queued.name} already exists in team ${queued.teamName}.`);
891
925
  }
@@ -894,7 +928,7 @@ export default function (pi: ExtensionAPI) {
894
928
  const parsedModel = parseQualifiedModel(level.model);
895
929
  const provider = parsedModel?.provider;
896
930
  const modelId = parsedModel?.model;
897
- const model = provider && modelId ? sessionCtx.modelRegistry?.find?.(provider, modelId) : undefined;
931
+ const model = provider && modelId ? launchContext.modelRegistry?.find?.(provider, modelId) : undefined;
898
932
  if (!model) throw new Error(`Read helper model \"${level.model}\" from intent tier ${level.slot} is not available in the lead session.`);
899
933
 
900
934
  const helperPrompt = buildReadHelperPrompt(queued.teamName, queued.requester, queued.prompt);
@@ -916,9 +950,15 @@ export default function (pi: ExtensionAPI) {
916
950
  helperKind: "read_helper",
917
951
  };
918
952
 
953
+ member.lifecycleRunId = generateLifecycleRunId();
919
954
  await teams.addMember(queued.teamName, member);
955
+ if (executionClosing) {
956
+ await shutdownTeammate(queued.teamName, member);
957
+ await removeQueuedReadHelperRequest(queued.teamName, queued.id);
958
+ break;
959
+ }
920
960
  observeReadAgentLaunch(
921
- runInProcessAgentWithSleepAssertion(queued.teamName, member, helperPrompt, sessionCtx, readAgentOptions())
961
+ runInProcessAgentWithSleepAssertion(queued.teamName, member, helperPrompt, launchContext, readAgentOptions())
922
962
  );
923
963
  await removeQueuedReadHelperRequest(teamName, queued.id);
924
964
  } catch (e) {
@@ -971,6 +1011,7 @@ export default function (pi: ExtensionAPI) {
971
1011
  }
972
1012
  }
973
1013
  } finally {
1014
+ settled();
974
1015
  readHelperQueueDraining = false;
975
1016
  if (readHelperQueueDrainPending) {
976
1017
  readHelperQueueDrainPending = false;
@@ -1023,9 +1064,12 @@ export default function (pi: ExtensionAPI) {
1023
1064
 
1024
1065
  leadInboxPollTimer = setInterval(async () => {
1025
1066
  if (!teamName) return;
1067
+ const generation = presentationGeneration;
1026
1068
  try {
1027
1069
  const unread = await messaging.readInbox(teamName, agentName, true, false);
1070
+ if (generation !== presentationGeneration) return;
1028
1071
  await renderLeadInboxStatus();
1072
+ if (generation !== presentationGeneration) return;
1029
1073
  await wakeLeadForInboxReports(unread);
1030
1074
  } catch {
1031
1075
  // Ignore errors for lead polling
@@ -1055,11 +1099,12 @@ export default function (pi: ExtensionAPI) {
1055
1099
  pi.events?.on?.("pi-prompt:prompt-build:start", async (payload: any) => {
1056
1100
  if (isTeammate || !sessionCtx) return;
1057
1101
  const requestedTeamName = teamPaths.sanitizeName(payload?.teamName || `prompt-build-${Date.now()}`);
1058
-
1102
+ const launchContext = sessionCtx;
1103
+ const settled = trackRootAdmission();
1059
1104
  try {
1060
1105
  const prompts = Array.isArray(payload?.prompts) ? payload.prompts : [];
1061
1106
  activePromptBuildTeamName = requestedTeamName;
1062
- const cwd = payload?.cwd || sessionCtx.cwd;
1107
+ const cwd = payload?.cwd || launchContext.cwd;
1063
1108
  const agentNamePrefix = teamPaths.sanitizeName(payload?.agentNamePrefix || "prompt-branch");
1064
1109
  if (payload?.model || payload?.thinking || payload?.role) {
1065
1110
  throw new Error("Prompt-build agents must use model_slot only; direct model, thinking, or role is not allowed.");
@@ -1069,7 +1114,7 @@ export default function (pi: ExtensionAPI) {
1069
1114
  if (level.role !== "read") throw new Error(`Prompt-build requires a read-* intent tier configured via /agents-favorite-models, got ${level.slot}.`);
1070
1115
 
1071
1116
  if (!teams.teamExists(requestedTeamName)) {
1072
- teams.createTeam(requestedTeamName, getPiSessionId(sessionCtx) || "local-session", "lead-agent", payload?.description || "pi-prompt prompt-build", level.model);
1117
+ teams.createTeam(requestedTeamName, getPiSessionId(launchContext) || "local-session", "lead-agent", payload?.description || "pi-prompt prompt-build", level.model);
1073
1118
  }
1074
1119
  // Prompt-build teams are private fanout jobs owned by pi-prompt. Do not
1075
1120
  // adopt them as the lead's current team, or normal session context and
@@ -1094,21 +1139,25 @@ export default function (pi: ExtensionAPI) {
1094
1139
  thinking: level.thinking,
1095
1140
  modelSlot: level.slot,
1096
1141
  };
1142
+ if (executionClosing) break;
1143
+ member.lifecycleRunId = generateLifecycleRunId();
1097
1144
  await teams.addMember(requestedTeamName, member);
1145
+ if (executionClosing) { await shutdownTeammate(requestedTeamName, member); break; }
1098
1146
  observeReadAgentLaunch(
1099
- runInProcessAgentWithSleepAssertion(requestedTeamName, member, prompt, sessionCtx, readAgentOptions())
1147
+ runInProcessAgentWithSleepAssertion(requestedTeamName, member, prompt, launchContext, readAgentOptions())
1100
1148
  );
1101
1149
  pi.events?.emit?.("pi-prompt:prompt-build:progress", { teamName: requestedTeamName, status: "spawned", started: i + 1, total: prompts.length, text: `building prompt — ${i + 1}/${prompts.length} branches started` });
1102
1150
  }
1103
1151
  } catch (err) {
1104
1152
  const message = err instanceof Error ? err.message : String(err);
1105
1153
  pi.events?.emit?.("pi-prompt:prompt-build:error", { teamName: requestedTeamName, error: message });
1106
- }
1154
+ } finally { settled(); }
1107
1155
  });
1108
1156
 
1109
1157
  function setSessionCtx(ctx: any): void {
1158
+ if (host && !host.attached) return;
1110
1159
  extensionShuttingDown = false;
1111
- sessionCtx = ctx;
1160
+ sessionCtx = host?.context ?? ctx;
1112
1161
  if (!isTeammate && !teamName) {
1113
1162
  const foundTeam = findLeadTeamForSession(getPiSessionId(ctx));
1114
1163
  if (foundTeam) adoptTeamAsLead(foundTeam, ctx);
@@ -1142,7 +1191,10 @@ export default function (pi: ExtensionAPI) {
1142
1191
  }
1143
1192
  }
1144
1193
 
1145
- async function cleanupExtensionRuntime(ctx: any, reason: unknown): Promise<void> {
1194
+ function detachPresentation(): void {
1195
+ presentationGeneration++;
1196
+ teamActivityRenderInFlight = false;
1197
+ navigationRuntimeAgents.clear();
1146
1198
  extensionShuttingDown = true;
1147
1199
  sessionCtx = null;
1148
1200
  stopLeadWatchdog();
@@ -1158,13 +1210,20 @@ export default function (pi: ExtensionAPI) {
1158
1210
  readHelperQueueFallbackTimer = null;
1159
1211
  readHelperQueueWatcher = null;
1160
1212
  leadPollingStarted = false;
1161
- leadWakeNotifiedMessageKeys.clear();
1162
1213
  readHelperQueueFallbackStarted = false;
1163
1214
  readHelperQueueWatchedTeam = null;
1164
- lifecycleFenceClearUnsubscribe();
1165
1215
  teamActivityRenderDirty = false;
1166
1216
  settleTeamActivityRenderWaiters();
1217
+ teamActivityStatusSnapshot = null;
1218
+ teamActivityStatusSnapshotSignature = null;
1219
+ teamActivityWidgetMounted = false;
1220
+ teamActivityWidgetTui = null;
1221
+ }
1167
1222
 
1223
+ async function cleanupExtensionRuntime(ctx: any, reason: unknown): Promise<void> {
1224
+ detachPresentation();
1225
+ leadWakeNotifiedMessageKeys.clear();
1226
+ lifecycleFenceClearUnsubscribe();
1168
1227
  try {
1169
1228
  await stopRunningAgentsForShutdown(reason);
1170
1229
  } finally {
@@ -1334,6 +1393,8 @@ export default function (pi: ExtensionAPI) {
1334
1393
  getSessionCtx: () => sessionCtx,
1335
1394
  setSessionCtx,
1336
1395
  pendingChildController,
1396
+ isHostAttached: () => !host || host.ready,
1397
+ waitForHost: () => host?.whenAttached() ?? Promise.resolve(),
1337
1398
  });
1338
1399
 
1339
1400
  if (isTeammate) {
@@ -1345,7 +1406,7 @@ export default function (pi: ExtensionAPI) {
1345
1406
  }));
1346
1407
  }
1347
1408
 
1348
- registerCoordinationTools(pi, {
1409
+ const coordination: Parameters<typeof registerCoordinationTools>[1] = {
1349
1410
  agentName,
1350
1411
  isTeammate,
1351
1412
  terminal,
@@ -1360,9 +1421,10 @@ export default function (pi: ExtensionAPI) {
1360
1421
  resetLeadWakeNotifiedCount: () => { leadWakeNotifiedMessageKeys.clear(); },
1361
1422
  deliverMessageToActiveAgent,
1362
1423
  extensionInstanceId,
1363
- });
1424
+ };
1425
+ if (isTeammate) registerCoordinationTools(pi, coordination);
1364
1426
 
1365
- registerTaskRuntimeTools(pi, {
1427
+ const taskTools: Parameters<typeof registerTaskRuntimeTools>[1] = {
1366
1428
  isTeammate,
1367
1429
  terminal,
1368
1430
  runningReadAgents,
@@ -1371,6 +1433,47 @@ export default function (pi: ExtensionAPI) {
1371
1433
  cancelQueuedAgent: (targetTeamName, targetAgentName) => teamToolsRuntime?.cancelQueuedAgent(targetTeamName, targetAgentName) ?? false,
1372
1434
  shutdownTeammate,
1373
1435
  getTeamName: () => teamName,
1374
- });
1375
-
1436
+ };
1437
+ if (isTeammate) registerTaskRuntimeTools(pi, taskTools);
1438
+
1439
+ return {
1440
+ host: host!, coordination, taskTools,
1441
+ hasWork: () => runningReadAgents.size > 0 || activeWritersTabs.length > 0 || pendingRootAdmissions.size > 0 || teamToolsRuntime?.hasPendingWork() === true,
1442
+ async attach(binding) {
1443
+ host!.attach(binding);
1444
+ const ctx = host!.snapshot(binding.ctx);
1445
+ const costAttached = combinedSessionCost?.attach(binding.pi, ctx);
1446
+ await host!.emit("session_start", binding.event, ctx);
1447
+ if (!host!.attached) return;
1448
+ if (!costAttached) combinedSessionCost?.attach(binding.pi, ctx);
1449
+ },
1450
+ activate() {
1451
+ host!.flush();
1452
+ teamToolsRuntime?.resumeQueues();
1453
+ void drainReadHelperQueueOnce();
1454
+ if (teamName) void notifyLeadOfInboxReports(teamName).catch(warnGroup);
1455
+ },
1456
+ detach() {
1457
+ sessionCtx?.ui?.setWidget?.("01-pi-extended-teams-readers", undefined);
1458
+ sessionCtx?.ui?.setWidget?.("01-pi-extended-teams-status", undefined);
1459
+ detachPresentation();
1460
+ combinedSessionCost?.detach();
1461
+ host?.detach();
1462
+ },
1463
+ async close(reason) {
1464
+ teamToolsRuntime.closeAdmission();
1465
+ executionClosing = true;
1466
+ sessionCtx?.ui?.setWidget?.("01-pi-extended-teams-readers", undefined);
1467
+ sessionCtx?.ui?.setWidget?.("01-pi-extended-teams-status", undefined);
1468
+ detachPresentation();
1469
+ host?.close();
1470
+ let timer: ReturnType<typeof setTimeout> | undefined;
1471
+ const admissions = Promise.race([
1472
+ Promise.all([...pendingRootAdmissions]),
1473
+ new Promise<never>((_resolve, reject) => { timer = setTimeout(() => reject(new Error("Team admission cleanup is still pending.")), 2_500); }),
1474
+ ]).finally(() => clearTimeout(timer));
1475
+ await Promise.all([host!.emit("session_shutdown", { reason }, undefined), admissions]);
1476
+ if (runningReadAgents.size) throw new Error("Team cleanup is still fenced by unsettled agents.");
1477
+ },
1478
+ };
1376
1479
  }
@@ -0,0 +1,91 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { TSchema } from "@sinclair/typebox";
3
+ import { Key } from "@mariozechner/pi-tui";
4
+ import { connectReloadOwner, type ReloadOwner } from "./reload-owner";
5
+ import { createTeamHost, type TeamHostBinding } from "./team-host";
6
+ import { registerAgentReportRenderer } from "../events/register-events";
7
+ import { publicTeamToolMetadata } from "../tools/public-team-tools";
8
+ import { registerCoordinationTools, type CoordinationToolsOptions } from "../tools/coordination-tools";
9
+ import { registerTaskRuntimeTools, type TaskRuntimeToolsOptions } from "../tools/task-runtime-tools";
10
+ import { registerFavoriteModelsCommand } from "../ui/favorite-models-command";
11
+ import { registerExtensionsCommand } from "../ui/extensions-command";
12
+ import { registerOnboardingCommand } from "../ui/onboarding-command";
13
+ import { registerCheckpointsCommand } from "../ui/checkpoints-command";
14
+
15
+ export interface TeamExecutionOwner extends ReloadOwner<TeamHostBinding> {
16
+ host: ReturnType<typeof createTeamHost>;
17
+ coordination: CoordinationToolsOptions;
18
+ taskTools: TaskRuntimeToolsOptions;
19
+ activate(): void;
20
+ }
21
+
22
+ export function registerLeadAttachment(pi: ExtensionAPI, createOwner: () => TeamExecutionOwner): void {
23
+ let connection: Awaited<ReturnType<typeof connectReloadOwner<TeamHostBinding>>> | undefined;
24
+ let attaching: Promise<void> | undefined;
25
+ const attach = (ctx: any, event: any): Promise<void> => {
26
+ if (connection) return Promise.resolve();
27
+ return attaching ??= connectReloadOwner(ctx.sessionManager, ctx.sessionManager.getSessionId(), { pi, ctx, event }, createOwner)
28
+ .then(value => { connection = value; execution().activate(); }).finally(() => { attaching = undefined; });
29
+ };
30
+ const execution = (): TeamExecutionOwner => {
31
+ if (!connection) throw new Error("Team execution is not attached to this session.");
32
+ return connection.owner as TeamExecutionOwner;
33
+ };
34
+
35
+ for (const metadata of Object.values(publicTeamToolMetadata)) {
36
+ pi.registerTool<TSchema>({ ...metadata, async execute(id, params, signal, update, ctx) {
37
+ await attach(ctx, { reason: "startup" });
38
+ const owner = execution();
39
+ return owner.host.tools.get(metadata.name).execute(id, params, signal, update, owner.host.snapshot(ctx));
40
+ } });
41
+ }
42
+ // These lead-only adapters carry no running task state. In-process child
43
+ // tools remain owned by the retained controller and its original imports.
44
+ registerCoordinationTools(pi, {
45
+ isTeammate: false, agentName: "team-lead",
46
+ get terminal() { return execution().coordination.terminal; },
47
+ getTeamName: () => execution().coordination.getTeamName(),
48
+ requireWriteAgentTeam: () => execution().coordination.requireWriteAgentTeam(),
49
+ requireTeamContext: name => execution().coordination.requireTeamContext(name),
50
+ releaseAllClaimsForAgent: (...args) => execution().coordination.releaseAllClaimsForAgent(...args),
51
+ drainWriteQueue: name => execution().coordination.drainWriteQueue(name),
52
+ resolveSkillFile: (...args) => execution().coordination.resolveSkillFile(...args),
53
+ adoptTeamAsLead: (name, ctx) => execution().coordination.adoptTeamAsLead(name, ctx && execution().host.snapshot(ctx)),
54
+ renderLeadInboxStatus: () => execution().coordination.renderLeadInboxStatus(),
55
+ resetLeadWakeNotifiedCount: () => execution().coordination.resetLeadWakeNotifiedCount(),
56
+ deliverMessageToActiveAgent: (...args) => execution().coordination.deliverMessageToActiveAgent!(...args),
57
+ });
58
+ registerTaskRuntimeTools(pi, {
59
+ isTeammate: false,
60
+ get terminal() { return execution().taskTools.terminal; },
61
+ get runningReadAgents() { return execution().taskTools.runningReadAgents; },
62
+ readAgentKey: (...args) => execution().taskTools.readAgentKey(...args),
63
+ getTeamName: () => execution().taskTools.getTeamName(),
64
+ interruptTeammate: name => execution().taskTools.interruptTeammate!(name),
65
+ cancelQueuedAgent: (...args) => execution().taskTools.cancelQueuedAgent!(...args),
66
+ shutdownTeammate: (...args) => execution().taskTools.shutdownTeammate(...args),
67
+ });
68
+ registerAgentReportRenderer(pi);
69
+ registerFavoriteModelsCommand(pi);
70
+ registerExtensionsCommand(pi);
71
+ registerOnboardingCommand(pi);
72
+ registerCheckpointsCommand(pi);
73
+ pi.registerShortcut(Key.alt("tab"), {
74
+ description: "Cycle active writer-agent tmux screens",
75
+ handler: ctx => execution().host.shortcuts.get(Key.alt("tab"))?.handler(execution().host.snapshot(ctx)),
76
+ });
77
+
78
+ pi.on("session_start", (event, ctx) => attach(ctx, event));
79
+ pi.on("session_shutdown", async event => {
80
+ const current = connection;
81
+ connection = undefined;
82
+ await current?.shutdown(event.reason ?? "quit");
83
+ });
84
+ for (const name of ["context", "turn_start", "tool_execution_start", "tool_execution_end", "message_end", "turn_end",
85
+ "before_agent_start", "agent_end", "agent_settled", "session_compact", "session_tree"] as const) {
86
+ (pi.on as any)(name, (event: unknown, ctx: unknown) => {
87
+ const owner = execution();
88
+ return owner.host.emit(name, event, owner.host.snapshot(ctx));
89
+ });
90
+ }
91
+ }
@@ -0,0 +1,107 @@
1
+ export interface ReloadOwner<Attachment> {
2
+ attach(attachment: Attachment): void | Promise<void>;
3
+ detach(): void;
4
+ close(reason: string): Promise<void>;
5
+ hasWork(): boolean;
6
+ }
7
+
8
+ interface RetainedOwner {
9
+ owner: ReloadOwner<unknown>;
10
+ state: "attaching" | "attached" | "detached" | "closing";
11
+ generation: number;
12
+ deadline?: number;
13
+ timer?: ReturnType<typeof setTimeout>;
14
+ closing?: Promise<void>;
15
+ }
16
+
17
+ interface Registry {
18
+ version: 1;
19
+ sessions: WeakMap<object, Map<string, RetainedOwner>>;
20
+ }
21
+
22
+ const REGISTRY_KEY = Symbol.for("pi-extended-teams.reload-owners.v1");
23
+ const RECOVERY_WINDOW_MS = 60_000;
24
+
25
+ // Pi re-evaluates modules on reload, but keeps the Node realm and (in the
26
+ // supported host) the root SessionManager. Session IDs alone can collide
27
+ // between two independent hosts opening the same saved conversation.
28
+ function registry(): Registry {
29
+ const globals = globalThis as typeof globalThis & { [REGISTRY_KEY]?: Registry };
30
+ return globals[REGISTRY_KEY] ??= { version: 1, sessions: new WeakMap() };
31
+ }
32
+
33
+ export async function connectReloadOwner<Attachment>(
34
+ manager: object,
35
+ sessionId: string,
36
+ attachment: Attachment,
37
+ createOwner: () => ReloadOwner<Attachment>,
38
+ ): Promise<{ owner: ReloadOwner<Attachment>; shutdown(reason: string): Promise<void> }> {
39
+ const sessions = registry().sessions;
40
+ let owners = sessions.get(manager);
41
+ if (!owners) sessions.set(manager, owners = new Map());
42
+ let record = owners.get(sessionId);
43
+ if (record && record.state !== "detached") {
44
+ throw new Error(`Team execution owner is ${record.state}; refusing a replacement.`);
45
+ }
46
+ if (!record) {
47
+ record = { owner: createOwner() as ReloadOwner<unknown>, state: "attaching", generation: 0 };
48
+ owners.set(sessionId, record);
49
+ }
50
+ const current = record;
51
+ const generation = ++current.generation;
52
+ current.state = "attaching";
53
+
54
+ const close = (reason: string): Promise<void> => {
55
+ if (current.closing) return current.closing;
56
+ current.state = "closing";
57
+ current.generation++;
58
+ clearTimeout(current.timer);
59
+ current.closing = Promise.resolve().then(() => current.owner.close(reason)).then(() => {
60
+ if (owners.get(sessionId) === current) owners.delete(sessionId);
61
+ });
62
+ return current.closing;
63
+ };
64
+ const armDeadline = (): void => {
65
+ clearTimeout(current.timer);
66
+ const epoch = current.generation;
67
+ current.timer = setTimeout(() => {
68
+ if (current.generation !== epoch || current.state === "attached") return;
69
+ // A failed cleanup remains in the registry as a fence. Durable lifecycle
70
+ // records retain the cleanup evidence; do not silently replace this owner.
71
+ void close("reload-timeout").catch(() => {});
72
+ }, Math.max(0, (current.deadline ?? Date.now()) - Date.now()));
73
+ current.timer.unref?.();
74
+ };
75
+
76
+ const isClosing = () => current.state === "closing";
77
+ if (current.deadline !== undefined) armDeadline();
78
+ try {
79
+ await current.owner.attach(attachment);
80
+ if (current.generation !== generation || current.state !== "attaching") {
81
+ throw new Error("Team reload attachment expired while reconnecting.");
82
+ }
83
+ clearTimeout(current.timer);
84
+ current.deadline = undefined;
85
+ current.state = "attached";
86
+ } catch (error) {
87
+ if (!isClosing()) {
88
+ current.owner.detach();
89
+ current.state = "detached";
90
+ current.deadline ??= Date.now() + RECOVERY_WINDOW_MS;
91
+ armDeadline();
92
+ }
93
+ throw error;
94
+ }
95
+
96
+ return {
97
+ owner: current.owner as ReloadOwner<Attachment>,
98
+ async shutdown(reason) {
99
+ if (current.generation !== generation || current.state !== "attached") return;
100
+ if (reason !== "reload" || !current.owner.hasWork()) return close(reason);
101
+ current.owner.detach();
102
+ current.state = "detached";
103
+ current.deadline = Date.now() + RECOVERY_WINDOW_MS;
104
+ armDeadline();
105
+ },
106
+ };
107
+ }