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/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, createContactSupervisorTool } from "./comms/tools";
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 { createPiSessionFn, isFamulusChildSession, modelCandidates } from "./subagent/pi-runtime";
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
- const managerPath = resolveManagerPath(config, home);
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
- registerFamulusCommand(pi, {
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
- // The foreground shell each child is blocked on, for its overrun wake.
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
- customTools: (req) => {
627
- const tools: Array<ToolDefinition<any, any, any>> = [
628
- // M4: every child can reach the supervisor and its siblings.
629
- createContactSupervisorTool(comms, req.childId),
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. Tools call ensureAvailable() lazily before use.
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 c
825
- .connect()
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
@@ -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.8";
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 = 5;
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 readonly managerPath: string | null;
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 { granted: res.granted === true, ...(typeof res.rejection === "string" ? { rejection: res.rejection } : {}) };
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) {
@@ -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;