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,339 @@
1
+ /**
2
+ * comms tools (design doc §4.7):
3
+ *
4
+ * - `contact_supervisor` — registered in child sessions; need_decision blocks
5
+ * until the parent replies, progress_update is fire-and-forget.
6
+ * - `agent_message` — parent session (and child variant with implicit `from`)
7
+ * for send / reply / broadcast / list.
8
+ *
9
+ * Zero runtime pi dependency: ToolDefinition is a type-only import.
10
+ */
11
+ import { Type } from "typebox";
12
+ import { realClock, type Clock } from "../clock";
13
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
14
+ import type { CommsWithOrigin } from "./comms";
15
+ import { assertSiblingAllowed } from "./routing";
16
+ import type { ChildStatus, Comms, CommsHost, MailboxEntry } from "./types";
17
+
18
+ export type AgentMessageSender =
19
+ | { kind: "parent" }
20
+ | { kind: "child"; childId: string; runId: string };
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // contact_supervisor (child sessions)
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const contactSupervisorParameters = Type.Object({
27
+ reason: Type.Union([Type.Literal("need_decision"), Type.Literal("progress_update")], {
28
+ description:
29
+ '"need_decision" blocks until the supervisor (parent agent) replies; ' +
30
+ '"progress_update" is fire-and-forget.',
31
+ }),
32
+ message: Type.String({ description: "Message for the supervisor." }),
33
+ });
34
+
35
+ export interface ContactSupervisorDetails {
36
+ reason: "need_decision" | "progress_update";
37
+ replied: boolean;
38
+ error?: string;
39
+ }
40
+
41
+ export function createContactSupervisorTool(
42
+ comms: Comms,
43
+ childId: string,
44
+ ): ToolDefinition<typeof contactSupervisorParameters, ContactSupervisorDetails> {
45
+ return {
46
+ name: "contact_supervisor",
47
+ label: "Contact Supervisor",
48
+ description:
49
+ "Contact the supervisor (parent agent). " +
50
+ 'Use reason "need_decision" when you are blocked and need the parent to decide — ' +
51
+ "the call blocks until the parent replies (10 minute timeout, after which you must " +
52
+ "decide yourself). " +
53
+ 'Use reason "progress_update" for a fire-and-forget status note.',
54
+ promptSnippet: "Ask the parent agent for a decision or report progress",
55
+ parameters: contactSupervisorParameters,
56
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
57
+ try {
58
+ const text = await comms.contactSupervisor(childId, params.reason, params.message);
59
+ return {
60
+ content: [{ type: "text", text }],
61
+ details: { reason: params.reason, replied: params.reason === "need_decision" },
62
+ };
63
+ } catch (err) {
64
+ const message = err instanceof Error ? err.message : String(err);
65
+ return {
66
+ content: [{ type: "text", text: `contact_supervisor failed: ${message}` }],
67
+ details: { reason: params.reason, replied: false, error: message },
68
+ };
69
+ }
70
+ },
71
+ };
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // agent_message (parent session; child variant with implicit from)
76
+ // ---------------------------------------------------------------------------
77
+
78
+ const agentMessageParameters = Type.Object({
79
+ action: Type.Union(
80
+ [Type.Literal("send"), Type.Literal("reply"), Type.Literal("broadcast"), Type.Literal("list")],
81
+ {
82
+ description:
83
+ '"send" a message to a child, "reply" to a pending decision request, ' +
84
+ '"broadcast" to all running children of a run, "list" pending requests and recent traffic.',
85
+ },
86
+ ),
87
+ to: Type.Optional(
88
+ Type.String({
89
+ description:
90
+ 'Target child_id or name ("send"/"reply"). For "broadcast" from the parent session: run_id. ' +
91
+ "Child sessions broadcast to their own run and must omit this.",
92
+ }),
93
+ ),
94
+ message: Type.Optional(Type.String({ description: "Message text." })),
95
+ delivery: Type.Optional(
96
+ Type.Union([Type.Literal("steer"), Type.Literal("queue")], {
97
+ description:
98
+ '"steer" (default) injects into a running child immediately; "queue" delivers after ' +
99
+ 'its current turn. Sending to a finished child is an error — resume with subagent({action:"resume"}).',
100
+ }),
101
+ ),
102
+ });
103
+
104
+ export interface AgentMessageDetails {
105
+ action: "send" | "reply" | "broadcast" | "list";
106
+ ok: boolean;
107
+ error?: string;
108
+ to?: string;
109
+ delivery?: "steer" | "queue";
110
+ delivered?: string[];
111
+ pending?: { childId: string; name: string; message: string; sinceMs: number }[];
112
+ children?: { childId: string; runId: string; name: string; status: ChildStatus }[];
113
+ log?: MailboxEntry[];
114
+ }
115
+
116
+ type AgentMessageResult = {
117
+ content: { type: "text"; text: string }[];
118
+ details: AgentMessageDetails;
119
+ };
120
+
121
+ function errorResult(
122
+ action: AgentMessageDetails["action"],
123
+ text: string,
124
+ extra: Partial<AgentMessageDetails> = {},
125
+ ): AgentMessageResult {
126
+ return {
127
+ content: [{ type: "text", text: `Error: ${text}` }],
128
+ details: { action, ok: false, error: text, ...extra },
129
+ };
130
+ }
131
+
132
+ function okResult(
133
+ action: AgentMessageDetails["action"],
134
+ text: string,
135
+ extra: Partial<AgentMessageDetails> = {},
136
+ ): AgentMessageResult {
137
+ return { content: [{ type: "text", text }], details: { action, ok: true, ...extra } };
138
+ }
139
+
140
+ function truncate(text: string, maxChars = 120): string {
141
+ const flat = text.replace(/\s+/g, " ").trim();
142
+ return flat.length > maxChars ? `${flat.slice(0, maxChars - 1)}…` : flat;
143
+ }
144
+
145
+ interface ChildRef {
146
+ childId: string;
147
+ runId: string;
148
+ name: string;
149
+ status: ChildStatus;
150
+ }
151
+
152
+ /** Resolve a `to` argument that may be a child_id or a display name. */
153
+ function resolveChildRef(host: CommsHost, ref: string): ChildRef | undefined {
154
+ const all = host.listChildren();
155
+ const byId = all.find((c) => c.childId === ref);
156
+ if (byId) return byId;
157
+ const byName = all.filter((c) => c.name === ref);
158
+ if (byName.length > 1) {
159
+ throw new Error(
160
+ `Ambiguous child name "${ref}" matches ${byName.map((c) => c.childId).join(", ")}; ` +
161
+ "use child_id.",
162
+ );
163
+ }
164
+ return byName[0];
165
+ }
166
+
167
+ function knownChildrenText(host: CommsHost): string {
168
+ return host.listChildren().map((c) => `${c.childId} (${c.name})`).join(", ") || "none";
169
+ }
170
+
171
+ function formatListText(
172
+ pending: { childId: string; name: string; message: string; sinceMs: number }[],
173
+ children: ChildRef[],
174
+ entries: MailboxEntry[],
175
+ now: number,
176
+ ): string {
177
+ const lines: string[] = [];
178
+ lines.push(`Pending decision requests: ${pending.length}`);
179
+ for (const p of pending) {
180
+ const ageS = Math.max(0, Math.round((now - p.sinceMs) / 1000));
181
+ lines.push(`- ${p.childId} (${p.name}) waiting ${ageS}s: "${truncate(p.message)}"`);
182
+ }
183
+ lines.push("", `Children: ${children.length}`);
184
+ for (const c of children) {
185
+ lines.push(`- ${c.childId} [${c.runId}] ${c.name} — ${c.status}`);
186
+ }
187
+ lines.push("", `Recent messages (last ${entries.length}):`);
188
+ for (const e of entries) {
189
+ const reply = e.reply !== undefined ? ` → reply: "${truncate(e.reply, 80)}"` : "";
190
+ lines.push(`- ${e.from} → ${e.to} (${e.kind}): "${truncate(e.message, 80)}"${reply}`);
191
+ }
192
+ return lines.join("\n");
193
+ }
194
+
195
+ /**
196
+ * @param comms the shared Comms instance
197
+ * @param sender parent session, or the child the tool is registered for
198
+ * @param host CommsHost — needed for lineage checks, name resolution, and
199
+ * the `list` summary (deviation from the two-arg sketch in the
200
+ * task brief; required by the §4.7 routing rules)
201
+ */
202
+ export function createAgentMessageTool(
203
+ comms: CommsWithOrigin,
204
+ sender: AgentMessageSender,
205
+ host: CommsHost,
206
+ clock: Clock = realClock,
207
+ ): ToolDefinition<typeof agentMessageParameters, AgentMessageDetails> {
208
+ const isChild = sender.kind === "child";
209
+ return {
210
+ name: "agent_message",
211
+ label: "Agent Message",
212
+ description: isChild
213
+ ? "Message sibling subagents in your run, or list pending requests. " +
214
+ '"send" delivers to a running sibling. Sending to a finished sibling errors and does not resume it; ' +
215
+ 'resume with subagent({ action: "resume", run_id, child_id, message }). ' +
216
+ '"broadcast" steers every running sibling in your run; "list" shows pending ' +
217
+ "decision requests and recent traffic. Cross-run messaging is rejected."
218
+ : "Message your subagents. " +
219
+ '"send" steers or queues a running child. Sending to a finished child errors ' +
220
+ '(it does not resume) and points at subagent({ action: "resume", run_id, child_id, message }). ' +
221
+ '"reply" answers a child\'s pending decision request; "broadcast" steers every ' +
222
+ 'running child of a run (to = run_id); "list" shows children, pending decision ' +
223
+ "requests, and recent traffic.",
224
+ promptSnippet: isChild
225
+ ? "Message sibling subagents in your run"
226
+ : "Send/reply/broadcast messages to subagents",
227
+ parameters: agentMessageParameters,
228
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
229
+ const action = params.action;
230
+ const from = isChild ? sender.childId : "supervisor";
231
+
232
+ if (action === "list") {
233
+ const pending = comms.pendingRequests();
234
+ const children = host.listChildren();
235
+ let entries: MailboxEntry[];
236
+ if (isChild) {
237
+ entries = comms.log(sender.runId, 20);
238
+ } else {
239
+ const runIds = [...new Set(children.map((c) => c.runId))];
240
+ entries = runIds
241
+ .flatMap((runId) => comms.log(runId, 200))
242
+ .sort((a, b) => a.ts - b.ts)
243
+ .slice(-20);
244
+ }
245
+ return okResult("list", formatListText(pending, children, entries, clock.now()), {
246
+ pending,
247
+ children,
248
+ log: entries,
249
+ });
250
+ }
251
+
252
+ if (!params.message) {
253
+ return errorResult(action, `action "${action}" requires "message".`);
254
+ }
255
+ const message = params.message;
256
+
257
+ if (action === "broadcast") {
258
+ let runId: string;
259
+ let fromChildId: string | undefined;
260
+ if (isChild) {
261
+ if (params.to !== undefined && params.to !== sender.runId) {
262
+ return errorResult("broadcast", "cross-run messaging not allowed");
263
+ }
264
+ runId = sender.runId;
265
+ fromChildId = sender.childId;
266
+ } else {
267
+ if (!params.to) {
268
+ return errorResult("broadcast", 'action "broadcast" requires "to" (run_id).');
269
+ }
270
+ runId = params.to;
271
+ }
272
+ const delivered = await comms.broadcast(runId, message, fromChildId);
273
+ const text =
274
+ delivered.length === 0
275
+ ? `Broadcast to run ${runId}: no running children received it.`
276
+ : `Broadcast to run ${runId} delivered to: ${delivered.join(", ")}.`;
277
+ return okResult("broadcast", text, { delivered });
278
+ }
279
+
280
+ // send / reply need a target child.
281
+ if (!params.to) {
282
+ return errorResult(action, `action "${action}" requires "to" (child_id or name).`);
283
+ }
284
+ let target: ChildRef | undefined;
285
+ try {
286
+ target = resolveChildRef(host, params.to);
287
+ } catch (err) {
288
+ return errorResult(action, err instanceof Error ? err.message : String(err));
289
+ }
290
+ if (!target) {
291
+ return errorResult(
292
+ action,
293
+ `Unknown child "${params.to}". Known children: ${knownChildrenText(host)}.`,
294
+ );
295
+ }
296
+ if (isChild) {
297
+ try {
298
+ assertSiblingAllowed(host, sender.childId, target.childId);
299
+ } catch (err) {
300
+ return errorResult(action, err instanceof Error ? err.message : String(err), {
301
+ to: target.childId,
302
+ });
303
+ }
304
+ }
305
+
306
+ if (action === "reply") {
307
+ try {
308
+ comms.reply(target.childId, message, from);
309
+ } catch (err) {
310
+ return errorResult("reply", err instanceof Error ? err.message : String(err), {
311
+ to: target.childId,
312
+ });
313
+ }
314
+ return okResult(
315
+ "reply",
316
+ `Replied to ${target.childId} (${target.name}): "${truncate(message, 80)}"`,
317
+ { to: target.childId },
318
+ );
319
+ }
320
+
321
+ // send
322
+ const delivery = params.delivery ?? "steer";
323
+ try {
324
+ await comms.send(target.childId, message, delivery, from);
325
+ } catch (err) {
326
+ return errorResult("send", err instanceof Error ? err.message : String(err), {
327
+ to: target.childId,
328
+ delivery,
329
+ });
330
+ }
331
+ const how =
332
+ delivery === "steer" ? "steered into the running child" : "queued for the running child";
333
+ return okResult("send", `Message to ${target.childId} (${target.name}) ${how}.`, {
334
+ to: target.childId,
335
+ delivery,
336
+ });
337
+ },
338
+ };
339
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * comms types (design doc appendix B, M4).
3
+ *
4
+ * `ChildStatus` / `ChildResult` / `ChildHandle` are structural re-declarations
5
+ * of the appendix B contract. They are intentionally NOT imported from
6
+ * `../subagent/types` (M3 is developed in parallel and may not exist yet);
7
+ * any value satisfying these shapes is accepted.
8
+ */
9
+
10
+ // ---------- structural subagent types (appendix B, re-declared) ----------
11
+
12
+ export type ChildStatus = "pending" | "running" | "completed" | "failed" | "interrupted";
13
+
14
+ export interface ChildResult {
15
+ status: "completed" | "failed" | "interrupted";
16
+ text: string;
17
+ error?: string;
18
+ durationMs: number;
19
+ }
20
+
21
+ export interface ChildHandle {
22
+ readonly childId: string;
23
+ readonly result: Promise<ChildResult>;
24
+ steer(message: string): Promise<void>;
25
+ followUp(message: string): Promise<void>;
26
+ resume(message: string): Promise<void>;
27
+ interrupt(): Promise<void>;
28
+ status(): ChildStatus;
29
+ lastEventAt(): number;
30
+ /** Resolved `provider/id` once the child session exists (optional for stubs). */
31
+ resolvedModel?(): string | undefined;
32
+ }
33
+
34
+ import type { FamulusWake } from "../wake";
35
+
36
+ // ---------- comms contract (appendix B, verbatim) ----------
37
+
38
+ export interface CommsHost {
39
+ // Implemented by the subagent registry (wired at integration time).
40
+ getChild(
41
+ childId: string,
42
+ ): { handle: ChildHandle; runId: string; name: string; status: ChildStatus } | undefined;
43
+ listChildren(): { childId: string; runId: string; name: string; status: ChildStatus }[];
44
+ sameRun(childIdA: string, childIdB: string): boolean;
45
+ notifySupervisor(wake: { content: string; details: FamulusWake }): void;
46
+ }
47
+
48
+ export interface MailboxEntry {
49
+ ts: number;
50
+ from: string; // "supervisor" | childId
51
+ to: string; // "supervisor" | childId
52
+ kind: "need_decision" | "progress_update" | "send" | "reply" | "broadcast";
53
+ message: string;
54
+ reply?: string;
55
+ }
56
+
57
+ export interface Comms {
58
+ contactSupervisor(
59
+ fromChildId: string,
60
+ reason: "need_decision" | "progress_update",
61
+ message: string,
62
+ ): Promise<string>; // need_decision → reply text; progress_update → immediate "ok"
63
+ reply(toChildId: string, message: string): void; // no pending waiter → throw
64
+ send(toChildId: string, message: string, delivery: "steer" | "queue"): Promise<void>;
65
+ broadcast(runId: string, message: string, fromChildId?: string): Promise<string[]>; // delivered childIds
66
+ pendingRequests(): { childId: string; name: string; message: string; sinceMs: number }[];
67
+ log(runId: string, limit?: number): MailboxEntry[]; // default 20, ring 200/run
68
+ }
package/src/config.ts ADDED
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Extension configuration loading (design doc §4.9).
3
+ *
4
+ * Config file: <famulus-home>/config.json
5
+ * Base directory resolution: PI_FAMULUS_HOME env > ~/.pi/agent/pi-famulus
6
+ */
7
+ import { accessSync, constants, readFileSync, statSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { delimiter, join } from "node:path";
10
+ import { nativePackageName, resolveNativeManagerPath } from "./native-manager.js";
11
+
12
+ export interface FamulusConfig {
13
+ /** Foreground budget for bash before auto-backgrounding (ms). */
14
+ foregroundBudgetMs: number;
15
+ /** Foreground budget for subagent runs (ms). Reserved for M3. */
16
+ subagentBudgetMs: number;
17
+ /** Explicit path to the pi-famulus binary, or null for auto-resolution. */
18
+ managerPath: string | null;
19
+ logLevel: "debug" | "info" | "warn" | "error";
20
+ /** Optional M3 subagent tuning section (design doc §4.6 limits). */
21
+ subagent?: FamulusSubagentConfig;
22
+ }
23
+
24
+ /** Optional `subagent` section of config.json; every field defaults (see resolveSubagentConfig). */
25
+ export interface FamulusSubagentConfig {
26
+ /** Sync-wait budget before a run is moved to background (ms). Overrides top-level subagentBudgetMs. */
27
+ budgetMs?: number;
28
+ /** Default per-child hard timeout (ms). */
29
+ timeoutMs?: number;
30
+ /** Stall watchdog: abort a child with no events for this long (ms). Paused during tools and need_decision. */
31
+ stallMs?: number;
32
+ /** Auto-resume attempts after a stall before the run settles failed (stalled). Default 1. */
33
+ stallRetries?: number;
34
+ /** Pause between the stall abort and the retry prompt (ms). Default 5000. */
35
+ stallRetryDelayMs?: number;
36
+ /** need_decision wait for the parent (ms). Independent of stallMs and timeoutMs. */
37
+ decisionTimeoutMs?: number;
38
+ /** Default per-run worker pool concurrency. */
39
+ concurrency?: number;
40
+ /** Global cap on concurrently running children across all runs. */
41
+ maxConcurrentChildren?: number;
42
+ /** Max child sessions spawned per hour. */
43
+ spawnBudgetPerHour?: number;
44
+ }
45
+
46
+ /** Subagent settings with every field resolved (design doc §4.6 defaults). */
47
+ export interface ResolvedSubagentConfig {
48
+ budgetMs: number;
49
+ timeoutMs: number;
50
+ stallMs: number;
51
+ stallRetries: number;
52
+ stallRetryDelayMs: number;
53
+ decisionTimeoutMs: number;
54
+ concurrency: number;
55
+ maxConcurrentChildren: number;
56
+ spawnBudgetPerHour: number;
57
+ }
58
+
59
+ /**
60
+ * Largest delay (ms) Node's setTimeout accepts without overflow. Larger
61
+ * configured values would clamp to ~1ms and fire immediately — cap instead.
62
+ */
63
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647;
64
+
65
+ function capTimerDelay(value: number): number {
66
+ return Math.min(Math.floor(value), MAX_TIMER_DELAY_MS);
67
+ }
68
+
69
+ export const DEFAULT_SUBAGENT_CONFIG: ResolvedSubagentConfig = {
70
+ budgetMs: 45000,
71
+ timeoutMs: 1_800_000,
72
+ stallMs: 300_000,
73
+ stallRetries: 1,
74
+ stallRetryDelayMs: 5_000,
75
+ decisionTimeoutMs: 600_000,
76
+ concurrency: 4,
77
+ maxConcurrentChildren: 8,
78
+ spawnBudgetPerHour: 32,
79
+ };
80
+
81
+ /** Merge defaults <- top-level subagentBudgetMs <- subagent section. */
82
+ export function resolveSubagentConfig(config: FamulusConfig): ResolvedSubagentConfig {
83
+ const section = config.subagent ?? {};
84
+ const resolved = { ...DEFAULT_SUBAGENT_CONFIG };
85
+ resolved.budgetMs = config.subagentBudgetMs > 0 ? config.subagentBudgetMs : resolved.budgetMs;
86
+ if (typeof section.budgetMs === "number" && section.budgetMs > 0) resolved.budgetMs = section.budgetMs;
87
+ if (typeof section.timeoutMs === "number" && section.timeoutMs > 0) resolved.timeoutMs = section.timeoutMs;
88
+ if (typeof section.stallMs === "number" && section.stallMs > 0) {
89
+ resolved.stallMs = capTimerDelay(section.stallMs);
90
+ }
91
+ if (typeof section.stallRetries === "number" && section.stallRetries >= 0) {
92
+ resolved.stallRetries = Math.floor(section.stallRetries);
93
+ }
94
+ if (typeof section.stallRetryDelayMs === "number" && section.stallRetryDelayMs >= 0) {
95
+ resolved.stallRetryDelayMs = capTimerDelay(section.stallRetryDelayMs);
96
+ }
97
+ if (typeof section.decisionTimeoutMs === "number" && section.decisionTimeoutMs > 0) {
98
+ resolved.decisionTimeoutMs = section.decisionTimeoutMs;
99
+ }
100
+ if (typeof section.concurrency === "number" && section.concurrency >= 1) {
101
+ resolved.concurrency = Math.floor(section.concurrency);
102
+ }
103
+ if (typeof section.maxConcurrentChildren === "number" && section.maxConcurrentChildren >= 1) {
104
+ resolved.maxConcurrentChildren = Math.floor(section.maxConcurrentChildren);
105
+ }
106
+ if (typeof section.spawnBudgetPerHour === "number" && section.spawnBudgetPerHour >= 1) {
107
+ resolved.spawnBudgetPerHour = Math.floor(section.spawnBudgetPerHour);
108
+ }
109
+ return resolved;
110
+ }
111
+
112
+ export const DEFAULT_CONFIG: FamulusConfig = {
113
+ foregroundBudgetMs: 20000,
114
+ subagentBudgetMs: 45000,
115
+ managerPath: null,
116
+ logLevel: "info",
117
+ };
118
+
119
+ /** Resolve the pi-famulus base directory. PI_FAMULUS_HOME overrides the default. */
120
+ export function getFamulusHome(env: NodeJS.ProcessEnv = process.env): string {
121
+ const override = env.PI_FAMULUS_HOME;
122
+ if (override && override.trim().length > 0) return override;
123
+ return join(homedir(), ".pi", "agent", "pi-famulus");
124
+ }
125
+
126
+ /** Well-known paths inside the pi-famulus home directory (design doc §3.1). */
127
+ export function famulusPaths(home: string) {
128
+ return {
129
+ home,
130
+ socket: join(home, "manager.sock"),
131
+ pidFile: join(home, "manager.pid"),
132
+ spawnLock: join(home, "manager.spawn.lock"),
133
+ log: join(home, "manager.log"),
134
+ config: join(home, "config.json"),
135
+ sessionsDir: join(home, "sessions"),
136
+ };
137
+ }
138
+
139
+ /** Full-output file path for a task (design doc §3.1 layout). */
140
+ export function taskOutputPath(home: string, sessionId: string, taskId: string): string {
141
+ return join(home, "sessions", sessionId, "tasks", `${taskId}.output`);
142
+ }
143
+
144
+ /** Load config.json, tolerating missing/malformed files and unknown fields. */
145
+ export function loadConfig(home: string = getFamulusHome()): FamulusConfig {
146
+ const path = famulusPaths(home).config;
147
+ let raw: unknown;
148
+ try {
149
+ raw = JSON.parse(readFileSync(path, "utf8"));
150
+ } catch {
151
+ return { ...DEFAULT_CONFIG };
152
+ }
153
+ if (typeof raw !== "object" || raw === null) return { ...DEFAULT_CONFIG };
154
+ const obj = raw as Record<string, unknown>;
155
+ const config = { ...DEFAULT_CONFIG };
156
+ if (typeof obj.foregroundBudgetMs === "number" && obj.foregroundBudgetMs > 0) {
157
+ config.foregroundBudgetMs = obj.foregroundBudgetMs;
158
+ }
159
+ if (typeof obj.subagentBudgetMs === "number" && obj.subagentBudgetMs > 0) {
160
+ config.subagentBudgetMs = obj.subagentBudgetMs;
161
+ }
162
+ if (typeof obj.managerPath === "string" && obj.managerPath.length > 0) {
163
+ config.managerPath = obj.managerPath;
164
+ }
165
+ if (
166
+ obj.logLevel === "debug" ||
167
+ obj.logLevel === "info" ||
168
+ obj.logLevel === "warn" ||
169
+ obj.logLevel === "error"
170
+ ) {
171
+ config.logLevel = obj.logLevel;
172
+ }
173
+ if (typeof obj.subagent === "object" && obj.subagent !== null) {
174
+ const section = obj.subagent as Record<string, unknown>;
175
+ const subagent: FamulusSubagentConfig = {};
176
+ if (typeof section.budgetMs === "number" && section.budgetMs > 0) subagent.budgetMs = section.budgetMs;
177
+ if (typeof section.timeoutMs === "number" && section.timeoutMs > 0) subagent.timeoutMs = section.timeoutMs;
178
+ if (typeof section.stallMs === "number" && section.stallMs > 0) {
179
+ subagent.stallMs = capTimerDelay(section.stallMs);
180
+ }
181
+ if (typeof section.stallRetries === "number" && section.stallRetries >= 0) {
182
+ subagent.stallRetries = Math.floor(section.stallRetries);
183
+ }
184
+ if (typeof section.stallRetryDelayMs === "number" && section.stallRetryDelayMs >= 0) {
185
+ subagent.stallRetryDelayMs = capTimerDelay(section.stallRetryDelayMs);
186
+ }
187
+ if (typeof section.decisionTimeoutMs === "number" && section.decisionTimeoutMs > 0) {
188
+ subagent.decisionTimeoutMs = section.decisionTimeoutMs;
189
+ }
190
+ if (typeof section.concurrency === "number" && section.concurrency >= 1) {
191
+ subagent.concurrency = section.concurrency;
192
+ }
193
+ if (typeof section.maxConcurrentChildren === "number" && section.maxConcurrentChildren >= 1) {
194
+ subagent.maxConcurrentChildren = section.maxConcurrentChildren;
195
+ }
196
+ if (typeof section.spawnBudgetPerHour === "number" && section.spawnBudgetPerHour >= 1) {
197
+ subagent.spawnBudgetPerHour = section.spawnBudgetPerHour;
198
+ }
199
+ config.subagent = subagent;
200
+ }
201
+ return config;
202
+ }
203
+
204
+ /** Return why a candidate is unusable, or null for an executable regular file. */
205
+ function managerCandidateProblem(path: string): string | null {
206
+ try {
207
+ if (!statSync(path).isFile()) return "not a regular file";
208
+ accessSync(path, constants.X_OK);
209
+ return null;
210
+ } catch (error) {
211
+ const code = (error as NodeJS.ErrnoException).code;
212
+ if (code === "ENOENT" || code === "ENOTDIR") return "missing";
213
+ if (code === "EACCES" || code === "EPERM") return "not executable";
214
+ return "inaccessible";
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Human-readable account of the manager search locations for the degraded-startup
220
+ * warning, including why explicit and home/bin candidates are ignored.
221
+ */
222
+ export function describeManagerSearch(
223
+ config: FamulusConfig,
224
+ home: string = getFamulusHome(),
225
+ env: NodeJS.ProcessEnv = process.env,
226
+ ): string {
227
+ const describeCandidate = (path: string): string => {
228
+ const problem = managerCandidateProblem(path);
229
+ return `${path}${problem ? ` (${problem}, ignored)` : ""}`;
230
+ };
231
+ const tried: string[] = [];
232
+ if (config.managerPath) tried.push(`config managerPath ${describeCandidate(config.managerPath)}`);
233
+ if (env.PI_FAMULUS_MANAGER_PATH) tried.push(`PI_FAMULUS_MANAGER_PATH ${describeCandidate(env.PI_FAMULUS_MANAGER_PATH)}`);
234
+ const nativeName = nativePackageName(process.platform, process.arch);
235
+ const native = resolveNativeManagerPath();
236
+ tried.push(nativeName
237
+ ? `npm ${nativeName}${native ? ` ${native}` : " (missing or unusable, ignored)"}`
238
+ : `npm native manager (unsupported ${process.platform}/${process.arch})`);
239
+ tried.push(describeCandidate(join(home, "bin", "pi-famulus")));
240
+ tried.push("pi-famulus on PATH");
241
+ return tried.join("; ");
242
+ }
243
+
244
+ /**
245
+ * Resolve an executable regular pi-famulus binary, or null if none is usable.
246
+ * Priority: config.managerPath > PI_FAMULUS_MANAGER_PATH env > npm native package > <home>/bin/pi-famulus > PATH.
247
+ */
248
+ export function resolveManagerPath(
249
+ config: FamulusConfig,
250
+ home: string = getFamulusHome(),
251
+ env: NodeJS.ProcessEnv = process.env,
252
+ ): string | null {
253
+ if (config.managerPath && managerCandidateProblem(config.managerPath) === null) return config.managerPath;
254
+ const envPath = env.PI_FAMULUS_MANAGER_PATH;
255
+ if (envPath && managerCandidateProblem(envPath) === null) return envPath;
256
+ const native = resolveNativeManagerPath();
257
+ if (native) return native;
258
+ const bundled = join(home, "bin", "pi-famulus");
259
+ if (managerCandidateProblem(bundled) === null) return bundled;
260
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
261
+ if (!dir) continue;
262
+ const candidate = join(dir, "pi-famulus");
263
+ if (managerCandidateProblem(candidate) === null) return candidate;
264
+ }
265
+ return null;
266
+ }