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