@ian-pascoe/pi-minimal-subagents 0.1.0

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.
@@ -0,0 +1,279 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+ import {
4
+ buildSessionContext,
5
+ getAgentDir,
6
+ SessionManager,
7
+ SettingsManager,
8
+ type ExtensionAPI,
9
+ type ExtensionContext,
10
+ type SessionEntry,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
13
+ import { resolveMinimalSubagentsSettings } from "./minimal-subagents-config.js";
14
+ import { snapshotCommittedContext } from "./minimal-subagents-context.js";
15
+ import { rememberForkSnapshot, takeForkSnapshot } from "./minimal-subagents-fork-lifecycle.js";
16
+ import {
17
+ buildEligibleModelIds,
18
+ COORDINATOR_TOOL_NAMES,
19
+ excludeCoordinatorTools,
20
+ } from "./minimal-subagents-capabilities.js";
21
+ import {
22
+ CHILD_IDENTITY_ENTRY_TYPE,
23
+ REGISTRY_ENTRY_TYPE,
24
+ replayRegistryEntries,
25
+ } from "./minimal-subagents-registry.js";
26
+ import { createCoordinatorToolSchemas } from "./minimal-subagents-tool-schemas.js";
27
+ import { findDeliveryEvidence, PiAgentSessionFactory } from "./minimal-subagents-sessions.js";
28
+ import { shutdownMinimalSubagentsSession } from "./minimal-subagents-shutdown.js";
29
+ import { createCoordinatorToolDefinitions } from "./minimal-subagents-tools.js";
30
+ import {
31
+ renderMinimalSubagentsMessage,
32
+ renderMinimalSubagentsResult,
33
+ } from "./minimal-subagents-rendering.js";
34
+ import { MinimalSubagentsUiController } from "./minimal-subagents-ui.js";
35
+ import type {
36
+ CallerSnapshot,
37
+ CoordinatorNotification,
38
+ ForkSnapshot,
39
+ RegistrySnapshot,
40
+ RootConversationEndpoint,
41
+ } from "./minimal-subagents-types.js";
42
+
43
+ const EXTENSION_ENTRYPOINT = fileURLToPath(new URL("./index.ts", import.meta.url));
44
+
45
+ function currentConversationMessages(context: ExtensionContext): AgentMessage[] {
46
+ const entries = context.sessionManager.getEntries() as SessionEntry[];
47
+ const messages = buildSessionContext(entries, context.sessionManager.getLeafId()).messages;
48
+ return snapshotCommittedContext(messages, !context.isIdle());
49
+ }
50
+
51
+ function rootCallerSnapshot(pi: ExtensionAPI, context: ExtensionContext): CallerSnapshot {
52
+ if (!context.model) throw new Error("Minimal subagents spawn: root has no effective model");
53
+ const activeTools = excludeCoordinatorTools(pi.getActiveTools());
54
+ const availableTools = excludeCoordinatorTools(pi.getAllTools().map((tool) => tool.name));
55
+ return {
56
+ messages: currentConversationMessages(context),
57
+ model: `${context.model.provider}/${context.model.id}`,
58
+ thinkingLevel: context.thinkingLevel ?? pi.getThinkingLevel(),
59
+ ordinaryTools: activeTools,
60
+ capabilityCeiling: availableTools,
61
+ availableTools,
62
+ spawnEntryId: context.sessionManager.getLeafId() ?? "root",
63
+ };
64
+ }
65
+
66
+ function createRootConversationEndpoint(
67
+ pi: ExtensionAPI,
68
+ context: ExtensionContext,
69
+ ): RootConversationEndpoint {
70
+ return {
71
+ async steerCoordinatorMessage(message) {
72
+ pi.sendMessage(
73
+ {
74
+ customType: message.customType,
75
+ content: message.content,
76
+ display: true,
77
+ details: message.details,
78
+ },
79
+ {
80
+ triggerTurn: true,
81
+ deliverAs: "steer",
82
+ },
83
+ );
84
+ },
85
+ hasDeliveryEvidence: (sourceAgentId, sourceTurnId) =>
86
+ findDeliveryEvidence(context.sessionManager.getEntries(), sourceAgentId, sourceTurnId),
87
+ };
88
+ }
89
+
90
+ function shouldSurfaceNotification(notification: CoordinatorNotification): boolean {
91
+ return ["failure", "interruption", "unavailable", "fork-clone-failure"].includes(
92
+ notification.type,
93
+ );
94
+ }
95
+
96
+ function notificationLevel(notification: CoordinatorNotification): "info" | "warning" | "error" {
97
+ if (notification.type === "failure" || notification.type === "fork-clone-failure") return "error";
98
+ if (
99
+ notification.type === "cancellation" ||
100
+ notification.type === "interruption" ||
101
+ notification.type === "unavailable"
102
+ ) {
103
+ return "warning";
104
+ }
105
+ return "info";
106
+ }
107
+
108
+ function hasHistoricalChildIdentity(entries: readonly SessionEntry[]): boolean {
109
+ return entries.some(
110
+ (entry) => entry.type === "custom" && entry.customType === CHILD_IDENTITY_ENTRY_TYPE,
111
+ );
112
+ }
113
+
114
+ function replayPreviousRoot(previousSessionFile: string): RegistrySnapshot {
115
+ const previousSession = SessionManager.open(previousSessionFile);
116
+ const previousRootSessionId = previousSession.getSessionId();
117
+ return replayRegistryEntries(previousSession.getEntries(), previousRootSessionId);
118
+ }
119
+
120
+ async function waitForRootSessionIdle(context: ExtensionContext): Promise<void> {
121
+ while (!context.isIdle()) {
122
+ await new Promise((resolve) => setTimeout(resolve, 25));
123
+ }
124
+ }
125
+
126
+ /** Register the six root coordinator tools and bind root-owned persistent subagent lifecycle hooks. */
127
+ export default function minimalSubagentsExtension(pi: ExtensionAPI) {
128
+ let coordinator: MinimalSubagentsCoordinator | undefined;
129
+ let uiController: MinimalSubagentsUiController | undefined;
130
+ let preparedFork: ForkSnapshot | undefined;
131
+
132
+ pi.registerMessageRenderer("minimal-subagents.message", renderMinimalSubagentsMessage);
133
+ pi.registerMessageRenderer("minimal-subagents.result", renderMinimalSubagentsResult);
134
+
135
+ pi.on("session_start", async (event, context) => {
136
+ const rootSessionId = context.sessionManager.getSessionId();
137
+ const agentDir = getAgentDir();
138
+ const settingsManager = SettingsManager.create(context.cwd, agentDir, {
139
+ projectTrusted: context.isProjectTrusted(),
140
+ });
141
+ const enabledModelPatterns = settingsManager.getEnabledModels();
142
+ const availableModels = context.modelRegistry.getAvailable();
143
+ const eligibleModelIds = buildEligibleModelIds({
144
+ availableModels,
145
+ scopedModels: context.scopedModels,
146
+ scopeConfigured: enabledModelPatterns !== undefined,
147
+ });
148
+ const minimalSubagentsConfig = resolveMinimalSubagentsSettings(
149
+ settingsManager,
150
+ eligibleModelIds,
151
+ );
152
+ if (minimalSubagentsConfig.warnings.length > 0) {
153
+ context.ui.notify(
154
+ `Minimal subagents configuration warnings:\n- ${minimalSubagentsConfig.warnings.join("\n- ")}`,
155
+ "warning",
156
+ );
157
+ }
158
+ const models = [...availableModels];
159
+ if (
160
+ context.model &&
161
+ !models.some(
162
+ (model) => model.provider === context.model?.provider && model.id === context.model?.id,
163
+ )
164
+ ) {
165
+ models.push(context.model);
166
+ }
167
+ const availableToolNames = excludeCoordinatorTools(pi.getAllTools().map((tool) => tool.name));
168
+ const schemas = createCoordinatorToolSchemas(eligibleModelIds);
169
+ let activeCoordinator!: MinimalSubagentsCoordinator;
170
+ const sessionFactory = new PiAgentSessionFactory({
171
+ cwd: context.cwd,
172
+ agentDir,
173
+ sessionDir: context.sessionManager.getSessionDir(),
174
+ rootSessionId,
175
+ extensionEntrypoint: EXTENSION_ENTRYPOINT,
176
+ models,
177
+ eligibleModelIds,
178
+ modelScopeRestricted: enabledModelPatterns !== undefined,
179
+ availableToolNames,
180
+ projectTrusted: context.isProjectTrusted(),
181
+ maxSubagentDepth: minimalSubagentsConfig.maxSubagentDepth,
182
+ onChildSessionActivity: () => activeCoordinator.scheduleDeliveryReconciliation(),
183
+ getCoordinatorTools: (callerId) =>
184
+ createCoordinatorToolDefinitions({
185
+ coordinator: activeCoordinator,
186
+ callerId,
187
+ allowFanoutTools: activeCoordinator.canAgentSpawn(callerId),
188
+ modelRoles: minimalSubagentsConfig.modelRoles,
189
+ schemas,
190
+ captureCaller: (childContext) =>
191
+ activeCoordinator.snapshotChildCaller(
192
+ callerId,
193
+ childContext.sessionManager.getLeafId() ?? callerId,
194
+ ),
195
+ onAttention: (message) => context.ui.notify(message, "error"),
196
+ }),
197
+ });
198
+ activeCoordinator = new MinimalSubagentsCoordinator({
199
+ sessions: sessionFactory,
200
+ root: createRootConversationEndpoint(pi, context),
201
+ maxSubagentDepth: minimalSubagentsConfig.maxSubagentDepth,
202
+ registry: {
203
+ rootSessionId,
204
+ append: (registryEvent) => pi.appendEntry(REGISTRY_ENTRY_TYPE, registryEvent),
205
+ },
206
+ notify: (notification) => {
207
+ uiController?.refresh();
208
+ if (shouldSurfaceNotification(notification)) {
209
+ context.ui.notify(notification.message, notificationLevel(notification));
210
+ }
211
+ },
212
+ });
213
+ coordinator = activeCoordinator;
214
+
215
+ let snapshot: RegistrySnapshot;
216
+ if (event.reason === "fork" && event.previousSessionFile) {
217
+ let forkSnapshot = takeForkSnapshot(event.previousSessionFile);
218
+ if (!forkSnapshot) {
219
+ await activeCoordinator.restore(replayPreviousRoot(event.previousSessionFile));
220
+ forkSnapshot = await activeCoordinator.prepareFork(event.previousSessionFile);
221
+ }
222
+ snapshot = forkSnapshot;
223
+ } else {
224
+ snapshot = replayRegistryEntries(context.sessionManager.getEntries(), rootSessionId);
225
+ }
226
+ await activeCoordinator.restore(snapshot);
227
+ activeCoordinator.writeCheckpoint();
228
+ uiController = new MinimalSubagentsUiController(activeCoordinator, context);
229
+ uiController.refresh();
230
+
231
+ const rootTools = createCoordinatorToolDefinitions({
232
+ coordinator: activeCoordinator,
233
+ callerId: "root",
234
+ allowFanoutTools: true,
235
+ modelRoles: minimalSubagentsConfig.modelRoles,
236
+ schemas,
237
+ captureCaller: (toolContext) => rootCallerSnapshot(pi, toolContext),
238
+ onActivity: () => uiController?.refresh(),
239
+ onAttention: (message) => context.ui.notify(message, "error"),
240
+ });
241
+ for (const tool of rootTools) pi.registerTool(tool);
242
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), ...COORDINATOR_TOOL_NAMES])]);
243
+
244
+ if (hasHistoricalChildIdentity(context.sessionManager.getEntries() as SessionEntry[])) {
245
+ context.ui.notify(
246
+ "Opened a former subagent session directly. It is now an independent root; former descendants and parent messaging were not restored. Concurrent ownership by its original root is unsupported.",
247
+ "warning",
248
+ );
249
+ }
250
+ });
251
+
252
+ pi.on("session_before_fork", async (_event, context) => {
253
+ const sessionFile = context.sessionManager.getSessionFile();
254
+ if (!coordinator || !sessionFile) return;
255
+ preparedFork = await coordinator.prepareFork(sessionFile);
256
+ rememberForkSnapshot(preparedFork);
257
+ });
258
+
259
+ pi.on("message_end", async (event) => {
260
+ if (!coordinator) return;
261
+ if (event.message.role === "toolResult" || event.message.role === "custom") {
262
+ await coordinator.reconcileDeliveries();
263
+ uiController?.refresh();
264
+ }
265
+ });
266
+
267
+ pi.on("session_shutdown", async (event, context) => {
268
+ if (coordinator) {
269
+ await shutdownMinimalSubagentsSession(event.reason, coordinator, {
270
+ isRootIdle: () => context.isIdle(),
271
+ waitForRootIdle: () => waitForRootSessionIdle(context),
272
+ });
273
+ }
274
+ uiController?.dispose();
275
+ uiController = undefined;
276
+ coordinator = undefined;
277
+ preparedFork = undefined;
278
+ });
279
+ }
@@ -0,0 +1,36 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type { ForkSnapshot } from "./minimal-subagents-types.js";
4
+
5
+ const FORK_SNAPSHOT_SYMBOL = Symbol.for("minimal-subagents.pending-fork-snapshots.v1");
6
+
7
+ type GlobalWithForkSnapshots = typeof globalThis & {
8
+ [FORK_SNAPSHOT_SYMBOL]?: Map<string, ForkSnapshot>;
9
+ };
10
+
11
+ function forkSnapshotStore(): Map<string, ForkSnapshot> {
12
+ const processGlobal = globalThis as GlobalWithForkSnapshots;
13
+ processGlobal[FORK_SNAPSHOT_SYMBOL] ??= new Map();
14
+ return processGlobal[FORK_SNAPSHOT_SYMBOL];
15
+ }
16
+
17
+ function canonicalSessionFile(sessionFile: string): string {
18
+ const absolutePath = resolve(sessionFile);
19
+ return existsSync(absolutePath) ? realpathSync(absolutePath) : absolutePath;
20
+ }
21
+
22
+ /** Retain a complete pre-fork hierarchy across Pi extension-instance replacement. */
23
+ export function rememberForkSnapshot(snapshot: ForkSnapshot): void {
24
+ forkSnapshotStore().set(
25
+ canonicalSessionFile(snapshot.source_root_session_file),
26
+ structuredClone(snapshot),
27
+ );
28
+ }
29
+
30
+ /** Consume the pre-fork hierarchy once when the destination root session starts. */
31
+ export function takeForkSnapshot(previousSessionFile: string): ForkSnapshot | undefined {
32
+ const key = canonicalSessionFile(previousSessionFile);
33
+ const snapshot = forkSnapshotStore().get(key);
34
+ if (snapshot) forkSnapshotStore().delete(key);
35
+ return snapshot ? structuredClone(snapshot) : undefined;
36
+ }
@@ -0,0 +1,219 @@
1
+ import type {
2
+ PersistedAgent,
3
+ PersistedDelivery,
4
+ RegistrySnapshot,
5
+ TurnResult,
6
+ } from "./minimal-subagents-types.js";
7
+
8
+ /** Names append-only root conversation entries that own the persistent agent registry. */
9
+ export const REGISTRY_ENTRY_TYPE = "minimal-subagents.registry";
10
+ /** Names the first custom entry that promotes a child JSONL session to persistent identity. */
11
+ export const CHILD_IDENTITY_ENTRY_TYPE = "minimal-subagents.identity";
12
+
13
+ interface RegistryEventBaseV1 {
14
+ version: 1;
15
+ root_session_id: string;
16
+ timestamp: string;
17
+ }
18
+
19
+ /** Stores one complete versioned root registry checkpoint. */
20
+ export interface RegistryCheckpointV1 extends RegistryEventBaseV1 {
21
+ event: "checkpoint";
22
+ snapshot: RegistrySnapshot;
23
+ }
24
+
25
+ /** Records one persistent child identity before runtime initialization. */
26
+ export interface AgentCreatedV1 extends RegistryEventBaseV1 {
27
+ event: "agent-created";
28
+ agent: PersistedAgent;
29
+ }
30
+
31
+ /** Records one collision-resistant active turn identity and start time. */
32
+ export interface TurnStartedV1 extends RegistryEventBaseV1 {
33
+ event: "turn-started";
34
+ agent_id: string;
35
+ turn_id: string;
36
+ started_at: string;
37
+ }
38
+
39
+ /** Records one terminal turn result for waits, status, and delivery recovery. */
40
+ export interface TurnSettledV1 extends RegistryEventBaseV1 {
41
+ event: "turn-settled";
42
+ result: TurnResult;
43
+ }
44
+
45
+ /** Records successful output awaiting keyed destination evidence. */
46
+ export interface DeliveryPendingV1 extends RegistryEventBaseV1 {
47
+ event: "delivery-pending";
48
+ delivery: PersistedDelivery;
49
+ }
50
+
51
+ /** Records keyed destination evidence or the latest delivery error. */
52
+ export interface DeliverySettledV1 extends RegistryEventBaseV1 {
53
+ event: "delivery-settled";
54
+ source_agent_id: string;
55
+ source_turn_id: string;
56
+ error?: string;
57
+ }
58
+
59
+ /** Records durable deletion tombstones for canonical agent identities. */
60
+ export interface AgentDeletedV1 extends RegistryEventBaseV1 {
61
+ event: "agent-deleted";
62
+ agent_ids: string[];
63
+ }
64
+
65
+ /** Unites all version-one append-only registry event envelopes. */
66
+ export type RegistryEventV1 =
67
+ | RegistryCheckpointV1
68
+ | AgentCreatedV1
69
+ | TurnStartedV1
70
+ | TurnSettledV1
71
+ | DeliveryPendingV1
72
+ | DeliverySettledV1
73
+ | AgentDeletedV1;
74
+
75
+ /** Defines unversioned registry payloads accepted by the event factory. */
76
+ export type RegistryEventData =
77
+ | { event: "checkpoint"; snapshot: RegistrySnapshot }
78
+ | { event: "agent-created"; agent: PersistedAgent }
79
+ | { event: "turn-started"; agent_id: string; turn_id: string; started_at: string }
80
+ | { event: "turn-settled"; result: TurnResult }
81
+ | { event: "delivery-pending"; delivery: PersistedDelivery }
82
+ | { event: "delivery-settled"; source_agent_id: string; source_turn_id: string; error?: string }
83
+ | { event: "agent-deleted"; agent_ids: string[] };
84
+
85
+ /** Add the versioned root ownership envelope to one append-only registry event. */
86
+ export function createRegistryEvent<TEvent extends RegistryEventData["event"]>(
87
+ rootSessionId: string,
88
+ event: TEvent,
89
+ data: Omit<Extract<RegistryEventData, { event: TEvent }>, "event">,
90
+ timestamp = new Date().toISOString(),
91
+ ): RegistryEventV1 {
92
+ return {
93
+ version: 1,
94
+ root_session_id: rootSessionId,
95
+ timestamp,
96
+ event,
97
+ ...data,
98
+ } as unknown as RegistryEventV1;
99
+ }
100
+
101
+ interface RegistryEntryLike {
102
+ type?: string;
103
+ customType?: string;
104
+ data?: unknown;
105
+ }
106
+
107
+ function isRegistryEvent(value: unknown, rootSessionId: string): value is RegistryEventV1 {
108
+ if (!value || typeof value !== "object") return false;
109
+ const candidate = value as Partial<RegistryEventV1>;
110
+ return (
111
+ candidate.version === 1 &&
112
+ candidate.root_session_id === rootSessionId &&
113
+ typeof candidate.timestamp === "string" &&
114
+ typeof candidate.event === "string"
115
+ );
116
+ }
117
+
118
+ function cloneSnapshot(snapshot: RegistrySnapshot): RegistrySnapshot {
119
+ return structuredClone(snapshot);
120
+ }
121
+
122
+ function deliveryKey(sourceAgentId: string, sourceTurnId: string): string {
123
+ return `${sourceAgentId}\u0000${sourceTurnId}`;
124
+ }
125
+
126
+ /** Replay root-session-wide registry entries, starting from the latest complete checkpoint. */
127
+ export function replayRegistryEntries(
128
+ entries: readonly RegistryEntryLike[],
129
+ rootSessionId: string,
130
+ ): RegistrySnapshot {
131
+ const events = entries
132
+ .filter((entry) => entry.type === "custom" && entry.customType === REGISTRY_ENTRY_TYPE)
133
+ .map((entry) => entry.data)
134
+ .filter((data): data is RegistryEventV1 => isRegistryEvent(data, rootSessionId));
135
+ let checkpointIndex = -1;
136
+ for (let index = events.length - 1; index >= 0; index--) {
137
+ if (events[index]?.event === "checkpoint") {
138
+ checkpointIndex = index;
139
+ break;
140
+ }
141
+ }
142
+
143
+ let snapshot: RegistrySnapshot = { agents: [], tombstones: [], deliveries: [] };
144
+ if (checkpointIndex >= 0) {
145
+ snapshot = cloneSnapshot((events[checkpointIndex] as RegistryCheckpointV1).snapshot);
146
+ }
147
+ const agents = new Map(snapshot.agents.map((agent) => [agent.agent_id, agent]));
148
+ const tombstones = new Set(snapshot.tombstones);
149
+ const deliveries = new Map(
150
+ snapshot.deliveries.map((delivery) => [
151
+ deliveryKey(delivery.source_agent_id, delivery.source_turn_id),
152
+ delivery,
153
+ ]),
154
+ );
155
+
156
+ for (const event of events.slice(checkpointIndex + 1)) {
157
+ switch (event.event) {
158
+ case "checkpoint": {
159
+ snapshot = cloneSnapshot(event.snapshot);
160
+ agents.clear();
161
+ for (const agent of snapshot.agents) agents.set(agent.agent_id, agent);
162
+ tombstones.clear();
163
+ for (const agentId of snapshot.tombstones) tombstones.add(agentId);
164
+ deliveries.clear();
165
+ for (const delivery of snapshot.deliveries) {
166
+ deliveries.set(deliveryKey(delivery.source_agent_id, delivery.source_turn_id), delivery);
167
+ }
168
+ break;
169
+ }
170
+ case "agent-created":
171
+ if (!tombstones.has(event.agent.agent_id))
172
+ agents.set(event.agent.agent_id, structuredClone(event.agent));
173
+ break;
174
+ case "turn-started": {
175
+ const agent = agents.get(event.agent_id);
176
+ if (agent) {
177
+ agent.active_turn_id = event.turn_id;
178
+ agent.active_turn_started_at = event.started_at;
179
+ }
180
+ break;
181
+ }
182
+ case "turn-settled": {
183
+ const agent = agents.get(event.result.agent_id);
184
+ if (agent) {
185
+ agent.active_turn_id = undefined;
186
+ agent.active_turn_started_at = undefined;
187
+ agent.latest_result = structuredClone(event.result);
188
+ }
189
+ break;
190
+ }
191
+ case "delivery-pending":
192
+ deliveries.set(
193
+ deliveryKey(event.delivery.source_agent_id, event.delivery.source_turn_id),
194
+ structuredClone(event.delivery),
195
+ );
196
+ break;
197
+ case "delivery-settled": {
198
+ const delivery = deliveries.get(deliveryKey(event.source_agent_id, event.source_turn_id));
199
+ if (delivery) {
200
+ delivery.settled = event.error === undefined;
201
+ delivery.error = event.error;
202
+ }
203
+ break;
204
+ }
205
+ case "agent-deleted":
206
+ for (const agentId of event.agent_ids) {
207
+ agents.delete(agentId);
208
+ tombstones.add(agentId);
209
+ }
210
+ break;
211
+ }
212
+ }
213
+
214
+ return {
215
+ agents: [...agents.values()],
216
+ tombstones: [...tombstones],
217
+ deliveries: [...deliveries.values()],
218
+ };
219
+ }