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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leechael and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # pi-famulus
2
+
3
+ Subagent orchestration, auto-backgrounding bash, monitoring, and agent-to-agent communication for pi.
4
+
5
+ After the first public release:
6
+
7
+ ```sh
8
+ pi install npm:pi-famulus
9
+ ```
10
+
11
+ npm installs the matching native manager automatically through an exact-version optional dependency. No Rust compiler, postinstall download, or separate manager installation is needed. Keep optional dependencies enabled.
12
+
13
+ Supported native packages:
14
+
15
+ | System | CPU | Package |
16
+ |---|---|---|
17
+ | Linux | x64 | `pi-famulus-linux-x64` |
18
+ | Linux | arm64 | `pi-famulus-linux-arm64` |
19
+ | macOS | Intel x64 | `pi-famulus-darwin-x64` |
20
+ | macOS | Apple Silicon arm64 | `pi-famulus-darwin-arm64` |
21
+
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.
23
+
24
+ The npm package also exposes the `pi-famulus` CLI (`npx pi-famulus --help`). The extension's binary search order is:
25
+
26
+ 1. Executable `managerPath` from configuration.
27
+ 2. Executable `PI_FAMULUS_MANAGER_PATH` override.
28
+ 3. The matching-version installed native npm package.
29
+ 4. Executable `~/.pi/agent/pi-famulus/bin/pi-famulus` (or the equivalent beneath `PI_FAMULUS_HOME`).
30
+ 5. Executable `pi-famulus` on `PATH`.
31
+
32
+ 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
+
34
+ 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.
35
+
36
+ See the [project README](https://github.com/Leechael/pi-famulus#readme) for tools, settings, one-time migration, and source-build instructions; see the [CLI manual](https://github.com/Leechael/pi-famulus/blob/main/docs/cli.md) for operations.
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { nativePackageName, resolveNativeManagerPath } from "../src/native-manager.js";
4
+
5
+ const binary = resolveNativeManagerPath();
6
+ if (!binary) {
7
+ const name = nativePackageName(process.platform, process.arch);
8
+ console.error(name
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.`);
11
+ process.exit(1);
12
+ }
13
+ const child = spawn(binary, process.argv.slice(2), { stdio: "inherit" });
14
+ // Supervisors often signal the wrapper PID, not the whole process group.
15
+ const forwarders = new Map(["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"].map(signal => {
16
+ const forward = () => child.kill(signal);
17
+ process.on(signal, forward);
18
+ return [signal, forward];
19
+ }));
20
+ let failed = false;
21
+ child.once("error", error => {
22
+ failed = true;
23
+ console.error(`pi-famulus: cannot execute native manager: ${error.message}`);
24
+ });
25
+ child.once("close", (code, signal) => {
26
+ for (const [name, forward] of forwarders) process.off(name, forward);
27
+ process.exitCode = failed ? 1 : (code ?? 1);
28
+ if (signal && !failed) process.kill(process.pid, signal);
29
+ });
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "pi-famulus",
3
+ "version": "0.1.0",
4
+ "description": "Subagents, monitoring, and auto-backgrounding bash for pi",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/Leechael/pi-famulus",
10
+ "directory": "extension"
11
+ },
12
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" },
13
+ "os": ["linux", "darwin"],
14
+ "cpu": ["x64", "arm64"],
15
+ "files": ["src", "bin", "README.md"],
16
+ "bin": { "pi-famulus": "./bin/pi-famulus.js" },
17
+ "optionalDependencies": {
18
+ "pi-famulus-linux-x64": "0.1.0",
19
+ "pi-famulus-linux-arm64": "0.1.0",
20
+ "pi-famulus-darwin-x64": "0.1.0",
21
+ "pi-famulus-darwin-arm64": "0.1.0"
22
+ },
23
+ "pi": {
24
+ "extensions": ["./src/index.ts"]
25
+ },
26
+ "scripts": {
27
+ "test": "vitest run",
28
+ "typecheck": "tsc --noEmit"
29
+ },
30
+ "dependencies": {
31
+ "typebox": "^1.3.7"
32
+ },
33
+ "devDependencies": {
34
+ "@earendil-works/pi-coding-agent": "0.87.1",
35
+ "@earendil-works/pi-tui": "^0.87.1",
36
+ "@types/node": "^24.0.0",
37
+ "typescript": "7.0.2",
38
+ "vitest": "^3.0.0"
39
+ },
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-coding-agent": "*",
42
+ "@earendil-works/pi-tui": "*"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "@earendil-works/pi-tui": {
46
+ "optional": true
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Built-in agent definitions (design doc §4.8).
3
+ *
4
+ * These form the lowest precedence layer: user and project definitions with
5
+ * the same name replace them wholesale.
6
+ */
7
+ import type { AgentDefinition } from "./definition";
8
+
9
+ export const BUILTIN_AGENTS: AgentDefinition[] = [
10
+ {
11
+ name: "explorer",
12
+ description:
13
+ "Fast read-only codebase exploration — finds files, symbols, and answers structure questions",
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"),
24
+ source: "builtin",
25
+ },
26
+ {
27
+ 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"),
39
+ source: "builtin",
40
+ },
41
+ ];
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Agent definition types and markdown parsing (design doc §4.8, appendix B).
3
+ *
4
+ * Pure module: zero pi dependencies, zero npm dependencies. Agent definitions
5
+ * are markdown files with a hand-parsed YAML-subset frontmatter block:
6
+ *
7
+ * ---
8
+ * name: explorer
9
+ * description: Fast codebase exploration
10
+ * tools: [read, bash, grep, find, ls] # or: read, bash, grep, find, ls
11
+ * model: anthropic:claude-haiku-4-5
12
+ * thinking: high
13
+ * ---
14
+ *
15
+ * Body becomes the system prompt (trimmed; may be empty).
16
+ */
17
+
18
+ export interface AgentDefinition {
19
+ /** Must match ^[a-z][a-z0-9-]*$ */
20
+ name: string;
21
+ /** Required, non-empty. */
22
+ description: string;
23
+ /** Tool allowlist. Defaults to ["read","bash","edit","write"]. */
24
+ tools: string[];
25
+ /** "provider:id" or bare id. */
26
+ model?: string;
27
+ thinking?: "minimal" | "low" | "medium" | "high" | "xhigh";
28
+ /** Frontmatter body, trimmed. Empty body → "". */
29
+ systemPrompt: string;
30
+ source: "builtin" | "user" | "project";
31
+ /** Filesystem path; builtins have none. */
32
+ path?: string;
33
+ }
34
+
35
+ export const DEFAULT_AGENT_TOOLS: readonly string[] = ["read", "bash", "edit", "write"];
36
+
37
+ const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
38
+ const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh"] as const;
39
+ type ThinkingLevel = (typeof THINKING_LEVELS)[number];
40
+
41
+ type Fail = (reason: string) => never;
42
+
43
+ function makeFail(source: AgentDefinition["source"], path: string | undefined): Fail {
44
+ return (reason) => {
45
+ const where = path ? ` at ${path}` : ` (${source} definition)`;
46
+ throw new Error(`Invalid agent definition${where}: ${reason}`);
47
+ };
48
+ }
49
+
50
+ /** Strip a trailing ` # comment` outside quotes and brackets (yaml-subset). */
51
+ function stripInlineComment(value: string): string {
52
+ let quote: '"' | "'" | null = null;
53
+ let depth = 0;
54
+ for (let i = 0; i < value.length; i++) {
55
+ const ch = value[i];
56
+ if (quote) {
57
+ if (ch === quote) quote = null;
58
+ continue;
59
+ }
60
+ if (ch === '"' || ch === "'") quote = ch;
61
+ else if (ch === "[") depth++;
62
+ else if (ch === "]") depth = Math.max(0, depth - 1);
63
+ else if (ch === "#" && depth === 0 && (i === 0 || /\s/.test(value[i - 1]))) {
64
+ return value.slice(0, i);
65
+ }
66
+ }
67
+ return value;
68
+ }
69
+
70
+ function unquote(value: string): string {
71
+ if (value.length >= 2) {
72
+ const first = value[0];
73
+ const last = value[value.length - 1];
74
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
75
+ return value.slice(1, -1);
76
+ }
77
+ }
78
+ return value;
79
+ }
80
+
81
+ function parseScalar(raw: string, field: string, fail: Fail): string {
82
+ const value = unquote(stripInlineComment(raw).trim());
83
+ if (value.startsWith("[") || value.endsWith("]")) {
84
+ fail(`field '${field}' must be a plain string, not an array`);
85
+ }
86
+ return value;
87
+ }
88
+
89
+ function parseStringArray(raw: string, field: string, fail: Fail): string[] {
90
+ const value = stripInlineComment(raw).trim();
91
+ // Accept both YAML flow arrays `[a, b, c]` and bare comma lists `a, b, c`
92
+ // (single item `a` included). Half-open brackets are still errors.
93
+ let inner: string;
94
+ if (value.startsWith("[") && value.endsWith("]")) {
95
+ inner = value.slice(1, -1).trim();
96
+ } else if (value.startsWith("[") || value.endsWith("]")) {
97
+ fail(`field '${field}' must use array syntax [a, b, c] or a comma list a, b, c`);
98
+ } else {
99
+ inner = value;
100
+ }
101
+ if (inner === "") return [];
102
+ return inner.split(",").map((item) => {
103
+ const scalar = unquote(item.trim());
104
+ if (scalar === "") fail(`field '${field}' contains an empty array item`);
105
+ if (scalar.startsWith("[") || scalar.startsWith("]")) {
106
+ fail(`field '${field}' has a malformed array item: "${item.trim()}"`);
107
+ }
108
+ return scalar;
109
+ });
110
+ }
111
+
112
+ interface Frontmatter {
113
+ fields: Record<string, string>;
114
+ body: string;
115
+ }
116
+
117
+ function parseFrontmatter(content: string, fail: Fail): Frontmatter {
118
+ const text = content.replace(/\r\n/g, "\n");
119
+ const lines = text.split("\n");
120
+ if ((lines[0] ?? "").trim() !== "---") {
121
+ fail("missing frontmatter block (file must start with '---')");
122
+ }
123
+ let end = -1;
124
+ for (let i = 1; i < lines.length; i++) {
125
+ if (lines[i].trim() === "---") {
126
+ end = i;
127
+ break;
128
+ }
129
+ }
130
+ if (end === -1) {
131
+ fail("unterminated frontmatter block (missing closing '---')");
132
+ }
133
+ const fields: Record<string, string> = {};
134
+ for (const line of lines.slice(1, end)) {
135
+ const trimmed = line.trim();
136
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
137
+ const match = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(trimmed);
138
+ if (!match) fail(`malformed frontmatter line: "${trimmed}"`);
139
+ fields[match[1]] = match[2];
140
+ }
141
+ return { fields, body: lines.slice(end + 1).join("\n") };
142
+ }
143
+
144
+ /**
145
+ * Parse one agent definition markdown file.
146
+ * Throws Error (message includes path and reason) on any validation failure.
147
+ */
148
+ export function parseAgentMarkdown(
149
+ content: string,
150
+ source: AgentDefinition["source"],
151
+ path?: string,
152
+ ): AgentDefinition {
153
+ const fail = makeFail(source, path);
154
+ const { fields, body } = parseFrontmatter(content, fail);
155
+
156
+ if (fields.name === undefined) fail("missing required field 'name'");
157
+ const name = parseScalar(fields.name, "name", fail);
158
+ if (!NAME_PATTERN.test(name)) {
159
+ fail(`invalid name "${name}" (must match ^[a-z][a-z0-9-]*$)`);
160
+ }
161
+
162
+ if (fields.description === undefined) fail("missing required field 'description'");
163
+ const description = parseScalar(fields.description, "description", fail);
164
+ if (description === "") fail("field 'description' must be non-empty");
165
+
166
+ let tools = [...DEFAULT_AGENT_TOOLS];
167
+ if (fields.tools !== undefined) {
168
+ tools = parseStringArray(fields.tools, "tools", fail);
169
+ }
170
+
171
+ let model: string | undefined;
172
+ if (fields.model !== undefined) {
173
+ const value = parseScalar(fields.model, "model", fail);
174
+ if (value === "") fail("field 'model' must be non-empty when present");
175
+ model = value;
176
+ }
177
+
178
+ let thinking: ThinkingLevel | undefined;
179
+ if (fields.thinking !== undefined) {
180
+ const value = parseScalar(fields.thinking, "thinking", fail);
181
+ if (!(THINKING_LEVELS as readonly string[]).includes(value)) {
182
+ fail(
183
+ `invalid thinking level "${value}" (expected one of: ${THINKING_LEVELS.join(", ")})`,
184
+ );
185
+ }
186
+ thinking = value as ThinkingLevel;
187
+ }
188
+
189
+ return {
190
+ name,
191
+ description,
192
+ tools,
193
+ ...(model !== undefined ? { model } : {}),
194
+ ...(thinking !== undefined ? { thinking } : {}),
195
+ systemPrompt: body.trim(),
196
+ source,
197
+ ...(path !== undefined ? { path } : {}),
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Resolve an agent by name. undefined → the default "worker" definition.
203
+ * Unknown names throw an Error listing all available agent names.
204
+ */
205
+ export function resolveAgent(
206
+ defs: AgentDefinition[],
207
+ name: string | undefined,
208
+ ): AgentDefinition {
209
+ const target = name ?? "worker";
210
+ const found = defs.find((def) => def.name === target);
211
+ if (found) return found;
212
+ const available = defs
213
+ .map((def) => def.name)
214
+ .sort()
215
+ .join(", ");
216
+ const list = available === "" ? "(none)" : available;
217
+ if (name === undefined) {
218
+ throw new Error(`Default agent "worker" is not defined. Available agents: ${list}`);
219
+ }
220
+ throw new Error(`Unknown agent "${name}". Available agents: ${list}`);
221
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Agent definition loading and caching (design doc §4.8, appendix B).
3
+ *
4
+ * Three-tier merge, later layers win by name:
5
+ * builtin < userDir (**\/*.md) < projectDir (**\/*.md)
6
+ *
7
+ * Pure module: node:fs/node:path only. Missing directories are an empty set,
8
+ * not an error. Unparseable files are collected into LoadReport.errors and
9
+ * never abort the load.
10
+ */
11
+ import { readdirSync, readFileSync, statSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { BUILTIN_AGENTS } from "./builtins";
14
+ import { parseAgentMarkdown, type AgentDefinition } from "./definition";
15
+
16
+ export interface LoadReport {
17
+ definitions: AgentDefinition[];
18
+ errors: { path: string; error: string }[];
19
+ }
20
+
21
+ export interface LoadAgentDefinitionsOptions {
22
+ userDir: string;
23
+ projectDir: string;
24
+ }
25
+
26
+ /** Recursively list *.md files under dir (sorted; missing dir → []). */
27
+ function scanMarkdownFiles(dir: string): string[] {
28
+ const out: string[] = [];
29
+ let entries;
30
+ try {
31
+ entries = readdirSync(dir, { withFileTypes: true });
32
+ } catch {
33
+ return out;
34
+ }
35
+ for (const entry of entries) {
36
+ const full = join(dir, entry.name);
37
+ if (entry.isDirectory()) {
38
+ out.push(...scanMarkdownFiles(full));
39
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
40
+ out.push(full);
41
+ }
42
+ }
43
+ return out.sort();
44
+ }
45
+
46
+ /** One-shot load: scan both dirs, parse everything, merge three tiers. */
47
+ export function loadAgentDefinitions(opts: LoadAgentDefinitionsOptions): LoadReport {
48
+ const merged = new Map<string, AgentDefinition>();
49
+ const errors: { path: string; error: string }[] = [];
50
+
51
+ for (const def of BUILTIN_AGENTS) {
52
+ merged.set(def.name, def);
53
+ }
54
+
55
+ const layers: { dir: string; source: "user" | "project" }[] = [
56
+ { dir: opts.userDir, source: "user" },
57
+ { dir: opts.projectDir, source: "project" },
58
+ ];
59
+ for (const { dir, source } of layers) {
60
+ for (const path of scanMarkdownFiles(dir)) {
61
+ try {
62
+ const def = parseAgentMarkdown(readFileSync(path, "utf8"), source, path);
63
+ merged.set(def.name, def);
64
+ } catch (err) {
65
+ errors.push({ path, error: err instanceof Error ? err.message : String(err) });
66
+ }
67
+ }
68
+ }
69
+
70
+ return { definitions: [...merged.values()], errors };
71
+ }
72
+
73
+ export interface AgentLoader {
74
+ /**
75
+ * Return the current definitions. Re-parses only when the set of markdown
76
+ * files or any file's mtime has changed since the last call; otherwise
77
+ * returns the identical LoadReport object.
78
+ */
79
+ reload(): LoadReport;
80
+ }
81
+
82
+ /**
83
+ * Create a loader with an mtime fingerprint cache. The fingerprint covers the
84
+ * sorted file list plus each file's mtime, so added/removed/modified files all
85
+ * trigger a re-parse while an unchanged tree costs only a directory scan.
86
+ */
87
+ export function createAgentLoader(opts: LoadAgentDefinitionsOptions): AgentLoader {
88
+ let cached: LoadReport | null = null;
89
+ let fingerprint: string | null = null;
90
+
91
+ function computeFingerprint(): string {
92
+ const parts: string[] = [];
93
+ for (const dir of [opts.userDir, opts.projectDir]) {
94
+ for (const path of scanMarkdownFiles(dir)) {
95
+ let mtime = "?";
96
+ try {
97
+ mtime = String(statSync(path).mtimeMs);
98
+ } catch {
99
+ // Unstatable file: keep "?" so the fingerprint differs from any
100
+ // previous numeric mtime and the load below records the error.
101
+ }
102
+ parts.push(`${path}:${mtime}`);
103
+ }
104
+ }
105
+ return parts.join("\n");
106
+ }
107
+
108
+ return {
109
+ reload(): LoadReport {
110
+ const fp = computeFingerprint();
111
+ if (cached !== null && fp === fingerprint) {
112
+ return cached;
113
+ }
114
+ cached = loadAgentDefinitions(opts);
115
+ fingerprint = fp;
116
+ return cached;
117
+ },
118
+ };
119
+ }