pi-famulus 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/format.ts CHANGED
@@ -7,11 +7,14 @@
7
7
  import {
8
8
  formatFamulusWake,
9
9
  shellWakeTitle,
10
+ type FamulusWake,
10
11
  wakeDuration,
11
12
  type FormattedWake,
12
13
  type OverrunShell,
14
+ type SubagentOverrunChild,
13
15
  type WakeItem,
14
16
  } from "./wake";
17
+ import { fill } from "./prompts.generated";
15
18
 
16
19
  export { FAMULUS_WAKE_LEAD_IN } from "./wake";
17
20
  export type { FormattedWake, WakeItem } from "./wake";
@@ -149,10 +152,7 @@ export function formatBackgroundNotice(
149
152
  outputPath: string,
150
153
  ): string {
151
154
  // Model-facing. The transcript row is drawn by the bash tool's renderResult.
152
- return [
153
- `Command "${displayCommand(command)}" moved to background (task_id: ${taskId}). Output: ${outputPath}.`,
154
- 'You will be notified when it completes, even if other commands are still running. Do not poll or sleep: reply to the user now with no tool call, and continue from the <pi-famulus-wake kind="task"> when it arrives.',
155
- ].join("\n");
155
+ return fill("tools.bash.result.backgrounded", { command: displayCommand(command), taskId, outputPath });
156
156
  }
157
157
 
158
158
  /** Transcript row for a backgrounded command (UI only; never sent to the model). */
@@ -312,16 +312,10 @@ const OVERRUN_ACTIVITY_CHARS = 300;
312
312
  * A child is past its soft budget and still running. The parent decides:
313
313
  * extend, steer, or interrupt; doing nothing leaves it running.
314
314
  */
315
- export function formatSubagentOverrun(info: SubagentOverrunInfo): FormattedWake {
316
- const shellBit = info.shell ? `; it is waiting on a shell command that has run ${wakeDuration(info.shell.elapsedMs)}` : "";
317
- const summary =
318
- `${info.name} has run ${wakeDuration(info.elapsedMs)} in this turn, past its ` +
319
- `${wakeDuration(info.budgetMs)} budget, and is still running${shellBit}.`;
315
+ function formatOverrunChild(info: SubagentOverrunInfo): SubagentOverrunChild {
320
316
  const oneLine = info.lastActivity.text.replace(/\s+/g, " ").trim();
321
- const text =
322
- oneLine.length > OVERRUN_ACTIVITY_CHARS ? `…${oneLine.slice(-OVERRUN_ACTIVITY_CHARS)}` : oneLine;
323
- return formatFamulusWake({
324
- kind: "subagent-overrun",
317
+ const text = oneLine.length > OVERRUN_ACTIVITY_CHARS ? `…${oneLine.slice(-OVERRUN_ACTIVITY_CHARS)}` : oneLine;
318
+ return {
325
319
  runId: info.runId,
326
320
  childId: info.childId,
327
321
  name: info.name,
@@ -330,8 +324,31 @@ export function formatSubagentOverrun(info: SubagentOverrunInfo): FormattedWake
330
324
  reminder: info.reminder,
331
325
  nextReminderMs: info.nextReminderMs,
332
326
  ...(info.hardCeilingMs !== undefined ? { hardCeilingMs: info.hardCeilingMs } : {}),
333
- summary,
334
327
  lastActivity: { agoMs: info.lastActivity.agoMs, text: text || "(no output yet)" },
335
328
  ...(info.shell ? { shell: { ...info.shell, command: shellWakeTitle(info.shell.command) } } : {}),
329
+ };
330
+ }
331
+
332
+ function formatSubagentOverrunDetails(info: SubagentOverrunInfo): Extract<FamulusWake, { kind: "subagent-overrun" }> {
333
+ const child = formatOverrunChild(info);
334
+ const shell = child.shell ? fill("wakes.overrun.summary-shell", { elapsed: wakeDuration(child.shell.elapsedMs) }) : "";
335
+ const summary = fill("wakes.overrun.summary", {
336
+ name: child.name,
337
+ elapsed: wakeDuration(child.elapsedMs),
338
+ budget: wakeDuration(child.budgetMs),
339
+ shell,
340
+ });
341
+ return { kind: "subagent-overrun", ...child, summary };
342
+ }
343
+
344
+ export function formatSubagentOverrun(info: SubagentOverrunInfo): FormattedWake {
345
+ return formatFamulusWake(formatSubagentOverrunDetails(info));
346
+ }
347
+
348
+ export function formatSubagentOverrunBatch(infos: SubagentOverrunInfo[]): FormattedWake {
349
+ if (infos.length === 1) return formatSubagentOverrun(infos[0]);
350
+ return formatFamulusWake({
351
+ ...formatSubagentOverrunDetails(infos[0]),
352
+ additional: infos.slice(1).map(formatOverrunChild),
336
353
  });
337
354
  }
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * M2: NotifyCenter + monitor tool.
6
6
  * M3: subagent tool (InProcessRunner + tasks/chain + budget-to-async) + fleet widget.
7
7
  */
8
- import { applyBehaviorGuidelines } from "./behavior-guidelines";
8
+ import { registerBehaviorGuidelines } from "./behavior-guidelines";
9
9
  import { realClock } from "./clock";
10
10
  import { ExitNotifyGate } from "./exit-notify-gate";
11
11
  import { ExitWatchdog } from "./exit-watchdog";
@@ -27,6 +27,7 @@ import { describeManagerSearch, getFamulusHome, loadConfig, resolveManagerPath,
27
27
  import type { TaskExitInfo } from "./format";
28
28
  import { ManagerClient, type ManagerEvent, type TaskRecord } from "./manager-client";
29
29
  import { createMonitorTool, exitEventFromRecord, MonitorRegistry } from "./monitor";
30
+ import { admitAgentChild, reregisterAgentLeases } from "./subagent/admission";
30
31
  import { NotifyCenter } from "./notify";
31
32
  import { onWakeMessageEnd, registryStatusLookup } from "./wake-delivery";
32
33
  import { createChildBashTool } from "./subagent/child-bash";
@@ -35,13 +36,14 @@ import {
35
36
  agentEndReason,
36
37
  headOf,
37
38
  tailOf,
39
+ updateAgentChildMetrics,
38
40
  writeAgentChildRecord,
39
41
  type AgentChildRecord,
40
42
  } from "./subagent/agent-records";
41
43
  import { TranscriptWriter } from "./subagent/transcript";
42
44
  import { FleetWidget } from "./subagent/fleet-widget";
43
45
  import { WorkIndex, type WorkItem } from "./work-index";
44
- import { createPiSessionFn, modelCandidates } from "./subagent/pi-runtime";
46
+ import { createPiSessionFn, isFamulusChildSession, modelCandidates } from "./subagent/pi-runtime";
45
47
  import { SubagentRegistry } from "./subagent/registry";
46
48
  import { InProcessRunner } from "./subagent/runner";
47
49
  import { createSubagentTool } from "./subagent/tool";
@@ -68,6 +70,8 @@ function toExitStatus(event: ManagerEvent): TaskExitInfo["status"] {
68
70
  }
69
71
 
70
72
  export default function (pi: ExtensionAPI): void {
73
+ if (isFamulusChildSession(pi)) return;
74
+ registerBehaviorGuidelines(pi);
71
75
  const home = getFamulusHome();
72
76
  const config = loadConfig(home);
73
77
  const managerPath = resolveManagerPath(config, home);
@@ -78,6 +82,29 @@ export default function (pi: ExtensionAPI): void {
78
82
 
79
83
  let ctx: ExtensionContext | null = null;
80
84
  let client: ManagerClient | null = null;
85
+ const globalAgentLeases = new Map<string, string>();
86
+ const pendingAgentLeases = new Set<string>();
87
+ const pendingAgentReregistrations = new Map<string, AbortController>();
88
+ let leaseReconciliation: Promise<void> | null = null;
89
+ const reconcileAgentLeases = (manager: ManagerClient): Promise<void> => {
90
+ if (leaseReconciliation) return leaseReconciliation;
91
+ leaseReconciliation = reregisterAgentLeases(
92
+ manager,
93
+ globalAgentLeases,
94
+ undefined,
95
+ pendingAgentLeases,
96
+ pendingAgentReregistrations,
97
+ ).finally(() => {
98
+ leaseReconciliation = null;
99
+ });
100
+ return leaseReconciliation;
101
+ };
102
+ const capacityNotices = new Set<string>();
103
+ const capacityNotice = (reason: "manager unavailable" | "daemon too old"): void => {
104
+ if (capacityNotices.has(reason)) return;
105
+ capacityNotices.add(reason);
106
+ console.warn(`pi-famulus: machine-wide agent capacity inactive (${reason}); using per-session limit only`);
107
+ };
81
108
  let notifyCenter: NotifyCenter | null = null;
82
109
  let monitorRegistry: MonitorRegistry | null = null;
83
110
  let subagentRegistry: SubagentRegistry | null = null;
@@ -407,10 +434,6 @@ export default function (pi: ExtensionAPI): void {
407
434
 
408
435
  pi.on("session_start", async (_event, startCtx) => {
409
436
  ctx = startCtx;
410
- const base = (
411
- startCtx as { getSystemPromptOptions?: () => { sections?: Record<string, string> } }
412
- ).getSystemPromptOptions?.();
413
- if (base) applyBehaviorGuidelines(base);
414
437
  notifyCenter?.dispose();
415
438
  notifyCenter = new NotifyCenter({
416
439
  sendMessage: (msg, opts) => pi.sendMessage(msg, opts),
@@ -426,8 +449,13 @@ export default function (pi: ExtensionAPI): void {
426
449
  });
427
450
  fleetWidget?.dispose();
428
451
  fleetWidget = null;
429
- subagentRegistry?.disposeAll();
452
+ comms.dispose(); // Release decision waiters before awaiting child aborts.
453
+ await subagentRegistry?.disposeAll().catch(() => {});
430
454
  subagentRegistry = null;
455
+ for (const controller of pendingAgentReregistrations.values()) controller.abort();
456
+ pendingAgentReregistrations.clear();
457
+ globalAgentLeases.clear();
458
+ pendingAgentLeases.clear();
431
459
 
432
460
  client = new ManagerClient({
433
461
  home,
@@ -483,7 +511,21 @@ export default function (pi: ExtensionAPI): void {
483
511
  }
484
512
  });
485
513
  client.onReconnect(() => {
514
+ // A daemon restart may drop permits; a same-session rebind may retain
515
+ // them. Keep each kind for idempotent re-registration and mark all local
516
+ // leases pending until the daemon confirms them.
517
+ for (const childId of globalAgentLeases.keys()) pendingAgentLeases.add(childId);
486
518
  void monitorRegistry?.rewatchAll().then(() => syncWithManager());
519
+ void (async () => {
520
+ const manager = client;
521
+ if (!manager || manager.protocolLevel() < 4) {
522
+ capacityNotice("daemon too old");
523
+ return;
524
+ }
525
+ // The registry and its child ids survive socket reconnects in this
526
+ // extension process; held ids include resumable interrupted children.
527
+ await reconcileAgentLeases(manager);
528
+ })().catch(() => {});
487
529
  });
488
530
 
489
531
  // M3: subagent registry + in-process runner + fleet widget. The runner's
@@ -519,6 +561,7 @@ export default function (pi: ExtensionAPI): void {
519
561
  getParentThinkingLevel: () => ctx?.thinkingLevel,
520
562
  getScopedModels: () => ctx?.scopedModels ?? [],
521
563
  getCwd: () => ctx?.cwd ?? process.cwd(),
564
+ getProjectTrusted: () => ctx?.isProjectTrusted() ?? false,
522
565
  customTools: (req) => {
523
566
  const tools: Array<ToolDefinition<any, any, any>> = [
524
567
  // M4: every child can reach the supervisor and its siblings.
@@ -527,21 +570,19 @@ export default function (pi: ExtensionAPI): void {
527
570
  ];
528
571
  // The no-background bash variant replaces the built-in bash inside
529
572
  // child sessions (custom tools override builtins by name).
530
- if (req.agent.tools.includes("bash")) {
531
- tools.push(
532
- createChildBashTool({
533
- getClient: () => client,
534
- home,
535
- sessionId: () => ctx?.sessionManager.getSessionId() ?? "",
536
- sessionEnv: () => (ctx ? sessionEnv(ctx) : {}),
537
- trackTask,
538
- childId: req.childId,
539
- runId: req.runId,
540
- clock,
541
- shells: childShells,
542
- }),
543
- );
544
- }
573
+ tools.push(
574
+ createChildBashTool({
575
+ getClient: () => client,
576
+ home,
577
+ sessionId: () => ctx?.sessionManager.getSessionId() ?? "",
578
+ sessionEnv: () => (ctx ? sessionEnv(ctx) : {}),
579
+ trackTask,
580
+ childId: req.childId,
581
+ runId: req.runId,
582
+ clock,
583
+ shells: childShells,
584
+ }),
585
+ );
545
586
  return tools;
546
587
  },
547
588
  });
@@ -559,9 +600,37 @@ export default function (pi: ExtensionAPI): void {
559
600
  stallRetryDelayMs: subagentConfig.stallRetryDelayMs,
560
601
  overrunRepeatMs: subagentConfig.overrunRepeatMs,
561
602
  hardTimeoutMs: subagentConfig.hardTimeoutMs,
562
- acquire: (req, ticket) => registry.admitChild(req.childId, ticket),
603
+ acquire: (req, ticket) => admitAgentChild({
604
+ childId: req.childId,
605
+ workKind: req.workKind ?? "other",
606
+ reserveLocal: () => registry.reserveChildSlot(req.childId, ticket),
607
+ manager: client,
608
+ leases: globalAgentLeases,
609
+ pendingLeases: pendingAgentLeases,
610
+ pendingReregistrations: pendingAgentReregistrations,
611
+ ticket,
612
+ notice: capacityNotice,
613
+ }),
563
614
  onActivity: (childId) => {
564
615
  syncTranscript(childId);
616
+ const handle = registry.handle(childId);
617
+ const usage = handle?.tokenUsage();
618
+ const wall = handle?.wallUsage();
619
+ if (usage && wall) {
620
+ const metrics = {
621
+ tokens_input: usage.input,
622
+ tokens_output: usage.output,
623
+ tokens_cache_read: usage.cacheRead,
624
+ tokens_cache_write: usage.cacheWrite,
625
+ llm_ms: wall.llmMs,
626
+ tool_ms: wall.toolMs,
627
+ queue_ms: wall.queueMs,
628
+ wall_other_ms: wall.otherMs,
629
+ wall_approximate: wall.approximate,
630
+ };
631
+ updateAgentChildMetrics(home, sessionIdForAgents(), childId, metrics);
632
+ logEvent("agent.usage", { child_id: childId, ...metrics });
633
+ }
565
634
  },
566
635
  onStall: (childId, attempt) => {
567
636
  // One event per stall detection: an auto-resume follows unless the
@@ -600,6 +669,8 @@ export default function (pi: ExtensionAPI): void {
600
669
  run_id: run.runId,
601
670
  name: c.name,
602
671
  agent: c.agent,
672
+ work_kind: c.workKind ?? "other",
673
+ queue_ms: c.queueMs ?? 0,
603
674
  ...(c.model ? { model: c.model } : {}),
604
675
  });
605
676
  }
@@ -611,8 +682,11 @@ export default function (pi: ExtensionAPI): void {
611
682
  child_id: c.childId,
612
683
  status: c.status,
613
684
  ...(error ? { error } : {}),
685
+ work_kind: c.workKind ?? "other",
614
686
  ...(c.result?.stalls ? { stalls: c.result.stalls } : {}),
687
+ ...(c.queueMs !== undefined ? { queue_ms: c.queueMs } : {}),
615
688
  duration_ms: c.result?.durationMs ?? Math.max(0, clock.now() - c.startedAt),
689
+ ...(c.result?.queueMs !== undefined ? { queue_ms: c.result.queueMs } : {}),
616
690
  });
617
691
  }
618
692
  previousAgentStatus.set(c.childId, c.status);
@@ -624,9 +698,20 @@ export default function (pi: ExtensionAPI): void {
624
698
  session_id: sid,
625
699
  name: c.name,
626
700
  agent: c.agent,
701
+ work_kind: c.workKind ?? "other",
702
+ ...(c.queueMs !== undefined ? { queue_ms: c.queueMs } : {}),
627
703
  ...(c.model !== undefined ? { model: c.model } : {}),
628
704
  status: c.status,
629
705
  started_at: c.startedAt,
706
+ tokens_input: c.tokenUsage?.input ?? 0,
707
+ tokens_output: c.tokenUsage?.output ?? 0,
708
+ tokens_cache_read: c.tokenUsage?.cacheRead ?? 0,
709
+ tokens_cache_write: c.tokenUsage?.cacheWrite ?? 0,
710
+ llm_ms: c.wallUsage?.llmMs ?? 0,
711
+ tool_ms: c.wallUsage?.toolMs ?? 0,
712
+ queue_ms: c.wallUsage?.queueMs ?? 0,
713
+ wall_other_ms: c.wallUsage?.otherMs ?? 0,
714
+ wall_approximate: c.wallUsage?.approximate ?? false,
630
715
  ...(c.endedAt !== undefined ? { ended_at: c.endedAt } : {}),
631
716
  ...(c.result?.error ? { error: c.result.error } : {}),
632
717
  ...(c.result?.attempts !== undefined && c.result.attempts > 1
@@ -655,6 +740,8 @@ export default function (pi: ExtensionAPI): void {
655
740
  runId: run.runId,
656
741
  name: c.name,
657
742
  agent: c.agent,
743
+ workKind: c.workKind ?? "other",
744
+ ...(c.queueMs !== undefined ? { queueMs: c.queueMs } : {}),
658
745
  ...(c.model !== undefined ? { model: c.model } : {}),
659
746
  cwd: startCtx.cwd,
660
747
  ...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
@@ -679,7 +766,11 @@ export default function (pi: ExtensionAPI): void {
679
766
  void c
680
767
  .connect()
681
768
  .then((ok) => {
682
- if (!ok && startCtx.hasUI) {
769
+ if (ok) {
770
+ if (c.protocolLevel() >= 4) void reconcileAgentLeases(c).catch(() => {});
771
+ return;
772
+ }
773
+ if (startCtx.hasUI) {
683
774
  const detail = c.lastError();
684
775
  const reason = detail ? ` (${detail})` : "";
685
776
  const searched = managerPath ? `using ${managerPath}` : `looked in: ${describeManagerSearch(config, home)}`;
@@ -698,19 +789,22 @@ export default function (pi: ExtensionAPI): void {
698
789
  exitWatchdog.dispose();
699
790
  fleetWidget?.dispose();
700
791
  fleetWidget = null;
701
- subagentRegistry?.disposeAll();
792
+ comms.dispose(); // Release decision waiters before awaiting child aborts.
793
+ await subagentRegistry?.disposeAll().catch(() => {});
702
794
  subagentRegistry = null;
703
795
  agentLoader = null;
704
- comms.dispose(); // resolve orphaned need_decision waiters
705
796
  monitorRegistry?.disposeAll();
706
797
  notifyCenter?.dispose();
707
798
  const current = client;
708
799
  client = null;
709
800
  if (current) {
710
- if (current.isAvailable()) {
711
- await current.shutdownSession().catch(() => {});
801
+ try {
802
+ if (current.isAvailable()) await current.shutdownSession();
803
+ } catch {
804
+ // Child teardown is best-effort; still release the manager connection.
805
+ } finally {
806
+ await current.close().catch(() => {});
712
807
  }
713
- await current.close();
714
808
  }
715
809
  ctx = null;
716
810
  });
@@ -728,8 +822,4 @@ export default function (pi: ExtensionAPI): void {
728
822
  });
729
823
  return message ? { message } : undefined;
730
824
  });
731
-
732
- pi.on("before_agent_start", async (event) => {
733
- applyBehaviorGuidelines(event.systemPromptOptions as { sections?: Record<string, string> });
734
- });
735
825
  }