pi-famulus 0.1.2 → 0.1.4

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/index.ts CHANGED
@@ -5,12 +5,13 @@
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";
12
12
  import { createExtensionEventLog } from "./events";
13
13
  import { readFileTail } from "./file-tail";
14
+ import { statSync } from "node:fs";
14
15
  import { readdir, readFile, stat } from "node:fs/promises";
15
16
  import { homedir } from "node:os";
16
17
  import { join } from "node:path";
@@ -26,19 +27,23 @@ import { describeManagerSearch, getFamulusHome, loadConfig, resolveManagerPath,
26
27
  import type { TaskExitInfo } from "./format";
27
28
  import { ManagerClient, type ManagerEvent, type TaskRecord } from "./manager-client";
28
29
  import { createMonitorTool, exitEventFromRecord, MonitorRegistry } from "./monitor";
30
+ import { admitAgentChild, reregisterAgentLeases } from "./subagent/admission";
29
31
  import { NotifyCenter } from "./notify";
32
+ import { onWakeMessageEnd, registryStatusLookup } from "./wake-delivery";
30
33
  import { createChildBashTool } from "./subagent/child-bash";
34
+ import { ChildShellTracker, createOverrunNotifier } from "./subagent/overrun";
31
35
  import {
32
36
  agentEndReason,
33
37
  headOf,
34
38
  tailOf,
39
+ updateAgentChildMetrics,
35
40
  writeAgentChildRecord,
36
41
  type AgentChildRecord,
37
42
  } from "./subagent/agent-records";
38
43
  import { TranscriptWriter } from "./subagent/transcript";
39
44
  import { FleetWidget } from "./subagent/fleet-widget";
40
45
  import { WorkIndex, type WorkItem } from "./work-index";
41
- import { createPiSessionFn, modelCandidates } from "./subagent/pi-runtime";
46
+ import { createPiSessionFn, isFamulusChildSession, modelCandidates } from "./subagent/pi-runtime";
42
47
  import { SubagentRegistry } from "./subagent/registry";
43
48
  import { InProcessRunner } from "./subagent/runner";
44
49
  import { createSubagentTool } from "./subagent/tool";
@@ -65,6 +70,8 @@ function toExitStatus(event: ManagerEvent): TaskExitInfo["status"] {
65
70
  }
66
71
 
67
72
  export default function (pi: ExtensionAPI): void {
73
+ if (isFamulusChildSession(pi)) return;
74
+ registerBehaviorGuidelines(pi);
68
75
  const home = getFamulusHome();
69
76
  const config = loadConfig(home);
70
77
  const managerPath = resolveManagerPath(config, home);
@@ -75,6 +82,29 @@ export default function (pi: ExtensionAPI): void {
75
82
 
76
83
  let ctx: ExtensionContext | null = null;
77
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
+ };
78
108
  let notifyCenter: NotifyCenter | null = null;
79
109
  let monitorRegistry: MonitorRegistry | null = null;
80
110
  let subagentRegistry: SubagentRegistry | null = null;
@@ -404,10 +434,6 @@ export default function (pi: ExtensionAPI): void {
404
434
 
405
435
  pi.on("session_start", async (_event, startCtx) => {
406
436
  ctx = startCtx;
407
- const base = (
408
- startCtx as { getSystemPromptOptions?: () => { sections?: Record<string, string> } }
409
- ).getSystemPromptOptions?.();
410
- if (base) applyBehaviorGuidelines(base);
411
437
  notifyCenter?.dispose();
412
438
  notifyCenter = new NotifyCenter({
413
439
  sendMessage: (msg, opts) => pi.sendMessage(msg, opts),
@@ -423,8 +449,13 @@ export default function (pi: ExtensionAPI): void {
423
449
  });
424
450
  fleetWidget?.dispose();
425
451
  fleetWidget = null;
426
- subagentRegistry?.disposeAll();
452
+ comms.dispose(); // Release decision waiters before awaiting child aborts.
453
+ await subagentRegistry?.disposeAll().catch(() => {});
427
454
  subagentRegistry = null;
455
+ for (const controller of pendingAgentReregistrations.values()) controller.abort();
456
+ pendingAgentReregistrations.clear();
457
+ globalAgentLeases.clear();
458
+ pendingAgentLeases.clear();
428
459
 
429
460
  client = new ManagerClient({
430
461
  home,
@@ -480,7 +511,21 @@ export default function (pi: ExtensionAPI): void {
480
511
  }
481
512
  });
482
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);
483
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(() => {});
484
529
  });
485
530
 
486
531
  // M3: subagent registry + in-process runner + fleet widget. The runner's
@@ -504,6 +549,8 @@ export default function (pi: ExtensionAPI): void {
504
549
  "warning",
505
550
  );
506
551
  }
552
+ // The foreground shell each child is blocked on, for its overrun wake.
553
+ const childShells = new ChildShellTracker();
507
554
  const createSession = createPiSessionFn({
508
555
  getModelRegistry: () => ctx?.modelRegistry ?? null,
509
556
  getModelRuntime: () => {
@@ -514,6 +561,7 @@ export default function (pi: ExtensionAPI): void {
514
561
  getParentThinkingLevel: () => ctx?.thinkingLevel,
515
562
  getScopedModels: () => ctx?.scopedModels ?? [],
516
563
  getCwd: () => ctx?.cwd ?? process.cwd(),
564
+ getProjectTrusted: () => ctx?.isProjectTrusted() ?? false,
517
565
  customTools: (req) => {
518
566
  const tools: Array<ToolDefinition<any, any, any>> = [
519
567
  // M4: every child can reach the supervisor and its siblings.
@@ -522,20 +570,19 @@ export default function (pi: ExtensionAPI): void {
522
570
  ];
523
571
  // The no-background bash variant replaces the built-in bash inside
524
572
  // 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
- }
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
+ );
539
586
  return tools;
540
587
  },
541
588
  });
@@ -551,15 +598,61 @@ export default function (pi: ExtensionAPI): void {
551
598
  stallMs: subagentConfig.stallMs,
552
599
  stallRetries: subagentConfig.stallRetries,
553
600
  stallRetryDelayMs: subagentConfig.stallRetryDelayMs,
554
- acquire: (req) => registry.admitChild(req.childId),
601
+ overrunRepeatMs: subagentConfig.overrunRepeatMs,
602
+ hardTimeoutMs: subagentConfig.hardTimeoutMs,
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
+ }),
555
614
  onActivity: (childId) => {
556
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
+ }
557
634
  },
558
635
  onStall: (childId, attempt) => {
559
636
  // One event per stall detection: an auto-resume follows unless the
560
637
  // retry budget (stallRetries) is already spent.
561
638
  logEvent("agent.stall", { child_id: childId, attempt });
562
639
  },
640
+ // Soft deadline: the child keeps running; the parent decides.
641
+ onOverrun: createOverrunNotifier({
642
+ now: () => clock.now(),
643
+ registry,
644
+ shells: childShells,
645
+ stat: (path) => {
646
+ try {
647
+ const st = statSync(path);
648
+ return { size: st.size, mtimeMs: st.mtimeMs };
649
+ } catch {
650
+ return null;
651
+ }
652
+ },
653
+ notify: (wake) => notifyCenter?.notify(wake),
654
+ logEvent,
655
+ }),
563
656
  });
564
657
  registry.setRunner(runner);
565
658
  subagentRegistry = registry;
@@ -576,18 +669,24 @@ export default function (pi: ExtensionAPI): void {
576
669
  run_id: run.runId,
577
670
  name: c.name,
578
671
  agent: c.agent,
672
+ work_kind: c.workKind ?? "other",
673
+ queue_ms: c.queueMs ?? 0,
579
674
  ...(c.model ? { model: c.model } : {}),
580
675
  });
581
676
  }
582
677
  if (["completed", "failed", "interrupted"].includes(c.status) && previousStatus !== c.status) {
583
678
  const error = c.result?.error;
679
+ // Only the opt-in hard ceiling settles with error=timeout now.
584
680
  if (error === "timeout") logEvent("agent.timeout", { child_id: c.childId });
585
681
  logEvent("agent.settle", {
586
682
  child_id: c.childId,
587
683
  status: c.status,
588
684
  ...(error ? { error } : {}),
685
+ work_kind: c.workKind ?? "other",
589
686
  ...(c.result?.stalls ? { stalls: c.result.stalls } : {}),
687
+ ...(c.queueMs !== undefined ? { queue_ms: c.queueMs } : {}),
590
688
  duration_ms: c.result?.durationMs ?? Math.max(0, clock.now() - c.startedAt),
689
+ ...(c.result?.queueMs !== undefined ? { queue_ms: c.result.queueMs } : {}),
591
690
  });
592
691
  }
593
692
  previousAgentStatus.set(c.childId, c.status);
@@ -599,9 +698,20 @@ export default function (pi: ExtensionAPI): void {
599
698
  session_id: sid,
600
699
  name: c.name,
601
700
  agent: c.agent,
701
+ work_kind: c.workKind ?? "other",
702
+ ...(c.queueMs !== undefined ? { queue_ms: c.queueMs } : {}),
602
703
  ...(c.model !== undefined ? { model: c.model } : {}),
603
704
  status: c.status,
604
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,
605
715
  ...(c.endedAt !== undefined ? { ended_at: c.endedAt } : {}),
606
716
  ...(c.result?.error ? { error: c.result.error } : {}),
607
717
  ...(c.result?.attempts !== undefined && c.result.attempts > 1
@@ -630,6 +740,8 @@ export default function (pi: ExtensionAPI): void {
630
740
  runId: run.runId,
631
741
  name: c.name,
632
742
  agent: c.agent,
743
+ workKind: c.workKind ?? "other",
744
+ ...(c.queueMs !== undefined ? { queueMs: c.queueMs } : {}),
633
745
  ...(c.model !== undefined ? { model: c.model } : {}),
634
746
  cwd: startCtx.cwd,
635
747
  ...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
@@ -654,7 +766,11 @@ export default function (pi: ExtensionAPI): void {
654
766
  void c
655
767
  .connect()
656
768
  .then((ok) => {
657
- if (!ok && startCtx.hasUI) {
769
+ if (ok) {
770
+ if (c.protocolLevel() >= 4) void reconcileAgentLeases(c).catch(() => {});
771
+ return;
772
+ }
773
+ if (startCtx.hasUI) {
658
774
  const detail = c.lastError();
659
775
  const reason = detail ? ` (${detail})` : "";
660
776
  const searched = managerPath ? `using ${managerPath}` : `looked in: ${describeManagerSearch(config, home)}`;
@@ -673,19 +789,22 @@ export default function (pi: ExtensionAPI): void {
673
789
  exitWatchdog.dispose();
674
790
  fleetWidget?.dispose();
675
791
  fleetWidget = null;
676
- subagentRegistry?.disposeAll();
792
+ comms.dispose(); // Release decision waiters before awaiting child aborts.
793
+ await subagentRegistry?.disposeAll().catch(() => {});
677
794
  subagentRegistry = null;
678
795
  agentLoader = null;
679
- comms.dispose(); // resolve orphaned need_decision waiters
680
796
  monitorRegistry?.disposeAll();
681
797
  notifyCenter?.dispose();
682
798
  const current = client;
683
799
  client = null;
684
800
  if (current) {
685
- if (current.isAvailable()) {
686
- 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(() => {});
687
807
  }
688
- await current.close();
689
808
  }
690
809
  ctx = null;
691
810
  });
@@ -694,7 +813,13 @@ export default function (pi: ExtensionAPI): void {
694
813
  notifyCenter?.settled();
695
814
  });
696
815
 
697
- pi.on("before_agent_start", async (event) => {
698
- applyBehaviorGuidelines(event.systemPromptOptions as { sections?: Record<string, string> });
816
+ // A wake entering the model's context (steer / triggerTurn); see wake-delivery.ts.
817
+ pi.on("message_end", async (event) => {
818
+ const message = onWakeMessageEnd(event.message, {
819
+ now: () => clock.now(),
820
+ logEvent,
821
+ lookup: () => (subagentRegistry ? registryStatusLookup(subagentRegistry) : null),
822
+ });
823
+ return message ? { message } : undefined;
699
824
  });
700
825
  }