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.
- package/README.md +105 -0
- package/assets/pi-extended-teams-agent-navigation.png +0 -0
- package/assets/pi-extended-teams-in-action.png +0 -0
- package/extensions/agents/read-agent-report.ts +181 -0
- package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
- package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
- package/extensions/agents/read-agent.test.ts +3077 -0
- package/extensions/agents/read-agent.ts +1119 -0
- package/extensions/agents/write-agent.test.ts +513 -0
- package/extensions/agents/write-agent.ts +392 -0
- package/extensions/events/register-events.test.ts +465 -0
- package/extensions/events/register-events.ts +409 -0
- package/extensions/index.test.ts +1659 -0
- package/extensions/index.ts +1228 -0
- package/extensions/internal/agent-session-files.test.ts +164 -0
- package/extensions/internal/agent-session-files.ts +320 -0
- package/extensions/internal/debug.ts +44 -0
- package/extensions/internal/model-selection.ts +82 -0
- package/extensions/internal/pi-command.test.ts +191 -0
- package/extensions/internal/pi-command.ts +224 -0
- package/extensions/internal/pi-runtime-api.test.ts +43 -0
- package/extensions/internal/pi-runtime-api.ts +78 -0
- package/extensions/internal/schema.ts +19 -0
- package/extensions/internal/session-context-reference.test.ts +290 -0
- package/extensions/internal/session-context-reference.ts +430 -0
- package/extensions/internal/session-files.test.ts +225 -0
- package/extensions/internal/session-files.ts +273 -0
- package/extensions/internal/session-usage.ts +59 -0
- package/extensions/resources/spawn-resource-plan.test.ts +233 -0
- package/extensions/resources/spawn-resource-plan.ts +247 -0
- package/extensions/runtime/active-agent-sleep.test.ts +157 -0
- package/extensions/runtime/active-agent-sleep.ts +117 -0
- package/extensions/runtime/nested-read-agents.ts +22 -0
- package/extensions/runtime/pending-child-controller.test.ts +169 -0
- package/extensions/runtime/pending-child-controller.ts +280 -0
- package/extensions/runtime/types.ts +68 -0
- package/extensions/team/contracts.test.ts +111 -0
- package/extensions/team/lifecycle.test.ts +1095 -0
- package/extensions/team/lifecycle.ts +502 -0
- package/extensions/team/recipient-closure.test.ts +97 -0
- package/extensions/team/recipient-closure.ts +120 -0
- package/extensions/team/roster.test.ts +129 -0
- package/extensions/team/roster.ts +165 -0
- package/extensions/team/team-contracts.json +43 -0
- package/extensions/team/writer-screens.test.ts +50 -0
- package/extensions/team/writer-screens.ts +156 -0
- package/extensions/tools/agent-communication-tools.test.ts +306 -0
- package/extensions/tools/agent-communication-tools.ts +183 -0
- package/extensions/tools/coordination-tools.test.ts +670 -0
- package/extensions/tools/coordination-tools.ts +304 -0
- package/extensions/tools/delegation-guard.test.ts +95 -0
- package/extensions/tools/delegation-guard.ts +65 -0
- package/extensions/tools/file-claim-tools.test.ts +104 -0
- package/extensions/tools/file-claim-tools.ts +68 -0
- package/extensions/tools/model-tools.ts +53 -0
- package/extensions/tools/predefined-tools.test.ts +647 -0
- package/extensions/tools/predefined-tools.ts +331 -0
- package/extensions/tools/read-helper.test.ts +35 -0
- package/extensions/tools/task-runtime-tools.test.ts +334 -0
- package/extensions/tools/task-runtime-tools.ts +227 -0
- package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
- package/extensions/tools/team-tools.ts +1270 -0
- package/extensions/ui/agent-follow-view.test.ts +520 -0
- package/extensions/ui/agent-follow-view.ts +779 -0
- package/extensions/ui/agent-navigation.test.ts +52 -0
- package/extensions/ui/agent-navigation.ts +55 -0
- package/extensions/ui/ansi.ts +16 -0
- package/extensions/ui/extensions-command.test.ts +336 -0
- package/extensions/ui/extensions-command.ts +282 -0
- package/extensions/ui/favorite-models-command.test.ts +280 -0
- package/extensions/ui/favorite-models-command.ts +423 -0
- package/extensions/ui/frame.ts +77 -0
- package/extensions/ui/input.ts +17 -0
- package/extensions/ui/read-agent-status.test.ts +117 -0
- package/extensions/ui/read-agent-status.ts +125 -0
- package/extensions/ui/renderers.ts +243 -0
- package/extensions/ui/status-widget.test.ts +143 -0
- package/extensions/ui/status-widget.ts +440 -0
- package/extensions/ui-frame.test.ts +69 -0
- package/package.json +75 -0
- package/skills/teams.md +256 -0
- package/src/adapters/terminal-registry.ts +78 -0
- package/src/adapters/tmux-adapter.test.ts +276 -0
- package/src/adapters/tmux-adapter.ts +200 -0
- package/src/orchestration/index.ts +437 -0
- package/src/orchestration/orchestrator.test.ts +396 -0
- package/src/orchestration/types.ts +125 -0
- package/src/utils/atomic-json.ts +21 -0
- package/src/utils/claims.test.ts +137 -0
- package/src/utils/claims.ts +169 -0
- package/src/utils/hooks.test.ts +75 -0
- package/src/utils/hooks.ts +35 -0
- package/src/utils/lifecycle-tombstone.test.ts +105 -0
- package/src/utils/lifecycle-tombstone.ts +268 -0
- package/src/utils/lock.race.child.ts +44 -0
- package/src/utils/lock.race.test.ts +198 -0
- package/src/utils/lock.test.ts +90 -0
- package/src/utils/lock.ts +186 -0
- package/src/utils/messaging.test.ts +337 -0
- package/src/utils/messaging.ts +441 -0
- package/src/utils/model-resolution.test.ts +231 -0
- package/src/utils/model-resolution.ts +322 -0
- package/src/utils/models.test.ts +8 -0
- package/src/utils/models.ts +115 -0
- package/src/utils/paths.ts +81 -0
- package/src/utils/predefined-teams/types.ts +48 -0
- package/src/utils/predefined-teams.save-template.test.ts +74 -0
- package/src/utils/predefined-teams.test.ts +441 -0
- package/src/utils/predefined-teams.ts +471 -0
- package/src/utils/read-helper-queue.ts +99 -0
- package/src/utils/report-events.test.ts +154 -0
- package/src/utils/report-events.ts +222 -0
- package/src/utils/runtime.test.ts +314 -0
- package/src/utils/runtime.ts +261 -0
- package/src/utils/security.test.ts +43 -0
- package/src/utils/settings.test.ts +480 -0
- package/src/utils/settings.ts +645 -0
- package/src/utils/shared-memory.test.ts +80 -0
- package/src/utils/shared-memory.ts +81 -0
- package/src/utils/tasks.race.test.ts +44 -0
- package/src/utils/tasks.test.ts +229 -0
- package/src/utils/tasks.ts +396 -0
- package/src/utils/teams.ts +231 -0
- package/src/utils/terminal-adapter.ts +103 -0
- package/src/utils/thinking-levels.test.ts +56 -0
- package/src/utils/thinking-levels.ts +47 -0
- package/src/utils/workflow-metadata.test.ts +11 -0
- package/src/utils/workflow-metadata.ts +88 -0
- package/src/utils/write-queue.test.ts +251 -0
- 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
|
+
}
|