@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.
package/src/channel.ts ADDED
@@ -0,0 +1,286 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import type {
6
+ SubClosedMessage,
7
+ SubSessionInfo,
8
+ CloseMessage,
9
+ DelegateManifest,
10
+ ActiveTurn,
11
+ InterruptRequest,
12
+ TalkMessage,
13
+ } from "./types.js";
14
+
15
+ const MAX_MANIFEST_BYTES = 1024 * 1024;
16
+ const MAX_TALK_BYTES = 1024 * 1024;
17
+ const MAX_CONTROL_BYTES = 64 * 1024;
18
+
19
+ export const MESSAGE_TYPE = "subagent-message";
20
+
21
+ export type TalkDirection = "to-main" | "to-sub";
22
+
23
+ function safeSegment(value: string): string {
24
+ return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 96) || "unknown";
25
+ }
26
+
27
+ export function runtimeRoot(): string {
28
+ const base = process.env.XDG_RUNTIME_DIR || os.tmpdir();
29
+ const owner = typeof process.getuid === "function" ? String(process.getuid()) : safeSegment(os.userInfo().username);
30
+ return path.join(base, `pi-subagent-${owner}`);
31
+ }
32
+
33
+ export function channelPath(mainSessionId: string, runId: string): string {
34
+ return path.join(runtimeRoot(), safeSegment(mainSessionId), safeSegment(runId));
35
+ }
36
+
37
+ export function writeAtomicJson(file: string, value: unknown, maxBytes = MAX_CONTROL_BYTES): void {
38
+ const serialized = `${JSON.stringify(value, null, 2)}\n`;
39
+ if (Buffer.byteLength(serialized, "utf8") > maxBytes) throw new Error(`${path.basename(file)} exceeds ${maxBytes} bytes.`);
40
+ // createChannel owns directory creation. Late writes must not resurrect a closed channel.
41
+ const temporary = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
42
+ try {
43
+ fs.writeFileSync(temporary, serialized, { encoding: "utf8", mode: 0o600 });
44
+ fs.renameSync(temporary, file);
45
+ } finally {
46
+ fs.rmSync(temporary, { force: true });
47
+ }
48
+ }
49
+
50
+ function readJson(file: string): unknown | undefined {
51
+ try {
52
+ return JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
53
+ } catch (error) {
54
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ function record(value: unknown): Record<string, unknown> | undefined {
60
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
61
+ }
62
+
63
+ export function createChannel(input: Omit<DelegateManifest, "version" | "token" | "createdAt">): DelegateManifest & { channelDir: string } {
64
+ const token = randomBytes(32).toString("hex");
65
+ const channelDir = channelPath(input.mainSessionId, input.runId);
66
+ fs.mkdirSync(path.dirname(channelDir), { recursive: true, mode: 0o700 });
67
+ // Never replace an existing run\'s capability token or remove its channel.
68
+ fs.mkdirSync(channelDir, { mode: 0o700 });
69
+ try {
70
+ fs.mkdirSync(path.join(channelDir, "to-main"), { mode: 0o700 });
71
+ fs.mkdirSync(path.join(channelDir, "to-sub"), { mode: 0o700 });
72
+ const manifest: DelegateManifest = { version: 1, ...input, token, createdAt: Date.now() };
73
+ writeAtomicJson(path.join(channelDir, "manifest.json"), manifest, MAX_MANIFEST_BYTES);
74
+ return { ...manifest, channelDir };
75
+ } catch (error) {
76
+ try { removeChannel(channelDir); } catch (cleanup) {
77
+ throw new AggregateError([error, cleanup], `Failed to create and clean up Pi Subagent channel ${channelDir}.`);
78
+ }
79
+ throw error;
80
+ }
81
+ }
82
+
83
+ function validProfile(value: unknown): boolean {
84
+ const profile = record(value);
85
+ return Boolean(profile && profile.version === 1 && typeof profile.name === "string" && profile.name.length > 0
86
+ && (profile.source === "global" || profile.source === "project") && typeof profile.sourcePath === "string"
87
+ && Array.isArray(profile.tools) && profile.tools.length > 0 && profile.tools.every((tool) => typeof tool === "string" && tool.length > 0)
88
+ && (profile.skills === undefined || (Array.isArray(profile.skills) && profile.skills.every((skill) => typeof skill === "string")))
89
+ && Array.isArray(profile.resolvedSkills) && profile.resolvedSkills.every((skill) => typeof skill === "string")
90
+ && Array.isArray(profile.resolvedExtensions) && profile.resolvedExtensions.every((extension) => typeof extension === "string")
91
+ && (profile.description === undefined || typeof profile.description === "string")
92
+ && (profile.model === undefined || typeof profile.model === "string")
93
+ && (profile.thinkingLevel === undefined || typeof profile.thinkingLevel === "string")
94
+ && (profile.invocation === undefined || profile.invocation === "both" || profile.invocation === "manual")
95
+ && (profile.sessionPersistence === undefined || profile.sessionPersistence === "ephemeral" || profile.sessionPersistence === "persistent")
96
+ && (profile.instructions === undefined || typeof profile.instructions === "string"));
97
+ }
98
+
99
+ export function readManifest(channelDir: string): DelegateManifest {
100
+ const value = record(readJson(path.join(channelDir, "manifest.json")));
101
+ if (!value || value.version !== 1 || typeof value.runId !== "string" || typeof value.mainSessionId !== "string"
102
+ || typeof value.title !== "string" || typeof value.task !== "string" || typeof value.cwd !== "string"
103
+ || (value.origin !== undefined && value.origin !== "manual")
104
+ || !validProfile(value.profile) || typeof value.token !== "string" || typeof value.createdAt !== "number") {
105
+ throw new Error("Invalid Pi Subagent channel manifest.");
106
+ }
107
+ return value as unknown as DelegateManifest;
108
+ }
109
+
110
+ function writeTalk(channelDir: string, manifest: DelegateManifest, direction: TalkDirection, message: string): TalkMessage {
111
+ // Each direction has one writer. Persist the counter before the payload;
112
+ // gaps after a failed write are harmless, and reloads cannot reorder messages.
113
+ const counterPath = path.join(channelDir, `${direction}-sequence.json`);
114
+ const storedCounter = readJson(counterPath);
115
+ const previous = record(storedCounter);
116
+ if (storedCounter !== undefined && (!previous || previous.runId !== manifest.runId || previous.token !== manifest.token
117
+ || !Number.isSafeInteger(previous.sequence) || (previous.sequence as number) < 1)) {
118
+ throw new Error("Invalid Pi Subagent talk sequence counter.");
119
+ }
120
+ const sequence = ((previous?.sequence as number | undefined) ?? 0) + 1;
121
+ if (!Number.isSafeInteger(sequence)) throw new Error("Pi Subagent talk sequence exhausted.");
122
+ writeAtomicJson(counterPath, { runId: manifest.runId, token: manifest.token, sequence });
123
+ const talk: TalkMessage = {
124
+ version: 1,
125
+ id: randomUUID(),
126
+ sequence,
127
+ runId: manifest.runId,
128
+ token: manifest.token,
129
+ createdAt: Date.now(),
130
+ message,
131
+ };
132
+ writeAtomicJson(path.join(channelDir, direction, `${safeSegment(talk.id)}.json`), talk, MAX_TALK_BYTES);
133
+ return talk;
134
+ }
135
+
136
+ export function talkToMain(channelDir: string, manifest: DelegateManifest, message: string): TalkMessage {
137
+ return writeTalk(channelDir, manifest, "to-main", message);
138
+ }
139
+
140
+ export function talkToSub(channelDir: string, manifest: DelegateManifest, message: string): TalkMessage {
141
+ return writeTalk(channelDir, manifest, "to-sub", message);
142
+ }
143
+
144
+ function validTalk(value: unknown, manifest: DelegateManifest): value is TalkMessage {
145
+ const item = record(value);
146
+ return Boolean(item && item.version === 1 && item.runId === manifest.runId && item.token === manifest.token
147
+ && typeof item.id === "string" && Number.isSafeInteger(item.sequence) && (item.sequence as number) > 0
148
+ && typeof item.createdAt === "number" && typeof item.message === "string");
149
+ }
150
+
151
+ function listTalk(channelDir: string, manifest: DelegateManifest, direction: TalkDirection): TalkMessage[] {
152
+ let names: string[];
153
+ try {
154
+ names = fs.readdirSync(path.join(channelDir, direction));
155
+ } catch (error) {
156
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
157
+ throw error;
158
+ }
159
+ const messages: TalkMessage[] = [];
160
+ for (const name of names.filter((name) => name.endsWith(".json")).sort()) {
161
+ const value = readJson(path.join(channelDir, direction, name));
162
+ if (validTalk(value, manifest)) messages.push(value);
163
+ }
164
+ return messages.sort((left, right) => left.sequence - right.sequence);
165
+ }
166
+
167
+ export function listTalkToMain(channelDir: string, manifest: DelegateManifest): TalkMessage[] {
168
+ return listTalk(channelDir, manifest, "to-main");
169
+ }
170
+
171
+ export function listTalkToSub(channelDir: string, manifest: DelegateManifest): TalkMessage[] {
172
+ return listTalk(channelDir, manifest, "to-sub");
173
+ }
174
+
175
+ export function removeTalk(channelDir: string, direction: TalkDirection, id: string): void {
176
+ fs.rmSync(path.join(channelDir, direction, `${safeSegment(id)}.json`), { force: true });
177
+ }
178
+
179
+ export function writeSubSessionInfo(channelDir: string, manifest: DelegateManifest, input: {
180
+ sessionId: string;
181
+ sessionFile: string;
182
+ }): SubSessionInfo {
183
+ const info: SubSessionInfo = {
184
+ version: 1,
185
+ runId: manifest.runId,
186
+ token: manifest.token,
187
+ sessionId: input.sessionId,
188
+ sessionFile: input.sessionFile,
189
+ createdAt: Date.now(),
190
+ };
191
+ writeAtomicJson(path.join(channelDir, "session.json"), info);
192
+ return info;
193
+ }
194
+
195
+ export function readSubSessionInfo(channelDir: string, manifest: DelegateManifest): SubSessionInfo | undefined {
196
+ const value = record(readJson(path.join(channelDir, "session.json")));
197
+ if (!value || value.version !== 1 || value.runId !== manifest.runId || value.token !== manifest.token
198
+ || typeof value.sessionId !== "string" || typeof value.sessionFile !== "string" || typeof value.createdAt !== "number") return undefined;
199
+ return value as unknown as SubSessionInfo;
200
+ }
201
+
202
+ export function writeActiveTurn(channelDir: string, manifest: DelegateManifest): ActiveTurn {
203
+ const turn: ActiveTurn = {
204
+ version: 1,
205
+ runId: manifest.runId,
206
+ token: manifest.token,
207
+ turnId: randomUUID(),
208
+ createdAt: Date.now(),
209
+ };
210
+ writeAtomicJson(path.join(channelDir, "active-turn.json"), turn);
211
+ return turn;
212
+ }
213
+
214
+ export function readActiveTurn(channelDir: string, manifest: Pick<DelegateManifest, "runId" | "token">): ActiveTurn | undefined {
215
+ const value = record(readJson(path.join(channelDir, "active-turn.json")));
216
+ if (!value || value.version !== 1 || value.runId !== manifest.runId || value.token !== manifest.token
217
+ || typeof value.turnId !== "string" || typeof value.createdAt !== "number") return undefined;
218
+ return value as unknown as ActiveTurn;
219
+ }
220
+
221
+ export function clearActiveTurn(channelDir: string, turn: ActiveTurn): void {
222
+ // An older settle event must not erase a newer turn.
223
+ try {
224
+ if (readActiveTurn(channelDir, turn)?.turnId === turn.turnId) {
225
+ fs.rmSync(path.join(channelDir, "active-turn.json"), { force: true });
226
+ }
227
+ } catch {}
228
+ }
229
+
230
+ export function writeInterrupt(channelDir: string, manifest: DelegateManifest, turn: ActiveTurn): InterruptRequest {
231
+ const request: InterruptRequest = { ...turn, requestedAt: Date.now() };
232
+ writeAtomicJson(path.join(channelDir, "interrupt.json"), request);
233
+ return request;
234
+ }
235
+
236
+ export function readInterrupt(channelDir: string, manifest: DelegateManifest): InterruptRequest | undefined {
237
+ const value = record(readJson(path.join(channelDir, "interrupt.json")));
238
+ if (!value || value.version !== 1 || value.runId !== manifest.runId || value.token !== manifest.token
239
+ || typeof value.turnId !== "string" || typeof value.createdAt !== "number"
240
+ || typeof value.requestedAt !== "number") return undefined;
241
+ return value as unknown as InterruptRequest;
242
+ }
243
+
244
+ export function clearInterrupt(channelDir: string): void {
245
+ fs.rmSync(path.join(channelDir, "interrupt.json"), { force: true });
246
+ }
247
+
248
+ export function writeClose(channelDir: string, manifest: DelegateManifest, reason: string): void {
249
+ const message: CloseMessage = {
250
+ version: 1,
251
+ runId: manifest.runId,
252
+ token: manifest.token,
253
+ createdAt: Date.now(),
254
+ reason,
255
+ };
256
+ writeAtomicJson(path.join(channelDir, "close.json"), message);
257
+ }
258
+
259
+ export function readClose(channelDir: string, manifest: DelegateManifest): CloseMessage | undefined {
260
+ const value = record(readJson(path.join(channelDir, "close.json")));
261
+ if (!value || value.version !== 1 || value.runId !== manifest.runId || value.token !== manifest.token
262
+ || typeof value.createdAt !== "number" || typeof value.reason !== "string") return undefined;
263
+ return value as unknown as CloseMessage;
264
+ }
265
+
266
+ export function writeSubClosed(channelDir: string, manifest: DelegateManifest, reason: string): void {
267
+ const message: SubClosedMessage = {
268
+ version: 1,
269
+ runId: manifest.runId,
270
+ token: manifest.token,
271
+ createdAt: Date.now(),
272
+ reason,
273
+ };
274
+ writeAtomicJson(path.join(channelDir, "closed.json"), message);
275
+ }
276
+
277
+ export function readSubClosed(channelDir: string, manifest: DelegateManifest): SubClosedMessage | undefined {
278
+ const value = record(readJson(path.join(channelDir, "closed.json")));
279
+ if (!value || value.version !== 1 || value.runId !== manifest.runId || value.token !== manifest.token
280
+ || typeof value.createdAt !== "number" || typeof value.reason !== "string") return undefined;
281
+ return value as unknown as SubClosedMessage;
282
+ }
283
+
284
+ export function removeChannel(channelDir: string): void {
285
+ fs.rmSync(channelDir, { recursive: true, force: true });
286
+ }
@@ -0,0 +1,90 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { channelPath, runtimeRoot } from "../channel.js";
5
+ import { subSessionDir, subSessionsRoot } from "../sessions.js";
6
+
7
+ function entries(directory: string): fs.Dirent[] {
8
+ try { return fs.readdirSync(directory, { withFileTypes: true }); }
9
+ catch (error) {
10
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
11
+ throw error;
12
+ }
13
+ }
14
+
15
+ /** Read only the header; never load transcripts or treat an unreadable session as deleted. */
16
+ function sessionId(file: string): string {
17
+ const fd = fs.openSync(file, "r");
18
+ try {
19
+ const buffer = Buffer.alloc(64 * 1024);
20
+ const length = fs.readSync(fd, buffer, 0, buffer.length, 0);
21
+ const text = buffer.subarray(0, length).toString("utf8");
22
+ const header = JSON.parse(text.split("\n", 1)[0]!);
23
+ if (header?.type !== "session" || typeof header.id !== "string" || !header.id) throw new Error("Invalid session header");
24
+ return header.id;
25
+ } catch (error) {
26
+ throw new Error(`Cannot inspect session ${file}: ${error instanceof Error ? error.message : String(error)}`);
27
+ } finally { fs.closeSync(fd); }
28
+ }
29
+
30
+ export function cleanSubFiles(currentMainId: string, currentSessionDir: string): { removed: number; errors: string[] } {
31
+ const stores = [
32
+ { root: runtimeRoot(), ownerDirectory: (id: string) => path.basename(path.dirname(channelPath(id, "unused"))) },
33
+ { root: subSessionsRoot(), ownerDirectory: (id: string) => path.basename(subSessionDir(id)) },
34
+ ].map((store) => {
35
+ const candidates = entries(store.root).filter((entry) => entry.isDirectory());
36
+ if (candidates.length && fs.lstatSync(store.root).isSymbolicLink()) throw new Error(`Refusing to clean symlinked directory: ${store.root}`);
37
+ return { ...store, candidates };
38
+ });
39
+ if (stores.every((store) => store.candidates.length === 0)) return { removed: 0, errors: [] };
40
+
41
+ const sessionsRoot = path.join(getAgentDir(), "sessions");
42
+ const directories = new Set([sessionsRoot]);
43
+ if (currentSessionDir) directories.add(currentSessionDir);
44
+ for (const entry of entries(sessionsRoot)) {
45
+ const file = path.join(sessionsRoot, entry.name);
46
+ if (entry.isDirectory() || (entry.isSymbolicLink() && fs.statSync(file).isDirectory())) directories.add(file);
47
+ }
48
+ const live = new Set([currentMainId]);
49
+ // Complete discovery before deleting anything. A read/parse error aborts cleanup.
50
+ for (const directory of directories) {
51
+ for (const entry of entries(directory)) {
52
+ if (entry.name.endsWith(".jsonl")) live.add(sessionId(path.join(directory, entry.name)));
53
+ }
54
+ }
55
+ let removed = 0;
56
+ const errors: string[] = [];
57
+ for (const store of stores) {
58
+ const liveDirectories = new Set([...live].map(store.ownerDirectory));
59
+ for (const entry of store.candidates) {
60
+ if (liveDirectories.has(entry.name)) continue;
61
+ const directory = path.join(store.root, entry.name);
62
+ try {
63
+ // Do not follow a directory replaced with a symlink during discovery.
64
+ if (!fs.lstatSync(directory).isDirectory()) continue;
65
+ fs.rmSync(directory, { recursive: true, force: true });
66
+ removed++;
67
+ } catch (error) {
68
+ errors.push(`${directory}: ${error instanceof Error ? error.message : String(error)}`);
69
+ }
70
+ }
71
+ }
72
+ return { removed, errors };
73
+ }
74
+
75
+ export function registerCleanSubCommand(pi: ExtensionAPI): void {
76
+ pi.registerCommand("clean-sub", {
77
+ description: "Delete managed Sub sessions and communication directories whose Main session can no longer be found.",
78
+ handler: async (_args, ctx) => {
79
+ try {
80
+ const result = cleanSubFiles(ctx.sessionManager.getSessionId(), ctx.sessionManager.getSessionDir());
81
+ ctx.ui.notify([
82
+ `Removed ${result.removed} orphaned Sub data directories (sessions and communication).`,
83
+ ...result.errors,
84
+ ].join("\n"), result.errors.length ? "warning" : "info");
85
+ } catch (error) {
86
+ ctx.ui.notify(`Cleanup aborted: ${error instanceof Error ? error.message : String(error)}`, "error");
87
+ }
88
+ },
89
+ });
90
+ }
@@ -0,0 +1,39 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { loadProfiles, resolveProfile } from "../profiles/loader.js";
3
+ import { resolveToolExtensions } from "../profiles/tool-sources.js";
4
+ import type { MainRunManager } from "../run-manager.js";
5
+
6
+ const DEFAULT_TASK = "The user invoked this profile for the current workspace. Perform the task described by your profile; ask Main for any required details.";
7
+
8
+ export function registerSubCommands(pi: ExtensionAPI, manager: MainRunManager): void {
9
+ const owned = new Set<string>();
10
+ const invoke = async (name: string, task: string, ctx: ExtensionCommandContext) => {
11
+ try {
12
+ const profile = resolveToolExtensions(resolveProfile(name, ctx.cwd, ctx.isProjectTrusted()), pi.getAllTools());
13
+ const result = await manager.invokeProfile({ profile, task: task.trim() || DEFAULT_TASK, cwd: ctx.cwd });
14
+ ctx.ui.notify(`${result.action}: ${name} · ${result.run.runId.slice(0, 8)}${result.run.subSessionId ? ` · session ${result.run.subSessionId}` : ""}`, "info");
15
+ } catch (error) {
16
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
17
+ }
18
+ };
19
+
20
+ // Trust and other extensions' startup registrations are resolved at this point.
21
+ // Only register the namespaced command; Pi's native fuzzy completion matches
22
+ // /review to /sub:review without an alias or a separate routing hook.
23
+ pi.on("resources_discover", (_event, ctx) => {
24
+ const catalog = loadProfiles(ctx.cwd, ctx.isProjectTrusted());
25
+ const occupied = new Set(pi.getCommands().map((command) => command.name));
26
+ for (const name of [...catalog.profiles.keys()].sort()) {
27
+ const command = `sub:${name}`;
28
+ if (occupied.has(command) && !owned.has(command)) {
29
+ ctx.ui.notify(`Cannot register /${command}: another command already uses that name.`, "warning");
30
+ continue;
31
+ }
32
+ pi.registerCommand(command, {
33
+ description: `Manually invoke profile ${name}; reuse its Sub in this Main session`,
34
+ handler: (task, commandCtx) => invoke(name, task, commandCtx),
35
+ });
36
+ owned.add(command);
37
+ }
38
+ });
39
+ }
@@ -0,0 +1,114 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { MESSAGE_TYPE, 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
+
7
+ type ReadonlySessionManager = ExtensionContext["sessionManager"];
8
+ interface PendingTalk {
9
+ queued: boolean;
10
+ queuedWhileBusy: boolean;
11
+ runSerial: number;
12
+ }
13
+ interface DeliveryState {
14
+ pending: Map<string, PendingTalk>;
15
+ runSerial: number;
16
+ aborted: boolean;
17
+ active: boolean;
18
+ }
19
+
20
+ // Pi can retain native message queues across /reload. Retain only their
21
+ // delivery state across extension replacement, keyed weakly by the session.
22
+ const STATE_KEY = Symbol.for("pi-subagent.inbox-state.v1");
23
+ const globals = globalThis as unknown as Record<symbol, unknown>;
24
+ const states = (globals[STATE_KEY] ??= new WeakMap<ReadonlySessionManager, DeliveryState>()) as WeakMap<ReadonlySessionManager, DeliveryState>;
25
+ function state(ctx: ExtensionContext): DeliveryState {
26
+ let value = states.get(ctx.sessionManager);
27
+ if (!value) {
28
+ value = { pending: new Map(), runSerial: 0, aborted: false, active: false };
29
+ states.set(ctx.sessionManager, value);
30
+ }
31
+ return value;
32
+ }
33
+ function key(direction: TalkDirection, runId: string, messageId: string): string {
34
+ return `${direction}:${runId}:${messageId}`;
35
+ }
36
+
37
+ export function rememberTalk(ctx: ExtensionContext, direction: TalkDirection, manifest: DelegateManifest, message: TalkMessage): PendingTalk {
38
+ const value = state(ctx);
39
+ const id = key(direction, manifest.runId, message.id);
40
+ let pending = value.pending.get(id);
41
+ if (!pending) {
42
+ pending = { queued: false, queuedWhileBusy: false, runSerial: value.runSerial };
43
+ value.pending.set(id, pending);
44
+ }
45
+ return pending;
46
+ }
47
+ export function forgetTalk(ctx: ExtensionContext, direction: TalkDirection, runId: string, messageId: string): void {
48
+ state(ctx).pending.delete(key(direction, runId, messageId));
49
+ }
50
+ /** Queue behind streaming work, but never compete with manual compaction or prompt preflight. */
51
+ export function canUseNativeQueue(ctx: ExtensionContext): boolean {
52
+ const value = state(ctx);
53
+ // Serialize idle submissions until Pi starts the run or records its receipt.
54
+ // Once active, subsequent deliveries use Pi's native follow-up queue.
55
+ if (ctx.isIdle()) return ![...value.pending.values()].some((pending) => pending.queued && !pending.queuedWhileBusy);
56
+ return value.active || ctx.signal !== undefined;
57
+ }
58
+ export function markTalkQueued(ctx: ExtensionContext, pending: PendingTalk): void {
59
+ pending.queued = true;
60
+ pending.queuedWhileBusy = !ctx.isIdle();
61
+ pending.runSerial = state(ctx).runSerial;
62
+ }
63
+
64
+ export function bindInboxEvents(pi: ExtensionAPI, getContext: () => ExtensionContext | undefined, wake: () => void): void {
65
+ const wakePending = () => {
66
+ const ctx = getContext();
67
+ if (ctx && state(ctx).pending.size > 0) wake();
68
+ };
69
+ pi.on("agent_start", () => {
70
+ const ctx = getContext();
71
+ if (!ctx) return;
72
+ parentWakeFor(pi, ctx).agentStarted();
73
+ const value = state(ctx);
74
+ value.runSerial++;
75
+ value.aborted = ctx.signal?.aborted === true;
76
+ value.active = true;
77
+ const runSerial = value.runSerial;
78
+ ctx.signal?.addEventListener("abort", () => { if (value.runSerial === runSerial) value.aborted = true; }, { once: true });
79
+ wakePending();
80
+ });
81
+ pi.on("session_shutdown", (event, ctx) => {
82
+ // Use the event context: manager shutdown may already have cleared its active context.
83
+ parentWakeFor(pi, ctx).sessionShutdown(event.reason);
84
+ });
85
+ pi.on("agent_end", (event) => {
86
+ const ctx = getContext();
87
+ if (!ctx) return;
88
+ const last = [...event.messages].reverse().find((message) => message.role === "assistant");
89
+ state(ctx).aborted ||= ctx.signal?.aborted === true || (last?.role === "assistant" && last.stopReason === "aborted");
90
+ });
91
+ pi.on("message_end", (event) => {
92
+ if (readTalkReceipt(event.message)
93
+ || (event.message.role === "custom" && event.message.customType === MESSAGE_TYPE)) wake();
94
+ });
95
+ pi.on("turn_start", wakePending);
96
+ pi.on("model_select", wakePending);
97
+ pi.on("session_compact", wakePending);
98
+ pi.on("session_compact_failed", wakePending);
99
+ pi.on("agent_settled", () => {
100
+ const ctx = getContext();
101
+ if (!ctx) return;
102
+ const value = state(ctx);
103
+ value.active = false;
104
+ // A normal settle has drained native follow-ups. Missing receipts then
105
+ // indicate withdrawn messages. Abort alone can leave the queue intact:
106
+ // keep in-flight state until the next normal run or a fresh session.
107
+ if (!value.aborted) {
108
+ for (const pending of value.pending.values()) {
109
+ if (pending.queuedWhileBusy && pending.runSerial <= value.runSerial) pending.queued = false;
110
+ }
111
+ }
112
+ wakePending();
113
+ });
114
+ }
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Text } from "@earendil-works/pi-tui";
3
+ import { MESSAGE_TYPE } from "./channel.js";
4
+ import { MainContextRuntime } from "./main-context.js";
5
+ import { StartupProfileRuntime } from "./profiles/runtime.js";
6
+ import { MainRunManager } from "./run-manager.js";
7
+ import { registerSub } from "./tools/sub.js";
8
+ import { talkView } from "./talk-render.js";
9
+ import { registerSubCommands } from "./commands/sub.js";
10
+ import { registerCleanSubCommand } from "./commands/clean-sub.js";
11
+ import { registerMainTools } from "./tools/main.js";
12
+
13
+ export default function piSubagent(pi: ExtensionAPI): void {
14
+ if (process.env.PI_SUBAGENT_ROLE === "sub") {
15
+ registerSub(pi);
16
+ return;
17
+ }
18
+
19
+ const manager = new MainRunManager(pi);
20
+ const startupProfile = new StartupProfileRuntime(pi);
21
+ registerMainTools(pi, manager);
22
+ registerSubCommands(pi, manager);
23
+ registerCleanSubCommand(pi);
24
+ startupProfile.register();
25
+ new MainContextRuntime(pi).register();
26
+
27
+ // Render live deliveries and saved custom messages with the same inbox card.
28
+ pi.registerMessageRenderer(MESSAGE_TYPE, (message, options, theme) => {
29
+ const details = message.details as { title?: string; message?: string } | undefined;
30
+ const title = details?.title ?? "Sub";
31
+ const subMessage = details?.message ?? "";
32
+ const view = talkView(subMessage, options.expanded);
33
+ let text = `${theme.fg("accent", "›")} ${theme.fg("toolTitle", theme.bold("message inbox"))} ${theme.fg("muted", `· ${title}`)}`;
34
+ if (subMessage) text += `\n\n${view.lines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
35
+ if (view.remaining > 0) {
36
+ text += theme.fg("muted", `\n... (${view.remaining} more lines, ${view.totalLines} total, ctrl+o to expand)`);
37
+ }
38
+ const box = new Box(1, 1, (line) => theme.bg("toolSuccessBg", line));
39
+ box.addChild(new Text(text, 0, 0));
40
+ return box;
41
+ });
42
+ pi.on("session_start", (_event, ctx) => manager.start(ctx));
43
+
44
+ pi.on("session_shutdown", () => manager.shutdown());
45
+ }
@@ -0,0 +1,91 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+
5
+ import { bindPromptSections } from "./profiles/system-prompt.js";
6
+
7
+ const MAIN_CONTEXT_FILE = "MAIN.md";
8
+ const MAX_MAIN_CONTEXT_BYTES = 256 * 1024;
9
+
10
+ export interface MainContextLoadResult {
11
+ content: string;
12
+ paths: string[];
13
+ diagnostics: Array<{ path: string; message: string }>;
14
+ }
15
+
16
+ function ancestorDirectories(cwd: string): string[] {
17
+ const directories: string[] = [];
18
+ let current = path.resolve(cwd);
19
+ while (true) {
20
+ directories.push(current);
21
+ const parent = path.dirname(current);
22
+ if (parent === current) return directories;
23
+ current = parent;
24
+ }
25
+ }
26
+
27
+ export function globalMainContextPath(): string {
28
+ return path.join(getAgentDir(), "subagent", MAIN_CONTEXT_FILE);
29
+ }
30
+
31
+ export function projectMainContextPath(directory: string): string {
32
+ return path.join(directory, CONFIG_DIR_NAME, "subagent", MAIN_CONTEXT_FILE);
33
+ }
34
+
35
+ export function loadMainContext(cwd: string, includeProject: boolean): MainContextLoadResult {
36
+ const candidates = [
37
+ globalMainContextPath(),
38
+ ...(includeProject
39
+ ? ancestorDirectories(cwd).reverse().map((directory) => projectMainContextPath(directory))
40
+ : []),
41
+ ];
42
+ const sections: Array<{ path: string; content: string }> = [];
43
+ const diagnostics: Array<{ path: string; message: string }> = [];
44
+ const seen = new Set<string>();
45
+
46
+ for (const candidate of candidates) {
47
+ const sourcePath = path.resolve(candidate);
48
+ if (seen.has(sourcePath)) continue;
49
+ seen.add(sourcePath);
50
+ try {
51
+ const stat = fs.statSync(sourcePath);
52
+ if (!stat.isFile()) throw new Error("MAIN.md must be a regular file");
53
+ if (stat.size > MAX_MAIN_CONTEXT_BYTES) {
54
+ throw new Error(`MAIN.md exceeds ${MAX_MAIN_CONTEXT_BYTES} bytes`);
55
+ }
56
+ const content = fs.readFileSync(sourcePath, "utf8").trim();
57
+ if (content) sections.push({ path: sourcePath, content });
58
+ } catch (error) {
59
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
60
+ diagnostics.push({ path: sourcePath, message: error instanceof Error ? error.message : String(error) });
61
+ }
62
+ }
63
+
64
+ return {
65
+ content: sections
66
+ .map((section) => `## Pi Subagent Main context: ${section.path}\n\n${section.content}`)
67
+ .join("\n\n"),
68
+ paths: sections.map((section) => section.path),
69
+ diagnostics,
70
+ };
71
+ }
72
+
73
+ export class MainContextRuntime {
74
+ private content = "";
75
+
76
+ constructor(private readonly pi: ExtensionAPI) {}
77
+
78
+ register(): void {
79
+ this.pi.on("session_start", (_event, ctx) => {
80
+ const loaded = loadMainContext(ctx.cwd, ctx.isProjectTrusted());
81
+ this.content = loaded.content;
82
+ for (const diagnostic of loaded.diagnostics) {
83
+ const message = `Pi Subagent Main context error (${diagnostic.path}): ${diagnostic.message}`;
84
+ ctx.ui.notify(message, "warning");
85
+ if (!ctx.hasUI) console.error(message);
86
+ }
87
+ });
88
+
89
+ bindPromptSections(this.pi, ["subagent_main"], (): Record<string, string> => this.content ? { subagent_main: this.content } : {});
90
+ }
91
+ }
@@ -0,0 +1,9 @@
1
+ export const MANUAL_SUB_PREFIX = "[sub:manual] ";
2
+
3
+ export function subSessionName(title: string, origin?: "manual"): string {
4
+ return `${origin === "manual" ? MANUAL_SUB_PREFIX : "[sub] "}${title}`;
5
+ }
6
+
7
+ export const MANUAL_MAIN_GUIDANCE = "This specialist was explicitly invoked by the user. Follow up only on the user's task and its results; do not assign unrelated work or substitute it for general consulting profiles. New tasks are initiated by the user with a profile command.";
8
+
9
+ export const MANUAL_SUB_GUIDANCE = "This is a user-invoked specialist session, not a general-purpose delegate. Work on the user's explicitly submitted tasks and related follow-ups from Main. Return findings through talk. If Main asks for unrelated work, ask it to use an agent-invokable profile or wait for the user's next command.";