pi-subagent-windows 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/shared.ts ADDED
@@ -0,0 +1,385 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import {
4
+ accessSync,
5
+ constants,
6
+ existsSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ renameSync,
11
+ rmSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { dirname, join, resolve } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const packageDir = dirname(fileURLToPath(import.meta.url));
19
+
20
+ export type RunState = "starting" | "busy" | "idle" | "exited" | "error";
21
+
22
+ export interface RunMetadata {
23
+ version: 1;
24
+ handle: string;
25
+ name?: string;
26
+ parentSessionId?: string;
27
+ parentSessionFile?: string;
28
+ childSessionId?: string;
29
+ tmuxSession: string;
30
+ runDir: string;
31
+ sessionFile: string;
32
+ cwd: string;
33
+ provider: string;
34
+ model: string;
35
+ thinking: string;
36
+ /** Extra pi CLI flags (tools, isolation) reused when the run is relaunched. */
37
+ launchArgs?: string[];
38
+ /** Set by the parent when it stops the child on quit or session switch; the child is relaunched on resume. */
39
+ suspended?: boolean;
40
+ state: RunState;
41
+ hasStarted: boolean;
42
+ createdAt: string;
43
+ updatedAt: string;
44
+ error?: string;
45
+ }
46
+
47
+ export interface InboxMessage {
48
+ message: string;
49
+ delivery: "auto" | "followUp";
50
+ }
51
+
52
+ interface SessionEntry {
53
+ type: string;
54
+ id: string;
55
+ parentId: string | null;
56
+ message?: unknown;
57
+ }
58
+
59
+ interface AssistantMessage {
60
+ role: "assistant";
61
+ content?: unknown;
62
+ stopReason?: string;
63
+ errorMessage?: string;
64
+ }
65
+
66
+ interface AssistantEntry extends SessionEntry {
67
+ type: "message";
68
+ message: AssistantMessage;
69
+ }
70
+
71
+ export function getAgentDir(): string {
72
+ return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
73
+ }
74
+
75
+ export function getRunsDir(): string {
76
+ return join(getAgentDir(), "subagents");
77
+ }
78
+
79
+ export function isWindows(): boolean {
80
+ return process.platform === "win32";
81
+ }
82
+
83
+ /** Location of the supervisor pid file used by the Windows process host. */
84
+ export function supervisorPidPath(runDir: string): string {
85
+ return join(runDir, "supervisor.pid");
86
+ }
87
+
88
+ export function readSupervisorPid(runDir: string): number | undefined {
89
+ try {
90
+ const value = Number.parseInt(readFileSync(supervisorPidPath(runDir), "utf8").trim(), 10);
91
+ return Number.isInteger(value) && value > 0 ? value : undefined;
92
+ } catch {
93
+ return undefined;
94
+ }
95
+ }
96
+
97
+ export function processIsAlive(pid: number): boolean {
98
+ try {
99
+ process.kill(pid, 0);
100
+ return true;
101
+ } catch (error) {
102
+ return (error as NodeJS.ErrnoException).code === "EPERM";
103
+ }
104
+ }
105
+
106
+ /**
107
+ * On Windows a run is a detached supervisor process tracked through its pid file.
108
+ * On other platforms the legacy tmux session remains the source of liveness.
109
+ */
110
+ export function isRunAlive(metadata: RunMetadata): boolean {
111
+ if (isWindows()) {
112
+ const pid = readSupervisorPid(metadata.runDir);
113
+ return pid !== undefined && processIsAlive(pid);
114
+ }
115
+ return tmuxSessionExists(metadata.tmuxSession);
116
+ }
117
+
118
+ /** Terminate a run and its whole process tree. */
119
+ export function killRun(metadata: RunMetadata): void {
120
+ if (isWindows()) {
121
+ const pid = readSupervisorPid(metadata.runDir);
122
+ if (pid !== undefined && processIsAlive(pid)) {
123
+ spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
124
+ }
125
+ return;
126
+ }
127
+ spawnSync("tmux", ["kill-session", "-t", metadata.tmuxSession], { stdio: "ignore" });
128
+ }
129
+
130
+ /** Resolve the managed Pi CLI entry the same way the pi launcher does. */
131
+ export function resolvePiCliPath(): string {
132
+ const installRoot = join(getAgentDir(), "install");
133
+ const currentVersion = readFileSync(join(installRoot, "current-version"), "utf8").trim();
134
+ if (!currentVersion || !/^[0-9A-Za-z._+-]+$/.test(currentVersion)) {
135
+ throw new Error("The managed Pi version file is invalid");
136
+ }
137
+ const packageDirectory = join(installRoot, "releases", currentVersion, "node_modules", "@earendil-works", "pi-coding-agent");
138
+ const manifest: unknown = JSON.parse(readFileSync(join(packageDirectory, "package.json"), "utf8"));
139
+ const bin = (manifest as { bin?: string | Record<string, string> }).bin;
140
+ const binPath = typeof bin === "string" ? bin : bin?.pi;
141
+ if (!binPath) throw new Error("The installed Pi package does not declare a pi bin");
142
+ return resolve(packageDirectory, binPath);
143
+ }
144
+
145
+ /** Atomically queue a message for a running child. */
146
+ export function writeInboxMessage(runDir: string, payload: InboxMessage): void {
147
+ const queueDir = inboxDir(runDir);
148
+ mkdirSync(queueDir, { recursive: true, mode: 0o700 });
149
+ const id = `${Date.now()}-${randomBytes(4).toString("hex")}`;
150
+ const target = join(queueDir, `${id}.json`);
151
+ const temporary = `${target}.tmp`;
152
+ writeFileSync(temporary, `${JSON.stringify(payload)}\n`, { encoding: "utf8", mode: 0o600 });
153
+ renameSync(temporary, target);
154
+ }
155
+
156
+ export function metadataPath(runDir: string): string {
157
+ return join(runDir, "metadata.json");
158
+ }
159
+
160
+ export function inboxDir(runDir: string): string {
161
+ return join(runDir, "inbox");
162
+ }
163
+
164
+ export function isValidRunName(value: unknown): value is string {
165
+ return (
166
+ typeof value === "string" &&
167
+ value.length > 0 &&
168
+ value.length <= 64 &&
169
+ value.trim() === value &&
170
+ !/[\u0000-\u001f\u007f]/.test(value)
171
+ );
172
+ }
173
+
174
+ export function runDisplayName(metadata: RunMetadata): string {
175
+ return metadata.name ? `${metadata.name} (${metadata.handle})` : metadata.handle;
176
+ }
177
+
178
+ export function readMetadata(runDir: string): RunMetadata | undefined {
179
+ try {
180
+ const value: unknown = JSON.parse(readFileSync(metadataPath(runDir), "utf8"));
181
+ if (typeof value !== "object" || value === null) return undefined;
182
+ const metadata = value as Partial<RunMetadata>;
183
+ if (
184
+ metadata.version !== 1 ||
185
+ typeof metadata.handle !== "string" ||
186
+ (metadata.name !== undefined && !isValidRunName(metadata.name)) ||
187
+ typeof metadata.tmuxSession !== "string" ||
188
+ typeof metadata.sessionFile !== "string" ||
189
+ typeof metadata.runDir !== "string"
190
+ ) {
191
+ return undefined;
192
+ }
193
+ return metadata as RunMetadata;
194
+ } catch {
195
+ return undefined;
196
+ }
197
+ }
198
+
199
+ export function writeMetadata(metadata: RunMetadata): void {
200
+ mkdirSync(dirname(metadataPath(metadata.runDir)), { recursive: true, mode: 0o700 });
201
+ const target = metadataPath(metadata.runDir);
202
+ const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
203
+ writeFileSync(temporary, `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
204
+ renameSync(temporary, target);
205
+ }
206
+
207
+ export function updateMetadata(runDir: string, patch: Partial<RunMetadata>): RunMetadata | undefined {
208
+ const current = readMetadata(runDir);
209
+ if (!current) return undefined;
210
+ const next: RunMetadata = {
211
+ ...current,
212
+ ...patch,
213
+ version: 1,
214
+ handle: current.handle,
215
+ runDir: current.runDir,
216
+ updatedAt: new Date().toISOString(),
217
+ };
218
+ writeMetadata(next);
219
+ return next;
220
+ }
221
+
222
+ export async function waitForRunShutdown(runDir: string, timeoutMs = 2000): Promise<void> {
223
+ const deadline = Date.now() + timeoutMs;
224
+ while (Date.now() < deadline) {
225
+ const metadata = readMetadata(runDir);
226
+ if (!metadata || metadata.state === "exited" || !isRunAlive(metadata)) return;
227
+ await new Promise<void>((resolveDelay) => setTimeout(resolveDelay, 50));
228
+ }
229
+ }
230
+
231
+ export function removeRunDir(runDir: string): void {
232
+ rmSync(runDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
233
+ }
234
+
235
+ export function tmuxSessionExists(session: string): boolean {
236
+ return spawnSync("tmux", ["has-session", "-t", session], { stdio: "ignore" }).status === 0;
237
+ }
238
+
239
+ /**
240
+ * Start the child pi process for a run.
241
+ *
242
+ * On Windows this spawns a detached, hidden supervisor that owns a `pi --mode rpc`
243
+ * child, so subagents survive `/reload` and parent restarts. The initial task is
244
+ * delivered through the inbox instead of CLI arguments, so `initialArgs` is only
245
+ * used on the tmux path.
246
+ */
247
+ export function launchRun(metadata: RunMetadata, initialArgs: string[] = []): void {
248
+ if (isWindows()) {
249
+ const supervisorPath = join(packageDir, "supervisor.ts");
250
+ const child = spawn(process.execPath, [supervisorPath, metadata.runDir], {
251
+ cwd: metadata.cwd,
252
+ detached: true,
253
+ stdio: "ignore",
254
+ windowsHide: true,
255
+ env: { ...process.env },
256
+ });
257
+ child.unref();
258
+ if (!child.pid) throw new Error("Failed to start the subagent supervisor");
259
+ return;
260
+ }
261
+
262
+ let launcher = "pi";
263
+ const testLauncher = join(metadata.cwd, "pi-test.sh");
264
+ try {
265
+ accessSync(testLauncher, constants.X_OK);
266
+ launcher = testLauncher;
267
+ } catch {
268
+ // Use the installed pi executable.
269
+ }
270
+
271
+ const result = spawnSync(
272
+ "tmux",
273
+ [
274
+ "new-session",
275
+ "-d",
276
+ "-s",
277
+ metadata.tmuxSession,
278
+ "-x",
279
+ "120",
280
+ "-y",
281
+ "40",
282
+ "-c",
283
+ metadata.cwd,
284
+ "--",
285
+ "env",
286
+ `PI_SUBAGENT_RUN_DIR=${metadata.runDir}`,
287
+ launcher,
288
+ "--session",
289
+ metadata.sessionFile,
290
+ "--provider",
291
+ metadata.provider,
292
+ "--model",
293
+ metadata.model,
294
+ "--thinking",
295
+ metadata.thinking,
296
+ ...(metadata.launchArgs ?? []),
297
+ ...initialArgs,
298
+ ],
299
+ { encoding: "utf8" },
300
+ );
301
+ if (result.status !== 0) throw new Error(result.stderr.trim() || "Failed to create tmux session");
302
+ }
303
+
304
+ export function effectiveRunState(metadata: RunMetadata): RunState {
305
+ if (
306
+ (metadata.state === "starting" || metadata.state === "busy" || metadata.state === "idle") &&
307
+ !isRunAlive(metadata)
308
+ ) {
309
+ return "exited";
310
+ }
311
+ return metadata.state;
312
+ }
313
+
314
+ export function listRuns(parentSessionId?: string): RunMetadata[] {
315
+ const root = getRunsDir();
316
+ if (!existsSync(root)) return [];
317
+ const runs: RunMetadata[] = [];
318
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
319
+ if (!entry.isDirectory()) continue;
320
+ const metadata = readMetadata(join(root, entry.name));
321
+ if (!metadata) continue;
322
+ if (parentSessionId && metadata.parentSessionId !== parentSessionId) continue;
323
+ runs.push(metadata);
324
+ }
325
+ return runs.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
326
+ }
327
+
328
+ function isSessionEntry(value: unknown): value is SessionEntry {
329
+ if (typeof value !== "object" || value === null) return false;
330
+ const entry = value as Record<string, unknown>;
331
+ return (
332
+ typeof entry.type === "string" &&
333
+ typeof entry.id === "string" &&
334
+ (entry.parentId === null || typeof entry.parentId === "string")
335
+ );
336
+ }
337
+
338
+ function activeBranch(entries: SessionEntry[]): SessionEntry[] {
339
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
340
+ const branch: SessionEntry[] = [];
341
+ const seen = new Set<string>();
342
+ let current = entries.at(-1);
343
+ while (current && !seen.has(current.id)) {
344
+ branch.push(current);
345
+ seen.add(current.id);
346
+ current = current.parentId === null ? undefined : byId.get(current.parentId);
347
+ }
348
+ return branch.reverse();
349
+ }
350
+
351
+ function isAssistantEntry(entry: SessionEntry): entry is AssistantEntry {
352
+ if (entry.type !== "message" || typeof entry.message !== "object" || entry.message === null) return false;
353
+ return (entry.message as Record<string, unknown>).role === "assistant";
354
+ }
355
+
356
+ export function readLatestAssistant(sessionFile: string): AssistantMessage | undefined {
357
+ let content: string;
358
+ try {
359
+ content = readFileSync(sessionFile, "utf8");
360
+ } catch {
361
+ return undefined;
362
+ }
363
+ const entries: SessionEntry[] = [];
364
+ for (const line of content.split("\n")) {
365
+ if (!line.trim()) continue;
366
+ try {
367
+ const value: unknown = JSON.parse(line);
368
+ if (isSessionEntry(value)) entries.push(value);
369
+ } catch {
370
+ // The final JSONL record may still be in the process of being appended.
371
+ }
372
+ }
373
+ return activeBranch(entries).findLast(isAssistantEntry)?.message;
374
+ }
375
+
376
+ export function assistantText(message: AssistantMessage): string {
377
+ if (!Array.isArray(message.content)) return message.errorMessage ?? "(no response text)";
378
+ const parts: string[] = [];
379
+ for (const item of message.content) {
380
+ if (typeof item !== "object" || item === null) continue;
381
+ const block = item as Record<string, unknown>;
382
+ if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
383
+ }
384
+ return parts.join("\n").trim() || message.errorMessage || "(no response text)";
385
+ }
@@ -0,0 +1,93 @@
1
+ ---
2
+ name: subagent
3
+ description: Spawn and communicate with observable Pi subagents for second opinions, independent investigations, and delegated implementation work.
4
+ ---
5
+
6
+ # Subagent
7
+
8
+ Use the `subagent` CLI to delegate work to a separate Pi process with its own context and tmux session. Managed subagents cannot spawn nested subagents, and this skill is not loaded into their sessions.
9
+
10
+ ## Spawn
11
+
12
+ Give each child a short, descriptive name. The name appears in the parent UI, while the generated handle remains its stable identifier:
13
+
14
+ ```sh
15
+ subagent spawn --name implementation --prompt "Complete task"
16
+ ```
17
+
18
+ By default, the child inherits the current Pi provider, model, and reasoning level from the bash environment. Use `--provider`, `--model`, or `--thinking` only when deliberately choosing a different configuration, for example a faster model for simple investigation or a stronger model for difficult review. Never pass `$PI_PROVIDER`, `$PI_MODEL`, or `$PI_REASONING_LEVEL` back to the CLI explicitly.
19
+
20
+ `--cwd` defaults to the current directory. Repeat `--prompt` and `--file` as needed; Pi receives all of them in the first user turn. Run `subagent spawn` directly without piping or redirecting its concise output.
21
+
22
+ ## Tools
23
+
24
+ Without `--tools`, Pi enables its four default tools:
25
+
26
+ - `read` - Read text files and images.
27
+ - `bash` - Execute shell commands. This can also modify files, so it is not a read-only capability.
28
+ - `edit` - Apply exact text replacements to existing files.
29
+ - `write` - Create or overwrite files.
30
+
31
+ Three additional built-in tools are available but off by default:
32
+
33
+ - `grep` - Search file contents.
34
+ - `find` - Find files by glob pattern.
35
+ - `ls` - List directory contents.
36
+
37
+ `--tools` is a comma-separated allowlist across built-in, extension, and custom tools. Common choices:
38
+
39
+ ```sh
40
+ # Enforced read-only repository investigation
41
+ --tools read,grep,find,ls
42
+
43
+ # Investigation that may run tests or other commands; bash can modify files
44
+ --tools read,bash,grep,find,ls
45
+
46
+ # Implementation work: omit --tools to use the defaults
47
+ ```
48
+
49
+ ## Isolation flags
50
+
51
+ - `--no-extensions` - Disable normal extension discovery. Use for an independent run that should not inherit unrelated extension behavior or extension-provided tools. The subagent control bridge is still explicitly loaded.
52
+ - `--no-skills` - Hide installed skills from the child. Use for tightly specified tasks that should rely only on the supplied prompt and files.
53
+ - `--no-prompt-templates` - Disable prompt-template commands. Usually unnecessary, but useful when testing a minimal child environment.
54
+ - `--no-context-files` - Ignore repository instruction files such as `AGENTS.md` and `CLAUDE.md`. Use only when those instructions would bias an independent investigation. Do not use for implementation unless intentionally bypassing repository guidance.
55
+
56
+ Spawn prints a random handle and either the tmux attach command or (on Windows) the session file path. Keep the handle for later commands. Subagents persist after their current turn completes: `wait`, completion, and becoming idle do not terminate them. They survive `/reload`. When the spawning Pi session quits or is replaced, they are suspended and relaunched idle with their history when that session is resumed. They are only removed by `subagent stop`.
57
+
58
+ ## Inspect and wait
59
+
60
+ ```sh
61
+ subagent status <handle>
62
+ subagent list
63
+ subagent wait <handle>
64
+ ```
65
+
66
+ Run `subagent list` at any time to rediscover active subagents and their handles, names, and current states if you no longer remember them.
67
+
68
+ `wait` follows the durable session state and prints the latest final assistant response. Run it directly so the bash tool returns that response; do not redirect it to a file or pipe it unless the user explicitly asks for an artifact. Do not infer completion from captured terminal text. Set the bash timeout above `wait`'s default 1800-second deadline.
69
+
70
+ ## Communicate
71
+
72
+ ```sh
73
+ # Change the name shown in the parent UI.
74
+ subagent rename <handle> "new name"
75
+
76
+ # Idle: starts a new prompt. Busy: steers the current work.
77
+ subagent send <handle> "message"
78
+
79
+ # Busy: waits until current work finishes, then starts another turn.
80
+ subagent send <handle> --follow-up "message"
81
+ ```
82
+
83
+ Names are 1–64 characters and need not be unique. Messages use the subagent extension's control inbox; do not use `tmux send-keys`.
84
+
85
+ ## Stop
86
+
87
+ ```sh
88
+ subagent stop <handle>
89
+ ```
90
+
91
+ Explicitly stop every subagent when it is no longer needed; do not leave completed subagents running idle. This terminates tmux and removes the run transcript and metadata. Keep an idle subagent alive only when concrete follow-up work is expected, then stop it afterward.
92
+
93
+ The interactive `/subagent` command lists active subagents spawned by the current Pi session and shows their status. On Windows, subagents run as hidden background processes with no attach screen; use `subagent status`, `subagent wait`, and the run's `session.jsonl` file for inspection. On platforms with tmux, selecting a subagent attaches to its tmux session instead.