@tekmidian/pai 0.66.47 → 0.66.49
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/dist/auto-route-CMARQbQ4.mjs +8 -0
- package/dist/{auto-route-DrncbMaz.mjs → auto-route-Dpm6nZ6X.mjs} +2 -2
- package/dist/{auto-route-DrncbMaz.mjs.map → auto-route-Dpm6nZ6X.mjs.map} +1 -1
- package/dist/{chain-Bba4Y8A8.mjs → chain-xSc3-u0X.mjs} +5 -5
- package/dist/{chain-Bba4Y8A8.mjs.map → chain-xSc3-u0X.mjs.map} +1 -1
- package/dist/cli/index.mjs +14 -14
- package/dist/cli/program.mjs +14 -14
- package/dist/{config-2pR0RqHM.mjs → config-CYjjGSWq.mjs} +41 -4
- package/dist/config-CYjjGSWq.mjs.map +1 -0
- package/dist/config-IuOG1W92.mjs +4 -0
- package/dist/daemon/index.mjs +10 -10
- package/dist/{daemon-C25MKGD8.mjs → daemon-BIG7XcqE.mjs} +10 -10
- package/dist/{daemon-C6K3kikA.mjs → daemon-CiSmbylJ.mjs} +7 -7
- package/dist/{daemon-C6K3kikA.mjs.map → daemon-CiSmbylJ.mjs.map} +1 -1
- package/dist/daemon-mcp/index.mjs +8 -8
- package/dist/{fallback-DyhAZAOe.mjs → fallback-BgAukP8r.mjs} +6 -5
- package/dist/fallback-BgAukP8r.mjs.map +1 -0
- package/dist/hooks/block-sleep-poll.mjs.map +2 -2
- package/dist/hooks/load-project-context.mjs +26 -2
- package/dist/hooks/load-project-context.mjs.map +2 -2
- package/dist/hooks/route-agents-to-worker.mjs +26 -2
- package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
- package/dist/hooks/security-validator.mjs +26 -2
- package/dist/hooks/security-validator.mjs.map +2 -2
- package/dist/hooks/worker-guard.mjs +26 -2
- package/dist/hooks/worker-guard.mjs.map +2 -2
- package/dist/hooks/worker-pool-host.mjs +242 -14
- package/dist/hooks/worker-pool-host.mjs.map +4 -4
- package/dist/hooks/worker-proxy.mjs +26 -2
- package/dist/hooks/worker-proxy.mjs.map +2 -2
- package/dist/hooks/worker-status-line.mjs +26 -2
- package/dist/hooks/worker-status-line.mjs.map +2 -2
- package/dist/hooks/worker-supervision.mjs +26 -2
- package/dist/hooks/worker-supervision.mjs.map +2 -2
- package/dist/index.mjs +4 -4
- package/dist/{local-EmOjw5jq.mjs → local-Bt-7L0oB.mjs} +3 -3
- package/dist/{local-EmOjw5jq.mjs.map → local-Bt-7L0oB.mjs.map} +1 -1
- package/dist/local-oqPQEmhb.mjs +7 -0
- package/dist/{local-tiers-BIINbhkY.mjs → local-tiers-C0QSQcP1.mjs} +2 -2
- package/dist/{local-tiers-8fmjiySw.mjs → local-tiers-_ZOTbjeP.mjs} +2 -2
- package/dist/{local-tiers-8fmjiySw.mjs.map → local-tiers-_ZOTbjeP.mjs.map} +1 -1
- package/dist/{main-resolver-DBXvnYKp.mjs → main-resolver-BwjexuSA.mjs} +3 -3
- package/dist/{main-resolver-B10-lsU0.mjs → main-resolver-CVo0ab7j.mjs} +3 -3
- package/dist/{main-resolver-B10-lsU0.mjs.map → main-resolver-CVo0ab7j.mjs.map} +1 -1
- package/dist/{pai-marker-CDwQOpQ8.mjs → pai-marker-CJ6WMt05.mjs} +4 -4
- package/dist/{pai-marker-CDwQOpQ8.mjs.map → pai-marker-CJ6WMt05.mjs.map} +1 -1
- package/dist/{planner-dO-3PLjD.mjs → planner-ZKXZf9JS.mjs} +8 -8
- package/dist/{planner-dO-3PLjD.mjs.map → planner-ZKXZf9JS.mjs.map} +1 -1
- package/dist/{pool-DLhqOY3P.mjs → pool-CgsnP3La.mjs} +3 -3
- package/dist/{pool-DLhqOY3P.mjs.map → pool-CgsnP3La.mjs.map} +1 -1
- package/dist/{pool-cli-DerrWWpP.mjs → pool-cli-kGGuZCzJ.mjs} +4 -4
- package/dist/{pool-cli-DerrWWpP.mjs.map → pool-cli-kGGuZCzJ.mjs.map} +1 -1
- package/dist/{program-B7gVKc-u.mjs → program-DgcwJN6Z.mjs} +25 -463
- package/dist/program-DgcwJN6Z.mjs.map +1 -0
- package/dist/{run-a_JzcVwv.mjs → run-CsrBbtj_.mjs} +686 -21
- package/dist/run-CsrBbtj_.mjs.map +1 -0
- package/dist/{run-env-DyFZ8C1s.mjs → run-env-Cyw2iwBh.mjs} +15 -5
- package/dist/{run-env-DyFZ8C1s.mjs.map → run-env-Cyw2iwBh.mjs.map} +1 -1
- package/dist/{status-C3fBJKU_.mjs → status-B0br9zV0.mjs} +2 -2
- package/dist/status-B0br9zV0.mjs.map +1 -0
- package/dist/{supervisor-B_2JKYxe.mjs → supervisor-BZA0KICt.mjs} +7 -7
- package/dist/{supervisor-B_2JKYxe.mjs.map → supervisor-BZA0KICt.mjs.map} +1 -1
- package/dist/workers-config-BifEz8ns.mjs +4 -0
- package/dist/{worktree-lnmAOFQv.mjs → worktree-B6d-1uIn.mjs} +2 -2
- package/dist/{worktree-lnmAOFQv.mjs.map → worktree-B6d-1uIn.mjs.map} +1 -1
- package/docs/worker.md +22 -0
- package/package.json +1 -1
- package/dist/auto-route-DYuVfXwc.mjs +0 -8
- package/dist/config-2pR0RqHM.mjs.map +0 -1
- package/dist/config-CQWpEdXb.mjs +0 -4
- package/dist/fallback-DyhAZAOe.mjs.map +0 -1
- package/dist/local-B5SekWOC.mjs +0 -7
- package/dist/program-B7gVKc-u.mjs.map +0 -1
- package/dist/run-a_JzcVwv.mjs.map +0 -1
- package/dist/status-C3fBJKU_.mjs.map +0 -1
- package/dist/workers-config-nXSiW07z.mjs +0 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Y as workersLogDir } from "./status-
|
|
3
|
-
import { g as worktreesDir } from "./worktree-
|
|
1
|
+
import { R as readWorkersSection } from "./config-CYjjGSWq.mjs";
|
|
2
|
+
import { Y as workersLogDir } from "./status-B0br9zV0.mjs";
|
|
3
|
+
import { g as worktreesDir } from "./worktree-B6d-1uIn.mjs";
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { dirname, join, resolve, sep } from "node:path";
|
|
@@ -344,4 +344,4 @@ function discoverPaiMarkers(searchDirs) {
|
|
|
344
344
|
|
|
345
345
|
//#endregion
|
|
346
346
|
export { unregistrableReason as a, pruneWorktreeProjects as i, ensurePaiMarker as n, worktreeReason as o, readPaiMarker as r, discoverPaiMarkers as t };
|
|
347
|
-
//# sourceMappingURL=pai-marker-
|
|
347
|
+
//# sourceMappingURL=pai-marker-CJ6WMt05.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pai-marker-CDwQOpQ8.mjs","names":[],"sources":["../src/registry/registrable.ts","../src/registry/pai-marker.ts"],"sourcesContent":["/**\n * registrable.ts — directories that must never become registered projects.\n *\n * A project is a durable thing. The registry, however, has been accepting\n * whatever directory a session happened to start in, and some of those\n * directories are disposable by construction. Measured on the real registry,\n * 2026-08-04:\n *\n * 08 - Others/MDF/Infrastruktur/.claude/worktrees/cool-haibt 1 session\n * 08 - Others/MDF/Infrastruktur/.claude/worktrees/strange-haibt 7 sessions\n * /private/tmp/ops-webui dead\n * /private/tmp/claude-501/-Users-…-AIBroker/aae854c6-… dead\n *\n * The worktrees are agent isolation directories, created to be removed. The temp\n * paths are exactly what their name says. Registering them attributes session\n * history to a location with no future, and lets `pai <name>` route someone into\n * a directory a cleanup can delete underneath them.\n *\n * So this refuses at the point of registration rather than reclassifying\n * afterwards. It is a guard, not a policy about what health should call things —\n * it stops the set growing while the vocabulary question (dead vs duplicate vs\n * misnamed vs ephemeral) is decided separately.\n *\n * Found by the AIBroker session while we were dividing up the dead-path work.\n */\n\nimport { readFileSync, statSync } from \"node:fs\";\nimport { dirname, resolve, sep } from \"node:path\";\nimport { readWorkersSection } from \"../workers/config.js\";\nimport { workersLogDir } from \"../workers/paths.js\";\nimport { worktreesDir } from \"../workers/worktree.js\";\nimport type { RegistryBackend } from \"../storage/registry-interface.js\";\n\n/**\n * The `gitdir:` target of a linked worktree, or undefined. A linked worktree's\n * `.git` is a FILE (\"gitdir: <main>/.git/worktrees/<name>\"); a normal repo's is\n * a directory and a submodule's points under `.git/modules/`.\n */\nfunction linkedWorktreeGitdir(root: string): string | undefined {\n const dotGit = resolve(root, \".git\");\n try {\n if (!statSync(dotGit).isFile()) return undefined;\n const m = readFileSync(dotGit, \"utf8\").match(/^gitdir:\\s*(.+?)\\s*$/m);\n return m && m[1].includes(`${sep}worktrees${sep}`) ? m[1] : undefined;\n } catch {\n return undefined;\n }\n}\n\n/** True when `root` is a linked git worktree (its `.git` is a `gitdir:` file). */\nexport function isLinkedWorktree(root: string): boolean {\n return linkedWorktreeGitdir(root) !== undefined;\n}\n\n/** The main repo a linked worktree belongs to, when resolvable. */\nexport function mainRepoOf(root: string): string | undefined {\n const gitdir = linkedWorktreeGitdir(root);\n // <main>/.git/worktrees/<name> -> <main>\n return gitdir ? dirname(dirname(dirname(gitdir))) : undefined;\n}\n\n/** The worker worktrees root from the workers config; undefined if unreadable. */\nfunction workerWorktreesRoot(): string | undefined {\n try {\n return resolve(worktreesDir(workersLogDir(readWorkersSection().workers)));\n } catch {\n return undefined;\n }\n}\n\n/** Path fragments that mark a location as disposable, with why. */\nconst EPHEMERAL: ReadonlyArray<{ fragment: string; because: string }> = [\n {\n // Agent worktrees. `.claude/worktrees/<name>` is created for isolation during\n // one task and removed afterwards.\n fragment: `${sep}.claude${sep}worktrees${sep}`,\n because: \"a git worktree created for agent isolation — it is meant to be removed\",\n },\n {\n // Worker log dirs and their worktrees, current (`<pai home>/logs/workers`)\n // and legacy (`~/.claude/logs/workers`) layouts, including the dir itself.\n fragment: `${sep}logs${sep}workers${sep}`,\n because: \"a worker log directory or worktree — it is meant to be removed\",\n },\n {\n fragment: `${sep}private${sep}tmp${sep}`,\n because: \"a system temp directory\",\n },\n {\n fragment: `${sep}var${sep}folders${sep}`,\n because: \"a macOS per-user temp directory\",\n },\n];\n\n/** Why a path is a (linked or worker) git worktree, or undefined. */\nexport function worktreeReason(\n rootPath: string,\n worktreesRoot: string | undefined = workerWorktreesRoot()\n): string | undefined {\n if (isLinkedWorktree(rootPath)) {\n const main = mainRepoOf(rootPath);\n return `a linked git worktree${main ? ` of ${main}` : \"\"}`;\n }\n const abs = resolve(rootPath);\n if (worktreesRoot && (abs === worktreesRoot || abs.startsWith(worktreesRoot + sep))) {\n return \"a worker worktree\";\n }\n return undefined;\n}\n\n/**\n * Why this path cannot be a project, or undefined if it can.\n *\n * Returns the reason rather than a boolean so the caller can tell the user which\n * rule caught them — \"refused\" without a reason invites someone to work around it\n * rather than move their project somewhere durable.\n */\nexport function unregistrableReason(\n rootPath: string,\n worktreesRoot: string | undefined = workerWorktreesRoot()\n): string | undefined {\n const wt = worktreeReason(rootPath, worktreesRoot);\n if (wt) return wt;\n\n // Compare with trailing separators so a fragment cannot match a partial\n // directory name: `/tmp/` must not match `/tmpdir/`.\n const padded = rootPath.endsWith(sep) ? rootPath : rootPath + sep;\n\n for (const { fragment, because } of EPHEMERAL) {\n if (padded.includes(fragment)) return because;\n }\n\n // A bare `/tmp/...` too. Kept separate from the list above because on macOS\n // /tmp is a symlink to /private/tmp, so both spellings reach the same place and\n // only one of them contains \"private\".\n if (padded.startsWith(`${sep}tmp${sep}`)) return \"a system temp directory\";\n\n return undefined;\n}\n\n/**\n * Remove registry rows whose root is unregistrable (linked worktree, worker\n * worktree/log dir, temp dir; rows registered before the guard existed). Returns\n * the slugs removed. Sessions on those rows are worker/agent scratch history.\n */\nexport async function pruneWorktreeProjects(\n backend: RegistryBackend,\n worktreesRoot: string | undefined = workerWorktreesRoot()\n): Promise<string[]> {\n const removed: string[] = [];\n for (const p of await backend.listProjects({})) {\n if (unregistrableReason(p.root_path, worktreesRoot)) {\n await backend.deleteProjectCascade(p.id);\n removed.push(p.slug);\n }\n }\n return removed;\n}\n","/**\n * PAI.md marker file management.\n *\n * Each registered project gets a `Notes/PAI.md` file with a YAML frontmatter\n * `pai:` block that PAI manages. The rest of the file (body content, other\n * frontmatter keys) is user-owned and never modified by PAI.\n *\n * YAML parsing/updating is done with simple regex — no external dependency.\n */\n\nimport {\n existsSync,\n readFileSync,\n writeFileSync,\n mkdirSync,\n readdirSync,\n statSync,\n} from \"node:fs\";\nimport { join, resolve } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { worktreeReason } from \"./registrable.js\";\n\n// ---------------------------------------------------------------------------\n// Container directories\n// ---------------------------------------------------------------------------\n\n/**\n * Directories that hold projects but are not themselves projects.\n *\n * Running a single Claude session from ~/Desktop is enough for the scanner to\n * register it as a project, after which every scan recreates a `Notes/PAI.md`\n * inside it. The user gets a Notes folder on their Desktop that reappears\n * however many times they delete it, containing a marker file that is\n * otherwise empty.\n *\n * The session is still worth tracking — it just belongs in the central notes\n * directory (~/.claude/projects/<encoded>/Notes) rather than as a folder\n * dropped into the user's Desktop.\n */\nconst CONTAINER_DIRS = [\n \"Desktop\",\n \"Documents\",\n \"Downloads\",\n \"Movies\",\n \"Music\",\n \"Pictures\",\n \"Public\",\n \"Library\",\n \"Applications\",\n];\n\n/**\n * True when a path is a place projects live rather than a project itself:\n * the home directory, a standard user container, or a system temp root.\n *\n * Only ever used to decline *creating* a marker. An existing marker is left\n * alone — the user may have put one there deliberately.\n */\nexport function isContainerDirectory(projectRoot: string): boolean {\n // Strip trailing slashes, but never strip the root itself down to \"\".\n const strip = (s: string): string => s.replace(/(?!^)\\/+$/, \"\");\n\n const p = strip(resolve(projectRoot));\n const home = strip(resolve(homedir()));\n\n if (p === home || p === \"/\") return true;\n if (p === \"/tmp\" || p === \"/private/tmp\" || p === \"/var/tmp\") return true;\n\n for (const dir of CONTAINER_DIRS) {\n if (p === join(home, dir)) return true;\n }\n\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface PaiMarker {\n /** Absolute path to the PAI.md file */\n path: string;\n /** The `slug` value from the `pai:` frontmatter block */\n slug: string;\n /** Absolute path to the project root (parent of Notes/) */\n projectRoot: string;\n}\n\n// ---------------------------------------------------------------------------\n// Template content (mirrors templates/pai-project.template.md)\n// ---------------------------------------------------------------------------\n\nconst TEMPLATE = `---\npai:\n slug: \"\\${SLUG}\"\n registered: \"\\${DATE}\"\n last_indexed: null\n status: active\n---\n\n# \\${DISPLAY_NAME}\n\n<!-- Everything below the YAML frontmatter is yours — PAI never modifies content here. -->\n<!-- Use this file for project notes, decisions, preferences, or anything you want. -->\n<!-- PAI only reads and updates the \\`pai:\\` block in the frontmatter above. -->\n`;\n\nfunction isoDate(): string {\n return new Date().toISOString().slice(0, 10);\n}\n\nfunction renderTemplate(slug: string, displayName: string): string {\n return TEMPLATE.replace(/\\$\\{SLUG\\}/g, slug)\n .replace(/\\$\\{DATE\\}/g, isoDate())\n .replace(/\\$\\{DISPLAY_NAME\\}/g, displayName);\n}\n\n// ---------------------------------------------------------------------------\n// YAML frontmatter helpers (regex-based, no external dependency)\n// ---------------------------------------------------------------------------\n\n/**\n * Split a markdown file with YAML frontmatter into its parts.\n *\n * Returns { frontmatter, body } where:\n * frontmatter — content between the opening and closing `---` delimiters\n * body — everything after the closing `---` line\n *\n * Returns null if the file does not begin with a `---` frontmatter block.\n */\nfunction parseFrontmatter(\n content: string\n): { frontmatter: string; body: string } | null {\n if (!content.startsWith(\"---\")) return null;\n\n // Skip past the opening \"---\" and its line ending\n const afterOpen = content.slice(3);\n const eolMatch = afterOpen.match(/^\\r?\\n/);\n if (!eolMatch) return null;\n\n const rest = afterOpen.slice(eolMatch[0].length);\n\n // Find closing \"---\" on its own line\n const closeMatch = rest.match(/^([\\s\\S]*?)\\n---[ \\t]*(\\r?\\n|$)/m);\n if (!closeMatch) return null;\n\n const frontmatter = closeMatch[1];\n const body = rest.slice(closeMatch[0].length);\n\n return { frontmatter, body };\n}\n\n/**\n * Extract a simple scalar YAML value from a block of YAML text.\n *\n * extractYamlValue(' slug: \"my-proj\"', \"slug\") → \"my-proj\"\n * extractYamlValue(' slug: my-proj', \"slug\") → \"my-proj\"\n * extractYamlValue(' last_indexed: null', \"last_indexed\") → \"null\"\n */\nfunction extractYamlValue(yamlBlock: string, key: string): string | null {\n const re = new RegExp(\n `^[ \\\\t]*${key}[ \\\\t]*:[ \\\\t]*\"?([^\"\\\\n\\\\r]*?)\"?[ \\\\t]*$`,\n \"m\"\n );\n const match = yamlBlock.match(re);\n if (!match) return null;\n return match[1].trim() || null;\n}\n\n/**\n * Replace the `pai:` mapping block inside a frontmatter string with\n * `newPaiBlock`. If no `pai:` block is found, appends it at the end.\n *\n * The regex captures the `pai:` key and all immediately-following indented\n * lines (the mapping values), then replaces the whole group.\n *\n * Edge case: the last indented line may not have a trailing newline when it\n * is the final line of the frontmatter string. We handle this by matching\n * lines that end with \\n OR with end-of-string.\n */\nfunction replacePaiBlock(frontmatter: string, newPaiBlock: string): string {\n // Normalise: ensure the frontmatter string ends with \\n so the regex\n // always finds a clean boundary after the last indented line.\n const fm = frontmatter.endsWith(\"\\n\") ? frontmatter : frontmatter + \"\\n\";\n\n // Match \"pai:\\n\" followed by any number of indented lines (each ending \\n).\n const paiRe = /^pai:[ \\t]*\\r?\\n(?:[ \\t]+[^\\r\\n]*\\r?\\n)*/m;\n if (paiRe.test(fm)) {\n // Replace and strip the extra trailing \\n we may have added.\n const replaced = fm.replace(paiRe, newPaiBlock);\n return frontmatter.endsWith(\"\\n\") ? replaced : replaced.replace(/\\n$/, \"\");\n }\n // pai: key not found — append it\n return fm + newPaiBlock;\n}\n\n/**\n * Build the canonical `pai:` YAML block (with a trailing newline).\n */\nfunction buildPaiBlock(\n slug: string,\n registered: string,\n lastIndexed: string | null,\n status: string\n): string {\n const lastIndexedStr =\n lastIndexed === null ? \"null\" : `\"${lastIndexed}\"`;\n return (\n `pai:\\n` +\n ` slug: \"${slug}\"\\n` +\n ` registered: \"${registered}\"\\n` +\n ` last_indexed: ${lastIndexedStr}\\n` +\n ` status: ${status}\\n`\n );\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Create or update `<projectRoot>/Notes/PAI.md`.\n *\n * - File absent: creates `Notes/` if needed, writes from template.\n * - File present: updates only the `pai:` frontmatter block; body and all\n * other frontmatter keys are preserved verbatim.\n *\n * @param projectRoot Absolute path to the project root directory.\n * @param slug PAI slug for this project.\n * @param displayName Human-readable name (defaults to slug if omitted).\n */\nexport function ensurePaiMarker(\n projectRoot: string,\n slug: string,\n displayName?: string\n): void {\n // A linked or worker worktree shares its tracked files with the main repo:\n // writing the slug here lands in the main repo's PAI.md when the branch is merged.\n if (worktreeReason(projectRoot)) return;\n\n const notesDir = join(projectRoot, \"Notes\");\n const markerPath = join(notesDir, \"PAI.md\");\n const name = displayName ?? slug;\n\n // --- File does not exist — create from template ---\n if (!existsSync(markerPath)) {\n // Never create a Notes/ folder inside a container directory. Doing so is\n // what puts a Notes folder on the user's Desktop that comes back every\n // time it is deleted.\n if (isContainerDirectory(projectRoot)) return;\n\n mkdirSync(notesDir, { recursive: true });\n writeFileSync(markerPath, renderTemplate(slug, name), \"utf8\");\n return;\n }\n\n // --- File exists — update only the `pai:` block ---\n const raw = readFileSync(markerPath, \"utf8\");\n const parsed = parseFrontmatter(raw);\n\n if (!parsed) {\n // No YAML frontmatter — prepend a fresh one, treat the whole file as body.\n const paiBlock = buildPaiBlock(slug, isoDate(), null, \"active\");\n const newContent = `---\\n${paiBlock}---\\n\\n${raw}`;\n writeFileSync(markerPath, newContent, \"utf8\");\n return;\n }\n\n const { frontmatter, body } = parsed;\n\n // Preserve existing `registered` date so we don't reset it on re-scan.\n const existingRegistered =\n extractYamlValue(frontmatter, \"registered\") ?? isoDate();\n\n // Preserve existing `last_indexed` value (may be \"null\" string or a date).\n const rawLastIndexed = extractYamlValue(frontmatter, \"last_indexed\");\n const lastIndexed =\n rawLastIndexed === null || rawLastIndexed === \"null\"\n ? null\n : rawLastIndexed;\n\n // Preserve existing `status`.\n const existingStatus = extractYamlValue(frontmatter, \"status\") ?? \"active\";\n\n const newPaiBlock = buildPaiBlock(\n slug,\n existingRegistered,\n lastIndexed,\n existingStatus\n );\n\n const newFrontmatter = replacePaiBlock(frontmatter, newPaiBlock);\n\n // Ensure the frontmatter block ends with exactly one newline before the\n // closing --- delimiter.\n const fmWithNewline = newFrontmatter.endsWith(\"\\n\")\n ? newFrontmatter\n : newFrontmatter + \"\\n\";\n\n // Reconstruct the full file. Preserve whatever separator the body has.\n const newContent = `---\\n${fmWithNewline}---\\n${body}`;\n writeFileSync(markerPath, newContent, \"utf8\");\n}\n\n/**\n * Read PAI marker data from `<projectRoot>/Notes/PAI.md`.\n * Returns null if the file does not exist or contains no `pai:` block.\n */\nexport function readPaiMarker(\n projectRoot: string\n): { slug: string; registered: string; status: string } | null {\n const markerPath = join(projectRoot, \"Notes\", \"PAI.md\");\n if (!existsSync(markerPath)) return null;\n\n const raw = readFileSync(markerPath, \"utf8\");\n const parsed = parseFrontmatter(raw);\n if (!parsed) return null;\n\n const slug = extractYamlValue(parsed.frontmatter, \"slug\");\n if (!slug) return null;\n\n const registered =\n extractYamlValue(parsed.frontmatter, \"registered\") ?? \"\";\n const status =\n extractYamlValue(parsed.frontmatter, \"status\") ?? \"active\";\n\n return { slug, registered, status };\n}\n\n/**\n * Scan a list of parent directories for `<child>/Notes/PAI.md` marker files.\n * Each directory in `searchDirs` is scanned one level deep — its immediate\n * child directories are checked for a `Notes/PAI.md` file.\n *\n * Returns an array of PaiMarker objects for every valid marker found.\n * Invalid or malformed markers are silently skipped.\n *\n * @param searchDirs Absolute paths to parent directories.\n */\nexport function discoverPaiMarkers(searchDirs: string[]): PaiMarker[] {\n const results: PaiMarker[] = [];\n\n for (const dir of searchDirs) {\n if (!existsSync(dir)) continue;\n\n let children: string[];\n try {\n children = readdirSync(dir);\n } catch {\n continue;\n }\n\n for (const child of children) {\n if (child.startsWith(\".\")) continue;\n const childPath = join(dir, child);\n try {\n if (!statSync(childPath).isDirectory()) continue;\n } catch {\n continue;\n }\n\n const markerData = readPaiMarker(childPath);\n if (!markerData) continue;\n\n results.push({\n path: join(childPath, \"Notes\", \"PAI.md\"),\n slug: markerData.slug,\n projectRoot: childPath,\n });\n }\n }\n\n return results;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"pai-marker-CJ6WMt05.mjs","names":[],"sources":["../src/registry/registrable.ts","../src/registry/pai-marker.ts"],"sourcesContent":["/**\n * registrable.ts — directories that must never become registered projects.\n *\n * A project is a durable thing. The registry, however, has been accepting\n * whatever directory a session happened to start in, and some of those\n * directories are disposable by construction. Measured on the real registry,\n * 2026-08-04:\n *\n * 08 - Others/MDF/Infrastruktur/.claude/worktrees/cool-haibt 1 session\n * 08 - Others/MDF/Infrastruktur/.claude/worktrees/strange-haibt 7 sessions\n * /private/tmp/ops-webui dead\n * /private/tmp/claude-501/-Users-…-AIBroker/aae854c6-… dead\n *\n * The worktrees are agent isolation directories, created to be removed. The temp\n * paths are exactly what their name says. Registering them attributes session\n * history to a location with no future, and lets `pai <name>` route someone into\n * a directory a cleanup can delete underneath them.\n *\n * So this refuses at the point of registration rather than reclassifying\n * afterwards. It is a guard, not a policy about what health should call things —\n * it stops the set growing while the vocabulary question (dead vs duplicate vs\n * misnamed vs ephemeral) is decided separately.\n *\n * Found by the AIBroker session while we were dividing up the dead-path work.\n */\n\nimport { readFileSync, statSync } from \"node:fs\";\nimport { dirname, resolve, sep } from \"node:path\";\nimport { readWorkersSection } from \"../workers/config.js\";\nimport { workersLogDir } from \"../workers/paths.js\";\nimport { worktreesDir } from \"../workers/worktree.js\";\nimport type { RegistryBackend } from \"../storage/registry-interface.js\";\n\n/**\n * The `gitdir:` target of a linked worktree, or undefined. A linked worktree's\n * `.git` is a FILE (\"gitdir: <main>/.git/worktrees/<name>\"); a normal repo's is\n * a directory and a submodule's points under `.git/modules/`.\n */\nfunction linkedWorktreeGitdir(root: string): string | undefined {\n const dotGit = resolve(root, \".git\");\n try {\n if (!statSync(dotGit).isFile()) return undefined;\n const m = readFileSync(dotGit, \"utf8\").match(/^gitdir:\\s*(.+?)\\s*$/m);\n return m && m[1].includes(`${sep}worktrees${sep}`) ? m[1] : undefined;\n } catch {\n return undefined;\n }\n}\n\n/** True when `root` is a linked git worktree (its `.git` is a `gitdir:` file). */\nexport function isLinkedWorktree(root: string): boolean {\n return linkedWorktreeGitdir(root) !== undefined;\n}\n\n/** The main repo a linked worktree belongs to, when resolvable. */\nexport function mainRepoOf(root: string): string | undefined {\n const gitdir = linkedWorktreeGitdir(root);\n // <main>/.git/worktrees/<name> -> <main>\n return gitdir ? dirname(dirname(dirname(gitdir))) : undefined;\n}\n\n/** The worker worktrees root from the workers config; undefined if unreadable. */\nfunction workerWorktreesRoot(): string | undefined {\n try {\n return resolve(worktreesDir(workersLogDir(readWorkersSection().workers)));\n } catch {\n return undefined;\n }\n}\n\n/** Path fragments that mark a location as disposable, with why. */\nconst EPHEMERAL: ReadonlyArray<{ fragment: string; because: string }> = [\n {\n // Agent worktrees. `.claude/worktrees/<name>` is created for isolation during\n // one task and removed afterwards.\n fragment: `${sep}.claude${sep}worktrees${sep}`,\n because: \"a git worktree created for agent isolation — it is meant to be removed\",\n },\n {\n // Worker log dirs and their worktrees, current (`<pai home>/logs/workers`)\n // and legacy (`~/.claude/logs/workers`) layouts, including the dir itself.\n fragment: `${sep}logs${sep}workers${sep}`,\n because: \"a worker log directory or worktree — it is meant to be removed\",\n },\n {\n fragment: `${sep}private${sep}tmp${sep}`,\n because: \"a system temp directory\",\n },\n {\n fragment: `${sep}var${sep}folders${sep}`,\n because: \"a macOS per-user temp directory\",\n },\n];\n\n/** Why a path is a (linked or worker) git worktree, or undefined. */\nexport function worktreeReason(\n rootPath: string,\n worktreesRoot: string | undefined = workerWorktreesRoot()\n): string | undefined {\n if (isLinkedWorktree(rootPath)) {\n const main = mainRepoOf(rootPath);\n return `a linked git worktree${main ? ` of ${main}` : \"\"}`;\n }\n const abs = resolve(rootPath);\n if (worktreesRoot && (abs === worktreesRoot || abs.startsWith(worktreesRoot + sep))) {\n return \"a worker worktree\";\n }\n return undefined;\n}\n\n/**\n * Why this path cannot be a project, or undefined if it can.\n *\n * Returns the reason rather than a boolean so the caller can tell the user which\n * rule caught them — \"refused\" without a reason invites someone to work around it\n * rather than move their project somewhere durable.\n */\nexport function unregistrableReason(\n rootPath: string,\n worktreesRoot: string | undefined = workerWorktreesRoot()\n): string | undefined {\n const wt = worktreeReason(rootPath, worktreesRoot);\n if (wt) return wt;\n\n // Compare with trailing separators so a fragment cannot match a partial\n // directory name: `/tmp/` must not match `/tmpdir/`.\n const padded = rootPath.endsWith(sep) ? rootPath : rootPath + sep;\n\n for (const { fragment, because } of EPHEMERAL) {\n if (padded.includes(fragment)) return because;\n }\n\n // A bare `/tmp/...` too. Kept separate from the list above because on macOS\n // /tmp is a symlink to /private/tmp, so both spellings reach the same place and\n // only one of them contains \"private\".\n if (padded.startsWith(`${sep}tmp${sep}`)) return \"a system temp directory\";\n\n return undefined;\n}\n\n/**\n * Remove registry rows whose root is unregistrable (linked worktree, worker\n * worktree/log dir, temp dir; rows registered before the guard existed). Returns\n * the slugs removed. Sessions on those rows are worker/agent scratch history.\n */\nexport async function pruneWorktreeProjects(\n backend: RegistryBackend,\n worktreesRoot: string | undefined = workerWorktreesRoot()\n): Promise<string[]> {\n const removed: string[] = [];\n for (const p of await backend.listProjects({})) {\n if (unregistrableReason(p.root_path, worktreesRoot)) {\n await backend.deleteProjectCascade(p.id);\n removed.push(p.slug);\n }\n }\n return removed;\n}\n","/**\n * PAI.md marker file management.\n *\n * Each registered project gets a `Notes/PAI.md` file with a YAML frontmatter\n * `pai:` block that PAI manages. The rest of the file (body content, other\n * frontmatter keys) is user-owned and never modified by PAI.\n *\n * YAML parsing/updating is done with simple regex — no external dependency.\n */\n\nimport {\n existsSync,\n readFileSync,\n writeFileSync,\n mkdirSync,\n readdirSync,\n statSync,\n} from \"node:fs\";\nimport { join, resolve } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { worktreeReason } from \"./registrable.js\";\n\n// ---------------------------------------------------------------------------\n// Container directories\n// ---------------------------------------------------------------------------\n\n/**\n * Directories that hold projects but are not themselves projects.\n *\n * Running a single Claude session from ~/Desktop is enough for the scanner to\n * register it as a project, after which every scan recreates a `Notes/PAI.md`\n * inside it. The user gets a Notes folder on their Desktop that reappears\n * however many times they delete it, containing a marker file that is\n * otherwise empty.\n *\n * The session is still worth tracking — it just belongs in the central notes\n * directory (~/.claude/projects/<encoded>/Notes) rather than as a folder\n * dropped into the user's Desktop.\n */\nconst CONTAINER_DIRS = [\n \"Desktop\",\n \"Documents\",\n \"Downloads\",\n \"Movies\",\n \"Music\",\n \"Pictures\",\n \"Public\",\n \"Library\",\n \"Applications\",\n];\n\n/**\n * True when a path is a place projects live rather than a project itself:\n * the home directory, a standard user container, or a system temp root.\n *\n * Only ever used to decline *creating* a marker. An existing marker is left\n * alone — the user may have put one there deliberately.\n */\nexport function isContainerDirectory(projectRoot: string): boolean {\n // Strip trailing slashes, but never strip the root itself down to \"\".\n const strip = (s: string): string => s.replace(/(?!^)\\/+$/, \"\");\n\n const p = strip(resolve(projectRoot));\n const home = strip(resolve(homedir()));\n\n if (p === home || p === \"/\") return true;\n if (p === \"/tmp\" || p === \"/private/tmp\" || p === \"/var/tmp\") return true;\n\n for (const dir of CONTAINER_DIRS) {\n if (p === join(home, dir)) return true;\n }\n\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface PaiMarker {\n /** Absolute path to the PAI.md file */\n path: string;\n /** The `slug` value from the `pai:` frontmatter block */\n slug: string;\n /** Absolute path to the project root (parent of Notes/) */\n projectRoot: string;\n}\n\n// ---------------------------------------------------------------------------\n// Template content (mirrors templates/pai-project.template.md)\n// ---------------------------------------------------------------------------\n\nconst TEMPLATE = `---\npai:\n slug: \"\\${SLUG}\"\n registered: \"\\${DATE}\"\n last_indexed: null\n status: active\n---\n\n# \\${DISPLAY_NAME}\n\n<!-- Everything below the YAML frontmatter is yours — PAI never modifies content here. -->\n<!-- Use this file for project notes, decisions, preferences, or anything you want. -->\n<!-- PAI only reads and updates the \\`pai:\\` block in the frontmatter above. -->\n`;\n\nfunction isoDate(): string {\n return new Date().toISOString().slice(0, 10);\n}\n\nfunction renderTemplate(slug: string, displayName: string): string {\n return TEMPLATE.replace(/\\$\\{SLUG\\}/g, slug)\n .replace(/\\$\\{DATE\\}/g, isoDate())\n .replace(/\\$\\{DISPLAY_NAME\\}/g, displayName);\n}\n\n// ---------------------------------------------------------------------------\n// YAML frontmatter helpers (regex-based, no external dependency)\n// ---------------------------------------------------------------------------\n\n/**\n * Split a markdown file with YAML frontmatter into its parts.\n *\n * Returns { frontmatter, body } where:\n * frontmatter — content between the opening and closing `---` delimiters\n * body — everything after the closing `---` line\n *\n * Returns null if the file does not begin with a `---` frontmatter block.\n */\nfunction parseFrontmatter(\n content: string\n): { frontmatter: string; body: string } | null {\n if (!content.startsWith(\"---\")) return null;\n\n // Skip past the opening \"---\" and its line ending\n const afterOpen = content.slice(3);\n const eolMatch = afterOpen.match(/^\\r?\\n/);\n if (!eolMatch) return null;\n\n const rest = afterOpen.slice(eolMatch[0].length);\n\n // Find closing \"---\" on its own line\n const closeMatch = rest.match(/^([\\s\\S]*?)\\n---[ \\t]*(\\r?\\n|$)/m);\n if (!closeMatch) return null;\n\n const frontmatter = closeMatch[1];\n const body = rest.slice(closeMatch[0].length);\n\n return { frontmatter, body };\n}\n\n/**\n * Extract a simple scalar YAML value from a block of YAML text.\n *\n * extractYamlValue(' slug: \"my-proj\"', \"slug\") → \"my-proj\"\n * extractYamlValue(' slug: my-proj', \"slug\") → \"my-proj\"\n * extractYamlValue(' last_indexed: null', \"last_indexed\") → \"null\"\n */\nfunction extractYamlValue(yamlBlock: string, key: string): string | null {\n const re = new RegExp(\n `^[ \\\\t]*${key}[ \\\\t]*:[ \\\\t]*\"?([^\"\\\\n\\\\r]*?)\"?[ \\\\t]*$`,\n \"m\"\n );\n const match = yamlBlock.match(re);\n if (!match) return null;\n return match[1].trim() || null;\n}\n\n/**\n * Replace the `pai:` mapping block inside a frontmatter string with\n * `newPaiBlock`. If no `pai:` block is found, appends it at the end.\n *\n * The regex captures the `pai:` key and all immediately-following indented\n * lines (the mapping values), then replaces the whole group.\n *\n * Edge case: the last indented line may not have a trailing newline when it\n * is the final line of the frontmatter string. We handle this by matching\n * lines that end with \\n OR with end-of-string.\n */\nfunction replacePaiBlock(frontmatter: string, newPaiBlock: string): string {\n // Normalise: ensure the frontmatter string ends with \\n so the regex\n // always finds a clean boundary after the last indented line.\n const fm = frontmatter.endsWith(\"\\n\") ? frontmatter : frontmatter + \"\\n\";\n\n // Match \"pai:\\n\" followed by any number of indented lines (each ending \\n).\n const paiRe = /^pai:[ \\t]*\\r?\\n(?:[ \\t]+[^\\r\\n]*\\r?\\n)*/m;\n if (paiRe.test(fm)) {\n // Replace and strip the extra trailing \\n we may have added.\n const replaced = fm.replace(paiRe, newPaiBlock);\n return frontmatter.endsWith(\"\\n\") ? replaced : replaced.replace(/\\n$/, \"\");\n }\n // pai: key not found — append it\n return fm + newPaiBlock;\n}\n\n/**\n * Build the canonical `pai:` YAML block (with a trailing newline).\n */\nfunction buildPaiBlock(\n slug: string,\n registered: string,\n lastIndexed: string | null,\n status: string\n): string {\n const lastIndexedStr =\n lastIndexed === null ? \"null\" : `\"${lastIndexed}\"`;\n return (\n `pai:\\n` +\n ` slug: \"${slug}\"\\n` +\n ` registered: \"${registered}\"\\n` +\n ` last_indexed: ${lastIndexedStr}\\n` +\n ` status: ${status}\\n`\n );\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Create or update `<projectRoot>/Notes/PAI.md`.\n *\n * - File absent: creates `Notes/` if needed, writes from template.\n * - File present: updates only the `pai:` frontmatter block; body and all\n * other frontmatter keys are preserved verbatim.\n *\n * @param projectRoot Absolute path to the project root directory.\n * @param slug PAI slug for this project.\n * @param displayName Human-readable name (defaults to slug if omitted).\n */\nexport function ensurePaiMarker(\n projectRoot: string,\n slug: string,\n displayName?: string\n): void {\n // A linked or worker worktree shares its tracked files with the main repo:\n // writing the slug here lands in the main repo's PAI.md when the branch is merged.\n if (worktreeReason(projectRoot)) return;\n\n const notesDir = join(projectRoot, \"Notes\");\n const markerPath = join(notesDir, \"PAI.md\");\n const name = displayName ?? slug;\n\n // --- File does not exist — create from template ---\n if (!existsSync(markerPath)) {\n // Never create a Notes/ folder inside a container directory. Doing so is\n // what puts a Notes folder on the user's Desktop that comes back every\n // time it is deleted.\n if (isContainerDirectory(projectRoot)) return;\n\n mkdirSync(notesDir, { recursive: true });\n writeFileSync(markerPath, renderTemplate(slug, name), \"utf8\");\n return;\n }\n\n // --- File exists — update only the `pai:` block ---\n const raw = readFileSync(markerPath, \"utf8\");\n const parsed = parseFrontmatter(raw);\n\n if (!parsed) {\n // No YAML frontmatter — prepend a fresh one, treat the whole file as body.\n const paiBlock = buildPaiBlock(slug, isoDate(), null, \"active\");\n const newContent = `---\\n${paiBlock}---\\n\\n${raw}`;\n writeFileSync(markerPath, newContent, \"utf8\");\n return;\n }\n\n const { frontmatter, body } = parsed;\n\n // Preserve existing `registered` date so we don't reset it on re-scan.\n const existingRegistered =\n extractYamlValue(frontmatter, \"registered\") ?? isoDate();\n\n // Preserve existing `last_indexed` value (may be \"null\" string or a date).\n const rawLastIndexed = extractYamlValue(frontmatter, \"last_indexed\");\n const lastIndexed =\n rawLastIndexed === null || rawLastIndexed === \"null\"\n ? null\n : rawLastIndexed;\n\n // Preserve existing `status`.\n const existingStatus = extractYamlValue(frontmatter, \"status\") ?? \"active\";\n\n const newPaiBlock = buildPaiBlock(\n slug,\n existingRegistered,\n lastIndexed,\n existingStatus\n );\n\n const newFrontmatter = replacePaiBlock(frontmatter, newPaiBlock);\n\n // Ensure the frontmatter block ends with exactly one newline before the\n // closing --- delimiter.\n const fmWithNewline = newFrontmatter.endsWith(\"\\n\")\n ? newFrontmatter\n : newFrontmatter + \"\\n\";\n\n // Reconstruct the full file. Preserve whatever separator the body has.\n const newContent = `---\\n${fmWithNewline}---\\n${body}`;\n writeFileSync(markerPath, newContent, \"utf8\");\n}\n\n/**\n * Read PAI marker data from `<projectRoot>/Notes/PAI.md`.\n * Returns null if the file does not exist or contains no `pai:` block.\n */\nexport function readPaiMarker(\n projectRoot: string\n): { slug: string; registered: string; status: string } | null {\n const markerPath = join(projectRoot, \"Notes\", \"PAI.md\");\n if (!existsSync(markerPath)) return null;\n\n const raw = readFileSync(markerPath, \"utf8\");\n const parsed = parseFrontmatter(raw);\n if (!parsed) return null;\n\n const slug = extractYamlValue(parsed.frontmatter, \"slug\");\n if (!slug) return null;\n\n const registered =\n extractYamlValue(parsed.frontmatter, \"registered\") ?? \"\";\n const status =\n extractYamlValue(parsed.frontmatter, \"status\") ?? \"active\";\n\n return { slug, registered, status };\n}\n\n/**\n * Scan a list of parent directories for `<child>/Notes/PAI.md` marker files.\n * Each directory in `searchDirs` is scanned one level deep — its immediate\n * child directories are checked for a `Notes/PAI.md` file.\n *\n * Returns an array of PaiMarker objects for every valid marker found.\n * Invalid or malformed markers are silently skipped.\n *\n * @param searchDirs Absolute paths to parent directories.\n */\nexport function discoverPaiMarkers(searchDirs: string[]): PaiMarker[] {\n const results: PaiMarker[] = [];\n\n for (const dir of searchDirs) {\n if (!existsSync(dir)) continue;\n\n let children: string[];\n try {\n children = readdirSync(dir);\n } catch {\n continue;\n }\n\n for (const child of children) {\n if (child.startsWith(\".\")) continue;\n const childPath = join(dir, child);\n try {\n if (!statSync(childPath).isDirectory()) continue;\n } catch {\n continue;\n }\n\n const markerData = readPaiMarker(childPath);\n if (!markerData) continue;\n\n results.push({\n path: join(childPath, \"Notes\", \"PAI.md\"),\n slug: markerData.slug,\n projectRoot: childPath,\n });\n }\n }\n\n return results;\n}\n"],"mappings":"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"}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import "./config-DSdCEcHS.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import { B as parseRunnerArgs, G as ledgerPath, V as shortText, Y as workersLogDir, f as loadStatus, m as newWorkerId } from "./status-
|
|
4
|
-
import { b as appendLedger, l as mergeWorker, o as git } from "./worktree-
|
|
2
|
+
import { R as readWorkersSection } from "./config-CYjjGSWq.mjs";
|
|
3
|
+
import { B as parseRunnerArgs, G as ledgerPath, V as shortText, Y as workersLogDir, f as loadStatus, m as newWorkerId } from "./status-B0br9zV0.mjs";
|
|
4
|
+
import { b as appendLedger, l as mergeWorker, o as git } from "./worktree-B6d-1uIn.mjs";
|
|
5
5
|
import "./helpers-CU6YCGGN.mjs";
|
|
6
6
|
import "./postgres-P567vfbi.mjs";
|
|
7
7
|
import "./factory-DRAEDNKV.mjs";
|
|
8
8
|
import "./embedding-gate-BwkbwfIL.mjs";
|
|
9
9
|
import "./embeddings-Bkx-RREL.mjs";
|
|
10
10
|
import "./router-bMM15VNd.mjs";
|
|
11
|
-
import "./pool-
|
|
12
|
-
import { n as runWorker, t as printResult } from "./run-
|
|
13
|
-
import "./run-env-
|
|
14
|
-
import { n as swapPromptArg } from "./chain-
|
|
11
|
+
import "./pool-CgsnP3La.mjs";
|
|
12
|
+
import { n as runWorker, t as printResult } from "./run-CsrBbtj_.mjs";
|
|
13
|
+
import "./run-env-Cyw2iwBh.mjs";
|
|
14
|
+
import { n as swapPromptArg } from "./chain-xSc3-u0X.mjs";
|
|
15
15
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
|
|
@@ -305,4 +305,4 @@ function printPlannerSummary(logDir, plannerId, results, merged, conflicted, fmt
|
|
|
305
305
|
|
|
306
306
|
//#endregion
|
|
307
307
|
export { runPlanner };
|
|
308
|
-
//# sourceMappingURL=planner-
|
|
308
|
+
//# sourceMappingURL=planner-ZKXZf9JS.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"planner-dO-3PLjD.mjs","names":[],"sources":["../src/workers/planner.ts"],"sourcesContent":["/**\n * planner.ts — the plan class: plan, spawn, integrate.\n *\n * `pai worker run --class plan -p \"<goal>\"` does not run one worker but a\n * small orchestration:\n *\n * 1. a planner worker (the plan class's provider) reads the repository and\n * writes `<logDir>/plans/<planner id>.json` — 5–50 sub-tasks, fewer only\n * when the goal itself names a smaller count, each with title, brief,\n * class, files and acceptance;\n * 2. the runner validates the plan and spawns the sub-tasks as children of\n * the planner worker (`parent` = the planner id, so `ps` shows the\n * tree), at most `workers.tree.maxChildren` at a time;\n * 3. each child that finishes delivers its structured report to the\n * planner's inbox as a `kind: \"result\"` handoff (see handoff.ts);\n * 4. the run finishes with a summary report and the branches to merge.\n *\n * The planner's prompt carries the prompt rules from the self-driving\n * codebases write-up: domain-specific instructions only, constraints over\n * step lists, explicit quantity ranges, no checkbox style.\n */\n\nimport { existsSync, mkdirSync, readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { readWorkersSection } from \"./config.js\";\nimport { appendLedger } from \"./ledger.js\";\nimport { ledgerPath, workersLogDir } from \"./paths.js\";\nimport { loadStatus, newWorkerId } from \"./status.js\";\nimport { parseRunnerArgs, shortText } from \"./args.js\";\nimport { swapPromptArg } from \"./chain.js\";\nimport { runWorker, printResult, type RunOptions, type StreamEvent } from \"./run.js\";\nimport { git, mergeWorker } from \"./worktree.js\";\nimport type { WorkerReport } from \"./report.js\";\n\n/** Where a planner run's plan file lives: <logDir>/plans/<planner id>.json. */\nexport function planPathFor(logDir: string, plannerId: string): string {\n return join(logDir, \"plans\", `${plannerId}.json`);\n}\n\nexport interface PlannerTask {\n title: string;\n brief: string;\n /** Task class of the child (implement, research, …); default implement. */\n class?: string;\n /** Files the sub-task likely touches. */\n files?: string[];\n /** How to tell the sub-task is done. */\n acceptance?: string[];\n}\n\nexport const MIN_TASKS = 5;\nexport const MAX_TASKS = 50;\n\n/**\n * Validate a raw plan file into its tasks. Accepts 1–MAX_TASKS tasks: the\n * prompt asks for MIN_TASKS–MAX_TASKS, but a goal may itself name a smaller\n * explicit count (\"one sub-task per file\" with three files), and that\n * explicit range wins. Everything else — missing file, damaged JSON, tasks\n * without a title or brief — throws with a message the operator can act on.\n */\nexport function validatePlan(raw: unknown): PlannerTask[] {\n if (typeof raw !== \"object\" || raw === null || Array.isArray(raw)) {\n throw new Error(\"plan file must be a JSON object\");\n }\n const tasksRaw = (raw as Record<string, unknown>).tasks;\n if (!Array.isArray(tasksRaw)) throw new Error('plan file needs a \"tasks\" array');\n if (tasksRaw.length < 1) throw new Error(\"plan file has no tasks\");\n if (tasksRaw.length > MAX_TASKS) {\n throw new Error(`plan file has ${tasksRaw.length} tasks; the maximum is ${MAX_TASKS}`);\n }\n const tasks: PlannerTask[] = [];\n for (let i = 0; i < tasksRaw.length; i++) {\n const t = tasksRaw[i];\n if (typeof t !== \"object\" || t === null || Array.isArray(t)) {\n throw new Error(`task ${i + 1} must be an object`);\n }\n const o = t as Record<string, unknown>;\n const title = typeof o.title === \"string\" ? o.title.trim() : \"\";\n const brief = typeof o.brief === \"string\" ? o.brief.trim() : \"\";\n if (!title) throw new Error(`task ${i + 1} needs a non-empty \"title\"`);\n if (!brief) throw new Error(`task ${i + 1} needs a non-empty \"brief\"`);\n const strArr = (v: unknown): string[] | undefined =>\n Array.isArray(v) && v.every((x) => typeof x === \"string\") ? (v as string[]) : undefined;\n const files = strArr(o.files);\n const acceptance = strArr(o.acceptance);\n tasks.push({\n title,\n brief,\n ...(typeof o.class === \"string\" && o.class.trim() ? { class: o.class.trim() } : {}),\n ...(files?.length ? { files } : {}),\n ...(acceptance?.length ? { acceptance } : {}),\n });\n }\n return tasks;\n}\n\n/** The planner worker's instructions, goal and prompt rules included. */\nexport function plannerPrompt(goal: string, planFile: string, maxChildren: number): string {\n return [\n \"You are a PLANNER worker. Turn the goal below into a plan file and stop —\",\n \"you do not implement anything, and you do not spawn workers yourself:\",\n \"the runner executes your plan on your behalf (the sub-tasks run as your\",\n \"children and report back to you).\",\n \"\",\n `Write the plan with the Write tool to ${planFile} as one JSON object:`,\n '{\"tasks\":[{\"title\":\"…\",\"brief\":\"…\",\"class\":\"implement\",\"files\":[\"…\"],\"acceptance\":[\"…\"]}]}',\n \"\",\n \"Prompt rules for the plan itself (they are yours too):\",\n \"- Domain-specific instructions only: name real files, commands and\",\n \" constraints of this repository, never generic advice.\",\n \"- Constraints over step lists: state what must hold (and what must not\",\n \" change), not a numbered procedure to follow.\",\n \"- Explicit quantity ranges: how many, how much, how long — never\",\n ' \"several\", \"some\", \"as needed\".',\n \"- No checkbox style: no `[ ]` items, no step numbering theatre.\",\n \"\",\n `Emit between ${MIN_TASKS} and ${MAX_TASKS} sub-tasks — unless the goal itself`,\n 'names a smaller explicit count (\"one sub-task per file\" with three files',\n \"means three); an explicit count in the goal always wins.\",\n `At most ${maxChildren} of them run at a time, so independent tasks are better`,\n \"than long chains. Each task's class picks its provider: implement, draft,\",\n \"review, research, spotcheck, simple, complex, image.\",\n \"\",\n \"Read the repository first (Glob/Grep/Read) so tasks name real files.\",\n \"Every sub-task runs in its own worktree of the same repository: name files\",\n \"relative to the repository root and never an absolute path or a worktree\",\n \"directory in a title, brief, files or acceptance entry.\",\n \"\",\n \"## Goal\",\n \"\",\n goal,\n ].join(\"\\n\");\n}\n\n/** The child's prompt for one planned sub-task. */\nexport function taskPrompt(goal: string, task: PlannerTask, index: number, of: number): string {\n return [\n `You are sub-task ${index + 1} of ${of} of a planned goal. Do exactly this sub-task;`,\n \"the other sub-tasks are other workers' business.\",\n \"\",\n `# ${task.title}`,\n \"\",\n task.brief,\n ...(task.files?.length ? [\"\", \"Files likely touched: \" + task.files.join(\", \")] : []),\n ...(task.acceptance?.length\n ? [\"\", \"Done means:\", ...task.acceptance.map((a) => `- ${a}`)]\n : []),\n \"\",\n \"## The overall goal (context only — your scope is the sub-task above)\",\n \"\",\n goal,\n ].join(\"\\n\");\n}\n\nexport interface PlannerDeps {\n /** Stage runner; tests inject a mock, production uses runWorker. */\n runStage?: (opts: RunOptions) => Promise<number>;\n /** logDir override for tests. */\n logDir?: string;\n /** maxChildren override for tests. */\n maxChildren?: number;\n}\n\n/**\n * The planner orchestration; returns the process exit code. The planner id is\n * minted here and preset on the phase-1 run, so the plan file's name is known\n * before the worker starts and the prompt can name its exact path.\n */\nexport async function runPlanner(opts: RunOptions, deps: PlannerDeps = {}): Promise<number> {\n const runStage = deps.runStage ?? runWorker;\n const { workers: config } = readWorkersSection();\n const logDir = deps.logDir ?? workersLogDir(config);\n const maxChildren = deps.maxChildren ?? config.tree.maxChildren;\n const parsed = parseRunnerArgs(opts.claudeArgs);\n const goal = parsed.prompt ?? \"\";\n const plannerId = newWorkerId();\n const planFile = planPathFor(logDir, plannerId);\n mkdirSync(join(logDir, \"plans\"), { recursive: true });\n\n // --- phase 1: the planner worker writes the plan file\n process.stderr.write(`planner ${plannerId}: writing plan (${planFile})\\n`);\n const rc1 = await runStage({\n ...opts,\n id: plannerId,\n claudeArgs: swapPromptArg(opts.claudeArgs, plannerPrompt(goal, planFile, maxChildren)),\n quiet: true,\n _planner: true,\n });\n if (rc1 !== 0) return rc1;\n if (!existsSync(planFile)) {\n process.stderr.write(\n `planner ${plannerId}: no plan file at ${planFile} — the planner worker did not write one. ` +\n `Re-run, or write the plan yourself and run the tasks with pai worker run.\\n`\n );\n appendLedger(ledgerPath(logDir), \"WORKER-PLAN-END\", { planner: plannerId, rc: 1, failed: \"plan\" });\n return 1;\n }\n let tasks: PlannerTask[];\n try {\n tasks = validatePlan(JSON.parse(readFileSync(planFile, \"utf8\")));\n } catch (e) {\n process.stderr.write(`planner ${plannerId}: invalid plan file ${planFile}: ${(e as Error).message}\\n`);\n appendLedger(ledgerPath(logDir), \"WORKER-PLAN-END\", { planner: plannerId, rc: 1, failed: \"plan\" });\n return 1;\n }\n appendLedger(ledgerPath(logDir), \"WORKER-PLAN\", {\n planner: plannerId,\n tasks: tasks.length,\n plan: planFile,\n });\n\n // --- phase 2: run the sub-tasks as children, maxChildren at a time. Each\n // child is spawned in the planner's own worktree (when it got one) rather\n // than the operator's cwd, so its branch is created off the planner's own\n // HEAD and can later be merged straight into it.\n const plannerStatus = loadStatus(logDir, plannerId);\n const plannerWorktreeDir = plannerStatus?.worktreeDir ?? null;\n const results: { task: PlannerTask; rc: number; id: string }[] = [];\n for (let i = 0; i < tasks.length; i += maxChildren) {\n const wave = tasks.slice(i, i + maxChildren);\n process.stderr.write(\n `planner ${plannerId}: sub-tasks ${i + 1}–${i + wave.length} of ${tasks.length}\\n`\n );\n const waveIds: string[] = new Array(wave.length).fill(\"\");\n const rcs = await Promise.all(\n wave.map((task, w) =>\n runStage({\n className: task.class ?? \"implement\",\n providerFlag: opts.providerFlag,\n modelFlag: opts.modelFlag,\n label: shortText(task.title, 40),\n noPane: opts.noPane,\n mcpFlag: opts.mcpFlag,\n claudeArgs: swapPromptArg(opts.claudeArgs, taskPrompt(goal, task, i + w, tasks.length)),\n cwd: plannerWorktreeDir ?? opts.cwd,\n worktreeFlag: opts.worktreeFlag,\n parent: plannerId,\n quiet: true,\n onWorkerStart: (wid) => {\n waveIds[w] = wid;\n },\n })\n )\n );\n for (let w = 0; w < wave.length; w++) results.push({ task: wave[w], rc: rcs[w], id: waveIds[w] });\n }\n\n // --- phase 3: the planner merges each finished child's branch into its\n // OWN worktree branch, in spawn order — never into the operator's checkout\n // — reusing `pai worker merge`'s own logic (mergeWorker). A conflicting\n // merge is aborted cleanly so the planner's branch stays clean and the\n // next child still gets a try; the child's branch is left intact either way.\n const merged: string[] = [];\n const conflicted: { id: string; branch: string; paths: string[] }[] = [];\n if (plannerWorktreeDir) {\n for (const r of results) {\n if (r.rc !== 0 || !r.id) continue;\n const st = loadStatus(logDir, r.id);\n if (!st?.branch) continue; // ran in place, or no worktree: nothing to merge\n try {\n mergeWorker(logDir, r.id);\n merged.push(r.id);\n } catch {\n let paths: string[] = [];\n try {\n git(plannerWorktreeDir, [\"rev-parse\", \"-q\", \"--verify\", \"MERGE_HEAD\"]);\n paths = git(plannerWorktreeDir, [\"diff\", \"--name-only\", \"--diff-filter=U\"])\n .split(\"\\n\")\n .filter(Boolean);\n git(plannerWorktreeDir, [\"merge\", \"--abort\"]);\n } catch {\n // no merge was left in progress: the failure was not a conflict\n }\n conflicted.push({ id: r.id, branch: st.branch, paths });\n }\n }\n }\n\n // --- phase 4: summary from the children's statuses and inbox handoffs\n const failed = results.filter((r) => r.rc !== 0).length;\n appendLedger(ledgerPath(logDir), \"WORKER-PLAN-END\", {\n planner: plannerId,\n rc: failed || conflicted.length ? 1 : 0,\n failed,\n merged: merged.length,\n conflicted: conflicted.length,\n });\n printPlannerSummary(logDir, plannerId, results, merged, conflicted, parsed.outputFormat, opts.quiet === true);\n return failed || conflicted.length ? 1 : 0;\n}\n\n/** Compose and print the planner's summary report (text or json). */\nfunction printPlannerSummary(\n logDir: string,\n plannerId: string,\n results: { task: PlannerTask; rc: number; id: string }[],\n merged: string[],\n conflicted: { id: string; branch: string; paths: string[] }[],\n fmt: \"text\" | \"json\" | \"stream-json\",\n quiet: boolean\n): void {\n const ok = results.filter((r) => r.rc === 0).length;\n const attempted = new Set([...merged, ...conflicted.map((c) => c.id)]);\n // a branch never attempted (no planner worktree to merge into) still needs\n // the old \"go merge me by hand\" hint\n const stillOpen: { id: string; branch: string }[] = [];\n for (const r of results) {\n if (r.rc !== 0 || !r.id || attempted.has(r.id)) continue;\n const st = loadStatus(logDir, r.id);\n if (st?.branch && !st.merged) stillOpen.push({ id: r.id, branch: st.branch });\n }\n const report: WorkerReport = {\n checks: results.map((r) => ({\n name: r.task.title,\n ok: r.rc === 0,\n detail: r.rc === 0 ? shortText(r.id ? loadStatus(logDir, r.id)?.last : \"\", 80) : `rc=${r.rc}`,\n })),\n ...(merged.length || conflicted.length || stillOpen.length\n ? {\n open: [\n ...merged.map((id) => `merged: ${id}`),\n ...conflicted.map(\n (c) =>\n `conflict: pai worker merge ${c.id} (${c.branch})` +\n (c.paths.length ? ` — ${c.paths.join(\", \")}` : \"\")\n ),\n ...stillOpen.map((c) => `branch to merge: pai worker merge ${c.id} (${c.branch})`),\n ],\n }\n : {}),\n notes:\n `${ok}/${results.length} sub-tasks ok` +\n (merged.length ? `; ${merged.length} merged` : \"\") +\n (conflicted.length ? `; ${conflicted.length} conflict(s)` : \"\") +\n (stillOpen.length ? `; ${stillOpen.length} branch(es) to merge` : \"\"),\n };\n if (quiet || fmt === \"stream-json\") return;\n const allOk = ok === results.length && conflicted.length === 0;\n const resultEvent: StreamEvent = {\n type: \"result\",\n result: [\n `planner ${plannerId}: ${report.notes}`,\n ...(conflicted.length ? [\"conflicts:\"] : []),\n ...conflicted.map(\n (c) =>\n ` pai worker merge ${c.id} # ${c.branch}, conflicting: ${c.paths.join(\", \") || \"unknown\"}`\n ),\n ...(stillOpen.length ? [\"branches to merge:\"] : []),\n ...stillOpen.map((c) => ` pai worker merge ${c.id} # ${c.branch}`),\n ].join(\"\\n\"),\n is_error: !allOk,\n };\n printResult(fmt, resultEvent, allOk ? 0 : 1, logDir, plannerId, report, {\n plan: results.length,\n ...(merged.length ? { mergedBranches: merged } : {}),\n ...(conflicted.length ? { conflictedBranches: conflicted.map((c) => c.branch) } : {}),\n ...(stillOpen.length ? { branches: stillOpen.map((c) => c.branch) } : {}),\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"planner-ZKXZf9JS.mjs","names":[],"sources":["../src/workers/planner.ts"],"sourcesContent":["/**\n * planner.ts — the plan class: plan, spawn, integrate.\n *\n * `pai worker run --class plan -p \"<goal>\"` does not run one worker but a\n * small orchestration:\n *\n * 1. a planner worker (the plan class's provider) reads the repository and\n * writes `<logDir>/plans/<planner id>.json` — 5–50 sub-tasks, fewer only\n * when the goal itself names a smaller count, each with title, brief,\n * class, files and acceptance;\n * 2. the runner validates the plan and spawns the sub-tasks as children of\n * the planner worker (`parent` = the planner id, so `ps` shows the\n * tree), at most `workers.tree.maxChildren` at a time;\n * 3. each child that finishes delivers its structured report to the\n * planner's inbox as a `kind: \"result\"` handoff (see handoff.ts);\n * 4. the run finishes with a summary report and the branches to merge.\n *\n * The planner's prompt carries the prompt rules from the self-driving\n * codebases write-up: domain-specific instructions only, constraints over\n * step lists, explicit quantity ranges, no checkbox style.\n */\n\nimport { existsSync, mkdirSync, readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { readWorkersSection } from \"./config.js\";\nimport { appendLedger } from \"./ledger.js\";\nimport { ledgerPath, workersLogDir } from \"./paths.js\";\nimport { loadStatus, newWorkerId } from \"./status.js\";\nimport { parseRunnerArgs, shortText } from \"./args.js\";\nimport { swapPromptArg } from \"./chain.js\";\nimport { runWorker, printResult, type RunOptions, type StreamEvent } from \"./run.js\";\nimport { git, mergeWorker } from \"./worktree.js\";\nimport type { WorkerReport } from \"./report.js\";\n\n/** Where a planner run's plan file lives: <logDir>/plans/<planner id>.json. */\nexport function planPathFor(logDir: string, plannerId: string): string {\n return join(logDir, \"plans\", `${plannerId}.json`);\n}\n\nexport interface PlannerTask {\n title: string;\n brief: string;\n /** Task class of the child (implement, research, …); default implement. */\n class?: string;\n /** Files the sub-task likely touches. */\n files?: string[];\n /** How to tell the sub-task is done. */\n acceptance?: string[];\n}\n\nexport const MIN_TASKS = 5;\nexport const MAX_TASKS = 50;\n\n/**\n * Validate a raw plan file into its tasks. Accepts 1–MAX_TASKS tasks: the\n * prompt asks for MIN_TASKS–MAX_TASKS, but a goal may itself name a smaller\n * explicit count (\"one sub-task per file\" with three files), and that\n * explicit range wins. Everything else — missing file, damaged JSON, tasks\n * without a title or brief — throws with a message the operator can act on.\n */\nexport function validatePlan(raw: unknown): PlannerTask[] {\n if (typeof raw !== \"object\" || raw === null || Array.isArray(raw)) {\n throw new Error(\"plan file must be a JSON object\");\n }\n const tasksRaw = (raw as Record<string, unknown>).tasks;\n if (!Array.isArray(tasksRaw)) throw new Error('plan file needs a \"tasks\" array');\n if (tasksRaw.length < 1) throw new Error(\"plan file has no tasks\");\n if (tasksRaw.length > MAX_TASKS) {\n throw new Error(`plan file has ${tasksRaw.length} tasks; the maximum is ${MAX_TASKS}`);\n }\n const tasks: PlannerTask[] = [];\n for (let i = 0; i < tasksRaw.length; i++) {\n const t = tasksRaw[i];\n if (typeof t !== \"object\" || t === null || Array.isArray(t)) {\n throw new Error(`task ${i + 1} must be an object`);\n }\n const o = t as Record<string, unknown>;\n const title = typeof o.title === \"string\" ? o.title.trim() : \"\";\n const brief = typeof o.brief === \"string\" ? o.brief.trim() : \"\";\n if (!title) throw new Error(`task ${i + 1} needs a non-empty \"title\"`);\n if (!brief) throw new Error(`task ${i + 1} needs a non-empty \"brief\"`);\n const strArr = (v: unknown): string[] | undefined =>\n Array.isArray(v) && v.every((x) => typeof x === \"string\") ? (v as string[]) : undefined;\n const files = strArr(o.files);\n const acceptance = strArr(o.acceptance);\n tasks.push({\n title,\n brief,\n ...(typeof o.class === \"string\" && o.class.trim() ? { class: o.class.trim() } : {}),\n ...(files?.length ? { files } : {}),\n ...(acceptance?.length ? { acceptance } : {}),\n });\n }\n return tasks;\n}\n\n/** The planner worker's instructions, goal and prompt rules included. */\nexport function plannerPrompt(goal: string, planFile: string, maxChildren: number): string {\n return [\n \"You are a PLANNER worker. Turn the goal below into a plan file and stop —\",\n \"you do not implement anything, and you do not spawn workers yourself:\",\n \"the runner executes your plan on your behalf (the sub-tasks run as your\",\n \"children and report back to you).\",\n \"\",\n `Write the plan with the Write tool to ${planFile} as one JSON object:`,\n '{\"tasks\":[{\"title\":\"…\",\"brief\":\"…\",\"class\":\"implement\",\"files\":[\"…\"],\"acceptance\":[\"…\"]}]}',\n \"\",\n \"Prompt rules for the plan itself (they are yours too):\",\n \"- Domain-specific instructions only: name real files, commands and\",\n \" constraints of this repository, never generic advice.\",\n \"- Constraints over step lists: state what must hold (and what must not\",\n \" change), not a numbered procedure to follow.\",\n \"- Explicit quantity ranges: how many, how much, how long — never\",\n ' \"several\", \"some\", \"as needed\".',\n \"- No checkbox style: no `[ ]` items, no step numbering theatre.\",\n \"\",\n `Emit between ${MIN_TASKS} and ${MAX_TASKS} sub-tasks — unless the goal itself`,\n 'names a smaller explicit count (\"one sub-task per file\" with three files',\n \"means three); an explicit count in the goal always wins.\",\n `At most ${maxChildren} of them run at a time, so independent tasks are better`,\n \"than long chains. Each task's class picks its provider: implement, draft,\",\n \"review, research, spotcheck, simple, complex, image.\",\n \"\",\n \"Read the repository first (Glob/Grep/Read) so tasks name real files.\",\n \"Every sub-task runs in its own worktree of the same repository: name files\",\n \"relative to the repository root and never an absolute path or a worktree\",\n \"directory in a title, brief, files or acceptance entry.\",\n \"\",\n \"## Goal\",\n \"\",\n goal,\n ].join(\"\\n\");\n}\n\n/** The child's prompt for one planned sub-task. */\nexport function taskPrompt(goal: string, task: PlannerTask, index: number, of: number): string {\n return [\n `You are sub-task ${index + 1} of ${of} of a planned goal. Do exactly this sub-task;`,\n \"the other sub-tasks are other workers' business.\",\n \"\",\n `# ${task.title}`,\n \"\",\n task.brief,\n ...(task.files?.length ? [\"\", \"Files likely touched: \" + task.files.join(\", \")] : []),\n ...(task.acceptance?.length\n ? [\"\", \"Done means:\", ...task.acceptance.map((a) => `- ${a}`)]\n : []),\n \"\",\n \"## The overall goal (context only — your scope is the sub-task above)\",\n \"\",\n goal,\n ].join(\"\\n\");\n}\n\nexport interface PlannerDeps {\n /** Stage runner; tests inject a mock, production uses runWorker. */\n runStage?: (opts: RunOptions) => Promise<number>;\n /** logDir override for tests. */\n logDir?: string;\n /** maxChildren override for tests. */\n maxChildren?: number;\n}\n\n/**\n * The planner orchestration; returns the process exit code. The planner id is\n * minted here and preset on the phase-1 run, so the plan file's name is known\n * before the worker starts and the prompt can name its exact path.\n */\nexport async function runPlanner(opts: RunOptions, deps: PlannerDeps = {}): Promise<number> {\n const runStage = deps.runStage ?? runWorker;\n const { workers: config } = readWorkersSection();\n const logDir = deps.logDir ?? workersLogDir(config);\n const maxChildren = deps.maxChildren ?? config.tree.maxChildren;\n const parsed = parseRunnerArgs(opts.claudeArgs);\n const goal = parsed.prompt ?? \"\";\n const plannerId = newWorkerId();\n const planFile = planPathFor(logDir, plannerId);\n mkdirSync(join(logDir, \"plans\"), { recursive: true });\n\n // --- phase 1: the planner worker writes the plan file\n process.stderr.write(`planner ${plannerId}: writing plan (${planFile})\\n`);\n const rc1 = await runStage({\n ...opts,\n id: plannerId,\n claudeArgs: swapPromptArg(opts.claudeArgs, plannerPrompt(goal, planFile, maxChildren)),\n quiet: true,\n _planner: true,\n });\n if (rc1 !== 0) return rc1;\n if (!existsSync(planFile)) {\n process.stderr.write(\n `planner ${plannerId}: no plan file at ${planFile} — the planner worker did not write one. ` +\n `Re-run, or write the plan yourself and run the tasks with pai worker run.\\n`\n );\n appendLedger(ledgerPath(logDir), \"WORKER-PLAN-END\", { planner: plannerId, rc: 1, failed: \"plan\" });\n return 1;\n }\n let tasks: PlannerTask[];\n try {\n tasks = validatePlan(JSON.parse(readFileSync(planFile, \"utf8\")));\n } catch (e) {\n process.stderr.write(`planner ${plannerId}: invalid plan file ${planFile}: ${(e as Error).message}\\n`);\n appendLedger(ledgerPath(logDir), \"WORKER-PLAN-END\", { planner: plannerId, rc: 1, failed: \"plan\" });\n return 1;\n }\n appendLedger(ledgerPath(logDir), \"WORKER-PLAN\", {\n planner: plannerId,\n tasks: tasks.length,\n plan: planFile,\n });\n\n // --- phase 2: run the sub-tasks as children, maxChildren at a time. Each\n // child is spawned in the planner's own worktree (when it got one) rather\n // than the operator's cwd, so its branch is created off the planner's own\n // HEAD and can later be merged straight into it.\n const plannerStatus = loadStatus(logDir, plannerId);\n const plannerWorktreeDir = plannerStatus?.worktreeDir ?? null;\n const results: { task: PlannerTask; rc: number; id: string }[] = [];\n for (let i = 0; i < tasks.length; i += maxChildren) {\n const wave = tasks.slice(i, i + maxChildren);\n process.stderr.write(\n `planner ${plannerId}: sub-tasks ${i + 1}–${i + wave.length} of ${tasks.length}\\n`\n );\n const waveIds: string[] = new Array(wave.length).fill(\"\");\n const rcs = await Promise.all(\n wave.map((task, w) =>\n runStage({\n className: task.class ?? \"implement\",\n providerFlag: opts.providerFlag,\n modelFlag: opts.modelFlag,\n label: shortText(task.title, 40),\n noPane: opts.noPane,\n mcpFlag: opts.mcpFlag,\n claudeArgs: swapPromptArg(opts.claudeArgs, taskPrompt(goal, task, i + w, tasks.length)),\n cwd: plannerWorktreeDir ?? opts.cwd,\n worktreeFlag: opts.worktreeFlag,\n parent: plannerId,\n quiet: true,\n onWorkerStart: (wid) => {\n waveIds[w] = wid;\n },\n })\n )\n );\n for (let w = 0; w < wave.length; w++) results.push({ task: wave[w], rc: rcs[w], id: waveIds[w] });\n }\n\n // --- phase 3: the planner merges each finished child's branch into its\n // OWN worktree branch, in spawn order — never into the operator's checkout\n // — reusing `pai worker merge`'s own logic (mergeWorker). A conflicting\n // merge is aborted cleanly so the planner's branch stays clean and the\n // next child still gets a try; the child's branch is left intact either way.\n const merged: string[] = [];\n const conflicted: { id: string; branch: string; paths: string[] }[] = [];\n if (plannerWorktreeDir) {\n for (const r of results) {\n if (r.rc !== 0 || !r.id) continue;\n const st = loadStatus(logDir, r.id);\n if (!st?.branch) continue; // ran in place, or no worktree: nothing to merge\n try {\n mergeWorker(logDir, r.id);\n merged.push(r.id);\n } catch {\n let paths: string[] = [];\n try {\n git(plannerWorktreeDir, [\"rev-parse\", \"-q\", \"--verify\", \"MERGE_HEAD\"]);\n paths = git(plannerWorktreeDir, [\"diff\", \"--name-only\", \"--diff-filter=U\"])\n .split(\"\\n\")\n .filter(Boolean);\n git(plannerWorktreeDir, [\"merge\", \"--abort\"]);\n } catch {\n // no merge was left in progress: the failure was not a conflict\n }\n conflicted.push({ id: r.id, branch: st.branch, paths });\n }\n }\n }\n\n // --- phase 4: summary from the children's statuses and inbox handoffs\n const failed = results.filter((r) => r.rc !== 0).length;\n appendLedger(ledgerPath(logDir), \"WORKER-PLAN-END\", {\n planner: plannerId,\n rc: failed || conflicted.length ? 1 : 0,\n failed,\n merged: merged.length,\n conflicted: conflicted.length,\n });\n printPlannerSummary(logDir, plannerId, results, merged, conflicted, parsed.outputFormat, opts.quiet === true);\n return failed || conflicted.length ? 1 : 0;\n}\n\n/** Compose and print the planner's summary report (text or json). */\nfunction printPlannerSummary(\n logDir: string,\n plannerId: string,\n results: { task: PlannerTask; rc: number; id: string }[],\n merged: string[],\n conflicted: { id: string; branch: string; paths: string[] }[],\n fmt: \"text\" | \"json\" | \"stream-json\",\n quiet: boolean\n): void {\n const ok = results.filter((r) => r.rc === 0).length;\n const attempted = new Set([...merged, ...conflicted.map((c) => c.id)]);\n // a branch never attempted (no planner worktree to merge into) still needs\n // the old \"go merge me by hand\" hint\n const stillOpen: { id: string; branch: string }[] = [];\n for (const r of results) {\n if (r.rc !== 0 || !r.id || attempted.has(r.id)) continue;\n const st = loadStatus(logDir, r.id);\n if (st?.branch && !st.merged) stillOpen.push({ id: r.id, branch: st.branch });\n }\n const report: WorkerReport = {\n checks: results.map((r) => ({\n name: r.task.title,\n ok: r.rc === 0,\n detail: r.rc === 0 ? shortText(r.id ? loadStatus(logDir, r.id)?.last : \"\", 80) : `rc=${r.rc}`,\n })),\n ...(merged.length || conflicted.length || stillOpen.length\n ? {\n open: [\n ...merged.map((id) => `merged: ${id}`),\n ...conflicted.map(\n (c) =>\n `conflict: pai worker merge ${c.id} (${c.branch})` +\n (c.paths.length ? ` — ${c.paths.join(\", \")}` : \"\")\n ),\n ...stillOpen.map((c) => `branch to merge: pai worker merge ${c.id} (${c.branch})`),\n ],\n }\n : {}),\n notes:\n `${ok}/${results.length} sub-tasks ok` +\n (merged.length ? `; ${merged.length} merged` : \"\") +\n (conflicted.length ? `; ${conflicted.length} conflict(s)` : \"\") +\n (stillOpen.length ? `; ${stillOpen.length} branch(es) to merge` : \"\"),\n };\n if (quiet || fmt === \"stream-json\") return;\n const allOk = ok === results.length && conflicted.length === 0;\n const resultEvent: StreamEvent = {\n type: \"result\",\n result: [\n `planner ${plannerId}: ${report.notes}`,\n ...(conflicted.length ? [\"conflicts:\"] : []),\n ...conflicted.map(\n (c) =>\n ` pai worker merge ${c.id} # ${c.branch}, conflicting: ${c.paths.join(\", \") || \"unknown\"}`\n ),\n ...(stillOpen.length ? [\"branches to merge:\"] : []),\n ...stillOpen.map((c) => ` pai worker merge ${c.id} # ${c.branch}`),\n ].join(\"\\n\"),\n is_error: !allOk,\n };\n printResult(fmt, resultEvent, allOk ? 0 : 1, logDir, plannerId, report, {\n plan: results.length,\n ...(merged.length ? { mergedBranches: merged } : {}),\n ...(conflicted.length ? { conflictedBranches: conflicted.map((c) => c.branch) } : {}),\n ...(stillOpen.length ? { branches: stillOpen.map((c) => c.branch) } : {}),\n });\n}\n"],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Y as workersLogDir } from "./status-
|
|
1
|
+
import { t as ANTHROPIC_NATIVE, y as WorkersConfigError } from "./config-CYjjGSWq.mjs";
|
|
2
|
+
import { Y as workersLogDir } from "./status-B0br9zV0.mjs";
|
|
3
3
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
@@ -348,4 +348,4 @@ function poolHostAlive(port, timeoutMs = 500) {
|
|
|
348
348
|
|
|
349
349
|
//#endregion
|
|
350
350
|
export { readMcpServers as _, poolMetricsPath as a, poolSettings as c, readPoolMetrics as d, readPoolState as f, mcpServersFromToolGrants as g, grantsChrome as h, poolIneligibleReason as i, poolSlotCwd as l, expandMcpNames as m, poolDir as n, poolPidPath as o, describeMcp as p, poolHostAlive as r, poolPort as s, poolCarry as t, postTaskToPool as u, writeMcpConfig as v };
|
|
351
|
-
//# sourceMappingURL=pool-
|
|
351
|
+
//# sourceMappingURL=pool-CgsnP3La.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pool-DLhqOY3P.mjs","names":["httpRequest"],"sources":["../src/workers/mcp.ts","../src/workers/pool.ts"],"sourcesContent":["/**\n * mcp.ts — the MCP allowlist for headless workers.\n *\n * Headless workers start with NO MCP servers: every server definition is a\n * prompt-time tool inventory the model pays to know, and a worker that only\n * reads and edits files needs none of it. A run may opt in with `--mcp\n * name[,name…]`, a role carrying `\"mcp\": [...]`, or implicitly by naming\n * `mcp__server__tool` in --allowedTools (a grant without its server loaded is\n * a dead letter). Names may be single servers from ~/.claude.json's\n * `mcpServers` or `workers.mcpSets` set names, which expand to their member\n * list. The filtered config lands in\n * `<logDir>/<id>.mcp.json` and is passed with `--strict-mcp-config\n * --mcp-config` so exactly those servers load. MCP servers are chosen at\n * launch only — a mid-run `say` cannot add any.\n *\n * One name in that space is not a server at all: `claude-in-chrome` is the\n * Chrome native-host bridge, which no config file can load and which the\n * `--chrome` flag switches on instead. It is recognised here so a grant\n * naming it becomes that flag rather than an \"unknown MCP server\" rejection.\n */\n\nimport { existsSync, readFileSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { WorkersConfigError, type WorkersConfig } from \"./config.js\";\n\n/** ~/.claude.json — the user's MCP server definitions (top-level mcpServers). */\nexport const CLAUDE_JSON = join(homedir(), \".claude.json\");\n\n/**\n * The browser bridge is not an MCP server. It rides the Chrome native-host\n * channel, so it never appears in `mcpServers`; a spawned claude has it off\n * and the `--chrome` flag switches it on. Tool grants naming it must\n * therefore neither resolve to a server nor be rejected as unknown — they\n * select a flag. See `grantsChrome`.\n */\nexport const CHROME_SERVER = \"claude-in-chrome\";\n\n/**\n * True for a real server definition, false for anything else that happens to\n * sit under `mcpServers`.\n *\n * The vendor config is not ours and has been observed carrying non-server\n * entries under that key — tool-usage records keyed by tool name\n * (`Read`, `Bash`, `mcp__server__tool`, …). Those are not servers: handing\n * one to `--mcp-config` yields \"invalid MCP server config\", and listing one\n * as available invites a run that cannot work. A server has a `command` (or a\n * `url` for remote transports); an `mcp__…` key is a tool name whatever its\n * shape.\n */\nfunction isServerDefinition(name: string, value: unknown): boolean {\n if (name.startsWith(\"mcp__\")) return false;\n if (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n const v = value as Record<string, unknown>;\n return (\n (typeof v.command === \"string\" && v.command.length > 0) ||\n (typeof v.url === \"string\" && v.url.length > 0)\n );\n}\n\nexport function readMcpServers(claudeJson = CLAUDE_JSON): Record<string, unknown> {\n try {\n if (!existsSync(claudeJson)) return {};\n const parsed = JSON.parse(readFileSync(claudeJson, \"utf8\")) as Record<string, unknown>;\n const servers = parsed.mcpServers;\n if (typeof servers !== \"object\" || servers === null || Array.isArray(servers)) return {};\n const out: Record<string, unknown> = {};\n for (const [name, value] of Object.entries(servers as Record<string, unknown>)) {\n if (isServerDefinition(name, value)) out[name] = value;\n }\n return out;\n } catch {\n // a damaged ~/.claude.json must not take workers down with it\n return {};\n }\n}\n\n/**\n * True when any `--mcp` name or `--allowedTools` grant asks for the browser\n * bridge: the bare server name, `mcp__claude-in-chrome`, or any\n * `mcp__claude-in-chrome__<tool>`. The runner turns this into `--chrome` on\n * the claude argv.\n */\nexport function grantsChrome(entries: string[]): boolean {\n for (const entry of entries) {\n for (const name of entry.split(\",\").map((s) => s.trim()).filter(Boolean)) {\n if (name === CHROME_SERVER) return true;\n if (name === `mcp__${CHROME_SERVER}`) return true;\n if (name.startsWith(`mcp__${CHROME_SERVER}__`)) return true;\n }\n }\n return false;\n}\n\n/**\n * Split a `--mcp a,b,c` flag value (also accepts repeated flags already split\n * by the caller) and expand set names from workers.mcpSets.\n */\nexport function expandMcpNames(\n names: string[],\n config: Pick<WorkersConfig, \"mcpSets\">,\n claudeJson = CLAUDE_JSON\n): string[] {\n const available = readMcpServers(claudeJson);\n const out: string[] = [];\n for (const raw of names) {\n for (const name of raw.split(\",\").map((s) => s.trim()).filter(Boolean)) {\n if (name === CHROME_SERVER) continue; // a flag, not a server — see grantsChrome\n if (name in config.mcpSets) {\n for (const member of config.mcpSets[name]) {\n if (!out.includes(member)) out.push(member);\n }\n continue;\n }\n if (!(name in available)) {\n const sets = Object.keys(config.mcpSets);\n throw new WorkersConfigError(\n `unknown MCP server \"${name}\". Available servers: ` +\n `${Object.keys(available).join(\", \") || \"(none in ~/.claude.json)\"}` +\n `${sets.length ? `; sets: ${sets.join(\", \")}` : \"\"}`\n );\n }\n if (!out.includes(name)) out.push(name);\n }\n }\n return out;\n}\n\n/**\n * Derive server names from tool grants: every `mcp__<server>__<tool>` (or bare\n * `mcp__<server>`, or `mcp__<server>__*`) in an --allowedTools list names a\n * server the run expects to be loaded. A grant is a dead letter unless its\n * server is in the filtered config, so the runner treats these as implicit\n * --mcp names — and only these; non-mcp grants load nothing.\n */\nexport function mcpServersFromToolGrants(tools: string[]): string[] {\n const out: string[] = [];\n for (const entry of tools) {\n for (const name of entry.split(\",\").map((s) => s.trim()).filter(Boolean)) {\n if (!name.startsWith(\"mcp__\")) continue;\n const server = name.slice(\"mcp__\".length).split(\"__\")[0];\n if (server === CHROME_SERVER) continue; // a flag, not a server — see grantsChrome\n if (server && !out.includes(server)) out.push(server);\n }\n }\n return out;\n}\n\n/** Path of a run's filtered MCP config. */\nexport function runMcpConfigPath(logDir: string, id: string): string {\n return join(logDir, `${id}.mcp.json`);\n}\n\n/**\n * Write a config containing only `names` (already expanded) and return its\n * path. Unknown names fail fast with the available list.\n */\nexport function writeMcpConfig(\n logDir: string,\n id: string,\n names: string[],\n claudeJson = CLAUDE_JSON\n): string {\n const available = readMcpServers(claudeJson);\n const unknown = names.filter((n) => !(n in available));\n if (unknown.length) {\n throw new WorkersConfigError(\n `unknown MCP server(s): ${unknown.join(\", \")}. Available: ` +\n `${Object.keys(available).join(\", \") || \"(none in ~/.claude.json)\"}`\n );\n }\n const servers: Record<string, unknown> = {};\n for (const n of names) servers[n] = available[n];\n const path = runMcpConfigPath(logDir, id);\n if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });\n writeFileSync(path, JSON.stringify({ mcpServers: servers }, null, 2) + \"\\n\", \"utf8\");\n return path;\n}\n\n/** `pai worker mcp list`: the servers and sets a run could name. */\nexport function describeMcp(config: Pick<WorkersConfig, \"mcpSets\">, claudeJson = CLAUDE_JSON): string[] {\n const servers = Object.keys(readMcpServers(claudeJson));\n const lines: string[] = [];\n if (servers.length) {\n lines.push(`servers (~/.claude.json):`);\n for (const s of servers) lines.push(` ${s}`);\n } else {\n lines.push(`no MCP servers defined in ~/.claude.json`);\n }\n const sets = Object.entries(config.mcpSets);\n if (sets.length) {\n lines.push(`sets (workers.mcpSets):`);\n for (const [name, members] of sets) lines.push(` ${name} = ${members.join(\", \")}`);\n }\n lines.push(`usage: pai worker run --mcp <name>[,<name>…] -p '<task>'`);\n return lines;\n}\n","/**\n * pool.ts — the worker session pool: one long-lived headless claude per\n * (provider, model) for successive small `pai worker run` tasks, so the\n * provider-side prompt cache carries over between them.\n *\n * Evidence (2026-10-16, router-usage.jsonl): 158 of 161 glm sessions carry\n * exactly ONE request — every `pai worker run` spawns a fresh claude, sends\n * one giant prompt, exits. The provider cache can only serve a fresh spawn\n * within its TTL, so the effective hit ratio on first requests is ~13%\n * (continuous sessions: 98%). A warm slot turns the next task into the\n * SECOND request of a continuous session instead.\n *\n * Architecture (mirrors the openai proxy, src/workers/proxy/server.ts):\n * - `pai worker pool on` starts a detached host (standalone/pool-host.ts,\n * bundled to dist/hooks/worker-pool-host.mjs) that owns the slot\n * processes and listens on a loopback port.\n * - runWorker, behind workers.pool.enabled, routes eligible runs to the\n * host (postTaskToPool); when the host answers `pooled`, the task ran as\n * one more user turn of a pooled claude and the result comes back as\n * JSON. Host down or task ineligible → the normal fresh spawn.\n * - Each pooled task keeps its full worker identity: own id, status file,\n * <id>.jsonl event mirror, ledger lines, AG2 contract and report\n * validation — the pool wraps the bookkeeping, never replaces it.\n *\n * Contamination: a pooled claude sees the previous task's turns. Every task\n * turn starts with POOL_TASK_BOUNDARY — a system-reminder line naming the\n * task and forbidding references to prior turns — and the worker contract\n * already forbids restating; the boundary is the measured-in risk dial.\n *\n * Cwd: a slot process has ONE cwd (fixed at spawn, the host's neutral\n * directory, workers.logDir). Tasks are pooled only when their own cwd is\n * that same directory — i.e. specs written against absolute paths; anything\n * else falls through to a fresh spawn rather than run in the wrong tree.\n */\n\nimport { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { request as httpRequest, type IncomingMessage } from \"node:http\";\nimport { dirname, join } from \"node:path\";\nimport { ANTHROPIC_NATIVE, type WorkersConfig, type WorkerProvider } from \"./config.js\";\nimport { grantsChrome, mcpServersFromToolGrants } from \"./mcp.js\";\nimport type { ParsedRunnerArgs } from \"./args.js\";\nimport type { StreamEvent, UsageBlock } from \"./run.js\";\nimport type { WorkerReport } from \"./report.js\";\nimport type { WorkerStatus } from \"./status.js\";\nimport { workersLogDir } from \"./paths.js\";\n\n// ---------------------------------------------------------------------------\n// Config-facing helpers\n// ---------------------------------------------------------------------------\n\n/** The pool section of the workers config, with its defaults applied. { enabled, maxSlots, idleSecs, port } */\nexport interface PoolSettings {\n enabled: boolean;\n /** Maximum slot processes the host keeps, across all (provider, model) keys. */\n maxSlots: number;\n /** Idle seconds before the host kills a slot (0 = never); provider cache TTL makes longer pointless. */\n idleSecs: number;\n /** Loopback port the host listens on. */\n port: number;\n}\n\nexport const DEFAULT_POOL_SETTINGS: PoolSettings = {\n enabled: false,\n maxSlots: 3,\n idleSecs: 900,\n port: 8798,\n};\n\nexport function poolSettings(config: WorkersConfig): PoolSettings {\n const p = config.pool;\n return {\n enabled: p?.enabled === true,\n maxSlots: p?.maxSlots ?? DEFAULT_POOL_SETTINGS.maxSlots,\n idleSecs: p?.idleSecs ?? DEFAULT_POOL_SETTINGS.idleSecs,\n port: p?.port ?? DEFAULT_POOL_SETTINGS.port,\n };\n}\n\n// ---------------------------------------------------------------------------\n// On-disk state (under <logDir>/pool/)\n// ---------------------------------------------------------------------------\n\n/** One pooled slot: a long-lived claude process keyed by provider+model+cwd. */\nexport interface PoolSlotRecord {\n provider: string;\n model: string;\n /** The slot's fixed working directory (the host's neutral dir). */\n cwd: string;\n pid: number;\n /** Claude session id from the slot's init event (`pai worker resume` can reach it). */\n claudeSession: string | null;\n /** Context window the slot's init event announced, when it did. */\n contextWindow?: number | null;\n spawnedAt: string;\n lastUsedAt: string;\n /** Tasks the slot has served. */\n tasks: number;\n /** While a task is mid-turn the slot is exclusive to it. */\n busyWith: string | null;\n}\n\nexport interface PoolState {\n hostPid: number;\n port: number;\n startedAt: string;\n updatedAt: string;\n slots: PoolSlotRecord[];\n /** Pooled tasks served since the host started. */\n tasksServed: number;\n /** cache_read tokens seen on the first request of each pooled task. */\n tokensCarried: number;\n}\n\nexport function poolDir(logDir: string): string {\n return join(logDir, \"pool\");\n}\n\nexport function poolStatePath(logDir: string): string {\n return join(poolDir(logDir), \"state.json\");\n}\n\nexport function poolMetricsPath(logDir: string): string {\n return join(poolDir(logDir), \"metrics.jsonl\");\n}\n\nexport function poolPidPath(logDir: string): string {\n return join(poolDir(logDir), \"host.pid\");\n}\n\nexport function readPoolState(logDir: string): PoolState | null {\n try {\n return JSON.parse(readFileSync(poolStatePath(logDir), \"utf8\")) as PoolState;\n } catch {\n return null;\n }\n}\n\n/** Persist the host's state (advisory file: a torn write costs a status read, not a task). */\nexport function writePoolState(state: PoolState, path: string): void {\n try {\n mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, JSON.stringify(state, null, 2), \"utf8\");\n } catch {\n /* status is best effort */\n }\n}\n\n/** One pooled task's cache-carry record (first request of the task's turn). */\nexport interface PoolMetricRow {\n ts: string;\n id: string;\n provider: string;\n model: string;\n /** Task index within its slot (1 = the task that spawned the slot). */\n task: number;\n slotSession: string | null;\n input_tokens: number;\n output_tokens: number;\n cache_creation_input_tokens: number;\n cache_read_input_tokens: number;\n}\n\n/** Append one task's carry record; accounting must never break a run. */\nexport function appendPoolMetric(row: PoolMetricRow, file: string): void {\n try {\n mkdirSync(dirname(file), { recursive: true });\n appendFileSync(file, JSON.stringify(row) + \"\\n\", \"utf8\");\n } catch {\n /* best effort */\n }\n}\n\nexport function readPoolMetrics(file: string): PoolMetricRow[] {\n if (!existsSync(file)) return [];\n const rows: PoolMetricRow[] = [];\n for (const l of readFileSync(file, \"utf8\").split(\"\\n\")) {\n if (!l) continue;\n try {\n const v = JSON.parse(l) as PoolMetricRow;\n if (typeof v.ts === \"string\" && typeof v.id === \"string\") rows.push(v);\n } catch {\n /* skip torn line */\n }\n }\n return rows;\n}\n\n/**\n * Cache-carry summary over pooled task records: how many tasks ran pooled\n * and how many tokens their FIRST request read from the provider cache —\n * the carry-over a fresh spawn would have missed.\n */\nexport interface PoolCarry {\n tasks: number;\n tokensCarried: number;\n /** cache_read / (read + write + fresh input) over the pooled first requests. */\n hitRatio: number;\n}\n\nexport function poolCarry(rows: PoolMetricRow[]): PoolCarry {\n let read = 0;\n let write = 0;\n let fresh = 0;\n for (const r of rows) {\n read += r.cache_read_input_tokens ?? 0;\n write += r.cache_creation_input_tokens ?? 0;\n fresh += r.input_tokens ?? 0;\n }\n const denom = read + write + fresh;\n return { tasks: rows.length, tokensCarried: read, hitRatio: denom ? read / denom : 0 };\n}\n\n// ---------------------------------------------------------------------------\n// Eligibility — which runs may go through the pool at all\n// ---------------------------------------------------------------------------\n\nexport interface PoolEligibilityInput {\n headless: boolean;\n prompt: string | null;\n /** Resolved provider name (\"anthropic\" for the built-in). */\n providerName: string;\n provider: Pick<WorkerProvider, \"native\" | \"engine\" | \"protocol\">;\n worktreeFlag: boolean | undefined;\n resumeOf: string | undefined;\n printCmd: boolean | undefined;\n parsed: Pick<ParsedRunnerArgs, \"callerSystemPrompt\" | \"callerMcpConfig\" | \"mcp\" | \"allowedTools\">;\n /** Caller's --mcp flag value, when given. */\n mcpFlag: string | undefined;\n noBrowser: boolean;\n outputFormat: ParsedRunnerArgs[\"outputFormat\"];\n /** Resolved cwd of the run (undefined = caller's own process cwd). */\n cwd: string | undefined;\n /** The cwd pooled slots run in. */\n slotCwd: string;\n}\n\nexport type PoolIneligibleReason =\n | \"pool-off\"\n | \"interactive\"\n | \"native-provider\"\n | \"engine\"\n | \"worktree\"\n | \"resume\"\n | \"print-cmd\"\n | \"system-prompt\"\n | \"mcp\"\n | \"browser\"\n | \"stream-json\"\n | \"cwd\";\n\n/**\n * Why this run may not use the pool, or null when it may. Every reason is a\n * hard incompatibility with a slot whose system prompt, MCP config, tool\n * grants and cwd were fixed at spawn — the run would silently get a\n * different setup than the fresh spawn it asked for.\n */\nexport function poolIneligibleReason(input: PoolEligibilityInput): PoolIneligibleReason | null {\n if (!input.headless || input.prompt === null) return \"interactive\";\n if (input.providerName === ANTHROPIC_NATIVE || input.provider.native) return \"native-provider\";\n if ((input.provider.engine ?? \"claude\") !== \"claude\") return \"engine\";\n if (input.worktreeFlag !== false) return \"worktree\";\n if (input.resumeOf !== undefined) return \"resume\";\n if (input.printCmd === true) return \"print-cmd\";\n if (input.parsed.callerSystemPrompt) return \"system-prompt\";\n if (input.parsed.callerMcpConfig || input.mcpFlag || input.parsed.mcp.length) return \"mcp\";\n if (mcpServersFromToolGrants(input.parsed.allowedTools).length) return \"mcp\";\n if (!input.noBrowser && grantsChrome(input.parsed.allowedTools)) return \"browser\";\n if (input.outputFormat === \"stream-json\") return \"stream-json\";\n if (input.cwd !== undefined && input.cwd !== input.slotCwd) return \"cwd\";\n return null;\n}\n\n/**\n * The pool host's cwd: a neutral directory no spec should depend on. The\n * workers logDir keeps slots out of every repo; the boundary line tells the\n * model where it runs so absolute-path specs stay correct.\n */\nexport function poolSlotCwd(config: WorkersConfig): string {\n return workersLogDir(config);\n}\n\n// ---------------------------------------------------------------------------\n// The task boundary — one delimiter per pooled turn\n// ---------------------------------------------------------------------------\n\n/**\n * The delimiter that opens every pooled task turn. A pooled session saw the\n * previous task's context; this line (plus the contract's own no-restating\n * rule) is the boundary: the new task is unrelated, prior turns must not be\n * referenced, and the fixed slot cwd is restated so absolute-path specs stay\n * anchored.\n */\nexport function poolTaskBoundary(taskNo: number, cwd: string): string {\n return (\n `<system-reminder>NEW TASK ${taskNo} — this session is a worker pool slot; the ` +\n `preceding turns were other, unrelated tasks by other workers. Ignore them ` +\n `completely: never reference, reuse or continue anything from them. Your working ` +\n `directory is ${cwd}; the task spec uses absolute paths. Work only on the task ` +\n `below.</system-reminder>`\n );\n}\n\n/** The full text fed to the slot for one task: boundary + spec + trailer. */\nexport function pooledTaskText(\n taskNo: number,\n cwd: string,\n prompt: string,\n trailer: string\n): string {\n return `${poolTaskBoundary(taskNo, cwd)}\\n\\n${prompt}${trailer}`;\n}\n\n// ---------------------------------------------------------------------------\n// Client: runWorker → pool host\n// ---------------------------------------------------------------------------\n\n/** The task payload a runWorker hands the pool host. */\nexport interface PoolTaskRequest {\n id: string;\n label: string;\n prompt: string;\n provider: string;\n model: string;\n className?: string;\n capability?: string;\n specPath?: string;\n parent?: string;\n stage?: string;\n reportFormat: \"json\" | \"ag2\";\n outputFormat: \"text\" | \"json\";\n /** Task time limit in minutes; null/0 = none. */\n maxMinutes: number | null;\n trailer: string;\n}\n\n/** What the host answers: either the finished task or a refusal to pool it. */\nexport interface PoolTaskResponse {\n pooled: boolean;\n reason?: PoolIneligibleReason | \"busy\" | \"host-error\";\n rc?: number;\n resultEvent?: StreamEvent | null;\n report?: WorkerReport | null;\n status?: WorkerStatus | null;\n}\n\n/** The pool host's HTTP health shape. */\nexport function poolPort(config: WorkersConfig): number {\n return config.pool?.port ?? 8798;\n}\n\n/**\n * Post one task to the pool host. Resolves null when the host is not\n * listening (callers fall back to a fresh spawn); resolves a PoolTaskResponse\n * otherwise. No undici here: the host holds the connection for the whole task\n * (minutes), which Node's fetch would cut off with its 300 s headers timeout.\n */\nexport function postTaskToPool(\n port: number,\n task: PoolTaskRequest,\n timeoutMs: number\n): Promise<PoolTaskResponse | null> {\n return new Promise((resolve) => {\n const body = JSON.stringify(task);\n const req = httpRequest(\n {\n host: \"127.0.0.1\",\n port,\n path: \"/task\",\n method: \"POST\",\n headers: { \"content-type\": \"application/json\", \"content-length\": Buffer.byteLength(body) },\n },\n (res: IncomingMessage) => {\n const chunks: Buffer[] = [];\n res.on(\"data\", (c: Buffer) => chunks.push(c));\n res.on(\"end\", () => {\n try {\n resolve(JSON.parse(Buffer.concat(chunks).toString(\"utf8\")) as PoolTaskResponse);\n } catch {\n resolve(null);\n }\n });\n res.on(\"error\", () => resolve(null));\n }\n );\n // the timer bounds connect+send only; the response may take the whole task\n req.setTimeout(timeoutMs, () => {\n // a deadline firing here is the task's own limit: the host kills the\n // slot and answers on its own; abandoning the socket is the client's\n // best move and the fresh-spawn fallback is NOT taken mid-task\n req.destroy();\n resolve(null);\n });\n req.on(\"error\", () => resolve(null));\n req.end(body);\n });\n}\n\n/** Cheap host probe for `pai worker pool status` and the on/off commands. */\nexport function poolHostAlive(port: number, timeoutMs = 500): Promise<boolean> {\n return new Promise((resolve) => {\n const req = httpRequest({ host: \"127.0.0.1\", port, path: \"/healthz\", method: \"GET\", timeout: timeoutMs }, (res) => {\n res.resume();\n res.on(\"end\", () => resolve(res.statusCode === 200));\n res.on(\"error\", () => resolve(false));\n });\n req.on(\"timeout\", () => {\n req.destroy();\n resolve(false);\n });\n req.on(\"error\", () => resolve(false));\n req.end();\n });\n}\n\n// ---------------------------------------------------------------------------\n// Host-side task execution (also exercised by the host tests through the\n// createPoolHost deps hook — the same code path the detached process runs)\n// ---------------------------------------------------------------------------\n\nexport interface PooledRunResult {\n rc: number;\n resultEvent: StreamEvent | null;\n report: WorkerReport | null;\n status: WorkerStatus;\n}\n\n/** Spawning surface of a slot process; injectable so tests run a fake claude. */\nexport interface SlotProcess {\n pid: number;\n write(line: string): boolean;\n kill(signal?: NodeJS.Signals | number): boolean;\n onLine(cb: (line: string) => void): void;\n onExit(cb: (code: number | null) => void): void;\n}\n\n/** How the host obtains a slot process; the real impl spawns `claude`. */\nexport type SlotSpawner = (argv: string[], cwd: string, env: NodeJS.ProcessEnv) => SlotProcess;\n\nexport interface PoolHostDeps {\n spawner: SlotSpawner;\n /** Slot env builder — buildRunEnv in production. */\n envFor: (providerName: string, provider: WorkerProvider) => NodeJS.ProcessEnv;\n /** Argv head of the claude binary — claudeCommand in production. */\n cmdHead: (env: NodeJS.ProcessEnv, caveman: boolean) => string[];\n now: () => Date;\n}\n\n/** Hash of the identity a slot is pinned to: provider, model and its cwd. */\nexport function slotKey(provider: string, model: string, cwd: string): string {\n return `${provider}\\u0000${model}\\u0000${cwd}`;\n}\n\nexport function shortSlotKey(key: string): string {\n return key.replaceAll(\"\\u0000\", \"/\");\n}\n\n/**\n * Decide which idle slot serves a task, or whether a new slot may spawn.\n * Pure over the slot list so tests can drive it directly.\n */\nexport function pickSlot(\n slots: PoolSlotRecord[],\n key: string,\n maxSlots: number,\n nowMs: number,\n idleSecs: number\n): { slot: PoolSlotRecord | null; evict: PoolSlotRecord | null; canSpawn: boolean } {\n const alive = (s: PoolSlotRecord): boolean => {\n try {\n process.kill(s.pid, 0);\n return true;\n } catch {\n return false;\n }\n };\n const fresh = slots.filter((s) => alive(s));\n const match = fresh.find((s) => slotKey(s.provider, s.model, s.cwd) === key && !s.busyWith);\n if (match) return { slot: match, evict: null, canSpawn: false };\n if (fresh.length < maxSlots) return { slot: null, evict: null, canSpawn: true };\n // at capacity: make room by evicting the longest-idle idle slot — capacity\n // is shared across keys, like connections in a DB pool. A fully busy pool\n // refuses, and the client falls back to a fresh spawn.\n const evict =\n fresh\n .filter((s) => !s.busyWith)\n .sort((a, b) => Date.parse(a.lastUsedAt) - Date.parse(b.lastUsedAt))[0] ?? null;\n return { slot: null, evict, canSpawn: evict !== null };\n}\n\n/** Whether a slot idled long enough to be reaped (idleSecs 0 = never). */\nexport function slotIdleFor(s: PoolSlotRecord, nowMs: number): number {\n return Math.max(0, Math.round((nowMs - Date.parse(s.lastUsedAt)) / 1000));\n}\n\nexport function slotShouldReap(s: PoolSlotRecord, nowMs: number, idleSecs: number): boolean {\n if (idleSecs <= 0 || s.busyWith) return false;\n return slotIdleFor(s, nowMs) >= idleSecs;\n}\n\n/** Input the host needs to execute one pooled task. Rebuilt per task. */\nexport interface PoolHostTask extends PoolTaskRequest {\n providerObj: WorkerProvider;\n noBrowser: boolean;\n proxyUrl?: string;\n contract: string | null;\n addendum: string | null;\n}\n\n/**\n * Pure check: which usage numbers mark a task's first request as cache-carried.\n * The proof bar from the spec: a pooled task's first request should read the\n * bulk of its prompt from cache, where a fresh spawn reads ~13%.\n */\nexport function carriedShare(u: UsageBlock | undefined): number | null {\n if (!u) return null;\n const denom =\n (u.input_tokens ?? 0) +\n (u.cache_read_input_tokens ?? 0) +\n (u.cache_creation_input_tokens ?? 0);\n if (!denom) return null;\n return (u.cache_read_input_tokens ?? 0) / denom;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"pool-CgsnP3La.mjs","names":["httpRequest"],"sources":["../src/workers/mcp.ts","../src/workers/pool.ts"],"sourcesContent":["/**\n * mcp.ts — the MCP allowlist for headless workers.\n *\n * Headless workers start with NO MCP servers: every server definition is a\n * prompt-time tool inventory the model pays to know, and a worker that only\n * reads and edits files needs none of it. A run may opt in with `--mcp\n * name[,name…]`, a role carrying `\"mcp\": [...]`, or implicitly by naming\n * `mcp__server__tool` in --allowedTools (a grant without its server loaded is\n * a dead letter). Names may be single servers from ~/.claude.json's\n * `mcpServers` or `workers.mcpSets` set names, which expand to their member\n * list. The filtered config lands in\n * `<logDir>/<id>.mcp.json` and is passed with `--strict-mcp-config\n * --mcp-config` so exactly those servers load. MCP servers are chosen at\n * launch only — a mid-run `say` cannot add any.\n *\n * One name in that space is not a server at all: `claude-in-chrome` is the\n * Chrome native-host bridge, which no config file can load and which the\n * `--chrome` flag switches on instead. It is recognised here so a grant\n * naming it becomes that flag rather than an \"unknown MCP server\" rejection.\n */\n\nimport { existsSync, readFileSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { WorkersConfigError, type WorkersConfig } from \"./config.js\";\n\n/** ~/.claude.json — the user's MCP server definitions (top-level mcpServers). */\nexport const CLAUDE_JSON = join(homedir(), \".claude.json\");\n\n/**\n * The browser bridge is not an MCP server. It rides the Chrome native-host\n * channel, so it never appears in `mcpServers`; a spawned claude has it off\n * and the `--chrome` flag switches it on. Tool grants naming it must\n * therefore neither resolve to a server nor be rejected as unknown — they\n * select a flag. See `grantsChrome`.\n */\nexport const CHROME_SERVER = \"claude-in-chrome\";\n\n/**\n * True for a real server definition, false for anything else that happens to\n * sit under `mcpServers`.\n *\n * The vendor config is not ours and has been observed carrying non-server\n * entries under that key — tool-usage records keyed by tool name\n * (`Read`, `Bash`, `mcp__server__tool`, …). Those are not servers: handing\n * one to `--mcp-config` yields \"invalid MCP server config\", and listing one\n * as available invites a run that cannot work. A server has a `command` (or a\n * `url` for remote transports); an `mcp__…` key is a tool name whatever its\n * shape.\n */\nfunction isServerDefinition(name: string, value: unknown): boolean {\n if (name.startsWith(\"mcp__\")) return false;\n if (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n const v = value as Record<string, unknown>;\n return (\n (typeof v.command === \"string\" && v.command.length > 0) ||\n (typeof v.url === \"string\" && v.url.length > 0)\n );\n}\n\nexport function readMcpServers(claudeJson = CLAUDE_JSON): Record<string, unknown> {\n try {\n if (!existsSync(claudeJson)) return {};\n const parsed = JSON.parse(readFileSync(claudeJson, \"utf8\")) as Record<string, unknown>;\n const servers = parsed.mcpServers;\n if (typeof servers !== \"object\" || servers === null || Array.isArray(servers)) return {};\n const out: Record<string, unknown> = {};\n for (const [name, value] of Object.entries(servers as Record<string, unknown>)) {\n if (isServerDefinition(name, value)) out[name] = value;\n }\n return out;\n } catch {\n // a damaged ~/.claude.json must not take workers down with it\n return {};\n }\n}\n\n/**\n * True when any `--mcp` name or `--allowedTools` grant asks for the browser\n * bridge: the bare server name, `mcp__claude-in-chrome`, or any\n * `mcp__claude-in-chrome__<tool>`. The runner turns this into `--chrome` on\n * the claude argv.\n */\nexport function grantsChrome(entries: string[]): boolean {\n for (const entry of entries) {\n for (const name of entry.split(\",\").map((s) => s.trim()).filter(Boolean)) {\n if (name === CHROME_SERVER) return true;\n if (name === `mcp__${CHROME_SERVER}`) return true;\n if (name.startsWith(`mcp__${CHROME_SERVER}__`)) return true;\n }\n }\n return false;\n}\n\n/**\n * Split a `--mcp a,b,c` flag value (also accepts repeated flags already split\n * by the caller) and expand set names from workers.mcpSets.\n */\nexport function expandMcpNames(\n names: string[],\n config: Pick<WorkersConfig, \"mcpSets\">,\n claudeJson = CLAUDE_JSON\n): string[] {\n const available = readMcpServers(claudeJson);\n const out: string[] = [];\n for (const raw of names) {\n for (const name of raw.split(\",\").map((s) => s.trim()).filter(Boolean)) {\n if (name === CHROME_SERVER) continue; // a flag, not a server — see grantsChrome\n if (name in config.mcpSets) {\n for (const member of config.mcpSets[name]) {\n if (!out.includes(member)) out.push(member);\n }\n continue;\n }\n if (!(name in available)) {\n const sets = Object.keys(config.mcpSets);\n throw new WorkersConfigError(\n `unknown MCP server \"${name}\". Available servers: ` +\n `${Object.keys(available).join(\", \") || \"(none in ~/.claude.json)\"}` +\n `${sets.length ? `; sets: ${sets.join(\", \")}` : \"\"}`\n );\n }\n if (!out.includes(name)) out.push(name);\n }\n }\n return out;\n}\n\n/**\n * Derive server names from tool grants: every `mcp__<server>__<tool>` (or bare\n * `mcp__<server>`, or `mcp__<server>__*`) in an --allowedTools list names a\n * server the run expects to be loaded. A grant is a dead letter unless its\n * server is in the filtered config, so the runner treats these as implicit\n * --mcp names — and only these; non-mcp grants load nothing.\n */\nexport function mcpServersFromToolGrants(tools: string[]): string[] {\n const out: string[] = [];\n for (const entry of tools) {\n for (const name of entry.split(\",\").map((s) => s.trim()).filter(Boolean)) {\n if (!name.startsWith(\"mcp__\")) continue;\n const server = name.slice(\"mcp__\".length).split(\"__\")[0];\n if (server === CHROME_SERVER) continue; // a flag, not a server — see grantsChrome\n if (server && !out.includes(server)) out.push(server);\n }\n }\n return out;\n}\n\n/** Path of a run's filtered MCP config. */\nexport function runMcpConfigPath(logDir: string, id: string): string {\n return join(logDir, `${id}.mcp.json`);\n}\n\n/**\n * Write a config containing only `names` (already expanded) and return its\n * path. Unknown names fail fast with the available list.\n */\nexport function writeMcpConfig(\n logDir: string,\n id: string,\n names: string[],\n claudeJson = CLAUDE_JSON\n): string {\n const available = readMcpServers(claudeJson);\n const unknown = names.filter((n) => !(n in available));\n if (unknown.length) {\n throw new WorkersConfigError(\n `unknown MCP server(s): ${unknown.join(\", \")}. Available: ` +\n `${Object.keys(available).join(\", \") || \"(none in ~/.claude.json)\"}`\n );\n }\n const servers: Record<string, unknown> = {};\n for (const n of names) servers[n] = available[n];\n const path = runMcpConfigPath(logDir, id);\n if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });\n writeFileSync(path, JSON.stringify({ mcpServers: servers }, null, 2) + \"\\n\", \"utf8\");\n return path;\n}\n\n/** `pai worker mcp list`: the servers and sets a run could name. */\nexport function describeMcp(config: Pick<WorkersConfig, \"mcpSets\">, claudeJson = CLAUDE_JSON): string[] {\n const servers = Object.keys(readMcpServers(claudeJson));\n const lines: string[] = [];\n if (servers.length) {\n lines.push(`servers (~/.claude.json):`);\n for (const s of servers) lines.push(` ${s}`);\n } else {\n lines.push(`no MCP servers defined in ~/.claude.json`);\n }\n const sets = Object.entries(config.mcpSets);\n if (sets.length) {\n lines.push(`sets (workers.mcpSets):`);\n for (const [name, members] of sets) lines.push(` ${name} = ${members.join(\", \")}`);\n }\n lines.push(`usage: pai worker run --mcp <name>[,<name>…] -p '<task>'`);\n return lines;\n}\n","/**\n * pool.ts — the worker session pool: one long-lived headless claude per\n * (provider, model) for successive small `pai worker run` tasks, so the\n * provider-side prompt cache carries over between them.\n *\n * Evidence (2026-10-16, router-usage.jsonl): 158 of 161 glm sessions carry\n * exactly ONE request — every `pai worker run` spawns a fresh claude, sends\n * one giant prompt, exits. The provider cache can only serve a fresh spawn\n * within its TTL, so the effective hit ratio on first requests is ~13%\n * (continuous sessions: 98%). A warm slot turns the next task into the\n * SECOND request of a continuous session instead.\n *\n * Architecture (mirrors the openai proxy, src/workers/proxy/server.ts):\n * - `pai worker pool on` starts a detached host (standalone/pool-host.ts,\n * bundled to dist/hooks/worker-pool-host.mjs) that owns the slot\n * processes and listens on a loopback port.\n * - runWorker, behind workers.pool.enabled, routes eligible runs to the\n * host (postTaskToPool); when the host answers `pooled`, the task ran as\n * one more user turn of a pooled claude and the result comes back as\n * JSON. Host down or task ineligible → the normal fresh spawn.\n * - Each pooled task keeps its full worker identity: own id, status file,\n * <id>.jsonl event mirror, ledger lines, AG2 contract and report\n * validation — the pool wraps the bookkeeping, never replaces it.\n *\n * Contamination: a pooled claude sees the previous task's turns. Every task\n * turn starts with POOL_TASK_BOUNDARY — a system-reminder line naming the\n * task and forbidding references to prior turns — and the worker contract\n * already forbids restating; the boundary is the measured-in risk dial.\n *\n * Cwd: a slot process has ONE cwd (fixed at spawn, the host's neutral\n * directory, workers.logDir). Tasks are pooled only when their own cwd is\n * that same directory — i.e. specs written against absolute paths; anything\n * else falls through to a fresh spawn rather than run in the wrong tree.\n */\n\nimport { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { request as httpRequest, type IncomingMessage } from \"node:http\";\nimport { dirname, join } from \"node:path\";\nimport { ANTHROPIC_NATIVE, type WorkersConfig, type WorkerProvider } from \"./config.js\";\nimport { grantsChrome, mcpServersFromToolGrants } from \"./mcp.js\";\nimport type { ParsedRunnerArgs } from \"./args.js\";\nimport type { StreamEvent, UsageBlock } from \"./run.js\";\nimport type { WorkerReport } from \"./report.js\";\nimport type { WorkerStatus } from \"./status.js\";\nimport { workersLogDir } from \"./paths.js\";\n\n// ---------------------------------------------------------------------------\n// Config-facing helpers\n// ---------------------------------------------------------------------------\n\n/** The pool section of the workers config, with its defaults applied. { enabled, maxSlots, idleSecs, port } */\nexport interface PoolSettings {\n enabled: boolean;\n /** Maximum slot processes the host keeps, across all (provider, model) keys. */\n maxSlots: number;\n /** Idle seconds before the host kills a slot (0 = never); provider cache TTL makes longer pointless. */\n idleSecs: number;\n /** Loopback port the host listens on. */\n port: number;\n}\n\nexport const DEFAULT_POOL_SETTINGS: PoolSettings = {\n enabled: false,\n maxSlots: 3,\n idleSecs: 900,\n port: 8798,\n};\n\nexport function poolSettings(config: WorkersConfig): PoolSettings {\n const p = config.pool;\n return {\n enabled: p?.enabled === true,\n maxSlots: p?.maxSlots ?? DEFAULT_POOL_SETTINGS.maxSlots,\n idleSecs: p?.idleSecs ?? DEFAULT_POOL_SETTINGS.idleSecs,\n port: p?.port ?? DEFAULT_POOL_SETTINGS.port,\n };\n}\n\n// ---------------------------------------------------------------------------\n// On-disk state (under <logDir>/pool/)\n// ---------------------------------------------------------------------------\n\n/** One pooled slot: a long-lived claude process keyed by provider+model+cwd. */\nexport interface PoolSlotRecord {\n provider: string;\n model: string;\n /** The slot's fixed working directory (the host's neutral dir). */\n cwd: string;\n pid: number;\n /** Claude session id from the slot's init event (`pai worker resume` can reach it). */\n claudeSession: string | null;\n /** Context window the slot's init event announced, when it did. */\n contextWindow?: number | null;\n spawnedAt: string;\n lastUsedAt: string;\n /** Tasks the slot has served. */\n tasks: number;\n /** While a task is mid-turn the slot is exclusive to it. */\n busyWith: string | null;\n}\n\nexport interface PoolState {\n hostPid: number;\n port: number;\n startedAt: string;\n updatedAt: string;\n slots: PoolSlotRecord[];\n /** Pooled tasks served since the host started. */\n tasksServed: number;\n /** cache_read tokens seen on the first request of each pooled task. */\n tokensCarried: number;\n}\n\nexport function poolDir(logDir: string): string {\n return join(logDir, \"pool\");\n}\n\nexport function poolStatePath(logDir: string): string {\n return join(poolDir(logDir), \"state.json\");\n}\n\nexport function poolMetricsPath(logDir: string): string {\n return join(poolDir(logDir), \"metrics.jsonl\");\n}\n\nexport function poolPidPath(logDir: string): string {\n return join(poolDir(logDir), \"host.pid\");\n}\n\nexport function readPoolState(logDir: string): PoolState | null {\n try {\n return JSON.parse(readFileSync(poolStatePath(logDir), \"utf8\")) as PoolState;\n } catch {\n return null;\n }\n}\n\n/** Persist the host's state (advisory file: a torn write costs a status read, not a task). */\nexport function writePoolState(state: PoolState, path: string): void {\n try {\n mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, JSON.stringify(state, null, 2), \"utf8\");\n } catch {\n /* status is best effort */\n }\n}\n\n/** One pooled task's cache-carry record (first request of the task's turn). */\nexport interface PoolMetricRow {\n ts: string;\n id: string;\n provider: string;\n model: string;\n /** Task index within its slot (1 = the task that spawned the slot). */\n task: number;\n slotSession: string | null;\n input_tokens: number;\n output_tokens: number;\n cache_creation_input_tokens: number;\n cache_read_input_tokens: number;\n}\n\n/** Append one task's carry record; accounting must never break a run. */\nexport function appendPoolMetric(row: PoolMetricRow, file: string): void {\n try {\n mkdirSync(dirname(file), { recursive: true });\n appendFileSync(file, JSON.stringify(row) + \"\\n\", \"utf8\");\n } catch {\n /* best effort */\n }\n}\n\nexport function readPoolMetrics(file: string): PoolMetricRow[] {\n if (!existsSync(file)) return [];\n const rows: PoolMetricRow[] = [];\n for (const l of readFileSync(file, \"utf8\").split(\"\\n\")) {\n if (!l) continue;\n try {\n const v = JSON.parse(l) as PoolMetricRow;\n if (typeof v.ts === \"string\" && typeof v.id === \"string\") rows.push(v);\n } catch {\n /* skip torn line */\n }\n }\n return rows;\n}\n\n/**\n * Cache-carry summary over pooled task records: how many tasks ran pooled\n * and how many tokens their FIRST request read from the provider cache —\n * the carry-over a fresh spawn would have missed.\n */\nexport interface PoolCarry {\n tasks: number;\n tokensCarried: number;\n /** cache_read / (read + write + fresh input) over the pooled first requests. */\n hitRatio: number;\n}\n\nexport function poolCarry(rows: PoolMetricRow[]): PoolCarry {\n let read = 0;\n let write = 0;\n let fresh = 0;\n for (const r of rows) {\n read += r.cache_read_input_tokens ?? 0;\n write += r.cache_creation_input_tokens ?? 0;\n fresh += r.input_tokens ?? 0;\n }\n const denom = read + write + fresh;\n return { tasks: rows.length, tokensCarried: read, hitRatio: denom ? read / denom : 0 };\n}\n\n// ---------------------------------------------------------------------------\n// Eligibility — which runs may go through the pool at all\n// ---------------------------------------------------------------------------\n\nexport interface PoolEligibilityInput {\n headless: boolean;\n prompt: string | null;\n /** Resolved provider name (\"anthropic\" for the built-in). */\n providerName: string;\n provider: Pick<WorkerProvider, \"native\" | \"engine\" | \"protocol\">;\n worktreeFlag: boolean | undefined;\n resumeOf: string | undefined;\n printCmd: boolean | undefined;\n parsed: Pick<ParsedRunnerArgs, \"callerSystemPrompt\" | \"callerMcpConfig\" | \"mcp\" | \"allowedTools\">;\n /** Caller's --mcp flag value, when given. */\n mcpFlag: string | undefined;\n noBrowser: boolean;\n outputFormat: ParsedRunnerArgs[\"outputFormat\"];\n /** Resolved cwd of the run (undefined = caller's own process cwd). */\n cwd: string | undefined;\n /** The cwd pooled slots run in. */\n slotCwd: string;\n}\n\nexport type PoolIneligibleReason =\n | \"pool-off\"\n | \"interactive\"\n | \"native-provider\"\n | \"engine\"\n | \"worktree\"\n | \"resume\"\n | \"print-cmd\"\n | \"system-prompt\"\n | \"mcp\"\n | \"browser\"\n | \"stream-json\"\n | \"cwd\";\n\n/**\n * Why this run may not use the pool, or null when it may. Every reason is a\n * hard incompatibility with a slot whose system prompt, MCP config, tool\n * grants and cwd were fixed at spawn — the run would silently get a\n * different setup than the fresh spawn it asked for.\n */\nexport function poolIneligibleReason(input: PoolEligibilityInput): PoolIneligibleReason | null {\n if (!input.headless || input.prompt === null) return \"interactive\";\n if (input.providerName === ANTHROPIC_NATIVE || input.provider.native) return \"native-provider\";\n if ((input.provider.engine ?? \"claude\") !== \"claude\") return \"engine\";\n if (input.worktreeFlag !== false) return \"worktree\";\n if (input.resumeOf !== undefined) return \"resume\";\n if (input.printCmd === true) return \"print-cmd\";\n if (input.parsed.callerSystemPrompt) return \"system-prompt\";\n if (input.parsed.callerMcpConfig || input.mcpFlag || input.parsed.mcp.length) return \"mcp\";\n if (mcpServersFromToolGrants(input.parsed.allowedTools).length) return \"mcp\";\n if (!input.noBrowser && grantsChrome(input.parsed.allowedTools)) return \"browser\";\n if (input.outputFormat === \"stream-json\") return \"stream-json\";\n if (input.cwd !== undefined && input.cwd !== input.slotCwd) return \"cwd\";\n return null;\n}\n\n/**\n * The pool host's cwd: a neutral directory no spec should depend on. The\n * workers logDir keeps slots out of every repo; the boundary line tells the\n * model where it runs so absolute-path specs stay correct.\n */\nexport function poolSlotCwd(config: WorkersConfig): string {\n return workersLogDir(config);\n}\n\n// ---------------------------------------------------------------------------\n// The task boundary — one delimiter per pooled turn\n// ---------------------------------------------------------------------------\n\n/**\n * The delimiter that opens every pooled task turn. A pooled session saw the\n * previous task's context; this line (plus the contract's own no-restating\n * rule) is the boundary: the new task is unrelated, prior turns must not be\n * referenced, and the fixed slot cwd is restated so absolute-path specs stay\n * anchored.\n */\nexport function poolTaskBoundary(taskNo: number, cwd: string): string {\n return (\n `<system-reminder>NEW TASK ${taskNo} — this session is a worker pool slot; the ` +\n `preceding turns were other, unrelated tasks by other workers. Ignore them ` +\n `completely: never reference, reuse or continue anything from them. Your working ` +\n `directory is ${cwd}; the task spec uses absolute paths. Work only on the task ` +\n `below.</system-reminder>`\n );\n}\n\n/** The full text fed to the slot for one task: boundary + spec + trailer. */\nexport function pooledTaskText(\n taskNo: number,\n cwd: string,\n prompt: string,\n trailer: string\n): string {\n return `${poolTaskBoundary(taskNo, cwd)}\\n\\n${prompt}${trailer}`;\n}\n\n// ---------------------------------------------------------------------------\n// Client: runWorker → pool host\n// ---------------------------------------------------------------------------\n\n/** The task payload a runWorker hands the pool host. */\nexport interface PoolTaskRequest {\n id: string;\n label: string;\n prompt: string;\n provider: string;\n model: string;\n className?: string;\n capability?: string;\n specPath?: string;\n parent?: string;\n stage?: string;\n reportFormat: \"json\" | \"ag2\";\n outputFormat: \"text\" | \"json\";\n /** Task time limit in minutes; null/0 = none. */\n maxMinutes: number | null;\n trailer: string;\n}\n\n/** What the host answers: either the finished task or a refusal to pool it. */\nexport interface PoolTaskResponse {\n pooled: boolean;\n reason?: PoolIneligibleReason | \"busy\" | \"host-error\";\n rc?: number;\n resultEvent?: StreamEvent | null;\n report?: WorkerReport | null;\n status?: WorkerStatus | null;\n}\n\n/** The pool host's HTTP health shape. */\nexport function poolPort(config: WorkersConfig): number {\n return config.pool?.port ?? 8798;\n}\n\n/**\n * Post one task to the pool host. Resolves null when the host is not\n * listening (callers fall back to a fresh spawn); resolves a PoolTaskResponse\n * otherwise. No undici here: the host holds the connection for the whole task\n * (minutes), which Node's fetch would cut off with its 300 s headers timeout.\n */\nexport function postTaskToPool(\n port: number,\n task: PoolTaskRequest,\n timeoutMs: number\n): Promise<PoolTaskResponse | null> {\n return new Promise((resolve) => {\n const body = JSON.stringify(task);\n const req = httpRequest(\n {\n host: \"127.0.0.1\",\n port,\n path: \"/task\",\n method: \"POST\",\n headers: { \"content-type\": \"application/json\", \"content-length\": Buffer.byteLength(body) },\n },\n (res: IncomingMessage) => {\n const chunks: Buffer[] = [];\n res.on(\"data\", (c: Buffer) => chunks.push(c));\n res.on(\"end\", () => {\n try {\n resolve(JSON.parse(Buffer.concat(chunks).toString(\"utf8\")) as PoolTaskResponse);\n } catch {\n resolve(null);\n }\n });\n res.on(\"error\", () => resolve(null));\n }\n );\n // the timer bounds connect+send only; the response may take the whole task\n req.setTimeout(timeoutMs, () => {\n // a deadline firing here is the task's own limit: the host kills the\n // slot and answers on its own; abandoning the socket is the client's\n // best move and the fresh-spawn fallback is NOT taken mid-task\n req.destroy();\n resolve(null);\n });\n req.on(\"error\", () => resolve(null));\n req.end(body);\n });\n}\n\n/** Cheap host probe for `pai worker pool status` and the on/off commands. */\nexport function poolHostAlive(port: number, timeoutMs = 500): Promise<boolean> {\n return new Promise((resolve) => {\n const req = httpRequest({ host: \"127.0.0.1\", port, path: \"/healthz\", method: \"GET\", timeout: timeoutMs }, (res) => {\n res.resume();\n res.on(\"end\", () => resolve(res.statusCode === 200));\n res.on(\"error\", () => resolve(false));\n });\n req.on(\"timeout\", () => {\n req.destroy();\n resolve(false);\n });\n req.on(\"error\", () => resolve(false));\n req.end();\n });\n}\n\n// ---------------------------------------------------------------------------\n// Host-side task execution (also exercised by the host tests through the\n// createPoolHost deps hook — the same code path the detached process runs)\n// ---------------------------------------------------------------------------\n\nexport interface PooledRunResult {\n rc: number;\n resultEvent: StreamEvent | null;\n report: WorkerReport | null;\n status: WorkerStatus;\n}\n\n/** Spawning surface of a slot process; injectable so tests run a fake claude. */\nexport interface SlotProcess {\n pid: number;\n write(line: string): boolean;\n kill(signal?: NodeJS.Signals | number): boolean;\n onLine(cb: (line: string) => void): void;\n onExit(cb: (code: number | null) => void): void;\n}\n\n/** How the host obtains a slot process; the real impl spawns `claude`. */\nexport type SlotSpawner = (argv: string[], cwd: string, env: NodeJS.ProcessEnv) => SlotProcess;\n\nexport interface PoolHostDeps {\n spawner: SlotSpawner;\n /** Slot env builder — buildRunEnv in production. */\n envFor: (providerName: string, provider: WorkerProvider) => NodeJS.ProcessEnv;\n /** Argv head of the claude binary — claudeCommand in production. */\n cmdHead: (env: NodeJS.ProcessEnv, caveman: boolean) => string[];\n now: () => Date;\n}\n\n/** Hash of the identity a slot is pinned to: provider, model and its cwd. */\nexport function slotKey(provider: string, model: string, cwd: string): string {\n return `${provider}\\u0000${model}\\u0000${cwd}`;\n}\n\nexport function shortSlotKey(key: string): string {\n return key.replaceAll(\"\\u0000\", \"/\");\n}\n\n/**\n * Decide which idle slot serves a task, or whether a new slot may spawn.\n * Pure over the slot list so tests can drive it directly.\n */\nexport function pickSlot(\n slots: PoolSlotRecord[],\n key: string,\n maxSlots: number,\n nowMs: number,\n idleSecs: number\n): { slot: PoolSlotRecord | null; evict: PoolSlotRecord | null; canSpawn: boolean } {\n const alive = (s: PoolSlotRecord): boolean => {\n try {\n process.kill(s.pid, 0);\n return true;\n } catch {\n return false;\n }\n };\n const fresh = slots.filter((s) => alive(s));\n const match = fresh.find((s) => slotKey(s.provider, s.model, s.cwd) === key && !s.busyWith);\n if (match) return { slot: match, evict: null, canSpawn: false };\n if (fresh.length < maxSlots) return { slot: null, evict: null, canSpawn: true };\n // at capacity: make room by evicting the longest-idle idle slot — capacity\n // is shared across keys, like connections in a DB pool. A fully busy pool\n // refuses, and the client falls back to a fresh spawn.\n const evict =\n fresh\n .filter((s) => !s.busyWith)\n .sort((a, b) => Date.parse(a.lastUsedAt) - Date.parse(b.lastUsedAt))[0] ?? null;\n return { slot: null, evict, canSpawn: evict !== null };\n}\n\n/** Whether a slot idled long enough to be reaped (idleSecs 0 = never). */\nexport function slotIdleFor(s: PoolSlotRecord, nowMs: number): number {\n return Math.max(0, Math.round((nowMs - Date.parse(s.lastUsedAt)) / 1000));\n}\n\nexport function slotShouldReap(s: PoolSlotRecord, nowMs: number, idleSecs: number): boolean {\n if (idleSecs <= 0 || s.busyWith) return false;\n return slotIdleFor(s, nowMs) >= idleSecs;\n}\n\n/** Input the host needs to execute one pooled task. Rebuilt per task. */\nexport interface PoolHostTask extends PoolTaskRequest {\n providerObj: WorkerProvider;\n noBrowser: boolean;\n proxyUrl?: string;\n contract: string | null;\n addendum: string | null;\n}\n\n/**\n * Pure check: which usage numbers mark a task's first request as cache-carried.\n * The proof bar from the spec: a pooled task's first request should read the\n * bulk of its prompt from cache, where a fresh spawn reads ~13%.\n */\nexport function carriedShare(u: UsageBlock | undefined): number | null {\n if (!u) return null;\n const denom =\n (u.input_tokens ?? 0) +\n (u.cache_read_input_tokens ?? 0) +\n (u.cache_creation_input_tokens ?? 0);\n if (!denom) return null;\n return (u.cache_read_input_tokens ?? 0) / denom;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,MAAa,cAAc,KAAK,SAAS,EAAE,eAAe;;;;;;;;AAS1D,MAAa,gBAAgB;;;;;;;;;;;;;AAc7B,SAAS,mBAAmB,MAAc,OAAyB;AACjE,KAAI,KAAK,WAAW,QAAQ,CAAE,QAAO;AACrC,KAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,MAAM,QAAQ,MAAM,CAAE,QAAO;CAChF,MAAM,IAAI;AACV,QACG,OAAO,EAAE,YAAY,YAAY,EAAE,QAAQ,SAAS,KACpD,OAAO,EAAE,QAAQ,YAAY,EAAE,IAAI,SAAS;;AAIjD,SAAgB,eAAe,aAAa,aAAsC;AAChF,KAAI;AACF,MAAI,CAAC,WAAW,WAAW,CAAE,QAAO,EAAE;EAEtC,MAAM,UADS,KAAK,MAAM,aAAa,YAAY,OAAO,CAAC,CACpC;AACvB,MAAI,OAAO,YAAY,YAAY,YAAY,QAAQ,MAAM,QAAQ,QAAQ,CAAE,QAAO,EAAE;EACxF,MAAM,MAA+B,EAAE;AACvC,OAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,QAAmC,CAC5E,KAAI,mBAAmB,MAAM,MAAM,CAAE,KAAI,QAAQ;AAEnD,SAAO;SACD;AAEN,SAAO,EAAE;;;;;;;;;AAUb,SAAgB,aAAa,SAA4B;AACvD,MAAK,MAAM,SAAS,QAClB,MAAK,MAAM,QAAQ,MAAM,MAAM,IAAI,CAAC,KAAK,MAAM,EAAE,MAAM,CAAC,CAAC,OAAO,QAAQ,EAAE;AACxE,MAAI,SAAS,cAAe,QAAO;AACnC,MAAI,SAAS,QAAQ,gBAAiB,QAAO;AAC7C,MAAI,KAAK,WAAW,QAAQ,cAAc,IAAI,CAAE,QAAO;;AAG3D,QAAO;;;;;;AAOT,SAAgB,eACd,OACA,QACA,aAAa,aACH;CACV,MAAM,YAAY,eAAe,WAAW;CAC5C,MAAM,MAAgB,EAAE;AACxB,MAAK,MAAM,OAAO,MAChB,MAAK,MAAM,QAAQ,IAAI,MAAM,IAAI,CAAC,KAAK,MAAM,EAAE,MAAM,CAAC,CAAC,OAAO,QAAQ,EAAE;AACtE,MAAI,SAAS,cAAe;AAC5B,MAAI,QAAQ,OAAO,SAAS;AAC1B,QAAK,MAAM,UAAU,OAAO,QAAQ,MAClC,KAAI,CAAC,IAAI,SAAS,OAAO,CAAE,KAAI,KAAK,OAAO;AAE7C;;AAEF,MAAI,EAAE,QAAQ,YAAY;GACxB,MAAM,OAAO,OAAO,KAAK,OAAO,QAAQ;AACxC,SAAM,IAAI,mBACR,uBAAuB,KAAK,wBACvB,OAAO,KAAK,UAAU,CAAC,KAAK,KAAK,IAAI,6BACrC,KAAK,SAAS,WAAW,KAAK,KAAK,KAAK,KAAK,KACnD;;AAEH,MAAI,CAAC,IAAI,SAAS,KAAK,CAAE,KAAI,KAAK,KAAK;;AAG3C,QAAO;;;;;;;;;AAUT,SAAgB,yBAAyB,OAA2B;CAClE,MAAM,MAAgB,EAAE;AACxB,MAAK,MAAM,SAAS,MAClB,MAAK,MAAM,QAAQ,MAAM,MAAM,IAAI,CAAC,KAAK,MAAM,EAAE,MAAM,CAAC,CAAC,OAAO,QAAQ,EAAE;AACxE,MAAI,CAAC,KAAK,WAAW,QAAQ,CAAE;EAC/B,MAAM,SAAS,KAAK,MAAM,EAAe,CAAC,MAAM,KAAK,CAAC;AACtD,MAAI,WAAW,cAAe;AAC9B,MAAI,UAAU,CAAC,IAAI,SAAS,OAAO,CAAE,KAAI,KAAK,OAAO;;AAGzD,QAAO;;;AAIT,SAAgB,iBAAiB,QAAgB,IAAoB;AACnE,QAAO,KAAK,QAAQ,GAAG,GAAG,WAAW;;;;;;AAOvC,SAAgB,eACd,QACA,IACA,OACA,aAAa,aACL;CACR,MAAM,YAAY,eAAe,WAAW;CAC5C,MAAM,UAAU,MAAM,QAAQ,MAAM,EAAE,KAAK,WAAW;AACtD,KAAI,QAAQ,OACV,OAAM,IAAI,mBACR,0BAA0B,QAAQ,KAAK,KAAK,CAAC,eACxC,OAAO,KAAK,UAAU,CAAC,KAAK,KAAK,IAAI,6BAC3C;CAEH,MAAM,UAAmC,EAAE;AAC3C,MAAK,MAAM,KAAK,MAAO,SAAQ,KAAK,UAAU;CAC9C,MAAM,OAAO,iBAAiB,QAAQ,GAAG;AACzC,KAAI,CAAC,WAAW,OAAO,CAAE,WAAU,QAAQ,EAAE,WAAW,MAAM,CAAC;AAC/D,eAAc,MAAM,KAAK,UAAU,EAAE,YAAY,SAAS,EAAE,MAAM,EAAE,GAAG,MAAM,OAAO;AACpF,QAAO;;;AAIT,SAAgB,YAAY,QAAwC,aAAa,aAAuB;CACtG,MAAM,UAAU,OAAO,KAAK,eAAe,WAAW,CAAC;CACvD,MAAM,QAAkB,EAAE;AAC1B,KAAI,QAAQ,QAAQ;AAClB,QAAM,KAAK,4BAA4B;AACvC,OAAK,MAAM,KAAK,QAAS,OAAM,KAAK,KAAK,IAAI;OAE7C,OAAM,KAAK,2CAA2C;CAExD,MAAM,OAAO,OAAO,QAAQ,OAAO,QAAQ;AAC3C,KAAI,KAAK,QAAQ;AACf,QAAM,KAAK,0BAA0B;AACrC,OAAK,MAAM,CAAC,MAAM,YAAY,KAAM,OAAM,KAAK,KAAK,KAAK,KAAK,QAAQ,KAAK,KAAK,GAAG;;AAErF,OAAM,KAAK,2DAA2D;AACtE,QAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACtIT,MAAa,wBAAsC;CACjD,SAAS;CACT,UAAU;CACV,UAAU;CACV,MAAM;CACP;AAED,SAAgB,aAAa,QAAqC;CAChE,MAAM,IAAI,OAAO;AACjB,QAAO;EACL,SAAS,GAAG,YAAY;EACxB,UAAU,GAAG,YAAY,sBAAsB;EAC/C,UAAU,GAAG,YAAY,sBAAsB;EAC/C,MAAM,GAAG,QAAQ,sBAAsB;EACxC;;AAsCH,SAAgB,QAAQ,QAAwB;AAC9C,QAAO,KAAK,QAAQ,OAAO;;AAG7B,SAAgB,cAAc,QAAwB;AACpD,QAAO,KAAK,QAAQ,OAAO,EAAE,aAAa;;AAG5C,SAAgB,gBAAgB,QAAwB;AACtD,QAAO,KAAK,QAAQ,OAAO,EAAE,gBAAgB;;AAG/C,SAAgB,YAAY,QAAwB;AAClD,QAAO,KAAK,QAAQ,OAAO,EAAE,WAAW;;AAG1C,SAAgB,cAAc,QAAkC;AAC9D,KAAI;AACF,SAAO,KAAK,MAAM,aAAa,cAAc,OAAO,EAAE,OAAO,CAAC;SACxD;AACN,SAAO;;;AAuCX,SAAgB,gBAAgB,MAA+B;AAC7D,KAAI,CAAC,WAAW,KAAK,CAAE,QAAO,EAAE;CAChC,MAAM,OAAwB,EAAE;AAChC,MAAK,MAAM,KAAK,aAAa,MAAM,OAAO,CAAC,MAAM,KAAK,EAAE;AACtD,MAAI,CAAC,EAAG;AACR,MAAI;GACF,MAAM,IAAI,KAAK,MAAM,EAAE;AACvB,OAAI,OAAO,EAAE,OAAO,YAAY,OAAO,EAAE,OAAO,SAAU,MAAK,KAAK,EAAE;UAChE;;AAIV,QAAO;;AAeT,SAAgB,UAAU,MAAkC;CAC1D,IAAI,OAAO;CACX,IAAI,QAAQ;CACZ,IAAI,QAAQ;AACZ,MAAK,MAAM,KAAK,MAAM;AACpB,UAAQ,EAAE,2BAA2B;AACrC,WAAS,EAAE,+BAA+B;AAC1C,WAAS,EAAE,gBAAgB;;CAE7B,MAAM,QAAQ,OAAO,QAAQ;AAC7B,QAAO;EAAE,OAAO,KAAK;EAAQ,eAAe;EAAM,UAAU,QAAQ,OAAO,QAAQ;EAAG;;;;;;;;AA+CxF,SAAgB,qBAAqB,OAA0D;AAC7F,KAAI,CAAC,MAAM,YAAY,MAAM,WAAW,KAAM,QAAO;AACrD,KAAI,MAAM,iBAAiB,oBAAoB,MAAM,SAAS,OAAQ,QAAO;AAC7E,MAAK,MAAM,SAAS,UAAU,cAAc,SAAU,QAAO;AAC7D,KAAI,MAAM,iBAAiB,MAAO,QAAO;AACzC,KAAI,MAAM,aAAa,OAAW,QAAO;AACzC,KAAI,MAAM,aAAa,KAAM,QAAO;AACpC,KAAI,MAAM,OAAO,mBAAoB,QAAO;AAC5C,KAAI,MAAM,OAAO,mBAAmB,MAAM,WAAW,MAAM,OAAO,IAAI,OAAQ,QAAO;AACrF,KAAI,yBAAyB,MAAM,OAAO,aAAa,CAAC,OAAQ,QAAO;AACvE,KAAI,CAAC,MAAM,aAAa,aAAa,MAAM,OAAO,aAAa,CAAE,QAAO;AACxE,KAAI,MAAM,iBAAiB,cAAe,QAAO;AACjD,KAAI,MAAM,QAAQ,UAAa,MAAM,QAAQ,MAAM,QAAS,QAAO;AACnE,QAAO;;;;;;;AAQT,SAAgB,YAAY,QAA+B;AACzD,QAAO,cAAc,OAAO;;;AAoE9B,SAAgB,SAAS,QAA+B;AACtD,QAAO,OAAO,MAAM,QAAQ;;;;;;;;AAS9B,SAAgB,eACd,MACA,MACA,WACkC;AAClC,QAAO,IAAI,SAAS,YAAY;EAC9B,MAAM,OAAO,KAAK,UAAU,KAAK;EACjC,MAAM,MAAMA,QACV;GACE,MAAM;GACN;GACA,MAAM;GACN,QAAQ;GACR,SAAS;IAAE,gBAAgB;IAAoB,kBAAkB,OAAO,WAAW,KAAK;IAAE;GAC3F,GACA,QAAyB;GACxB,MAAM,SAAmB,EAAE;AAC3B,OAAI,GAAG,SAAS,MAAc,OAAO,KAAK,EAAE,CAAC;AAC7C,OAAI,GAAG,aAAa;AAClB,QAAI;AACF,aAAQ,KAAK,MAAM,OAAO,OAAO,OAAO,CAAC,SAAS,OAAO,CAAC,CAAqB;YACzE;AACN,aAAQ,KAAK;;KAEf;AACF,OAAI,GAAG,eAAe,QAAQ,KAAK,CAAC;IAEvC;AAED,MAAI,WAAW,iBAAiB;AAI9B,OAAI,SAAS;AACb,WAAQ,KAAK;IACb;AACF,MAAI,GAAG,eAAe,QAAQ,KAAK,CAAC;AACpC,MAAI,IAAI,KAAK;GACb;;;AAIJ,SAAgB,cAAc,MAAc,YAAY,KAAuB;AAC7E,QAAO,IAAI,SAAS,YAAY;EAC9B,MAAM,MAAMA,QAAY;GAAE,MAAM;GAAa;GAAM,MAAM;GAAY,QAAQ;GAAO,SAAS;GAAW,GAAG,QAAQ;AACjH,OAAI,QAAQ;AACZ,OAAI,GAAG,aAAa,QAAQ,IAAI,eAAe,IAAI,CAAC;AACpD,OAAI,GAAG,eAAe,QAAQ,MAAM,CAAC;IACrC;AACF,MAAI,GAAG,iBAAiB;AACtB,OAAI,SAAS;AACb,WAAQ,MAAM;IACd;AACF,MAAI,GAAG,eAAe,QAAQ,MAAM,CAAC;AACrC,MAAI,KAAK;GACT"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import "./config-DSdCEcHS.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import { l as fmtTokens } from "./status-
|
|
4
|
-
import { a as poolMetricsPath, c as poolSettings, d as readPoolMetrics, f as readPoolState, n as poolDir, o as poolPidPath, r as poolHostAlive, s as poolPort, t as poolCarry } from "./pool-
|
|
2
|
+
import { H as writeWorkersSection, R as readWorkersSection } from "./config-CYjjGSWq.mjs";
|
|
3
|
+
import { l as fmtTokens } from "./status-B0br9zV0.mjs";
|
|
4
|
+
import { a as poolMetricsPath, c as poolSettings, d as readPoolMetrics, f as readPoolState, n as poolDir, o as poolPidPath, r as poolHostAlive, s as poolPort, t as poolCarry } from "./pool-CgsnP3La.mjs";
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
@@ -140,4 +140,4 @@ async function poolStatusText(logDir) {
|
|
|
140
140
|
|
|
141
141
|
//#endregion
|
|
142
142
|
export { poolOff, poolOn, poolStatusText };
|
|
143
|
-
//# sourceMappingURL=pool-cli-
|
|
143
|
+
//# sourceMappingURL=pool-cli-kGGuZCzJ.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pool-cli-
|
|
1
|
+
{"version":3,"file":"pool-cli-kGGuZCzJ.mjs","names":["httpRequest"],"sources":["../src/workers/pool-cli.ts"],"sourcesContent":["/**\n * pool-cli.ts — the `pai worker pool on|off|status` implementations.\n *\n * on flips workers.pool.enabled in the main config and starts the detached\n * host (dist/hooks/worker-pool-host.mjs, same find-and-spawn pattern as the\n * openai proxy); off flips the flag back and shuts the host down; status\n * prints the live state: slots, tasks served, cache-carry total.\n */\n\nimport { spawn } from \"node:child_process\";\nimport { existsSync, mkdirSync, unlinkSync, writeFileSync, readFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { readWorkersSection, writeWorkersSection } from \"./config.js\";\nimport { fmtTokens } from \"./status.js\";\nimport {\n poolCarry,\n poolDir,\n poolHostAlive,\n poolPort,\n poolSettings,\n poolPidPath,\n readPoolMetrics,\n poolMetricsPath,\n readPoolState,\n} from \"./pool.js\";\nimport { request as httpRequest } from \"node:http\";\n\n/** Where the standalone host bundle lives (see standaloneProxyPath in proxy/server.ts). */\nexport function standalonePoolHostPath(): string | null {\n let dir = dirname(fileURLToPath(import.meta.url));\n for (let i = 0; i < 6; i++) {\n for (const cand of [\n join(dir, \"worker-pool-host.mjs\"),\n join(dir, \"hooks\", \"worker-pool-host.mjs\"),\n join(dir, \"dist\", \"hooks\", \"worker-pool-host.mjs\"),\n ]) {\n if (existsSync(cand)) return cand;\n }\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\nfunction setPoolEnabled(enabled: boolean): void {\n const { raw, workers } = readWorkersSection();\n workers.pool = { ...(workers.pool ?? { enabled, maxSlots: 3, idleSecs: 900, port: 8798 }), enabled };\n writeWorkersSection(raw, workers);\n}\n\n/** POST /shutdown, best effort: a dead host needs no shutdown. */\nfunction shutdownHost(port: number): void {\n const req = httpRequest({ host: \"127.0.0.1\", port, path: \"/shutdown\", method: \"POST\", timeout: 1500 }, (res) => {\n res.resume();\n });\n req.on(\"timeout\", () => req.destroy());\n req.on(\"error\", () => {});\n req.end();\n}\n\nexport async function poolOn(logDir: string): Promise<string[]> {\n const { workers } = readWorkersSection();\n const settings = poolSettings(workers);\n const port = settings.port;\n if (await poolHostAlive(port)) {\n setPoolEnabled(true);\n return [`pool host already running on port ${port} — routing enabled`];\n }\n const script = standalonePoolHostPath();\n if (!script) {\n throw new Error(\n `the pool host build was not found — run \\`bun run build\\` first, ` +\n `or start one by hand: node dist/hooks/worker-pool-host.mjs --port ${port}`\n );\n }\n mkdirSync(logDir, { recursive: true });\n mkdirSync(poolDir(logDir), { recursive: true }); // the pid file lands before the host's first state write\n const child = spawn(process.execPath, [script, \"--port\", String(port)], {\n detached: true,\n stdio: \"ignore\",\n cwd: logDir,\n });\n child.unref();\n writeFileSync(poolPidPath(logDir), `${child.pid}\\n`, \"utf8\");\n for (let i = 0; i < 40; i++) {\n if (await poolHostAlive(port)) {\n setPoolEnabled(true);\n return [\n `pool host started (pid ${child.pid}, port ${port}, cwd ${logDir})`,\n `routing enabled: eligible headless runs (no worktree, no own system prompt/MCP) go through the pool`,\n `slots: max ${settings.maxSlots}, idle kill ${settings.idleSecs}s`,\n ];\n }\n await new Promise((r) => setTimeout(r, 250));\n }\n throw new Error(`the pool host did not come up on port ${port} (see ${poolPidPath(logDir)})`);\n}\n\nexport async function poolOff(logDir: string): Promise<string[]> {\n const { workers } = readWorkersSection();\n const port = poolPort(workers);\n setPoolEnabled(false);\n shutdownHost(port);\n const lines = [`routing disabled — every run spawns fresh again`];\n const pidPath = poolPidPath(logDir);\n if (existsSync(pidPath)) {\n const pid = parseInt(readFileSync(pidPath, \"utf8\").trim(), 10);\n unlinkSync(pidPath);\n try {\n process.kill(pid, \"SIGTERM\");\n lines.push(`pool host pid ${pid} stopped`);\n } catch {\n lines.push(`pool host pid ${pid} was not running (pid file removed)`);\n }\n } else {\n lines.push(`no pool host pid file — nothing to stop`);\n }\n return lines;\n}\n\n/** The `pai worker pool status` text: config, host, slots, carry. */\nexport async function poolStatusText(logDir: string): Promise<string> {\n const { workers } = readWorkersSection();\n const settings = poolSettings(workers);\n const port = settings.port;\n const alive = await poolHostAlive(port);\n const lines: string[] = [];\n lines.push(`pool: ${settings.enabled ? \"enabled\" : \"disabled\"} (config workers.pool; host port ${port}, max ${settings.maxSlots} slots, idle kill ${settings.idleSecs}s)`);\n const state = readPoolState(logDir);\n if (!state) {\n lines.push(alive ? `host: alive on port ${port} (no state file yet)` : `host: not running`);\n return lines.join(\"\\n\");\n }\n lines.push(\n `host: pid ${state.hostPid}, port ${state.port}, up since ${state.startedAt}, ` +\n `${alive ? \"alive\" : \"NOT answering (state file is stale)\"}`\n );\n if (state.slots.length) {\n lines.push(`slots (${state.slots.length}):`);\n for (const s of state.slots) {\n lines.push(\n ` ${s.provider}/${s.model} pid ${s.pid} session ${(s.claudeSession ?? \"?\").slice(0, 8)} ` +\n `tasks ${s.tasks} ${s.busyWith ? `busy ${s.busyWith}` : \"idle\"} last ${s.lastUsedAt}`\n );\n }\n } else {\n lines.push(`slots: none (a slot spawns with the first pooled task)`);\n }\n const carry = poolCarry(readPoolMetrics(poolMetricsPath(logDir)));\n lines.push(\n `tasks served ${state.tasksServed} · carried ${fmtTokens(carry.tokensCarried)} cache-read tokens ` +\n `over ${carry.tasks} task(s) · first-request hit ratio ${(carry.hitRatio * 100).toFixed(1)}%`\n );\n return lines.join(\"\\n\");\n}\n"],"mappings":"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"}
|