pi-famulus 0.1.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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/bin/pi-famulus.js +29 -0
  4. package/package.json +49 -0
  5. package/src/agents/builtins.ts +41 -0
  6. package/src/agents/definition.ts +221 -0
  7. package/src/agents/loader.ts +119 -0
  8. package/src/bash-override.ts +372 -0
  9. package/src/behavior-guidelines.ts +42 -0
  10. package/src/clock.ts +169 -0
  11. package/src/comms/comms.ts +199 -0
  12. package/src/comms/mailbox.ts +156 -0
  13. package/src/comms/registry-host.ts +73 -0
  14. package/src/comms/reply-command.ts +52 -0
  15. package/src/comms/routing.ts +23 -0
  16. package/src/comms/tools.ts +339 -0
  17. package/src/comms/types.ts +68 -0
  18. package/src/config.ts +266 -0
  19. package/src/events.ts +61 -0
  20. package/src/exit-notify-gate.ts +69 -0
  21. package/src/exit-watchdog.ts +60 -0
  22. package/src/file-tail.ts +48 -0
  23. package/src/format.ts +276 -0
  24. package/src/index.ts +700 -0
  25. package/src/manager-client.ts +839 -0
  26. package/src/monitor-batching.ts +191 -0
  27. package/src/monitor.ts +517 -0
  28. package/src/native-manager.d.ts +9 -0
  29. package/src/native-manager.js +39 -0
  30. package/src/notify.ts +231 -0
  31. package/src/shell-exec.ts +145 -0
  32. package/src/subagent/agent-records.ts +168 -0
  33. package/src/subagent/child-bash.ts +183 -0
  34. package/src/subagent/conversation.ts +76 -0
  35. package/src/subagent/fleet-widget.ts +176 -0
  36. package/src/subagent/model-spec.ts +108 -0
  37. package/src/subagent/pi-runtime.ts +295 -0
  38. package/src/subagent/pool.ts +190 -0
  39. package/src/subagent/registry.ts +575 -0
  40. package/src/subagent/runner.ts +709 -0
  41. package/src/subagent/tool.ts +741 -0
  42. package/src/subagent/transcript.ts +71 -0
  43. package/src/subagent/types.ts +125 -0
  44. package/src/task-exit-notify.ts +21 -0
  45. package/src/task-tools.ts +330 -0
  46. package/src/tui/message-renderers.ts +183 -0
  47. package/src/tui/pi-tui-load.ts +186 -0
  48. package/src/tui/scroll-detail-view.ts +190 -0
  49. package/src/tui/task-output-paths.ts +46 -0
  50. package/src/tui/tasks-command.ts +515 -0
  51. package/src/tui/tool-component.ts +29 -0
  52. package/src/wake.ts +219 -0
  53. package/src/work-index.ts +182 -0
package/src/index.ts ADDED
@@ -0,0 +1,700 @@
1
+ /**
2
+ * pi-famulus extension entry point (design doc §4).
3
+ *
4
+ * M1: bash override (auto-backgrounding) + task_* tools + manager client.
5
+ * M2: NotifyCenter + monitor tool.
6
+ * M3: subagent tool (InProcessRunner + tasks/chain + budget-to-async) + fleet widget.
7
+ */
8
+ import { applyBehaviorGuidelines } from "./behavior-guidelines";
9
+ import { realClock } from "./clock";
10
+ import { ExitNotifyGate } from "./exit-notify-gate";
11
+ import { ExitWatchdog } from "./exit-watchdog";
12
+ import { createExtensionEventLog } from "./events";
13
+ import { readFileTail } from "./file-tail";
14
+ import { readdir, readFile, stat } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
18
+ import { resolveAgent as resolveAgentDef } from "./agents/definition";
19
+ import { createAgentLoader, type AgentLoader } from "./agents/loader";
20
+ import { createBashOverride } from "./bash-override";
21
+ import { createComms, type CommsWithOrigin } from "./comms/comms";
22
+ import { createRegistryCommsHost } from "./comms/registry-host";
23
+ import { createAgentMessageTool, createContactSupervisorTool } from "./comms/tools";
24
+ import { registerReplyCommand } from "./comms/reply-command";
25
+ import { describeManagerSearch, getFamulusHome, loadConfig, resolveManagerPath, resolveSubagentConfig } from "./config";
26
+ import type { TaskExitInfo } from "./format";
27
+ import { ManagerClient, type ManagerEvent, type TaskRecord } from "./manager-client";
28
+ import { createMonitorTool, exitEventFromRecord, MonitorRegistry } from "./monitor";
29
+ import { NotifyCenter } from "./notify";
30
+ import { createChildBashTool } from "./subagent/child-bash";
31
+ import {
32
+ agentEndReason,
33
+ headOf,
34
+ tailOf,
35
+ writeAgentChildRecord,
36
+ type AgentChildRecord,
37
+ } from "./subagent/agent-records";
38
+ import { TranscriptWriter } from "./subagent/transcript";
39
+ import { FleetWidget } from "./subagent/fleet-widget";
40
+ import { WorkIndex, type WorkItem } from "./work-index";
41
+ import { createPiSessionFn, modelCandidates } from "./subagent/pi-runtime";
42
+ import { SubagentRegistry } from "./subagent/registry";
43
+ import { InProcessRunner } from "./subagent/runner";
44
+ import { createSubagentTool } from "./subagent/tool";
45
+ import { createTaskListTool, createTaskOutputTool, createTaskStopTool } from "./task-tools";
46
+ import { registerFamulusMessageRenderers } from "./tui/message-renderers";
47
+ import { registerTasksCommand } from "./tui/tasks-command";
48
+ import { stderrPathFor } from "./tui/task-output-paths";
49
+ import { shellWakeTitle } from "./wake";
50
+
51
+ /** Read only the tail of a task output file for the notification preview (≤ maxChars). */
52
+ export function readPreview(outputPath: string | undefined, maxChars: number): string {
53
+ if (!outputPath) return "";
54
+ const tail = readFileTail(outputPath, Math.max(maxChars, maxChars * 4));
55
+ if (!tail || !tail.text) return "";
56
+ return tail.text.length <= maxChars ? tail.text : tail.text.slice(-maxChars);
57
+ }
58
+
59
+ function toExitStatus(event: ManagerEvent): TaskExitInfo["status"] {
60
+ // The manager died without shutting down; its runner took the task down.
61
+ if (event.end_reason === "manager-crash") return "orphaned";
62
+ if (event.signal) return "killed";
63
+ if (event.exit_code === 0) return "completed";
64
+ return "failed";
65
+ }
66
+
67
+ export default function (pi: ExtensionAPI): void {
68
+ const home = getFamulusHome();
69
+ const config = loadConfig(home);
70
+ const managerPath = resolveManagerPath(config, home);
71
+ const clock = realClock;
72
+
73
+ // Claude-style transcript pills for notifications (TUI only; no-ops elsewhere).
74
+ registerFamulusMessageRenderers(pi);
75
+
76
+ let ctx: ExtensionContext | null = null;
77
+ let client: ManagerClient | null = null;
78
+ let notifyCenter: NotifyCenter | null = null;
79
+ let monitorRegistry: MonitorRegistry | null = null;
80
+ let subagentRegistry: SubagentRegistry | null = null;
81
+ let fleetWidget: FleetWidget | null = null;
82
+ let agentLoader: AgentLoader | null = null;
83
+ /** task_id -> metadata, for notifications and the original manager task start time. */
84
+ const taskMeta = new Map<string, { kind: string; command: string; cwd?: string; startedAt?: number }>();
85
+ /**
86
+ * task_ids whose task_exited should wake the parent via <pi-famulus-wake kind="task">.
87
+ * Parent bash only adds ids when it actually backgrounded the command.
88
+ * Child-bash (sync wait) must not — otherwise every subagent shell completion
89
+ * is mis-labeled as a parent "Background command" wake (§4.2 / §4.6).
90
+ */
91
+ const notifyOnExit = new Set<string>();
92
+ const exitGate = new ExitNotifyGate<ManagerEvent>({ clock });
93
+ const workIndex = new WorkIndex({ clock });
94
+ const patchExited = (taskId: string, event: ManagerEvent): void => {
95
+ workIndex.patch(taskId, {
96
+ status: toExitStatus(event),
97
+ endedAt: clock.now(),
98
+ exitCode: event.exit_code ?? null,
99
+ ...(event.signal ? { signal: event.signal } : {}),
100
+ ...(event.end_reason ? { endReason: event.end_reason } : {}),
101
+ ...(event.output_path
102
+ ? { outputPath: event.output_path, stderrPath: stderrPathFor(event.output_path) }
103
+ : {}),
104
+ });
105
+ };
106
+ /**
107
+ * Is the daemon process alive? The pid file can be stale (pid reuse), so
108
+ * this errs towards "alive": a false positive only delays the settle, a
109
+ * false negative would wake the agent while its command still runs.
110
+ */
111
+ const isDaemonAlive = async (): Promise<boolean> => {
112
+ try {
113
+ const pf = JSON.parse(await readFile(join(home, "manager.pid"), "utf8")) as { pid?: unknown };
114
+ if (typeof pf.pid !== "number" || pf.pid <= 0) return false;
115
+ try {
116
+ process.kill(pf.pid, 0);
117
+ return true;
118
+ } catch (e) {
119
+ return (e as NodeJS.ErrnoException).code === "EPERM"; // alive, not ours
120
+ }
121
+ } catch {
122
+ return false; // no pid file / unparseable: no daemon
123
+ }
124
+ };
125
+ /**
126
+ * This session's task records, straight from disk: the fallback for when
127
+ * the manager is unavailable. A record still "running" whose daemon is
128
+ * dead is what the next daemon start marks orphaned (manager-crash), so
129
+ * it settles here; a "running" record with a live daemon is left alone.
130
+ */
131
+ const loadDiskTasks = async (): Promise<TaskRecord[] | null> => {
132
+ const sid = ctx?.sessionManager.getSessionId();
133
+ if (!sid) return null;
134
+ const dir = join(home, "sessions", sid, "tasks");
135
+ let files: string[];
136
+ try {
137
+ files = await readdir(dir);
138
+ } catch {
139
+ return null;
140
+ }
141
+ const daemonAlive = await isDaemonAlive();
142
+ const out: TaskRecord[] = [];
143
+ for (const f of files) {
144
+ if (!f.endsWith(".json")) continue;
145
+ try {
146
+ const rec = JSON.parse(await readFile(join(dir, f), "utf8")) as TaskRecord;
147
+ // The persisted output_size lags the output file; the file is the truth.
148
+ try {
149
+ rec.output_size = Math.max(rec.output_size, (await stat(rec.output_path)).size);
150
+ } catch { /* output file gone */ }
151
+ if (rec.status === "running" && !daemonAlive) {
152
+ rec.status = "orphaned";
153
+ rec.end_reason = "manager-crash";
154
+ rec.ended_at ??= clock.now();
155
+ }
156
+ out.push(rec);
157
+ } catch { /* not a record */ }
158
+ }
159
+ return out;
160
+ };
161
+ /**
162
+ * Settle everything the manager reports as ended that we still show live:
163
+ * monitors in the registry and shell/monitor rows in the index. Runs when
164
+ * the user or model looks (task_list, /tasks), after task_stop, after a
165
+ * monitor timeout, and after a reconnect. A backgrounded command the model
166
+ * is waiting on gets its exit wake here: after a manager crash no
167
+ * task_exited ever comes (the task ended orphaned), and without the wake
168
+ * the model would wait forever.
169
+ */
170
+ const syncWithManager = async (): Promise<void> => {
171
+ const c = client;
172
+ let tasks: TaskRecord[];
173
+ if (c && c.isAvailable()) {
174
+ try {
175
+ tasks = await c.list(true);
176
+ } catch {
177
+ return;
178
+ }
179
+ } else {
180
+ // Unavailable (it crashed and every respawn attempt failed): settle
181
+ // from the records on disk instead of letting the model wait forever.
182
+ const disk = await loadDiskTasks();
183
+ if (!disk) return;
184
+ tasks = disk;
185
+ }
186
+ monitorRegistry?.reconcile(tasks);
187
+ for (const task of workIndex.staleLive(tasks)) {
188
+ const event = exitEventFromRecord(task);
189
+ // Route through the gate: if the real task_exited is processed after
190
+ // this (a stop-triggered exit written after the list response, a
191
+ // reconnect replay), the consumed mark stashes it instead of waking
192
+ // the agent a second time.
193
+ if (notifyOnExit.has(task.task_id) && exitGate.onExit(task.task_id, event, false) === "notify") {
194
+ deliverExit(task.task_id, event);
195
+ } else {
196
+ patchExited(task.task_id, event);
197
+ }
198
+ }
199
+ };
200
+ // Settle on our own while a wake is pending: the agent was told to end its
201
+ // turn, so nothing else may look before the wake is due.
202
+ const exitWatchdog = new ExitWatchdog({
203
+ clock,
204
+ hasPending: () => notifyOnExit.size > 0,
205
+ tick: async () => {
206
+ await client?.ensureAvailable().catch(() => false);
207
+ await syncWithManager();
208
+ },
209
+ });
210
+ const eventLog = createExtensionEventLog(home, () => ctx?.sessionManager.getSessionId() ?? "", clock);
211
+ const logEvent = (type: string, fields?: Record<string, unknown>) => eventLog.write(type, fields);
212
+
213
+ const trackTask = (taskId: string, meta: { kind: string; command: string; cwd?: string }) => {
214
+ taskMeta.set(taskId, { ...meta, startedAt: taskMeta.get(taskId)?.startedAt });
215
+ };
216
+ const upsertBackgroundTask = (taskId: string, startedAt: number) => {
217
+ const meta = taskMeta.get(taskId);
218
+ workIndex.upsert({
219
+ id: taskId,
220
+ kind: "shell",
221
+ status: "running",
222
+ title: meta?.command?.replace(/\s+/g, " ").trim() || taskId,
223
+ command: meta?.command,
224
+ cwd: meta?.cwd,
225
+ startedAt,
226
+ countsAsWorker: true,
227
+ });
228
+ };
229
+ const deliverExit = (taskId: string, event: ManagerEvent) => {
230
+ notifyOnExit.delete(taskId);
231
+ const meta = taskMeta.get(taskId);
232
+ taskMeta.delete(taskId);
233
+ workIndex.patch(taskId, {
234
+ status: toExitStatus(event),
235
+ endedAt: clock.now(),
236
+ exitCode: event.exit_code ?? null,
237
+ ...(event.signal ? { signal: event.signal } : {}),
238
+ ...(event.end_reason ? { endReason: event.end_reason } : {}),
239
+ ...(event.output_path
240
+ ? { outputPath: event.output_path, stderrPath: stderrPathFor(event.output_path) }
241
+ : {}),
242
+ });
243
+ notifyCenter?.notifyTaskExit({
244
+ taskId,
245
+ kind: meta?.kind ?? event.kind ?? "shell",
246
+ command: meta?.command ?? event.command ?? "",
247
+ status: toExitStatus(event),
248
+ exitCode: event.exit_code ?? null,
249
+ ...(typeof event.signal === "string" && event.signal ? { signal: event.signal } : {}),
250
+ durationMs: event.duration_ms ?? 0,
251
+ outputPath: event.output_path ?? "",
252
+ preview: readPreview(event.output_path, 4000),
253
+ });
254
+ };
255
+
256
+ const markNotifyOnExit = (taskId: string) => {
257
+ notifyOnExit.add(taskId);
258
+ exitWatchdog.arm();
259
+ void client?.markBackground(taskId).catch(() => {});
260
+ const prior = exitGate.mark(taskId);
261
+ if (prior) {
262
+ deliverExit(taskId, prior);
263
+ return;
264
+ }
265
+ const startedAt = taskMeta.get(taskId)?.startedAt;
266
+ if (startedAt !== undefined) {
267
+ upsertBackgroundTask(taskId, startedAt);
268
+ } else {
269
+ // If task_started was missed (e.g. reconnect), recover the daemon's start
270
+ // time rather than making this task appear younger than it is.
271
+ void client?.list(true).then((tasks) => {
272
+ const task = tasks.find((candidate) => candidate.task_id === taskId);
273
+ const recoveredStart = taskMeta.get(taskId)?.startedAt ?? task?.started_at;
274
+ if (recoveredStart !== undefined && notifyOnExit.has(taskId)) {
275
+ const current = taskMeta.get(taskId) ?? { kind: "shell", command: task?.command ?? "" };
276
+ taskMeta.set(taskId, { ...current, startedAt: recoveredStart });
277
+ upsertBackgroundTask(taskId, recoveredStart);
278
+ }
279
+ }).catch(() => {});
280
+ }
281
+ };
282
+
283
+ const sessionEnv = (c: ExtensionContext): Record<string, string> => ({
284
+ PI_SESSION_ID: c.sessionManager.getSessionId(),
285
+ PI_SESSION_FILE: c.sessionManager.getSessionFile() ?? "",
286
+ PI_PROVIDER: c.model?.provider ?? "",
287
+ PI_MODEL: c.model?.id ?? "",
288
+ PI_REASONING_LEVEL: c.thinkingLevel ?? "",
289
+ });
290
+
291
+ const deps = {
292
+ getClient: () => client,
293
+ config,
294
+ home,
295
+ sessionId: () => ctx?.sessionManager.getSessionId() ?? "",
296
+ sessionEnv,
297
+ trackTask,
298
+ markNotifyOnExit,
299
+ clock,
300
+ getRegistry: () => subagentRegistry,
301
+ getIndex: () => workIndex,
302
+ syncWithManager: () => syncWithManager(),
303
+ };
304
+
305
+ monitorRegistry = new MonitorRegistry({
306
+ getClient: () => client,
307
+ sessionEnv,
308
+ getNotifyCenter: () => notifyCenter,
309
+ trackTask,
310
+ clock,
311
+ logEvent,
312
+ toast: (message, type) => {
313
+ if (ctx?.hasUI) ctx.ui.notify(message, type);
314
+ },
315
+ onExited: (taskId, event) => patchExited(taskId, event),
316
+ afterStop: () => void syncWithManager(),
317
+ });
318
+
319
+ pi.registerTool(createBashOverride(deps));
320
+ pi.registerTool(createTaskListTool({ ...deps, getIndex: () => workIndex }));
321
+ pi.registerTool(createTaskOutputTool({ ...deps, getIndex: () => workIndex }));
322
+ pi.registerTool(
323
+ createTaskStopTool({
324
+ ...deps,
325
+ getIndex: () => workIndex,
326
+ noteStopRequested: (id) => monitorRegistry?.noteStopRequested(id),
327
+ }),
328
+ );
329
+ pi.registerTool(createMonitorTool(monitorRegistry));
330
+ registerTasksCommand(pi, {
331
+ getRegistry: () => subagentRegistry,
332
+ getIndex: () => workIndex,
333
+ getClient: () => client,
334
+ home,
335
+ sessionId: () => ctx?.sessionManager.getSessionId() ?? "",
336
+ clock,
337
+ syncWithManager: deps.syncWithManager,
338
+ });
339
+ monitorRegistry.onChange(() => {
340
+ for (const mon of monitorRegistry.listActive()) {
341
+ const existing = workIndex.get(mon.taskId);
342
+ // Never revive a row that already ended (an exit can beat registration).
343
+ if (existing && existing.status !== "running" && existing.status !== "pending") continue;
344
+ workIndex.upsert({
345
+ id: mon.taskId,
346
+ kind: "monitor",
347
+ status: "running",
348
+ title: mon.description,
349
+ command: taskMeta.get(mon.taskId)?.command,
350
+ cwd: taskMeta.get(mon.taskId)?.cwd,
351
+ startedAt: existing?.startedAt ?? mon.startedAt,
352
+ outputPath: existing?.outputPath,
353
+ stderrPath: existing?.stderrPath,
354
+ countsAsWorker: false,
355
+ });
356
+ }
357
+ });
358
+
359
+ // M3: subagent tool. The registry/runner are (re)built on every session_start;
360
+ // the tool resolves them lazily through getters.
361
+ const subagentConfig = resolveSubagentConfig(config);
362
+ // M4: comms host bridges the registry and the NotifyCenter. A single Comms
363
+ // instance lives at extension scope (mailbox is per-run namespaced; pending
364
+ // waiters are resolved via dispose() on session_shutdown).
365
+ const commsHost = createRegistryCommsHost({
366
+ getRegistry: () => subagentRegistry,
367
+ getNotifyCenter: () => notifyCenter,
368
+ });
369
+ const comms: CommsWithOrigin = createComms(commsHost, {
370
+ decisionTimeoutMs: subagentConfig.decisionTimeoutMs,
371
+ clock,
372
+ logEvent,
373
+ });
374
+ registerReplyCommand(pi, comms);
375
+ pi.registerTool(
376
+ createSubagentTool({
377
+ getRegistry: () => subagentRegistry,
378
+ getNotifyCenter: () => notifyCenter,
379
+ getIndex: () => workIndex,
380
+ budgetMs: () => subagentConfig.budgetMs,
381
+ defaultTimeoutMs: subagentConfig.timeoutMs,
382
+ defaultConcurrency: subagentConfig.concurrency,
383
+ clock,
384
+ resolveAgent: (name) => {
385
+ const loader = agentLoader;
386
+ if (!loader) return resolveAgentDef([], name); // builtins-only fallback
387
+ return resolveAgentDef(loader.reload().definitions, name);
388
+ },
389
+ listModels: () => {
390
+ const current = ctx?.model;
391
+ return modelCandidates({
392
+ getModelRegistry: () => ctx?.modelRegistry ?? null,
393
+ getScopedModels: () => ctx?.scopedModels ?? [],
394
+ }).map((c) => ({
395
+ ...c,
396
+ current: current !== undefined && c.provider === current.provider && c.id === current.id,
397
+ }));
398
+ },
399
+ }),
400
+ );
401
+ // M4: parent-side agent_message tool (child-side variant is injected into
402
+ // each child session via customTools, see below).
403
+ pi.registerTool(createAgentMessageTool(comms, { kind: "parent" }, commsHost, clock));
404
+
405
+ pi.on("session_start", async (_event, startCtx) => {
406
+ ctx = startCtx;
407
+ const base = (
408
+ startCtx as { getSystemPromptOptions?: () => { sections?: Record<string, string> } }
409
+ ).getSystemPromptOptions?.();
410
+ if (base) applyBehaviorGuidelines(base);
411
+ notifyCenter?.dispose();
412
+ notifyCenter = new NotifyCenter({
413
+ sendMessage: (msg, opts) => pi.sendMessage(msg, opts),
414
+ isIdle: () => ctx?.isIdle() ?? true,
415
+ clock,
416
+ logEvent,
417
+ listStillRunning: () =>
418
+ [...notifyOnExit].map((id) => {
419
+ const command = taskMeta.get(id)?.command;
420
+ const title = command ? shellWakeTitle(command) || id : id;
421
+ return { id, title };
422
+ }),
423
+ });
424
+ fleetWidget?.dispose();
425
+ fleetWidget = null;
426
+ subagentRegistry?.disposeAll();
427
+ subagentRegistry = null;
428
+
429
+ client = new ManagerClient({
430
+ home,
431
+ sessionId: startCtx.sessionManager.getSessionId(),
432
+ managerPath,
433
+ cwd: startCtx.cwd,
434
+ clock,
435
+ log: () => {}, // keep quiet; degradation is surfaced via tools
436
+ });
437
+
438
+ client.onEvent((event) => {
439
+ if (event.event === "task_started" && event.task_id) {
440
+ const prior = taskMeta.get(event.task_id);
441
+ taskMeta.set(event.task_id, {
442
+ kind: prior?.kind ?? event.kind ?? "shell",
443
+ command: prior?.command ?? event.command ?? "",
444
+ ...(typeof event.ts === "number"
445
+ ? { startedAt: event.ts }
446
+ : prior?.startedAt !== undefined
447
+ ? { startedAt: prior.startedAt }
448
+ : {}),
449
+ });
450
+ if (notifyOnExit.has(event.task_id) && typeof event.ts === "number") {
451
+ upsertBackgroundTask(event.task_id, event.ts);
452
+ }
453
+ if (event.kind === "monitor" && !workIndex.get(event.task_id)) {
454
+ workIndex.upsert({
455
+ id: event.task_id,
456
+ kind: "monitor",
457
+ status: "running",
458
+ title: event.command || event.task_id,
459
+ command: event.command,
460
+ cwd: taskMeta.get(event.task_id)?.cwd,
461
+ startedAt: clock.now(),
462
+ countsAsWorker: false,
463
+ });
464
+ }
465
+ return;
466
+ }
467
+ if (event.event === "output" && event.task_id && typeof event.chunk === "string") {
468
+ monitorRegistry?.handleOutput(event.task_id, event.chunk, event.next_cursor);
469
+ return;
470
+ }
471
+ if (event.event === "task_exited" && event.task_id) {
472
+ // A known monitor closes (and patches the index via onExited); an
473
+ // unknown id is buffered in case a monitor start is about to claim it.
474
+ if (monitorRegistry?.handleExit(event.task_id, event)) return;
475
+ patchExited(event.task_id, event);
476
+ // Exit may share a socket read with wait done:false, before bash marks
477
+ // the id. Stash and fire on the late mark. Sync waits never mark.
478
+ if (exitGate.onExit(event.task_id, event, false) !== "notify") return;
479
+ deliverExit(event.task_id, event);
480
+ }
481
+ });
482
+ client.onReconnect(() => {
483
+ void monitorRegistry?.rewatchAll().then(() => syncWithManager());
484
+ });
485
+
486
+ // M3: subagent registry + in-process runner + fleet widget. The runner's
487
+ // per-generation admission goes through the registry (global concurrency
488
+ // cap); the session factory resolves models/cwd lazily from ctx.
489
+ const registry = new SubagentRegistry({
490
+ maxConcurrentChildren: subagentConfig.maxConcurrentChildren,
491
+ spawnBudgetPerHour: subagentConfig.spawnBudgetPerHour,
492
+ clock,
493
+ });
494
+ // M5: agent definitions, reloaded lazily (mtime-cached) per subagent call.
495
+ agentLoader = createAgentLoader({
496
+ userDir: join(homedir(), ".pi", "agent", "agents"),
497
+ projectDir: join(startCtx.cwd, ".pi", "agents"),
498
+ });
499
+ const agentLoadErrors = agentLoader.reload().errors;
500
+ if (agentLoadErrors.length > 0 && startCtx.hasUI) {
501
+ startCtx.ui.notify(
502
+ `pi-famulus: skipped ${agentLoadErrors.length} invalid agent definition(s): ` +
503
+ agentLoadErrors.map((e) => `${e.path} (${e.error})`).join("; "),
504
+ "warning",
505
+ );
506
+ }
507
+ const createSession = createPiSessionFn({
508
+ getModelRegistry: () => ctx?.modelRegistry ?? null,
509
+ getModelRuntime: () => {
510
+ const registry = ctx?.modelRegistry as { runtime?: unknown } | null | undefined;
511
+ return registry?.runtime;
512
+ },
513
+ getParentModel: () => ctx?.model,
514
+ getParentThinkingLevel: () => ctx?.thinkingLevel,
515
+ getScopedModels: () => ctx?.scopedModels ?? [],
516
+ getCwd: () => ctx?.cwd ?? process.cwd(),
517
+ customTools: (req) => {
518
+ const tools: Array<ToolDefinition<any, any, any>> = [
519
+ // M4: every child can reach the supervisor and its siblings.
520
+ createContactSupervisorTool(comms, req.childId),
521
+ createAgentMessageTool(comms, { kind: "child", childId: req.childId, runId: req.runId }, commsHost, clock),
522
+ ];
523
+ // The no-background bash variant replaces the built-in bash inside
524
+ // child sessions (custom tools override builtins by name).
525
+ if (req.agent.tools.includes("bash")) {
526
+ tools.push(
527
+ createChildBashTool({
528
+ getClient: () => client,
529
+ home,
530
+ sessionId: () => ctx?.sessionManager.getSessionId() ?? "",
531
+ sessionEnv: () => (ctx ? sessionEnv(ctx) : {}),
532
+ trackTask,
533
+ childId: req.childId,
534
+ runId: req.runId,
535
+ clock,
536
+ }),
537
+ );
538
+ }
539
+ return tools;
540
+ },
541
+ });
542
+ const transcripts = new TranscriptWriter(home, clock);
543
+ const syncTranscript = (childId: string): string | undefined => {
544
+ const handle = registry.handle(childId);
545
+ if (!handle) return undefined;
546
+ return transcripts.sync(startCtx.sessionManager.getSessionId(), childId, handle.conversation());
547
+ };
548
+ const runner = new InProcessRunner({
549
+ createSession,
550
+ clock,
551
+ stallMs: subagentConfig.stallMs,
552
+ stallRetries: subagentConfig.stallRetries,
553
+ stallRetryDelayMs: subagentConfig.stallRetryDelayMs,
554
+ acquire: (req) => registry.admitChild(req.childId),
555
+ onActivity: (childId) => {
556
+ syncTranscript(childId);
557
+ },
558
+ onStall: (childId, attempt) => {
559
+ // One event per stall detection: an auto-resume follows unless the
560
+ // retry budget (stallRetries) is already spent.
561
+ logEvent("agent.stall", { child_id: childId, attempt });
562
+ },
563
+ });
564
+ registry.setRunner(runner);
565
+ subagentRegistry = registry;
566
+ // Persist child records so `pi-famulus ls` / task_list can see in-process agents.
567
+ const sessionIdForAgents = () => startCtx.sessionManager.getSessionId();
568
+ const previousAgentStatus = new Map<string, string>();
569
+ registry.onTransition((run) => {
570
+ const sid = sessionIdForAgents();
571
+ for (const c of run.children) {
572
+ const previousStatus = previousAgentStatus.get(c.childId);
573
+ if (c.status === "running" && previousStatus !== "running") {
574
+ logEvent("agent.start", {
575
+ child_id: c.childId,
576
+ run_id: run.runId,
577
+ name: c.name,
578
+ agent: c.agent,
579
+ ...(c.model ? { model: c.model } : {}),
580
+ });
581
+ }
582
+ if (["completed", "failed", "interrupted"].includes(c.status) && previousStatus !== c.status) {
583
+ const error = c.result?.error;
584
+ if (error === "timeout") logEvent("agent.timeout", { child_id: c.childId });
585
+ logEvent("agent.settle", {
586
+ child_id: c.childId,
587
+ status: c.status,
588
+ ...(error ? { error } : {}),
589
+ ...(c.result?.stalls ? { stalls: c.result.stalls } : {}),
590
+ duration_ms: c.result?.durationMs ?? Math.max(0, clock.now() - c.startedAt),
591
+ });
592
+ }
593
+ previousAgentStatus.set(c.childId, c.status);
594
+ const rec: AgentChildRecord = {
595
+ v: 1,
596
+ kind: "agent",
597
+ child_id: c.childId,
598
+ run_id: run.runId,
599
+ session_id: sid,
600
+ name: c.name,
601
+ agent: c.agent,
602
+ ...(c.model !== undefined ? { model: c.model } : {}),
603
+ status: c.status,
604
+ started_at: c.startedAt,
605
+ ...(c.endedAt !== undefined ? { ended_at: c.endedAt } : {}),
606
+ ...(c.result?.error ? { error: c.result.error } : {}),
607
+ ...(c.result?.attempts !== undefined && c.result.attempts > 1
608
+ ? { attempts: c.result.attempts }
609
+ : {}),
610
+ ...(c.result?.stalls ? { stalls: c.result.stalls } : {}),
611
+ ...(c.prompt !== undefined ? { prompt_head: headOf(c.prompt) } : {}),
612
+ ...(c.result?.text ? { result_tail: tailOf(c.result.text) } : {}),
613
+ };
614
+ const endReason = agentEndReason(c.status, c.result);
615
+ if (endReason) rec.end_reason = endReason;
616
+ const transcript = syncTranscript(c.childId);
617
+ if (transcript) {
618
+ rec.transcript = transcript;
619
+ rec.tool_calls = transcripts.toolCallCount(c.childId);
620
+ }
621
+ writeAgentChildRecord(home, rec);
622
+ workIndex.upsert({
623
+ id: c.childId,
624
+ kind: "agent",
625
+ status: c.status,
626
+ title: `${c.name} (${c.agent})${c.model ? ` ${c.model}` : ""}`,
627
+ startedAt: c.startedAt,
628
+ ...(c.endedAt !== undefined ? { endedAt: c.endedAt } : {}),
629
+ countsAsWorker: false,
630
+ runId: run.runId,
631
+ name: c.name,
632
+ agent: c.agent,
633
+ ...(c.model !== undefined ? { model: c.model } : {}),
634
+ cwd: startCtx.cwd,
635
+ ...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
636
+ ...(c.preamble !== undefined ? { preamble: c.preamble } : {}),
637
+ ...(c.result?.text ? { text: c.result.text } : {}),
638
+ ...(c.result?.error ? { error: c.result.error } : {}),
639
+ });
640
+ }
641
+ });
642
+ if (startCtx.hasUI) {
643
+ fleetWidget = new FleetWidget({
644
+ index: workIndex,
645
+ getUi: () => (ctx?.hasUI ? (ctx.ui as never) : null),
646
+ clock,
647
+ });
648
+ fleetWidget.start();
649
+ }
650
+
651
+ // Connect in the background: a cold manager spawn must not stall the
652
+ // first prompt. Tools call ensureAvailable() lazily before use.
653
+ const c = client;
654
+ void c
655
+ .connect()
656
+ .then((ok) => {
657
+ if (!ok && startCtx.hasUI) {
658
+ const detail = c.lastError();
659
+ const reason = detail ? ` (${detail})` : "";
660
+ const searched = managerPath ? `using ${managerPath}` : `looked in: ${describeManagerSearch(config, home)}`;
661
+ startCtx.ui.notify(
662
+ `pi-famulus unavailable${reason}; ${searched}. ` +
663
+ "Bash runs locally without auto-backgrounding, task_*/monitor are disabled, " +
664
+ "and subagents cannot run bash. Install it or set PI_FAMULUS_MANAGER_PATH (see README Install).",
665
+ "warning",
666
+ );
667
+ }
668
+ })
669
+ .catch(() => {});
670
+ });
671
+
672
+ pi.on("session_shutdown", async () => {
673
+ exitWatchdog.dispose();
674
+ fleetWidget?.dispose();
675
+ fleetWidget = null;
676
+ subagentRegistry?.disposeAll();
677
+ subagentRegistry = null;
678
+ agentLoader = null;
679
+ comms.dispose(); // resolve orphaned need_decision waiters
680
+ monitorRegistry?.disposeAll();
681
+ notifyCenter?.dispose();
682
+ const current = client;
683
+ client = null;
684
+ if (current) {
685
+ if (current.isAvailable()) {
686
+ await current.shutdownSession().catch(() => {});
687
+ }
688
+ await current.close();
689
+ }
690
+ ctx = null;
691
+ });
692
+
693
+ pi.on("agent_settled", async () => {
694
+ notifyCenter?.settled();
695
+ });
696
+
697
+ pi.on("before_agent_start", async (event) => {
698
+ applyBehaviorGuidelines(event.systemPromptOptions as { sections?: Record<string, string> });
699
+ });
700
+ }