pi-famulus 0.1.8 → 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/bin/pi-famulus.js +2 -2
- 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 +18 -6
- package/src/index.ts +164 -36
- package/src/manager-client.ts +151 -6
- package/src/native-manager.d.ts +30 -0
- package/src/native-manager.js +260 -11
- 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,16 +21,16 @@ 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
|
+
import { ensureNativeManagerInstalled } from "./native-manager.js";
|
|
27
28
|
import type { TaskExitInfo } from "./format";
|
|
28
29
|
import { EXTENSION_VERSION, ManagerClient, type ManagerEvent, type TaskRecord } from "./manager-client";
|
|
29
30
|
import { createMonitorTool, exitEventFromRecord, MonitorRegistry } from "./monitor";
|
|
30
31
|
import { admitAgentChild, reregisterAgentLeases } from "./subagent/admission";
|
|
31
32
|
import { NotifyCenter } from "./notify";
|
|
32
33
|
import { onWakeMessageEnd, registryStatusLookup } from "./wake-delivery";
|
|
33
|
-
import { createChildBashTool } from "./subagent/child-bash";
|
|
34
34
|
import { ChildShellTracker, createOverrunNotifier } from "./subagent/overrun";
|
|
35
35
|
import {
|
|
36
36
|
agentEndReason,
|
|
@@ -43,7 +43,11 @@ import {
|
|
|
43
43
|
import { TranscriptWriter } from "./subagent/transcript";
|
|
44
44
|
import { FleetWidget } from "./subagent/fleet-widget";
|
|
45
45
|
import { WorkIndex, type WorkItem } from "./work-index";
|
|
46
|
-
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";
|
|
47
51
|
import { SubagentRegistry } from "./subagent/registry";
|
|
48
52
|
import { InProcessRunner } from "./subagent/runner";
|
|
49
53
|
import { createSubagentTool } from "./subagent/tool";
|
|
@@ -55,6 +59,52 @@ import { registerTasksCommand } from "./tui/tasks-command";
|
|
|
55
59
|
import { stderrPathFor } from "./tui/task-output-paths";
|
|
56
60
|
import { shellWakeTitle } from "./wake";
|
|
57
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
|
+
|
|
58
108
|
/** Read only the tail of a task output file for the notification preview (≤ maxChars). */
|
|
59
109
|
export function readPreview(outputPath: string | undefined, maxChars: number): string {
|
|
60
110
|
if (!outputPath) return "";
|
|
@@ -76,6 +126,10 @@ interface FamulusRuntime {
|
|
|
76
126
|
}
|
|
77
127
|
|
|
78
128
|
export default function (pi: ExtensionAPI): void {
|
|
129
|
+
if (isHostedChildProcess()) {
|
|
130
|
+
registerChildMode(pi);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
79
133
|
if (isFamulusChildSession(pi)) return;
|
|
80
134
|
// A /reload parked the previous instance's runtime: adopt it so its shells,
|
|
81
135
|
// monitors and subagents keep running (see reload-handoff.ts). Another
|
|
@@ -99,7 +153,28 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
99
153
|
registerBehaviorGuidelines(pi);
|
|
100
154
|
const home = getFamulusHome();
|
|
101
155
|
const config = loadConfig(home);
|
|
102
|
-
|
|
156
|
+
// pi update reinstalls extensions with npm. omit=optional drops the platform
|
|
157
|
+
// package during that install. Fetch it in the background so a slow or
|
|
158
|
+
// unreachable registry cannot stall extension startup.
|
|
159
|
+
let managerPath = resolveManagerPath(config, home);
|
|
160
|
+
let famulusInfo: {
|
|
161
|
+
extensionVersion: string;
|
|
162
|
+
managerPath: string | null;
|
|
163
|
+
describeSearch: () => string;
|
|
164
|
+
getClient: () => ManagerClient | null;
|
|
165
|
+
} | null = null;
|
|
166
|
+
const nativeRestore: Promise<string | null> = managerPath
|
|
167
|
+
? Promise.resolve(managerPath)
|
|
168
|
+
: Promise.resolve()
|
|
169
|
+
.then(() => ensureNativeManagerInstalled())
|
|
170
|
+
.then((path) => {
|
|
171
|
+
if (path) {
|
|
172
|
+
managerPath = path;
|
|
173
|
+
if (famulusInfo) famulusInfo.managerPath = path;
|
|
174
|
+
}
|
|
175
|
+
return path;
|
|
176
|
+
})
|
|
177
|
+
.catch(() => null);
|
|
103
178
|
const clock = realClock;
|
|
104
179
|
|
|
105
180
|
// Claude-style transcript pills for notifications (TUI only; no-ops elsewhere).
|
|
@@ -392,12 +467,17 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
392
467
|
clock,
|
|
393
468
|
syncWithManager: deps.syncWithManager,
|
|
394
469
|
});
|
|
395
|
-
|
|
470
|
+
registerAttachCommand(pi, {
|
|
471
|
+
getClient: () => client,
|
|
472
|
+
getRegistry: () => subagentRegistry,
|
|
473
|
+
});
|
|
474
|
+
famulusInfo = {
|
|
396
475
|
extensionVersion: EXTENSION_VERSION,
|
|
397
476
|
managerPath,
|
|
398
477
|
describeSearch: () => describeManagerSearch(config, home),
|
|
399
478
|
getClient: () => client,
|
|
400
|
-
}
|
|
479
|
+
};
|
|
480
|
+
registerFamulusCommand(pi, famulusInfo);
|
|
401
481
|
monitorRegistry.onChange(() => {
|
|
402
482
|
for (const mon of monitorRegistry.listActive()) {
|
|
403
483
|
const existing = workIndex.get(mon.taskId);
|
|
@@ -424,9 +504,11 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
424
504
|
// M4: comms host bridges the registry and the NotifyCenter. A single Comms
|
|
425
505
|
// instance lives at extension scope (mailbox is per-run namespaced; pending
|
|
426
506
|
// waiters are resolved via dispose() on session_shutdown).
|
|
507
|
+
let watchResume: (childId: string) => void = () => {};
|
|
427
508
|
const commsHost = createRegistryCommsHost({
|
|
428
509
|
getRegistry: () => subagentRegistry,
|
|
429
510
|
getNotifyCenter: () => notifyCenter,
|
|
511
|
+
onResumeAccepted: (childId) => watchResume(childId),
|
|
430
512
|
});
|
|
431
513
|
const comms: CommsWithOrigin = createComms(commsHost, {
|
|
432
514
|
decisionTimeoutMs: subagentConfig.decisionTimeoutMs,
|
|
@@ -443,6 +525,9 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
443
525
|
defaultTimeoutMs: subagentConfig.timeoutMs,
|
|
444
526
|
defaultConcurrency: subagentConfig.concurrency,
|
|
445
527
|
clock,
|
|
528
|
+
registerResumeWatch: (watch) => {
|
|
529
|
+
watchResume = watch;
|
|
530
|
+
},
|
|
446
531
|
resolveAgent: (name) => {
|
|
447
532
|
const loader = agentLoader;
|
|
448
533
|
if (!loader) return resolveAgentDef([], name); // builtins-only fallback
|
|
@@ -523,11 +608,48 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
523
608
|
sessionId: startCtx.sessionManager.getSessionId(),
|
|
524
609
|
managerPath,
|
|
525
610
|
cwd: startCtx.cwd,
|
|
611
|
+
sessionFile: startCtx.sessionManager.getSessionFile?.(),
|
|
526
612
|
clock,
|
|
527
613
|
log: () => {}, // keep quiet; degradation is surfaced via tools
|
|
528
614
|
});
|
|
529
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();
|
|
530
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
|
+
}
|
|
531
653
|
if (event.event === "task_started" && event.task_id) {
|
|
532
654
|
const prior = taskMeta.get(event.task_id);
|
|
533
655
|
taskMeta.set(event.task_id, {
|
|
@@ -539,6 +661,14 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
539
661
|
? { startedAt: prior.startedAt }
|
|
540
662
|
: {}),
|
|
541
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
|
+
}
|
|
542
672
|
if (notifyOnExit.has(event.task_id) && typeof event.ts === "number") {
|
|
543
673
|
upsertBackgroundTask(event.task_id, event.ts);
|
|
544
674
|
}
|
|
@@ -561,6 +691,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
561
691
|
return;
|
|
562
692
|
}
|
|
563
693
|
if (event.event === "task_exited" && event.task_id) {
|
|
694
|
+
childShells.endByTask(event.task_id);
|
|
564
695
|
// A known monitor closes (and patches the index via onExited); an
|
|
565
696
|
// unknown id is buffered in case a monitor start is about to claim it.
|
|
566
697
|
if (monitorRegistry?.handleExit(event.task_id, event)) return;
|
|
@@ -610,9 +741,7 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
610
741
|
"warning",
|
|
611
742
|
);
|
|
612
743
|
}
|
|
613
|
-
|
|
614
|
-
const childShells = new ChildShellTracker();
|
|
615
|
-
const createSession = createPiSessionFn({
|
|
744
|
+
const createSession = createHostedSessionFn({
|
|
616
745
|
getModelRegistry: () => ctx?.modelRegistry ?? null,
|
|
617
746
|
getModelRuntime: () => {
|
|
618
747
|
const registry = ctx?.modelRegistry as { runtime?: unknown } | null | undefined;
|
|
@@ -623,29 +752,10 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
623
752
|
getScopedModels: () => ctx?.scopedModels ?? [],
|
|
624
753
|
getCwd: () => ctx?.cwd ?? process.cwd(),
|
|
625
754
|
getProjectTrusted: () => ctx?.isProjectTrusted() ?? false,
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
createAgentMessageTool(comms, { kind: "child", childId: req.childId, runId: req.runId }, commsHost, clock),
|
|
631
|
-
];
|
|
632
|
-
// The no-background bash variant replaces the built-in bash inside
|
|
633
|
-
// child sessions (custom tools override builtins by name).
|
|
634
|
-
tools.push(
|
|
635
|
-
createChildBashTool({
|
|
636
|
-
getClient: () => client,
|
|
637
|
-
home,
|
|
638
|
-
sessionId: () => ctx?.sessionManager.getSessionId() ?? "",
|
|
639
|
-
sessionEnv: () => (ctx ? sessionEnv(ctx) : {}),
|
|
640
|
-
trackTask,
|
|
641
|
-
childId: req.childId,
|
|
642
|
-
runId: req.runId,
|
|
643
|
-
clock,
|
|
644
|
-
shells: childShells,
|
|
645
|
-
}),
|
|
646
|
-
);
|
|
647
|
-
return tools;
|
|
648
|
-
},
|
|
755
|
+
getClient: () => client,
|
|
756
|
+
getHome: () => home,
|
|
757
|
+
extensionEntry: fileURLToPath(import.meta.url),
|
|
758
|
+
piCommand: subagentConfig.piCommand,
|
|
649
759
|
});
|
|
650
760
|
// The registry outlives startCtx across a /reload, which leaves startCtx
|
|
651
761
|
// stale; its closures keep plain values instead.
|
|
@@ -819,13 +929,23 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
819
929
|
startFleetWidget(startCtx);
|
|
820
930
|
|
|
821
931
|
// Connect in the background: a cold manager spawn must not stall the
|
|
822
|
-
// first prompt.
|
|
932
|
+
// first prompt. The cache install, if one is running, finishes first so a
|
|
933
|
+
// missing optional package is not reported before it can be restored.
|
|
934
|
+
// Tools call ensureAvailable() lazily before use.
|
|
823
935
|
const c = client;
|
|
824
|
-
void
|
|
825
|
-
.
|
|
936
|
+
void nativeRestore
|
|
937
|
+
.then((path) => {
|
|
938
|
+
if (client !== c || !c) return null;
|
|
939
|
+
if (path) c.useManagerPath(path);
|
|
940
|
+
return c.connect();
|
|
941
|
+
})
|
|
826
942
|
.then((ok) => {
|
|
943
|
+
if (ok === null || client !== c || !c) return;
|
|
827
944
|
if (ok) {
|
|
828
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(() => {});
|
|
829
949
|
return;
|
|
830
950
|
}
|
|
831
951
|
// A /reload may have replaced startCtx by now; report to the live one.
|
|
@@ -870,6 +990,14 @@ function createFamulusRuntime(retiring?: Promise<unknown>): FamulusRuntime {
|
|
|
870
990
|
};
|
|
871
991
|
const runtime: FamulusRuntime = { attach: (host) => binding.attach(host) };
|
|
872
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
|
+
|
|
873
1001
|
pi.on("session_shutdown", async (event) => {
|
|
874
1002
|
if (event.reason === "reload" && ctx) {
|
|
875
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
|
}
|
|
@@ -285,9 +293,13 @@ function delay(clock: Clock, ms: number): Promise<void> {
|
|
|
285
293
|
export class ManagerClient {
|
|
286
294
|
private readonly home: string;
|
|
287
295
|
private readonly sessionId: string;
|
|
288
|
-
private
|
|
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
|
}
|
|
@@ -327,6 +343,11 @@ export class ManagerClient {
|
|
|
327
343
|
return this.state === "connected";
|
|
328
344
|
}
|
|
329
345
|
|
|
346
|
+
/** A binary found after construction. The first path wins. */
|
|
347
|
+
useManagerPath(path: string): void {
|
|
348
|
+
if (!this.managerPath) this.managerPath = path;
|
|
349
|
+
}
|
|
350
|
+
|
|
330
351
|
/** Maximum protocol level reported by the connected manager; 0 means unknown/legacy. */
|
|
331
352
|
protocolLevel(): number {
|
|
332
353
|
return this.protocolLevel_;
|
|
@@ -453,20 +474,140 @@ export class ManagerClient {
|
|
|
453
474
|
childId: string,
|
|
454
475
|
workKind = "other",
|
|
455
476
|
signal?: AbortSignal,
|
|
456
|
-
): Promise<{ granted: boolean; rejection?: string }> {
|
|
477
|
+
): Promise<{ granted: boolean; rejection?: string; queuedBehind?: number }> {
|
|
457
478
|
if (signal?.aborted) throw new Error("subagent admission cancelled");
|
|
458
479
|
const message: Record<string, unknown> = { type: "acquire_agent", child_id: childId };
|
|
459
480
|
if (this.protocolLevel_ >= 5) message.work_kind = workKind;
|
|
460
481
|
// Protocol 4 has no cancel frame. Keep its immediate acquire correlated so
|
|
461
482
|
// admission can release a grant that arrives after the generation aborts.
|
|
462
483
|
const res = await this.request(message, DEFAULT_REQUEST_TIMEOUT_MS, this.protocolLevel_ >= 5 ? signal : undefined, true);
|
|
463
|
-
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
|
+
};
|
|
464
489
|
}
|
|
465
490
|
|
|
466
491
|
async releaseAgent(childId: string): Promise<void> {
|
|
467
492
|
await this.request({ type: "release_agent", child_id: childId });
|
|
468
493
|
}
|
|
469
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
|
+
|
|
470
611
|
/** Daemon status, including optional in-place upgrade metadata. */
|
|
471
612
|
async status(): Promise<ManagerStatusResponse> {
|
|
472
613
|
const res = await this.request({ type: "status" });
|
|
@@ -769,6 +910,10 @@ export class ManagerClient {
|
|
|
769
910
|
...(this.cwd ? { cwd: this.cwd } : {}),
|
|
770
911
|
extension_version: EXTENSION_VERSION,
|
|
771
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 } : {}),
|
|
772
917
|
}),
|
|
773
918
|
);
|
|
774
919
|
});
|
|
@@ -860,7 +1005,7 @@ export class ManagerClient {
|
|
|
860
1005
|
waitForAdmission = false,
|
|
861
1006
|
): Promise<Record<string, unknown>> {
|
|
862
1007
|
const type = String(msg.type);
|
|
863
|
-
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);
|
|
864
1009
|
const effectiveTimeoutMs = retryable ? Math.max(timeoutMs, RECONNECT_WINDOW_MS + 1000) : timeoutMs;
|
|
865
1010
|
return new Promise((resolve, reject) => {
|
|
866
1011
|
if (signal?.aborted) {
|
package/src/native-manager.d.ts
CHANGED
|
@@ -3,7 +3,37 @@ export interface NativeManagerOptions {
|
|
|
3
3
|
arch?: string;
|
|
4
4
|
/** Node package resolver; tests can use createRequire from an isolated consumer. */
|
|
5
5
|
resolve?: (specifier: string) => string;
|
|
6
|
+
/** Walk node_modules from this file instead of this module. */
|
|
7
|
+
from?: string;
|
|
8
|
+
/** Do not walk above this directory. */
|
|
9
|
+
stopAt?: string;
|
|
10
|
+
/** Versioned platform cache. `false` disables the cache lookup. */
|
|
11
|
+
cacheDir?: string | false;
|
|
12
|
+
}
|
|
13
|
+
export interface NativeCacheOptions {
|
|
14
|
+
version?: string;
|
|
15
|
+
platform?: string;
|
|
16
|
+
arch?: string;
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
home?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface EnsureNativeOptions extends NativeManagerOptions {
|
|
21
|
+
env?: NodeJS.ProcessEnv;
|
|
22
|
+
npmCommand?: string;
|
|
23
|
+
spawn?: (
|
|
24
|
+
command: string,
|
|
25
|
+
args: string[],
|
|
26
|
+
options?: { timeout?: number; windowsHide?: boolean; encoding?: "utf8" },
|
|
27
|
+
) => { status: number | null; error?: Error };
|
|
6
28
|
}
|
|
7
29
|
export function nativePackageName(platform: string, arch: string): string | null;
|
|
30
|
+
export function nativeCacheDir(options?: NativeCacheOptions): string;
|
|
8
31
|
/** Null when unsupported, missing, corrupt, non-executable, or a different version. */
|
|
9
32
|
export function resolveNativeManagerPath(options?: NativeManagerOptions): string | null;
|
|
33
|
+
/** `npm <package> <path>` or why the exact platform package was not usable. */
|
|
34
|
+
export function describeNativeLookup(options?: NativeManagerOptions): string;
|
|
35
|
+
/**
|
|
36
|
+
* Install the exact platform package into the versioned cache when it is missing.
|
|
37
|
+
* Returns the binary path, or null when it is still unusable.
|
|
38
|
+
*/
|
|
39
|
+
export function ensureNativeManagerInstalled(options?: EnsureNativeOptions): string | null;
|