pi-famulus 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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/bin/pi-famulus.js +29 -0
  4. package/package.json +49 -0
  5. package/src/agents/builtins.ts +41 -0
  6. package/src/agents/definition.ts +221 -0
  7. package/src/agents/loader.ts +119 -0
  8. package/src/bash-override.ts +372 -0
  9. package/src/behavior-guidelines.ts +42 -0
  10. package/src/clock.ts +169 -0
  11. package/src/comms/comms.ts +199 -0
  12. package/src/comms/mailbox.ts +156 -0
  13. package/src/comms/registry-host.ts +73 -0
  14. package/src/comms/reply-command.ts +52 -0
  15. package/src/comms/routing.ts +23 -0
  16. package/src/comms/tools.ts +339 -0
  17. package/src/comms/types.ts +68 -0
  18. package/src/config.ts +266 -0
  19. package/src/events.ts +61 -0
  20. package/src/exit-notify-gate.ts +69 -0
  21. package/src/exit-watchdog.ts +60 -0
  22. package/src/file-tail.ts +48 -0
  23. package/src/format.ts +276 -0
  24. package/src/index.ts +700 -0
  25. package/src/manager-client.ts +839 -0
  26. package/src/monitor-batching.ts +191 -0
  27. package/src/monitor.ts +517 -0
  28. package/src/native-manager.d.ts +9 -0
  29. package/src/native-manager.js +39 -0
  30. package/src/notify.ts +231 -0
  31. package/src/shell-exec.ts +145 -0
  32. package/src/subagent/agent-records.ts +168 -0
  33. package/src/subagent/child-bash.ts +183 -0
  34. package/src/subagent/conversation.ts +76 -0
  35. package/src/subagent/fleet-widget.ts +176 -0
  36. package/src/subagent/model-spec.ts +108 -0
  37. package/src/subagent/pi-runtime.ts +295 -0
  38. package/src/subagent/pool.ts +190 -0
  39. package/src/subagent/registry.ts +575 -0
  40. package/src/subagent/runner.ts +709 -0
  41. package/src/subagent/tool.ts +741 -0
  42. package/src/subagent/transcript.ts +71 -0
  43. package/src/subagent/types.ts +125 -0
  44. package/src/task-exit-notify.ts +21 -0
  45. package/src/task-tools.ts +330 -0
  46. package/src/tui/message-renderers.ts +183 -0
  47. package/src/tui/pi-tui-load.ts +186 -0
  48. package/src/tui/scroll-detail-view.ts +190 -0
  49. package/src/tui/task-output-paths.ts +46 -0
  50. package/src/tui/tasks-command.ts +515 -0
  51. package/src/tui/tool-component.ts +29 -0
  52. package/src/wake.ts +219 -0
  53. package/src/work-index.ts +182 -0
@@ -0,0 +1,839 @@
1
+ /**
2
+ * pi-famulus unix socket client (design doc §4.1, protocol §3.3).
3
+ *
4
+ * - Implements the §3.1 startup flow: connect -> spawn via lock -> zombie cleanup.
5
+ * - Request/response multiplexing over a single long-lived connection.
6
+ * - Server events dispatched to registered handlers.
7
+ * - On unexpected disconnect: immediate then exponential-backoff reconnect for 22s,
8
+ * re-hello after reconnect. If all attempts fail the client is marked unavailable
9
+ * and callers are expected to degrade (bash falls back to local execution).
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ import { randomUUID } from "node:crypto";
13
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
14
+ import net from "node:net";
15
+ import { famulusPaths } from "./config";
16
+ import { realClock, type Clock, type ClockTimer } from "./clock";
17
+
18
+ const MAX_FRAME_BYTES = 4 * 1024 * 1024; // 4 MiB (§3.3)
19
+ const EXTENSION_VERSION = "0.1.0";
20
+ // 3: speaks in-place upgrade (resends on reconnect, start keys).
21
+ const PROTOCOL = 3;
22
+ const HELLO_TIMEOUT_MS = 5000;
23
+ const RECONNECT_HELLO_TIMEOUT_MS = 25_000;
24
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
25
+ const SOCKET_READY_TIMEOUT_MS = 2000;
26
+ const SOCKET_READY_POLL_MS = 50;
27
+ const RECONNECT_WINDOW_MS = 27_000;
28
+ const RETRY_COOLDOWN_MS = 30000;
29
+ const MANAGER_SHUTDOWN_WAIT_MS = 5000;
30
+ const MANAGER_SHUTTING_DOWN = "manager is shutting down";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Protocol types (field names are contractual, see design doc §3.3)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export type TaskOrigin =
37
+ | { via: "bash-fg" | "bash-bg" | "monitor" }
38
+ | { via: "child-bash"; child_id: string; run_id: string };
39
+
40
+ export type StopReason = "tui" | "cli" | "tool" | "timeout" | "rate-limit" | "session-end";
41
+
42
+ export interface StartRequest {
43
+ kind: "shell" | "monitor";
44
+ command: string;
45
+ cwd: string;
46
+ env: Record<string, string>;
47
+ run_in_background?: boolean;
48
+ timeout_ms?: number | null;
49
+ origin?: TaskOrigin;
50
+ }
51
+
52
+ export interface StartResponse {
53
+ task_id: string;
54
+ pid: number;
55
+ }
56
+
57
+ export interface WaitResponse {
58
+ done: boolean;
59
+ exit_code?: number | null;
60
+ }
61
+
62
+ export interface OutputResponse {
63
+ chunk: string;
64
+ next_cursor: number;
65
+ status: string;
66
+ exit_code: number | null;
67
+ total_size: number;
68
+ }
69
+
70
+ export interface TaskRecord {
71
+ task_id: string;
72
+ session_id: string;
73
+ kind: string;
74
+ command: string;
75
+ cwd: string;
76
+ pid: number;
77
+ status: string;
78
+ exit_code: number | null;
79
+ signal: string | null;
80
+ started_at: number;
81
+ ended_at: number | null;
82
+ output_path: string;
83
+ output_size: number;
84
+ origin?: TaskOrigin;
85
+ backgrounded_at?: number;
86
+ end_reason?: string;
87
+ }
88
+
89
+ /** Server-pushed event (§3.3). Fields beyond `event` depend on the event kind. */
90
+ export interface ManagerEvent {
91
+ event: "task_started" | "output" | "task_exited" | "session_rebound" | string;
92
+ task_id?: string;
93
+ kind?: string;
94
+ command?: string;
95
+ pid?: number;
96
+ chunk?: string;
97
+ next_cursor?: number;
98
+ exit_code?: number | null;
99
+ signal?: string | null;
100
+ duration_ms?: number;
101
+ output_path?: string;
102
+ output_size?: number;
103
+ end_reason?: string;
104
+ ts?: number;
105
+ }
106
+
107
+ export class ManagerError extends Error {
108
+ readonly code: string;
109
+ constructor(code: string, message: string) {
110
+ super(message);
111
+ this.name = "ManagerError";
112
+ this.code = code;
113
+ }
114
+ }
115
+
116
+ export interface ManagerClientOptions {
117
+ home: string;
118
+ sessionId: string;
119
+ managerPath: string | null;
120
+ piPid?: number;
121
+ /** Session working directory, sent on hello (optional; older managers ignore it). */
122
+ cwd?: string;
123
+ log?: (message: string) => void;
124
+ clock?: Clock;
125
+ }
126
+
127
+ export interface LastUpgrade {
128
+ at: number;
129
+ ok: boolean;
130
+ from_version: string;
131
+ to_version?: string;
132
+ error?: string;
133
+ trigger: string;
134
+ }
135
+
136
+ export interface ManagerStatusResponse {
137
+ sessions: SessionInfo[];
138
+ generation?: number;
139
+ last_upgrade?: LastUpgrade | null;
140
+ }
141
+
142
+ export interface SessionInfo {
143
+ session_id: string;
144
+ pi_pid: number;
145
+ connected: boolean;
146
+ cwd?: string;
147
+ extension_version?: string;
148
+ protocol?: number;
149
+ }
150
+
151
+ type ClientState = "disconnected" | "connected" | "unavailable";
152
+
153
+ type EventHandler = (event: ManagerEvent) => void;
154
+
155
+ interface PendingRequest {
156
+ resolve: (value: Record<string, unknown>) => void;
157
+ reject: (err: Error) => void;
158
+ timer: ClockTimer;
159
+ message: Record<string, unknown>;
160
+ timeoutMs: number;
161
+ retryable: boolean;
162
+ }
163
+
164
+ /** Reassembles `u32 BE length + JSON` frames from a byte stream. */
165
+ class FrameDecoder {
166
+ private buf: Buffer = Buffer.alloc(0);
167
+
168
+ push(data: Buffer): Record<string, unknown>[] {
169
+ this.buf = this.buf.length === 0 ? data : Buffer.concat([this.buf, data]);
170
+ const messages: Record<string, unknown>[] = [];
171
+ while (this.buf.length >= 4) {
172
+ const len = this.buf.readUInt32BE(0);
173
+ if (len > MAX_FRAME_BYTES) {
174
+ throw new Error(`pi-famulus frame too large: ${len} bytes`);
175
+ }
176
+ if (this.buf.length < 4 + len) break;
177
+ const payload = this.buf.subarray(4, 4 + len).toString("utf8");
178
+ this.buf = this.buf.subarray(4 + len);
179
+ messages.push(JSON.parse(payload) as Record<string, unknown>);
180
+ }
181
+ return messages;
182
+ }
183
+
184
+ reset(): void {
185
+ this.buf = Buffer.alloc(0);
186
+ }
187
+ }
188
+
189
+ function encodeFrame(message: Record<string, unknown>): Buffer {
190
+ const payload = Buffer.from(JSON.stringify(message), "utf8");
191
+ const header = Buffer.alloc(4);
192
+ header.writeUInt32BE(payload.length, 0);
193
+ return Buffer.concat([header, payload]);
194
+ }
195
+
196
+ function pidAlive(pid: number): boolean {
197
+ try {
198
+ process.kill(pid, 0);
199
+ return true;
200
+ } catch (err) {
201
+ // EPERM means the process exists but we cannot signal it.
202
+ return (err as NodeJS.ErrnoException).code === "EPERM";
203
+ }
204
+ }
205
+
206
+ /**
207
+ * O_EXCL pid-file lock used by the extension while spawning the daemon.
208
+ * The Rust CLI uses an fd-lock on the same path and leaves an empty file after
209
+ * release — treat empty / non-pid / dead-pid contents as stale and break them.
210
+ * Exported for unit tests.
211
+ */
212
+ export function tryAcquireSpawnLockFile(lockPath: string, pid: number = process.pid): boolean {
213
+ for (let attempt = 0; attempt < 3; attempt++) {
214
+ try {
215
+ writeFileSync(lockPath, String(pid), { flag: "wx" });
216
+ return true;
217
+ } catch (err) {
218
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") return false;
219
+ let stale = false;
220
+ try {
221
+ const raw = readFileSync(lockPath, "utf8").trim();
222
+ const holderPid = Number.parseInt(raw, 10);
223
+ // Empty (Rust leftover), unparseable, or dead holder → reclaim.
224
+ stale = raw === "" || !Number.isFinite(holderPid) || !pidAlive(holderPid);
225
+ } catch {
226
+ stale = true;
227
+ }
228
+ if (!stale) return false;
229
+ try {
230
+ unlinkSync(lockPath);
231
+ } catch {
232
+ return false;
233
+ }
234
+ }
235
+ }
236
+ return false;
237
+ }
238
+
239
+ export function releaseSpawnLockFile(lockPath: string): void {
240
+ try {
241
+ unlinkSync(lockPath);
242
+ } catch {
243
+ // already gone
244
+ }
245
+ }
246
+
247
+ function delay(clock: Clock, ms: number): Promise<void> {
248
+ // These awaited timers intentionally remain ref'd; an unref'd handshake
249
+ // timer can let print-mode pi exit before the manager connection completes.
250
+ return clock.sleep(ms);
251
+ }
252
+
253
+ export class ManagerClient {
254
+ private readonly home: string;
255
+ private readonly sessionId: string;
256
+ private readonly managerPath: string | null;
257
+ private readonly piPid: number;
258
+ private readonly cwd: string | undefined;
259
+ private readonly log: (message: string) => void;
260
+ private readonly clock: Clock;
261
+
262
+ private socket: net.Socket | null = null;
263
+ private decoder = new FrameDecoder();
264
+ private pending = new Map<string, PendingRequest>();
265
+ private queuedRequests = new Set<PendingRequest>();
266
+ private helloWaiter: { resolve: () => void; reject: (err: Error) => void } | null = null;
267
+ private eventHandlers = new Set<EventHandler>();
268
+ private reconnectHandlers = new Set<() => void>();
269
+ private state: ClientState = "disconnected";
270
+ private intentionalClose = false;
271
+ private rebound = false;
272
+ private reconnecting: Promise<void> | null = null;
273
+ /** One in-flight connect shared by session_start and ensureAvailable. */
274
+ private connecting: Promise<boolean> | null = null;
275
+ private lastFailureAt = 0;
276
+ private lastFailureMessage = "";
277
+
278
+ constructor(options: ManagerClientOptions) {
279
+ this.home = options.home;
280
+ this.sessionId = options.sessionId;
281
+ this.managerPath = options.managerPath;
282
+ this.piPid = options.piPid ?? process.pid;
283
+ this.cwd = options.cwd;
284
+ this.log = options.log ?? (() => {});
285
+ this.clock = options.clock ?? realClock;
286
+ }
287
+
288
+ private now(): number {
289
+ return this.clock.now();
290
+ }
291
+
292
+ isAvailable(): boolean {
293
+ return this.state === "connected";
294
+ }
295
+
296
+ /** Last connect/reconnect failure reason (empty when never failed / currently connected). */
297
+ lastError(): string {
298
+ return this.lastFailureMessage;
299
+ }
300
+
301
+ /**
302
+ * Ensure a live connection, used by tools before issuing requests.
303
+ * When previously marked unavailable, a single retry is allowed after a
304
+ * cooldown so a recovered manager is picked up without hot-looping.
305
+ */
306
+ async ensureAvailable(): Promise<boolean> {
307
+ if (this.state === "connected") return true;
308
+ if (this.connecting) return this.connecting;
309
+ if (this.reconnecting) {
310
+ await this.reconnecting;
311
+ return this.isAvailable();
312
+ }
313
+ if (this.state === "unavailable" && this.now() - this.lastFailureAt < RETRY_COOLDOWN_MS) {
314
+ return false;
315
+ }
316
+ return this.connect();
317
+ }
318
+
319
+ onEvent(handler: EventHandler): () => void {
320
+ this.eventHandlers.add(handler);
321
+ return () => this.eventHandlers.delete(handler);
322
+ }
323
+
324
+ /** Fired after a successful reconnect (post re-hello); used to re-subscribe watches. */
325
+ onReconnect(handler: () => void): () => void {
326
+ this.reconnectHandlers.add(handler);
327
+ return () => this.reconnectHandlers.delete(handler);
328
+ }
329
+
330
+ /**
331
+ * Connect to the manager, spawning it if necessary (§3.1 flow).
332
+ * Returns true when connected and hello completed.
333
+ */
334
+ async connect(): Promise<boolean> {
335
+ if (this.state === "connected") return true;
336
+ if (this.connecting) return this.connecting;
337
+ this.intentionalClose = false;
338
+ let run!: Promise<boolean>;
339
+ run = (async (): Promise<boolean> => {
340
+ try {
341
+ await this.connectFlow(true);
342
+ this.state = "connected";
343
+ return true;
344
+ } catch (err) {
345
+ this.log(`connect failed: ${(err as Error).message}`);
346
+ this.state = "unavailable";
347
+ this.lastFailureAt = this.now();
348
+ this.lastFailureMessage = (err as Error).message;
349
+ return false;
350
+ } finally {
351
+ if (this.connecting === run) this.connecting = null;
352
+ }
353
+ })();
354
+ this.connecting = run;
355
+ return run;
356
+ }
357
+
358
+ /** Graceful session shutdown: stop all tasks of this session, then disconnect. */
359
+ async shutdownSession(): Promise<string[]> {
360
+ const res = await this.request({ type: "shutdown_session" });
361
+ return (res.stopped as string[] | undefined) ?? [];
362
+ }
363
+
364
+ async start(req: StartRequest): Promise<StartResponse> {
365
+ const res = await this.request({ type: "start", ...req, key: randomUUID() });
366
+ return { task_id: res.task_id as string, pid: res.pid as number };
367
+ }
368
+
369
+ async wait(taskId: string, budgetMs: number): Promise<WaitResponse> {
370
+ const res = await this.request(
371
+ { type: "wait", task_id: taskId, budget_ms: budgetMs },
372
+ budgetMs + 15000,
373
+ );
374
+ return { done: res.done === true, exit_code: (res.exit_code as number | null) ?? null };
375
+ }
376
+
377
+ async output(taskId: string, cursor: number, maxBytes: number): Promise<OutputResponse> {
378
+ const res = await this.request({
379
+ type: "output",
380
+ task_id: taskId,
381
+ cursor,
382
+ max_bytes: maxBytes,
383
+ });
384
+ return {
385
+ chunk: (res.chunk as string) ?? "",
386
+ next_cursor: (res.next_cursor as number) ?? cursor,
387
+ status: (res.status as string) ?? "unknown",
388
+ exit_code: (res.exit_code as number | null) ?? null,
389
+ total_size: (res.total_size as number) ?? 0,
390
+ };
391
+ }
392
+
393
+ async markBackground(taskId: string): Promise<void> {
394
+ await this.request({ type: "mark_background", task_id: taskId });
395
+ }
396
+
397
+ async stop(taskId: string, reason: StopReason = "tool"): Promise<void> {
398
+ await this.request({ type: "stop", task_id: taskId, reason });
399
+ }
400
+
401
+ async list(all = false): Promise<TaskRecord[]> {
402
+ const res = await this.request({ type: "list", all });
403
+ return ((res.tasks as TaskRecord[] | undefined) ?? []) as TaskRecord[];
404
+ }
405
+
406
+ /** Daemon status, including optional in-place upgrade metadata. */
407
+ async status(): Promise<ManagerStatusResponse> {
408
+ const res = await this.request({ type: "status" });
409
+ return {
410
+ sessions: (res.sessions as SessionInfo[] | undefined) ?? [],
411
+ ...(typeof res.generation === "number" ? { generation: res.generation } : {}),
412
+ ...(res.last_upgrade && typeof res.last_upgrade === "object"
413
+ ? { last_upgrade: res.last_upgrade as LastUpgrade }
414
+ : {}),
415
+ };
416
+ }
417
+
418
+ /** Connected sessions (status). Older managers may reject this for extension clients. */
419
+ async sessions(): Promise<SessionInfo[]> {
420
+ return (await this.status()).sessions;
421
+ }
422
+
423
+ async watch(taskId: string): Promise<void> {
424
+ await this.request({ type: "watch", task_id: taskId });
425
+ }
426
+
427
+ async unwatch(taskId: string): Promise<void> {
428
+ await this.request({ type: "unwatch", task_id: taskId });
429
+ }
430
+
431
+ /** Close the connection without reconnecting. */
432
+ async close(): Promise<void> {
433
+ this.intentionalClose = true;
434
+ this.state = "disconnected";
435
+ this.failAllPending(new Error("manager client closed"));
436
+ const socket = this.socket;
437
+ this.socket = null;
438
+ if (socket) {
439
+ socket.removeAllListeners();
440
+ socket.end();
441
+ socket.destroy();
442
+ }
443
+ }
444
+
445
+ // -------------------------------------------------------------------------
446
+ // Startup flow (§3.1)
447
+ // -------------------------------------------------------------------------
448
+
449
+ private async connectFlow(allowZombieRetry: boolean, helloTimeoutMs = HELLO_TIMEOUT_MS): Promise<void> {
450
+ const paths = famulusPaths(this.home);
451
+ try {
452
+ await this.connectAndHello(paths.socket, helloTimeoutMs);
453
+ return;
454
+ } catch (err) {
455
+ if (err instanceof HelloError) {
456
+ if (err.message.includes(MANAGER_SHUTTING_DOWN)) {
457
+ // A shutdown manager refuses hello while it completes its bounded
458
+ // kill grace. Wait for its pid to exit before retrying/spawning.
459
+ await this.waitForManagerExit(paths.pidFile);
460
+ if (allowZombieRetry) {
461
+ await this.connectFlow(false, helloTimeoutMs);
462
+ return;
463
+ }
464
+ throw err;
465
+ }
466
+ // Socket exists but hello failed: possible zombie socket (§3.1 step 5).
467
+ this.handleZombie(paths.socket, paths.pidFile);
468
+ if (allowZombieRetry) {
469
+ await this.connectFlow(false, helloTimeoutMs);
470
+ return;
471
+ }
472
+ throw err;
473
+ }
474
+ // Connect failed: nobody listening. Try to spawn the manager.
475
+ }
476
+
477
+ // The home directory may not exist yet on a fresh machine.
478
+ mkdirSync(this.home, { recursive: true });
479
+ const acquired = this.tryAcquireSpawnLock(paths.spawnLock);
480
+ if (acquired) {
481
+ try {
482
+ this.spawnManager();
483
+ await this.waitForSocket(paths.socket, SOCKET_READY_TIMEOUT_MS);
484
+ } finally {
485
+ this.releaseSpawnLock(paths.spawnLock);
486
+ }
487
+ } else {
488
+ // Someone else is spawning; just wait for the socket to appear.
489
+ await this.waitForSocket(paths.socket, SOCKET_READY_TIMEOUT_MS);
490
+ }
491
+ await this.connectAndHello(paths.socket, helloTimeoutMs);
492
+ }
493
+
494
+ private async waitForManagerExit(pidFile: string): Promise<void> {
495
+ let pid: number;
496
+ try {
497
+ const info = JSON.parse(readFileSync(pidFile, "utf8")) as { pid?: number };
498
+ if (typeof info.pid !== "number") return;
499
+ pid = info.pid;
500
+ } catch {
501
+ return;
502
+ }
503
+ const deadline = this.now() + MANAGER_SHUTDOWN_WAIT_MS;
504
+ while (pidAlive(pid) && this.now() < deadline) {
505
+ await this.clock.sleep(SOCKET_READY_POLL_MS);
506
+ }
507
+ }
508
+
509
+ private handleZombie(socketPath: string, pidFile: string): void {
510
+ let pid: number | null = null;
511
+ try {
512
+ const info = JSON.parse(readFileSync(pidFile, "utf8")) as { pid?: number };
513
+ if (typeof info.pid === "number") pid = info.pid;
514
+ } catch {
515
+ // no readable pid file
516
+ }
517
+ if (pid !== null && pidAlive(pid)) {
518
+ // Manager is alive but rejected hello; nothing to clean up.
519
+ return;
520
+ }
521
+ for (const file of [socketPath, pidFile]) {
522
+ try {
523
+ unlinkSync(file);
524
+ } catch {
525
+ // already gone
526
+ }
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Exclusive create of manager.spawn.lock with our pid as contents.
532
+ * Compatible with the Rust CLI's fd-lock on the same path: that lock leaves
533
+ * an empty file behind after release, which must not look like a live hold.
534
+ * Returns false only when another live holder (numeric pid still alive) owns it.
535
+ */
536
+ private tryAcquireSpawnLock(lockPath: string): boolean {
537
+ return tryAcquireSpawnLockFile(lockPath, process.pid);
538
+ }
539
+
540
+ private releaseSpawnLock(lockPath: string): void {
541
+ releaseSpawnLockFile(lockPath);
542
+ }
543
+
544
+ private spawnManager(): void {
545
+ if (!this.managerPath) {
546
+ throw new Error("pi-famulus binary not found (set managerPath in config.json or PI_FAMULUS_MANAGER_PATH)");
547
+ }
548
+ // Pass --home explicitly: relying on PI_FAMULUS_HOME env inheritance breaks when
549
+ // this.home came from an explicit override rather than the environment.
550
+ const child = spawn(this.managerPath, ["--home", this.home, "daemon"], {
551
+ detached: true,
552
+ stdio: "ignore",
553
+ env: process.env,
554
+ });
555
+ child.unref();
556
+ child.on("error", () => {}); // surfaced via waitForSocket timeout
557
+ }
558
+
559
+ private async waitForSocket(socketPath: string, timeoutMs: number): Promise<void> {
560
+ const deadline = this.now() + timeoutMs;
561
+ for (;;) {
562
+ if (existsSync(socketPath)) {
563
+ const ok = await new Promise<boolean>((resolve) => {
564
+ const probe = net.connect(socketPath);
565
+ probe.once("connect", () => {
566
+ probe.destroy();
567
+ resolve(true);
568
+ });
569
+ probe.once("error", () => resolve(false));
570
+ });
571
+ if (ok) return;
572
+ }
573
+ if (this.now() >= deadline) {
574
+ throw new Error("timed out waiting for pi-famulus socket");
575
+ }
576
+ await delay(this.clock, SOCKET_READY_POLL_MS);
577
+ }
578
+ }
579
+
580
+ // -------------------------------------------------------------------------
581
+ // Connection / protocol internals
582
+ // -------------------------------------------------------------------------
583
+
584
+ private async connectAndHello(socketPath: string, helloTimeoutMs = HELLO_TIMEOUT_MS): Promise<void> {
585
+ const socket = await new Promise<net.Socket>((resolve, reject) => {
586
+ const s = net.connect(socketPath);
587
+ s.once("connect", () => resolve(s));
588
+ s.once("error", (err) => reject(err));
589
+ });
590
+ this.attachSocket(socket);
591
+ try {
592
+ await this.hello(helloTimeoutMs);
593
+ } catch (err) {
594
+ this.detachSocket();
595
+ throw new HelloError((err as Error).message);
596
+ }
597
+ }
598
+
599
+ private attachSocket(socket: net.Socket): void {
600
+ const previous = this.socket;
601
+ if (previous && previous !== socket) {
602
+ // Drop the old socket's handlers before it can close and tear down the new one.
603
+ previous.removeAllListeners();
604
+ previous.destroy();
605
+ }
606
+ this.socket = socket;
607
+ this.decoder.reset();
608
+ socket.on("data", (data) => {
609
+ if (this.socket !== socket) return;
610
+ this.onData(data);
611
+ });
612
+ socket.on("close", () => {
613
+ if (this.socket !== socket) return;
614
+ this.onClose();
615
+ });
616
+ socket.on("error", (err) => {
617
+ if (this.socket !== socket) return;
618
+ this.log(`socket error: ${err.message}`);
619
+ });
620
+ }
621
+
622
+ private detachSocket(): void {
623
+ const socket = this.socket;
624
+ this.socket = null;
625
+ if (socket) {
626
+ socket.removeAllListeners();
627
+ socket.destroy();
628
+ }
629
+ }
630
+
631
+ private hello(timeoutMs = HELLO_TIMEOUT_MS): Promise<void> {
632
+ const socket = this.socket;
633
+ if (!socket) return Promise.reject(new Error("no socket"));
634
+ // The id is included so a manager that echoes request ids resolves via the
635
+ // pending map; otherwise the first unnamed response settles the waiter.
636
+ const id = randomUUID();
637
+ return new Promise<void>((resolve, reject) => {
638
+ const done = (err?: Error) => {
639
+ this.clock.clearTimeout(timer);
640
+ this.pending.delete(id);
641
+ this.helloWaiter = null;
642
+ if (err) reject(err);
643
+ else resolve();
644
+ };
645
+ const timer = this.clock.setTimeout(() => done(new Error("hello timed out")), timeoutMs);
646
+ this.helloWaiter = { resolve: () => done(), reject: (err) => done(err) };
647
+ this.pending.set(id, {
648
+ resolve: () => done(), reject: (err) => done(err), timer,
649
+ message: { type: "hello" }, timeoutMs, retryable: false,
650
+ });
651
+ socket.write(
652
+ encodeFrame({
653
+ v: 1,
654
+ id,
655
+ type: "hello",
656
+ client_kind: "extension",
657
+ session_id: this.sessionId,
658
+ pi_pid: this.piPid,
659
+ ...(this.cwd ? { cwd: this.cwd } : {}),
660
+ extension_version: EXTENSION_VERSION,
661
+ protocol: PROTOCOL,
662
+ }),
663
+ );
664
+ });
665
+ }
666
+
667
+ private onData(data: Buffer): void {
668
+ let messages: Record<string, unknown>[];
669
+ try {
670
+ messages = this.decoder.push(data);
671
+ } catch (err) {
672
+ this.log(`protocol error: ${(err as Error).message}`);
673
+ this.detachSocket();
674
+ this.onClose();
675
+ return;
676
+ }
677
+ for (const msg of messages) {
678
+ this.dispatch(msg);
679
+ }
680
+ }
681
+
682
+ private dispatch(msg: Record<string, unknown>): void {
683
+ if (msg.type === "event") {
684
+ const event = msg as unknown as ManagerEvent;
685
+ if (event.event === "session_rebound") {
686
+ // Only the socket that is still current lost the session. A stale
687
+ // hello's rebound must not disable reconnect on the winning socket.
688
+ this.rebound = true;
689
+ }
690
+ for (const handler of this.eventHandlers) {
691
+ try {
692
+ handler(event);
693
+ } catch (err) {
694
+ this.log(`event handler error: ${(err as Error).message}`);
695
+ }
696
+ }
697
+ return;
698
+ }
699
+ const id = msg.id as string | undefined;
700
+ if (id && this.pending.has(id)) {
701
+ this.settle(id, msg);
702
+ return;
703
+ }
704
+ if (this.helloWaiter && typeof msg.ok === "boolean") {
705
+ // Unnamed response: correlate by order with the outstanding hello.
706
+ if (msg.ok === true) this.helloWaiter.resolve();
707
+ else this.helloWaiter.reject(errorFromResponse(msg));
708
+ this.helloWaiter = null;
709
+ return;
710
+ }
711
+ this.log(`unmatched message: ${JSON.stringify(msg).slice(0, 200)}`);
712
+ }
713
+
714
+ private settle(id: string, msg: Record<string, unknown>): void {
715
+ const entry = this.pending.get(id);
716
+ if (!entry) return;
717
+ this.pending.delete(id);
718
+ this.clock.clearTimeout(entry.timer);
719
+ if (msg.ok === true) {
720
+ entry.resolve(msg);
721
+ } else {
722
+ entry.reject(errorFromResponse(msg));
723
+ }
724
+ }
725
+
726
+ private request(msg: Record<string, unknown>, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): Promise<Record<string, unknown>> {
727
+ const type = String(msg.type);
728
+ const retryable = ["wait", "output", "list", "watch", "status", "stop", "mark_background", "start"].includes(type);
729
+ const effectiveTimeoutMs = retryable ? Math.max(timeoutMs, RECONNECT_WINDOW_MS + 1000) : timeoutMs;
730
+ return new Promise((resolve, reject) => {
731
+ const entry: PendingRequest = {
732
+ resolve, reject, message: msg, timeoutMs: effectiveTimeoutMs, retryable,
733
+ timer: this.clock.setTimeout(() => {
734
+ for (const [id, pending] of this.pending) {
735
+ if (pending === entry) this.pending.delete(id);
736
+ }
737
+ this.queuedRequests.delete(entry);
738
+ reject(new Error(`pi-famulus request timed out: ${type}`));
739
+ }, effectiveTimeoutMs),
740
+ };
741
+ if (this.socket && this.state === "connected") this.sendPending(entry);
742
+ else if (retryable && (this.reconnecting || this.connecting)) this.queuedRequests.add(entry);
743
+ else {
744
+ this.clock.clearTimeout(entry.timer);
745
+ reject(new Error("pi-famulus not connected"));
746
+ }
747
+ });
748
+ }
749
+
750
+ private sendPending(entry: PendingRequest): void {
751
+ const socket = this.socket;
752
+ if (!socket || this.state !== "connected") return;
753
+ const id = randomUUID();
754
+ this.pending.set(id, entry);
755
+ socket.write(encodeFrame({ v: 1, id, ...entry.message }));
756
+ }
757
+
758
+ private resendPending(): void {
759
+ const entries = [...new Set([...this.pending.values(), ...this.queuedRequests])].filter((entry) => entry.retryable);
760
+ this.pending.clear();
761
+ this.queuedRequests.clear();
762
+ for (const entry of entries) this.sendPending(entry);
763
+ }
764
+
765
+ private onClose(): void {
766
+ const wasConnected = this.state === "connected";
767
+ this.detachSocket();
768
+ for (const [id, entry] of [...this.pending]) {
769
+ if (entry.retryable && !this.intentionalClose && !this.rebound) continue;
770
+ this.pending.delete(id);
771
+ this.clock.clearTimeout(entry.timer);
772
+ entry.reject(new Error("pi-famulus connection lost"));
773
+ }
774
+ if (this.helloWaiter) {
775
+ this.helloWaiter.reject(new Error("connection closed during hello"));
776
+ this.helloWaiter = null;
777
+ }
778
+ if (this.intentionalClose || this.rebound) {
779
+ this.state = "disconnected";
780
+ return;
781
+ }
782
+ this.state = "disconnected";
783
+ if (wasConnected) {
784
+ let run!: Promise<void>;
785
+ run = this.reconnectLoop().finally(() => {
786
+ if (this.reconnecting === run) this.reconnecting = null;
787
+ });
788
+ this.reconnecting = run;
789
+ }
790
+ }
791
+
792
+ private async reconnectLoop(): Promise<void> {
793
+ const deadline = this.now() + RECONNECT_WINDOW_MS;
794
+ let nextDelayMs = 0;
795
+ while (this.now() < deadline) {
796
+ if (nextDelayMs > 0) await delay(this.clock, Math.min(nextDelayMs, deadline - this.now()));
797
+ if (this.intentionalClose || this.rebound) return;
798
+ try {
799
+ await this.connectFlow(true, RECONNECT_HELLO_TIMEOUT_MS);
800
+ this.state = "connected";
801
+ this.resendPending();
802
+ this.log("reconnected to pi-famulus");
803
+ for (const handler of this.reconnectHandlers) {
804
+ try {
805
+ handler();
806
+ } catch (err) {
807
+ this.log(`reconnect handler error: ${(err as Error).message}`);
808
+ }
809
+ }
810
+ return;
811
+ } catch (err) {
812
+ this.log(`reconnect attempt failed: ${(err as Error).message}`);
813
+ nextDelayMs = nextDelayMs === 0 ? 100 : Math.min(nextDelayMs * 2, 2000);
814
+ }
815
+ }
816
+ this.state = "unavailable";
817
+ this.lastFailureAt = this.now();
818
+ this.lastFailureMessage = "reconnect exhausted";
819
+ this.failAllPending(new Error("pi-famulus reconnect exhausted"));
820
+ this.log("giving up on pi-famulus; bash falls back to local execution");
821
+ }
822
+
823
+ private failAllPending(err: Error): void {
824
+ const entries = new Set([...this.pending.values(), ...this.queuedRequests]);
825
+ for (const entry of entries) {
826
+ this.clock.clearTimeout(entry.timer);
827
+ entry.reject(err);
828
+ }
829
+ this.pending.clear();
830
+ this.queuedRequests.clear();
831
+ }
832
+ }
833
+
834
+ class HelloError extends Error {}
835
+
836
+ function errorFromResponse(msg: Record<string, unknown>): ManagerError {
837
+ const err = msg.error as { code?: string; message?: string } | undefined;
838
+ return new ManagerError(err?.code ?? "E_INTERNAL", err?.message ?? "unknown manager error");
839
+ }