pi-famulus 0.1.9 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/package.json +8 -8
- package/src/bounded-output.ts +17 -1
- package/src/child-mode.ts +214 -0
- package/src/comms/comms.ts +18 -13
- package/src/comms/registry-host.ts +10 -0
- package/src/comms/tools.ts +9 -2
- package/src/comms/types.ts +9 -3
- package/src/config.ts +16 -0
- package/src/format.ts +4 -0
- package/src/index.ts +391 -36
- package/src/manager-client.ts +147 -5
- package/src/notify.ts +1 -0
- package/src/pi-command.ts +123 -0
- package/src/prompts.generated.ts +8 -9
- package/src/subagent/agent-records.ts +51 -2
- package/src/subagent/hosted-session.ts +314 -0
- package/src/subagent/overrun.ts +11 -0
- package/src/subagent/pi-runtime.ts +4 -0
- package/src/subagent/registry.ts +78 -0
- package/src/subagent/runner.ts +94 -17
- package/src/subagent/tool.ts +79 -9
- package/src/subagent/types.ts +10 -1
- package/src/tui/attach-command.ts +164 -0
- package/src/work-index.ts +6 -0
package/src/manager-client.ts
CHANGED
|
@@ -18,10 +18,10 @@ import { famulusPaths } from "./config";
|
|
|
18
18
|
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
19
19
|
|
|
20
20
|
const MAX_FRAME_BYTES = 4 * 1024 * 1024; // 4 MiB (§3.3)
|
|
21
|
-
const EXTENSION_VERSION = "0.1
|
|
21
|
+
const EXTENSION_VERSION = "0.2.1";
|
|
22
22
|
export { EXTENSION_VERSION };
|
|
23
23
|
// 5: speaks per-kind agent admission and queued acquire.
|
|
24
|
-
const PROTOCOL =
|
|
24
|
+
const PROTOCOL = 6;
|
|
25
25
|
const HELLO_TIMEOUT_MS = 5000;
|
|
26
26
|
const RECONNECT_HELLO_TIMEOUT_MS = 25_000;
|
|
27
27
|
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
@@ -124,6 +124,8 @@ export interface ManagerEvent {
|
|
|
124
124
|
output_size?: number;
|
|
125
125
|
end_reason?: string;
|
|
126
126
|
ts?: number;
|
|
127
|
+
/** Set on task_started when the daemon recorded who asked for the task. */
|
|
128
|
+
origin?: { via: string; child_id?: string; run_id?: string };
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
export class ManagerError extends Error {
|
|
@@ -142,6 +144,12 @@ export interface ManagerClientOptions {
|
|
|
142
144
|
piPid?: number;
|
|
143
145
|
/** Session working directory, sent on hello (optional; older managers ignore it). */
|
|
144
146
|
cwd?: string;
|
|
147
|
+
/** `parent` for a pi session, `child` for pi-famulus inside a hosted subagent. */
|
|
148
|
+
role?: "parent" | "child";
|
|
149
|
+
childId?: string;
|
|
150
|
+
token?: string;
|
|
151
|
+
/** Pi session file this process has open. */
|
|
152
|
+
sessionFile?: string;
|
|
145
153
|
log?: (message: string) => void;
|
|
146
154
|
clock?: Clock;
|
|
147
155
|
}
|
|
@@ -288,6 +296,10 @@ export class ManagerClient {
|
|
|
288
296
|
private managerPath: string | null;
|
|
289
297
|
private readonly piPid: number;
|
|
290
298
|
private readonly cwd: string | undefined;
|
|
299
|
+
private readonly role: "parent" | "child";
|
|
300
|
+
private readonly childId: string | undefined;
|
|
301
|
+
private readonly token: string | undefined;
|
|
302
|
+
private readonly sessionFile: string | undefined;
|
|
291
303
|
private readonly log: (message: string) => void;
|
|
292
304
|
private readonly clock: Clock;
|
|
293
305
|
|
|
@@ -315,6 +327,10 @@ export class ManagerClient {
|
|
|
315
327
|
this.managerPath = options.managerPath;
|
|
316
328
|
this.piPid = options.piPid ?? process.pid;
|
|
317
329
|
this.cwd = options.cwd;
|
|
330
|
+
this.role = options.role ?? "parent";
|
|
331
|
+
this.childId = options.childId;
|
|
332
|
+
this.token = options.token;
|
|
333
|
+
this.sessionFile = options.sessionFile;
|
|
318
334
|
this.log = options.log ?? (() => {});
|
|
319
335
|
this.clock = options.clock ?? realClock;
|
|
320
336
|
}
|
|
@@ -458,20 +474,140 @@ export class ManagerClient {
|
|
|
458
474
|
childId: string,
|
|
459
475
|
workKind = "other",
|
|
460
476
|
signal?: AbortSignal,
|
|
461
|
-
): Promise<{ granted: boolean; rejection?: string }> {
|
|
477
|
+
): Promise<{ granted: boolean; rejection?: string; queuedBehind?: number }> {
|
|
462
478
|
if (signal?.aborted) throw new Error("subagent admission cancelled");
|
|
463
479
|
const message: Record<string, unknown> = { type: "acquire_agent", child_id: childId };
|
|
464
480
|
if (this.protocolLevel_ >= 5) message.work_kind = workKind;
|
|
465
481
|
// Protocol 4 has no cancel frame. Keep its immediate acquire correlated so
|
|
466
482
|
// admission can release a grant that arrives after the generation aborts.
|
|
467
483
|
const res = await this.request(message, DEFAULT_REQUEST_TIMEOUT_MS, this.protocolLevel_ >= 5 ? signal : undefined, true);
|
|
468
|
-
return {
|
|
484
|
+
return {
|
|
485
|
+
granted: res.granted === true,
|
|
486
|
+
...(typeof res.rejection === "string" ? { rejection: res.rejection } : {}),
|
|
487
|
+
...(typeof res.queued_behind === "number" ? { queuedBehind: res.queued_behind } : {}),
|
|
488
|
+
};
|
|
469
489
|
}
|
|
470
490
|
|
|
471
491
|
async releaseAgent(childId: string): Promise<void> {
|
|
472
492
|
await this.request({ type: "release_agent", child_id: childId });
|
|
473
493
|
}
|
|
474
494
|
|
|
495
|
+
requireHostedProtocol(): void {
|
|
496
|
+
if (this.protocolLevel_ < 6) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
"this pi-famulus daemon does not host subagents (protocol " +
|
|
499
|
+
`${this.protocolLevel_}; need 6). Restart pi so it starts the current daemon.`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async agentSpawn(input: {
|
|
505
|
+
childId: string;
|
|
506
|
+
runId: string;
|
|
507
|
+
name: string;
|
|
508
|
+
argv: string[];
|
|
509
|
+
cwd: string;
|
|
510
|
+
env: Record<string, string>;
|
|
511
|
+
}): Promise<Record<string, unknown>> {
|
|
512
|
+
this.requireHostedProtocol();
|
|
513
|
+
return this.request({
|
|
514
|
+
type: "agent_spawn",
|
|
515
|
+
child_id: input.childId,
|
|
516
|
+
run_id: input.runId,
|
|
517
|
+
name: input.name,
|
|
518
|
+
argv: input.argv,
|
|
519
|
+
cwd: input.cwd,
|
|
520
|
+
env: input.env,
|
|
521
|
+
}, 20_000);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async agentInput(childId: string, op: string, text: string, source = "parent", since?: string): Promise<Record<string, unknown>> {
|
|
525
|
+
this.requireHostedProtocol();
|
|
526
|
+
return this.request({
|
|
527
|
+
type: "agent_input",
|
|
528
|
+
child_id: childId,
|
|
529
|
+
op,
|
|
530
|
+
text,
|
|
531
|
+
source,
|
|
532
|
+
...(since ? { since } : {}),
|
|
533
|
+
}, 20_000);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async agentWatch(childId: string): Promise<void> {
|
|
537
|
+
this.requireHostedProtocol();
|
|
538
|
+
await this.request({ type: "agent_watch", child_id: childId });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async agentUnwatch(childId: string): Promise<void> {
|
|
542
|
+
this.requireHostedProtocol();
|
|
543
|
+
await this.request({ type: "agent_unwatch", child_id: childId });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async agentPeers(): Promise<Array<{ childId: string; name: string; runId: string; activity: string; residency: string }>> {
|
|
547
|
+
this.requireHostedProtocol();
|
|
548
|
+
const res = await this.request({ type: "agent_peers" });
|
|
549
|
+
const peers = Array.isArray(res.peers) ? res.peers : [];
|
|
550
|
+
return peers.flatMap((peer) => {
|
|
551
|
+
if (!peer || typeof peer !== "object") return [];
|
|
552
|
+
const row = peer as Record<string, unknown>;
|
|
553
|
+
if (typeof row.child_id !== "string") return [];
|
|
554
|
+
return [{
|
|
555
|
+
childId: row.child_id,
|
|
556
|
+
name: typeof row.name === "string" ? row.name : row.child_id,
|
|
557
|
+
runId: typeof row.run_id === "string" ? row.run_id : "",
|
|
558
|
+
activity: typeof row.activity === "string" ? row.activity : "idle",
|
|
559
|
+
residency: typeof row.residency === "string" ? row.residency : "",
|
|
560
|
+
}];
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async agentPark(childId: string): Promise<void> {
|
|
565
|
+
this.requireHostedProtocol();
|
|
566
|
+
await this.request({ type: "agent_park", child_id: childId });
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async agentTakeover(childId: string): Promise<void> {
|
|
570
|
+
this.requireHostedProtocol();
|
|
571
|
+
await this.request({ type: "agent_takeover", child_id: childId });
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async hostedLookup(query: { sessionId?: string; sessionFile?: string }): Promise<Record<string, unknown> | null> {
|
|
575
|
+
if (this.protocolLevel_ < 6) return null;
|
|
576
|
+
const res = await this.request({
|
|
577
|
+
type: "hosted_lookup",
|
|
578
|
+
...(query.sessionId ? { session_id: query.sessionId } : {}),
|
|
579
|
+
...(query.sessionFile ? { session_file: query.sessionFile } : {}),
|
|
580
|
+
});
|
|
581
|
+
const hosted = res.hosted;
|
|
582
|
+
return hosted && typeof hosted === "object" ? hosted as Record<string, unknown> : null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async agentComms(requestId: string, reason: string, message: string): Promise<string> {
|
|
586
|
+
this.requireHostedProtocol();
|
|
587
|
+
const res = await this.request({ type: "agent_comms", request_id: requestId, reason, message }, 11 * 60 * 1000);
|
|
588
|
+
return typeof res.reply === "string" ? res.reply : "";
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async agentCommsReply(requestId: string, childId: string, message: string): Promise<void> {
|
|
592
|
+
this.requireHostedProtocol();
|
|
593
|
+
await this.request({ type: "agent_comms_reply", request_id: requestId, child_id: childId, message });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async agentUiResponse(childId: string, rpcId: string, value: Record<string, unknown>, cancelled: boolean): Promise<void> {
|
|
597
|
+
this.requireHostedProtocol();
|
|
598
|
+
await this.request({ type: "agent_ui_response", child_id: childId, rpc_id: rpcId, value, cancelled });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async agentCache(expiresAt: number | null, tier: string | null, lifetimeMs: number | null): Promise<void> {
|
|
602
|
+
if (this.protocolLevel_ < 6) return;
|
|
603
|
+
await this.request({
|
|
604
|
+
type: "agent_cache",
|
|
605
|
+
...(expiresAt !== null ? { expires_at: expiresAt } : {}),
|
|
606
|
+
...(tier ? { tier } : {}),
|
|
607
|
+
...(lifetimeMs !== null ? { lifetime_ms: lifetimeMs } : {}),
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
475
611
|
/** Daemon status, including optional in-place upgrade metadata. */
|
|
476
612
|
async status(): Promise<ManagerStatusResponse> {
|
|
477
613
|
const res = await this.request({ type: "status" });
|
|
@@ -501,6 +637,8 @@ export class ManagerClient {
|
|
|
501
637
|
async close(): Promise<void> {
|
|
502
638
|
this.intentionalClose = true;
|
|
503
639
|
this.state = "disconnected";
|
|
640
|
+
this.eventHandlers.clear();
|
|
641
|
+
this.reconnectHandlers.clear();
|
|
504
642
|
this.failAllPending(new Error("manager client closed"));
|
|
505
643
|
const socket = this.socket;
|
|
506
644
|
this.socket = null;
|
|
@@ -774,6 +912,10 @@ export class ManagerClient {
|
|
|
774
912
|
...(this.cwd ? { cwd: this.cwd } : {}),
|
|
775
913
|
extension_version: EXTENSION_VERSION,
|
|
776
914
|
protocol: PROTOCOL,
|
|
915
|
+
role: this.role,
|
|
916
|
+
...(this.sessionFile ? { session_file: this.sessionFile } : {}),
|
|
917
|
+
...(this.childId ? { child_id: this.childId } : {}),
|
|
918
|
+
...(this.token ? { token: this.token } : {}),
|
|
777
919
|
}),
|
|
778
920
|
);
|
|
779
921
|
});
|
|
@@ -865,7 +1007,7 @@ export class ManagerClient {
|
|
|
865
1007
|
waitForAdmission = false,
|
|
866
1008
|
): Promise<Record<string, unknown>> {
|
|
867
1009
|
const type = String(msg.type);
|
|
868
|
-
const retryable = ["wait", "output", "list", "watch", "status", "stop", "mark_background", "start", "acquire_agent", "cancel_acquire_agent", "release_agent"].includes(type);
|
|
1010
|
+
const retryable = ["wait", "output", "list", "watch", "status", "stop", "mark_background", "start", "acquire_agent", "cancel_acquire_agent", "release_agent", "agent_spawn", "agent_watch", "agent_park", "agent_peers", "hosted_lookup"].includes(type);
|
|
869
1011
|
const effectiveTimeoutMs = retryable ? Math.max(timeoutMs, RECONNECT_WINDOW_MS + 1000) : timeoutMs;
|
|
870
1012
|
return new Promise((resolve, reject) => {
|
|
871
1013
|
if (signal?.aborted) {
|
package/src/notify.ts
CHANGED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the pi command a hosted subagent should be. The daemon never searches
|
|
3
|
+
* PATH; the extension resolves it where pi is already running.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { basename } from "node:path";
|
|
8
|
+
export type PiCommand = { argv: string[] } | { error: string };
|
|
9
|
+
|
|
10
|
+
export function resolvePiArgv(configured?: string | null, argv: readonly string[] = process.argv): PiCommand {
|
|
11
|
+
const raw = (configured && configured.trim()) || process.env.PI_FAMULUS_PI?.trim() || "";
|
|
12
|
+
if (raw) {
|
|
13
|
+
if (raw.startsWith("[")) {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
16
|
+
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((part) => typeof part === "string" && part.length > 0)) {
|
|
17
|
+
return { argv: parsed as string[] };
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
return { error: `PI_FAMULUS_PI / subagent.piCommand is not a JSON argv array: ${raw}` };
|
|
21
|
+
}
|
|
22
|
+
return { error: `PI_FAMULUS_PI / subagent.piCommand is not a JSON argv array: ${raw}` };
|
|
23
|
+
}
|
|
24
|
+
return { argv: raw.includes(" ") ? splitCommand(raw) : [raw] };
|
|
25
|
+
}
|
|
26
|
+
const fromProcess = runningPiArgv(argv);
|
|
27
|
+
if (fromProcess) return { argv: fromProcess };
|
|
28
|
+
const exec = process.execPath;
|
|
29
|
+
try {
|
|
30
|
+
const req = createRequire(import.meta.url);
|
|
31
|
+
const pkg = req.resolve("@earendil-works/pi-coding-agent/package.json");
|
|
32
|
+
const cli = pkg.replace(/package\.json$/, "dist/cli.js");
|
|
33
|
+
if (existsSync(cli)) return { argv: [exec, cli] };
|
|
34
|
+
} catch {
|
|
35
|
+
// The host did not load pi as a resolvable package.
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
error:
|
|
39
|
+
"cannot find the pi executable that is running this session. Set subagent.piCommand in config.json or PI_FAMULUS_PI to the pi command.",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const PARENT_FLAGS = new Set([
|
|
44
|
+
"-ne",
|
|
45
|
+
"--no-extensions",
|
|
46
|
+
"-ns",
|
|
47
|
+
"--no-skills",
|
|
48
|
+
"-np",
|
|
49
|
+
"--no-prompt-templates",
|
|
50
|
+
"-nc",
|
|
51
|
+
"--no-context-files",
|
|
52
|
+
"--offline",
|
|
53
|
+
"--no-mcp",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Flags from the pi process that is spawning the subagent. The child is a new
|
|
58
|
+
* process, so it only sees providers and tools that its own command line loads.
|
|
59
|
+
* Eval and SDK hosts pass those as `-e` on argv (`-ne` plus an explicit faux
|
|
60
|
+
* provider extension); forwarding them is what lets the child use the same model.
|
|
61
|
+
*/
|
|
62
|
+
export function parentResourceArgs(argv: readonly string[] = process.argv.slice(2)): string[] {
|
|
63
|
+
const out: string[] = [];
|
|
64
|
+
for (let i = 0; i < argv.length; i++) {
|
|
65
|
+
const arg = argv[i];
|
|
66
|
+
if (arg === "-e" || arg === "--extension") {
|
|
67
|
+
const value = argv[i + 1];
|
|
68
|
+
if (value && !value.startsWith("-")) {
|
|
69
|
+
out.push("-e", value);
|
|
70
|
+
i++;
|
|
71
|
+
}
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (PARENT_FLAGS.has(arg)) out.push(arg);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The pi process that loaded this extension. `node_modules/.bin/pi` is a
|
|
81
|
+
* symlink (eval and `npm exec`); its path does not contain `pi-coding-agent`,
|
|
82
|
+
* so matching only that substring misses the process that is actually running.
|
|
83
|
+
*/
|
|
84
|
+
function runningPiArgv(argv: readonly string[]): string[] | null {
|
|
85
|
+
const entry = argv[1] ?? "";
|
|
86
|
+
if (!entry || !existsSync(entry)) return null;
|
|
87
|
+
let real = entry;
|
|
88
|
+
try {
|
|
89
|
+
real = realpathSync(entry);
|
|
90
|
+
} catch {
|
|
91
|
+
// A broken symlink still has a path; keep the original.
|
|
92
|
+
}
|
|
93
|
+
const base = basename(entry).toLowerCase();
|
|
94
|
+
const namedPi = base === "pi" || base === "pi.cmd" || base === "pi.exe" || entry.includes("pi-coding-agent") || real.includes("pi-coding-agent");
|
|
95
|
+
if (!namedPi) return null;
|
|
96
|
+
if (/\.[cm]?js$/i.test(real)) return [process.execPath, real];
|
|
97
|
+
return [entry];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function splitCommand(raw: string): string[] {
|
|
101
|
+
const parts: string[] = [];
|
|
102
|
+
let current = "";
|
|
103
|
+
let quote: '"' | "'" | null = null;
|
|
104
|
+
for (const ch of raw) {
|
|
105
|
+
if (quote) {
|
|
106
|
+
if (ch === quote) quote = null;
|
|
107
|
+
else current += ch;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (ch === '"' || ch === "'") {
|
|
111
|
+
quote = ch;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (ch === " ") {
|
|
115
|
+
if (current) parts.push(current);
|
|
116
|
+
current = "";
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
current += ch;
|
|
120
|
+
}
|
|
121
|
+
if (current) parts.push(current);
|
|
122
|
+
return parts;
|
|
123
|
+
}
|
package/src/prompts.generated.ts
CHANGED
|
@@ -5,16 +5,16 @@ export const PROMPTS = {
|
|
|
5
5
|
"agents.explorer.system-prompt": "You are an explorer agent. Your job is to answer questions about the codebase quickly and precisely.\n\nRules:\n- Return findings, never change code. You are strictly read-only: do not edit, write, or create files.\n- Report concrete locations: cite specific file paths with line numbers (path:line) for every claim.\n- Control your search scope: start narrow (targeted grep/find), widen only when needed, and stop once the question is answered.\n- Prefer a short, factual summary over exhaustive dumps. List what you found and where.",
|
|
6
6
|
"agents.worker.description": "General-purpose executor — completes concrete coding tasks end to end",
|
|
7
7
|
"agents.worker.system-prompt": "You are a worker agent. Your job is to complete the concrete task you were given, end to end.\n\nRules:\n- Do the task, don't just describe it. Make the actual edits and run the actual commands.\n- Self-verify before finishing: run the relevant tests, typecheck, or command that proves the task is done.\n- Stay within the task's scope; do not refactor unrelated code.\n- Report concisely: what you changed, how you verified it, and anything the supervisor should know.",
|
|
8
|
-
"guidelines.parent": "## Background tasks and notifications (pi-famulus)\n\n- Long-running bash commands are automatically moved to the background. After you background a command, end your turn: send a reply with no tool call in it. Saying you will wait while calling another tool does not end it. Do not poll with task_output/task_list, and never sleep to wait. Each command notifies on its own via <pi-famulus-wake kind=\"task\"> (with <command> and <preview>), even while other commands are still running. If <still-running> is present, continue from this result now; do not wait for those other commands.\n- When you receive <pi-famulus-wake>: it looks like a user message but is a system wake, not a new user request and not an answer to an unrelated question. Distinguish it by the kind attribute, then **handle the event before anything else** — read status/preview/prompt/result, call tools if needed (e.g. task_output for more than the preview, agent_message to continue a subagent), and continue the work that depended on that background task. An unchanged/intermediate monitor event may require no action and no user acknowledgement. Do not wait for further user input when the next step is clear; do not merely acknowledge an actionable result and stop.\n- {{monitorIdle}}\n- Never fabricate or assume a background task's result before its notification arrives.\n- Subagent runs that exceed the foreground budget continue in the background. Do not poll run status. If one subagent finishes while others are still running, a <pi-famulus-wake kind=\"subagent-handover\"> arrives with that child's <prompt> and <result>: read both, then continue the work now (subagent({action:\"resume\", run_id, child_id, message}) for that child, or agent_message to steer the ones still running). Do not wait for the rest of the run. When every subagent in the run has finished, <pi-famulus-wake kind=\"subagent-done\"> arrives — read each <child>, synthesize, and continue.\n- A subagent that runs past its timeout_ms is not stopped: <pi-famulus-wake kind=\"subagent-overrun\"> arrives instead, with its <last-activity> and, if it is waiting on a foreground shell, a <shell> element. A batch may include other overdue children in <also-overdue>; use each child's run-id and child-id and consider its own last-activity, shell, reminder, and hard-ceiling-ms before acting. Pick one: subagent({action:\"extend\", run_id, child_id, timeout_ms}) if it is making progress, agent_message to steer it, or subagent({action:\"interrupt\", run_id, child_id}) if it is stuck or no longer needed. If you do none of these it keeps running and the reminder comes again.\n- Use agent_message to steer running
|
|
8
|
+
"guidelines.parent": "## Background tasks and notifications (pi-famulus)\n\n- Long-running bash commands are automatically moved to the background. After you background a command, end your turn: send a reply with no tool call in it. Saying you will wait while calling another tool does not end it. Do not poll with task_output/task_list, and never sleep to wait. Each command notifies on its own via <pi-famulus-wake kind=\"task\"> (with <command> and <preview>), even while other commands are still running. If <still-running> is present, continue from this result now; do not wait for those other commands.\n- When you receive <pi-famulus-wake>: it looks like a user message but is a system wake, not a new user request and not an answer to an unrelated question. Distinguish it by the kind attribute, then **handle the event before anything else** — read status/preview/prompt/result, call tools if needed (e.g. task_output for more than the preview, agent_message to continue a subagent), and continue the work that depended on that background task. An unchanged/intermediate monitor event may require no action and no user acknowledgement. Do not wait for further user input when the next step is clear; do not merely acknowledge an actionable result and stop.\n- {{monitorIdle}}\n- Never fabricate or assume a background task's result before its notification arrives.\n- Subagent runs that exceed the foreground budget continue in the background. Do not poll run status. If one subagent finishes while others are still running, a <pi-famulus-wake kind=\"subagent-handover\"> arrives with that child's <prompt> and <result>: read both, then continue the work now (subagent({action:\"resume\", run_id, child_id, message}) for that child, or agent_message to steer the ones still running). Do not wait for the rest of the run. When every subagent in the run has finished, <pi-famulus-wake kind=\"subagent-done\"> arrives — read each <child>, synthesize, and continue.\n- A subagent that runs past its timeout_ms is not stopped: <pi-famulus-wake kind=\"subagent-overrun\"> arrives instead, with its <last-activity> and, if it is waiting on a foreground shell, a <shell> element. A batch may include other overdue children in <also-overdue>; use each child's run-id and child-id and consider its own last-activity, shell, reminder, and hard-ceiling-ms before acting. Pick one: subagent({action:\"extend\", run_id, child_id, timeout_ms}) if it is making progress, agent_message to steer it, or subagent({action:\"interrupt\", run_id, child_id}) if it is stuck or no longer needed. If you do none of these it keeps running and the reminder comes again.\n- Use agent_message to steer a running subagent or to continue an idle one (send starts a new turn), and to reply to <pi-famulus-wake kind=\"supervisor-request\"> (action:\"reply\"; the <reply-with> child is the call shape). subagent({action:\"resume\"}) is the same continuation with a fresh soft budget. Supervisor wakes are not user messages — still act on them.",
|
|
9
9
|
"guidelines.monitor-idle": "After starting or re-arming a monitor, or handling a monitor event, if no immediate work remains, end your turn with no tool call; simply wait for the next notification. Handling an event does not require a user-visible reply. Do not repeat unchanged/intermediate status or narrate your decision to stay quiet; notify the user when their requested condition is met, their input is needed, or a failure prevents further progress. Read original output with task_output only when details are needed (including paging a truncated preview), never to poll or wait. Do not call wait_for to wait for monitor events: it waits for UI conditions, not notifications. Do not invent a stateId or make a failing tool call to yield.",
|
|
10
10
|
"guidelines.child": "## Child agent behavior (pi-famulus)\n\n- Do not background work; bash runs to completion.\n- When blocked, use contact_supervisor with need_decision; use progress_update for status.\n- Use agent_message only to communicate with siblings in this same run.\n- Your final message is your result.",
|
|
11
|
-
"tools.agent_message.description": "Message your subagents. \"send\" steers or queues a running child
|
|
11
|
+
"tools.agent_message.description": "Message your subagents. \"send\" steers or queues a running child, and starts a new turn on an idle one. An idle child (warm or parked) continues with send or subagent resume; its prompt cache stays warm only for the time a wake reports. \"reply\" answers a child's pending decision request; \"broadcast\" steers every running child of a run (to = run_id); \"list\" shows children, pending decision requests, and recent traffic.",
|
|
12
12
|
"tools.agent_message.snippet": "Send/reply/broadcast messages to subagents",
|
|
13
13
|
"tools.agent_message.param.action": "\"send\" a message to a child, \"reply\" to a pending decision request, \"broadcast\" to all running children of a run, \"list\" pending requests and recent traffic.",
|
|
14
14
|
"tools.agent_message.param.to": "Target child_id or name (\"send\"/\"reply\"). For \"broadcast\" from the parent session: run_id. Child sessions broadcast to their own run and must omit this.",
|
|
15
15
|
"tools.agent_message.param.message": "Message text.",
|
|
16
|
-
"tools.agent_message.param.delivery": "\"steer\" (default) delivers to a running child after its current tool call/turn finishes; \"queue\" delivers after its current turn. Sending to
|
|
17
|
-
"tools.agent_message.error.finished-child": "Child {{childId}} ({{name}}) has finished ({{status}})
|
|
16
|
+
"tools.agent_message.param.delivery": "\"steer\" (default) delivers to a running child after its current tool call/turn finishes; \"queue\" delivers after its current turn. Sending to an idle child starts a new turn.",
|
|
17
|
+
"tools.agent_message.error.finished-child": "Child {{childId}} ({{name}}) has finished ({{status}}), and this host cannot start another turn. Use subagent({ action: \"resume\", run_id: \"{{runId}}\", child_id: \"{{childId}}\", message: \"...\" }).",
|
|
18
18
|
"tools.bash.description": "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {{maxLines}} lines or {{maxKb}}KB (whichever is hit first). Foreground commands that exceed the foreground budget are automatically moved to the background; you will be notified when they complete. Optionally provide a timeout in seconds (hard kill limit), or run_in_background to background immediately.",
|
|
19
19
|
"tools.bash.snippet": "Execute bash commands (ls, grep, find, etc.)",
|
|
20
20
|
"tools.bash.rules": ["You can inspect PI_* environment variables for current model and session details.","Long-running bash commands are moved to the background automatically; do not poll or sleep to wait for them. End your turn (a reply with no tool call) and resume from the task wake when it arrives."],
|
|
@@ -23,7 +23,7 @@ export const PROMPTS = {
|
|
|
23
23
|
"tools.bash.param.run_in_background": "Start the command in the background and return immediately. You will be notified when it completes.",
|
|
24
24
|
"tools.bash.result.backgrounded": "Command \"{{command}}\" moved to background (task_id: {{taskId}}). Output: {{outputPath}}.\nYou will be notified when it completes, even if other commands are still running. Do not poll or sleep: reply to the user now with no tool call, and continue from the <pi-famulus-wake kind=\"task\"> when it arrives.",
|
|
25
25
|
"tools.bash.error.bare-sleep": "Refusing to run a bare sleep/idle-loop command: {{command}}. Do not sleep to wait for background work; completion is delivered via notification. Use the monitor tool to watch for a condition, or run_in_background for long commands.",
|
|
26
|
-
"tools.child.agent_message.description": "Message sibling subagents in your run, or list pending requests. \"send\" delivers to a running sibling
|
|
26
|
+
"tools.child.agent_message.description": "Message sibling subagents in your run, or list pending requests. \"send\" delivers to a running sibling and starts a new turn on an idle one. \"broadcast\" steers every running sibling in your run; \"list\" shows pending decision requests and recent traffic. Cross-run messaging is rejected.",
|
|
27
27
|
"tools.child.agent_message.snippet": "Message sibling subagents in your run",
|
|
28
28
|
"tools.child.bash.description": "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {{maxLines}} lines or {{maxKb}}KB (whichever is hit first). The command runs to completion (or the optional timeout in seconds, after which it is killed). There is no background execution inside subagents.",
|
|
29
29
|
"tools.child.bash.snippet": "Execute bash commands (ls, grep, find, etc.)",
|
|
@@ -49,7 +49,7 @@ export const PROMPTS = {
|
|
|
49
49
|
"tools.subagent.rules": ["When a <pi-famulus-wake kind=\"subagent-handover\"> arrives, read <prompt> and <result> immediately and continue: subagent({action:\"resume\", run_id, child_id, message}) for that child, or agent_message to steer children that are still running. Do not wait for the rest of the run.","Subagent runs that exceed the foreground budget continue in the background; you are notified per finished child and again when the run completes — do not poll.","A failed subagent does not fail the whole run; inspect per-subagent sections in the result.","<pi-famulus-wake> is a system wake, not a user reply. kind=subagent-handover is one child; kind=subagent-done is the whole run."],
|
|
50
50
|
"tools.subagent.param.tasks": "Start new subagents in parallel. Use only when action is omitted. For any action call, omit tasks entirely; do not supply placeholder tasks.",
|
|
51
51
|
"tools.subagent.param.tasks.agent": "Agent definition name (default: worker)",
|
|
52
|
-
"tools.subagent.param.tasks.prompt": "Task prompt for this subagent",
|
|
52
|
+
"tools.subagent.param.tasks.prompt": "Task prompt for this subagent. The child is a new process: it cannot see this conversation, so the prompt must be self-contained.",
|
|
53
53
|
"tools.subagent.param.tasks.name": "Display name (default: agent name + ordinal)",
|
|
54
54
|
"tools.subagent.param.work_kind": "Explicit planned workload class for machine-wide admission (default: other)",
|
|
55
55
|
"tools.subagent.param.chain": "Start a new sequential chain (always awaited). Use only when action is omitted. For any action call, omit chain entirely.",
|
|
@@ -158,11 +158,10 @@ export const PROMPT_SEGMENTS: Record<string, { prompt: PromptId; text?: string;
|
|
|
158
158
|
"guidelines.no-fabrication": {"prompt":"guidelines.parent","text":"Never fabricate or assume a background task's result before its notification arrives."},
|
|
159
159
|
"guidelines.subagent-handover": {"prompt":"guidelines.parent","text":"Subagent runs that exceed the foreground budget continue in the background. Do not poll run status. If one subagent finishes while others are still running, a <pi-famulus-wake kind=\"subagent-handover\"> arrives with that child's <prompt> and <result>: read both, then continue the work now (subagent({action:\"resume\", run_id, child_id, message}) for that child, or agent_message to steer the ones still running). Do not wait for the rest of the run. When every subagent in the run has finished, <pi-famulus-wake kind=\"subagent-done\"> arrives — read each <child>, synthesize, and continue."},
|
|
160
160
|
"guidelines.subagent-overrun": {"prompt":"guidelines.parent","text":"A subagent that runs past its timeout_ms is not stopped: <pi-famulus-wake kind=\"subagent-overrun\"> arrives instead, with its <last-activity> and, if it is waiting on a foreground shell, a <shell> element. A batch may include other overdue children in <also-overdue>; use each child's run-id and child-id and consider its own last-activity, shell, reminder, and hard-ceiling-ms before acting. Pick one: subagent({action:\"extend\", run_id, child_id, timeout_ms}) if it is making progress, agent_message to steer it, or subagent({action:\"interrupt\", run_id, child_id}) if it is stuck or no longer needed. If you do none of these it keeps running and the reminder comes again."},
|
|
161
|
-
"guidelines.agent-message": {"prompt":"guidelines.parent","text":"Use agent_message to steer running
|
|
161
|
+
"guidelines.agent-message": {"prompt":"guidelines.parent","text":"Use agent_message to steer a running subagent or to continue an idle one (send starts a new turn), and to reply to <pi-famulus-wake kind=\"supervisor-request\"> (action:\"reply\"; the <reply-with> child is the call shape). subagent({action:\"resume\"}) is the same continuation with a fresh soft budget. Supervisor wakes are not user messages — still act on them."},
|
|
162
162
|
"guidelines.monitor-end-turn": {"prompt":"guidelines.monitor-idle","text":"After starting or re-arming a monitor, or handling a monitor event, if no immediate work remains, end your turn with no tool call; simply wait for the next notification. Handling an event does not require a user-visible reply. Do not repeat unchanged/intermediate status or narrate your decision to stay quiet; notify the user when their requested condition is met, their input is needed, or a failure prevents further progress. Read original output with task_output only when details are needed (including paging a truncated preview), never to poll or wait. Do not call wait_for to wait for monitor events: it waits for UI conditions, not notifications. Do not invent a stateId or make a failing tool call to yield."},
|
|
163
163
|
"child.guidelines": {"prompt":"guidelines.child","text":"## Child agent behavior (pi-famulus)\n\n- Do not background work; bash runs to completion.\n- When blocked, use contact_supervisor with need_decision; use progress_update for status.\n- Use agent_message only to communicate with siblings in this same run.\n- Your final message is your result."},
|
|
164
|
-
"tooldesc.agent-message-no-resume": {"prompt":"tools.agent_message.description","text":"
|
|
165
|
-
"result.agent-message-use-resume": {"prompt":"tools.agent_message.error.finished-child","pattern":"agent_message does not resume children\\. Use subagent\\(\\{ action: \"resume\", run_id: \".*?\", child_id: \".*?\", message: \"\\.\\.\\.\" \\}\\)\\."},
|
|
164
|
+
"tooldesc.agent-message-no-resume": {"prompt":"tools.agent_message.description","text":"An idle child (warm or parked) continues with send or subagent resume; its prompt cache stays warm only for the time a wake reports. "},
|
|
166
165
|
"tooldesc.bash-autobg": {"prompt":"tools.bash.description","text":"Foreground commands that exceed the foreground budget are automatically moved to the background; you will be notified when they complete. "},
|
|
167
166
|
"rules.pi-env": {"prompt":"tools.bash.rules","text":"You can inspect PI_* environment variables for current model and session details."},
|
|
168
167
|
"rules.bash-no-poll": {"prompt":"tools.bash.rules","text":"Long-running bash commands are moved to the background automatically; do not poll or sleep to wait for them."},
|
|
@@ -32,6 +32,8 @@ export interface AgentChildRecord {
|
|
|
32
32
|
error?: string;
|
|
33
33
|
/** Why the child ended (terminal statuses only). */
|
|
34
34
|
end_reason?: AgentEndReason;
|
|
35
|
+
/** Seconds until a rate-limited provider may be retried, when known. */
|
|
36
|
+
retry_after?: number;
|
|
35
37
|
/** Task prompt without the agent preamble, capped. */
|
|
36
38
|
prompt_head?: string;
|
|
37
39
|
/** Tail of the final result text, capped. */
|
|
@@ -58,6 +60,7 @@ export type AgentEndReason =
|
|
|
58
60
|
| "completed"
|
|
59
61
|
| "failed"
|
|
60
62
|
| "model-error"
|
|
63
|
+
| "rate-limit"
|
|
61
64
|
| "stalled"
|
|
62
65
|
| "timeout"
|
|
63
66
|
| "interrupted"
|
|
@@ -66,10 +69,11 @@ export type AgentEndReason =
|
|
|
66
69
|
/** Map a terminal child status + result to the end_reason recorded on disk. */
|
|
67
70
|
export function agentEndReason(
|
|
68
71
|
status: AgentChildRecord["status"],
|
|
69
|
-
result: { error?: string; endReason?: "model-error" } | undefined,
|
|
72
|
+
result: { error?: string; endReason?: "model-error" | "rate-limit" } | undefined,
|
|
70
73
|
): AgentEndReason | undefined {
|
|
71
74
|
if (status === "completed") return "completed";
|
|
72
75
|
if (status === "failed") {
|
|
76
|
+
if (result?.endReason === "rate-limit") return "rate-limit";
|
|
73
77
|
if (result?.endReason === "model-error") return "model-error";
|
|
74
78
|
return result?.error === "stalled" ? "stalled" : "failed";
|
|
75
79
|
}
|
|
@@ -174,6 +178,42 @@ function parseRecord(raw: string): AgentChildRecord | null {
|
|
|
174
178
|
}
|
|
175
179
|
|
|
176
180
|
/** Load agent child records from disk (all sessions under home). */
|
|
181
|
+
/** Hosted children the daemon still has warm. They outlive a gone parent session. */
|
|
182
|
+
export function warmHostedChildIds(home: string): Set<string> {
|
|
183
|
+
const out = new Set<string>();
|
|
184
|
+
try {
|
|
185
|
+
const { pid } = JSON.parse(readFileSync(join(home, "manager.pid"), "utf8")) as { pid?: unknown };
|
|
186
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return out;
|
|
187
|
+
try {
|
|
188
|
+
process.kill(pid, 0);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if ((error as NodeJS.ErrnoException).code !== "EPERM") return out;
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
let files: string[];
|
|
196
|
+
try {
|
|
197
|
+
files = readdirSync(join(home, "agents")).filter((name) => name.endsWith(".json"));
|
|
198
|
+
} catch {
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
for (const file of files) {
|
|
202
|
+
try {
|
|
203
|
+
const value = JSON.parse(readFileSync(join(home, "agents", file), "utf8")) as {
|
|
204
|
+
residency?: string;
|
|
205
|
+
child_id?: string;
|
|
206
|
+
};
|
|
207
|
+
if (value.residency === "warm" && typeof value.child_id === "string") {
|
|
208
|
+
out.add(value.child_id);
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
// skip bad hosted records
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
177
217
|
export function loadAgentChildRecords(
|
|
178
218
|
home: string,
|
|
179
219
|
opts: { sessionId?: string; includeTerminal?: boolean; connectedSessionIds?: ReadonlySet<string> } = {},
|
|
@@ -189,6 +229,7 @@ export function loadAgentChildRecords(
|
|
|
189
229
|
} catch {
|
|
190
230
|
return [];
|
|
191
231
|
}
|
|
232
|
+
const warm = opts.connectedSessionIds === undefined ? new Set<string>() : warmHostedChildIds(home);
|
|
192
233
|
const out: AgentChildRecord[] = [];
|
|
193
234
|
for (const sid of sessionIds) {
|
|
194
235
|
const dir = agentRecordsDir(home, sid);
|
|
@@ -205,7 +246,9 @@ export function loadAgentChildRecords(
|
|
|
205
246
|
const disconnected =
|
|
206
247
|
opts.connectedSessionIds !== undefined && !opts.connectedSessionIds.has(rec.session_id);
|
|
207
248
|
const shown: AgentChildRecord =
|
|
208
|
-
disconnected &&
|
|
249
|
+
disconnected &&
|
|
250
|
+
(rec.status === "pending" || rec.status === "running") &&
|
|
251
|
+
!warm.has(rec.child_id)
|
|
209
252
|
? { ...rec, status: "interrupted" }
|
|
210
253
|
: rec;
|
|
211
254
|
const terminal =
|
|
@@ -220,6 +263,12 @@ export function loadAgentChildRecords(
|
|
|
220
263
|
return out;
|
|
221
264
|
}
|
|
222
265
|
|
|
266
|
+
/** One child by id or name, across every session under home. */
|
|
267
|
+
export function findAgentChildRecord(home: string, childIdOrName: string): AgentChildRecord | undefined {
|
|
268
|
+
const recs = loadAgentChildRecords(home, { includeTerminal: true });
|
|
269
|
+
return recs.find((rec) => rec.child_id === childIdOrName) ?? recs.find((rec) => rec.name === childIdOrName);
|
|
270
|
+
}
|
|
271
|
+
|
|
223
272
|
export function isAgentStatusActive(status: AgentChildRecord["status"]): boolean {
|
|
224
273
|
return status === "pending" || status === "running";
|
|
225
274
|
}
|