pi-extended-teams 2.1.16

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.
Files changed (130) hide show
  1. package/README.md +105 -0
  2. package/assets/pi-extended-teams-agent-navigation.png +0 -0
  3. package/assets/pi-extended-teams-in-action.png +0 -0
  4. package/extensions/agents/read-agent-report.ts +181 -0
  5. package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
  6. package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
  7. package/extensions/agents/read-agent.test.ts +3077 -0
  8. package/extensions/agents/read-agent.ts +1119 -0
  9. package/extensions/agents/write-agent.test.ts +513 -0
  10. package/extensions/agents/write-agent.ts +392 -0
  11. package/extensions/events/register-events.test.ts +465 -0
  12. package/extensions/events/register-events.ts +409 -0
  13. package/extensions/index.test.ts +1659 -0
  14. package/extensions/index.ts +1228 -0
  15. package/extensions/internal/agent-session-files.test.ts +164 -0
  16. package/extensions/internal/agent-session-files.ts +320 -0
  17. package/extensions/internal/debug.ts +44 -0
  18. package/extensions/internal/model-selection.ts +82 -0
  19. package/extensions/internal/pi-command.test.ts +191 -0
  20. package/extensions/internal/pi-command.ts +224 -0
  21. package/extensions/internal/pi-runtime-api.test.ts +43 -0
  22. package/extensions/internal/pi-runtime-api.ts +78 -0
  23. package/extensions/internal/schema.ts +19 -0
  24. package/extensions/internal/session-context-reference.test.ts +290 -0
  25. package/extensions/internal/session-context-reference.ts +430 -0
  26. package/extensions/internal/session-files.test.ts +225 -0
  27. package/extensions/internal/session-files.ts +273 -0
  28. package/extensions/internal/session-usage.ts +59 -0
  29. package/extensions/resources/spawn-resource-plan.test.ts +233 -0
  30. package/extensions/resources/spawn-resource-plan.ts +247 -0
  31. package/extensions/runtime/active-agent-sleep.test.ts +157 -0
  32. package/extensions/runtime/active-agent-sleep.ts +117 -0
  33. package/extensions/runtime/nested-read-agents.ts +22 -0
  34. package/extensions/runtime/pending-child-controller.test.ts +169 -0
  35. package/extensions/runtime/pending-child-controller.ts +280 -0
  36. package/extensions/runtime/types.ts +68 -0
  37. package/extensions/team/contracts.test.ts +111 -0
  38. package/extensions/team/lifecycle.test.ts +1095 -0
  39. package/extensions/team/lifecycle.ts +502 -0
  40. package/extensions/team/recipient-closure.test.ts +97 -0
  41. package/extensions/team/recipient-closure.ts +120 -0
  42. package/extensions/team/roster.test.ts +129 -0
  43. package/extensions/team/roster.ts +165 -0
  44. package/extensions/team/team-contracts.json +43 -0
  45. package/extensions/team/writer-screens.test.ts +50 -0
  46. package/extensions/team/writer-screens.ts +156 -0
  47. package/extensions/tools/agent-communication-tools.test.ts +306 -0
  48. package/extensions/tools/agent-communication-tools.ts +183 -0
  49. package/extensions/tools/coordination-tools.test.ts +670 -0
  50. package/extensions/tools/coordination-tools.ts +304 -0
  51. package/extensions/tools/delegation-guard.test.ts +95 -0
  52. package/extensions/tools/delegation-guard.ts +65 -0
  53. package/extensions/tools/file-claim-tools.test.ts +104 -0
  54. package/extensions/tools/file-claim-tools.ts +68 -0
  55. package/extensions/tools/model-tools.ts +53 -0
  56. package/extensions/tools/predefined-tools.test.ts +647 -0
  57. package/extensions/tools/predefined-tools.ts +331 -0
  58. package/extensions/tools/read-helper.test.ts +35 -0
  59. package/extensions/tools/task-runtime-tools.test.ts +334 -0
  60. package/extensions/tools/task-runtime-tools.ts +227 -0
  61. package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
  62. package/extensions/tools/team-tools.ts +1270 -0
  63. package/extensions/ui/agent-follow-view.test.ts +520 -0
  64. package/extensions/ui/agent-follow-view.ts +779 -0
  65. package/extensions/ui/agent-navigation.test.ts +52 -0
  66. package/extensions/ui/agent-navigation.ts +55 -0
  67. package/extensions/ui/ansi.ts +16 -0
  68. package/extensions/ui/extensions-command.test.ts +336 -0
  69. package/extensions/ui/extensions-command.ts +282 -0
  70. package/extensions/ui/favorite-models-command.test.ts +280 -0
  71. package/extensions/ui/favorite-models-command.ts +423 -0
  72. package/extensions/ui/frame.ts +77 -0
  73. package/extensions/ui/input.ts +17 -0
  74. package/extensions/ui/read-agent-status.test.ts +117 -0
  75. package/extensions/ui/read-agent-status.ts +125 -0
  76. package/extensions/ui/renderers.ts +243 -0
  77. package/extensions/ui/status-widget.test.ts +143 -0
  78. package/extensions/ui/status-widget.ts +440 -0
  79. package/extensions/ui-frame.test.ts +69 -0
  80. package/package.json +75 -0
  81. package/skills/teams.md +256 -0
  82. package/src/adapters/terminal-registry.ts +78 -0
  83. package/src/adapters/tmux-adapter.test.ts +276 -0
  84. package/src/adapters/tmux-adapter.ts +200 -0
  85. package/src/orchestration/index.ts +437 -0
  86. package/src/orchestration/orchestrator.test.ts +396 -0
  87. package/src/orchestration/types.ts +125 -0
  88. package/src/utils/atomic-json.ts +21 -0
  89. package/src/utils/claims.test.ts +137 -0
  90. package/src/utils/claims.ts +169 -0
  91. package/src/utils/hooks.test.ts +75 -0
  92. package/src/utils/hooks.ts +35 -0
  93. package/src/utils/lifecycle-tombstone.test.ts +105 -0
  94. package/src/utils/lifecycle-tombstone.ts +268 -0
  95. package/src/utils/lock.race.child.ts +44 -0
  96. package/src/utils/lock.race.test.ts +198 -0
  97. package/src/utils/lock.test.ts +90 -0
  98. package/src/utils/lock.ts +186 -0
  99. package/src/utils/messaging.test.ts +337 -0
  100. package/src/utils/messaging.ts +441 -0
  101. package/src/utils/model-resolution.test.ts +231 -0
  102. package/src/utils/model-resolution.ts +322 -0
  103. package/src/utils/models.test.ts +8 -0
  104. package/src/utils/models.ts +115 -0
  105. package/src/utils/paths.ts +81 -0
  106. package/src/utils/predefined-teams/types.ts +48 -0
  107. package/src/utils/predefined-teams.save-template.test.ts +74 -0
  108. package/src/utils/predefined-teams.test.ts +441 -0
  109. package/src/utils/predefined-teams.ts +471 -0
  110. package/src/utils/read-helper-queue.ts +99 -0
  111. package/src/utils/report-events.test.ts +154 -0
  112. package/src/utils/report-events.ts +222 -0
  113. package/src/utils/runtime.test.ts +314 -0
  114. package/src/utils/runtime.ts +261 -0
  115. package/src/utils/security.test.ts +43 -0
  116. package/src/utils/settings.test.ts +480 -0
  117. package/src/utils/settings.ts +645 -0
  118. package/src/utils/shared-memory.test.ts +80 -0
  119. package/src/utils/shared-memory.ts +81 -0
  120. package/src/utils/tasks.race.test.ts +44 -0
  121. package/src/utils/tasks.test.ts +229 -0
  122. package/src/utils/tasks.ts +396 -0
  123. package/src/utils/teams.ts +231 -0
  124. package/src/utils/terminal-adapter.ts +103 -0
  125. package/src/utils/thinking-levels.test.ts +56 -0
  126. package/src/utils/thinking-levels.ts +47 -0
  127. package/src/utils/workflow-metadata.test.ts +11 -0
  128. package/src/utils/workflow-metadata.ts +88 -0
  129. package/src/utils/write-queue.test.ts +251 -0
  130. package/src/utils/write-queue.ts +202 -0
@@ -0,0 +1,1228 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { type ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+ import { findLeadTeamForSession, getPiSessionId, registerLeadSession, resolveSkillFile } from "./internal/session-files.js";
5
+ import { buildReadAgentIdleNudgeMessage, describeReadAgentStatus, shouldNudgeReadAgentIdle, type ReadAgentStatusDescription } from "./ui/read-agent-status.js";
6
+ import { teamActivityStatusWidget, type TeamActivityStatusEntry, type TeamActivityStatusSnapshot } from "./ui/status-widget.js";
7
+ import { runReadAgentInProcess, sendMessageToRunningReadAgent } from "./agents/read-agent.js";
8
+ import { createWriteAgentRuntime } from "./agents/write-agent.js";
9
+ import { registerExtensionEvents } from "./events/register-events.js";
10
+ import { createLifecycleRuntime } from "./team/lifecycle.js";
11
+ import { buildRoster as buildTeamRoster, formatRosterForPrompt, releaseAllClaimsForAgent, requireTeamContext as resolveTeamContext, requireWriteAgentTeam as resolveWriteAgentTeam } from "./team/roster.js";
12
+ import { createWriterScreenState, registerWriterScreenShortcut, removeWriterScreenTab, upsertWriterScreenTab, type ActiveWriterTab } from "./team/writer-screens.js";
13
+ import { registerFavoriteModelsCommand } from "./ui/favorite-models-command.js";
14
+ import { registerExtensionsCommand } from "./ui/extensions-command.js";
15
+ import { installAgentNavigation } from "./ui/agent-navigation.js";
16
+ import { buildReadHelperPrompt, registerCoordinationTools } from "./tools/coordination-tools.js";
17
+ import { createReportProgressTool } from "./tools/agent-communication-tools.js";
18
+ import { registerTaskRuntimeTools } from "./tools/task-runtime-tools.js";
19
+ import { registerTeamTools, type TeamToolsRuntime } from "./tools/team-tools.js";
20
+ import { canonicalPersistedModelSlot, loadSettings, requireFavoriteModelLevel } from "../src/utils/settings";
21
+ import { formatAnimatedProgress, formatContextUsage, formatElapsed, formatModelLabel } from "./ui/renderers.js";
22
+ import type { CompletedAgentReport, RunningReadAgent } from "./runtime/types.js";
23
+ import { createPendingChildController } from "./runtime/pending-child-controller.js";
24
+ import { createActiveAgentSleepController, runWithActiveAgentSleepAssertion } from "./runtime/active-agent-sleep.js";
25
+ export { panelBgFill, framePanel, frameWidget, frameWidgetFullWidth, logWindowStart } from "./ui/frame.js";
26
+ import * as messaging from "../src/utils/messaging";
27
+ import * as teams from "../src/utils/teams";
28
+ import * as runtime from "../src/utils/runtime";
29
+ import * as teamPaths from "../src/utils/paths";
30
+ import { listReadHelperQueue, removeQueuedReadHelperRequest } from "../src/utils/read-helper-queue";
31
+ import type { Member } from "../src/utils/models";
32
+ import { getTerminalAdapter } from "../src/adapters/terminal-registry";
33
+ import { createSpawnResourcePlan, parentProjectTrustForSpawn } from "./resources/spawn-resource-plan.js";
34
+ import { generateExtensionInstanceId, listLifecycleTombstones, onLifecycleTombstoneCleared, readLifecycleTombstone } from "../src/utils/lifecycle-tombstone";
35
+
36
+ export default function (pi: ExtensionAPI) {
37
+ const isTeammate = !!process.env.PI_AGENT_NAME;
38
+ const agentName = process.env.PI_AGENT_NAME || "team-lead";
39
+ const extensionInstanceId = generateExtensionInstanceId();
40
+ const envTeamName = process.env.PI_TEAM_NAME;
41
+
42
+ // Teammates are explicitly bound by PI_TEAM_NAME. Lead sessions are adopted
43
+ // later at session_start only when the persisted Pi session id matches.
44
+ let teamName = envTeamName;
45
+
46
+ const terminal = getTerminalAdapter();
47
+ const activeWritersTabs: ActiveWriterTab[] = [];
48
+ const writerScreenState = createWriterScreenState(activeWritersTabs);
49
+
50
+ // Track whether lead inbox/helper polling has been started (to avoid duplicates)
51
+ let leadPollingStarted = false;
52
+ let leadInboxPollTimer: NodeJS.Timeout | null = null;
53
+ let readHelperQueueWatchedTeam: string | null = null;
54
+ let readHelperQueueWatcher: fs.FSWatcher | null = null;
55
+ let readHelperQueueFallbackStarted = false;
56
+ let readHelperQueueFallbackTimer: NodeJS.Timeout | null = null;
57
+ let readHelperQueueDraining = false;
58
+ let readHelperQueueDrainPending = false;
59
+ let sessionCtx: any = null;
60
+ let extensionShuttingDown = false;
61
+ const lifecycleFenceClearUnsubscribe = onLifecycleTombstoneCleared((clearedTeamName) => {
62
+ if (clearedTeamName === teamName) void drainReadHelperQueueOnce();
63
+ });
64
+ const runningReadAgents = new Map<string, RunningReadAgent>();
65
+ const completedAgentReports = new Map<string, CompletedAgentReport[]>();
66
+ const TEAM_ACTIVITY_RENDER_DEBOUNCE_MS = 75;
67
+ let readAgentStatusTimer: NodeJS.Timeout | null = null;
68
+ let teamActivityStatusSnapshot: TeamActivityStatusSnapshot | null = null;
69
+ let teamActivityStatusSnapshotSignature: string | null = null;
70
+ let teamActivityWidgetMounted = false;
71
+ let teamActivityWidgetTui: { requestRender?: () => void } | null = null;
72
+ let teamActivityRenderInFlight = false;
73
+ let teamActivityRenderDirty = false;
74
+ let teamActivityRenderTimer: NodeJS.Timeout | null = null;
75
+ const teamActivityRenderWaiters: Array<{ resolve(): void; reject(error: unknown): void }> = [];
76
+ let activePromptBuildTeamName: string | null = null;
77
+ let leadInboxWidgetCleared = false;
78
+ let leadInboxUnreadCount = 0;
79
+ // Message identities already injected into the lead prompt. Tracking identity
80
+ // instead of unread count prevents a read message being replaced by a new one
81
+ // at the same count from silently suppressing the new wake.
82
+ const leadWakeNotifiedMessageKeys = new Set<string>();
83
+ const createCurrentSpawnResourcePlan = (input: { cwd: string; projectTrusted: boolean }) => {
84
+ return createSpawnResourcePlan({ ...input, pi });
85
+ };
86
+ let teamToolsRuntime: TeamToolsRuntime | undefined;
87
+ const pendingChildController = createPendingChildController();
88
+ const activeAgentSleepController = createActiveAgentSleepController();
89
+ const { startWriteAgent, drainWriteQueue, releaseWriteAgentSleepAssertion } = createWriteAgentRuntime({
90
+ terminal,
91
+ getProjectTrusted: (cwd) => parentProjectTrustForSpawn(sessionCtx, cwd),
92
+ createResourcePlan: createCurrentSpawnResourcePlan,
93
+ sleepController: activeAgentSleepController,
94
+ onWriterActive: (tab) => upsertWriterScreenTab(writerScreenState, tab),
95
+ onWriterInactive: (targetTeamName, member) => removeWriterScreenTab(writerScreenState, { teamName: targetTeamName, name: member.name, paneId: member.tmuxPaneId }),
96
+ });
97
+ const { shutdownTeammate, startLeadWatchdog, stopLeadWatchdog } = createLifecycleRuntime({
98
+ isTeammate,
99
+ terminal,
100
+ runningReadAgents,
101
+ readAgentKey,
102
+ isCurrentReadAgentRun,
103
+ renderReadAgentStatus,
104
+ drainWriteQueue,
105
+ getSessionCwd: () => sessionCtx?.cwd,
106
+ getTeamName: () => teamName,
107
+ extensionInstanceId,
108
+ onWriterInactive: (targetTeamName, member) => {
109
+ releaseWriteAgentSleepAssertion(targetTeamName, member);
110
+ removeWriterScreenTab(writerScreenState, { teamName: targetTeamName, name: member.name, paneId: member.tmuxPaneId });
111
+ },
112
+ onTeammateClosing: (targetTeamName, member) => {
113
+ if (!member.lifecycleRunId) return;
114
+ pendingChildController.cancelParent({
115
+ teamName: targetTeamName,
116
+ parentName: member.name,
117
+ parentRunId: member.lifecycleRunId,
118
+ });
119
+ },
120
+ onTeammateSettled: (targetTeamName, member) => {
121
+ if (!member.lifecycleRunId) return;
122
+ if (member.parentAgentName && member.parentLifecycleRunId) {
123
+ pendingChildController.settleChildRun({
124
+ teamName: targetTeamName,
125
+ parentName: member.parentAgentName,
126
+ parentRunId: member.parentLifecycleRunId,
127
+ childName: member.name,
128
+ childRunId: member.lifecycleRunId,
129
+ });
130
+ }
131
+ pendingChildController.forgetParent({
132
+ teamName: targetTeamName,
133
+ parentName: member.name,
134
+ parentRunId: member.lifecycleRunId,
135
+ });
136
+ },
137
+ });
138
+
139
+ function readAgentKey(targetTeamName: string, targetAgentName: string): string {
140
+ return `${targetTeamName}:${targetAgentName}`;
141
+ }
142
+
143
+ function runInProcessAgentWithSleepAssertion(
144
+ ...args: Parameters<typeof runReadAgentInProcess>
145
+ ): ReturnType<typeof runReadAgentInProcess> {
146
+ return runWithActiveAgentSleepAssertion(activeAgentSleepController, () => {
147
+ return runReadAgentInProcess(...args);
148
+ });
149
+ }
150
+
151
+ function observeReadAgentLaunch(launch: Promise<void> | void): void {
152
+ // The runner reports its own terminal failures and owns teardown. This
153
+ // observer exists only to terminate the fire-and-forget rejection chain.
154
+ void Promise.resolve(launch).catch(() => {});
155
+ }
156
+
157
+ async function deliverMessageToActiveAgent(
158
+ targetTeamName: string,
159
+ targetAgentName: string,
160
+ content: string,
161
+ expectedRecipientRunId?: string
162
+ ): Promise<boolean> {
163
+ const target = runningReadAgents.get(readAgentKey(targetTeamName, targetAgentName));
164
+ if (expectedRecipientRunId !== undefined && target?.runId !== expectedRecipientRunId) return false;
165
+ const delivered = await sendMessageToRunningReadAgent(target, content);
166
+ if (delivered) renderReadAgentStatus();
167
+ return delivered;
168
+ }
169
+
170
+ function memberActivityRole(member: Member): "read" | "write" {
171
+ if (member.role === "read" || member.role === "write") return member.role;
172
+ return member.modelSlot?.startsWith("reading-") ? "read" : "write";
173
+ }
174
+
175
+ function isNestedReadHelperMember(member: Member | undefined): boolean {
176
+ return !!member
177
+ && memberActivityRole(member) === "read"
178
+ && member.delegationDepth === 1
179
+ && member.helperKind === "read_helper";
180
+ }
181
+
182
+ function readAgentHasLastError(agent: RunningReadAgent): boolean {
183
+ return !!agent.lastError;
184
+ }
185
+
186
+ function countActiveNestedReadHelpersByWriter(
187
+ members: Member[],
188
+ activeReadMemberNames: Set<string>,
189
+ excludedReadMemberNames: Set<string> = new Set()
190
+ ): Map<string, number> {
191
+ const membersByName = new Map(members.map(member => [member.name, member]));
192
+ const counts = new Map<string, number>();
193
+
194
+ for (const child of members) {
195
+ const writerName = child.requestedBy;
196
+ if (!activeReadMemberNames.has(child.name)
197
+ || excludedReadMemberNames.has(child.name)
198
+ || !isNestedReadHelperMember(child)
199
+ || !writerName
200
+ || child.parentAgentName !== writerName
201
+ || !child.parentLifecycleRunId) continue;
202
+
203
+ const parent = membersByName.get(writerName);
204
+ if (!parent
205
+ || memberActivityRole(parent) !== "write"
206
+ || parent.lifecycleRunId !== child.parentLifecycleRunId) continue;
207
+
208
+ counts.set(writerName, (counts.get(writerName) ?? 0) + 1);
209
+ }
210
+
211
+ return counts;
212
+ }
213
+
214
+ function runtimeHeartbeatIsRecent(status: runtime.AgentRuntimeStatus, now: number): boolean {
215
+ return !!status.lastHeartbeatAt && (now - status.lastHeartbeatAt) <= runtime.HEARTBEAT_STALE_MS;
216
+ }
217
+
218
+ function hasActiveReadAgentLifecycle(agent: RunningReadAgent): boolean {
219
+ return agent.teardownState !== "stopping"
220
+ && agent.teardownState !== "quarantined"
221
+ && agent.teardownState !== "persistence_failed"
222
+ && agent.teardownState !== "finalized";
223
+ }
224
+
225
+ function isVisibleRuntimeOnlyMember(
226
+ member: Member,
227
+ runtimeStatus: runtime.AgentRuntimeStatus | null,
228
+ now: number
229
+ ): runtimeStatus is runtime.AgentRuntimeStatus {
230
+ return member.isActive !== false
231
+ && runtimeStatus?.ready === true
232
+ && runtimeHeartbeatIsRecent(runtimeStatus, now);
233
+ }
234
+
235
+ function runtimeOnlyStatusLabel(status: runtime.AgentRuntimeStatus, now: number): string {
236
+ if (!runtimeHeartbeatIsRecent(status, now)) return "stale";
237
+ if (status.currentAction && status.currentAction !== "done") return status.currentAction;
238
+ return status.ready ? "ready" : "running";
239
+ }
240
+
241
+ function runtimeOnlyActionLabel(status: runtime.AgentRuntimeStatus): string {
242
+ if (status.activeToolName) return `${status.currentAction || "working"}: ${status.activeToolName}`;
243
+ return status.currentAction || (status.ready ? "ready" : "running");
244
+ }
245
+
246
+ function runtimeOnlyHeartbeatDetail(status: runtime.AgentRuntimeStatus, now: number): string {
247
+ return runtimeHeartbeatIsRecent(status, now) ? "" : "heartbeat stale";
248
+ }
249
+
250
+ function formatRunningContextUsage(usage: runtime.ContextUsageSnapshot | undefined): string {
251
+ return formatContextUsage(usage ?? runtime.initialContextUsage());
252
+ }
253
+
254
+ function formatAgentProgressStatus(
255
+ name: string,
256
+ model: string | undefined,
257
+ thinking: string | undefined,
258
+ modelSlot: string | undefined,
259
+ elapsed: string,
260
+ contextUsage: string,
261
+ latestProgress: string | undefined,
262
+ now: number,
263
+ activeReadHelperCount = 0
264
+ ): string {
265
+ const shortModel = model ? model.split("/").pop() || model : "";
266
+ const modelAndThinking = [shortModel, thinking].filter(Boolean).join("/");
267
+ const identity = modelAndThinking ? `(${name}) ${modelAndThinking}` : `(${name})`;
268
+ const helperSuffix = activeReadHelperCount > 0 ? `+${activeReadHelperCount}` : undefined;
269
+ const progress = latestProgress ? formatAnimatedProgress(latestProgress, now) : undefined;
270
+ return [identity, helperSuffix, modelSlot, elapsed, contextUsage, progress].filter(Boolean).join(" · ");
271
+ }
272
+
273
+ function refreshInProcessAgentUsage(agent: RunningReadAgent, now: number): void {
274
+ try {
275
+ const session = agent.session;
276
+ const stats = session?.getSessionStats();
277
+ if (!stats) return;
278
+ if (stats.tokens.total !== agent.tokensUsed) {
279
+ agent.tokensUsed = stats.tokens.total;
280
+ agent.lastActivityAt = now;
281
+ agent.idleNudgeLevel = undefined;
282
+ }
283
+ const contextUsage = stats.contextUsage ?? session?.getContextUsage?.();
284
+ agent.contextUsage = stats.tokens.total > 0
285
+ ? contextUsage
286
+ : runtime.initialContextUsage(contextUsage?.contextWindow);
287
+ } catch {
288
+ // Ignore stats races while the nested session is shutting down.
289
+ }
290
+ }
291
+
292
+ function currentSessionAgentGroupName(ctx?: any): string | undefined {
293
+ const sessionId = getPiSessionId(ctx ?? sessionCtx) || "local-session";
294
+ return teamPaths.sanitizeName(`session-${sessionId}`);
295
+ }
296
+
297
+ function isCurrentReadAgentRun(key: string, state: RunningReadAgent): boolean {
298
+ return runningReadAgents.get(key) === state;
299
+ }
300
+
301
+ function rememberCompletedAgentReport(teamName: string, report: CompletedAgentReport): void {
302
+ const current = completedAgentReports.get(teamName) ?? [];
303
+ const dedupeKey = `${report.source}:${report.name}:${report.completedAt}:${report.summary || ""}`;
304
+ const next = [
305
+ ...current.filter((item) => `${item.source}:${item.name}:${item.completedAt}:${item.summary || ""}` !== dedupeKey),
306
+ report,
307
+ ].sort((a, b) => b.completedAt - a.completedAt).slice(0, 50);
308
+ completedAgentReports.set(teamName, next);
309
+ }
310
+
311
+ // Quietly nudge this agent's loop without cluttering the transcript. A custom
312
+ // message with display:false still reaches the model as a user turn (see
313
+ // convertToLlm) but is never rendered, so team coordination stays silent.
314
+ // Falls back to a visible user message on older pi builds without sendMessage.
315
+ function quietTrigger(content: string): void {
316
+ const api = pi as any;
317
+ if (typeof api.sendMessage === "function") {
318
+ api.sendMessage(
319
+ { customType: "pi-extended-teams-wake", content, display: false },
320
+ { triggerTurn: true, deliverAs: "followUp" }
321
+ );
322
+ } else {
323
+ pi.sendUserMessage(content);
324
+ }
325
+ }
326
+
327
+ // Deliver a finished agent's report into the lead's context without adding
328
+ // the full body to the transcript. Hidden custom messages still participate
329
+ // in LLM context (see convertToLlm), and triggerTurn makes the lead synthesize
330
+ // the report automatically — no read_inbox, no manual polling.
331
+ function emitAgentReport(reportTeamName: string, name: string, startedAt: number, tokens: number, report: string, ok: boolean, suppressLeadInjection = false): void {
332
+ const api = pi as any;
333
+ const details = { name, elapsedMs: Date.now() - startedAt, tokens, ok };
334
+ pi.events?.emit?.("pi-extended-teams:agent-report", { teamName: reportTeamName, name, startedAt, tokens, report, ok, details });
335
+
336
+ // pi-prompt prompt-build teams are extension-controlled fanout jobs. Their
337
+ // reports are consumed by pi-prompt via the event above and must not be
338
+ // injected as visible user turns, otherwise the main agent starts answering
339
+ // each branch report and can ask unrelated follow-up questions.
340
+ if (suppressLeadInjection || reportTeamName.startsWith("prompt-build-")) return;
341
+
342
+ if (typeof api.sendMessage === "function") {
343
+ api.sendMessage(
344
+ { customType: "pi-extended-teams-report", content: report, display: false, details },
345
+ { triggerTurn: true, deliverAs: "followUp" }
346
+ );
347
+ } else {
348
+ pi.sendUserMessage(`Report from ${name}:\n${report}`);
349
+ }
350
+ }
351
+
352
+ function emitAgentProgress(progressTeamName: string, name: string, status: string, updatedAt: number): void {
353
+ pi.events?.emit?.("pi-extended-teams:agent-progress", { teamName: progressTeamName, name, status, updatedAt });
354
+ }
355
+
356
+ function wakeLeadForInboxReports(unread: any[]): void {
357
+ if (!teamName) return;
358
+ const keyedUnread = unread.map((message) => ({
359
+ message,
360
+ key: String(message?.id || `${message?.from || "unknown"}:${message?.timestamp || ""}:${message?.text || ""}`),
361
+ }));
362
+ const currentKeys = new Set(keyedUnread.map(({ key }) => key));
363
+ for (const key of leadWakeNotifiedMessageKeys) {
364
+ if (!currentKeys.has(key)) leadWakeNotifiedMessageKeys.delete(key);
365
+ }
366
+ const fresh = keyedUnread.filter(({ key }) => {
367
+ if (leadWakeNotifiedMessageKeys.has(key)) return false;
368
+ leadWakeNotifiedMessageKeys.add(key);
369
+ return true;
370
+ });
371
+ if (fresh.length === 0) return;
372
+
373
+ // Always enqueue a real lead prompt, including while the current turn is busy.
374
+ // `quietTrigger` uses a follow-up user turn so Pi delivers it after the current
375
+ // turn instead of relying on another poll or the user typing manually.
376
+ const label = fresh.length === 1 ? "1 new agent message" : `${fresh.length} new agent messages`;
377
+ const hasFinalReport = fresh.some(({ message }) => message?.metadata?.finalReport === true);
378
+ const opening = hasFinalReport
379
+ ? `A teammate finished and sent its final report: ${label} is ready in the ${teamName} inbox.`
380
+ : `Check your agent messages now: ${label} is ready in the ${teamName} inbox.`;
381
+ quietTrigger(
382
+ `${opening} Call read_inbox once, integrate the new information, and continue the active task. The reporting agent is self-exiting; do not call stop_teammate after a final report. This is an automatic user-style follow-up; do not sleep or poll.`
383
+ );
384
+ }
385
+
386
+ async function notifyLeadOfInboxReports(targetTeamName: string): Promise<void> {
387
+ if (isTeammate) return;
388
+ const unread = await messaging.peekInbox(targetTeamName, agentName, true);
389
+ await renderLeadInboxStatus();
390
+ wakeLeadForInboxReports(unread);
391
+ }
392
+
393
+ function getIdleReadAgentNudgeMessage(agent: RunningReadAgent, status: ReadAgentStatusDescription): string | null {
394
+ if (status.idleLevel === "none") return null;
395
+ if (!shouldNudgeReadAgentIdle(agent.idleNudgeLevel, status.idleLevel)) return null;
396
+ if (sessionCtx?.isIdle && !sessionCtx.isIdle()) return null;
397
+
398
+ agent.idleNudgeLevel = status.idleLevel;
399
+ return buildReadAgentIdleNudgeMessage(agent, status);
400
+ }
401
+
402
+ function wakeLeadForIdleReadAgents(messages: string[]): void {
403
+ if (messages.length === 0) return;
404
+ if (messages.length === 1) {
405
+ quietTrigger(messages[0]);
406
+ return;
407
+ }
408
+
409
+ quietTrigger([
410
+ `${messages.length} read agents need attention:`,
411
+ ...messages.map((message) => `- ${message}`),
412
+ ].join("\n"));
413
+ }
414
+
415
+ function clearLeadInboxWidgetOnce(): void {
416
+ if (leadInboxWidgetCleared) return;
417
+ leadInboxWidgetCleared = true;
418
+ sessionCtx?.ui?.setWidget?.("02-pi-extended-teams-inbox", undefined);
419
+ }
420
+
421
+ function renderReadAgentStatus() {
422
+ markTeamActivityStatusDirty();
423
+ }
424
+
425
+ function waitForTeamActivityRender(): Promise<void> {
426
+ return new Promise((resolve, reject) => teamActivityRenderWaiters.push({ resolve, reject }));
427
+ }
428
+
429
+ function settleTeamActivityRenderWaiters(error?: unknown): void {
430
+ const waiters = teamActivityRenderWaiters.splice(0);
431
+ for (const waiter of waiters) {
432
+ if (error) waiter.reject(error);
433
+ else waiter.resolve();
434
+ }
435
+ }
436
+
437
+ function queueTeamActivityStatusRender(delayMs = TEAM_ACTIVITY_RENDER_DEBOUNCE_MS): void {
438
+ if (extensionShuttingDown || teamActivityRenderTimer || teamActivityRenderInFlight) return;
439
+ teamActivityRenderTimer = setTimeout(() => {
440
+ teamActivityRenderTimer = null;
441
+ void flushTeamActivityStatusRender();
442
+ }, Math.max(0, delayMs));
443
+ }
444
+
445
+ function markTeamActivityStatusDirty(delayMs = TEAM_ACTIVITY_RENDER_DEBOUNCE_MS): void {
446
+ if (extensionShuttingDown) return;
447
+ teamActivityRenderDirty = true;
448
+ queueTeamActivityStatusRender(delayMs);
449
+ }
450
+
451
+ async function renderTeamActivityStatus(): Promise<void> {
452
+ if (extensionShuttingDown) return;
453
+ teamActivityRenderDirty = true;
454
+ if (teamActivityRenderTimer) {
455
+ clearTimeout(teamActivityRenderTimer);
456
+ teamActivityRenderTimer = null;
457
+ }
458
+ const promise = waitForTeamActivityRender();
459
+ void flushTeamActivityStatusRender();
460
+ return promise;
461
+ }
462
+
463
+ async function flushTeamActivityStatusRender(): Promise<void> {
464
+ if (teamActivityRenderInFlight) return;
465
+ if (!teamActivityRenderDirty) {
466
+ settleTeamActivityRenderWaiters();
467
+ return;
468
+ }
469
+
470
+ teamActivityRenderInFlight = true;
471
+ let renderError: unknown;
472
+ try {
473
+ teamActivityRenderDirty = false;
474
+ await renderTeamActivityStatusNow();
475
+ } catch (error) {
476
+ renderError = error;
477
+ } finally {
478
+ teamActivityRenderInFlight = false;
479
+ if (renderError) {
480
+ teamActivityRenderDirty = false;
481
+ settleTeamActivityRenderWaiters(renderError);
482
+ } else if (teamActivityRenderDirty) {
483
+ queueTeamActivityStatusRender();
484
+ } else {
485
+ settleTeamActivityRenderWaiters();
486
+ }
487
+ }
488
+ }
489
+
490
+ function isTeamActivityExpanded(): boolean {
491
+ return !!sessionCtx?.ui?.getToolsExpanded?.();
492
+ }
493
+
494
+ function mountTeamActivityWidget(): void {
495
+ if (teamActivityWidgetMounted || !sessionCtx?.ui?.setWidget) return;
496
+ teamActivityWidgetMounted = true;
497
+ sessionCtx.ui.setWidget(
498
+ "01-pi-extended-teams-readers",
499
+ (tui: any) => {
500
+ teamActivityWidgetTui = tui;
501
+ return teamActivityStatusWidget(
502
+ () => teamActivityStatusSnapshot,
503
+ isTeamActivityExpanded,
504
+ () => tui.requestRender?.()
505
+ );
506
+ },
507
+ { placement: "belowEditor" }
508
+ );
509
+ }
510
+
511
+ function getTeamActivitySnapshotSignature(snapshot: TeamActivityStatusSnapshot): string {
512
+ return JSON.stringify({
513
+ activeCount: snapshot.activeCount,
514
+ readCount: snapshot.readCount,
515
+ writeCount: snapshot.writeCount,
516
+ unreadCount: snapshot.unreadCount,
517
+ statusCounts: snapshot.statusCounts ?? {},
518
+ entries: snapshot.entries,
519
+ });
520
+ }
521
+
522
+ function updateTeamActivityWidget(snapshot: TeamActivityStatusSnapshot): void {
523
+ const nextSignature = getTeamActivitySnapshotSignature(snapshot);
524
+ const changed = nextSignature !== teamActivityStatusSnapshotSignature;
525
+ teamActivityStatusSnapshot = snapshot;
526
+ teamActivityStatusSnapshotSignature = nextSignature;
527
+ mountTeamActivityWidget();
528
+ if (changed) teamActivityWidgetTui?.requestRender?.();
529
+ }
530
+
531
+ function clearTeamActivityWidget(): void {
532
+ teamActivityStatusSnapshot = null;
533
+ teamActivityStatusSnapshotSignature = null;
534
+ teamActivityWidgetMounted = false;
535
+ teamActivityWidgetTui = null;
536
+ sessionCtx?.ui?.setWidget?.("01-pi-extended-teams-readers", undefined);
537
+ }
538
+
539
+ async function renderTeamActivityStatusNow() {
540
+ if (!sessionCtx?.ui) return;
541
+
542
+ const now = Date.now();
543
+ const activityTeamName = teamName || null;
544
+ const runningAgents = activityTeamName
545
+ ? Array.from(runningReadAgents.values())
546
+ .filter(agent => agent.teamName === activityTeamName && hasActiveReadAgentLifecycle(agent))
547
+ .sort((a, b) => a.name.localeCompare(b.name))
548
+ : [];
549
+ const readAgents = runningAgents.filter(agent => (agent.role || "read") === "read");
550
+ const writeAgents = runningAgents.filter(agent => (agent.role || "read") === "write");
551
+ const activityConfig = activityTeamName ? await teams.readConfig(activityTeamName).catch(() => null) : null;
552
+ const activityMembers = activityConfig?.members ?? [];
553
+ const activityTombstones = activityTeamName
554
+ ? await listLifecycleTombstones(activityTeamName).catch(() => [])
555
+ : [];
556
+ const runtimeOnlyMembers = activityTeamName
557
+ ? (await Promise.all(activityMembers
558
+ .filter(member => member.name !== "team-lead")
559
+ .filter(member => !runningReadAgents.has(readAgentKey(activityTeamName, member.name)))
560
+ .map(async (member) => ({
561
+ member,
562
+ runtimeStatus: await runtime.readRuntimeStatus(activityTeamName, member.name).catch(() => null),
563
+ }))))
564
+ .filter((entry): entry is { member: Member; runtimeStatus: runtime.AgentRuntimeStatus } => isVisibleRuntimeOnlyMember(entry.member, entry.runtimeStatus, now))
565
+ : [];
566
+ const runtimeOnlyMemberNames = new Set(runtimeOnlyMembers.map(({ member }) => member.name));
567
+ const runtimeOnlyReadMembers = runtimeOnlyMembers.filter(({ member }) => memberActivityRole(member) === "read");
568
+ const runtimeOnlyWriteMembers = runtimeOnlyMembers.filter(({ member }) => memberActivityRole(member) === "write");
569
+ const activeReadMemberNames = new Set([
570
+ ...readAgents.map(agent => agent.name),
571
+ ...runtimeOnlyReadMembers
572
+ .filter(({ runtimeStatus }) => runtimeStatus.currentAction !== "done")
573
+ .map(({ member }) => member.name),
574
+ ]);
575
+ const activityMembersByName = new Map(activityMembers.map(member => [member.name, member]));
576
+ const failedNestedReadHelperNames = new Set([
577
+ ...readAgents
578
+ .filter(agent => readAgentHasLastError(agent) && isNestedReadHelperMember(activityMembersByName.get(agent.name)))
579
+ .map(agent => agent.name),
580
+ ...runtimeOnlyMembers
581
+ .filter(({ member, runtimeStatus }) => runtimeStatus.lastError && isNestedReadHelperMember(member))
582
+ .map(({ member }) => member.name),
583
+ ]);
584
+ const activeReadHelperCounts = countActiveNestedReadHelpersByWriter(
585
+ activityMembers,
586
+ activeReadMemberNames,
587
+ failedNestedReadHelperNames
588
+ );
589
+ const activeWriteMembers = activityTeamName
590
+ ? activityMembers
591
+ .filter(member => member.name !== "team-lead" && member.isActive !== false && memberActivityRole(member) === "write")
592
+ .filter(member => !!(member.tmuxPaneId && terminal?.isAlive?.(member.tmuxPaneId)) && !runningReadAgents.has(readAgentKey(activityTeamName, member.name)) && !runtimeOnlyMemberNames.has(member.name)) ?? []
593
+ : [];
594
+ const unreadLeadMessages = activityTeamName ? await messaging.readInbox(activityTeamName, agentName, true, false).catch(() => []) : [];
595
+ leadInboxUnreadCount = unreadLeadMessages.length;
596
+ clearLeadInboxWidgetOnce();
597
+
598
+ if ((readAgents.length > 0 || writeAgents.length > 0 || runtimeOnlyReadMembers.length > 0 || runtimeOnlyWriteMembers.length > 0 || activeWriteMembers.length > 0 || activityTombstones.length > 0) && !readAgentStatusTimer) {
599
+ readAgentStatusTimer = setInterval(renderReadAgentStatus, 1000);
600
+ }
601
+
602
+ if (readAgents.length === 0 && writeAgents.length === 0 && runtimeOnlyReadMembers.length === 0 && runtimeOnlyWriteMembers.length === 0 && activeWriteMembers.length === 0 && activityTombstones.length === 0) {
603
+ sessionCtx.ui.setStatus?.("01-pi-extended-teams-read", undefined);
604
+ clearTeamActivityWidget();
605
+ sessionCtx.ui.setWidget?.("01-pi-extended-teams-status", undefined);
606
+ if (readAgentStatusTimer) {
607
+ clearInterval(readAgentStatusTimer);
608
+ readAgentStatusTimer = null;
609
+ }
610
+ return;
611
+ }
612
+
613
+ const activeCount = readAgents.length + writeAgents.length + runtimeOnlyReadMembers.length + runtimeOnlyWriteMembers.length + activeWriteMembers.length;
614
+ const entries: TeamActivityStatusEntry[] = [];
615
+ const footerStatuses: string[] = [];
616
+ const statusCounts: Record<string, number> = {};
617
+ const idleNudgeMessages: string[] = [];
618
+ const countStatus = (status: string | undefined) => {
619
+ if (!status) return;
620
+ statusCounts[status] = (statusCounts[status] ?? 0) + 1;
621
+ };
622
+
623
+ for (const agent of writeAgents) {
624
+ refreshInProcessAgentUsage(agent, now);
625
+ const elapsed = formatElapsed(now - agent.startedAt);
626
+ const status = describeReadAgentStatus(agent, now);
627
+ const idleNudgeMessage = getIdleReadAgentNudgeMessage(agent, status);
628
+ if (idleNudgeMessage) idleNudgeMessages.push(idleNudgeMessage);
629
+ const modelLabel = formatModelLabel(agent.model, agent.thinking);
630
+ const slotLabel = agent.modelSlot ? `slot:${agent.modelSlot}` : "";
631
+ const contextUsage = formatRunningContextUsage(agent.contextUsage);
632
+ const detail = [modelLabel, slotLabel, elapsed, contextUsage, status.detail].filter(Boolean).join(" · ");
633
+ countStatus(status.label);
634
+ entries.push({ name: agent.name, role: "write", status: status.label, detail });
635
+ footerStatuses.push(formatAgentProgressStatus(agent.name, agent.model, agent.thinking, agent.modelSlot, elapsed, contextUsage, agent.latestProgress, now, activeReadHelperCounts.get(agent.name)));
636
+ }
637
+
638
+ for (const { member, runtimeStatus } of runtimeOnlyWriteMembers.sort((a, b) => a.member.name.localeCompare(b.member.name))) {
639
+ const elapsed = formatElapsed(now - (runtimeStatus.startedAt || member.joinedAt));
640
+ const modelLabel = formatModelLabel(member.model, member.thinking);
641
+ const modelSlot = canonicalPersistedModelSlot(member.modelSlot);
642
+ const slotLabel = modelSlot ? `slot:${modelSlot}` : "";
643
+ const contextUsage = formatRunningContextUsage(runtimeStatus.contextUsage);
644
+ const screen = member.tmuxPaneId ? (member.windowId ? `${member.windowId}/${member.tmuxPaneId}` : member.tmuxPaneId) : "";
645
+ const action = runtimeOnlyActionLabel(runtimeStatus);
646
+ const heartbeat = runtimeOnlyHeartbeatDetail(runtimeStatus, now);
647
+ const detail = [screen, modelLabel, slotLabel, elapsed, contextUsage, heartbeat, action].filter(Boolean).join(" · ");
648
+ const status = runtimeOnlyStatusLabel(runtimeStatus, now);
649
+ countStatus(status);
650
+ entries.push({ name: member.name, role: "write", status, detail });
651
+ footerStatuses.push(formatAgentProgressStatus(member.name, member.model, member.thinking, modelSlot, elapsed, contextUsage, runtimeStatus.latestProgress, now, activeReadHelperCounts.get(member.name)));
652
+ }
653
+
654
+ for (const member of activeWriteMembers.sort((a, b) => a.name.localeCompare(b.name))) {
655
+ upsertWriterScreenTab(writerScreenState, {
656
+ teamName: activityTeamName!,
657
+ name: member.name,
658
+ paneId: member.tmuxPaneId,
659
+ windowId: member.windowId,
660
+ joinedAt: member.joinedAt,
661
+ });
662
+ const runtimeStatus = await runtime.readRuntimeStatus(activityTeamName!, member.name).catch(() => null);
663
+ const elapsed = formatElapsed(now - (runtimeStatus?.startedAt || member.joinedAt));
664
+ const modelLabel = formatModelLabel(member.model, member.thinking);
665
+ const contextUsage = formatRunningContextUsage(runtimeStatus?.contextUsage);
666
+ const action = runtimeStatus?.activeToolName
667
+ ? `${runtimeStatus.currentAction || "working"}: ${runtimeStatus.activeToolName}`
668
+ : runtimeStatus?.currentAction || "running";
669
+ const screen = member.windowId ? `${member.windowId}/${member.tmuxPaneId}` : member.tmuxPaneId;
670
+ const detail = [screen, modelLabel, elapsed, contextUsage, action].filter(Boolean).join(" · ");
671
+ const modelSlot = canonicalPersistedModelSlot(member.modelSlot);
672
+ countStatus("bg");
673
+ entries.push({ name: member.name, role: "write", status: "bg", detail });
674
+ footerStatuses.push(formatAgentProgressStatus(member.name, member.model, member.thinking, modelSlot, elapsed, contextUsage, runtimeStatus?.latestProgress, now, activeReadHelperCounts.get(member.name)));
675
+ }
676
+
677
+ for (const { member, runtimeStatus } of runtimeOnlyReadMembers.sort((a, b) => a.member.name.localeCompare(b.member.name))) {
678
+ const elapsed = formatElapsed(now - (runtimeStatus.startedAt || member.joinedAt));
679
+ const modelLabel = formatModelLabel(member.model, member.thinking);
680
+ const modelSlot = canonicalPersistedModelSlot(member.modelSlot);
681
+ const slotLabel = modelSlot ? `slot:${modelSlot}` : "";
682
+ const contextUsage = formatRunningContextUsage(runtimeStatus.contextUsage);
683
+ const action = runtimeOnlyActionLabel(runtimeStatus);
684
+ const heartbeat = runtimeOnlyHeartbeatDetail(runtimeStatus, now);
685
+ const detail = [modelLabel, slotLabel, elapsed, contextUsage, heartbeat, action].filter(Boolean).join(" · ");
686
+ const status = runtimeOnlyStatusLabel(runtimeStatus, now);
687
+ countStatus(status);
688
+ entries.push({ name: member.name, role: "read", status, detail });
689
+ footerStatuses.push(formatAgentProgressStatus(member.name, member.model, member.thinking, modelSlot, elapsed, contextUsage, runtimeStatus.latestProgress, now));
690
+ }
691
+
692
+ for (const agent of readAgents) {
693
+ refreshInProcessAgentUsage(agent, now);
694
+ const elapsed = formatElapsed(now - agent.startedAt);
695
+ const status = describeReadAgentStatus(agent, now);
696
+ const idleNudgeMessage = getIdleReadAgentNudgeMessage(agent, status);
697
+ if (idleNudgeMessage) idleNudgeMessages.push(idleNudgeMessage);
698
+ const modelLabel = formatModelLabel(agent.model, agent.thinking);
699
+ const slotLabel = agent.modelSlot ? `slot:${agent.modelSlot}` : "";
700
+ const contextUsage = formatRunningContextUsage(agent.contextUsage);
701
+ const detail = [modelLabel, slotLabel, elapsed, contextUsage, status.detail].filter(Boolean).join(" · ");
702
+ countStatus(status.label);
703
+ entries.push({ name: agent.name, role: "read", status: status.label, detail });
704
+ footerStatuses.push(formatAgentProgressStatus(agent.name, agent.model, agent.thinking, agent.modelSlot, elapsed, contextUsage, agent.latestProgress, now));
705
+ }
706
+
707
+ for (const { agentName: quarantinedName, result } of activityTombstones) {
708
+ const persistedMember = activityMembers.find(member => member.name === quarantinedName);
709
+ const role = result.status === "occupied"
710
+ ? result.tombstone.role
711
+ : persistedMember ? memberActivityRole(persistedMember) : "read";
712
+ const detail = result.status === "occupied"
713
+ ? `inactive · run ${result.tombstone.runId} · ${result.tombstone.phase}`
714
+ : `inactive · corrupt lifecycle tombstone · ${result.error}`;
715
+ countStatus("quarantined");
716
+ entries.push({ name: quarantinedName, role, status: "quarantined", detail });
717
+ }
718
+
719
+ wakeLeadForIdleReadAgents(idleNudgeMessages);
720
+
721
+ for (const [index, entry] of entries.entries()) entry.displayText = footerStatuses[index];
722
+
723
+ sessionCtx.ui.setStatus?.("01-pi-extended-teams-read", undefined);
724
+ sessionCtx.ui.setWidget?.("01-pi-extended-teams-status", undefined);
725
+ updateTeamActivityWidget({
726
+ activeCount,
727
+ readCount: readAgents.length + runtimeOnlyReadMembers.length,
728
+ writeCount: writeAgents.length + runtimeOnlyWriteMembers.length + activeWriteMembers.length,
729
+ unreadCount: leadInboxUnreadCount,
730
+ entries,
731
+ statusCounts,
732
+ updatedAt: now,
733
+ });
734
+ }
735
+
736
+ function ensureReadAgentStatusTicker() {
737
+ if (readAgentStatusTimer) return;
738
+ readAgentStatusTimer = setInterval(renderReadAgentStatus, 1000);
739
+ renderReadAgentStatus();
740
+ }
741
+
742
+ async function renderLeadInboxStatus() {
743
+ clearLeadInboxWidgetOnce();
744
+ await renderTeamActivityStatus();
745
+ }
746
+
747
+ function requireTeamContext(explicitTeamName?: string): string {
748
+ return resolveTeamContext(teamName, explicitTeamName);
749
+ }
750
+
751
+ async function requireWriteAgentTeam(): Promise<string> {
752
+ return resolveWriteAgentTeam(teamName, isTeammate, agentName);
753
+ }
754
+
755
+ async function buildRoster(teamName: string) {
756
+ return buildTeamRoster(teamName, { terminal, runningReadAgents, readAgentKey });
757
+ }
758
+
759
+ async function drainReadHelperQueueOnce() {
760
+ if (isTeammate || !sessionCtx || !teamName) return;
761
+ if (readHelperQueueDraining) {
762
+ readHelperQueueDrainPending = true;
763
+ return;
764
+ }
765
+ readHelperQueueDraining = true;
766
+ try {
767
+ for (let drained = 0; drained < 10 && teamName; drained++) {
768
+ const [nextQueued] = await listReadHelperQueue(teamName);
769
+ if (!nextQueued) break;
770
+ const fence = await readLifecycleTombstone(teamName, nextQueued.name);
771
+ if (fence.status !== "absent") break;
772
+ const queued = nextQueued;
773
+
774
+ try {
775
+ const config = await teams.readConfig(queued.teamName);
776
+ if (config.members.some(member => member.name === queued.name)) {
777
+ throw new Error(`Teammate ${queued.name} already exists in team ${queued.teamName}.`);
778
+ }
779
+ const level = requireFavoriteModelLevel(loadSettings({ projectDir: queued.cwd }), queued.modelSlot);
780
+ if (level.role !== "read") throw new Error(`Read helper ${queued.name} requires a read-* intent tier configured via /agents-favorite-models, got ${level.slot}.`);
781
+ const [provider, modelId] = level.model.split("/", 2);
782
+ const model = provider && modelId ? sessionCtx.modelRegistry?.find?.(provider, modelId) : undefined;
783
+ if (!model) throw new Error(`Read helper model \"${level.model}\" from intent tier ${level.slot} is not available in the lead session.`);
784
+
785
+ const helperPrompt = buildReadHelperPrompt(queued.teamName, queued.requester, queued.prompt);
786
+ const member: Member = {
787
+ agentId: `${queued.name}@${queued.teamName}`,
788
+ name: queued.name,
789
+ agentType: "teammate",
790
+ role: "read",
791
+ model: level.model,
792
+ joinedAt: Date.now(),
793
+ tmuxPaneId: "",
794
+ cwd: queued.cwd,
795
+ subscriptions: [],
796
+ prompt: helperPrompt,
797
+ color: "cyan",
798
+ thinking: level.thinking,
799
+ modelSlot: level.slot,
800
+ requestedBy: queued.requester,
801
+ helperKind: "read_helper",
802
+ };
803
+
804
+ await teams.addMember(queued.teamName, member);
805
+ observeReadAgentLaunch(
806
+ runInProcessAgentWithSleepAssertion(queued.teamName, member, helperPrompt, sessionCtx, readAgentOptions())
807
+ );
808
+ await removeQueuedReadHelperRequest(teamName, queued.id);
809
+ } catch (e) {
810
+ const latestFence = await readLifecycleTombstone(queued.teamName, queued.name);
811
+ if (latestFence.status !== "absent") {
812
+ await messaging.sendPlainMessage(
813
+ queued.teamName,
814
+ "system",
815
+ "team-lead",
816
+ `Retained read helper ${queued.name}: lifecycle quarantine appeared before admission.`,
817
+ `Read helper ${queued.name} retained by quarantine`,
818
+ "yellow"
819
+ ).catch(() => {});
820
+ break;
821
+ }
822
+ await removeQueuedReadHelperRequest(teamName, queued.id);
823
+ const message = `Read helper ${queued.name} could not start for ${queued.requester}: ${e instanceof Error ? e.message : String(e)}`;
824
+ let requesterReceivedFailure = queued.requester === "team-lead";
825
+ if (!requesterReceivedFailure) {
826
+ try {
827
+ const deliveredDirectly = await deliverMessageToActiveAgent(queued.teamName, queued.requester, message);
828
+ if (!deliveredDirectly) {
829
+ await messaging.sendPlainMessageIfRunning(
830
+ queued.teamName,
831
+ queued.name,
832
+ queued.requester,
833
+ message,
834
+ `Read helper ${queued.name} failed`,
835
+ "red"
836
+ );
837
+ }
838
+ requesterReceivedFailure = true;
839
+ } catch {
840
+ requesterReceivedFailure = false;
841
+ }
842
+ }
843
+ const delivery = requesterReceivedFailure
844
+ ? `Failure sent to ${queued.requester}.`
845
+ : `${queued.requester} is no longer running; the failure is retained here.`;
846
+ await messaging.sendPlainMessage(
847
+ queued.teamName,
848
+ queued.name,
849
+ "team-lead",
850
+ `${message} ${delivery}`,
851
+ `Read helper ${queued.name} failed`,
852
+ "red",
853
+ { metadata: { finalReport: true, helperCompletion: true, outcome: "failed", requestedBy: queued.requester } }
854
+ ).catch(() => {});
855
+ await notifyLeadOfInboxReports(queued.teamName).catch(() => {});
856
+ }
857
+ }
858
+ } finally {
859
+ readHelperQueueDraining = false;
860
+ if (readHelperQueueDrainPending) {
861
+ readHelperQueueDrainPending = false;
862
+ void drainReadHelperQueueOnce();
863
+ }
864
+ }
865
+ }
866
+
867
+ function startReadHelperQueueDraining() {
868
+ if (isTeammate || !sessionCtx || !teamName) return;
869
+ if (readHelperQueueWatchedTeam === teamName) {
870
+ void drainReadHelperQueueOnce();
871
+ return;
872
+ }
873
+
874
+ readHelperQueueWatcher?.close();
875
+ readHelperQueueWatchedTeam = teamName;
876
+ void drainReadHelperQueueOnce();
877
+
878
+ try {
879
+ const queueFile = teamPaths.readHelperQueuePath(teamName);
880
+ fs.mkdirSync(path.dirname(queueFile), { recursive: true });
881
+ readHelperQueueWatcher = fs.watch(path.dirname(queueFile), (_eventType, filename) => {
882
+ if (!filename || filename.toString() === path.basename(queueFile)) {
883
+ void drainReadHelperQueueOnce();
884
+ }
885
+ });
886
+ } catch {
887
+ readHelperQueueWatcher = null;
888
+ }
889
+
890
+ // Low-cost fallback for platforms where fs.watch misses an event. This does
891
+ // no model work and only reads one small JSON queue file.
892
+ if (!readHelperQueueFallbackStarted) {
893
+ readHelperQueueFallbackStarted = true;
894
+ readHelperQueueFallbackTimer = setInterval(() => { void drainReadHelperQueueOnce(); }, 5000);
895
+ }
896
+ }
897
+
898
+ /**
899
+ * Start inbox polling for the team lead.
900
+ * Called when a team is created or when the lead reconnects to an existing team.
901
+ * Requires sessionCtx to be set (from session_start).
902
+ */
903
+ function startLeadInboxPolling() {
904
+ if (isTeammate || !sessionCtx) return;
905
+ startReadHelperQueueDraining();
906
+ if (leadPollingStarted) return;
907
+ leadPollingStarted = true;
908
+
909
+ leadInboxPollTimer = setInterval(async () => {
910
+ if (!teamName) return;
911
+ try {
912
+ const unread = await messaging.readInbox(teamName, agentName, true, false);
913
+ await renderLeadInboxStatus();
914
+ wakeLeadForInboxReports(unread);
915
+ } catch {
916
+ // Ignore errors for lead polling
917
+ }
918
+ }, 30000);
919
+ }
920
+
921
+ // Make this session the active lead for `name`: set the current team, register
922
+ // the lead session, and start quiet background maintenance. Idempotent. Without
923
+ // this, operating on an existing/reconnected team leaves `teamName` unset, which
924
+ // silently breaks the active session context, inbox poll, and report wakeups.
925
+ function adoptTeamAsLead(name: string, ctx?: any): void {
926
+ if (isTeammate || !name) return;
927
+ const sessionId = getPiSessionId(ctx ?? sessionCtx);
928
+ if (teamName !== name) {
929
+ teamName = name;
930
+ }
931
+ registerLeadSession(name, sessionId);
932
+ ensureReadAgentStatusTicker();
933
+ startLeadInboxPolling();
934
+ startReadHelperQueueDraining();
935
+ startLeadWatchdog();
936
+ }
937
+
938
+ pi.events?.on?.("pi-prompt:prompt-build:start", async (payload: any) => {
939
+ if (isTeammate || !sessionCtx) return;
940
+ const requestedTeamName = teamPaths.sanitizeName(payload?.teamName || `prompt-build-${Date.now()}`);
941
+
942
+ try {
943
+ const prompts = Array.isArray(payload?.prompts) ? payload.prompts : [];
944
+ activePromptBuildTeamName = requestedTeamName;
945
+ const cwd = payload?.cwd || sessionCtx.cwd;
946
+ const agentNamePrefix = teamPaths.sanitizeName(payload?.agentNamePrefix || "prompt-branch");
947
+ if (payload?.model || payload?.thinking || payload?.role) {
948
+ throw new Error("Prompt-build agents must use model_slot only; direct model, thinking, or role is not allowed.");
949
+ }
950
+ const slot = payload?.model_slot || "read-review";
951
+ const level = requireFavoriteModelLevel(loadSettings({ projectDir: cwd }), slot);
952
+ if (level.role !== "read") throw new Error(`Prompt-build requires a read-* intent tier configured via /agents-favorite-models, got ${level.slot}.`);
953
+
954
+ if (!teams.teamExists(requestedTeamName)) {
955
+ teams.createTeam(requestedTeamName, getPiSessionId(sessionCtx) || "local-session", "lead-agent", payload?.description || "pi-prompt prompt-build", level.model);
956
+ }
957
+ // Prompt-build teams are private fanout jobs owned by pi-prompt. Do not
958
+ // adopt them as the lead's current team, or normal session context and
959
+ // bottom-status widgets get hijacked by the prompt-building run.
960
+ pi.events?.emit?.("pi-prompt:prompt-build:progress", { teamName: requestedTeamName, status: "started", total: prompts.length, text: `building prompt — ${prompts.length} branches started` });
961
+
962
+ for (let i = 0; i < prompts.length; i += 1) {
963
+ const name = teamPaths.sanitizeName(`${agentNamePrefix}-${i + 1}`);
964
+ const prompt = String(prompts[i] ?? "");
965
+ const member: Member = {
966
+ agentId: `${name}@${requestedTeamName}`,
967
+ name,
968
+ agentType: "teammate",
969
+ role: "read",
970
+ model: level.model,
971
+ joinedAt: Date.now(),
972
+ tmuxPaneId: "",
973
+ cwd,
974
+ subscriptions: [],
975
+ prompt,
976
+ color: "cyan",
977
+ thinking: level.thinking,
978
+ modelSlot: level.slot,
979
+ };
980
+ await teams.addMember(requestedTeamName, member);
981
+ observeReadAgentLaunch(
982
+ runInProcessAgentWithSleepAssertion(requestedTeamName, member, prompt, sessionCtx, readAgentOptions())
983
+ );
984
+ 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` });
985
+ }
986
+ } catch (err) {
987
+ const message = err instanceof Error ? err.message : String(err);
988
+ pi.events?.emit?.("pi-prompt:prompt-build:error", { teamName: requestedTeamName, error: message });
989
+ }
990
+ });
991
+
992
+ function setSessionCtx(ctx: any): void {
993
+ extensionShuttingDown = false;
994
+ sessionCtx = ctx;
995
+ if (!isTeammate && !teamName) {
996
+ const foundTeam = findLeadTeamForSession(getPiSessionId(ctx));
997
+ if (foundTeam) adoptTeamAsLead(foundTeam, ctx);
998
+ }
999
+ }
1000
+
1001
+ async function stopRunningAgentsForShutdown(reason: unknown): Promise<void> {
1002
+ const states = Array.from(runningReadAgents.values());
1003
+ const results = await Promise.all(states.map(async (state) => {
1004
+ const config = await teams.readConfig(state.teamName).catch(() => null);
1005
+ const member = config?.members.find(item => item.name === state.name) ?? {
1006
+ agentId: `${state.name}@${state.teamName}`,
1007
+ name: state.name,
1008
+ agentType: "teammate" as const,
1009
+ role: state.role === "write" ? "write" as const : "read" as const,
1010
+ model: state.model,
1011
+ thinking: state.thinking as Member["thinking"],
1012
+ modelSlot: state.modelSlot as Member["modelSlot"],
1013
+ joinedAt: state.startedAt,
1014
+ tmuxPaneId: "",
1015
+ cwd: "",
1016
+ subscriptions: [],
1017
+ lifecycleRunId: state.runId,
1018
+ };
1019
+ return shutdownTeammate(state.teamName, member, { drainQueue: false, reason });
1020
+ }));
1021
+
1022
+ const failures = results.filter(result => result.status === "persistence_failed" || result.status === "cleanup_failed");
1023
+ if (failures.length > 0) {
1024
+ throw new Error(`Could not close ${failures.length} agent recipient(s) during extension shutdown: ${failures.map(result => result.error || result.status).join("; ")}`);
1025
+ }
1026
+ }
1027
+
1028
+ async function cleanupExtensionRuntime(ctx: any, reason: unknown): Promise<void> {
1029
+ extensionShuttingDown = true;
1030
+ sessionCtx = null;
1031
+ stopLeadWatchdog();
1032
+ if (readAgentStatusTimer) clearInterval(readAgentStatusTimer);
1033
+ if (teamActivityRenderTimer) clearTimeout(teamActivityRenderTimer);
1034
+ if (leadInboxPollTimer) clearInterval(leadInboxPollTimer);
1035
+ if (readHelperQueueFallbackTimer) clearInterval(readHelperQueueFallbackTimer);
1036
+ readHelperQueueWatcher?.close();
1037
+
1038
+ readAgentStatusTimer = null;
1039
+ teamActivityRenderTimer = null;
1040
+ leadInboxPollTimer = null;
1041
+ readHelperQueueFallbackTimer = null;
1042
+ readHelperQueueWatcher = null;
1043
+ leadPollingStarted = false;
1044
+ leadWakeNotifiedMessageKeys.clear();
1045
+ readHelperQueueFallbackStarted = false;
1046
+ readHelperQueueWatchedTeam = null;
1047
+ lifecycleFenceClearUnsubscribe();
1048
+ teamActivityRenderDirty = false;
1049
+ settleTeamActivityRenderWaiters();
1050
+
1051
+ try {
1052
+ await stopRunningAgentsForShutdown(reason);
1053
+ } finally {
1054
+ activeAgentSleepController.dispose();
1055
+ }
1056
+
1057
+ teamActivityStatusSnapshot = null;
1058
+ teamActivityStatusSnapshotSignature = null;
1059
+ teamActivityWidgetMounted = false;
1060
+ teamActivityWidgetTui = null;
1061
+ ctx?.ui?.setWidget?.("01-pi-extended-teams-readers", undefined);
1062
+ ctx?.ui?.setWidget?.("01-pi-extended-teams-status", undefined);
1063
+ ctx?.ui?.setStatus?.("01-pi-extended-teams-read", undefined);
1064
+ }
1065
+
1066
+ registerExtensionEvents(pi, {
1067
+ isTeammate,
1068
+ agentName,
1069
+ getTeamName: () => teamName,
1070
+ setSessionCtx,
1071
+ terminal,
1072
+ quietTrigger,
1073
+ startLeadInboxPolling,
1074
+ startLeadWatchdog,
1075
+ buildRoster,
1076
+ formatRosterForPrompt,
1077
+ });
1078
+
1079
+ pi.on("session_shutdown", async (event: any, ctx: any) => {
1080
+ await cleanupExtensionRuntime(ctx, event?.reason);
1081
+ });
1082
+
1083
+ if (!isTeammate) {
1084
+ pi.on("session_start", async (_event: any, ctx: any) => {
1085
+ installAgentNavigation(ctx, {
1086
+ getAgents: () => Array.from(runningReadAgents.values())
1087
+ .filter(agent => !teamName || agent.teamName === teamName),
1088
+ stopAgent: async (name: string) => {
1089
+ const targetTeamName = teamName;
1090
+ if (!targetTeamName) return;
1091
+ const config = await teams.readConfig(targetTeamName);
1092
+ const member = config.members.find(item => item.name === name && item.name !== "team-lead");
1093
+ if (!member) return;
1094
+ const teardown = await shutdownTeammate(targetTeamName, member);
1095
+ const killed = teardown.status === "settled" && teardown.finalized && teardown.removedMember;
1096
+ let leadNotificationError: unknown;
1097
+ try {
1098
+ await messaging.sendPlainMessage(
1099
+ targetTeamName,
1100
+ "system",
1101
+ "team-lead",
1102
+ killed
1103
+ ? `The user killed subagent ${name}.`
1104
+ : `The user tried to kill subagent ${name}, but teardown ended with status ${teardown.status}.`,
1105
+ killed ? `Subagent ${name} killed by user` : `Subagent ${name} kill incomplete`,
1106
+ killed ? "yellow" : "red"
1107
+ );
1108
+ } catch (error) {
1109
+ leadNotificationError = error;
1110
+ }
1111
+ if (killed) {
1112
+ ctx.ui?.notify?.(`Stopped agent ${name}.`, "info");
1113
+ } else if (teardown.status === "timed_out") {
1114
+ ctx.ui?.notify?.(`Agent ${name} is inactive but quarantined after teardown timed out.`, "warning");
1115
+ } else {
1116
+ ctx.ui?.notify?.(`Agent ${name} cleanup is blocked${teardown.error ? `: ${teardown.error}` : "."}`, "warning");
1117
+ }
1118
+ if (leadNotificationError) {
1119
+ ctx.ui?.notify?.(
1120
+ `Could not notify the main agent that ${name} was killed: ${leadNotificationError instanceof Error ? leadNotificationError.message : String(leadNotificationError)}`,
1121
+ "warning"
1122
+ );
1123
+ }
1124
+ },
1125
+ sendMessage: async (name: string, content: string) => {
1126
+ const targetTeamName = teamName;
1127
+ if (!targetTeamName) throw new Error("No active agent session context is available.");
1128
+ const deliveredDirectly = await deliverMessageToActiveAgent(targetTeamName, name, content);
1129
+ if (!deliveredDirectly) {
1130
+ await messaging.sendPlainMessageIfRunning(targetTeamName, "team-lead", name, content, "Message from team-lead");
1131
+ }
1132
+ },
1133
+ });
1134
+ });
1135
+ }
1136
+
1137
+ registerFavoriteModelsCommand(pi);
1138
+ registerExtensionsCommand(pi);
1139
+
1140
+ registerWriterScreenShortcut(pi, {
1141
+ getTeamName: () => teamName,
1142
+ terminal,
1143
+ state: writerScreenState,
1144
+ });
1145
+
1146
+ function readAgentOptions() {
1147
+ return {
1148
+ isTeammate,
1149
+ getTeamName: () => teamName,
1150
+ runningReadAgents,
1151
+ readAgentKey,
1152
+ isCurrentReadAgentRun,
1153
+ ensureReadAgentStatusTicker,
1154
+ renderReadAgentStatus,
1155
+ rememberCompletedAgentReport,
1156
+ emitAgentReport,
1157
+ emitAgentProgress,
1158
+ releaseAllClaimsForAgent,
1159
+ shutdownTeammate,
1160
+ agentName,
1161
+ renderLeadInboxStatus,
1162
+ notifyLeadOfInboxReports,
1163
+ deliverMessageToActiveAgent,
1164
+ createResourcePlan: createCurrentSpawnResourcePlan,
1165
+ extensionInstanceId,
1166
+ createNestedReadAgentTools: (binding: Parameters<TeamToolsRuntime["createNestedReadAgentTools"]>[0]) => {
1167
+ return teamToolsRuntime?.createNestedReadAgentTools(binding) ?? [];
1168
+ },
1169
+ pendingChildController,
1170
+ };
1171
+ }
1172
+
1173
+ teamToolsRuntime = registerTeamTools(pi, {
1174
+ terminal,
1175
+ runningReadAgents,
1176
+ readAgentKey,
1177
+ isCurrentReadAgentRun,
1178
+ renderReadAgentStatus,
1179
+ readAgentOptions,
1180
+ runReadAgentInProcess: runInProcessAgentWithSleepAssertion,
1181
+ startWriteAgent,
1182
+ shutdownTeammate,
1183
+ adoptTeamAsLead,
1184
+ buildRoster,
1185
+ isTeammate,
1186
+ agentName,
1187
+ getTeamName: () => teamName,
1188
+ getSessionCtx: () => sessionCtx,
1189
+ setSessionCtx,
1190
+ pendingChildController,
1191
+ });
1192
+
1193
+ if (isTeammate) {
1194
+ pi.registerTool(createReportProgressTool({
1195
+ isTeammate,
1196
+ agentName,
1197
+ getTeamName: () => teamName,
1198
+ getLifecycleRunId: () => process.env.PI_LIFECYCLE_RUN_ID,
1199
+ }));
1200
+ }
1201
+
1202
+ registerCoordinationTools(pi, {
1203
+ agentName,
1204
+ isTeammate,
1205
+ terminal,
1206
+ getTeamName: () => teamName,
1207
+ requireWriteAgentTeam,
1208
+ requireTeamContext,
1209
+ releaseAllClaimsForAgent,
1210
+ drainWriteQueue,
1211
+ resolveSkillFile,
1212
+ adoptTeamAsLead,
1213
+ renderLeadInboxStatus,
1214
+ resetLeadWakeNotifiedCount: () => { leadWakeNotifiedMessageKeys.clear(); },
1215
+ deliverMessageToActiveAgent,
1216
+ extensionInstanceId,
1217
+ });
1218
+
1219
+ registerTaskRuntimeTools(pi, {
1220
+ isTeammate,
1221
+ terminal,
1222
+ runningReadAgents,
1223
+ readAgentKey,
1224
+ shutdownTeammate,
1225
+ getTeamName: () => teamName,
1226
+ });
1227
+
1228
+ }