pi-famulus 0.1.9 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -140,6 +140,10 @@ export interface SubagentToolDeps {
140
140
  /** Agent definition resolver (M5 wires the real loader; default: worker). */
141
141
  resolveAgent?: (name: string | undefined) => AgentDefinition;
142
142
  /** Selectable models for action:"models" (§4.6); absent → action errors. */
143
+ /** Receives the watcher for turns started by agent_message send. */
144
+ registerResumeWatch?: (watch: (childId: string) => void) => void;
145
+ /** Rebuild a child this session no longer holds so resume can use child_id. */
146
+ recoverChild?: (childId: string) => Promise<RunRecord | undefined>;
143
147
  listModels?: () => {
144
148
  provider: string;
145
149
  id: string;
@@ -226,6 +230,8 @@ function toNotificationInfo(record: RunRecord, now: number) {
226
230
  status: c.status,
227
231
  text: c.result?.text ?? "",
228
232
  error: c.result?.error,
233
+ ...(c.result?.endReason !== undefined ? { endReason: c.result.endReason } : {}),
234
+ ...(c.result?.retryAfter !== undefined ? { retryAfter: c.result.retryAfter } : {}),
229
235
  ...(c.result?.warning !== undefined ? { warning: c.result.warning } : {}),
230
236
  ...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
231
237
  })),
@@ -346,6 +352,20 @@ export function createSubagentTool(
346
352
  return true;
347
353
  };
348
354
 
355
+ const armResumeWake = (childId: string): void => {
356
+ const registry = deps.getRegistry();
357
+ if (!registry) return;
358
+ const record = registry.list().find((run) => run.children.some((c) => c.childId === childId));
359
+ if (!record) return;
360
+ const handedOver = new Set<string>();
361
+ void registry.getResult(childId)?.then(() => {
362
+ if (!notifyChildHandover(registry, record.runId, childId, handedOver)) {
363
+ notifyRunCompleted(registry, record.runId);
364
+ }
365
+ });
366
+ };
367
+ deps.registerResumeWatch?.(armResumeWake);
368
+
349
369
  const startRun = async (
350
370
  params: SubagentParams,
351
371
  signal: AbortSignal | undefined,
@@ -547,7 +567,23 @@ export function createSubagentTool(
547
567
  };
548
568
  }
549
569
 
550
- const record = requireRun(registry, params.run_id);
570
+ let record = params.run_id
571
+ ? requireRun(registry, params.run_id)
572
+ : params.child_id
573
+ ? resolveRunByChild(registry, params.child_id)
574
+ : undefined;
575
+ if (params.child_id && !params.run_id && action === "resume" && deps.recoverChild &&
576
+ (!record || !registry.findChild(record.runId, params.child_id))) {
577
+ record =
578
+ await deps.recoverChild(params.child_id);
579
+ }
580
+ if (!record) {
581
+ if (params.child_id && !params.run_id) {
582
+ throw new Error(`unknown child "${params.child_id}" (no run in this session holds it)`);
583
+ }
584
+ requireRun(registry, params.run_id);
585
+ throw new Error("run_id is required for this action");
586
+ }
551
587
 
552
588
  if (action === "get") {
553
589
  return {
@@ -585,18 +621,45 @@ export function createSubagentTool(
585
621
  const known = record.children.map((c) => `${c.name} (${c.childId})`).join(", ");
586
622
  throw new Error(`no child "${params.child_id}" in run ${record.runId} (children: ${known})`);
587
623
  }
624
+ const idle = record.children.filter((c) => {
625
+ if (params.child_id && c.childId !== params.child_id && c.name !== params.child_id) return false;
626
+ return c.status === "completed" || c.status === "failed" || c.status === "interrupted";
627
+ });
588
628
  for (const child of targets) {
589
629
  await registry.handle(child.childId)?.interrupt();
590
630
  }
591
- const scope = params.child_id ? `subagent ${params.child_id}` : `${targets.length} subagent(s)`;
631
+ for (const child of idle) {
632
+ await registry.handle(child.childId)?.interrupt();
633
+ }
634
+ const interrupted = [...targets, ...idle].map((c) => c.childId);
635
+ const scope = params.child_id ? `subagent ${params.child_id}` : `${interrupted.length} subagent(s)`;
592
636
  return {
593
637
  content: [{ type: "text", text: `Interrupted ${scope} in run ${record.runId}.` }],
594
- details: { run_id: record.runId, interrupted: targets.map((c) => c.childId) },
638
+ details: { run_id: record.runId, interrupted },
595
639
  };
596
640
  }
597
641
 
598
642
  if (action === "steer") {
599
643
  if (!params.message) throw new Error("message is required for steer");
644
+ const named = params.child_id
645
+ ? record.children.find((c) => c.childId === params.child_id || c.name === params.child_id)
646
+ : record.children.length === 1 ? record.children[0] : undefined;
647
+ if (named && named.status !== "running" && named.status !== "pending") {
648
+ const { queuedBehind } = await registry.resumeChild(named.childId, params.message);
649
+ armResumeWake(named.childId);
650
+ return {
651
+ content: [{
652
+ type: "text",
653
+ text: fill("tools.subagent.result.resumed", {
654
+ name: named.name,
655
+ childId: named.childId,
656
+ runId: record.runId,
657
+ queued: queuedText(queuedBehind),
658
+ }),
659
+ }],
660
+ details: { run_id: record.runId, child_id: named.childId, queued_behind: queuedBehind },
661
+ };
662
+ }
600
663
  const handle = resolveSingleActiveChild(registry, record, params.child_id, "steer");
601
664
  await handle.steer(params.message);
602
665
  return {
@@ -645,12 +708,7 @@ export function createSubagentTool(
645
708
  );
646
709
  // Resume is asynchronous. If siblings are still running, hand this child
647
710
  // back as soon as it finishes; otherwise the run-complete wake covers it.
648
- const handedOver = new Set<string>();
649
- void registry.getResult(child.childId)?.then(() => {
650
- if (!notifyChildHandover(registry, record.runId, child.childId, handedOver)) {
651
- notifyRunCompleted(registry, record.runId);
652
- }
653
- });
711
+ armResumeWake(child.childId);
654
712
  return {
655
713
  content: [
656
714
  {
@@ -733,6 +791,18 @@ export function createSubagentTool(
733
791
  };
734
792
  }
735
793
 
794
+ function resolveRunByChild(registry: SubagentRegistry, childIdOrName: string) {
795
+ const runs = registry.list();
796
+ const byId = runs.filter((run) => run.children.some((c) => c.childId === childIdOrName));
797
+ if (byId.length === 1) return byId[0];
798
+ const byName = runs.filter((run) => run.children.some((c) => c.name === childIdOrName));
799
+ if (byName.length > 1) {
800
+ const ids = byName.map((run) => run.runId).join(", ");
801
+ throw new Error(`child name "${childIdOrName}" is in more than one run (${ids}); pass run_id`);
802
+ }
803
+ return byName[0];
804
+ }
805
+
736
806
  /** Resume result clause for a turn waiting for an admission slot ("" when it started at once). */
737
807
  function queuedText(queuedBehind: number | null): string {
738
808
  if (queuedBehind === null) return "";
@@ -46,7 +46,9 @@ export interface ChildResult {
46
46
  /** Non-fatal caveat, e.g. agent-definition model fell back to parent model. */
47
47
  warning?: string;
48
48
  /** Set when the model itself stopped with an error (provider failure). */
49
- endReason?: "model-error";
49
+ endReason?: "model-error" | "rate-limit";
50
+ /** Seconds until a rate-limited provider may be retried, when known. */
51
+ retryAfter?: number;
50
52
  /**
51
53
  * Total generations run (1 + resumes + stall retries). Omitted when 1.
52
54
  * Note: a user resume() also increments this; use `stalls` to count
@@ -102,6 +104,9 @@ export interface ChildSessionEvent {
102
104
  role?: string;
103
105
  /** Pi's stable tool-call id, used to pair execution start/end events. */
104
106
  toolCallId?: string;
107
+ toolName?: string;
108
+ args?: unknown;
109
+ isError?: boolean;
105
110
  usage?: ChildTokenUsage;
106
111
  }
107
112
 
@@ -135,6 +140,8 @@ export interface ChildHandle {
135
140
  tokenUsage(): ChildTokenUsage;
136
141
  /** Cumulative observed LLM, tool, admission-queue and approximate wall time. */
137
142
  wallUsage(): ChildWallUsage;
143
+ /** Hosted-process view, when this child runs as `pi --mode rpc`. */
144
+ hosting?(): { residency?: string; driver?: string; cacheExpiresAt?: number; sessionFile?: string; sessionId?: string } | undefined;
138
145
  }
139
146
 
140
147
  /**
@@ -176,6 +183,8 @@ export type CreateSessionFn = (req: ChildRunRequest) => Promise<ChildSessionAdap
176
183
 
177
184
  export interface ChildRunner {
178
185
  start(req: ChildRunRequest, onHandle?: (handle: ChildHandle) => void): Promise<ChildHandle>;
186
+ /** Reattach a child the daemon still hosts, without sending its first prompt. */
187
+ attach?(req: ChildRunRequest, onHandle?: (handle: ChildHandle) => void): Promise<ChildHandle>;
179
188
  }
180
189
 
181
190
  /**
@@ -0,0 +1,164 @@
1
+ /**
2
+ * `/attach <child>`: talk to a hosted subagent through the daemon, in the pi TUI.
3
+ * Lines are the child's transcript; Enter sends a prompt (steered while it runs).
4
+ */
5
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import type { ManagerClient } from "../manager-client";
7
+ import type { SubagentRegistry } from "../subagent/registry";
8
+ import { fitLines, loadPiTui } from "./pi-tui-load";
9
+
10
+ export interface AttachCommandDeps {
11
+ getClient: () => ManagerClient | null;
12
+ getRegistry: () => SubagentRegistry | null;
13
+ }
14
+
15
+ export function registerAttachCommand(pi: ExtensionAPI, deps: AttachCommandDeps): void {
16
+ pi.registerCommand("attach", {
17
+ description: "Talk to a hosted subagent (child id)",
18
+ getArgumentCompletions: () => {
19
+ const children = deps.getRegistry()?.list().flatMap((run) => run.children.map((child) => child.childId)) ?? [];
20
+ return children.map((id) => ({ value: id, label: id }));
21
+ },
22
+ handler: async (args, ctx) => {
23
+ const childId = args.trim();
24
+ if (!childId) {
25
+ ctx.ui.notify("Usage: /attach <child id>", "warning");
26
+ return;
27
+ }
28
+ const client = deps.getClient();
29
+ if (!client?.isAvailable() || client.protocolLevel() < 6) {
30
+ ctx.ui.notify("Hosted subagents need a connected pi-famulus daemon (protocol 6).", "error");
31
+ return;
32
+ }
33
+ if (!ctx.hasUI || ctx.mode !== "tui") {
34
+ ctx.ui.notify(`Attach from a terminal with: pi-famulus attach ${childId}`, "info");
35
+ return;
36
+ }
37
+ await openAttachView(ctx, client, childId);
38
+ },
39
+ });
40
+ }
41
+
42
+ interface EntryMessage {
43
+ role?: string;
44
+ content?: string | { text?: string }[];
45
+ message?: EntryMessage;
46
+ }
47
+
48
+ function textOf(message: EntryMessage | undefined): string {
49
+ const content = message?.content;
50
+ if (typeof content === "string") return content;
51
+ if (!Array.isArray(content)) return "";
52
+ return content.map((part) => (typeof part === "string" ? part : part?.text ?? "")).join("");
53
+ }
54
+
55
+ function linesFromEntries(entries: unknown): string[] {
56
+ if (!Array.isArray(entries)) return [];
57
+ const out: string[] = [];
58
+ for (const entry of entries as EntryMessage[]) {
59
+ const message = entry.message ?? entry;
60
+ const text = textOf(message);
61
+ if (!text) continue;
62
+ if (message.role === "user") out.push(`you: ${text}`);
63
+ else if (message.role === "assistant") out.push(text);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** Live transcript for one hosted child. Closing does not respawn a parked child. */
69
+ export async function openAttachView(ctx: ExtensionContext, client: ManagerClient, childId: string): Promise<void> {
70
+ const piTui = loadPiTui();
71
+ if (!piTui) {
72
+ ctx.ui.notify(`pi-famulus attach ${childId}`, "info");
73
+ return;
74
+ }
75
+ await client.agentWatch(childId).catch((err: unknown) => {
76
+ throw new Error(err instanceof Error ? err.message : String(err));
77
+ });
78
+ const seeded = await client.agentInput(childId, "get_entries", "", "parent").catch(() => null);
79
+ const state = await client.agentInput(childId, "get_state", "", "parent").catch(() => null);
80
+ const lines: string[] = [`attached to ${childId}`, ""];
81
+ lines.push(...linesFromEntries(seeded?.entries));
82
+ let input = "";
83
+ let activity = state?.isStreaming === true ? "running" : "idle";
84
+ let status = activity;
85
+ let requestRender: (() => void) | null = null;
86
+ const unsub = client.onEvent((event) => {
87
+ const hosted = event as {
88
+ event?: string;
89
+ child_id?: string;
90
+ record?: { type?: string; message?: { role?: string; content?: { text?: string }[] } };
91
+ activity?: string;
92
+ residency?: string;
93
+ driver?: string;
94
+ };
95
+ if (hosted.child_id !== childId) return;
96
+ if (hosted.event === "agent_state") {
97
+ activity = hosted.activity ?? activity;
98
+ status = `${hosted.activity ?? "?"} · ${hosted.residency ?? "?"} · ${hosted.driver ?? "parent"}`;
99
+ requestRender?.();
100
+ return;
101
+ }
102
+ if (hosted.event !== "agent_rpc" || !hosted.record) return;
103
+ const record = hosted.record;
104
+ if (record.type === "message_end" && record.message?.role === "assistant") {
105
+ const text = textOf(record.message);
106
+ if (text) lines.push(text);
107
+ } else if (record.type === "agent_start") {
108
+ activity = "running";
109
+ lines.push("…");
110
+ }
111
+ requestRender?.();
112
+ });
113
+ try {
114
+ await ctx.ui.custom<void>((tui, _theme, _kb, done) => {
115
+ requestRender = () => tui.requestRender();
116
+ return {
117
+ render(width: number) {
118
+ const limit = Math.max(1, width);
119
+ const rows = Math.max(4, (tui.terminal?.rows ?? 24) - 6);
120
+ const body = lines.flatMap((line) => fitLines(line, limit)).slice(-rows);
121
+ return [
122
+ `◆ ${childId} ${status}`,
123
+ ...body,
124
+ "",
125
+ `› ${input}`,
126
+ "enter send esc close",
127
+ ];
128
+ },
129
+ invalidate() {},
130
+ handleInput(data: string) {
131
+ if (data === "\u001b") {
132
+ done();
133
+ return;
134
+ }
135
+ if (data === "\r" || data === "\n") {
136
+ const text = input;
137
+ input = "";
138
+ if (!text.trim()) return;
139
+ lines.push(`you: ${text}`);
140
+ const op = activity === "running" ? "prompt_steer" : "prompt";
141
+ void client.agentInput(childId, op, text, "user").catch((err: unknown) => {
142
+ lines.push(err instanceof Error ? err.message : String(err));
143
+ requestRender?.();
144
+ });
145
+ tui.requestRender();
146
+ return;
147
+ }
148
+ if (data === "\u007f" || data === "\b") {
149
+ input = input.slice(0, -1);
150
+ tui.requestRender();
151
+ return;
152
+ }
153
+ if (data.length === 1 && data >= " ") {
154
+ input += data;
155
+ tui.requestRender();
156
+ }
157
+ },
158
+ };
159
+ });
160
+ } finally {
161
+ unsub();
162
+ await client.agentUnwatch(childId).catch(() => {});
163
+ }
164
+ }
package/src/work-index.ts CHANGED
@@ -11,6 +11,12 @@ export type WorkKind = "shell" | "monitor" | "agent";
11
11
  export const WORK_RETAIN_MS = 10 * 60 * 1000;
12
12
  export const WORK_FINISHED_CAP = 50;
13
13
 
14
+ /** stderr line when print mode drops live work and still exits 0. */
15
+ export function printModeDropWarning(ids: string[]): string | null {
16
+ if (ids.length === 0) return null;
17
+ return `pi-famulus: print mode is ending; stopping ${ids.length} background item(s) (${ids.join(", ")}). The process still exits 0.\n`;
18
+ }
19
+
14
20
  export interface WorkItem {
15
21
  id: string;
16
22
  kind: WorkKind;