pi-famulus 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/package.json +7 -7
- 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/index.ts +128 -30
- package/src/manager-client.ts +145 -5
- package/src/pi-command.ts +123 -0
- package/src/prompts.generated.ts +7 -8
- package/src/subagent/hosted-session.ts +268 -0
- package/src/subagent/overrun.ts +11 -0
- package/src/subagent/pi-runtime.ts +4 -0
- package/src/subagent/runner.ts +19 -9
- package/src/subagent/tool.ts +70 -9
- package/src/subagent/types.ts +2 -0
- package/src/tui/attach-command.ts +164 -0
package/src/index.ts
CHANGED
|
@@ -21,9 +21,9 @@ import { createAgentLoader, type AgentLoader } from "./agents/loader";
|
|
|
21
21
|
import { createBashOverride } from "./bash-override";
|
|
22
22
|
import { createComms, type CommsWithOrigin } from "./comms/comms";
|
|
23
23
|
import { createRegistryCommsHost } from "./comms/registry-host";
|
|
24
|
-
import { createAgentMessageTool
|
|
24
|
+
import { createAgentMessageTool } from "./comms/tools";
|
|
25
25
|
import { registerReplyCommand } from "./comms/reply-command";
|
|
26
|
-
import { describeManagerSearch, getFamulusHome, loadConfig, resolveManagerPath, resolveSubagentConfig } from "./config";
|
|
26
|
+
import { describeManagerSearch, getFamulusHome, loadConfig, resolveManagerPath, resolveSubagentConfig, taskOutputPath } from "./config";
|
|
27
27
|
import { ensureNativeManagerInstalled } from "./native-manager.js";
|
|
28
28
|
import type { TaskExitInfo } from "./format";
|
|
29
29
|
import { EXTENSION_VERSION, ManagerClient, type ManagerEvent, type TaskRecord } from "./manager-client";
|
|
@@ -31,7 +31,6 @@ import { createMonitorTool, exitEventFromRecord, MonitorRegistry } from "./monit
|
|
|
31
31
|
import { admitAgentChild, reregisterAgentLeases } from "./subagent/admission";
|
|
32
32
|
import { NotifyCenter } from "./notify";
|
|
33
33
|
import { onWakeMessageEnd, registryStatusLookup } from "./wake-delivery";
|
|
34
|
-
import { createChildBashTool } from "./subagent/child-bash";
|
|
35
34
|
import { ChildShellTracker, createOverrunNotifier } from "./subagent/overrun";
|
|
36
35
|
import {
|
|
37
36
|
agentEndReason,
|
|
@@ -44,7 +43,11 @@ import {
|
|
|
44
43
|
import { TranscriptWriter } from "./subagent/transcript";
|
|
45
44
|
import { FleetWidget } from "./subagent/fleet-widget";
|
|
46
45
|
import { WorkIndex, type WorkItem } from "./work-index";
|
|
47
|
-
import {
|
|
46
|
+
import { fileURLToPath } from "node:url";
|
|
47
|
+
import { isFamulusChildSession, modelCandidates } from "./subagent/pi-runtime";
|
|
48
|
+
import { createHostedSessionFn } from "./subagent/hosted-session";
|
|
49
|
+
import { isHostedChildProcess, registerChildMode } from "./child-mode";
|
|
50
|
+
import { openAttachView, registerAttachCommand } from "./tui/attach-command";
|
|
48
51
|
import { SubagentRegistry } from "./subagent/registry";
|
|
49
52
|
import { InProcessRunner } from "./subagent/runner";
|
|
50
53
|
import { createSubagentTool } from "./subagent/tool";
|
|
@@ -56,6 +59,52 @@ import { registerTasksCommand } from "./tui/tasks-command";
|
|
|
56
59
|
import { stderrPathFor } from "./tui/task-output-paths";
|
|
57
60
|
import { shellWakeTitle } from "./wake";
|
|
58
61
|
|
|
62
|
+
async function promptIfHostedSession(
|
|
63
|
+
c: ManagerClient,
|
|
64
|
+
live: () => ExtensionContext | null,
|
|
65
|
+
query: { sessionFile?: string | null; sessionId?: string },
|
|
66
|
+
where: "start" | "switch",
|
|
67
|
+
): Promise<"takeover" | "stay" | "none"> {
|
|
68
|
+
if (!c.isAvailable() || c.protocolLevel() < 6) return "none";
|
|
69
|
+
const found = await c.hostedLookup({
|
|
70
|
+
...(query.sessionId ? { sessionId: query.sessionId } : {}),
|
|
71
|
+
...(query.sessionFile ? { sessionFile: query.sessionFile } : {}),
|
|
72
|
+
}).catch(() => null);
|
|
73
|
+
if (!found || found.residency !== "warm" || typeof found.child_id !== "string") return "none";
|
|
74
|
+
const uiCtx = live();
|
|
75
|
+
if (!uiCtx) return "none";
|
|
76
|
+
if (where === "start" && query.sessionId && uiCtx.sessionManager.getSessionId() !== query.sessionId) return "none";
|
|
77
|
+
if (!uiCtx.hasUI) {
|
|
78
|
+
uiCtx.ui.notify(
|
|
79
|
+
"This session file belongs to a warm hosted subagent. Use /attach from the parent, or open it in the TUI to take over.",
|
|
80
|
+
"warning",
|
|
81
|
+
);
|
|
82
|
+
return "stay";
|
|
83
|
+
}
|
|
84
|
+
const choice = await uiCtx.ui.select("This session is hosted by pi-famulus", [
|
|
85
|
+
"Attach to the hosted process",
|
|
86
|
+
"Take over",
|
|
87
|
+
"Cancel",
|
|
88
|
+
]);
|
|
89
|
+
if (choice === "Take over") {
|
|
90
|
+
try {
|
|
91
|
+
await c.agentTakeover(found.child_id);
|
|
92
|
+
return "takeover";
|
|
93
|
+
} catch (err) {
|
|
94
|
+
uiCtx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
95
|
+
return "stay";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (choice === "Attach to the hosted process") {
|
|
99
|
+
try {
|
|
100
|
+
await openAttachView(uiCtx, c, found.child_id);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
uiCtx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return "stay";
|
|
106
|
+
}
|
|
107
|
+
|
|
59
108
|
/** Read only the tail of a task output file for the notification preview (≤ maxChars). */
|
|
60
109
|
export function readPreview(outputPath: string | undefined, maxChars: number): string {
|
|
61
110
|
if (!outputPath) return "";
|
|
@@ -77,6 +126,10 @@ interface FamulusRuntime {
|
|
|
77
126
|
}
|
|
78
127
|
|
|
79
128
|
export default function (pi: ExtensionAPI): void {
|
|
129
|
+
if (isHostedChildProcess()) {
|
|
130
|
+
registerChildMode(pi);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
80
133
|
if (isFamulusChildSession(pi)) return;
|
|
81
134
|
// A /reload parked the previous instance's runtime: adopt it so its shells,
|
|
82
135
|
// monitors and subagents keep running (see reload-handoff.ts). Another
|
|
@@ -414,6 +467,10 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
414
467
|
clock,
|
|
415
468
|
syncWithManager: deps.syncWithManager,
|
|
416
469
|
});
|
|
470
|
+
registerAttachCommand(pi, {
|
|
471
|
+
getClient: () => client,
|
|
472
|
+
getRegistry: () => subagentRegistry,
|
|
473
|
+
});
|
|
417
474
|
famulusInfo = {
|
|
418
475
|
extensionVersion: EXTENSION_VERSION,
|
|
419
476
|
managerPath,
|
|
@@ -447,9 +504,11 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
447
504
|
// M4: comms host bridges the registry and the NotifyCenter. A single Comms
|
|
448
505
|
// instance lives at extension scope (mailbox is per-run namespaced; pending
|
|
449
506
|
// waiters are resolved via dispose() on session_shutdown).
|
|
507
|
+
let watchResume: (childId: string) => void = () => {};
|
|
450
508
|
const commsHost = createRegistryCommsHost({
|
|
451
509
|
getRegistry: () => subagentRegistry,
|
|
452
510
|
getNotifyCenter: () => notifyCenter,
|
|
511
|
+
onResumeAccepted: (childId) => watchResume(childId),
|
|
453
512
|
});
|
|
454
513
|
const comms: CommsWithOrigin = createComms(commsHost, {
|
|
455
514
|
decisionTimeoutMs: subagentConfig.decisionTimeoutMs,
|
|
@@ -466,6 +525,9 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
466
525
|
defaultTimeoutMs: subagentConfig.timeoutMs,
|
|
467
526
|
defaultConcurrency: subagentConfig.concurrency,
|
|
468
527
|
clock,
|
|
528
|
+
registerResumeWatch: (watch) => {
|
|
529
|
+
watchResume = watch;
|
|
530
|
+
},
|
|
469
531
|
resolveAgent: (name) => {
|
|
470
532
|
const loader = agentLoader;
|
|
471
533
|
if (!loader) return resolveAgentDef([], name); // builtins-only fallback
|
|
@@ -546,11 +608,48 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
546
608
|
sessionId: startCtx.sessionManager.getSessionId(),
|
|
547
609
|
managerPath,
|
|
548
610
|
cwd: startCtx.cwd,
|
|
611
|
+
sessionFile: startCtx.sessionManager.getSessionFile?.(),
|
|
549
612
|
clock,
|
|
550
613
|
log: () => {}, // keep quiet; degradation is surfaced via tools
|
|
551
614
|
});
|
|
552
615
|
|
|
616
|
+
// Hosted children run bash in their own process. The parent learns the
|
|
617
|
+
// foreground shell from task_started (origin child-bash) so overrun wakes
|
|
618
|
+
// still carry <shell>.
|
|
619
|
+
const childShells = new ChildShellTracker();
|
|
620
|
+
const parentSessionId = startCtx.sessionManager.getSessionId();
|
|
553
621
|
client.onEvent((event) => {
|
|
622
|
+
const hosted = event as ManagerEvent & {
|
|
623
|
+
child_id?: string;
|
|
624
|
+
reason?: string;
|
|
625
|
+
request_id?: string;
|
|
626
|
+
message?: string;
|
|
627
|
+
name?: string;
|
|
628
|
+
residency?: string;
|
|
629
|
+
driver?: string;
|
|
630
|
+
last_text?: string;
|
|
631
|
+
};
|
|
632
|
+
if (hosted.event === "agent_comms" && hosted.child_id && hosted.request_id) {
|
|
633
|
+
const reason = hosted.reason === "need_decision" ? "need_decision" : "progress_update";
|
|
634
|
+
void comms.contactSupervisor(hosted.child_id, reason, hosted.message ?? "").then((reply) => {
|
|
635
|
+
if (reason === "need_decision") void client?.agentCommsReply(hosted.request_id!, hosted.child_id!, reply).catch(() => {});
|
|
636
|
+
}).catch(() => {});
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (hosted.event === "agent_detach" && hosted.child_id) {
|
|
640
|
+
notifyCenter?.notify({
|
|
641
|
+
customType: "pi-famulus-wake",
|
|
642
|
+
content: `System wake — not a new user message.\n\nThe user talked to ${hosted.name ?? hosted.child_id} directly.\n${hosted.last_text ?? ""}`,
|
|
643
|
+
details: { kind: "supervisor-update", from: hosted.child_id, name: hosted.name ?? hosted.child_id, message: hosted.last_text ?? "" },
|
|
644
|
+
});
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (hosted.event === "agent_state" && hosted.child_id) {
|
|
648
|
+
const handle = subagentRegistry?.handle(hosted.child_id) as { pauseStall?: () => void; resumeStall?: () => void } | undefined;
|
|
649
|
+
if (hosted.driver === "user") handle?.pauseStall?.();
|
|
650
|
+
else handle?.resumeStall?.();
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
554
653
|
if (event.event === "task_started" && event.task_id) {
|
|
555
654
|
const prior = taskMeta.get(event.task_id);
|
|
556
655
|
taskMeta.set(event.task_id, {
|
|
@@ -562,6 +661,14 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
562
661
|
? { startedAt: prior.startedAt }
|
|
563
662
|
: {}),
|
|
564
663
|
});
|
|
664
|
+
if (event.origin?.via === "child-bash" && event.origin.child_id) {
|
|
665
|
+
childShells.start(event.origin.child_id, {
|
|
666
|
+
taskId: event.task_id,
|
|
667
|
+
command: event.command ?? "",
|
|
668
|
+
startedAt: typeof event.ts === "number" ? event.ts : clock.now(),
|
|
669
|
+
outputPath: taskOutputPath(home, parentSessionId, event.task_id),
|
|
670
|
+
});
|
|
671
|
+
}
|
|
565
672
|
if (notifyOnExit.has(event.task_id) && typeof event.ts === "number") {
|
|
566
673
|
upsertBackgroundTask(event.task_id, event.ts);
|
|
567
674
|
}
|
|
@@ -584,6 +691,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
584
691
|
return;
|
|
585
692
|
}
|
|
586
693
|
if (event.event === "task_exited" && event.task_id) {
|
|
694
|
+
childShells.endByTask(event.task_id);
|
|
587
695
|
// A known monitor closes (and patches the index via onExited); an
|
|
588
696
|
// unknown id is buffered in case a monitor start is about to claim it.
|
|
589
697
|
if (monitorRegistry?.handleExit(event.task_id, event)) return;
|
|
@@ -633,9 +741,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
633
741
|
"warning",
|
|
634
742
|
);
|
|
635
743
|
}
|
|
636
|
-
|
|
637
|
-
const childShells = new ChildShellTracker();
|
|
638
|
-
const createSession = createPiSessionFn({
|
|
744
|
+
const createSession = createHostedSessionFn({
|
|
639
745
|
getModelRegistry: () => ctx?.modelRegistry ?? null,
|
|
640
746
|
getModelRuntime: () => {
|
|
641
747
|
const registry = ctx?.modelRegistry as { runtime?: unknown } | null | undefined;
|
|
@@ -646,29 +752,10 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
646
752
|
getScopedModels: () => ctx?.scopedModels ?? [],
|
|
647
753
|
getCwd: () => ctx?.cwd ?? process.cwd(),
|
|
648
754
|
getProjectTrusted: () => ctx?.isProjectTrusted() ?? false,
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
createAgentMessageTool(comms, { kind: "child", childId: req.childId, runId: req.runId }, commsHost, clock),
|
|
654
|
-
];
|
|
655
|
-
// The no-background bash variant replaces the built-in bash inside
|
|
656
|
-
// child sessions (custom tools override builtins by name).
|
|
657
|
-
tools.push(
|
|
658
|
-
createChildBashTool({
|
|
659
|
-
getClient: () => client,
|
|
660
|
-
home,
|
|
661
|
-
sessionId: () => ctx?.sessionManager.getSessionId() ?? "",
|
|
662
|
-
sessionEnv: () => (ctx ? sessionEnv(ctx) : {}),
|
|
663
|
-
trackTask,
|
|
664
|
-
childId: req.childId,
|
|
665
|
-
runId: req.runId,
|
|
666
|
-
clock,
|
|
667
|
-
shells: childShells,
|
|
668
|
-
}),
|
|
669
|
-
);
|
|
670
|
-
return tools;
|
|
671
|
-
},
|
|
755
|
+
getClient: () => client,
|
|
756
|
+
getHome: () => home,
|
|
757
|
+
extensionEntry: fileURLToPath(import.meta.url),
|
|
758
|
+
piCommand: subagentConfig.piCommand,
|
|
672
759
|
});
|
|
673
760
|
// The registry outlives startCtx across a /reload, which leaves startCtx
|
|
674
761
|
// stale; its closures keep plain values instead.
|
|
@@ -856,6 +943,9 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
856
943
|
if (ok === null || client !== c || !c) return;
|
|
857
944
|
if (ok) {
|
|
858
945
|
if (c.protocolLevel() >= 4) void reconcileAgentLeases(c).catch(() => {});
|
|
946
|
+
const file = startCtx.sessionManager.getSessionFile?.();
|
|
947
|
+
const sid = startCtx.sessionManager.getSessionId();
|
|
948
|
+
void promptIfHostedSession(c, () => ctx, { sessionFile: file, sessionId: sid }, "start").catch(() => {});
|
|
859
949
|
return;
|
|
860
950
|
}
|
|
861
951
|
// A /reload may have replaced startCtx by now; report to the live one.
|
|
@@ -900,6 +990,14 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
900
990
|
};
|
|
901
991
|
const runtime: FamulusRuntime = { attach: (host) => binding.attach(host) };
|
|
902
992
|
|
|
993
|
+
pi.on("session_before_switch", async (event, switchCtx) => {
|
|
994
|
+
const file = event.targetSessionFile;
|
|
995
|
+
if (!file || !client?.isAvailable()) return;
|
|
996
|
+
const outcome = await promptIfHostedSession(client, () => switchCtx, { sessionFile: file }, "switch");
|
|
997
|
+
if (outcome === "none" || outcome === "takeover") return;
|
|
998
|
+
return { cancel: true };
|
|
999
|
+
});
|
|
1000
|
+
|
|
903
1001
|
pi.on("session_shutdown", async (event) => {
|
|
904
1002
|
if (event.reason === "reload" && ctx) {
|
|
905
1003
|
// Keep the session's shells, monitors and subagents running; the
|
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.
|
|
21
|
+
const EXTENSION_VERSION = "0.2.0";
|
|
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" });
|
|
@@ -774,6 +910,10 @@ export class ManagerClient {
|
|
|
774
910
|
...(this.cwd ? { cwd: this.cwd } : {}),
|
|
775
911
|
extension_version: EXTENSION_VERSION,
|
|
776
912
|
protocol: PROTOCOL,
|
|
913
|
+
role: this.role,
|
|
914
|
+
...(this.sessionFile ? { session_file: this.sessionFile } : {}),
|
|
915
|
+
...(this.childId ? { child_id: this.childId } : {}),
|
|
916
|
+
...(this.token ? { token: this.token } : {}),
|
|
777
917
|
}),
|
|
778
918
|
);
|
|
779
919
|
});
|
|
@@ -865,7 +1005,7 @@ export class ManagerClient {
|
|
|
865
1005
|
waitForAdmission = false,
|
|
866
1006
|
): Promise<Record<string, unknown>> {
|
|
867
1007
|
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);
|
|
1008
|
+
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
1009
|
const effectiveTimeoutMs = retryable ? Math.max(timeoutMs, RECONNECT_WINDOW_MS + 1000) : timeoutMs;
|
|
870
1010
|
return new Promise((resolve, reject) => {
|
|
871
1011
|
if (signal?.aborted) {
|
|
@@ -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
|
+
}
|