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
@@ -0,0 +1,575 @@
1
+ /**
2
+ * RunRegistry (design doc Appendix B) plus the M3 runtime limits (§4.6):
3
+ *
4
+ * - run/child id generation ("run_xxxxxxxx" / "ch_xxxxxxxx", crypto random);
5
+ * - child status transitions with onTransition callbacks (fleet widget,
6
+ * notifications);
7
+ * - global concurrency admission (default 8 across all runs; excess children
8
+ * queue instead of being rejected);
9
+ * - session spawn budget (default 32 child sessions per hour);
10
+ * - lineage: v1 = same runId.
11
+ *
12
+ * Wiring contract: the runner must be constructed with
13
+ * `acquire: (req) => registry.admitChild(req.childId)` so every generation
14
+ * (initial start and resume) passes through admission. startChild() wires the
15
+ * first generation's result; admitChild() wires subsequent generations.
16
+ *
17
+ * Zero pi dependency.
18
+ */
19
+ import { randomBytes } from "node:crypto";
20
+ import { realClock, type Clock } from "../clock";
21
+ import type {
22
+ ChildHandle,
23
+ ChildResult,
24
+ ChildRunRequest,
25
+ ChildRunner,
26
+ ChildStatus,
27
+ DisposableChildHandle,
28
+ } from "./types";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Appendix B public record shape
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export interface RunRecord {
35
+ runId: string;
36
+ kind: "tasks" | "chain";
37
+ children: {
38
+ childId: string;
39
+ name: string;
40
+ agent: string;
41
+ model?: string;
42
+ status: ChildStatus;
43
+ /** User-authored prompt (after chain interpolation), without injected preamble. */
44
+ prompt?: string;
45
+ /** Agent-authored instructions prepended to the task prompt. */
46
+ preamble?: string;
47
+ result?: ChildResult;
48
+ startedAt: number;
49
+ endedAt?: number;
50
+ }[];
51
+ status: "running" | "completed" | "partial" | "failed" | "interrupted";
52
+ createdAt: number;
53
+ }
54
+
55
+ export interface RunRegistry {
56
+ createRun(kind: RunRecord["kind"]): RunRecord;
57
+ get(runId: string): RunRecord | undefined;
58
+ list(): RunRecord[];
59
+ handle(childId: string): ChildHandle | undefined; // active and finished (not disposed)
60
+ findChild(runId: string, childIdOrName: string): ChildHandle | undefined;
61
+ lineage(runIdA: string, runIdB: string): boolean; // v1: same runId
62
+ onTransition(cb: (run: RunRecord) => void): void; // fleet widget / notifications
63
+ disposeRun(runId: string): void;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Registry implementation
68
+ // ---------------------------------------------------------------------------
69
+
70
+ export interface SubagentRegistryOptions {
71
+ /** Global cap on concurrently running children (default 8). */
72
+ maxConcurrentChildren?: number;
73
+ /** Max child sessions created per hour (default 32). */
74
+ spawnBudgetPerHour?: number;
75
+ /** Shared time source. */
76
+ clock?: Clock;
77
+ }
78
+
79
+ export interface StartChildOptions {
80
+ /**
81
+ * Re-checked after admission, just before the session is spawned. Returning
82
+ * false cancels the child as interrupted (used for fail_fast while queued).
83
+ * Cleared once the child reaches a terminal state.
84
+ */
85
+ shouldStart?: () => boolean;
86
+ }
87
+
88
+ export interface ActiveChildInfo {
89
+ childId: string;
90
+ runId: string;
91
+ name: string;
92
+ agent: string;
93
+ model?: string;
94
+ status: ChildStatus;
95
+ startedAt: number;
96
+ lastEventAt: number;
97
+ }
98
+
99
+ interface InternalChild {
100
+ childId: string;
101
+ runId: string;
102
+ name: string;
103
+ agent: string;
104
+ /** Best-effort model id for fleet / ls (set when startChild runs). */
105
+ model?: string;
106
+ status: ChildStatus;
107
+ prompt?: string;
108
+ preamble?: string;
109
+ result?: ChildResult;
110
+ startedAt: number;
111
+ endedAt?: number;
112
+ handle?: ChildHandle;
113
+ shouldStart?: () => boolean;
114
+ }
115
+
116
+ interface InternalRun {
117
+ runId: string;
118
+ kind: "tasks" | "chain";
119
+ status: RunRecord["status"];
120
+ createdAt: number;
121
+ children: InternalChild[];
122
+ }
123
+
124
+ /** Error thrown by admission when a queued child is cancelled (fail_fast). */
125
+ export class ChildCancelledError extends Error {
126
+ constructor(message = "cancelled (fail_fast)") {
127
+ super(message);
128
+ this.name = "ChildCancelledError";
129
+ }
130
+ }
131
+
132
+ /** Synthetic handle for children that never got a session (e.g. spawn budget). */
133
+ class FailedChildHandle implements ChildHandle {
134
+ constructor(
135
+ readonly childId: string,
136
+ private readonly failure: ChildResult,
137
+ private readonly at: number,
138
+ ) {}
139
+
140
+ get result(): Promise<ChildResult> {
141
+ return Promise.resolve(this.failure);
142
+ }
143
+
144
+ private dead(): Error {
145
+ return new Error(`subagent ${this.childId} never started: ${this.failure.error ?? "failed"}`);
146
+ }
147
+
148
+ steer(): Promise<void> {
149
+ return Promise.reject(this.dead());
150
+ }
151
+
152
+ followUp(): Promise<void> {
153
+ return Promise.reject(this.dead());
154
+ }
155
+
156
+ resume(): Promise<void> {
157
+ return Promise.reject(this.dead());
158
+ }
159
+
160
+ interrupt(): Promise<void> {
161
+ return Promise.resolve();
162
+ }
163
+
164
+ status(): ChildStatus {
165
+ return "failed";
166
+ }
167
+
168
+ lastEventAt(): number {
169
+ return this.at;
170
+ }
171
+
172
+ resolvedModel(): string | undefined {
173
+ return undefined;
174
+ }
175
+
176
+ conversation() {
177
+ return [];
178
+ }
179
+ }
180
+
181
+ export class SubagentRegistry implements RunRegistry {
182
+ private readonly maxChildren: number;
183
+ private readonly spawnBudget: number;
184
+ private readonly clock: Clock;
185
+
186
+ private runner: ChildRunner | null = null;
187
+ private readonly runs = new Map<string, InternalRun>();
188
+ private readonly children = new Map<string, InternalChild>();
189
+ private readonly transitionCbs = new Set<(run: RunRecord) => void>();
190
+ private activeSlots = 0;
191
+ private readonly slotWaiters: (() => void)[] = [];
192
+ private spawnTimes: number[] = [];
193
+
194
+ constructor(opts: SubagentRegistryOptions = {}) {
195
+ this.maxChildren = opts.maxConcurrentChildren ?? 8;
196
+ this.spawnBudget = opts.spawnBudgetPerHour ?? 32;
197
+ this.clock = opts.clock ?? realClock;
198
+ }
199
+
200
+ private now(): number {
201
+ return this.clock.now();
202
+ }
203
+
204
+ /** Late-bound to break the registry <-> runner construction cycle. */
205
+ setRunner(runner: ChildRunner): void {
206
+ this.runner = runner;
207
+ }
208
+
209
+ // -------------------------------------------------------------------------
210
+ // RunRegistry interface
211
+ // -------------------------------------------------------------------------
212
+
213
+ createRun(kind: RunRecord["kind"]): RunRecord {
214
+ const run: InternalRun = {
215
+ runId: this.newId("run_"),
216
+ kind,
217
+ status: "running",
218
+ createdAt: this.now(),
219
+ children: [],
220
+ };
221
+ this.runs.set(run.runId, run);
222
+ this.emit(run);
223
+ return snapshot(run);
224
+ }
225
+
226
+ get(runId: string): RunRecord | undefined {
227
+ const run = this.runs.get(runId);
228
+ return run ? snapshot(run) : undefined;
229
+ }
230
+
231
+ list(): RunRecord[] {
232
+ return [...this.runs.values()].map(snapshot);
233
+ }
234
+
235
+ handle(childId: string): ChildHandle | undefined {
236
+ return this.children.get(childId)?.handle;
237
+ }
238
+
239
+ findChild(runId: string, childIdOrName: string): ChildHandle | undefined {
240
+ const run = this.runs.get(runId);
241
+ if (!run) return undefined;
242
+ const child =
243
+ run.children.find((c) => c.childId === childIdOrName) ??
244
+ run.children.find((c) => c.name === childIdOrName);
245
+ return child?.handle;
246
+ }
247
+
248
+ lineage(runIdA: string, runIdB: string): boolean {
249
+ return runIdA === runIdB;
250
+ }
251
+
252
+ onTransition(cb: (run: RunRecord) => void): void {
253
+ this.transitionCbs.add(cb);
254
+ }
255
+
256
+ disposeRun(runId: string): void {
257
+ const run = this.runs.get(runId);
258
+ if (!run) return;
259
+ const now = this.now();
260
+ // Settle while children are still in the map. interrupt() calls settleChild,
261
+ // which no-ops once the child has been deleted — that used to emit a final
262
+ // "running" record (ghost agents in ls).
263
+ for (const child of run.children) {
264
+ if (child.status === "pending" || child.status === "running") {
265
+ child.status = "interrupted";
266
+ child.endedAt = now;
267
+ child.result = {
268
+ status: "interrupted",
269
+ text: child.result?.text ?? "",
270
+ error: "disposed",
271
+ durationMs: Math.max(0, now - child.startedAt),
272
+ };
273
+ }
274
+ }
275
+ this.recomputeRunStatus(run);
276
+ this.emit(run);
277
+ this.runs.delete(runId);
278
+ for (const child of run.children) {
279
+ this.children.delete(child.childId);
280
+ const handle = child.handle as DisposableChildHandle | undefined;
281
+ if (!handle) continue;
282
+ void handle.interrupt().catch(() => {});
283
+ try {
284
+ handle.dispose?.();
285
+ } catch {
286
+ // ignore
287
+ }
288
+ }
289
+ }
290
+
291
+ // -------------------------------------------------------------------------
292
+ // M3 extensions beyond Appendix B
293
+ // -------------------------------------------------------------------------
294
+
295
+ /** Allocate a pending child inside a run. Returns the child id. */
296
+ addChild(runId: string, info: { name: string; agent: string }): string {
297
+ const run = this.runs.get(runId);
298
+ if (!run) throw new Error(`unknown run ${runId}`);
299
+ const child: InternalChild = {
300
+ childId: this.newId("ch_"),
301
+ runId,
302
+ name: info.name,
303
+ agent: info.agent,
304
+ status: "pending",
305
+ startedAt: this.now(),
306
+ };
307
+ run.children.push(child);
308
+ this.children.set(child.childId, child);
309
+ this.emit(run);
310
+ return child.childId;
311
+ }
312
+
313
+ /**
314
+ * Start a previously added child: consume spawn budget, launch via the
315
+ * runner (which passes through admission), and wire status transitions.
316
+ * Never throws for expected failures — they are recorded as child results.
317
+ */
318
+ async startChild(req: ChildRunRequest, opts?: StartChildOptions): Promise<ChildHandle> {
319
+ const child = this.children.get(req.childId);
320
+ if (!child) throw new Error(`unknown child ${req.childId} (addChild first)`);
321
+ child.shouldStart = opts?.shouldStart;
322
+ child.prompt = req.taskPrompt ?? req.prompt;
323
+ child.preamble = req.agent.systemPrompt || undefined;
324
+ child.model = req.model ?? req.agent.model;
325
+
326
+ const runner = this.runner;
327
+ if (!runner) {
328
+ return this.failWithoutSession(child, "subagent runner is not initialized");
329
+ }
330
+ try {
331
+ this.consumeSpawnBudget();
332
+ } catch (err) {
333
+ return this.failWithoutSession(child, (err as Error).message);
334
+ }
335
+
336
+ const handle = await runner.start(req);
337
+ child.handle = handle;
338
+ // Prefer the actually resolved provider/id over the request-time spec
339
+ // (inherits parent model when neither param nor agent.model is set).
340
+ const resolved = handle.resolvedModel();
341
+ if (resolved) child.model = resolved;
342
+ // Generation-1 result wiring (later generations are wired in admitChild,
343
+ // where the handle is already visible).
344
+ handle.result.then((result) => this.settleChild(child.childId, result));
345
+ // Reflect "running" when the runner had no admission hook wired; terminal
346
+ // states are left to the result wiring above so the result is recorded.
347
+ if (handle.status() === "running" && child.status === "pending") {
348
+ this.transitionChild(child, "running");
349
+ } else if (resolved) {
350
+ // Model became known after session create — refresh observers/disk.
351
+ const run = this.runs.get(child.runId);
352
+ if (run) this.emit(run);
353
+ }
354
+ return handle;
355
+ }
356
+
357
+ /**
358
+ * Admission hook invoked by the runner once per generation. Waits for a
359
+ * global concurrency slot, re-checks shouldStart, marks the child running,
360
+ * and wires result settlement for resumed generations. Returns the slot
361
+ * releaser.
362
+ */
363
+ async admitChild(childId: string): Promise<() => void> {
364
+ await this.acquireSlot();
365
+ let released = false;
366
+ const release = () => {
367
+ if (released) return;
368
+ released = true;
369
+ this.releaseSlot();
370
+ };
371
+ const child = this.children.get(childId);
372
+ if (!child) {
373
+ release();
374
+ throw new Error(`unknown child ${childId}`);
375
+ }
376
+ if (child.shouldStart && !child.shouldStart()) {
377
+ release();
378
+ throw new ChildCancelledError();
379
+ }
380
+ this.transitionChild(child, "running");
381
+ const handle = child.handle;
382
+ if (handle) {
383
+ // Resume generations: the handle (and its fresh result promise) already
384
+ // exists, so wire settlement here. Generation 1 is wired by startChild.
385
+ handle.result.then((result) => this.settleChild(childId, result));
386
+ }
387
+ return release;
388
+ }
389
+
390
+ /** Current generation's result promise for a child (post-resume aware). */
391
+ getResult(childId: string): Promise<ChildResult> | undefined {
392
+ return this.children.get(childId)?.handle?.result;
393
+ }
394
+
395
+ /**
396
+ * After a pool/chain finishes, mark children that were never submitted
397
+ * (still pending, no handle) as interrupted. Submitted-but-queued children
398
+ * settle through their own handle wiring.
399
+ */
400
+ finalizeRun(runId: string, error: string): void {
401
+ const run = this.runs.get(runId);
402
+ if (!run) return;
403
+ let changed = false;
404
+ for (const child of run.children) {
405
+ if (child.status === "pending" && !child.handle) {
406
+ child.status = "interrupted";
407
+ child.result = { status: "interrupted", text: "", error, durationMs: 0 };
408
+ child.endedAt = this.now();
409
+ changed = true;
410
+ }
411
+ }
412
+ if (changed) {
413
+ this.recomputeRunStatus(run);
414
+ this.emit(run);
415
+ }
416
+ }
417
+
418
+ /** Active (pending or running) children across all runs, oldest first. */
419
+ activeChildren(): ActiveChildInfo[] {
420
+ const out: ActiveChildInfo[] = [];
421
+ for (const child of this.children.values()) {
422
+ if (child.status !== "pending" && child.status !== "running") continue;
423
+ out.push({
424
+ childId: child.childId,
425
+ runId: child.runId,
426
+ name: child.name,
427
+ agent: child.agent,
428
+ model: child.model,
429
+ status: child.status,
430
+ startedAt: child.startedAt,
431
+ lastEventAt: child.handle?.lastEventAt() ?? child.startedAt,
432
+ });
433
+ }
434
+ out.sort((a, b) => a.startedAt - b.startedAt);
435
+ return out;
436
+ }
437
+
438
+ /** Dispose every run (session shutdown). */
439
+ disposeAll(): void {
440
+ for (const runId of [...this.runs.keys()]) {
441
+ this.disposeRun(runId);
442
+ }
443
+ }
444
+
445
+ // -------------------------------------------------------------------------
446
+ // Internals
447
+ // -------------------------------------------------------------------------
448
+
449
+ private failWithoutSession(child: InternalChild, error: string): ChildHandle {
450
+ const result: ChildResult = { status: "failed", text: "", error, durationMs: 0 };
451
+ const handle = new FailedChildHandle(child.childId, result, this.now());
452
+ child.handle = handle;
453
+ this.settleChild(child.childId, result);
454
+ return handle;
455
+ }
456
+
457
+ private settleChild(childId: string, result: ChildResult): void {
458
+ const child = this.children.get(childId);
459
+ if (!child) return;
460
+ if (child.status !== "pending" && child.status !== "running") return; // already terminal
461
+ child.status = result.status;
462
+ child.result = result;
463
+ child.endedAt = this.now();
464
+ child.shouldStart = undefined;
465
+ const run = this.runs.get(child.runId);
466
+ if (run) {
467
+ this.recomputeRunStatus(run);
468
+ this.emit(run);
469
+ }
470
+ }
471
+
472
+ private transitionChild(child: InternalChild, status: ChildStatus): void {
473
+ if (child.status === status) return;
474
+ child.status = status;
475
+ if (status === "running") {
476
+ // (Re-)start: elapsed time and any previous generation's result reset.
477
+ child.startedAt = this.now();
478
+ child.result = undefined;
479
+ child.endedAt = undefined;
480
+ }
481
+ const run = this.runs.get(child.runId);
482
+ if (run) {
483
+ this.recomputeRunStatus(run);
484
+ this.emit(run);
485
+ }
486
+ }
487
+
488
+ private recomputeRunStatus(run: InternalRun): void {
489
+ if (run.children.length === 0) {
490
+ run.status = "running";
491
+ return;
492
+ }
493
+ const terminal = (s: ChildStatus) => s === "completed" || s === "failed" || s === "interrupted";
494
+ if (run.children.some((c) => !terminal(c.status))) {
495
+ run.status = "running";
496
+ return;
497
+ }
498
+ const statuses = run.children.map((c) => c.status);
499
+ if (statuses.every((s) => s === "completed")) run.status = "completed";
500
+ else if (statuses.every((s) => s === "interrupted")) run.status = "interrupted";
501
+ else if (statuses.every((s) => s === "failed")) run.status = "failed";
502
+ else run.status = "partial";
503
+ }
504
+
505
+ private emit(run: InternalRun): void {
506
+ if (this.transitionCbs.size === 0) return;
507
+ const record = snapshot(run);
508
+ for (const cb of this.transitionCbs) {
509
+ try {
510
+ cb(record);
511
+ } catch {
512
+ // observer errors must not break the registry
513
+ }
514
+ }
515
+ }
516
+
517
+ private newId(prefix: string): string {
518
+ for (;;) {
519
+ const id = prefix + randomBytes(4).toString("hex");
520
+ if (!this.runs.has(id) && !this.children.has(id)) return id;
521
+ }
522
+ }
523
+
524
+ private consumeSpawnBudget(): void {
525
+ const cutoff = this.now() - 3_600_000;
526
+ this.spawnTimes = this.spawnTimes.filter((t) => t > cutoff);
527
+ if (this.spawnTimes.length >= this.spawnBudget) {
528
+ throw new Error(
529
+ `subagent spawn budget exhausted (${this.spawnBudget} sessions per hour); try again later`,
530
+ );
531
+ }
532
+ this.spawnTimes.push(this.now());
533
+ }
534
+
535
+ private acquireSlot(): Promise<void> {
536
+ if (this.activeSlots < this.maxChildren) {
537
+ this.activeSlots++;
538
+ return Promise.resolve();
539
+ }
540
+ return new Promise((resolve) => {
541
+ this.slotWaiters.push(resolve);
542
+ });
543
+ }
544
+
545
+ private releaseSlot(): void {
546
+ const next = this.slotWaiters.shift();
547
+ if (next) {
548
+ // Hand the slot off directly; the count stays at the cap.
549
+ next();
550
+ } else {
551
+ this.activeSlots = Math.max(0, this.activeSlots - 1);
552
+ }
553
+ }
554
+ }
555
+
556
+ function snapshot(run: InternalRun): RunRecord {
557
+ return {
558
+ runId: run.runId,
559
+ kind: run.kind,
560
+ status: run.status,
561
+ createdAt: run.createdAt,
562
+ children: run.children.map((c) => ({
563
+ childId: c.childId,
564
+ name: c.name,
565
+ agent: c.agent,
566
+ ...(c.model !== undefined ? { model: c.model } : {}),
567
+ status: c.status,
568
+ ...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
569
+ ...(c.preamble !== undefined ? { preamble: c.preamble } : {}),
570
+ result: c.result,
571
+ startedAt: c.startedAt,
572
+ endedAt: c.endedAt,
573
+ })),
574
+ };
575
+ }