@ian-pascoe/pi-minimal-subagents 0.1.0 → 0.2.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.
- package/README.md +77 -9
- package/package.json +6 -6
- package/src/minimal-subagents-capabilities.ts +8 -8
- package/src/minimal-subagents-config.ts +222 -73
- package/src/minimal-subagents-context.ts +7 -1
- package/src/minimal-subagents-coordinator.ts +943 -116
- package/src/minimal-subagents-delivery-ledger.ts +529 -0
- package/src/minimal-subagents-extension.ts +382 -64
- package/src/minimal-subagents-fork-lifecycle.ts +22 -12
- package/src/minimal-subagents-message-envelope.ts +20 -0
- package/src/minimal-subagents-registry-wire.ts +269 -0
- package/src/minimal-subagents-registry.ts +1505 -132
- package/src/minimal-subagents-render-contract.ts +301 -0
- package/src/minimal-subagents-rendering.ts +513 -372
- package/src/minimal-subagents-session-wire.ts +42 -0
- package/src/minimal-subagents-sessions.ts +437 -101
- package/src/minimal-subagents-shutdown.ts +1 -1
- package/src/minimal-subagents-tool-schemas.ts +21 -7
- package/src/minimal-subagents-tools.ts +75 -25
- package/src/minimal-subagents-types.ts +76 -21
- package/src/minimal-subagents-ui.ts +214 -23
|
@@ -5,14 +5,25 @@ import {
|
|
|
5
5
|
getAgentDir,
|
|
6
6
|
SessionManager,
|
|
7
7
|
SettingsManager,
|
|
8
|
+
type AgentSettledEvent,
|
|
8
9
|
type ExtensionAPI,
|
|
9
10
|
type ExtensionContext,
|
|
11
|
+
type ExtensionFactory,
|
|
12
|
+
type MessageEndEvent,
|
|
13
|
+
type SessionBeforeForkEvent,
|
|
10
14
|
type SessionEntry,
|
|
15
|
+
type SessionShutdownEvent,
|
|
16
|
+
type SessionStartEvent,
|
|
17
|
+
type SessionTreeEvent,
|
|
11
18
|
} from "@earendil-works/pi-coding-agent";
|
|
12
19
|
import { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
|
|
13
20
|
import { resolveMinimalSubagentsSettings } from "./minimal-subagents-config.js";
|
|
14
21
|
import { snapshotCommittedContext } from "./minimal-subagents-context.js";
|
|
15
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
isForkDestinationForSource,
|
|
24
|
+
rememberForkSnapshot,
|
|
25
|
+
takeForkSnapshot,
|
|
26
|
+
} from "./minimal-subagents-fork-lifecycle.js";
|
|
16
27
|
import {
|
|
17
28
|
buildEligibleModelIds,
|
|
18
29
|
COORDINATOR_TOOL_NAMES,
|
|
@@ -22,9 +33,14 @@ import {
|
|
|
22
33
|
CHILD_IDENTITY_ENTRY_TYPE,
|
|
23
34
|
REGISTRY_ENTRY_TYPE,
|
|
24
35
|
replayRegistryEntries,
|
|
36
|
+
type RegistryReplayDiagnostic,
|
|
25
37
|
} from "./minimal-subagents-registry.js";
|
|
26
38
|
import { createCoordinatorToolSchemas } from "./minimal-subagents-tool-schemas.js";
|
|
27
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
findDeliveryEvidence,
|
|
41
|
+
PiAgentSessionFactory,
|
|
42
|
+
type PiAgentSessionFactoryOptions,
|
|
43
|
+
} from "./minimal-subagents-sessions.js";
|
|
28
44
|
import { shutdownMinimalSubagentsSession } from "./minimal-subagents-shutdown.js";
|
|
29
45
|
import { createCoordinatorToolDefinitions } from "./minimal-subagents-tools.js";
|
|
30
46
|
import {
|
|
@@ -33,9 +49,11 @@ import {
|
|
|
33
49
|
} from "./minimal-subagents-rendering.js";
|
|
34
50
|
import { MinimalSubagentsUiController } from "./minimal-subagents-ui.js";
|
|
35
51
|
import type {
|
|
52
|
+
AgentSessionFactory,
|
|
36
53
|
CallerSnapshot,
|
|
37
54
|
CoordinatorNotification,
|
|
38
55
|
ForkSnapshot,
|
|
56
|
+
PersistedAgent,
|
|
39
57
|
RegistrySnapshot,
|
|
40
58
|
RootConversationEndpoint,
|
|
41
59
|
} from "./minimal-subagents-types.js";
|
|
@@ -43,7 +61,7 @@ import type {
|
|
|
43
61
|
const EXTENSION_ENTRYPOINT = fileURLToPath(new URL("./index.ts", import.meta.url));
|
|
44
62
|
|
|
45
63
|
function currentConversationMessages(context: ExtensionContext): AgentMessage[] {
|
|
46
|
-
const entries = context.sessionManager.getEntries()
|
|
64
|
+
const entries = context.sessionManager.getEntries();
|
|
47
65
|
const messages = buildSessionContext(entries, context.sessionManager.getLeafId()).messages;
|
|
48
66
|
return snapshotCommittedContext(messages, !context.isIdle());
|
|
49
67
|
}
|
|
@@ -68,7 +86,7 @@ function createRootConversationEndpoint(
|
|
|
68
86
|
context: ExtensionContext,
|
|
69
87
|
): RootConversationEndpoint {
|
|
70
88
|
return {
|
|
71
|
-
async
|
|
89
|
+
async queueCoordinatorMessage(message) {
|
|
72
90
|
pi.sendMessage(
|
|
73
91
|
{
|
|
74
92
|
customType: message.customType,
|
|
@@ -82,8 +100,14 @@ function createRootConversationEndpoint(
|
|
|
82
100
|
},
|
|
83
101
|
);
|
|
84
102
|
},
|
|
85
|
-
hasDeliveryEvidence: (sourceAgentId, sourceTurnId) =>
|
|
86
|
-
findDeliveryEvidence(
|
|
103
|
+
hasDeliveryEvidence: (sourceAgentId, sourceTurnId, deliveryId) =>
|
|
104
|
+
findDeliveryEvidence(
|
|
105
|
+
context.sessionManager.getBranch(),
|
|
106
|
+
sourceAgentId,
|
|
107
|
+
sourceTurnId,
|
|
108
|
+
deliveryId,
|
|
109
|
+
),
|
|
110
|
+
isIdle: () => context.isIdle(),
|
|
87
111
|
};
|
|
88
112
|
}
|
|
89
113
|
|
|
@@ -111,10 +135,158 @@ function hasHistoricalChildIdentity(entries: readonly SessionEntry[]): boolean {
|
|
|
111
135
|
);
|
|
112
136
|
}
|
|
113
137
|
|
|
114
|
-
function
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return
|
|
138
|
+
function reportInvalidRegistryRecords(
|
|
139
|
+
context: ExtensionContext,
|
|
140
|
+
): (diagnostics: RegistryReplayDiagnostic[]) => void {
|
|
141
|
+
return (diagnostics) => {
|
|
142
|
+
const summaries = diagnostics
|
|
143
|
+
.slice(0, 3)
|
|
144
|
+
.map(({ entry_index: entryIndex, code }) => `entry ${entryIndex}: ${code}`)
|
|
145
|
+
.join(", ");
|
|
146
|
+
context.ui.notify(
|
|
147
|
+
`Minimal subagents Registry ignored ${diagnostics.length} invalid active-branch record${diagnostics.length === 1 ? "" : "s"} (${summaries}${diagnostics.length > 3 ? ", …" : ""}).`,
|
|
148
|
+
"warning",
|
|
149
|
+
);
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function interruptSelectedForkBranch(snapshot: RegistrySnapshot): RegistrySnapshot {
|
|
154
|
+
const interrupted = structuredClone(snapshot);
|
|
155
|
+
for (const agent of interrupted.agents) {
|
|
156
|
+
if (!agent.active_turn_id) continue;
|
|
157
|
+
agent.latest_result = {
|
|
158
|
+
agent_id: agent.agent_id,
|
|
159
|
+
turn_id: agent.active_turn_id,
|
|
160
|
+
status: "interrupted",
|
|
161
|
+
output: "",
|
|
162
|
+
error: "Turn interrupted because its source branch was forked",
|
|
163
|
+
};
|
|
164
|
+
agent.active_turn_id = undefined;
|
|
165
|
+
agent.active_turn_started_at = undefined;
|
|
166
|
+
}
|
|
167
|
+
return interrupted;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function orderForkAgentsParentFirst(agents: readonly PersistedAgent[]): PersistedAgent[] {
|
|
171
|
+
const byId = new Map(agents.map((agent) => [agent.agent_id, agent]));
|
|
172
|
+
const depth = (agent: PersistedAgent) => {
|
|
173
|
+
let value = 0;
|
|
174
|
+
let parentId = agent.parent_id;
|
|
175
|
+
const visited = new Set<string>();
|
|
176
|
+
while (parentId !== "root" && !visited.has(parentId)) {
|
|
177
|
+
visited.add(parentId);
|
|
178
|
+
value++;
|
|
179
|
+
parentId = byId.get(parentId)?.parent_id ?? "root";
|
|
180
|
+
}
|
|
181
|
+
return value;
|
|
182
|
+
};
|
|
183
|
+
return [...agents].sort((left, right) => depth(left) - depth(right));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function unavailableForkAgent(agent: PersistedAgent, error: string): PersistedAgent {
|
|
187
|
+
return {
|
|
188
|
+
...structuredClone(agent),
|
|
189
|
+
session_file: undefined,
|
|
190
|
+
session_id: undefined,
|
|
191
|
+
session_leaf_id: undefined,
|
|
192
|
+
clone_error: error,
|
|
193
|
+
active_turn_id: undefined,
|
|
194
|
+
active_turn_started_at: undefined,
|
|
195
|
+
availability: "unavailable",
|
|
196
|
+
missing_dependencies: [error],
|
|
197
|
+
unavailable_reason: error,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function cloneSelectedForkSessions(
|
|
202
|
+
sessionFactory: AgentSessionFactory,
|
|
203
|
+
snapshot: RegistrySnapshot,
|
|
204
|
+
sourceRootSessionFile: string,
|
|
205
|
+
sourceRootSessionId: string,
|
|
206
|
+
context: ExtensionContext,
|
|
207
|
+
): Promise<ForkSnapshot> {
|
|
208
|
+
const agents: PersistedAgent[] = [];
|
|
209
|
+
const failedSubtrees = new Set<string>();
|
|
210
|
+
for (const agent of orderForkAgentsParentFirst(snapshot.agents)) {
|
|
211
|
+
const failedAncestor = [...failedSubtrees].find(
|
|
212
|
+
(agentId) => agent.agent_id === agentId || agent.agent_id.startsWith(`${agentId}.`),
|
|
213
|
+
);
|
|
214
|
+
if (failedAncestor) {
|
|
215
|
+
agents.push(unavailableForkAgent(agent, `Ancestor clone failed: ${failedAncestor}`));
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const clone = await sessionFactory.cloneForkSourceSession(agent, sourceRootSessionId);
|
|
220
|
+
if (!clone.sessionLeafId) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`Minimal subagents fork recovery: no selected session leaf for ${agent.agent_id}`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
agents.push({
|
|
226
|
+
...structuredClone(agent),
|
|
227
|
+
session_file: clone.sessionFile,
|
|
228
|
+
session_id: clone.sessionId,
|
|
229
|
+
session_leaf_id: clone.sessionLeafId,
|
|
230
|
+
});
|
|
231
|
+
} catch (error) {
|
|
232
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
233
|
+
failedSubtrees.add(agent.agent_id);
|
|
234
|
+
agents.push(unavailableForkAgent(agent, message));
|
|
235
|
+
context.ui.notify(`Fork recovery clone failed for ${agent.agent_id}: ${message}`, "error");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
...structuredClone(snapshot),
|
|
240
|
+
source_root_session_file: sourceRootSessionFile,
|
|
241
|
+
source_root_session_id: sourceRootSessionId,
|
|
242
|
+
agents,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function bindForkSnapshotToDestination(
|
|
247
|
+
sessionFactory: AgentSessionFactory,
|
|
248
|
+
snapshot: ForkSnapshot,
|
|
249
|
+
context: ExtensionContext,
|
|
250
|
+
): Promise<ForkSnapshot> {
|
|
251
|
+
const agents: PersistedAgent[] = [];
|
|
252
|
+
const failedSubtrees = new Set<string>();
|
|
253
|
+
for (const agent of orderForkAgentsParentFirst(snapshot.agents)) {
|
|
254
|
+
const failedAncestor = [...failedSubtrees].find(
|
|
255
|
+
(agentId) => agent.agent_id === agentId || agent.agent_id.startsWith(`${agentId}.`),
|
|
256
|
+
);
|
|
257
|
+
if (failedAncestor || !agent.session_file || !agent.session_id) {
|
|
258
|
+
agents.push(
|
|
259
|
+
unavailableForkAgent(
|
|
260
|
+
agent,
|
|
261
|
+
agent.clone_error ?? `Ancestor ownership failed: ${failedAncestor ?? agent.agent_id}`,
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
const owned = await sessionFactory.adoptForkSessionOwnership(
|
|
268
|
+
agent,
|
|
269
|
+
snapshot.source_root_session_id,
|
|
270
|
+
);
|
|
271
|
+
if (!owned.sessionLeafId) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`Minimal subagents fork ownership: no selected session leaf for ${agent.agent_id}`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
agents.push({
|
|
277
|
+
...structuredClone(agent),
|
|
278
|
+
session_file: owned.sessionFile,
|
|
279
|
+
session_id: owned.sessionId,
|
|
280
|
+
session_leaf_id: owned.sessionLeafId,
|
|
281
|
+
});
|
|
282
|
+
} catch (error) {
|
|
283
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
284
|
+
failedSubtrees.add(agent.agent_id);
|
|
285
|
+
agents.push(unavailableForkAgent(agent, message));
|
|
286
|
+
context.ui.notify(`Fork ownership failed for ${agent.agent_id}: ${message}`, "error");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { ...structuredClone(snapshot), agents };
|
|
118
290
|
}
|
|
119
291
|
|
|
120
292
|
async function waitForRootSessionIdle(context: ExtensionContext): Promise<void> {
|
|
@@ -123,18 +295,49 @@ async function waitForRootSessionIdle(context: ExtensionContext): Promise<void>
|
|
|
123
295
|
}
|
|
124
296
|
}
|
|
125
297
|
|
|
126
|
-
/**
|
|
127
|
-
export
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
298
|
+
/** SDK and runtime construction effects required by the Minimal Subagents lifecycle controller. */
|
|
299
|
+
export interface MinimalSubagentsLifecycleEffects {
|
|
300
|
+
/** Resolve Pi's current agent directory without coupling lifecycle tests to process configuration. */
|
|
301
|
+
getAgentDirectory(): string;
|
|
302
|
+
/** Construct the Pi child-session adapter after root settings and model scope are resolved. */
|
|
303
|
+
createSessionFactory(options: PiAgentSessionFactoryOptions): AgentSessionFactory;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const productionLifecycleEffects: MinimalSubagentsLifecycleEffects = {
|
|
307
|
+
getAgentDirectory: getAgentDir,
|
|
308
|
+
createSessionFactory: (options) => new PiAgentSessionFactory(options),
|
|
309
|
+
};
|
|
131
310
|
|
|
132
|
-
|
|
133
|
-
|
|
311
|
+
/** Own coordinator, UI, and prepared-fork state for one root Pi session lifecycle. */
|
|
312
|
+
export class MinimalSubagentsLifecycleController {
|
|
313
|
+
private coordinator: MinimalSubagentsCoordinator | undefined;
|
|
314
|
+
private uiController: MinimalSubagentsUiController | undefined;
|
|
315
|
+
private preparedFork:
|
|
316
|
+
| { sourceSessionFile: string; selectedBranchSnapshot: RegistrySnapshot }
|
|
317
|
+
| undefined;
|
|
318
|
+
|
|
319
|
+
/** Bind one controller to one Pi extension instance and its runtime construction effects. */
|
|
320
|
+
constructor(
|
|
321
|
+
private readonly pi: ExtensionAPI,
|
|
322
|
+
private readonly effects: MinimalSubagentsLifecycleEffects,
|
|
323
|
+
) {}
|
|
324
|
+
|
|
325
|
+
/** Register renderers and the six Pi lifecycle event handlers owned by Minimal Subagents. */
|
|
326
|
+
register(): void {
|
|
327
|
+
this.pi.registerMessageRenderer("minimal-subagents.message", renderMinimalSubagentsMessage);
|
|
328
|
+
this.pi.registerMessageRenderer("minimal-subagents.result", renderMinimalSubagentsResult);
|
|
329
|
+
|
|
330
|
+
this.pi.on("session_start", (event, context) => this.startSession(event, context));
|
|
331
|
+
this.pi.on("session_before_fork", (event, context) => this.prepareSessionFork(event, context));
|
|
332
|
+
this.pi.on("session_tree", (event, context) => this.restoreSessionTree(event, context));
|
|
333
|
+
this.pi.on("message_end", (event, context) => this.reconcileMessageDelivery(event, context));
|
|
334
|
+
this.pi.on("agent_settled", (event, context) => this.releaseSettledRecipient(event, context));
|
|
335
|
+
this.pi.on("session_shutdown", (event, context) => this.shutdownSession(event, context));
|
|
336
|
+
}
|
|
134
337
|
|
|
135
|
-
|
|
338
|
+
private async startSession(event: SessionStartEvent, context: ExtensionContext): Promise<void> {
|
|
136
339
|
const rootSessionId = context.sessionManager.getSessionId();
|
|
137
|
-
const agentDir =
|
|
340
|
+
const agentDir = this.effects.getAgentDirectory();
|
|
138
341
|
const settingsManager = SettingsManager.create(context.cwd, agentDir, {
|
|
139
342
|
projectTrusted: context.isProjectTrusted(),
|
|
140
343
|
});
|
|
@@ -164,10 +367,18 @@ export default function minimalSubagentsExtension(pi: ExtensionAPI) {
|
|
|
164
367
|
) {
|
|
165
368
|
models.push(context.model);
|
|
166
369
|
}
|
|
167
|
-
const availableToolNames = excludeCoordinatorTools(
|
|
370
|
+
const availableToolNames = excludeCoordinatorTools(
|
|
371
|
+
this.pi.getAllTools().map((tool) => tool.name),
|
|
372
|
+
);
|
|
168
373
|
const schemas = createCoordinatorToolSchemas(eligibleModelIds);
|
|
169
|
-
let activeCoordinator
|
|
170
|
-
const
|
|
374
|
+
let activeCoordinator: MinimalSubagentsCoordinator | undefined;
|
|
375
|
+
const requireActiveCoordinator = (): MinimalSubagentsCoordinator => {
|
|
376
|
+
if (!activeCoordinator) {
|
|
377
|
+
throw new Error("Minimal subagents lifecycle: coordinator is not initialized");
|
|
378
|
+
}
|
|
379
|
+
return activeCoordinator;
|
|
380
|
+
};
|
|
381
|
+
const sessionFactory = this.effects.createSessionFactory({
|
|
171
382
|
cwd: context.cwd,
|
|
172
383
|
agentDir,
|
|
173
384
|
sessionDir: context.sessionManager.getSessionDir(),
|
|
@@ -179,54 +390,94 @@ export default function minimalSubagentsExtension(pi: ExtensionAPI) {
|
|
|
179
390
|
availableToolNames,
|
|
180
391
|
projectTrusted: context.isProjectTrusted(),
|
|
181
392
|
maxSubagentDepth: minimalSubagentsConfig.maxSubagentDepth,
|
|
182
|
-
onChildSessionActivity: () => activeCoordinator
|
|
183
|
-
getCoordinatorTools: (callerId) =>
|
|
184
|
-
|
|
185
|
-
|
|
393
|
+
onChildSessionActivity: () => activeCoordinator?.scheduleDeliveryReconciliation(),
|
|
394
|
+
getCoordinatorTools: (callerId) => {
|
|
395
|
+
const coordinator = requireActiveCoordinator();
|
|
396
|
+
return createCoordinatorToolDefinitions({
|
|
397
|
+
coordinator,
|
|
186
398
|
callerId,
|
|
187
|
-
allowFanoutTools:
|
|
399
|
+
allowFanoutTools: coordinator.canAgentSpawn(callerId),
|
|
188
400
|
modelRoles: minimalSubagentsConfig.modelRoles,
|
|
189
401
|
schemas,
|
|
190
402
|
captureCaller: (childContext) =>
|
|
191
|
-
|
|
403
|
+
coordinator.snapshotChildCaller(
|
|
192
404
|
callerId,
|
|
193
405
|
childContext.sessionManager.getLeafId() ?? callerId,
|
|
194
406
|
),
|
|
195
407
|
onAttention: (message) => context.ui.notify(message, "error"),
|
|
196
|
-
})
|
|
408
|
+
});
|
|
409
|
+
},
|
|
197
410
|
});
|
|
198
411
|
activeCoordinator = new MinimalSubagentsCoordinator({
|
|
199
412
|
sessions: sessionFactory,
|
|
200
|
-
root: createRootConversationEndpoint(pi, context),
|
|
413
|
+
root: createRootConversationEndpoint(this.pi, context),
|
|
201
414
|
maxSubagentDepth: minimalSubagentsConfig.maxSubagentDepth,
|
|
202
415
|
registry: {
|
|
203
416
|
rootSessionId,
|
|
204
|
-
append: (registryEvent) => pi.appendEntry(REGISTRY_ENTRY_TYPE, registryEvent),
|
|
417
|
+
append: (registryEvent) => this.pi.appendEntry(REGISTRY_ENTRY_TYPE, registryEvent),
|
|
205
418
|
},
|
|
206
419
|
notify: (notification) => {
|
|
207
|
-
uiController?.refresh();
|
|
420
|
+
this.uiController?.refresh();
|
|
208
421
|
if (shouldSurfaceNotification(notification)) {
|
|
209
422
|
context.ui.notify(notification.message, notificationLevel(notification));
|
|
210
423
|
}
|
|
211
424
|
},
|
|
212
425
|
});
|
|
213
|
-
coordinator = activeCoordinator;
|
|
426
|
+
this.coordinator = activeCoordinator;
|
|
214
427
|
|
|
215
428
|
let snapshot: RegistrySnapshot;
|
|
216
429
|
if (event.reason === "fork" && event.previousSessionFile) {
|
|
430
|
+
const previousSession = SessionManager.open(event.previousSessionFile);
|
|
431
|
+
const sourceRootSessionId = previousSession.getSessionId();
|
|
217
432
|
let forkSnapshot = takeForkSnapshot(event.previousSessionFile);
|
|
433
|
+
if (forkSnapshot?.source_root_session_id !== sourceRootSessionId) {
|
|
434
|
+
if (forkSnapshot) {
|
|
435
|
+
context.ui.notify(
|
|
436
|
+
"Minimal subagents fork handoff rejected because its source root identity did not match.",
|
|
437
|
+
"error",
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
forkSnapshot = undefined;
|
|
441
|
+
}
|
|
218
442
|
if (!forkSnapshot) {
|
|
219
|
-
|
|
220
|
-
|
|
443
|
+
if (
|
|
444
|
+
isForkDestinationForSource(context.sessionManager.getHeader(), event.previousSessionFile)
|
|
445
|
+
) {
|
|
446
|
+
const selectedSnapshot = interruptSelectedForkBranch(
|
|
447
|
+
replayRegistryEntries(
|
|
448
|
+
context.sessionManager.getBranch(),
|
|
449
|
+
sourceRootSessionId,
|
|
450
|
+
reportInvalidRegistryRecords(context),
|
|
451
|
+
),
|
|
452
|
+
);
|
|
453
|
+
forkSnapshot = await cloneSelectedForkSessions(
|
|
454
|
+
sessionFactory,
|
|
455
|
+
selectedSnapshot,
|
|
456
|
+
event.previousSessionFile,
|
|
457
|
+
sourceRootSessionId,
|
|
458
|
+
context,
|
|
459
|
+
);
|
|
460
|
+
} else {
|
|
461
|
+
context.ui.notify(
|
|
462
|
+
"Minimal subagents fork recovery skipped because the destination selected branch could not be proven from parentSession provenance.",
|
|
463
|
+
"warning",
|
|
464
|
+
);
|
|
465
|
+
}
|
|
221
466
|
}
|
|
222
|
-
snapshot = forkSnapshot
|
|
467
|
+
snapshot = forkSnapshot
|
|
468
|
+
? await bindForkSnapshotToDestination(sessionFactory, forkSnapshot, context)
|
|
469
|
+
: { agents: [], tombstones: [], deliveries: [] };
|
|
223
470
|
} else {
|
|
224
|
-
snapshot = replayRegistryEntries(
|
|
471
|
+
snapshot = replayRegistryEntries(
|
|
472
|
+
context.sessionManager.getBranch(),
|
|
473
|
+
rootSessionId,
|
|
474
|
+
reportInvalidRegistryRecords(context),
|
|
475
|
+
);
|
|
225
476
|
}
|
|
226
477
|
await activeCoordinator.restore(snapshot);
|
|
227
478
|
activeCoordinator.writeCheckpoint();
|
|
228
|
-
uiController = new MinimalSubagentsUiController(activeCoordinator, context);
|
|
229
|
-
uiController.refresh();
|
|
479
|
+
this.uiController = new MinimalSubagentsUiController(activeCoordinator, context);
|
|
480
|
+
this.uiController.refresh();
|
|
230
481
|
|
|
231
482
|
const rootTools = createCoordinatorToolDefinitions({
|
|
232
483
|
coordinator: activeCoordinator,
|
|
@@ -234,46 +485,113 @@ export default function minimalSubagentsExtension(pi: ExtensionAPI) {
|
|
|
234
485
|
allowFanoutTools: true,
|
|
235
486
|
modelRoles: minimalSubagentsConfig.modelRoles,
|
|
236
487
|
schemas,
|
|
237
|
-
captureCaller: (toolContext) => rootCallerSnapshot(pi, toolContext),
|
|
238
|
-
onActivity: () => uiController?.refresh(),
|
|
488
|
+
captureCaller: (toolContext) => rootCallerSnapshot(this.pi, toolContext),
|
|
489
|
+
onActivity: () => this.uiController?.refresh(),
|
|
239
490
|
onAttention: (message) => context.ui.notify(message, "error"),
|
|
240
491
|
});
|
|
241
|
-
for (const tool of rootTools) pi.registerTool(tool);
|
|
242
|
-
pi.setActiveTools([...new Set([...pi.getActiveTools(), ...COORDINATOR_TOOL_NAMES])]);
|
|
492
|
+
for (const tool of rootTools) this.pi.registerTool(tool);
|
|
493
|
+
this.pi.setActiveTools([...new Set([...this.pi.getActiveTools(), ...COORDINATOR_TOOL_NAMES])]);
|
|
243
494
|
|
|
244
|
-
if (hasHistoricalChildIdentity(context.sessionManager.
|
|
495
|
+
if (hasHistoricalChildIdentity(context.sessionManager.getBranch())) {
|
|
245
496
|
context.ui.notify(
|
|
246
497
|
"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
498
|
"warning",
|
|
248
499
|
);
|
|
249
500
|
}
|
|
250
|
-
}
|
|
501
|
+
}
|
|
251
502
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
503
|
+
private async prepareSessionFork(
|
|
504
|
+
event: SessionBeforeForkEvent,
|
|
505
|
+
context: ExtensionContext,
|
|
506
|
+
): Promise<void> {
|
|
507
|
+
const sourceSessionFile = context.sessionManager.getSessionFile();
|
|
508
|
+
if (!sourceSessionFile) {
|
|
509
|
+
this.preparedFork = undefined;
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const selectedEntry = context.sessionManager.getEntry(event.entryId);
|
|
513
|
+
const selectedLeafId =
|
|
514
|
+
event.position === "before" ? (selectedEntry?.parentId ?? undefined) : event.entryId;
|
|
515
|
+
const selectedBranch =
|
|
516
|
+
event.position === "before" && selectedEntry?.parentId === null
|
|
517
|
+
? []
|
|
518
|
+
: context.sessionManager.getBranch(selectedLeafId);
|
|
519
|
+
this.preparedFork = {
|
|
520
|
+
sourceSessionFile,
|
|
521
|
+
selectedBranchSnapshot: interruptSelectedForkBranch(
|
|
522
|
+
replayRegistryEntries(
|
|
523
|
+
selectedBranch,
|
|
524
|
+
context.sessionManager.getSessionId(),
|
|
525
|
+
reportInvalidRegistryRecords(context),
|
|
526
|
+
),
|
|
527
|
+
),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
258
530
|
|
|
259
|
-
|
|
260
|
-
|
|
531
|
+
private async restoreSessionTree(
|
|
532
|
+
_event: SessionTreeEvent,
|
|
533
|
+
context: ExtensionContext,
|
|
534
|
+
): Promise<void> {
|
|
535
|
+
if (!this.coordinator) return;
|
|
536
|
+
const snapshot = replayRegistryEntries(
|
|
537
|
+
context.sessionManager.getBranch(),
|
|
538
|
+
context.sessionManager.getSessionId(),
|
|
539
|
+
reportInvalidRegistryRecords(context),
|
|
540
|
+
);
|
|
541
|
+
await this.coordinator.restore(snapshot);
|
|
542
|
+
this.coordinator.writeCheckpoint();
|
|
543
|
+
this.uiController?.refresh();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private async reconcileMessageDelivery(
|
|
547
|
+
event: MessageEndEvent,
|
|
548
|
+
_context: ExtensionContext,
|
|
549
|
+
): Promise<void> {
|
|
550
|
+
if (!this.coordinator) return;
|
|
261
551
|
if (event.message.role === "toolResult" || event.message.role === "custom") {
|
|
262
|
-
await coordinator.reconcileDeliveries();
|
|
263
|
-
uiController?.refresh();
|
|
552
|
+
await this.coordinator.reconcileDeliveries();
|
|
553
|
+
this.uiController?.refresh();
|
|
264
554
|
}
|
|
265
|
-
}
|
|
555
|
+
}
|
|
266
556
|
|
|
267
|
-
|
|
268
|
-
if (coordinator)
|
|
269
|
-
|
|
557
|
+
private releaseSettledRecipient(_event: AgentSettledEvent, context: ExtensionContext): void {
|
|
558
|
+
if (!this.coordinator) return;
|
|
559
|
+
if (context.isIdle()) this.coordinator.markRecipientIdle("root");
|
|
560
|
+
this.uiController?.refresh();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private async shutdownSession(
|
|
564
|
+
event: SessionShutdownEvent,
|
|
565
|
+
context: ExtensionContext,
|
|
566
|
+
): Promise<void> {
|
|
567
|
+
if (this.coordinator) {
|
|
568
|
+
if (event.reason === "fork" && this.preparedFork) {
|
|
569
|
+
await this.coordinator.restore(this.preparedFork.selectedBranchSnapshot);
|
|
570
|
+
rememberForkSnapshot(
|
|
571
|
+
await this.coordinator.prepareFork(this.preparedFork.sourceSessionFile),
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
await shutdownMinimalSubagentsSession(event.reason, this.coordinator, {
|
|
270
575
|
isRootIdle: () => context.isIdle(),
|
|
271
576
|
waitForRootIdle: () => waitForRootSessionIdle(context),
|
|
272
577
|
});
|
|
273
578
|
}
|
|
274
|
-
uiController?.dispose();
|
|
275
|
-
uiController = undefined;
|
|
276
|
-
coordinator = undefined;
|
|
277
|
-
preparedFork = undefined;
|
|
278
|
-
}
|
|
579
|
+
this.uiController?.dispose();
|
|
580
|
+
this.uiController = undefined;
|
|
581
|
+
this.coordinator = undefined;
|
|
582
|
+
this.preparedFork = undefined;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Compose the Minimal Subagents extension with production or faithful SDK runtime effects. */
|
|
587
|
+
export function createMinimalSubagentsExtension(
|
|
588
|
+
effects: MinimalSubagentsLifecycleEffects = productionLifecycleEffects,
|
|
589
|
+
): ExtensionFactory {
|
|
590
|
+
return (pi) => {
|
|
591
|
+
new MinimalSubagentsLifecycleController(pi, effects).register();
|
|
592
|
+
};
|
|
279
593
|
}
|
|
594
|
+
|
|
595
|
+
const minimalSubagentsExtension = createMinimalSubagentsExtension();
|
|
596
|
+
|
|
597
|
+
export default minimalSubagentsExtension;
|
|
@@ -2,16 +2,14 @@ import { existsSync, realpathSync } from "node:fs";
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import type { ForkSnapshot } from "./minimal-subagents-types.js";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
};
|
|
5
|
+
declare global {
|
|
6
|
+
// eslint-disable-next-line no-var -- A process-global handoff must be visible to replacement extension instances.
|
|
7
|
+
var minimalSubagentsForkSnapshots: Map<string, ForkSnapshot> | undefined;
|
|
8
|
+
}
|
|
10
9
|
|
|
11
10
|
function forkSnapshotStore(): Map<string, ForkSnapshot> {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return processGlobal[FORK_SNAPSHOT_SYMBOL];
|
|
11
|
+
globalThis.minimalSubagentsForkSnapshots ??= new Map();
|
|
12
|
+
return globalThis.minimalSubagentsForkSnapshots;
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
function canonicalSessionFile(sessionFile: string): string {
|
|
@@ -19,12 +17,24 @@ function canonicalSessionFile(sessionFile: string): string {
|
|
|
19
17
|
return existsSync(absolutePath) ? realpathSync(absolutePath) : absolutePath;
|
|
20
18
|
}
|
|
21
19
|
|
|
20
|
+
/** Prove a process-loss fork destination was derived from the expected canonical source file. */
|
|
21
|
+
export function isForkDestinationForSource(
|
|
22
|
+
destinationHeader: { parentSession?: string } | null,
|
|
23
|
+
previousSessionFile: string,
|
|
24
|
+
): boolean {
|
|
25
|
+
return (
|
|
26
|
+
destinationHeader?.parentSession !== undefined &&
|
|
27
|
+
canonicalSessionFile(destinationHeader.parentSession) ===
|
|
28
|
+
canonicalSessionFile(previousSessionFile)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
/** Retain a complete pre-fork hierarchy across Pi extension-instance replacement. */
|
|
23
33
|
export function rememberForkSnapshot(snapshot: ForkSnapshot): void {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
);
|
|
34
|
+
const canonicalSourceFile = canonicalSessionFile(snapshot.source_root_session_file);
|
|
35
|
+
const retained = structuredClone(snapshot);
|
|
36
|
+
retained.source_root_session_file = canonicalSourceFile;
|
|
37
|
+
forkSnapshotStore().set(canonicalSourceFile, retained);
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
/** Consume the pre-fork hierarchy once when the destination root session starts. */
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CoordinatorMessage } from "./minimal-subagents-types.js";
|
|
2
|
+
|
|
3
|
+
/** Add the stable source identity that the receiving model must see. */
|
|
4
|
+
export function addCoordinatorMessageEnvelope(message: CoordinatorMessage): CoordinatorMessage {
|
|
5
|
+
const kind = message.customType === "minimal-subagents.result" ? "result" : "message";
|
|
6
|
+
const status = message.details.status ? ` | status=${message.details.status}` : "";
|
|
7
|
+
const envelope = `[Subagent ${kind} | agent=${message.details.source_agent_id} | turn=${message.details.source_turn_id}${status}]`;
|
|
8
|
+
return {
|
|
9
|
+
...message,
|
|
10
|
+
content: message.content.length > 0 ? `${envelope}\n${message.content}` : envelope,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Remove the model-only envelope from the styled TUI message body. */
|
|
15
|
+
export function stripCoordinatorMessageEnvelope(content: string): string {
|
|
16
|
+
return content.replace(
|
|
17
|
+
/^\[Subagent (?:message|result) \| agent=[^|\]\n]+ \| turn=[^|\]\n]+(?: \| status=[^|\]\n]+)?\]\n?/,
|
|
18
|
+
"",
|
|
19
|
+
);
|
|
20
|
+
}
|