pi-famulus 0.1.3 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/bin/pi-famulus.js +1 -1
- package/package.json +11 -7
- package/src/agents/builtins.ts +5 -22
- package/src/agents/definition.ts +4 -6
- package/src/bash-override.ts +31 -29
- package/src/behavior-guidelines.ts +40 -16
- package/src/comms/comms.ts +7 -3
- package/src/comms/tools.ts +11 -36
- package/src/config.ts +43 -7
- package/src/format.ts +31 -14
- package/src/index.ts +123 -33
- package/src/manager-client.ts +267 -115
- package/src/monitor.ts +14 -33
- package/src/native-manager.js +8 -3
- package/src/prompts.generated.ts +183 -0
- package/src/shell-exec.ts +6 -2
- package/src/subagent/admission.ts +224 -0
- package/src/subagent/agent-records.ts +66 -3
- package/src/subagent/child-bash.ts +6 -14
- package/src/subagent/overrun.ts +36 -21
- package/src/subagent/pi-runtime.ts +116 -31
- package/src/subagent/registry.ts +165 -42
- package/src/subagent/runner.ts +335 -60
- package/src/subagent/tool.ts +49 -72
- package/src/subagent/types.ts +60 -5
- package/src/subagent/usage.ts +48 -0
- package/src/task-tools.ts +15 -25
- package/src/tui/message-renderers.ts +17 -1
- package/src/wake.ts +66 -37
- package/src/work-index.ts +4 -0
package/src/manager-client.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-famulus unix socket client (design doc §4.1, protocol §3.3).
|
|
3
3
|
*
|
|
4
|
-
* - Implements
|
|
4
|
+
* - Implements §3.1 startup: connect -> spawn via lock; only the daemon's
|
|
5
|
+
* lifetime-lock holder cleans up stale socket/pid files.
|
|
5
6
|
* - Request/response multiplexing over a single long-lived connection.
|
|
6
7
|
* - Server events dispatched to registered handlers.
|
|
7
8
|
* - On unexpected disconnect: immediate then exponential-backoff reconnect for 22s,
|
|
@@ -12,21 +13,24 @@ import { spawn } from "node:child_process";
|
|
|
12
13
|
import { randomUUID } from "node:crypto";
|
|
13
14
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
14
15
|
import net from "node:net";
|
|
16
|
+
import { StringDecoder } from "node:string_decoder";
|
|
15
17
|
import { famulusPaths } from "./config";
|
|
16
18
|
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
17
19
|
|
|
18
20
|
const MAX_FRAME_BYTES = 4 * 1024 * 1024; // 4 MiB (§3.3)
|
|
19
|
-
const EXTENSION_VERSION = "0.1.
|
|
20
|
-
//
|
|
21
|
-
const PROTOCOL =
|
|
21
|
+
const EXTENSION_VERSION = "0.1.6";
|
|
22
|
+
// 5: speaks per-kind agent admission and queued acquire.
|
|
23
|
+
const PROTOCOL = 5;
|
|
22
24
|
const HELLO_TIMEOUT_MS = 5000;
|
|
23
25
|
const RECONNECT_HELLO_TIMEOUT_MS = 25_000;
|
|
24
26
|
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
25
|
-
|
|
27
|
+
// One bounded startup budget: keep waiting for a live child, but only
|
|
28
|
+
// respawn when it explicitly reports losing the daemon's lifetime lock.
|
|
29
|
+
const STARTUP_WINDOW_MS = 10_000;
|
|
26
30
|
const SOCKET_READY_POLL_MS = 50;
|
|
27
31
|
const RECONNECT_WINDOW_MS = 27_000;
|
|
28
32
|
const RETRY_COOLDOWN_MS = 30000;
|
|
29
|
-
const
|
|
33
|
+
const SHUTDOWN_PROBE_TIMEOUT_MS = 2000;
|
|
30
34
|
const MANAGER_SHUTTING_DOWN = "manager is shutting down";
|
|
31
35
|
|
|
32
36
|
// ---------------------------------------------------------------------------
|
|
@@ -152,13 +156,23 @@ type ClientState = "disconnected" | "connected" | "unavailable";
|
|
|
152
156
|
|
|
153
157
|
type EventHandler = (event: ManagerEvent) => void;
|
|
154
158
|
|
|
159
|
+
interface SpawnAttempt {
|
|
160
|
+
failure?: Error;
|
|
161
|
+
contended: boolean;
|
|
162
|
+
output: string;
|
|
163
|
+
dispose(): void;
|
|
164
|
+
}
|
|
165
|
+
|
|
155
166
|
interface PendingRequest {
|
|
156
167
|
resolve: (value: Record<string, unknown>) => void;
|
|
157
168
|
reject: (err: Error) => void;
|
|
158
|
-
timer
|
|
169
|
+
timer?: ClockTimer;
|
|
170
|
+
retryTimer?: ClockTimer;
|
|
159
171
|
message: Record<string, unknown>;
|
|
160
172
|
timeoutMs: number;
|
|
161
173
|
retryable: boolean;
|
|
174
|
+
requestId?: string;
|
|
175
|
+
removeAbortListener?: () => void;
|
|
162
176
|
}
|
|
163
177
|
|
|
164
178
|
/** Reassembles `u32 BE length + JSON` frames from a byte stream. */
|
|
@@ -266,6 +280,7 @@ export class ManagerClient {
|
|
|
266
280
|
private helloWaiter: { resolve: () => void; reject: (err: Error) => void } | null = null;
|
|
267
281
|
private eventHandlers = new Set<EventHandler>();
|
|
268
282
|
private reconnectHandlers = new Set<() => void>();
|
|
283
|
+
private protocolLevel_ = 0;
|
|
269
284
|
private state: ClientState = "disconnected";
|
|
270
285
|
private intentionalClose = false;
|
|
271
286
|
private rebound = false;
|
|
@@ -293,6 +308,11 @@ export class ManagerClient {
|
|
|
293
308
|
return this.state === "connected";
|
|
294
309
|
}
|
|
295
310
|
|
|
311
|
+
/** Maximum protocol level reported by the connected manager; 0 means unknown/legacy. */
|
|
312
|
+
protocolLevel(): number {
|
|
313
|
+
return this.protocolLevel_;
|
|
314
|
+
}
|
|
315
|
+
|
|
296
316
|
/** Last connect/reconnect failure reason (empty when never failed / currently connected). */
|
|
297
317
|
lastError(): string {
|
|
298
318
|
return this.lastFailureMessage;
|
|
@@ -338,14 +358,16 @@ export class ManagerClient {
|
|
|
338
358
|
let run!: Promise<boolean>;
|
|
339
359
|
run = (async (): Promise<boolean> => {
|
|
340
360
|
try {
|
|
341
|
-
await this.connectFlow(
|
|
361
|
+
await this.connectFlow();
|
|
362
|
+
this.checkStartupOpen();
|
|
342
363
|
this.state = "connected";
|
|
364
|
+
this.lastFailureMessage = "";
|
|
343
365
|
return true;
|
|
344
366
|
} catch (err) {
|
|
345
|
-
this.
|
|
346
|
-
this.state = "unavailable";
|
|
367
|
+
this.state = this.intentionalClose ? "disconnected" : "unavailable";
|
|
347
368
|
this.lastFailureAt = this.now();
|
|
348
|
-
this.lastFailureMessage = (err as Error).message;
|
|
369
|
+
this.lastFailureMessage = this.startupFailureDetails((err as Error).message);
|
|
370
|
+
this.log(`connect failed: ${this.lastFailureMessage}`);
|
|
349
371
|
return false;
|
|
350
372
|
} finally {
|
|
351
373
|
if (this.connecting === run) this.connecting = null;
|
|
@@ -403,6 +425,24 @@ export class ManagerClient {
|
|
|
403
425
|
return ((res.tasks as TaskRecord[] | undefined) ?? []) as TaskRecord[];
|
|
404
426
|
}
|
|
405
427
|
|
|
428
|
+
async acquireAgent(
|
|
429
|
+
childId: string,
|
|
430
|
+
workKind = "other",
|
|
431
|
+
signal?: AbortSignal,
|
|
432
|
+
): Promise<{ granted: boolean; rejection?: string }> {
|
|
433
|
+
if (signal?.aborted) throw new Error("subagent admission cancelled");
|
|
434
|
+
const message: Record<string, unknown> = { type: "acquire_agent", child_id: childId };
|
|
435
|
+
if (this.protocolLevel_ >= 5) message.work_kind = workKind;
|
|
436
|
+
// Protocol 4 has no cancel frame. Keep its immediate acquire correlated so
|
|
437
|
+
// admission can release a grant that arrives after the generation aborts.
|
|
438
|
+
const res = await this.request(message, DEFAULT_REQUEST_TIMEOUT_MS, this.protocolLevel_ >= 5 ? signal : undefined, true);
|
|
439
|
+
return { granted: res.granted === true, ...(typeof res.rejection === "string" ? { rejection: res.rejection } : {}) };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async releaseAgent(childId: string): Promise<void> {
|
|
443
|
+
await this.request({ type: "release_agent", child_id: childId });
|
|
444
|
+
}
|
|
445
|
+
|
|
406
446
|
/** Daemon status, including optional in-place upgrade metadata. */
|
|
407
447
|
async status(): Promise<ManagerStatusResponse> {
|
|
408
448
|
const res = await this.request({ type: "status" });
|
|
@@ -446,85 +486,85 @@ export class ManagerClient {
|
|
|
446
486
|
// Startup flow (§3.1)
|
|
447
487
|
// -------------------------------------------------------------------------
|
|
448
488
|
|
|
449
|
-
private
|
|
489
|
+
private checkStartupOpen(): void {
|
|
490
|
+
if (this.intentionalClose) throw new Error("manager client closed");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private async connectFlow(helloTimeoutMs = HELLO_TIMEOUT_MS, outerDeadline = Infinity): Promise<void> {
|
|
450
494
|
const paths = famulusPaths(this.home);
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
await this.connectFlow(false, helloTimeoutMs);
|
|
470
|
-
return;
|
|
495
|
+
const deadline = Math.min(outerDeadline, this.now() + Math.max(STARTUP_WINDOW_MS, helloTimeoutMs));
|
|
496
|
+
let shutdownObserved = false;
|
|
497
|
+
let retryDelayMs = 100;
|
|
498
|
+
let lastReason = "";
|
|
499
|
+
for (;;) {
|
|
500
|
+
this.checkStartupOpen();
|
|
501
|
+
if (this.now() >= deadline) throw new Error(`pi-famulus startup deadline exhausted: ${lastReason}`);
|
|
502
|
+
const probeMs = shutdownObserved ? SHUTDOWN_PROBE_TIMEOUT_MS : helloTimeoutMs;
|
|
503
|
+
try {
|
|
504
|
+
await this.connectAndHello(paths.socket, Math.min(probeMs, deadline - this.now()));
|
|
505
|
+
return;
|
|
506
|
+
} catch (err) {
|
|
507
|
+
lastReason = (err as Error).message;
|
|
508
|
+
if (!(err instanceof HelloError)) {
|
|
509
|
+
// Nobody listening: the daemon's lifetime lock safely arbitrates
|
|
510
|
+
// any spawn, including a retiring owner whose files are gone.
|
|
511
|
+
await this.ensureSocketReady(paths.socket, paths.spawnLock, deadline);
|
|
512
|
+
continue;
|
|
471
513
|
}
|
|
472
|
-
|
|
514
|
+
if (err.message === `E_INTERNAL: ${MANAGER_SHUTTING_DOWN}`) shutdownObserved = true;
|
|
515
|
+
else if (!shutdownObserved || !err.transportFailure) throw err;
|
|
516
|
+
// Only a known shutdown permits retrying a stalled/lost handshake.
|
|
517
|
+
// Re-probe instead of trusting pid liveness: that pid may be stale,
|
|
518
|
+
// and another client may already have started a healthy successor.
|
|
473
519
|
}
|
|
474
|
-
|
|
520
|
+
await delay(this.clock, Math.min(retryDelayMs, Math.max(0, deadline - this.now())));
|
|
521
|
+
retryDelayMs = Math.min(retryDelayMs * 2, 1000);
|
|
475
522
|
}
|
|
523
|
+
}
|
|
476
524
|
|
|
525
|
+
private async ensureSocketReady(socketPath: string, spawnLock: string, deadline: number): Promise<void> {
|
|
477
526
|
// The home directory may not exist yet on a fresh machine.
|
|
478
527
|
mkdirSync(this.home, { recursive: true });
|
|
479
|
-
|
|
480
|
-
|
|
528
|
+
let contentionDelayMs = 100;
|
|
529
|
+
let lastContention = "";
|
|
530
|
+
for (;;) {
|
|
531
|
+
this.checkStartupOpen();
|
|
532
|
+
if (this.now() >= deadline) throw new Error(`pi-famulus startup deadline exhausted${lastContention ? ` after contention: ${lastContention}` : ""}`);
|
|
533
|
+
const acquired = this.tryAcquireSpawnLock(spawnLock);
|
|
534
|
+
let attempt: SpawnAttempt | undefined;
|
|
481
535
|
try {
|
|
482
|
-
this.spawnManager();
|
|
483
|
-
await this.waitForSocket(
|
|
536
|
+
if (acquired) attempt = this.spawnManager();
|
|
537
|
+
await this.waitForSocket(socketPath, deadline - this.now(), attempt);
|
|
538
|
+
break;
|
|
539
|
+
} catch (err) {
|
|
540
|
+
// A daemon that lost manager.lock exits successfully, but did NOT
|
|
541
|
+
// start our successor. Retry only this explicit contention state.
|
|
542
|
+
if (!attempt?.contended || this.now() >= deadline) throw err;
|
|
543
|
+
lastContention = attempt.failure?.message ?? "daemon lifetime lock held";
|
|
544
|
+
this.log(`startup contention: ${lastContention}; retrying after ${contentionDelayMs}ms`);
|
|
484
545
|
} finally {
|
|
485
|
-
|
|
546
|
+
attempt?.dispose();
|
|
547
|
+
if (acquired) this.releaseSpawnLock(spawnLock);
|
|
486
548
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
await this.waitForSocket(paths.socket, SOCKET_READY_TIMEOUT_MS);
|
|
549
|
+
await delay(this.clock, Math.min(contentionDelayMs, deadline - this.now()));
|
|
550
|
+
contentionDelayMs = Math.min(contentionDelayMs * 2, 1000);
|
|
490
551
|
}
|
|
491
|
-
await this.connectAndHello(paths.socket, helloTimeoutMs);
|
|
492
552
|
}
|
|
493
553
|
|
|
494
|
-
private
|
|
495
|
-
|
|
554
|
+
private startupFailureDetails(reason: string): string {
|
|
555
|
+
const paths = famulusPaths(this.home);
|
|
556
|
+
let managerPid = "missing/unreadable";
|
|
557
|
+
let spawnHolder = "missing/unreadable";
|
|
558
|
+
const describePid = (pid: number) => `${pid} (${pidAlive(pid) ? "alive" : "dead"})`;
|
|
496
559
|
try {
|
|
497
|
-
const info = JSON.parse(readFileSync(pidFile, "utf8")) as { pid?: number };
|
|
498
|
-
if (typeof info.pid
|
|
499
|
-
|
|
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;
|
|
560
|
+
const info = JSON.parse(readFileSync(paths.pidFile, "utf8")) as { pid?: number };
|
|
561
|
+
if (typeof info.pid === "number") managerPid = describePid(info.pid);
|
|
562
|
+
} catch { /* diagnostic only */ }
|
|
511
563
|
try {
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
} catch {
|
|
515
|
-
|
|
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
|
-
}
|
|
564
|
+
const raw = readFileSync(paths.spawnLock, "utf8").trim();
|
|
565
|
+
spawnHolder = /^\d+$/.test(raw) ? describePid(Number(raw)) : "empty/non-pid";
|
|
566
|
+
} catch { /* diagnostic only */ }
|
|
567
|
+
return `${reason}; home=${this.home}; binary=${this.managerPath ?? "missing"}; socket=${existsSync(paths.socket) ? "present" : "missing"}; manager.pid=${managerPid}; spawn.lock=${spawnHolder}`;
|
|
528
568
|
}
|
|
529
569
|
|
|
530
570
|
/**
|
|
@@ -541,7 +581,7 @@ export class ManagerClient {
|
|
|
541
581
|
releaseSpawnLockFile(lockPath);
|
|
542
582
|
}
|
|
543
583
|
|
|
544
|
-
private spawnManager():
|
|
584
|
+
private spawnManager(): SpawnAttempt {
|
|
545
585
|
if (!this.managerPath) {
|
|
546
586
|
throw new Error("pi-famulus binary not found (set managerPath in config.json or PI_FAMULUS_MANAGER_PATH)");
|
|
547
587
|
}
|
|
@@ -549,31 +589,59 @@ export class ManagerClient {
|
|
|
549
589
|
// this.home came from an explicit override rather than the environment.
|
|
550
590
|
const child = spawn(this.managerPath, ["--home", this.home, "daemon"], {
|
|
551
591
|
detached: true,
|
|
552
|
-
stdio: "ignore",
|
|
592
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
553
593
|
env: process.env,
|
|
554
594
|
});
|
|
595
|
+
const streams = [child.stdout, child.stderr];
|
|
596
|
+
// Each pipe owns its partial UTF-8 bytes; interleaved streams cannot share carry.
|
|
597
|
+
const decoders = streams.map(() => new StringDecoder("utf8"));
|
|
598
|
+
const attempt: SpawnAttempt = {
|
|
599
|
+
contended: false,
|
|
600
|
+
output: "",
|
|
601
|
+
dispose: () => {
|
|
602
|
+
// Keep draining detached daemon pipes without keeping pi alive.
|
|
603
|
+
// Closing a live child's reader would turn a later write into SIGPIPE.
|
|
604
|
+
streams.forEach((stream, index) => {
|
|
605
|
+
stream?.off("data", captures[index]);
|
|
606
|
+
stream?.resume();
|
|
607
|
+
(stream as net.Socket | null)?.unref?.();
|
|
608
|
+
});
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
const append = (text: string) => {
|
|
612
|
+
const tail = (attempt.output + text).slice(-2048);
|
|
613
|
+
// A UTF-16 bound must not retain just the low half of a surrogate pair.
|
|
614
|
+
const first = tail.charCodeAt(0);
|
|
615
|
+
attempt.output = first >= 0xdc00 && first <= 0xdfff ? tail.slice(1) : tail;
|
|
616
|
+
};
|
|
617
|
+
const captures = decoders.map((decoder) => (data: Buffer) => append(decoder.write(data)));
|
|
618
|
+
streams.forEach((stream, index) => stream?.on("data", captures[index]));
|
|
555
619
|
child.unref();
|
|
556
|
-
child.on("error", () => {
|
|
620
|
+
child.on("error", (err) => { attempt.failure = new Error(`manager spawn failed: ${err.message}`); });
|
|
621
|
+
// 'close' follows stdio EOF; 'exit' alone may precede the contention line.
|
|
622
|
+
child.on("close", (code, signal) => {
|
|
623
|
+
for (const decoder of decoders) append(decoder.end());
|
|
624
|
+
attempt.contended = code === 0 && attempt.output.includes("pi-famulus already running");
|
|
625
|
+
attempt.failure = new Error(`manager exited before socket ready (code=${code}, signal=${signal}): ${attempt.output.trim()}`);
|
|
626
|
+
});
|
|
627
|
+
return attempt;
|
|
557
628
|
}
|
|
558
629
|
|
|
559
|
-
private async waitForSocket(socketPath: string, timeoutMs: number): Promise<void> {
|
|
630
|
+
private async waitForSocket(socketPath: string, timeoutMs: number, attempt?: SpawnAttempt): Promise<void> {
|
|
560
631
|
const deadline = this.now() + timeoutMs;
|
|
561
632
|
for (;;) {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
resolve(true);
|
|
568
|
-
});
|
|
569
|
-
probe.once("error", () => resolve(false));
|
|
570
|
-
});
|
|
633
|
+
this.checkStartupOpen();
|
|
634
|
+
// A named pipe is not a file: existsSync does not see it, connecting does.
|
|
635
|
+
if (process.platform === "win32" || existsSync(socketPath)) {
|
|
636
|
+
const ok = await this.openSocket(socketPath, Math.min(SOCKET_READY_POLL_MS, Math.max(0, deadline - this.now())))
|
|
637
|
+
.then((probe) => { probe.destroy(); return true; }, () => false);
|
|
571
638
|
if (ok) return;
|
|
572
639
|
}
|
|
640
|
+
if (attempt?.failure) throw attempt.failure;
|
|
573
641
|
if (this.now() >= deadline) {
|
|
574
|
-
throw new Error(
|
|
642
|
+
throw new Error(`timed out waiting for pi-famulus socket (spawn=${attempt ? "running" : "other holder"}${attempt?.output ? `, output=${attempt.output.trim()}` : ""})`);
|
|
575
643
|
}
|
|
576
|
-
await delay(this.clock, SOCKET_READY_POLL_MS);
|
|
644
|
+
await delay(this.clock, Math.min(SOCKET_READY_POLL_MS, deadline - this.now()));
|
|
577
645
|
}
|
|
578
646
|
}
|
|
579
647
|
|
|
@@ -581,18 +649,36 @@ export class ManagerClient {
|
|
|
581
649
|
// Connection / protocol internals
|
|
582
650
|
// -------------------------------------------------------------------------
|
|
583
651
|
|
|
584
|
-
private
|
|
585
|
-
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
|
|
652
|
+
private openSocket(socketPath: string, timeoutMs: number): Promise<net.Socket> {
|
|
653
|
+
return new Promise((resolve, reject) => {
|
|
654
|
+
const socket = net.connect(socketPath);
|
|
655
|
+
const timer = this.clock.setTimeout(() => {
|
|
656
|
+
socket.destroy();
|
|
657
|
+
reject(new Error("timed out connecting to pi-famulus socket"));
|
|
658
|
+
}, Math.max(0, timeoutMs));
|
|
659
|
+
socket.once("connect", () => { this.clock.clearTimeout(timer); resolve(socket); });
|
|
660
|
+
socket.once("error", (err) => {
|
|
661
|
+
this.clock.clearTimeout(timer);
|
|
662
|
+
socket.destroy();
|
|
663
|
+
reject(err);
|
|
664
|
+
});
|
|
589
665
|
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private async connectAndHello(socketPath: string, helloTimeoutMs = HELLO_TIMEOUT_MS): Promise<void> {
|
|
669
|
+
const deadline = this.now() + Math.max(0, helloTimeoutMs);
|
|
670
|
+
const socket = await this.openSocket(socketPath, helloTimeoutMs);
|
|
671
|
+
if (this.intentionalClose) {
|
|
672
|
+
socket.destroy();
|
|
673
|
+
this.checkStartupOpen();
|
|
674
|
+
}
|
|
590
675
|
this.attachSocket(socket);
|
|
591
676
|
try {
|
|
592
|
-
await this.hello(
|
|
677
|
+
await this.hello(Math.max(0, deadline - this.now()));
|
|
593
678
|
} catch (err) {
|
|
594
679
|
this.detachSocket();
|
|
595
|
-
|
|
680
|
+
const reason = err instanceof ManagerError ? `${err.code}: ${err.message}` : (err as Error).message;
|
|
681
|
+
throw new HelloError(reason, !(err instanceof ManagerError));
|
|
596
682
|
}
|
|
597
683
|
}
|
|
598
684
|
|
|
@@ -646,7 +732,7 @@ export class ManagerClient {
|
|
|
646
732
|
this.helloWaiter = { resolve: () => done(), reject: (err) => done(err) };
|
|
647
733
|
this.pending.set(id, {
|
|
648
734
|
resolve: () => done(), reject: (err) => done(err), timer,
|
|
649
|
-
message: { type: "hello" }, timeoutMs, retryable: false,
|
|
735
|
+
message: { type: "hello" }, timeoutMs, retryable: false, requestId: id,
|
|
650
736
|
});
|
|
651
737
|
socket.write(
|
|
652
738
|
encodeFrame({
|
|
@@ -670,6 +756,9 @@ export class ManagerClient {
|
|
|
670
756
|
messages = this.decoder.push(data);
|
|
671
757
|
} catch (err) {
|
|
672
758
|
this.log(`protocol error: ${(err as Error).message}`);
|
|
759
|
+
// Preserve the actual handshake failure before onClose replaces it
|
|
760
|
+
// with a generic connection-lost error.
|
|
761
|
+
this.helloWaiter?.reject(new ManagerError("E_PROTOCOL", (err as Error).message));
|
|
673
762
|
this.detachSocket();
|
|
674
763
|
this.onClose();
|
|
675
764
|
return;
|
|
@@ -715,33 +804,88 @@ export class ManagerClient {
|
|
|
715
804
|
const entry = this.pending.get(id);
|
|
716
805
|
if (!entry) return;
|
|
717
806
|
this.pending.delete(id);
|
|
718
|
-
this.clock.clearTimeout(entry.timer);
|
|
807
|
+
if (entry.timer !== undefined) this.clock.clearTimeout(entry.timer);
|
|
808
|
+
if (entry.retryTimer !== undefined) this.clock.clearInterval(entry.retryTimer);
|
|
809
|
+
entry.removeAbortListener?.();
|
|
719
810
|
if (msg.ok === true) {
|
|
811
|
+
if (entry.message.type === "hello") {
|
|
812
|
+
const serverMaximum = typeof msg.protocol === "number" ? msg.protocol : 0;
|
|
813
|
+
this.protocolLevel_ = Math.min(PROTOCOL, serverMaximum);
|
|
814
|
+
}
|
|
720
815
|
entry.resolve(msg);
|
|
721
816
|
} else {
|
|
722
817
|
entry.reject(errorFromResponse(msg));
|
|
723
818
|
}
|
|
724
819
|
}
|
|
725
820
|
|
|
726
|
-
private request(
|
|
821
|
+
private request(
|
|
822
|
+
msg: Record<string, unknown>,
|
|
823
|
+
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
824
|
+
signal?: AbortSignal,
|
|
825
|
+
waitForAdmission = false,
|
|
826
|
+
): Promise<Record<string, unknown>> {
|
|
727
827
|
const type = String(msg.type);
|
|
728
|
-
const retryable = ["wait", "output", "list", "watch", "status", "stop", "mark_background", "start"].includes(type);
|
|
828
|
+
const retryable = ["wait", "output", "list", "watch", "status", "stop", "mark_background", "start", "acquire_agent", "cancel_acquire_agent", "release_agent"].includes(type);
|
|
729
829
|
const effectiveTimeoutMs = retryable ? Math.max(timeoutMs, RECONNECT_WINDOW_MS + 1000) : timeoutMs;
|
|
730
830
|
return new Promise((resolve, reject) => {
|
|
831
|
+
if (signal?.aborted) {
|
|
832
|
+
reject(new Error("subagent admission cancelled"));
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
731
835
|
const entry: PendingRequest = {
|
|
732
|
-
resolve,
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
836
|
+
resolve,
|
|
837
|
+
reject,
|
|
838
|
+
message: msg,
|
|
839
|
+
timeoutMs: effectiveTimeoutMs,
|
|
840
|
+
retryable,
|
|
841
|
+
};
|
|
842
|
+
const removeEntry = () => {
|
|
843
|
+
if (entry.requestId) this.pending.delete(entry.requestId);
|
|
844
|
+
this.queuedRequests.delete(entry);
|
|
845
|
+
if (entry.timer !== undefined) this.clock.clearTimeout(entry.timer);
|
|
846
|
+
if (entry.retryTimer !== undefined) this.clock.clearInterval(entry.retryTimer);
|
|
847
|
+
entry.removeAbortListener?.();
|
|
740
848
|
};
|
|
849
|
+
const cancelRemoteAcquire = () => {
|
|
850
|
+
const requestId = entry.requestId;
|
|
851
|
+
if (type === "acquire_agent" && this.protocolLevel_ >= 5 && requestId) {
|
|
852
|
+
// Protocol-5 cancellation is retryable across reconnects, so a grant
|
|
853
|
+
// raced with a dropped socket cannot outlive the cancelled generation.
|
|
854
|
+
void this.request({
|
|
855
|
+
type: "cancel_acquire_agent",
|
|
856
|
+
request_id: requestId,
|
|
857
|
+
child_id: msg.child_id,
|
|
858
|
+
}).catch(() => {});
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
if (effectiveTimeoutMs > 0 && !(waitForAdmission && signal)) {
|
|
862
|
+
entry.timer = this.clock.setTimeout(() => {
|
|
863
|
+
cancelRemoteAcquire();
|
|
864
|
+
removeEntry();
|
|
865
|
+
reject(new Error(`pi-famulus request timed out: ${type}`));
|
|
866
|
+
}, effectiveTimeoutMs);
|
|
867
|
+
}
|
|
868
|
+
if (waitForAdmission && type === "acquire_agent") {
|
|
869
|
+
entry.retryTimer = this.clock.setInterval(() => {
|
|
870
|
+
const id = entry.requestId;
|
|
871
|
+
const socket = this.socket;
|
|
872
|
+
if (!id || this.pending.get(id) !== entry || !socket || this.state !== "connected") return;
|
|
873
|
+
socket.write(encodeFrame({ v: 1, id, ...entry.message }));
|
|
874
|
+
}, 3000);
|
|
875
|
+
}
|
|
876
|
+
if (signal) {
|
|
877
|
+
const onAbort = () => {
|
|
878
|
+
cancelRemoteAcquire();
|
|
879
|
+
removeEntry();
|
|
880
|
+
reject(new Error("subagent admission cancelled"));
|
|
881
|
+
};
|
|
882
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
883
|
+
entry.removeAbortListener = () => signal.removeEventListener("abort", onAbort);
|
|
884
|
+
}
|
|
741
885
|
if (this.socket && this.state === "connected") this.sendPending(entry);
|
|
742
886
|
else if (retryable && (this.reconnecting || this.connecting)) this.queuedRequests.add(entry);
|
|
743
887
|
else {
|
|
744
|
-
|
|
888
|
+
removeEntry();
|
|
745
889
|
reject(new Error("pi-famulus not connected"));
|
|
746
890
|
}
|
|
747
891
|
});
|
|
@@ -750,7 +894,8 @@ export class ManagerClient {
|
|
|
750
894
|
private sendPending(entry: PendingRequest): void {
|
|
751
895
|
const socket = this.socket;
|
|
752
896
|
if (!socket || this.state !== "connected") return;
|
|
753
|
-
const id = randomUUID();
|
|
897
|
+
const id = entry.requestId ?? randomUUID();
|
|
898
|
+
entry.requestId = id;
|
|
754
899
|
this.pending.set(id, entry);
|
|
755
900
|
socket.write(encodeFrame({ v: 1, id, ...entry.message }));
|
|
756
901
|
}
|
|
@@ -768,7 +913,9 @@ export class ManagerClient {
|
|
|
768
913
|
for (const [id, entry] of [...this.pending]) {
|
|
769
914
|
if (entry.retryable && !this.intentionalClose && !this.rebound) continue;
|
|
770
915
|
this.pending.delete(id);
|
|
771
|
-
this.clock.clearTimeout(entry.timer);
|
|
916
|
+
if (entry.timer !== undefined) this.clock.clearTimeout(entry.timer);
|
|
917
|
+
if (entry.retryTimer !== undefined) this.clock.clearInterval(entry.retryTimer);
|
|
918
|
+
entry.removeAbortListener?.();
|
|
772
919
|
entry.reject(new Error("pi-famulus connection lost"));
|
|
773
920
|
}
|
|
774
921
|
if (this.helloWaiter) {
|
|
@@ -796,7 +943,8 @@ export class ManagerClient {
|
|
|
796
943
|
if (nextDelayMs > 0) await delay(this.clock, Math.min(nextDelayMs, deadline - this.now()));
|
|
797
944
|
if (this.intentionalClose || this.rebound) return;
|
|
798
945
|
try {
|
|
799
|
-
await this.connectFlow(
|
|
946
|
+
await this.connectFlow(RECONNECT_HELLO_TIMEOUT_MS, deadline);
|
|
947
|
+
if (this.intentionalClose || this.rebound) return;
|
|
800
948
|
this.state = "connected";
|
|
801
949
|
this.resendPending();
|
|
802
950
|
this.log("reconnected to pi-famulus");
|
|
@@ -823,7 +971,9 @@ export class ManagerClient {
|
|
|
823
971
|
private failAllPending(err: Error): void {
|
|
824
972
|
const entries = new Set([...this.pending.values(), ...this.queuedRequests]);
|
|
825
973
|
for (const entry of entries) {
|
|
826
|
-
this.clock.clearTimeout(entry.timer);
|
|
974
|
+
if (entry.timer !== undefined) this.clock.clearTimeout(entry.timer);
|
|
975
|
+
if (entry.retryTimer !== undefined) this.clock.clearInterval(entry.retryTimer);
|
|
976
|
+
entry.removeAbortListener?.();
|
|
827
977
|
entry.reject(err);
|
|
828
978
|
}
|
|
829
979
|
this.pending.clear();
|
|
@@ -831,7 +981,9 @@ export class ManagerClient {
|
|
|
831
981
|
}
|
|
832
982
|
}
|
|
833
983
|
|
|
834
|
-
class HelloError extends Error {
|
|
984
|
+
class HelloError extends Error {
|
|
985
|
+
constructor(message: string, readonly transportFailure: boolean) { super(message); }
|
|
986
|
+
}
|
|
835
987
|
|
|
836
988
|
function errorFromResponse(msg: Record<string, unknown>): ManagerError {
|
|
837
989
|
const err = msg.error as { code?: string; message?: string } | undefined;
|
package/src/monitor.ts
CHANGED
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
import { Type } from "typebox";
|
|
11
11
|
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { formatMonitorEvent } from "./format";
|
|
13
|
+
import { MONITOR_IDLE_INSTRUCTION } from "./behavior-guidelines";
|
|
13
14
|
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
14
15
|
|
|
15
16
|
import type { ManagerClient, ManagerEvent, TaskRecord } from "./manager-client";
|
|
16
17
|
import { LineBatcher, RateLimiter, SaturationWindow } from "./monitor-batching";
|
|
17
18
|
import type { NotifyCenter } from "./notify";
|
|
19
|
+
import { fill, fillList, PROMPTS } from "./prompts.generated";
|
|
18
20
|
import { statusGlyph, toolComponent } from "./tui/tool-component";
|
|
19
21
|
|
|
20
22
|
|
|
@@ -40,10 +42,7 @@ const EXIT_AFTER_EVENT_MS = 2_000;
|
|
|
40
42
|
* 2026-09-30c: 5 of the 6 FAILs outside grok). Its "Keep working" was tried
|
|
41
43
|
* and dropped: gpt-6-luna started duplicate monitors (eval 2026-09-30d).
|
|
42
44
|
*/
|
|
43
|
-
export const MONITOR_STARTED_INSTRUCTION =
|
|
44
|
-
'You will get a <pi-famulus-wake kind="monitor"> for each event, and a notice when it exits or times out. ' +
|
|
45
|
-
"Do not poll it (task_list, task_output, or reading what it watches) or sleep. " +
|
|
46
|
-
"If nothing else is left to do, reply to the user now with no tool call.";
|
|
45
|
+
export const MONITOR_STARTED_INSTRUCTION = fill("tools.monitor.result.started", { monitorIdle: MONITOR_IDLE_INSTRUCTION });
|
|
47
46
|
/**
|
|
48
47
|
* Events for ids the registry does not know yet. The manager streams a monitor
|
|
49
48
|
* from spawn, so output and even the exit can arrive before `start()` has the
|
|
@@ -440,24 +439,18 @@ function fullEnv(ctx: ExtensionContext, deps: MonitorDeps): Record<string, strin
|
|
|
440
439
|
}
|
|
441
440
|
|
|
442
441
|
const monitorParameters = Type.Object({
|
|
443
|
-
command: Type.String({
|
|
444
|
-
|
|
445
|
-
"Command producing one event per line on stdout. Must be line-buffered " +
|
|
446
|
-
"(e.g. use `stdbuf -oL` / `grep --line-buffered` where needed).",
|
|
447
|
-
}),
|
|
448
|
-
description: Type.String({
|
|
449
|
-
description: "Short human-readable description of what is being watched",
|
|
450
|
-
}),
|
|
442
|
+
command: Type.String({ description: PROMPTS["tools.monitor.param.command"] }),
|
|
443
|
+
description: Type.String({ description: PROMPTS["tools.monitor.param.description"] }),
|
|
451
444
|
timeout_ms: Type.Optional(
|
|
452
445
|
Type.Number({
|
|
453
|
-
description:
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
description: "Keep the monitor alive until the session ends (no timeout). Default false.",
|
|
446
|
+
description: fill("tools.monitor.param.timeout_ms", {
|
|
447
|
+
defaultMs: DEFAULT_TIMEOUT_MS,
|
|
448
|
+
minMs: MIN_TIMEOUT_MS,
|
|
449
|
+
maxMs: MAX_TIMEOUT_MS,
|
|
450
|
+
}),
|
|
459
451
|
}),
|
|
460
452
|
),
|
|
453
|
+
persistent: Type.Optional(Type.Boolean({ description: PROMPTS["tools.monitor.param.persistent"] })),
|
|
461
454
|
});
|
|
462
455
|
|
|
463
456
|
export function createMonitorTool(
|
|
@@ -466,21 +459,9 @@ export function createMonitorTool(
|
|
|
466
459
|
return {
|
|
467
460
|
name: "monitor",
|
|
468
461
|
label: "Monitor",
|
|
469
|
-
description:
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
"The command must be line-buffered: each event must be a single line. " +
|
|
473
|
-
"It must keep running and follow its source, e.g. `tail -n +1 -F file | grep --line-buffered PATTERN`; " +
|
|
474
|
-
"a command that reads once and exits (a plain grep or cat) only reports what is there now. " +
|
|
475
|
-
"Add `-m1` to grep to stop after the first match. " +
|
|
476
|
-
"Silence is not success: write the command so failures also produce lines " +
|
|
477
|
-
"(e.g. grep for both success and error patterns). " +
|
|
478
|
-
"Events arrive as system wakes (not new user messages). Handle each <pi-famulus-wake kind=\"monitor\"> before other work. Do not poll.",
|
|
479
|
-
promptSnippet: "Watch a command's line stream and get injected events",
|
|
480
|
-
promptGuidelines: [
|
|
481
|
-
"Use the monitor tool to watch for conditions instead of running sleep/poll loops in bash.",
|
|
482
|
-
"When woken by a <pi-famulus-wake kind=\"monitor\">, handle the <event> before doing anything else — it is not a new user request and not user confirmation.",
|
|
483
|
-
],
|
|
462
|
+
description: fill("tools.monitor.description", { monitorIdle: MONITOR_IDLE_INSTRUCTION }),
|
|
463
|
+
promptSnippet: PROMPTS["tools.monitor.snippet"],
|
|
464
|
+
promptGuidelines: fillList("tools.monitor.rules", { monitorIdle: MONITOR_IDLE_INSTRUCTION }),
|
|
484
465
|
parameters: monitorParameters,
|
|
485
466
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
486
467
|
const { taskId, timeoutMs } = await registry.start(params, ctx);
|