pi-famulus 0.1.2 → 0.1.4
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 +3 -1
- package/package.json +31 -12
- package/src/agents/builtins.ts +5 -22
- package/src/agents/definition.ts +16 -12
- package/src/bash-override.ts +8 -21
- package/src/behavior-guidelines.ts +40 -15
- package/src/clock.ts +5 -0
- package/src/comms/comms.ts +7 -3
- package/src/comms/tools.ts +11 -36
- package/src/config.ts +25 -1
- package/src/format.ts +83 -5
- package/src/index.ts +155 -30
- package/src/manager-client.ts +265 -114
- package/src/monitor.ts +14 -33
- package/src/notify.ts +7 -7
- 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 +65 -56
- package/src/subagent/model-spec.ts +134 -16
- package/src/subagent/overrun.ts +191 -0
- package/src/subagent/pi-runtime.ts +118 -33
- package/src/subagent/registry.ts +235 -34
- package/src/subagent/runner.ts +565 -99
- package/src/subagent/tool.ts +111 -61
- package/src/subagent/types.ts +71 -7
- package/src/subagent/usage.ts +48 -0
- package/src/task-tools.ts +15 -25
- package/src/thinking-levels.ts +26 -0
- package/src/tui/message-renderers.ts +52 -2
- package/src/wake-delivery.ts +137 -0
- package/src/wake.ts +270 -7
- 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.4";
|
|
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,58 @@ 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 (;;) {
|
|
633
|
+
this.checkStartupOpen();
|
|
562
634
|
if (existsSync(socketPath)) {
|
|
563
|
-
const ok = await
|
|
564
|
-
|
|
565
|
-
probe.once("connect", () => {
|
|
566
|
-
probe.destroy();
|
|
567
|
-
resolve(true);
|
|
568
|
-
});
|
|
569
|
-
probe.once("error", () => resolve(false));
|
|
570
|
-
});
|
|
635
|
+
const ok = await this.openSocket(socketPath, Math.min(SOCKET_READY_POLL_MS, Math.max(0, deadline - this.now())))
|
|
636
|
+
.then((probe) => { probe.destroy(); return true; }, () => false);
|
|
571
637
|
if (ok) return;
|
|
572
638
|
}
|
|
639
|
+
if (attempt?.failure) throw attempt.failure;
|
|
573
640
|
if (this.now() >= deadline) {
|
|
574
|
-
throw new Error(
|
|
641
|
+
throw new Error(`timed out waiting for pi-famulus socket (spawn=${attempt ? "running" : "other holder"}${attempt?.output ? `, output=${attempt.output.trim()}` : ""})`);
|
|
575
642
|
}
|
|
576
|
-
await delay(this.clock, SOCKET_READY_POLL_MS);
|
|
643
|
+
await delay(this.clock, Math.min(SOCKET_READY_POLL_MS, deadline - this.now()));
|
|
577
644
|
}
|
|
578
645
|
}
|
|
579
646
|
|
|
@@ -581,18 +648,36 @@ export class ManagerClient {
|
|
|
581
648
|
// Connection / protocol internals
|
|
582
649
|
// -------------------------------------------------------------------------
|
|
583
650
|
|
|
584
|
-
private
|
|
585
|
-
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
|
|
651
|
+
private openSocket(socketPath: string, timeoutMs: number): Promise<net.Socket> {
|
|
652
|
+
return new Promise((resolve, reject) => {
|
|
653
|
+
const socket = net.connect(socketPath);
|
|
654
|
+
const timer = this.clock.setTimeout(() => {
|
|
655
|
+
socket.destroy();
|
|
656
|
+
reject(new Error("timed out connecting to pi-famulus socket"));
|
|
657
|
+
}, Math.max(0, timeoutMs));
|
|
658
|
+
socket.once("connect", () => { this.clock.clearTimeout(timer); resolve(socket); });
|
|
659
|
+
socket.once("error", (err) => {
|
|
660
|
+
this.clock.clearTimeout(timer);
|
|
661
|
+
socket.destroy();
|
|
662
|
+
reject(err);
|
|
663
|
+
});
|
|
589
664
|
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private async connectAndHello(socketPath: string, helloTimeoutMs = HELLO_TIMEOUT_MS): Promise<void> {
|
|
668
|
+
const deadline = this.now() + Math.max(0, helloTimeoutMs);
|
|
669
|
+
const socket = await this.openSocket(socketPath, helloTimeoutMs);
|
|
670
|
+
if (this.intentionalClose) {
|
|
671
|
+
socket.destroy();
|
|
672
|
+
this.checkStartupOpen();
|
|
673
|
+
}
|
|
590
674
|
this.attachSocket(socket);
|
|
591
675
|
try {
|
|
592
|
-
await this.hello(
|
|
676
|
+
await this.hello(Math.max(0, deadline - this.now()));
|
|
593
677
|
} catch (err) {
|
|
594
678
|
this.detachSocket();
|
|
595
|
-
|
|
679
|
+
const reason = err instanceof ManagerError ? `${err.code}: ${err.message}` : (err as Error).message;
|
|
680
|
+
throw new HelloError(reason, !(err instanceof ManagerError));
|
|
596
681
|
}
|
|
597
682
|
}
|
|
598
683
|
|
|
@@ -646,7 +731,7 @@ export class ManagerClient {
|
|
|
646
731
|
this.helloWaiter = { resolve: () => done(), reject: (err) => done(err) };
|
|
647
732
|
this.pending.set(id, {
|
|
648
733
|
resolve: () => done(), reject: (err) => done(err), timer,
|
|
649
|
-
message: { type: "hello" }, timeoutMs, retryable: false,
|
|
734
|
+
message: { type: "hello" }, timeoutMs, retryable: false, requestId: id,
|
|
650
735
|
});
|
|
651
736
|
socket.write(
|
|
652
737
|
encodeFrame({
|
|
@@ -670,6 +755,9 @@ export class ManagerClient {
|
|
|
670
755
|
messages = this.decoder.push(data);
|
|
671
756
|
} catch (err) {
|
|
672
757
|
this.log(`protocol error: ${(err as Error).message}`);
|
|
758
|
+
// Preserve the actual handshake failure before onClose replaces it
|
|
759
|
+
// with a generic connection-lost error.
|
|
760
|
+
this.helloWaiter?.reject(new ManagerError("E_PROTOCOL", (err as Error).message));
|
|
673
761
|
this.detachSocket();
|
|
674
762
|
this.onClose();
|
|
675
763
|
return;
|
|
@@ -715,33 +803,88 @@ export class ManagerClient {
|
|
|
715
803
|
const entry = this.pending.get(id);
|
|
716
804
|
if (!entry) return;
|
|
717
805
|
this.pending.delete(id);
|
|
718
|
-
this.clock.clearTimeout(entry.timer);
|
|
806
|
+
if (entry.timer !== undefined) this.clock.clearTimeout(entry.timer);
|
|
807
|
+
if (entry.retryTimer !== undefined) this.clock.clearInterval(entry.retryTimer);
|
|
808
|
+
entry.removeAbortListener?.();
|
|
719
809
|
if (msg.ok === true) {
|
|
810
|
+
if (entry.message.type === "hello") {
|
|
811
|
+
const serverMaximum = typeof msg.protocol === "number" ? msg.protocol : 0;
|
|
812
|
+
this.protocolLevel_ = Math.min(PROTOCOL, serverMaximum);
|
|
813
|
+
}
|
|
720
814
|
entry.resolve(msg);
|
|
721
815
|
} else {
|
|
722
816
|
entry.reject(errorFromResponse(msg));
|
|
723
817
|
}
|
|
724
818
|
}
|
|
725
819
|
|
|
726
|
-
private request(
|
|
820
|
+
private request(
|
|
821
|
+
msg: Record<string, unknown>,
|
|
822
|
+
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
823
|
+
signal?: AbortSignal,
|
|
824
|
+
waitForAdmission = false,
|
|
825
|
+
): Promise<Record<string, unknown>> {
|
|
727
826
|
const type = String(msg.type);
|
|
728
|
-
const retryable = ["wait", "output", "list", "watch", "status", "stop", "mark_background", "start"].includes(type);
|
|
827
|
+
const retryable = ["wait", "output", "list", "watch", "status", "stop", "mark_background", "start", "acquire_agent", "cancel_acquire_agent", "release_agent"].includes(type);
|
|
729
828
|
const effectiveTimeoutMs = retryable ? Math.max(timeoutMs, RECONNECT_WINDOW_MS + 1000) : timeoutMs;
|
|
730
829
|
return new Promise((resolve, reject) => {
|
|
830
|
+
if (signal?.aborted) {
|
|
831
|
+
reject(new Error("subagent admission cancelled"));
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
731
834
|
const entry: PendingRequest = {
|
|
732
|
-
resolve,
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
835
|
+
resolve,
|
|
836
|
+
reject,
|
|
837
|
+
message: msg,
|
|
838
|
+
timeoutMs: effectiveTimeoutMs,
|
|
839
|
+
retryable,
|
|
840
|
+
};
|
|
841
|
+
const removeEntry = () => {
|
|
842
|
+
if (entry.requestId) this.pending.delete(entry.requestId);
|
|
843
|
+
this.queuedRequests.delete(entry);
|
|
844
|
+
if (entry.timer !== undefined) this.clock.clearTimeout(entry.timer);
|
|
845
|
+
if (entry.retryTimer !== undefined) this.clock.clearInterval(entry.retryTimer);
|
|
846
|
+
entry.removeAbortListener?.();
|
|
740
847
|
};
|
|
848
|
+
const cancelRemoteAcquire = () => {
|
|
849
|
+
const requestId = entry.requestId;
|
|
850
|
+
if (type === "acquire_agent" && this.protocolLevel_ >= 5 && requestId) {
|
|
851
|
+
// Protocol-5 cancellation is retryable across reconnects, so a grant
|
|
852
|
+
// raced with a dropped socket cannot outlive the cancelled generation.
|
|
853
|
+
void this.request({
|
|
854
|
+
type: "cancel_acquire_agent",
|
|
855
|
+
request_id: requestId,
|
|
856
|
+
child_id: msg.child_id,
|
|
857
|
+
}).catch(() => {});
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
if (effectiveTimeoutMs > 0 && !(waitForAdmission && signal)) {
|
|
861
|
+
entry.timer = this.clock.setTimeout(() => {
|
|
862
|
+
cancelRemoteAcquire();
|
|
863
|
+
removeEntry();
|
|
864
|
+
reject(new Error(`pi-famulus request timed out: ${type}`));
|
|
865
|
+
}, effectiveTimeoutMs);
|
|
866
|
+
}
|
|
867
|
+
if (waitForAdmission && type === "acquire_agent") {
|
|
868
|
+
entry.retryTimer = this.clock.setInterval(() => {
|
|
869
|
+
const id = entry.requestId;
|
|
870
|
+
const socket = this.socket;
|
|
871
|
+
if (!id || this.pending.get(id) !== entry || !socket || this.state !== "connected") return;
|
|
872
|
+
socket.write(encodeFrame({ v: 1, id, ...entry.message }));
|
|
873
|
+
}, 3000);
|
|
874
|
+
}
|
|
875
|
+
if (signal) {
|
|
876
|
+
const onAbort = () => {
|
|
877
|
+
cancelRemoteAcquire();
|
|
878
|
+
removeEntry();
|
|
879
|
+
reject(new Error("subagent admission cancelled"));
|
|
880
|
+
};
|
|
881
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
882
|
+
entry.removeAbortListener = () => signal.removeEventListener("abort", onAbort);
|
|
883
|
+
}
|
|
741
884
|
if (this.socket && this.state === "connected") this.sendPending(entry);
|
|
742
885
|
else if (retryable && (this.reconnecting || this.connecting)) this.queuedRequests.add(entry);
|
|
743
886
|
else {
|
|
744
|
-
|
|
887
|
+
removeEntry();
|
|
745
888
|
reject(new Error("pi-famulus not connected"));
|
|
746
889
|
}
|
|
747
890
|
});
|
|
@@ -750,7 +893,8 @@ export class ManagerClient {
|
|
|
750
893
|
private sendPending(entry: PendingRequest): void {
|
|
751
894
|
const socket = this.socket;
|
|
752
895
|
if (!socket || this.state !== "connected") return;
|
|
753
|
-
const id = randomUUID();
|
|
896
|
+
const id = entry.requestId ?? randomUUID();
|
|
897
|
+
entry.requestId = id;
|
|
754
898
|
this.pending.set(id, entry);
|
|
755
899
|
socket.write(encodeFrame({ v: 1, id, ...entry.message }));
|
|
756
900
|
}
|
|
@@ -768,7 +912,9 @@ export class ManagerClient {
|
|
|
768
912
|
for (const [id, entry] of [...this.pending]) {
|
|
769
913
|
if (entry.retryable && !this.intentionalClose && !this.rebound) continue;
|
|
770
914
|
this.pending.delete(id);
|
|
771
|
-
this.clock.clearTimeout(entry.timer);
|
|
915
|
+
if (entry.timer !== undefined) this.clock.clearTimeout(entry.timer);
|
|
916
|
+
if (entry.retryTimer !== undefined) this.clock.clearInterval(entry.retryTimer);
|
|
917
|
+
entry.removeAbortListener?.();
|
|
772
918
|
entry.reject(new Error("pi-famulus connection lost"));
|
|
773
919
|
}
|
|
774
920
|
if (this.helloWaiter) {
|
|
@@ -796,7 +942,8 @@ export class ManagerClient {
|
|
|
796
942
|
if (nextDelayMs > 0) await delay(this.clock, Math.min(nextDelayMs, deadline - this.now()));
|
|
797
943
|
if (this.intentionalClose || this.rebound) return;
|
|
798
944
|
try {
|
|
799
|
-
await this.connectFlow(
|
|
945
|
+
await this.connectFlow(RECONNECT_HELLO_TIMEOUT_MS, deadline);
|
|
946
|
+
if (this.intentionalClose || this.rebound) return;
|
|
800
947
|
this.state = "connected";
|
|
801
948
|
this.resendPending();
|
|
802
949
|
this.log("reconnected to pi-famulus");
|
|
@@ -823,7 +970,9 @@ export class ManagerClient {
|
|
|
823
970
|
private failAllPending(err: Error): void {
|
|
824
971
|
const entries = new Set([...this.pending.values(), ...this.queuedRequests]);
|
|
825
972
|
for (const entry of entries) {
|
|
826
|
-
this.clock.clearTimeout(entry.timer);
|
|
973
|
+
if (entry.timer !== undefined) this.clock.clearTimeout(entry.timer);
|
|
974
|
+
if (entry.retryTimer !== undefined) this.clock.clearInterval(entry.retryTimer);
|
|
975
|
+
entry.removeAbortListener?.();
|
|
827
976
|
entry.reject(err);
|
|
828
977
|
}
|
|
829
978
|
this.pending.clear();
|
|
@@ -831,7 +980,9 @@ export class ManagerClient {
|
|
|
831
980
|
}
|
|
832
981
|
}
|
|
833
982
|
|
|
834
|
-
class HelloError extends Error {
|
|
983
|
+
class HelloError extends Error {
|
|
984
|
+
constructor(message: string, readonly transportFailure: boolean) { super(message); }
|
|
985
|
+
}
|
|
835
986
|
|
|
836
987
|
function errorFromResponse(msg: Record<string, unknown>): ManagerError {
|
|
837
988
|
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);
|