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/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # pi-famulus
2
2
 
3
- Subagent orchestration, auto-backgrounding bash, monitoring, and agent-to-agent communication for pi.
3
+ Subagent orchestration, auto-backgrounding bash, monitoring, and agent-to-agent communication for pi 1.0.0 or newer.
4
+
5
+ Workers inherit user/global and trusted-project pi configuration and resources, including codemode and MCP, then receive famulus communication tools. An explicit agent `tools` list remains an allowlist; omitting it inherits the user’s default tools. Parent-only CLI resource overrides are not copied.
4
6
 
5
7
  After the first public release:
6
8
 
@@ -18,8 +20,9 @@ Supported native packages:
18
20
  | Linux | arm64 | `pi-famulus-linux-arm64` |
19
21
  | macOS | Intel x64 | `pi-famulus-darwin-x64` |
20
22
  | macOS | Apple Silicon arm64 | `pi-famulus-darwin-arm64` |
23
+ | Windows | x64 | `pi-famulus-win-x64` |
21
24
 
22
- Linux binaries are statically linked with musl. macOS binaries target macOS 13 or newer; your Node/pi runtime's requirements also apply. Windows and other CPU architectures are not supported.
25
+ Linux binaries are statically linked with musl. macOS binaries target macOS 13 or newer; Windows x64 binaries use MSVC with the CRT linked statically. Windows ARM64 is not published until CI produces that tarball. Your Node/pi runtime's requirements also apply. In-place manager upgrade remains Unix-only.
23
26
 
24
27
  The npm package also exposes the `pi-famulus` CLI (`npx pi-famulus --help`). The extension's binary search order is:
25
28
 
@@ -29,6 +32,8 @@ The npm package also exposes the `pi-famulus` CLI (`npx pi-famulus --help`). The
29
32
  4. Executable `~/.pi/agent/pi-famulus/bin/pi-famulus` (or the equivalent beneath `PI_FAMULUS_HOME`).
30
33
  5. Executable `pi-famulus` on `PATH`.
31
34
 
35
+ On Windows the file name in steps 4 and 5 (and inside the native package) is `pi-famulus.exe`.
36
+
32
37
  Configuration and runtime state remain in `~/.pi/agent/pi-famulus`. Installing/updating the npm package does not move that state or copy a binary into the shared home. The npm CLI uses the package's native executable; use the extension configuration above when intentionally running a separately built manager.
33
38
 
34
39
  If optional dependencies were omitted, reinstall with them enabled. The CLI reports an actionable error; the extension can still discover an explicitly installed manager and otherwise enters degraded mode. An arbitrary different-version native npm package is never substituted.
package/bin/pi-famulus.js CHANGED
@@ -7,7 +7,7 @@ if (!binary) {
7
7
  const name = nativePackageName(process.platform, process.arch);
8
8
  console.error(name
9
9
  ? `pi-famulus: missing usable ${name}. Reinstall pi-famulus with optional dependencies enabled (npm install --include=optional pi-famulus).`
10
- : `pi-famulus: unsupported ${process.platform}/${process.arch}; only Linux/macOS x64/arm64 are supported.`);
10
+ : `pi-famulus: unsupported ${process.platform}/${process.arch}; only Linux/macOS/Windows x64/arm64 are supported.`);
11
11
  process.exit(1);
12
12
  }
13
13
  const child = spawn(binary, process.argv.slice(2), { stdio: "inherit" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-famulus",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "description": "Subagents, monitoring, and auto-backgrounding bash for pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,7 +15,8 @@
15
15
  },
16
16
  "os": [
17
17
  "linux",
18
- "darwin"
18
+ "darwin",
19
+ "win32"
19
20
  ],
20
21
  "cpu": [
21
22
  "x64",
@@ -30,10 +31,11 @@
30
31
  "pi-famulus": "./bin/pi-famulus.js"
31
32
  },
32
33
  "optionalDependencies": {
33
- "pi-famulus-linux-x64": "0.1.3",
34
- "pi-famulus-linux-arm64": "0.1.3",
35
- "pi-famulus-darwin-x64": "0.1.3",
36
- "pi-famulus-darwin-arm64": "0.1.3"
34
+ "pi-famulus-linux-x64": "0.1.6",
35
+ "pi-famulus-linux-arm64": "0.1.6",
36
+ "pi-famulus-darwin-x64": "0.1.6",
37
+ "pi-famulus-darwin-arm64": "0.1.6",
38
+ "pi-famulus-win-x64": "0.1.6"
37
39
  },
38
40
  "pi": {
39
41
  "extensions": [
@@ -41,6 +43,8 @@
41
43
  ]
42
44
  },
43
45
  "scripts": {
46
+ "deps": "nub install --frozen-lockfile --node-linker hoisted",
47
+ "prompts": "node scripts/build-prompts.ts",
44
48
  "test": "vitest run",
45
49
  "typecheck": "tsc --noEmit"
46
50
  },
@@ -53,7 +57,7 @@
53
57
  "vitest": "^3.0.0"
54
58
  },
55
59
  "peerDependencies": {
56
- "@earendil-works/pi-coding-agent": "*",
60
+ "@earendil-works/pi-coding-agent": ">=1.0.0",
57
61
  "typebox": "*",
58
62
  "@earendil-works/pi-tui": "*"
59
63
  },
@@ -4,38 +4,21 @@
4
4
  * These form the lowest precedence layer: user and project definitions with
5
5
  * the same name replace them wholesale.
6
6
  */
7
+ import { PROMPTS } from "../prompts.generated";
7
8
  import type { AgentDefinition } from "./definition";
8
9
 
9
10
  export const BUILTIN_AGENTS: AgentDefinition[] = [
10
11
  {
11
12
  name: "explorer",
12
- description:
13
- "Fast read-only codebase exploration — finds files, symbols, and answers structure questions",
13
+ description: PROMPTS["agents.explorer.description"],
14
14
  tools: ["read", "grep", "find", "ls", "bash"],
15
- systemPrompt: [
16
- "You are an explorer agent. Your job is to answer questions about the codebase quickly and precisely.",
17
- "",
18
- "Rules:",
19
- "- Return findings, never change code. You are strictly read-only: do not edit, write, or create files.",
20
- "- Report concrete locations: cite specific file paths with line numbers (path:line) for every claim.",
21
- "- Control your search scope: start narrow (targeted grep/find), widen only when needed, and stop once the question is answered.",
22
- "- Prefer a short, factual summary over exhaustive dumps. List what you found and where.",
23
- ].join("\n"),
15
+ systemPrompt: PROMPTS["agents.explorer.system-prompt"],
24
16
  source: "builtin",
25
17
  },
26
18
  {
27
19
  name: "worker",
28
- description: "General-purpose executor — completes concrete coding tasks end to end",
29
- tools: ["read", "bash", "edit", "write"],
30
- systemPrompt: [
31
- "You are a worker agent. Your job is to complete the concrete task you were given, end to end.",
32
- "",
33
- "Rules:",
34
- "- Do the task, don't just describe it. Make the actual edits and run the actual commands.",
35
- "- Self-verify before finishing: run the relevant tests, typecheck, or command that proves the task is done.",
36
- "- Stay within the task's scope; do not refactor unrelated code.",
37
- "- Report concisely: what you changed, how you verified it, and anything the supervisor should know.",
38
- ].join("\n"),
20
+ description: PROMPTS["agents.worker.description"],
21
+ systemPrompt: PROMPTS["agents.worker.system-prompt"],
39
22
  source: "builtin",
40
23
  },
41
24
  ];
@@ -26,8 +26,8 @@ export interface AgentDefinition {
26
26
  name: string;
27
27
  /** Required, non-empty. */
28
28
  description: string;
29
- /** Tool allowlist. Defaults to ["read","bash","edit","write"]. */
30
- tools: string[];
29
+ /** Omitted: inherit user-configured tools. Explicit list: allowlist; []: disables user-configured tools, but injected Famulus tools remain. */
30
+ tools?: string[];
31
31
  /** "provider:id" or bare id. */
32
32
  model?: string;
33
33
  /** Thinking level (pi core parity; src/thinking-levels.ts). */
@@ -39,8 +39,6 @@ export interface AgentDefinition {
39
39
  path?: string;
40
40
  }
41
41
 
42
- export const DEFAULT_AGENT_TOOLS: readonly string[] = ["read", "bash", "edit", "write"];
43
-
44
42
  const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
45
43
 
46
44
  type Fail = (reason: string) => never;
@@ -168,7 +166,7 @@ export function parseAgentMarkdown(
168
166
  const description = parseScalar(fields.description, "description", fail);
169
167
  if (description === "") fail("field 'description' must be non-empty");
170
168
 
171
- let tools = [...DEFAULT_AGENT_TOOLS];
169
+ let tools: string[] | undefined;
172
170
  if (fields.tools !== undefined) {
173
171
  tools = parseStringArray(fields.tools, "tools", fail);
174
172
  }
@@ -195,7 +193,7 @@ export function parseAgentMarkdown(
195
193
  return {
196
194
  name,
197
195
  description,
198
- tools,
196
+ ...(tools !== undefined ? { tools } : {}),
199
197
  ...(model !== undefined ? { model } : {}),
200
198
  ...(thinking !== undefined ? { thinking } : {}),
201
199
  systemPrompt: body.trim(),
@@ -18,11 +18,12 @@ import { writeFileSync } from "node:fs";
18
18
  import { tmpdir } from "node:os";
19
19
  import { join } from "node:path";
20
20
  import { Type, type Static } from "typebox";
21
- import type {
22
- AgentToolResult,
23
- BashToolDetails,
24
- ExtensionContext,
25
- ToolDefinition,
21
+ import {
22
+ getShellConfig,
23
+ type AgentToolResult,
24
+ type BashToolDetails,
25
+ type ExtensionContext,
26
+ type ToolDefinition,
26
27
  } from "@earendil-works/pi-coding-agent";
27
28
  import { taskOutputPath, type FamulusConfig } from "./config";
28
29
  import { realClock, type Clock, type ClockTimer } from "./clock";
@@ -30,6 +31,7 @@ import { backgroundRowText, formatBackgroundNotice, truncateTail } from "./forma
30
31
  import { toolComponent } from "./tui/tool-component";
31
32
  import type { WorkIndex } from "./work-index";
32
33
  import type { ManagerClient } from "./manager-client";
34
+ import { fill, PROMPTS } from "./prompts.generated";
33
35
  import {
34
36
  appendStatus,
35
37
  bareSleepError,
@@ -48,15 +50,12 @@ const MAX_LINES = SHELL_MAX_LINES;
48
50
  const MAX_BYTES = SHELL_MAX_BYTES;
49
51
 
50
52
  const bashParameters = Type.Object({
51
- command: Type.String({ description: "The bash command to execute" }),
53
+ command: Type.String({ description: PROMPTS["tools.bash.param.command"] }),
52
54
  timeout: Type.Optional(
53
- Type.Number({ description: "Hard kill timeout in seconds (optional, no default timeout)" }),
55
+ Type.Number({ description: PROMPTS["tools.bash.param.timeout"] }),
54
56
  ),
55
57
  run_in_background: Type.Optional(
56
- Type.Boolean({
57
- description:
58
- "Start the command in the background and return immediately. You will be notified when it completes.",
59
- }),
58
+ Type.Boolean({ description: PROMPTS["tools.bash.param.run_in_background"] }),
60
59
  ),
61
60
  });
62
61
 
@@ -135,11 +134,8 @@ function isActiveStatus(status: string | undefined): boolean {
135
134
  return status === undefined || status === "running" || status === "pending";
136
135
  }
137
136
 
138
- const BARE_SLEEP_GUIDANCE =
139
- "Do not sleep to wait for background work; completion is delivered via notification. Use the monitor tool to watch for a condition, or run_in_background for long commands.";
140
-
141
137
  function rejectBareSleep(command: string): string | null {
142
- return bareSleepError(command, BARE_SLEEP_GUIDANCE);
138
+ return bareSleepError(command, "tools.bash.error.bare-sleep");
143
139
  }
144
140
 
145
141
  function resolveTimeoutMs(timeoutSeconds: number | undefined): number | null {
@@ -171,15 +167,29 @@ async function executeLocal(
171
167
  ): Promise<AgentToolResult<FamulusBashDetails | undefined>> {
172
168
  const timeoutMs = resolveTimeoutMs(params.timeout);
173
169
  const startedAtMs = clock.now();
174
- const shell = process.env.SHELL && process.env.SHELL.length > 0 ? process.env.SHELL : "/bin/bash";
170
+ // pi's own choice (Git Bash on Windows, /bin/bash on Unix): commands are
171
+ // written for pi's bash tool, not for whatever $SHELL is.
172
+ let shell: ReturnType<typeof getShellConfig>;
173
+ try {
174
+ shell = getShellConfig();
175
+ } catch (err) {
176
+ throw new Error(`Failed to start shell: ${(err as Error).message}`);
177
+ }
178
+ const viaStdin = shell.commandTransport === "stdin";
175
179
 
176
180
  const output = await new Promise<{ text: string; exitCode: number | null; signal: string | null; timedOut: boolean; aborted: boolean }>(
177
181
  (resolve, reject) => {
178
- const child = spawn(shell, ["-c", params.command], {
182
+ const child = spawn(shell.shell, viaStdin ? shell.args : [...shell.args, params.command], {
179
183
  cwd: ctx.cwd,
180
184
  env: process.env,
181
- stdio: ["ignore", "pipe", "pipe"],
185
+ stdio: [viaStdin ? "pipe" : "ignore", "pipe", "pipe"],
186
+ windowsHide: true,
182
187
  });
188
+ if (viaStdin) {
189
+ // Swallow EPIPE if the shell exits before consuming stdin (pi's runner does the same).
190
+ child.stdin?.on("error", () => {});
191
+ child.stdin?.end(params.command);
192
+ }
183
193
  const chunks: Buffer[] = [];
184
194
  let settled = false;
185
195
  let timedOut = false;
@@ -289,17 +299,9 @@ export function createBashOverride(
289
299
  return {
290
300
  name: "bash",
291
301
  label: "Bash",
292
- description:
293
- "Execute a bash command in the current working directory. Returns stdout and stderr. " +
294
- `Output is truncated to last ${MAX_LINES} lines or ${MAX_BYTES / 1024}KB (whichever is hit first). ` +
295
- "Foreground commands that exceed the foreground budget are automatically moved to the background; " +
296
- "you will be notified when they complete. Optionally provide a timeout in seconds (hard kill limit), " +
297
- "or run_in_background to background immediately.",
298
- promptSnippet: "Execute bash commands (ls, grep, find, etc.)",
299
- promptGuidelines: [
300
- "You can inspect PI_* environment variables for current model and session details.",
301
- "Long-running bash commands are moved to the background automatically; do not poll or sleep to wait for them. End your turn (a reply with no tool call) and resume from the task wake when it arrives.",
302
- ],
302
+ description: fill("tools.bash.description", { maxLines: MAX_LINES, maxKb: MAX_BYTES / 1024 }),
303
+ promptSnippet: PROMPTS["tools.bash.snippet"],
304
+ promptGuidelines: [...PROMPTS["tools.bash.rules"]],
303
305
  parameters: bashParameters,
304
306
  outputSchema: bashOutputSchema,
305
307
  renderResult(result, { expanded }, theme, context) {
@@ -4,26 +4,22 @@
4
4
  * Returning `systemPrompt` from before_agent_start forces the whole prompt for
5
5
  * that user-prompt run only (and clobbers other extensions). Wake turns started
6
6
  * by sendMessage({ triggerTurn: true }) do not call before_agent_start again.
7
- * A named section is diffed into the transcript and stays visible on those turns.
7
+ * A named section is recorded on user turns. Pi can rebuild the base prompt
8
+ * after a wake's tool result and remove that run-local section, so the request
9
+ * projection restores only our section whenever it is missing or stale.
8
10
  */
9
- export const BEHAVIOR_GUIDELINES_SECTION = "pi-famulus";
11
+ import type { ContextWithSystemEvent, ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ // .ts suffix: eval/ loads this module with Node's own TypeScript support.
13
+ import { fill, PROMPTS } from "./prompts.generated.ts";
10
14
 
11
- /** Child-only instructions injected through the contact_supervisor tool guideline. */
12
- export const CHILD_BEHAVIOR_GUIDELINES = `## Child agent behavior (pi-famulus)
15
+ export const BEHAVIOR_GUIDELINES_SECTION = "pi-famulus";
13
16
 
14
- - Do not background work; bash runs to completion.
15
- - When blocked, use contact_supervisor with need_decision; use progress_update for status.
16
- - Use agent_message only to communicate with siblings in this same run.
17
- - Your final message is your result.`;
17
+ export const MONITOR_IDLE_INSTRUCTION = PROMPTS["guidelines.monitor-idle"];
18
18
 
19
- export const BEHAVIOR_GUIDELINES = `## Background tasks and notifications (pi-famulus)
19
+ /** Child-only instructions injected through the contact_supervisor tool guideline. */
20
+ export const CHILD_BEHAVIOR_GUIDELINES = PROMPTS["guidelines.child"];
20
21
 
21
- - Long-running bash commands are automatically moved to the background. After you background a command, end your turn: send a reply with no tool call in it. Saying you will wait while calling another tool does not end it. Do not poll with task_output/task_list, and never sleep to wait. Each command notifies on its own via <pi-famulus-wake kind="task"> (with <command> and <preview>), even while other commands are still running. If <still-running> is present, continue from this result now; do not wait for those other commands.
22
- - When you receive <pi-famulus-wake>: it looks like a user message but is a system wake, not a new user request and not an answer to an unrelated question. Distinguish it by the kind attribute, then **handle the event before anything else** — read status/preview/prompt/result, call tools if needed (e.g. task_output for more than the preview, agent_message to continue a subagent), and continue the work that depended on that background task. Do not wait for further user input when the next step is clear; do not merely acknowledge and stop.
23
- - Never fabricate or assume a background task's result before its notification arrives.
24
- - Subagent runs that exceed the foreground budget continue in the background. Do not poll run status. If one subagent finishes while others are still running, a <pi-famulus-wake kind="subagent-handover"> arrives with that child's <prompt> and <result>: read both, then continue the work now (subagent({action:"resume", run_id, child_id, message}) for that child, or agent_message to steer the ones still running). Do not wait for the rest of the run. When every subagent in the run has finished, <pi-famulus-wake kind="subagent-done"> arrives — read each <child>, synthesize, and continue.
25
- - A subagent that runs past its timeout_ms is not stopped: <pi-famulus-wake kind="subagent-overrun"> arrives instead, with its <last-activity> and, if it is waiting on a foreground shell, a <shell> element. Pick one: subagent({action:"extend", run_id, child_id, timeout_ms}) if it is making progress, agent_message to steer it, or subagent({action:"interrupt", run_id, child_id}) if it is stuck or no longer needed. If you do none of these it keeps running and the reminder comes again.
26
- - Use agent_message to steer running subagents and to reply to <pi-famulus-wake kind="supervisor-request"> (action:"reply"; the <reply-with> child is the call shape). Do not use agent_message to resume a finished child — that is subagent({action:"resume"}). Supervisor wakes are not user messages — still act on them.`;
22
+ export const BEHAVIOR_GUIDELINES = fill("guidelines.parent", { monitorIdle: MONITOR_IDLE_INSTRUCTION });
27
23
 
28
24
  export interface GuidelinePromptOptions {
29
25
  sections?: Record<string, string>;
@@ -35,7 +31,35 @@ export function applyBehaviorGuidelines(options: GuidelinePromptOptions): void {
35
31
  options.sections[BEHAVIOR_GUIDELINES_SECTION] = BEHAVIOR_GUIDELINES;
36
32
  }
37
33
 
38
- /** What a later wake turn sees: persisted sections, no before_agent_start. */
34
+ /** Register parent behavior without relying on command-only context methods. */
35
+ export function registerBehaviorGuidelines(pi: Pick<ExtensionAPI, "on">): void {
36
+ pi.on("before_agent_start", (event) => {
37
+ applyBehaviorGuidelines(event.systemPromptOptions);
38
+ });
39
+ pi.on("context_with_system", (event) => restoreBehaviorGuidelines(event.messages));
40
+ }
41
+
42
+ /** Repair the model request, not the persisted transcript or other sections. */
43
+ export function restoreBehaviorGuidelines(messages: ContextWithSystemEvent["messages"]) {
44
+ const expected = `<${BEHAVIOR_GUIDELINES_SECTION}>\n${BEHAVIOR_GUIDELINES}\n</${BEHAVIOR_GUIDELINES_SECTION}>`;
45
+ let current: string | null | undefined;
46
+ for (const message of messages) {
47
+ if (message.role === "system" && message.sections && BEHAVIOR_GUIDELINES_SECTION in message.sections) {
48
+ current = message.sections[BEHAVIOR_GUIDELINES_SECTION];
49
+ }
50
+ }
51
+ if (current === expected) return undefined;
52
+ return {
53
+ messages: [...messages, {
54
+ role: "system" as const,
55
+ content: "",
56
+ sections: { [BEHAVIOR_GUIDELINES_SECTION]: expected },
57
+ timestamp: Date.now(),
58
+ }],
59
+ };
60
+ }
61
+
62
+ /** Render named sections without replacing unrelated instructions. */
39
63
  export function wakePromptFromSections(sections: Record<string, string>): string {
40
64
  return Object.entries(sections)
41
65
  .map(([name, content]) => `<${name}>\n${content}\n</${name}>`)
@@ -10,6 +10,7 @@ import {
10
10
  type MailboxOptions,
11
11
  } from "./mailbox";
12
12
  import type { Comms, CommsHost } from "./types";
13
+ import { fill } from "../prompts.generated";
13
14
  import { formatFamulusWake, type FormattedWake } from "../wake";
14
15
  import type { Clock } from "../clock";
15
16
 
@@ -149,9 +150,12 @@ export function createComms(host: CommsHost, options: CommsOptions = {}): CommsW
149
150
  // Lifecycle (resume) belongs to the subagent tool. Resuming here never
150
151
  // wired a completion wake, so the parent hung.
151
152
  throw new Error(
152
- `Child ${toChildId} (${child.name}) has finished (${child.status}). ` +
153
- `agent_message does not resume children. ` +
154
- `Use subagent({ action: "resume", run_id: "${child.runId}", child_id: "${toChildId}", message: "..." }).`,
153
+ fill("tools.agent_message.error.finished-child", {
154
+ childId: toChildId,
155
+ name: child.name,
156
+ status: child.status,
157
+ runId: child.runId,
158
+ }),
155
159
  );
156
160
  } else {
157
161
  throw new Error(
@@ -13,6 +13,7 @@ import { realClock, type Clock } from "../clock";
13
13
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
14
14
  import type { CommsWithOrigin } from "./comms";
15
15
  import { assertSiblingAllowed } from "./routing";
16
+ import { PROMPTS } from "../prompts.generated";
16
17
  import type { ChildStatus, Comms, CommsHost, MailboxEntry } from "./types";
17
18
 
18
19
  export type AgentMessageSender =
@@ -25,11 +26,9 @@ export type AgentMessageSender =
25
26
 
26
27
  const contactSupervisorParameters = Type.Object({
27
28
  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.',
29
+ description: PROMPTS["tools.child.contact_supervisor.param.reason"],
31
30
  }),
32
- message: Type.String({ description: "Message for the supervisor." }),
31
+ message: Type.String({ description: PROMPTS["tools.child.contact_supervisor.param.message"] }),
33
32
  });
34
33
 
35
34
  export interface ContactSupervisorDetails {
@@ -45,13 +44,8 @@ export function createContactSupervisorTool(
45
44
  return {
46
45
  name: "contact_supervisor",
47
46
  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",
47
+ description: PROMPTS["tools.child.contact_supervisor.description"],
48
+ promptSnippet: PROMPTS["tools.child.contact_supervisor.snippet"],
55
49
  parameters: contactSupervisorParameters,
56
50
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
57
51
  try {
@@ -79,24 +73,18 @@ const agentMessageParameters = Type.Object({
79
73
  action: Type.Union(
80
74
  [Type.Literal("send"), Type.Literal("reply"), Type.Literal("broadcast"), Type.Literal("list")],
81
75
  {
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.',
76
+ description: PROMPTS["tools.agent_message.param.action"],
85
77
  },
86
78
  ),
87
79
  to: Type.Optional(
88
80
  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.",
81
+ description: PROMPTS["tools.agent_message.param.to"],
92
82
  }),
93
83
  ),
94
- message: Type.Optional(Type.String({ description: "Message text." })),
84
+ message: Type.Optional(Type.String({ description: PROMPTS["tools.agent_message.param.message"] })),
95
85
  delivery: Type.Optional(
96
86
  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"}).',
87
+ description: PROMPTS["tools.agent_message.param.delivery"],
100
88
  }),
101
89
  ),
102
90
  });
@@ -209,21 +197,8 @@ export function createAgentMessageTool(
209
197
  return {
210
198
  name: "agent_message",
211
199
  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",
200
+ description: isChild ? PROMPTS["tools.child.agent_message.description"] : PROMPTS["tools.agent_message.description"],
201
+ promptSnippet: isChild ? PROMPTS["tools.child.agent_message.snippet"] : PROMPTS["tools.agent_message.snippet"],
227
202
  parameters: agentMessageParameters,
228
203
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
229
204
  const action = params.action;
package/src/config.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { accessSync, constants, readFileSync, statSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
- import { delimiter, join } from "node:path";
9
+ import { delimiter, join, win32 } from "node:path";
10
10
  import { nativePackageName, resolveNativeManagerPath } from "./native-manager.js";
11
11
 
12
12
  export interface FamulusConfig {
@@ -141,11 +141,43 @@ export function getFamulusHome(env: NodeJS.ProcessEnv = process.env): string {
141
141
  return join(homedir(), ".pi", "agent", "pi-famulus");
142
142
  }
143
143
 
144
+ /** FNV-1a 64-bit over UTF-8 bytes — must match manager `sys::fnv1a64`. */
145
+ export function fnv1a64(input: string): string {
146
+ let h = 0xcbf29ce484222325n;
147
+ for (const byte of Buffer.from(input, "utf8")) {
148
+ h ^= BigInt(byte);
149
+ h = (h * 0x0100000001b3n) & 0xffffffffffffffffn;
150
+ }
151
+ return h.toString(16);
152
+ }
153
+
154
+ /**
155
+ * Named-pipe identity of a home — must match manager `lifecycle::windows_pipe_ident`.
156
+ * The home is made absolute lexically (`path.win32.resolve` against `cwd`):
157
+ * `.` / `..` and repeated separators collapse, symlinks and junctions are
158
+ * not expanded. One spelling per directory (backslashes, no trailing
159
+ * separator except a drive root, lower case). The same relative home from
160
+ * two working directories must not share a pipe.
161
+ */
162
+ export function windowsHomeKey(home: string, cwd: string = process.cwd()): string {
163
+ let s = win32.resolve(cwd, home);
164
+ while (s.length > 3 && s.endsWith("\\")) s = s.slice(0, -1);
165
+ return s.toLowerCase();
166
+ }
167
+
168
+ export function windowsPipeName(home: string, cwd: string = process.cwd()): string {
169
+ return `\\\\.\\pipe\\pi-famulus-${fnv1a64(windowsHomeKey(home, cwd))}`;
170
+ }
171
+
144
172
  /** Well-known paths inside the pi-famulus home directory (design doc §3.1). */
145
- export function famulusPaths(home: string) {
173
+ export function famulusPaths(home: string, platform: NodeJS.Platform = process.platform) {
174
+ const socket =
175
+ platform === "win32"
176
+ ? windowsPipeName(home)
177
+ : join(home, "manager.sock");
146
178
  return {
147
179
  home,
148
- socket: join(home, "manager.sock"),
180
+ socket,
149
181
  pidFile: join(home, "manager.pid"),
150
182
  spawnLock: join(home, "manager.spawn.lock"),
151
183
  log: join(home, "manager.log"),
@@ -225,6 +257,9 @@ export function loadConfig(home: string = getFamulusHome()): FamulusConfig {
225
257
  return config;
226
258
  }
227
259
 
260
+ /** File name of the manager in <home>/bin and on PATH: Windows only starts `.exe` images. */
261
+ export const MANAGER_FILE_NAME = process.platform === "win32" ? "pi-famulus.exe" : "pi-famulus";
262
+
228
263
  /** Return why a candidate is unusable, or null for an executable regular file. */
229
264
  function managerCandidateProblem(path: string): string | null {
230
265
  try {
@@ -260,14 +295,15 @@ export function describeManagerSearch(
260
295
  tried.push(nativeName
261
296
  ? `npm ${nativeName}${native ? ` ${native}` : " (missing or unusable, ignored)"}`
262
297
  : `npm native manager (unsupported ${process.platform}/${process.arch})`);
263
- tried.push(describeCandidate(join(home, "bin", "pi-famulus")));
298
+ tried.push(describeCandidate(join(home, "bin", MANAGER_FILE_NAME)));
264
299
  tried.push("pi-famulus on PATH");
265
300
  return tried.join("; ");
266
301
  }
267
302
 
268
303
  /**
269
304
  * Resolve an executable regular pi-famulus binary, or null if none is usable.
270
- * Priority: config.managerPath > PI_FAMULUS_MANAGER_PATH env > npm native package > <home>/bin/pi-famulus > PATH.
305
+ * Priority: config.managerPath > PI_FAMULUS_MANAGER_PATH env > npm native package > <home>/bin/pi-famulus > PATH
306
+ * (`pi-famulus.exe` on Windows).
271
307
  */
272
308
  export function resolveManagerPath(
273
309
  config: FamulusConfig,
@@ -279,11 +315,11 @@ export function resolveManagerPath(
279
315
  if (envPath && managerCandidateProblem(envPath) === null) return envPath;
280
316
  const native = resolveNativeManagerPath();
281
317
  if (native) return native;
282
- const bundled = join(home, "bin", "pi-famulus");
318
+ const bundled = join(home, "bin", MANAGER_FILE_NAME);
283
319
  if (managerCandidateProblem(bundled) === null) return bundled;
284
320
  for (const dir of (env.PATH ?? "").split(delimiter)) {
285
321
  if (!dir) continue;
286
- const candidate = join(dir, "pi-famulus");
322
+ const candidate = join(dir, MANAGER_FILE_NAME);
287
323
  if (managerCandidateProblem(candidate) === null) return candidate;
288
324
  }
289
325
  return null;