pi-famulus 0.2.0 → 0.2.2-beta.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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * TUI widgets for intercom message cards.
3
+ *
4
+ * Upstream baseline: agent-intercom @ 98183fb, plugin/pi — verbatim port of
5
+ * the plugin's ChatText/ChatCard renderers and text-measurement helpers.
6
+ */
7
+
8
+ /** Rough display width: CJK ideographs and wide chars count as 2. */
9
+ export function stringWidth(s: string): number {
10
+ let w = 0;
11
+ for (const ch of s) {
12
+ const cp = ch.codePointAt(0) ?? 0;
13
+ w += cp >= 0x2e80 ? 2 : 1;
14
+ }
15
+ return w;
16
+ }
17
+
18
+ /** Word-wrap one line, treating ANSI SGR sequences as zero-width. */
19
+ export function wrapAnsiLine(line: string, width: number): string[] {
20
+ if (stringWidth(line.replace(/\x1b\[[0-9;]*m/g, "")) <= width) return [line];
21
+ const tokens = line.match(/(\x1b\[[0-9;]*m|\s+|[^\s\x1b]+)/g) ?? [line];
22
+ const lines: string[] = [];
23
+ let cur = "";
24
+ let curWidth = 0;
25
+ const push = () => {
26
+ lines.push(cur.trimEnd());
27
+ cur = "";
28
+ curWidth = 0;
29
+ };
30
+ for (const token of tokens) {
31
+ if (token.startsWith("\x1b")) {
32
+ cur += token;
33
+ continue;
34
+ }
35
+ if (/^\s+$/.test(token)) {
36
+ if (cur && curWidth + 1 <= width) {
37
+ cur += " ";
38
+ curWidth += 1;
39
+ }
40
+ continue;
41
+ }
42
+ let piece = token;
43
+ let pieceWidth = stringWidth(piece);
44
+ if (curWidth > 0 && curWidth + pieceWidth > width) push();
45
+ while (pieceWidth > width) {
46
+ let cut = 0;
47
+ let cutWidth = 0;
48
+ for (const ch of piece) {
49
+ const w = stringWidth(ch);
50
+ if (cutWidth + w > width) break;
51
+ cutWidth += w;
52
+ cut += ch.length;
53
+ }
54
+ cur += piece.slice(0, cut);
55
+ push();
56
+ piece = piece.slice(cut);
57
+ pieceWidth = stringWidth(piece);
58
+ }
59
+ cur += piece;
60
+ curWidth += pieceWidth;
61
+ }
62
+ if (cur.trimEnd()) push();
63
+ return lines.length ? lines : [""];
64
+ }
65
+
66
+ /** Local wall-clock `YYYY-mm-dd HH:ss`. */
67
+ export function formatTimestamp(d: Date): string {
68
+ const p = (n: number) => String(n).padStart(2, "0");
69
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
70
+ }
71
+
72
+ /** Minimal Text component satisfying pi's Component contract; no pi-tui dependency. */
73
+ export class ChatText {
74
+ private text: string;
75
+ private paddingX: number;
76
+
77
+ constructor(text: string, paddingX = 1) {
78
+ this.text = text;
79
+ this.paddingX = paddingX;
80
+ }
81
+
82
+ invalidate(): void {}
83
+
84
+ render(width: number): string[] {
85
+ const padding = Math.min(this.paddingX, Math.max(0, Math.floor((width - 1) / 2)));
86
+ const pad = " ".repeat(padding);
87
+ const contentWidth = Math.max(1, width - padding * 2);
88
+ const out: string[] = [];
89
+ for (const line of this.text.split("\n")) {
90
+ for (const wrapped of wrapAnsiLine(line, contentWidth)) {
91
+ out.push(pad + wrapped);
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+ }
97
+
98
+ /** Boxed card with a full-width background, matching pi's custom message look. */
99
+ export class ChatCard {
100
+ private header: string;
101
+ private body: string;
102
+ private bg: (text: string) => string;
103
+
104
+ constructor(header: string, body: string, bg: (text: string) => string) {
105
+ this.header = header;
106
+ this.body = body;
107
+ this.bg = bg;
108
+ }
109
+
110
+ invalidate(): void {}
111
+
112
+ render(width: number): string[] {
113
+ const px = 1;
114
+ const contentWidth = Math.max(1, width - px * 2);
115
+ const out: string[] = [this.bg(" ".repeat(width))];
116
+ const padLine = (s: string) => {
117
+ const visible = stringWidth(s.replace(/\x1b\[[0-9;]*m/g, ""));
118
+ return this.bg(" ".repeat(px) + s + " ".repeat(Math.max(0, width - px - visible)));
119
+ };
120
+ for (const line of `${this.header}\n${this.body}`.split("\n")) {
121
+ for (const wrapped of wrapAnsiLine(line, contentWidth)) {
122
+ out.push(padLine(wrapped));
123
+ }
124
+ }
125
+ out.push(this.bg(" ".repeat(width)));
126
+ // One transparent line below the card as extra bottom margin.
127
+ out.push("");
128
+ return out;
129
+ }
130
+ }
@@ -18,10 +18,11 @@ 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.2.0";
21
+ const EXTENSION_VERSION = "0.2.2-beta.0";
22
22
  export { EXTENSION_VERSION };
23
- // 5: speaks per-kind agent admission and queued acquire.
24
- const PROTOCOL = 6;
23
+ // 6: hosted subagents. 7: the extension's messaging bridge (`intercom`
24
+ // request plus `intercom`/`intercom_reset` events).
25
+ const PROTOCOL = 7;
25
26
  const HELLO_TIMEOUT_MS = 5000;
26
27
  const RECONNECT_HELLO_TIMEOUT_MS = 25_000;
27
28
  const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
@@ -108,7 +109,14 @@ export interface MonitorOutputRecord {
108
109
 
109
110
  /** Server-pushed event (§3.3). Fields beyond `event` depend on the event kind. */
110
111
  export interface ManagerEvent {
111
- event: "task_started" | "output" | "task_exited" | "session_rebound" | string;
112
+ event:
113
+ | "task_started"
114
+ | "output"
115
+ | "task_exited"
116
+ | "session_rebound"
117
+ | "intercom"
118
+ | "intercom_reset"
119
+ | string;
112
120
  task_id?: string;
113
121
  kind?: string;
114
122
  command?: string;
@@ -126,6 +134,8 @@ export interface ManagerEvent {
126
134
  ts?: number;
127
135
  /** Set on task_started when the daemon recorded who asked for the task. */
128
136
  origin?: { via: string; child_id?: string; run_id?: string };
137
+ /** An intercom bridge frame (deliver/receipt/idle_notice) for this session. */
138
+ frame?: Record<string, unknown>;
129
139
  }
130
140
 
131
141
  export class ManagerError extends Error {
@@ -608,6 +618,19 @@ export class ManagerClient {
608
618
  });
609
619
  }
610
620
 
621
+ /** The extension's messaging bridge: one intercom client-protocol op
622
+ * (register/send/list/receipt/…). Protocol 7+; rejected by older managers. */
623
+ async intercom(op: Record<string, unknown>): Promise<Record<string, unknown>> {
624
+ if (this.protocolLevel_ < 7) {
625
+ throw new ManagerError(
626
+ "E_VERSION",
627
+ "this pi-famulus daemon does not serve cross-session messaging (protocol " +
628
+ `${this.protocolLevel_}; need 7). Restart pi so it starts the current daemon.`,
629
+ );
630
+ }
631
+ return this.request({ type: "intercom", op });
632
+ }
633
+
611
634
  /** Daemon status, including optional in-place upgrade metadata. */
612
635
  async status(): Promise<ManagerStatusResponse> {
613
636
  const res = await this.request({ type: "status" });
@@ -637,6 +660,8 @@ export class ManagerClient {
637
660
  async close(): Promise<void> {
638
661
  this.intentionalClose = true;
639
662
  this.state = "disconnected";
663
+ this.eventHandlers.clear();
664
+ this.reconnectHandlers.clear();
640
665
  this.failAllPending(new Error("manager client closed"));
641
666
  const socket = this.socket;
642
667
  this.socket = null;
package/src/notify.ts CHANGED
@@ -311,4 +311,5 @@ export class NotifyCenter {
311
311
  }
312
312
  onDelivered?.(sourcePreview ?? (details?.kind === "monitor" ? (message.details as { event?: string }).event : undefined));
313
313
  }
314
+
314
315
  }
@@ -49,7 +49,7 @@ export const PROMPTS = {
49
49
  "tools.subagent.rules": ["When a <pi-famulus-wake kind=\"subagent-handover\"> arrives, read <prompt> and <result> immediately and continue: subagent({action:\"resume\", run_id, child_id, message}) for that child, or agent_message to steer children that are still running. Do not wait for the rest of the run.","Subagent runs that exceed the foreground budget continue in the background; you are notified per finished child and again when the run completes — do not poll.","A failed subagent does not fail the whole run; inspect per-subagent sections in the result.","<pi-famulus-wake> is a system wake, not a user reply. kind=subagent-handover is one child; kind=subagent-done is the whole run."],
50
50
  "tools.subagent.param.tasks": "Start new subagents in parallel. Use only when action is omitted. For any action call, omit tasks entirely; do not supply placeholder tasks.",
51
51
  "tools.subagent.param.tasks.agent": "Agent definition name (default: worker)",
52
- "tools.subagent.param.tasks.prompt": "Task prompt for this subagent",
52
+ "tools.subagent.param.tasks.prompt": "Task prompt for this subagent. The child is a new process: it cannot see this conversation, so the prompt must be self-contained.",
53
53
  "tools.subagent.param.tasks.name": "Display name (default: agent name + ordinal)",
54
54
  "tools.subagent.param.work_kind": "Explicit planned workload class for machine-wide admission (default: other)",
55
55
  "tools.subagent.param.chain": "Start a new sequential chain (always awaited). Use only when action is omitted. For any action call, omit chain entirely.",
@@ -29,6 +29,7 @@ const REGISTRATIONS = new Set<PropertyKey>([
29
29
  "registerShortcut",
30
30
  "registerFlag",
31
31
  "registerMessageRenderer",
32
+ "registerEntryRenderer",
32
33
  "registerProvider",
33
34
  ]);
34
35
 
@@ -51,8 +52,13 @@ export function createPiBinding(): PiBinding {
51
52
  let current: ExtensionAPI | null = null;
52
53
  let live = false;
53
54
 
54
- const call = (pi: ExtensionAPI, method: PropertyKey, args: unknown[]): unknown =>
55
- (Reflect.get(pi, method) as (...a: unknown[]) => unknown).apply(pi, args);
55
+ const call = (pi: ExtensionAPI, method: PropertyKey, args: unknown[]): unknown => {
56
+ const fn = Reflect.get(pi, method) as unknown;
57
+ // A host surface may not implement every registration kind (hosted
58
+ // child fakes, older pi); skipping beats crashing the replay.
59
+ if (typeof fn !== "function") return undefined;
60
+ return (fn as (...a: unknown[]) => unknown).apply(pi, args);
61
+ };
56
62
 
57
63
  const api = new Proxy({} as ExtensionAPI, {
58
64
  get(_target, key) {
@@ -32,6 +32,8 @@ export interface AgentChildRecord {
32
32
  error?: string;
33
33
  /** Why the child ended (terminal statuses only). */
34
34
  end_reason?: AgentEndReason;
35
+ /** Seconds until a rate-limited provider may be retried, when known. */
36
+ retry_after?: number;
35
37
  /** Task prompt without the agent preamble, capped. */
36
38
  prompt_head?: string;
37
39
  /** Tail of the final result text, capped. */
@@ -58,6 +60,7 @@ export type AgentEndReason =
58
60
  | "completed"
59
61
  | "failed"
60
62
  | "model-error"
63
+ | "rate-limit"
61
64
  | "stalled"
62
65
  | "timeout"
63
66
  | "interrupted"
@@ -66,10 +69,11 @@ export type AgentEndReason =
66
69
  /** Map a terminal child status + result to the end_reason recorded on disk. */
67
70
  export function agentEndReason(
68
71
  status: AgentChildRecord["status"],
69
- result: { error?: string; endReason?: "model-error" } | undefined,
72
+ result: { error?: string; endReason?: "model-error" | "rate-limit" } | undefined,
70
73
  ): AgentEndReason | undefined {
71
74
  if (status === "completed") return "completed";
72
75
  if (status === "failed") {
76
+ if (result?.endReason === "rate-limit") return "rate-limit";
73
77
  if (result?.endReason === "model-error") return "model-error";
74
78
  return result?.error === "stalled" ? "stalled" : "failed";
75
79
  }
@@ -174,6 +178,42 @@ function parseRecord(raw: string): AgentChildRecord | null {
174
178
  }
175
179
 
176
180
  /** Load agent child records from disk (all sessions under home). */
181
+ /** Hosted children the daemon still has warm. They outlive a gone parent session. */
182
+ export function warmHostedChildIds(home: string): Set<string> {
183
+ const out = new Set<string>();
184
+ try {
185
+ const { pid } = JSON.parse(readFileSync(join(home, "manager.pid"), "utf8")) as { pid?: unknown };
186
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return out;
187
+ try {
188
+ process.kill(pid, 0);
189
+ } catch (error) {
190
+ if ((error as NodeJS.ErrnoException).code !== "EPERM") return out;
191
+ }
192
+ } catch {
193
+ return out;
194
+ }
195
+ let files: string[];
196
+ try {
197
+ files = readdirSync(join(home, "agents")).filter((name) => name.endsWith(".json"));
198
+ } catch {
199
+ return out;
200
+ }
201
+ for (const file of files) {
202
+ try {
203
+ const value = JSON.parse(readFileSync(join(home, "agents", file), "utf8")) as {
204
+ residency?: string;
205
+ child_id?: string;
206
+ };
207
+ if (value.residency === "warm" && typeof value.child_id === "string") {
208
+ out.add(value.child_id);
209
+ }
210
+ } catch {
211
+ // skip bad hosted records
212
+ }
213
+ }
214
+ return out;
215
+ }
216
+
177
217
  export function loadAgentChildRecords(
178
218
  home: string,
179
219
  opts: { sessionId?: string; includeTerminal?: boolean; connectedSessionIds?: ReadonlySet<string> } = {},
@@ -189,6 +229,7 @@ export function loadAgentChildRecords(
189
229
  } catch {
190
230
  return [];
191
231
  }
232
+ const warm = opts.connectedSessionIds === undefined ? new Set<string>() : warmHostedChildIds(home);
192
233
  const out: AgentChildRecord[] = [];
193
234
  for (const sid of sessionIds) {
194
235
  const dir = agentRecordsDir(home, sid);
@@ -205,7 +246,9 @@ export function loadAgentChildRecords(
205
246
  const disconnected =
206
247
  opts.connectedSessionIds !== undefined && !opts.connectedSessionIds.has(rec.session_id);
207
248
  const shown: AgentChildRecord =
208
- disconnected && (rec.status === "pending" || rec.status === "running")
249
+ disconnected &&
250
+ (rec.status === "pending" || rec.status === "running") &&
251
+ !warm.has(rec.child_id)
209
252
  ? { ...rec, status: "interrupted" }
210
253
  : rec;
211
254
  const terminal =
@@ -220,6 +263,12 @@ export function loadAgentChildRecords(
220
263
  return out;
221
264
  }
222
265
 
266
+ /** One child by id or name, across every session under home. */
267
+ export function findAgentChildRecord(home: string, childIdOrName: string): AgentChildRecord | undefined {
268
+ const recs = loadAgentChildRecords(home, { includeTerminal: true });
269
+ return recs.find((rec) => rec.child_id === childIdOrName) ?? recs.find((rec) => rec.name === childIdOrName);
270
+ }
271
+
223
272
  export function isAgentStatusActive(status: AgentChildRecord["status"]): boolean {
224
273
  return status === "pending" || status === "running";
225
274
  }
@@ -66,10 +66,10 @@ export function createHostedSessionFn(deps: HostedSessionDeps): CreateSessionFn
66
66
  "--thinking", String(thinking),
67
67
  "--append-system-prompt", promptFile,
68
68
  ...(trustInherited ? [] : [deps.getProjectTrusted?.() ? "-a" : "-na"]),
69
- ...(req.agent.tools ? ["--tools", req.agent.tools.join(",")] : []),
69
+ ...(req.agent.tools ? ["--tools", hostedChildTools(req.agent.tools).join(",")] : []),
70
70
  ];
71
71
  const env = { ...process.env } as Record<string, string>;
72
- if (req.agent.tools) env.PI_FAMULUS_CHILD_TOOLS = req.agent.tools.join(",");
72
+ if (req.agent.tools) env.PI_FAMULUS_CHILD_TOOLS = hostedChildTools(req.agent.tools).join(",");
73
73
  env.PI_FAMULUS_CHILD_RUN = req.runId;
74
74
  env.PI_FAMULUS_CHILD_NAME = req.name;
75
75
  const spawned = await client.agentSpawn({
@@ -80,14 +80,7 @@ export function createHostedSessionFn(deps: HostedSessionDeps): CreateSessionFn
80
80
  cwd,
81
81
  env,
82
82
  });
83
- let warning = model.warning;
84
- try {
85
- await client.agentInput(req.childId, "set_steering_mode", "all", "parent");
86
- } catch (err) {
87
- const message = err instanceof Error ? err.message : String(err);
88
- const note = `Steering mode was not set (${message}); queued input may wait until the current turn ends.`;
89
- warning = warning ? `${warning} ${note}` : note;
90
- }
83
+ const warning = model.warning;
91
84
  await client.agentWatch(req.childId);
92
85
  const session = new HostedRpcSession(client, req.childId, {
93
86
  warning,
@@ -108,6 +101,7 @@ class HostedRpcSession implements ChildSessionAdapter {
108
101
  private readonly unsubscribe: () => void;
109
102
  private streaming = false;
110
103
  private lastText = "";
104
+ private lastFailure: { stopReason: "error" | "aborted"; errorMessage?: string } | undefined;
111
105
  private turns: { role: string; text: string }[] = [];
112
106
  private usage: ChildTokenUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
113
107
  private settle: (() => void) | null = null;
@@ -172,6 +166,10 @@ class HostedRpcSession implements ChildSessionAdapter {
172
166
  return this.lastText || undefined;
173
167
  }
174
168
 
169
+ getLastAssistantFailure(): { stopReason: "error" | "aborted"; errorMessage?: string } | undefined {
170
+ return this.lastFailure;
171
+ }
172
+
175
173
  getConversation() {
176
174
  return this.turns.map((turn) => ({ ...turn }));
177
175
  }
@@ -216,6 +214,9 @@ class HostedRpcSession implements ChildSessionAdapter {
216
214
  };
217
215
  if (event.activity === "running") this.streaming = true;
218
216
  if (event.activity === "idle" || event.activity === "failed") {
217
+ if (event.activity === "failed" && !this.lastFailure) {
218
+ this.lastFailure = { stopReason: "error", errorMessage: "hosted child process failed" };
219
+ }
219
220
  this.streaming = false;
220
221
  this.settle?.();
221
222
  this.settle = null;
@@ -223,7 +224,20 @@ class HostedRpcSession implements ChildSessionAdapter {
223
224
  return;
224
225
  }
225
226
  if (event.event !== "agent_rpc" || !event.record || typeof event.record !== "object") return;
226
- const record = event.record as { type?: string; message?: { role?: string; content?: { type?: string; text?: string }[]; usage?: Record<string, unknown> }; toolCallId?: string };
227
+ const record = event.record as {
228
+ type?: string;
229
+ message?: {
230
+ role?: string;
231
+ content?: { type?: string; text?: string }[];
232
+ usage?: Record<string, unknown>;
233
+ stopReason?: string;
234
+ errorMessage?: string;
235
+ };
236
+ toolCallId?: string;
237
+ toolName?: string;
238
+ args?: unknown;
239
+ isError?: boolean;
240
+ };
227
241
  const type = record.type ?? "";
228
242
  if (type === "agent_start") this.streaming = true;
229
243
  if (type === "agent_settled") {
@@ -237,6 +251,18 @@ class HostedRpcSession implements ChildSessionAdapter {
237
251
  this.lastText = text;
238
252
  this.turns.push({ role: "assistant", text });
239
253
  }
254
+ const stopReason = record.message.stopReason;
255
+ if (stopReason === "error" || stopReason === "aborted") {
256
+ this.lastFailure = {
257
+ stopReason,
258
+ ...(record.message.errorMessage ? { errorMessage: record.message.errorMessage } : {}),
259
+ };
260
+ if (record.message.errorMessage) {
261
+ this.turns.push({ role: "assistant error", text: record.message.errorMessage });
262
+ }
263
+ } else {
264
+ this.lastFailure = undefined;
265
+ }
240
266
  const usage = record.message.usage;
241
267
  if (usage) {
242
268
  this.usage.input += num(usage.input);
@@ -249,10 +275,21 @@ class HostedRpcSession implements ChildSessionAdapter {
249
275
  const text = (record.message.content ?? []).map((part) => part.text ?? "").join("");
250
276
  if (text) this.turns.push({ role: "user", text });
251
277
  }
278
+ if (type === "tool_execution_end") {
279
+ const name = record.toolName ?? "tool";
280
+ const text = record.args === undefined ? name : `${name} ${JSON.stringify(record.args)}`;
281
+ this.turns.push({
282
+ role: record.isError ? `tool ${name} error` : `tool ${name}`,
283
+ text,
284
+ });
285
+ }
252
286
  const adapted: ChildSessionEvent = {
253
287
  type,
254
288
  ...(record.message?.role ? { role: record.message.role } : {}),
255
289
  ...(record.toolCallId ? { toolCallId: record.toolCallId } : {}),
290
+ ...(record.toolName ? { toolName: record.toolName } : {}),
291
+ ...(record.args !== undefined ? { args: record.args } : {}),
292
+ ...(record.isError !== undefined ? { isError: record.isError } : {}),
256
293
  };
257
294
  for (const listener of this.listeners) listener(adapted);
258
295
  }
@@ -262,6 +299,15 @@ function num(value: unknown): number {
262
299
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
263
300
  }
264
301
 
302
+ const SUPERVISOR_TOOLS = ["contact_supervisor", "agent_message"];
303
+
304
+ /** Agent allowlists still keep the two supervisor tools. */
305
+ export function hostedChildTools(tools: string[]): string[] {
306
+ const kept = new Set(tools);
307
+ for (const name of SUPERVISOR_TOOLS) kept.add(name);
308
+ return [...kept];
309
+ }
310
+
265
311
  /** Request id for a child→parent decision. */
266
312
  export function newCommsRequestId(): string {
267
313
  return `rq_${randomUUID().slice(0, 8)}`;
@@ -342,6 +342,84 @@ export class SubagentRegistry implements RunRegistry {
342
342
  // M3 extensions beyond Appendix B
343
343
  // -------------------------------------------------------------------------
344
344
 
345
+ /**
346
+ * Reconstruct a run/child the previous parent process wrote to disk
347
+ * so resume can reattach by child_id.
348
+ */
349
+ adoptExisting(info: {
350
+ runId: string;
351
+ childId: string;
352
+ name: string;
353
+ agent: string;
354
+ workKind?: string;
355
+ startedAt?: number;
356
+ prompt?: string;
357
+ }): void {
358
+ if (this.children.has(info.childId)) return;
359
+ let run = this.runs.get(info.runId);
360
+ if (!run) {
361
+ run = {
362
+ runId: info.runId,
363
+ kind: "tasks",
364
+ status: "interrupted",
365
+ createdAt: info.startedAt ?? this.now(),
366
+ children: [],
367
+ };
368
+ this.runs.set(info.runId, run);
369
+ }
370
+ const child: InternalChild = {
371
+ childId: info.childId,
372
+ runId: info.runId,
373
+ name: info.name,
374
+ agent: info.agent,
375
+ workKind: info.workKind ?? "other",
376
+ status: "interrupted",
377
+ startedAt: info.startedAt ?? this.now(),
378
+ endedAt: this.now(),
379
+ turn: 1,
380
+ queueMs: 0,
381
+ prompt: info.prompt,
382
+ };
383
+ run.children.push(child);
384
+ this.children.set(child.childId, child);
385
+ this.emit(run);
386
+ }
387
+
388
+ /** Bind a live hosted session to an adopted child without sending its first prompt. */
389
+ async attachChild(req: ChildRunRequest): Promise<ChildHandle> {
390
+ const child = this.children.get(req.childId);
391
+ if (!child) throw new Error(`unknown child ${req.childId} (adoptExisting first)`);
392
+ const runner = this.runner;
393
+ if (!runner?.attach) {
394
+ throw new Error(`subagent ${req.childId} has no live session to resume`);
395
+ }
396
+ child.prompt = req.taskPrompt ?? req.prompt;
397
+ child.preamble = req.agent.systemPrompt || undefined;
398
+ child.model = req.model ?? req.agent.model;
399
+ let handle: ChildHandle;
400
+ try {
401
+ handle = await runner.attach(req, (startingHandle) => {
402
+ child.handle = startingHandle;
403
+ });
404
+ } catch (error) {
405
+ const failed = child.handle as DisposableChildHandle | undefined;
406
+ child.handle = undefined;
407
+ await failed?.dispose?.();
408
+ throw error;
409
+ }
410
+ if (!this.children.has(child.childId)) return handle;
411
+ child.handle = handle;
412
+ handle.result.then((result) => this.settleChild(child.childId, result));
413
+ const resolved = handle.resolvedModel();
414
+ if (resolved) {
415
+ const thinking = handle.effectiveThinkingLevel?.();
416
+ child.model = `${resolved}${thinking ? `:${thinking}` : ""}`;
417
+ }
418
+ const run = this.runs.get(child.runId);
419
+ if (run) this.emit(run);
420
+ return handle;
421
+ }
422
+
345
423
  /** Allocate a pending child inside a run. Returns the child id. */
346
424
  addChild(runId: string, info: { name: string; agent: string; workKind?: string }): string {
347
425
  const run = this.runs.get(runId);