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/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, 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
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 { 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";
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
- // The foreground shell each child is blocked on, for its overrun wake.
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
- customTools: (req) => {
650
- const tools: Array<ToolDefinition<any, any, any>> = [
651
- // M4: every child can reach the supervisor and its siblings.
652
- createContactSupervisorTool(comms, req.childId),
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
@@ -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.9";
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
  }
@@ -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 { 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
+ };
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
+ }