@oldflag2333333/pi-subagent 0.0.0-stage → 0.1.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.
@@ -0,0 +1,49 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ResolvedProfile } from "./types.js";
4
+ import { isMcpTool, MCP_EXTENSIONS } from "./mcp.js";
5
+
6
+ const NATIVE_TOOLS = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"]);
7
+ const SUPPORTED_BUILTIN_EXTENSIONS = new Set<string>(MCP_EXTENSIONS);
8
+
9
+ interface ToolSource {
10
+ name: string;
11
+ sourceInfo: {
12
+ path: string;
13
+ source: string;
14
+ };
15
+ }
16
+
17
+ export function resolveToolExtensions(profile: ResolvedProfile, tools: ToolSource[]): ResolvedProfile {
18
+ const available = new Map(tools.map((tool) => [tool.name, tool]));
19
+ const extensionPaths = new Set<string>();
20
+ for (const name of profile.tools) {
21
+ // Native MCP connects asynchronously; do not require its tools in Main yet.
22
+ if (isMcpTool(name)) continue;
23
+ const tool = available.get(name);
24
+ if (!tool) throw new Error(`Profile '${profile.name}' references unavailable tool '${name}'.`);
25
+ const sourcePath = tool.sourceInfo.path;
26
+ if (tool.sourceInfo.source === "builtin") {
27
+ if (NATIVE_TOOLS.has(name) && (sourcePath === `builtin:${name}` || sourcePath === `<builtin:${name}>`)) continue;
28
+ if (!SUPPORTED_BUILTIN_EXTENSIONS.has(sourcePath)) {
29
+ throw new Error(`Profile '${profile.name}' tool '${name}' requires unsupported isolated built-in extension '${sourcePath}'.`);
30
+ }
31
+ extensionPaths.add(sourcePath);
32
+ continue;
33
+ }
34
+ if (tool.sourceInfo.source === "sdk" || !sourcePath || sourcePath.startsWith("<")) {
35
+ throw new Error(`Profile '${profile.name}' tool '${name}' cannot be loaded in an isolated Sub process.`);
36
+ }
37
+ let stat: fs.Stats;
38
+ try {
39
+ stat = fs.statSync(sourcePath);
40
+ } catch {
41
+ throw new Error(`Profile '${profile.name}' tool '${name}' extension is missing: ${sourcePath}.`);
42
+ }
43
+ if (!stat.isFile() && !stat.isDirectory()) {
44
+ throw new Error(`Profile '${profile.name}' tool '${name}' has an unsupported extension path: ${sourcePath}.`);
45
+ }
46
+ extensionPaths.add(path.resolve(sourcePath));
47
+ }
48
+ return { ...profile, resolvedExtensions: [...extensionPaths] };
49
+ }
@@ -0,0 +1,37 @@
1
+ export type ProfileSource = "global" | "project";
2
+ export type SessionPersistence = "ephemeral" | "persistent";
3
+ export type ProfileInvocation = "both" | "manual";
4
+
5
+ export interface ProfileDefinition {
6
+ version: 1;
7
+ name: string;
8
+ description?: string;
9
+ model?: string;
10
+ thinkingLevel?: string;
11
+ sessionPersistence?: SessionPersistence;
12
+ /** Manual profiles are started by user commands, not model delegation. */
13
+ invocation?: ProfileInvocation;
14
+ tools: string[];
15
+ skills?: string[];
16
+ instructions?: string;
17
+ }
18
+
19
+ export interface LoadedProfile extends ProfileDefinition {
20
+ source: ProfileSource;
21
+ sourcePath: string;
22
+ }
23
+
24
+ export interface ResolvedProfile extends LoadedProfile {
25
+ resolvedSkills: string[];
26
+ resolvedExtensions: string[];
27
+ }
28
+
29
+ export interface ProfileDiagnostic {
30
+ path: string;
31
+ message: string;
32
+ }
33
+
34
+ export interface ProfileCatalog {
35
+ profiles: Map<string, LoadedProfile>;
36
+ diagnostics: ProfileDiagnostic[];
37
+ }
@@ -0,0 +1,442 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { AdapterRegistry } from "./adapters/index.js";
6
+ import { ChannelMonitor } from "./channel-monitor.js";
7
+ import { bindInboxEvents } from "./inbox-state.js";
8
+ import { HerdrLaunchCleanupError } from "./adapters/herdr.js";
9
+ import {
10
+ createChannel,
11
+ readActiveTurn,
12
+ listTalkToMain,
13
+ readSubClosed,
14
+ readSubSessionInfo,
15
+ readManifest,
16
+ removeChannel,
17
+ talkToSub,
18
+ writeClose,
19
+ writeSubClosed,
20
+ writeInterrupt,
21
+ } from "./channel.js";
22
+ import type { ResolvedProfile } from "./profiles/types.js";
23
+ import { selectResumableSubSessions, resolveResumableSubSession, type ResumableSubSession } from "./sessions.js";
24
+ import { MANUAL_MAIN_GUIDANCE } from "./manual-context.js";
25
+ import { deliverTalk, ProtocolErrors } from "./talk-delivery.js";
26
+ import type { RunSnapshot, SubAgentStatus } from "./types.js";
27
+
28
+ const RUN_ENTRY = "subagent-run";
29
+ const MANUAL_BINDING_ENTRY = "subagent-manual-profile";
30
+ export const MAX_OPEN_SUBS = 4;
31
+
32
+ function parseSnapshot(value: unknown): RunSnapshot | undefined {
33
+ if (!value || typeof value !== "object") return;
34
+ const item = value as Partial<RunSnapshot>;
35
+ if (item.version !== 1 || typeof item.runId !== "string" || typeof item.mainSessionId !== "string"
36
+ || typeof item.title !== "string" || typeof item.cwd !== "string"
37
+ || typeof item.profileName !== "string" || typeof item.channelDir !== "string"
38
+ || typeof item.createdAt !== "number" || typeof item.updatedAt !== "number") return;
39
+ if (item.surface && item.surface.adapter !== "herdr") return;
40
+ return {
41
+ version: 1,
42
+ runId: item.runId,
43
+ mainSessionId: item.mainSessionId,
44
+ title: item.title,
45
+ cwd: item.cwd,
46
+ profileName: item.profileName,
47
+ ...(item.origin === "manual" ? { origin: "manual" as const } : {}),
48
+ ...(typeof item.purpose === "string" ? { purpose: item.purpose } : {}),
49
+ sessionPersistence: item.sessionPersistence === "persistent" ? "persistent" : "ephemeral",
50
+ channelDir: item.channelDir,
51
+ createdAt: item.createdAt,
52
+ updatedAt: item.updatedAt,
53
+ ...(typeof item.closedAt === "number" ? { closedAt: item.closedAt } : {}),
54
+ ...(typeof item.subSessionId === "string" ? { subSessionId: item.subSessionId } : {}),
55
+ ...(typeof item.subSessionFile === "string" ? { subSessionFile: item.subSessionFile } : {}),
56
+ ...(item.surface ? { surface: item.surface } : {}),
57
+ };
58
+ }
59
+
60
+ export class MainRunManager {
61
+ readonly runs = new Map<string, RunSnapshot>();
62
+ private readonly titles = new Map<string, string>();
63
+ private readonly adapters: AdapterRegistry;
64
+ private readonly closedRuns = new Map<string, RunSnapshot>();
65
+ private readonly pollErrors = new ProtocolErrors();
66
+ private readonly monitor: ChannelMonitor;
67
+ private eventsBound = false;
68
+ private ctx?: ExtensionContext;
69
+ private sessionId?: string;
70
+ private readonly history = new Map<string, RunSnapshot>();
71
+ private readonly manualBindings = new Map<string, string>();
72
+ private manualQueue: Promise<void> = Promise.resolve();
73
+ private manualLifetime = new AbortController();
74
+
75
+ constructor(private readonly pi: ExtensionAPI) {
76
+ this.adapters = new AdapterRegistry(pi);
77
+ this.monitor = new ChannelMonitor(
78
+ (ids) => this.poll(ids),
79
+ (id, error) => {
80
+ if (this.ctx) this.pollErrors.report(this.ctx, `watch:${id}`, new Error(`File watching unavailable; the 5-second scan remains active. ${error instanceof Error ? error.message : String(error)}`));
81
+ },
82
+ (id) => this.pollErrors.clear(`watch:${id}`),
83
+ );
84
+ }
85
+
86
+ start(ctx: ExtensionContext): void {
87
+ const sessionId = ctx.sessionManager.getSessionId();
88
+ if (this.sessionId && this.sessionId !== sessionId) {
89
+ this.manualLifetime.abort();
90
+ this.monitor.stop();
91
+ this.runs.clear();
92
+ this.closedRuns.clear();
93
+ this.titles.clear();
94
+ this.history.clear();
95
+ this.manualBindings.clear();
96
+ }
97
+ if (this.manualLifetime.signal.aborted) this.manualLifetime = new AbortController();
98
+ this.sessionId = sessionId;
99
+ this.ctx = ctx;
100
+ if (!this.eventsBound) {
101
+ bindInboxEvents(this.pi, () => this.ctx, () => this.monitor.wakeAll());
102
+ this.eventsBound = true;
103
+ }
104
+ this.restore(ctx);
105
+ for (const run of [...this.runs.values(), ...this.closedRuns.values()]) this.monitor.add(run.runId, run.channelDir, "to-main");
106
+ this.monitor.start();
107
+ this.poll();
108
+ }
109
+
110
+ shutdown(): void {
111
+ this.manualLifetime.abort();
112
+ this.monitor.stop();
113
+ this.ctx = undefined;
114
+ }
115
+
116
+ private restore(ctx: ExtensionContext): void {
117
+ const latest = new Map<string, RunSnapshot>();
118
+ for (const entry of ctx.sessionManager.getEntries()) {
119
+ if (entry.type === "custom" && entry.customType === MANUAL_BINDING_ENTRY) {
120
+ const binding = entry.data as { mainSessionId?: string; profileName?: string; runId?: string } | undefined;
121
+ if (binding?.mainSessionId === ctx.sessionManager.getSessionId() && typeof binding.profileName === "string" && typeof binding.runId === "string") {
122
+ this.manualBindings.set(binding.profileName, binding.runId);
123
+ }
124
+ }
125
+ if (entry.type !== "custom" || entry.customType !== RUN_ENTRY) continue;
126
+ const snapshot = parseSnapshot(entry.data);
127
+ if (snapshot?.mainSessionId === ctx.sessionManager.getSessionId()) latest.set(snapshot.runId, snapshot);
128
+ }
129
+ for (const run of latest.values()) {
130
+ this.history.set(run.runId, run);
131
+ this.titles.set(run.runId, run.title);
132
+ if (fs.existsSync(run.channelDir)) {
133
+ (run.closedAt === undefined ? this.runs : this.closedRuns).set(run.runId, run);
134
+ }
135
+ }
136
+ }
137
+
138
+ private save(run: RunSnapshot): void {
139
+ run.updatedAt = Date.now();
140
+ this.history.set(run.runId, run);
141
+ if (run.closedAt === undefined) {
142
+ this.runs.set(run.runId, run);
143
+ this.closedRuns.delete(run.runId);
144
+ } else {
145
+ this.runs.delete(run.runId);
146
+ this.closedRuns.set(run.runId, run);
147
+ }
148
+ this.titles.set(run.runId, run.title);
149
+ this.pi.appendEntry(RUN_ENTRY, { ...run });
150
+ this.monitor.add(run.runId, run.channelDir, "to-main");
151
+ this.monitor.wake(run.runId);
152
+ }
153
+
154
+ titleFor(runId: string): string | undefined {
155
+ const open = this.runs.get(runId) ?? [...this.runs.values()].find((run) => run.runId.startsWith(runId));
156
+ if (open) {
157
+ this.titles.set(open.runId, open.title);
158
+ return open.title;
159
+ }
160
+ return this.titles.get(runId) ?? [...this.titles].find(([id]) => id.startsWith(runId))?.[1];
161
+ }
162
+
163
+ private findRun(runId: string): RunSnapshot {
164
+ const exact = this.runs.get(runId);
165
+ if (exact) return exact;
166
+ const matches = [...this.runs.values()].filter((candidate) => candidate.runId.startsWith(runId));
167
+ if (matches.length > 1) throw new Error(`Ambiguous Sub prefix '${runId}'. Use a longer run ID.`);
168
+ if (!matches[0]) throw new Error(`Unknown Sub '${runId}'.`);
169
+ return matches[0];
170
+ }
171
+
172
+ async delegate(input: {
173
+ title: string;
174
+ task: string;
175
+ cwd: string;
176
+ profile: ResolvedProfile;
177
+ resumeSessionId?: string;
178
+ origin?: "manual";
179
+ }, signal?: AbortSignal): Promise<RunSnapshot> {
180
+ if (!this.ctx) throw new Error("Pi Subagent is not attached to an active Main session.");
181
+ if (input.profile.invocation === "manual" && input.origin !== "manual") throw new Error("This profile is user-invoked only; use its slash command.");
182
+ if (this.runs.size >= MAX_OPEN_SUBS) throw new Error(`Pi Subagent allows at most ${MAX_OPEN_SUBS} open Subs.`);
183
+ const resume = input.resumeSessionId ? resolveResumableSubSession(input.resumeSessionId, this.resumableSessions(), input.origin === "manual") : undefined;
184
+ if (resume && input.profile.sessionPersistence !== "persistent") {
185
+ throw new Error(`Profile '${input.profile.name}' must use sessionPersistence 'persistent' when resuming a Sub session.`);
186
+ }
187
+ if (resume && [...this.runs.values()].some((run) => run.subSessionId === resume.sessionId)) {
188
+ throw new Error(`Persistent Sub session '${resume.sessionId}' is already open.`);
189
+ }
190
+ const runId = randomUUID();
191
+ const mainSessionId = this.ctx.sessionManager.getSessionId();
192
+ const projectTrusted = this.ctx.isProjectTrusted();
193
+ const cwd = resume?.cwd ?? input.cwd;
194
+ const channel = createChannel({
195
+ runId,
196
+ mainSessionId,
197
+ title: input.title,
198
+ task: input.task,
199
+ cwd,
200
+ profile: input.profile,
201
+ ...(input.origin ? { origin: input.origin } : {}),
202
+ });
203
+ const run: RunSnapshot = {
204
+ version: 1,
205
+ runId,
206
+ mainSessionId,
207
+ title: input.title,
208
+ cwd,
209
+ profileName: input.profile.name,
210
+ ...(input.origin ? { origin: input.origin, purpose: input.profile.description } : {}),
211
+ sessionPersistence: input.profile.sessionPersistence ?? "ephemeral",
212
+ channelDir: channel.channelDir,
213
+ createdAt: Date.now(),
214
+ updatedAt: Date.now(),
215
+ ...(resume ? { subSessionId: resume.sessionId, subSessionFile: resume.sessionFile } : {}),
216
+ };
217
+ try {
218
+ this.save(run);
219
+ const adapter = await this.adapters.resolve();
220
+ const entryPath = fileURLToPath(new URL("./index.ts", import.meta.url));
221
+ run.surface = await adapter.launch({
222
+ runId,
223
+ mainSessionId,
224
+ title: input.title,
225
+ task: input.task,
226
+ cwd,
227
+ projectTrusted,
228
+ ...(input.origin ? { origin: input.origin } : {}),
229
+ ...(resume ? { resumeSessionId: resume.sessionId, resumeSessionFile: resume.sessionFile } : {}),
230
+ profile: input.profile,
231
+ channelDir: channel.channelDir,
232
+ token: channel.token,
233
+ entryPath,
234
+ }, signal);
235
+ const session = readSubSessionInfo(run.channelDir, channel);
236
+ if (session) {
237
+ run.subSessionId = session.sessionId;
238
+ run.subSessionFile = session.sessionFile;
239
+ }
240
+ this.save(run);
241
+ if (input.origin === "manual") {
242
+ this.pi.appendEntry(MANUAL_BINDING_ENTRY, { mainSessionId, profileName: input.profile.name, runId });
243
+ this.manualBindings.set(input.profile.name, runId);
244
+ }
245
+ return run;
246
+ } catch (error) {
247
+ let cleanupFailure: unknown;
248
+ if (error instanceof HerdrLaunchCleanupError) {
249
+ run.surface = error.handle;
250
+ cleanupFailure = error;
251
+ } else if (run.surface) {
252
+ try { await this.adapters.close(run.surface); } catch (cleanup) { cleanupFailure = cleanup; }
253
+ }
254
+ if (cleanupFailure) {
255
+ let persistenceFailure = "";
256
+ try { this.save(run); } catch (persist) { persistenceFailure = ` Recovery state could not be persisted: ${persist instanceof Error ? persist.message : String(persist)}.`; }
257
+ const original = error instanceof Error ? error.message : String(error);
258
+ const cleanup = cleanupFailure instanceof Error ? cleanupFailure.message : String(cleanupFailure);
259
+ const failure = error === cleanupFailure ? original : `${original} Cleanup failed: ${cleanup}`;
260
+ throw new Error(`${failure} Sub run ${run.runId} remains open.${persistenceFailure} Use close_sub to retry.`, { cause: error });
261
+ }
262
+ this.retainClosed(run, "The Sub launch did not complete.");
263
+ throw error;
264
+ }
265
+ }
266
+
267
+ /** Commands serialize launch/reuse so two invocations cannot create two sessions. */
268
+ invokeProfile(input: { profile: ResolvedProfile; task: string; cwd: string }): Promise<{ run: RunSnapshot; action: "created" | "resumed" | "queued" }> {
269
+ const owner = this.ctx?.sessionManager.getSessionId();
270
+ const signal = this.manualLifetime.signal;
271
+ const pending = this.manualQueue.then(async () => {
272
+ signal.throwIfAborted();
273
+ if (!owner || this.ctx?.sessionManager.getSessionId() !== owner) throw new Error("The Main session changed before the profile command ran.");
274
+ const recoverable = [...this.runs.values()].filter((run) => run.origin === "manual" && run.mainSessionId === owner && run.profileName === input.profile.name);
275
+ if (recoverable.length > 1) throw new Error("Multiple manual runs need cleanup before this profile can be invoked again.");
276
+ const bound = this.manualBindings.get(input.profile.name) ?? recoverable[0]?.runId;
277
+ const previous = bound ? this.history.get(bound) : undefined;
278
+ if (bound && (!previous || previous.mainSessionId !== owner || previous.origin !== "manual" || previous.profileName !== input.profile.name)) throw new Error("The saved profile binding is unavailable; refusing to create a replacement session.");
279
+ if (previous && fs.existsSync(previous.channelDir)) {
280
+ const manifest = readManifest(previous.channelDir);
281
+ const session = readSubSessionInfo(previous.channelDir, manifest);
282
+ if (session) {
283
+ previous.subSessionId = session.sessionId;
284
+ previous.subSessionFile = session.sessionFile;
285
+ this.save(previous);
286
+ }
287
+ if (previous.closedAt === undefined && readSubClosed(previous.channelDir, manifest)) {
288
+ this.retainClosed(previous, "The user-invoked Sub was closed.");
289
+ }
290
+ }
291
+ const task = `[User-invoked profile: ${input.profile.name}]\nThe user explicitly requests this task:\n${input.task}`;
292
+ if (previous && previous.closedAt === undefined && fs.existsSync(previous.channelDir)) {
293
+ const status = await this.adapters.status(previous.surface);
294
+ if (status === "unknown") {
295
+ if (await this.adapters.exists(previous.surface) !== false) throw new Error("Cannot verify the existing Sub is available. Close its tab before retrying; no duplicate session was created.");
296
+ this.retainClosed(previous, "Herdr confirmed that the Sub tab is closed.");
297
+ } else {
298
+ signal.throwIfAborted();
299
+ this.talk(previous.runId, task);
300
+ return { run: previous, action: "queued" as const };
301
+ }
302
+ }
303
+ if (previous && previous.closedAt === undefined && !fs.existsSync(previous.channelDir)) {
304
+ if (await this.adapters.exists(previous.surface) !== false) throw new Error("The existing Sub channel is missing and its tab is not confirmed closed; refusing to open the session twice.");
305
+ this.finishClosed(previous);
306
+ }
307
+ if (previous?.sessionPersistence === "persistent") {
308
+ if (input.profile.sessionPersistence !== "persistent") throw new Error("This profile already has a persistent binding; keep sessionPersistence set to persistent to reuse it.");
309
+ if (!previous.subSessionId) throw new Error("The previous Sub session ID was not recorded; refusing to create a replacement session.");
310
+ }
311
+ const resumeSessionId = previous?.sessionPersistence === "persistent" ? previous.subSessionId : undefined;
312
+ const run = await this.delegate({
313
+ title: input.profile.name, task, cwd: input.cwd, profile: input.profile, origin: "manual",
314
+ ...(resumeSessionId ? { resumeSessionId } : {}),
315
+ }, signal);
316
+ return { run, action: resumeSessionId ? "resumed" as const : "created" as const };
317
+ });
318
+ this.manualQueue = pending.then(() => {}, () => {});
319
+ return pending;
320
+ }
321
+
322
+ talk(runId: string, message: string): { run: RunSnapshot; messageId: string } {
323
+ const run = this.findRun(runId);
324
+ const manifest = readManifest(run.channelDir);
325
+ const sent = talkToSub(run.channelDir, manifest, message);
326
+ return { run, messageId: sent.id };
327
+ }
328
+
329
+ private resumableSessions(): ResumableSubSession[] {
330
+ if (!this.sessionId) return [];
331
+ return selectResumableSubSessions(this.history.values(), this.sessionId);
332
+ }
333
+
334
+ async subs(): Promise<{
335
+ open: Array<{ run: RunSnapshot; status: SubAgentStatus }>;
336
+ resumable: ResumableSubSession[];
337
+ }> {
338
+ if (!this.ctx) return { open: [], resumable: [] };
339
+ this.poll();
340
+ const owner = this.sessionId;
341
+ const runs = [...this.runs.values()].filter((run) => run.mainSessionId === owner);
342
+ const open = await Promise.all(runs.map(async (run) => ({ run, status: await this.adapters.status(run.surface) })));
343
+ // Main may switch sessions or a Sub may close while Herdr queries are pending.
344
+ if (!this.ctx || this.sessionId !== owner) return { open: [], resumable: [] };
345
+ return {
346
+ open: open.filter(({ run }) => this.runs.has(run.runId) && run.closedAt === undefined),
347
+ resumable: this.resumableSessions(),
348
+ };
349
+ }
350
+
351
+ interrupt(runId: string): RunSnapshot {
352
+ const run = this.findRun(runId);
353
+ const manifest = readManifest(run.channelDir);
354
+ const turn = readActiveTurn(run.channelDir, manifest);
355
+ if (!turn) throw new Error(`Sub '${run.title}' has no active turn to interrupt.`);
356
+ writeInterrupt(run.channelDir, manifest, turn);
357
+ return run;
358
+ }
359
+
360
+ async close(runId: string, reason: string): Promise<RunSnapshot> {
361
+ const run = this.findRun(runId);
362
+ try {
363
+ const manifest = readManifest(run.channelDir);
364
+ writeClose(run.channelDir, manifest, reason);
365
+ } catch {}
366
+ await this.adapters.close(run.surface);
367
+ this.retainClosed(run, reason);
368
+ return run;
369
+ }
370
+
371
+ private retainClosed(run: RunSnapshot, reason: string): void {
372
+ run.closedAt = Date.now();
373
+ try {
374
+ const manifest = readManifest(run.channelDir);
375
+ const session = readSubSessionInfo(run.channelDir, manifest);
376
+ if (session) { run.subSessionId = session.sessionId; run.subSessionFile = session.sessionFile; }
377
+ // Preserve the closure fact even if session persistence later fails.
378
+ writeSubClosed(run.channelDir, manifest, reason);
379
+ if (listTalkToMain(run.channelDir, manifest).length === 0) {
380
+ this.finishClosed(run);
381
+ return;
382
+ }
383
+ } catch (error) { this.reportChannelError(run, error); }
384
+ // A closed surface with a pending or broken inbox is not an open Sub.
385
+ this.save(run);
386
+ }
387
+
388
+ private reportChannelError(run: RunSnapshot, error: unknown): void {
389
+ if (this.ctx) this.pollErrors.report(this.ctx, run.runId, error);
390
+ else console.error(`Pi Subagent channel error (${run.runId}): ${error instanceof Error ? error.message : String(error)}`);
391
+ }
392
+
393
+ private finishClosed(run: RunSnapshot): void {
394
+ if (run.origin === "manual" || (run.sessionPersistence === "persistent" && run.subSessionId && run.subSessionFile)) {
395
+ run.closedAt ??= Date.now();
396
+ this.save(run); // Keep this Main's closed session identity after channel cleanup.
397
+ }
398
+ removeChannel(run.channelDir);
399
+ this.monitor.remove(run.runId);
400
+ this.runs.delete(run.runId);
401
+ this.closedRuns.delete(run.runId);
402
+ this.pollErrors.clear(run.runId);
403
+ }
404
+
405
+ private poll(ids?: string[]): void {
406
+ const ctx = this.ctx;
407
+ if (!ctx) return;
408
+ const runs = ids ? ids.flatMap((id) => {
409
+ const run = this.runs.get(id) ?? this.closedRuns.get(id);
410
+ return run ? [run] : [];
411
+ }) : [...this.runs.values(), ...this.closedRuns.values()];
412
+ for (const run of runs) {
413
+ try {
414
+ const manifest = readManifest(run.channelDir);
415
+ if (manifest.runId !== run.runId || manifest.mainSessionId !== run.mainSessionId) throw new Error("Channel manifest identity mismatch.");
416
+ const session = readSubSessionInfo(run.channelDir, manifest);
417
+ if (session && (run.subSessionId !== session.sessionId || run.subSessionFile !== session.sessionFile)) {
418
+ run.subSessionId = session.sessionId;
419
+ run.subSessionFile = session.sessionFile;
420
+ this.save(run);
421
+ }
422
+ if (run.closedAt === undefined && readSubClosed(run.channelDir, manifest)) {
423
+ run.closedAt = Date.now();
424
+ this.save(run);
425
+ }
426
+ const messages = listTalkToMain(run.channelDir, manifest);
427
+ let pending = messages.length;
428
+ for (const message of messages) {
429
+ const content = `[Pi Subagent Sub message]${run.origin === "manual" ? `\n[User-invoked specialist: ${run.profileName}]\n${run.purpose ? `Purpose: ${run.purpose}\n` : ""}${MANUAL_MAIN_GUIDANCE}` : ""}\nSub '${run.title}' (${run.runId}) says:\n${message.message}\n\n${run.closedAt === undefined ? `Use talk with runId '${run.runId}' to respond, or close_sub when the delivery is accepted and no more work is needed.` : "This Sub has already closed; this is a retained final delivery."}`;
430
+ const result = deliverTalk(this.pi, ctx, run.channelDir, manifest, "to-main", message, content);
431
+ if (result === "acknowledged") pending--;
432
+ }
433
+ if (run.closedAt !== undefined && pending === 0) this.finishClosed(run);
434
+ this.pollErrors.clear(run.runId);
435
+ } catch (error) {
436
+ this.pollErrors.report(ctx, run.runId, error);
437
+ }
438
+ }
439
+ }
440
+ }
441
+
442
+ export { RUN_ENTRY };
@@ -0,0 +1,61 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import type { RunSnapshot } from "./types.js";
5
+
6
+ export function subSessionsRoot(): string {
7
+ return path.join(getAgentDir(), "subagent", "sessions");
8
+ }
9
+
10
+ export function subSessionDir(mainSessionId: string): string {
11
+ if (!mainSessionId || mainSessionId === "." || mainSessionId === "..") throw new Error("Invalid Main session ID.");
12
+ return path.join(subSessionsRoot(), encodeURIComponent(mainSessionId));
13
+ }
14
+
15
+ export interface ResumableSubSession {
16
+ sessionId: string;
17
+ sessionFile: string;
18
+ title: string;
19
+ cwd: string;
20
+ modifiedAt: number;
21
+ origin?: "manual";
22
+ profileName?: string;
23
+ purpose?: string;
24
+ }
25
+
26
+ /** Only confirmed-closed sessions recorded by this Main are eligible. */
27
+ export function selectResumableSubSessions(runs: Iterable<RunSnapshot>, mainSessionId: string): ResumableSubSession[] {
28
+ const owned = [...runs].filter((run) => run.mainSessionId === mainSessionId);
29
+ // A missing channel is not proof of closure. An unclosed run still blocks resume.
30
+ const activeIds = new Set(owned.filter((run) => run.closedAt === undefined).map((run) => run.subSessionId));
31
+ const latest = new Map<string, RunSnapshot>();
32
+ for (const run of owned) {
33
+ if (run.sessionPersistence !== "persistent" || run.closedAt === undefined || !run.subSessionId || !run.subSessionFile
34
+ || activeIds.has(run.subSessionId)) continue;
35
+ const previous = latest.get(run.subSessionId);
36
+ if (!previous || run.createdAt > previous.createdAt || (run.createdAt === previous.createdAt && run.updatedAt >= previous.updatedAt)) {
37
+ latest.set(run.subSessionId, run);
38
+ }
39
+ }
40
+ return [...latest.values()].flatMap((run) => {
41
+ try {
42
+ if (!fs.statSync(run.subSessionFile!).isFile()) return [];
43
+ } catch { return []; }
44
+ return [{
45
+ sessionId: run.subSessionId!, sessionFile: run.subSessionFile!, title: run.title, cwd: run.cwd,
46
+ modifiedAt: run.updatedAt, profileName: run.profileName,
47
+ ...(run.origin ? { origin: run.origin } : {}),
48
+ ...(run.purpose ? { purpose: run.purpose } : {}),
49
+ }];
50
+ }).sort((left, right) => right.modifiedAt - left.modifiedAt || left.sessionId.localeCompare(right.sessionId));
51
+ }
52
+
53
+ export function resolveResumableSubSession(sessionId: string, candidates: ResumableSubSession[], includeManual = false): ResumableSubSession {
54
+ const sessions = candidates.filter((session) => includeManual || session.origin !== "manual");
55
+ const exact = sessions.find((session) => session.sessionId === sessionId);
56
+ if (exact) return exact;
57
+ const matches = sessions.filter((session) => session.sessionId.startsWith(sessionId));
58
+ if (matches.length === 0) throw new Error(`No persistent Sub session belonging to this Main matches '${sessionId}'.`);
59
+ if (matches.length > 1) throw new Error(`Persistent Sub session id '${sessionId}' is ambiguous.`);
60
+ return matches[0]!;
61
+ }
@@ -0,0 +1,78 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { MESSAGE_TYPE, removeTalk, type TalkDirection } from "./channel.js";
3
+ import type { DelegateManifest, TalkMessage } from "./types.js";
4
+ import { readTalkReceipt } from "./talk-message.js";
5
+ import { parentWakeFor } from "./parent-wake.js";
6
+ import { canUseNativeQueue, forgetTalk, markTalkQueued, rememberTalk } from "./inbox-state.js";
7
+
8
+ function hasReceipt(ctx: ExtensionContext, direction: TalkDirection, runId: string, messageId: string): boolean {
9
+ return ctx.sessionManager.getEntries().some((entry) => {
10
+ // Accept both custom receipts and user-message receipts from v0.1.0.
11
+ const details = entry.type === "message" ? readTalkReceipt(entry.message)
12
+ : entry.type === "custom_message" && entry.customType === MESSAGE_TYPE
13
+ ? entry.details as { direction?: string; runId?: string; messageId?: string } | undefined
14
+ : undefined;
15
+ return details?.direction === direction && details.runId === runId && details.messageId === messageId;
16
+ });
17
+ }
18
+
19
+ /** pi.sendMessage is fire-and-forget; its return is not a session receipt. */
20
+ export function deliverTalk(
21
+ pi: ExtensionAPI,
22
+ ctx: ExtensionContext,
23
+ channelDir: string,
24
+ manifest: DelegateManifest,
25
+ direction: TalkDirection,
26
+ message: TalkMessage,
27
+ content: string,
28
+ ): "acknowledged" | "waiting" | "sent" {
29
+ if (hasReceipt(ctx, direction, manifest.runId, message.id)) {
30
+ if (!parentWakeFor(pi, ctx).retryFailedWake()) return "waiting";
31
+ forgetTalk(ctx, direction, manifest.runId, message.id);
32
+ removeTalk(channelDir, direction, message.id);
33
+ return "acknowledged";
34
+ }
35
+ const pending = rememberTalk(ctx, direction, manifest, message);
36
+ if (pending.queued || !canUseNativeQueue(ctx)) return "waiting";
37
+ if (!ctx.model) throw new Error("Cannot deliver talk: the receiving Pi has no selected model.");
38
+ markTalkQueued(ctx, pending);
39
+ try {
40
+ // Pi owns waiting behind current work, not Pi Subagent. In-flight IDs prevent
41
+ // repeated file notifications from submitting the same follow-up twice.
42
+ // Keep peer content in the custom inbox card. Idle runs use a separate,
43
+ // fixed user wake so Pi assembles the profile sections through its input pipeline.
44
+ parentWakeFor(pi, ctx).sendMessage({
45
+ customType: MESSAGE_TYPE,
46
+ content,
47
+ display: true,
48
+ details: { title: manifest.title, message: message.message, direction, runId: manifest.runId, messageId: message.id },
49
+ }, { deliverAs: "followUp", triggerTurn: true });
50
+ } catch (error) {
51
+ pending.queued = false;
52
+ throw error;
53
+ }
54
+ // Some modes append synchronously. Otherwise lifecycle/watch wakes confirm receipt.
55
+ if (hasReceipt(ctx, direction, manifest.runId, message.id)) {
56
+ forgetTalk(ctx, direction, manifest.runId, message.id);
57
+ removeTalk(channelDir, direction, message.id);
58
+ }
59
+ return "sent";
60
+ }
61
+
62
+ /** Report an unchanged protocol failure once, then allow retry on later polls. */
63
+ export class ProtocolErrors {
64
+ private readonly reported = new Map<string, string>();
65
+
66
+ report(ctx: ExtensionContext, key: string, error: unknown): void {
67
+ const message = `Pi Subagent channel error (${key}): ${error instanceof Error ? error.message : String(error)}`;
68
+ if (this.reported.get(key) === message) return;
69
+ this.reported.set(key, message);
70
+ let notified = false;
71
+ try { ctx.ui.notify(message, "error"); notified = ctx.hasUI; } catch {}
72
+ if (!notified) console.error(message);
73
+ }
74
+
75
+ clear(key: string): void {
76
+ this.reported.delete(key);
77
+ }
78
+ }