github-router 0.3.41 → 0.3.42
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/{lifecycle-CpnAVVQ_.js → lifecycle-DU0UI2t5.js} +2 -2
- package/dist/{lifecycle-CpnAVVQ_.js.map → lifecycle-DU0UI2t5.js.map} +1 -1
- package/dist/{lifecycle-DpnTmHCo.js → lifecycle-zr19Ot-e.js} +2 -2
- package/dist/main.js +192 -43
- package/dist/main.js.map +1 -1
- package/dist/{paths-cZle37Jp.js → paths-lwEqM5-i.js} +293 -2
- package/dist/paths-lwEqM5-i.js.map +1 -0
- package/dist/{paths-B7jmIPYq.js → paths-nd-94lLq.js} +1 -1
- package/package.json +1 -1
- package/dist/paths-cZle37Jp.js.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as writeRuntimeFileSecure, t as PATHS } from "./paths-
|
|
1
|
+
import { c as writeRuntimeFileSecure, t as PATHS } from "./paths-lwEqM5-i.js";
|
|
2
2
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
@@ -307,4 +307,4 @@ async function sweepStaleWorktreesAtBoot() {
|
|
|
307
307
|
|
|
308
308
|
//#endregion
|
|
309
309
|
export { sweepRegistry as a, registerExitHandlers as i, getInstanceUuid as n, sweepStaleWorktreesAtBoot as o, recordWorkerRepo as r, WorktreeRegistry as t };
|
|
310
|
-
//# sourceMappingURL=lifecycle-
|
|
310
|
+
//# sourceMappingURL=lifecycle-DU0UI2t5.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lifecycle-CpnAVVQ_.js","names":["_instanceUuid: string | null","_activeRegistry: WorktreeRegistry | null","_exitHandler: (() => void) | null","_sigintHandler: (() => void) | null","_sigtermHandler: (() => void) | null","raw: string","cleaned: Array<LedgerEntry>","_ledgerChain: Promise<void>","ledger: LedgerFile","names: Array<string>"],"sources":["../src/lib/worker-agent/lifecycle.ts"],"sourcesContent":["/**\n * Lifecycle plumbing for worker worktrees: in-memory registry, signal\n * handlers, ledger of repos touched, and the boot-time PID+instance\n * safety net.\n *\n * Plan: see `plans/we-have-added-a-dreamy-tide.md` (\"Worktree mode\" →\n * \"Cleanup paths\"). Three layers cooperate, none of them sufficient\n * alone:\n *\n * 1. Per-call cleanup (`engine.ts` finally block invoking\n * `WorktreeHandle.remove()`) — covers the happy path.\n *\n * 2. Session-end signal sweep (this file, registered via\n * `registerExitHandlers`) — covers Ctrl+C, service-manager stop,\n * and (in `github-router claude` mode) the spawned child's exit.\n * Synchronous `execFileSync` is intentional: exit handlers can't\n * reliably await async work.\n *\n * 3. Boot-time PID+instance sweep (`sweepStaleWorktreesAtBoot`) —\n * covers SIGKILL, OOM, container restart. Walks the ledger of\n * repos this proxy has touched and removes worktree dirs whose\n * `<pid>` is dead OR whose `<instance>` UUID doesn't match the\n * current proxy's UUID.\n *\n * Ledger writes are ATOMIC (temp + rename) per peer review — a\n * concurrent-RMW corruption would silently strand worktrees because\n * the boot sweep can't find their repo roots.\n */\n\nimport { execFileSync } from \"node:child_process\"\nimport { randomBytes, randomUUID } from \"node:crypto\"\nimport fs from \"node:fs/promises\"\nimport path from \"node:path\"\nimport process from \"node:process\"\n\nimport { PATHS, writeRuntimeFileSecure } from \"../paths\"\n\n/**\n * Same regex worktree.ts uses for its per-call age sweep — kept in\n * sync intentionally. `<pid>-<uuid>-<8hex>` strictly.\n */\nconst WORKTREE_DIR_NAME_RE =\n /^(\\d+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-([0-9a-f]{8})$/\n\n/**\n * Cap on the ledger: how many repos we remember across boots, and how\n * old an entry may be before it's pruned. Both are belt-and-suspenders\n * — the per-call age sweep is the primary guard against accumulation\n * inside any single repo.\n */\nconst LEDGER_MAX_ENTRIES = 100\nconst LEDGER_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000\n\nexport interface WorktreeRegistryEntry {\n repoRoot: string\n dir: string\n branch: string\n}\n\n/**\n * Set-like in-memory registry of worktrees this proxy created. Engine\n * passes it to `createWorktree` so per-call cleanup deletes the entry\n * on success; the signal handlers walk what's left at shutdown.\n *\n * Not a bare `Set` because we want to expose only the operations we\n * actually use, and we want a stable testable surface.\n */\nexport class WorktreeRegistry {\n private readonly entries = new Set<WorktreeRegistryEntry>()\n\n add(entry: WorktreeRegistryEntry): void {\n this.entries.add(entry)\n }\n delete(entry: WorktreeRegistryEntry): void {\n this.entries.delete(entry)\n }\n has(entry: WorktreeRegistryEntry): boolean {\n return this.entries.has(entry)\n }\n values(): IterableIterator<WorktreeRegistryEntry> {\n return this.entries.values()\n }\n get size(): number {\n return this.entries.size\n }\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------\n// Per-launch instance UUID\n// ---------------------------------------------------------------------\n\nlet _instanceUuid: string | null = null\n\n/**\n * Stable UUID4 generated once per proxy process. Used in worktree\n * dir/branch names so the boot sweep can reliably distinguish \"this\n * proxy's still-live worktrees\" from \"stranded dirs from a prior\n * proxy that happens to have a recycled PID\" — Docker PID-1 across\n * container restarts is the classic case (peer-review HIGH finding).\n */\nexport function getInstanceUuid(): string {\n if (_instanceUuid === null) {\n _instanceUuid = randomUUID()\n }\n return _instanceUuid\n}\n\n/** Test-only: reset the cached UUID. */\nexport function __resetInstanceUuidForTests(): void {\n _instanceUuid = null\n}\n\n// ---------------------------------------------------------------------\n// Signal handlers + sweepRegistry\n// ---------------------------------------------------------------------\n\nlet _registered = false\nlet _activeRegistry: WorktreeRegistry | null = null\nlet _exitHandler: (() => void) | null = null\nlet _sigintHandler: (() => void) | null = null\nlet _sigtermHandler: (() => void) | null = null\n\n/**\n * Synchronous cleanup of every registry entry. Best-effort:\n * `execFileSync` failures are swallowed (the dir may have been\n * removed already, or git may not be on PATH any more in some\n * environments). After a successful removal we drop the entry from\n * the registry so a second call is a true no-op.\n *\n * Synchronous on purpose — exit handlers can't reliably await async\n * work; the process would die before the promise settled.\n */\nexport function sweepRegistry(): void {\n if (!_activeRegistry) return\n // Snapshot the values first so we can mutate the underlying set\n // during iteration without skipping entries.\n const snapshot = [..._activeRegistry.values()]\n for (const entry of snapshot) {\n try {\n // `-C entry.repoRoot` is load-bearing: without it git resolves\n // the worktree path relative to the proxy's cwd (which is the\n // user's launch dir, typically NOT inside the target repo), and\n // fails with `fatal: '<path>' is not a working tree`. The E2E\n // boot-sweep test (worker-agent-boot-sweep.test.ts) is what\n // caught the missing flag.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", entry.dir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // Already gone, EBUSY, or git not on PATH — best effort.\n }\n try {\n execFileSync(\"git\", [\"-C\", entry.repoRoot, \"branch\", \"-D\", entry.branch], {\n stdio: \"ignore\",\n timeout: 5_000,\n windowsHide: true,\n })\n } catch {\n // Same as above.\n }\n _activeRegistry.delete(entry)\n }\n}\n\n/**\n * Windows ConPTY / node-pty signal behavior:\n *\n * When a ConPTY host (VS Code terminal, Windows Terminal, node-pty) closes\n * the pseudo-console, the ConPTY layer sends CTRL_CLOSE_EVENT to the\n * process group. Node.js translates this into SIGINT (NOT SIGTERM). The\n * process has a ~5-second window before forced termination.\n *\n * Implication: the SIGTERM handler below may NEVER fire in node-pty\n * environments. This is by design — the three-layer cleanup architecture\n * ensures coverage:\n * 1. Per-call cleanup (engine.ts finally block) — happy path\n * 2. SIGINT handler (this file) — ConPTY close, Ctrl+C\n * 3. `exit` handler (this file) — unconditional, fires on any exit\n * 4. Boot-time PID+instance sweep (sweepStaleWorktreesAtBoot) — crash recovery\n *\n * Layers 1+2+3 cover ConPTY; layer 4 covers SIGKILL/OOM/container restart.\n */\n\n/**\n * Wire up SIGINT/SIGTERM/exit handlers that walk the registry and\n * remove every entry. Idempotent: subsequent calls swap the registry\n * pointer but do NOT register additional process listeners (otherwise\n * we'd leak listeners on every `runWorkerAgent`).\n *\n * Signal handlers re-raise the signal after sweeping. Naively running\n * the sweep on SIGINT/SIGTERM and returning would *suppress* the\n * signal: Node defaults to terminating the process on these, but only\n * if no user listener is attached. Once we attach a listener, the\n * default action is cancelled and the process keeps running — which\n * means Ctrl-C would clean worktrees but not actually exit, leaving\n * orphan processes in dev. The `process.kill(pid, sig)` re-raise\n * after removing our own listener restores the default behaviour\n * (the second delivery now hits an empty listener list, so Node\n * terminates with the conventional `128 + signum` exit code).\n */\nexport function registerExitHandlers(registry: WorktreeRegistry): void {\n _activeRegistry = registry\n if (_registered) return\n _registered = true\n _exitHandler = () => sweepRegistry()\n _sigintHandler = () => {\n sweepRegistry()\n if (_sigintHandler) process.off(\"SIGINT\", _sigintHandler)\n process.kill(process.pid, \"SIGINT\")\n }\n _sigtermHandler = () => {\n sweepRegistry()\n if (_sigtermHandler) process.off(\"SIGTERM\", _sigtermHandler)\n process.kill(process.pid, \"SIGTERM\")\n }\n process.on(\"SIGINT\", _sigintHandler)\n process.on(\"SIGTERM\", _sigtermHandler)\n // `exit` handlers can only run synchronous code — exactly what\n // sweepRegistry does. Async work here would never complete.\n process.on(\"exit\", _exitHandler)\n}\n\n/**\n * Test-only: unregister the handlers and reset module state. Tests\n * that want to verify `registerExitHandlers` semantics must clean up\n * after themselves or future tests in the same process inherit the\n * (now stale) registry pointer.\n */\nexport function __unregisterExitHandlersForTests(): void {\n if (_sigintHandler) {\n process.off(\"SIGINT\", _sigintHandler)\n _sigintHandler = null\n }\n if (_sigtermHandler) {\n process.off(\"SIGTERM\", _sigtermHandler)\n _sigtermHandler = null\n }\n if (_exitHandler) {\n process.off(\"exit\", _exitHandler)\n _exitHandler = null\n }\n _registered = false\n _activeRegistry = null\n}\n\n// ---------------------------------------------------------------------\n// Ledger: which repos has this proxy touched?\n// ---------------------------------------------------------------------\n\ninterface LedgerEntry {\n repoRoot: string\n lastSeenMs: number\n}\n\ninterface LedgerFile {\n entries: Array<LedgerEntry>\n}\n\nfunction ledgerPath(): string {\n return path.join(PATHS.APP_DIR, \"worker-repos.json\")\n}\n\nasync function readLedger(): Promise<LedgerFile> {\n let raw: string\n try {\n raw = await fs.readFile(ledgerPath(), \"utf8\")\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n return { entries: [] }\n }\n return { entries: [] }\n }\n try {\n const parsed = JSON.parse(raw) as Partial<LedgerFile>\n if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] }\n const cleaned: Array<LedgerEntry> = []\n for (const e of parsed.entries) {\n if (\n e &&\n typeof e === \"object\" &&\n typeof (e as LedgerEntry).repoRoot === \"string\" &&\n typeof (e as LedgerEntry).lastSeenMs === \"number\"\n ) {\n cleaned.push({\n repoRoot: (e as LedgerEntry).repoRoot,\n lastSeenMs: (e as LedgerEntry).lastSeenMs,\n })\n }\n }\n return { entries: cleaned }\n } catch {\n // Corrupted JSON — start fresh rather than crashing the proxy.\n return { entries: [] }\n }\n}\n\n/**\n * Per-process serializer for ledger writes. Multiple concurrent\n * `recordWorkerRepo` calls (legitimate: several workers may start at\n * once) would otherwise race read-modify-write on the JSON file. Each\n * call chains onto the previous so the on-disk sequence is\n * deterministic from this process's perspective.\n *\n * Cross-process safety is provided by the atomic temp+rename below,\n * which makes the final state of the file always be a well-formed\n * full snapshot from ONE writer — never a partial write or\n * interleaved JSON.\n */\nlet _ledgerChain: Promise<void> = Promise.resolve()\n\n/**\n * Append `repoRoot` to the ledger (or update its `lastSeenMs`).\n * Atomic temp+rename per peer review.\n */\nexport function recordWorkerRepo(repoRoot: string): Promise<void> {\n const next = _ledgerChain.then(async () => {\n await fs.mkdir(PATHS.APP_DIR, { recursive: true })\n const current = await readLedger()\n // Dedup: drop any existing entry for this root before appending\n // the fresh one so the array doesn't grow unbounded with repeats.\n const filtered = current.entries.filter((e) => e.repoRoot !== repoRoot)\n filtered.push({ repoRoot, lastSeenMs: Date.now() })\n // Prune by age and cap entry count (newest wins).\n const now = Date.now()\n const pruned = filtered\n .filter((e) => now - e.lastSeenMs < LEDGER_MAX_AGE_MS)\n .slice(-LEDGER_MAX_ENTRIES)\n const ledger: LedgerFile = { entries: pruned }\n\n // Atomic temp+rename. The temp filename is unique per call\n // (PID + 8 random hex chars) so concurrent processes don't\n // collide on the temp name; the final `rename` is atomic on\n // POSIX and on Windows (both with same filesystem).\n const tmp = `${ledgerPath()}.tmp.${process.pid}.${randomBytes(4).toString(\n \"hex\",\n )}`\n try {\n await writeRuntimeFileSecure(tmp, JSON.stringify(ledger, null, 2))\n await fs.rename(tmp, ledgerPath())\n } catch (err) {\n // Clean up the temp file if rename failed midway.\n await fs.unlink(tmp).catch(() => {})\n throw err\n }\n })\n // Swallow chain-internal errors so one failed write doesn't poison\n // the chain for every subsequent caller. Each call still sees its\n // own rejection (we return `next`, not the catch-handler chain).\n _ledgerChain = next.catch(() => undefined)\n return next\n}\n\nfunction isPidAlive(pid: number): boolean {\n if (!Number.isInteger(pid) || pid <= 0) return false\n try {\n process.kill(pid, 0)\n return true\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code\n // EPERM = process exists but we can't signal it — still alive\n // for our purposes (we just need to know whether to clean up).\n if (code === \"EPERM\") return true\n return false\n }\n}\n\n/**\n * Boot-time sweep. For every repo we recorded in the ledger,\n * enumerate `<repoRoot>/.git/worker-worktrees/` (the conventional\n * location — for repos already inside a worktree, the actual\n * `git-common-dir` may differ, in which case we'll miss this batch\n * and the per-call age sweep will catch them within 7 days) and\n * remove dirs that aren't owned by THIS proxy.\n *\n * Ownership rule: dir is \"ours\" iff its embedded PID is alive AND\n * its embedded UUID equals `getInstanceUuid()`. Either condition\n * failing → remove.\n */\nexport async function sweepStaleWorktreesAtBoot(): Promise<void> {\n const ledger = await readLedger()\n if (ledger.entries.length === 0) return\n const currentUuid = getInstanceUuid()\n for (const entry of ledger.entries) {\n const parent = path.join(entry.repoRoot, \".git\", \"worker-worktrees\")\n let names: Array<string>\n try {\n names = await fs.readdir(parent)\n } catch {\n continue\n }\n for (const name of names) {\n const m = WORKTREE_DIR_NAME_RE.exec(name)\n if (!m) continue\n const pid = Number.parseInt(m[1], 10)\n const uuid = m[2]\n const isOurs = isPidAlive(pid) && uuid === currentUuid\n if (isOurs) continue\n\n const fullDir = path.join(parent, name)\n const branch = `worker/${pid}-${uuid}-${m[3]}`\n try {\n // `-C entry.repoRoot` is load-bearing here too — see the\n // matching comment in `sweepRegistry`. The boot sweep runs\n // BEFORE any worker tool has set cwd, so the proxy's cwd is\n // the user's launch dir, which is almost never inside the\n // target repo.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", fullDir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"branch\", \"-D\", branch],\n { stdio: \"ignore\", timeout: 5_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n await fs.rm(fullDir, { recursive: true, force: true })\n } catch {\n // ignore — git may have removed it already\n }\n }\n }\n}\n\n/** Test-only: clear the ledger file (does NOT remove on-disk worktrees). */\nexport async function __clearLedgerForTests(): Promise<void> {\n await fs.unlink(ledgerPath()).catch(() => {})\n}\n\n/** Test-only: read the ledger as a plain array (no side effects). */\nexport async function __readLedgerForTests(): Promise<Array<LedgerEntry>> {\n return (await readLedger()).entries\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"lifecycle-DU0UI2t5.js","names":["_instanceUuid: string | null","_activeRegistry: WorktreeRegistry | null","_exitHandler: (() => void) | null","_sigintHandler: (() => void) | null","_sigtermHandler: (() => void) | null","raw: string","cleaned: Array<LedgerEntry>","_ledgerChain: Promise<void>","ledger: LedgerFile","names: Array<string>"],"sources":["../src/lib/worker-agent/lifecycle.ts"],"sourcesContent":["/**\n * Lifecycle plumbing for worker worktrees: in-memory registry, signal\n * handlers, ledger of repos touched, and the boot-time PID+instance\n * safety net.\n *\n * Plan: see `plans/we-have-added-a-dreamy-tide.md` (\"Worktree mode\" →\n * \"Cleanup paths\"). Three layers cooperate, none of them sufficient\n * alone:\n *\n * 1. Per-call cleanup (`engine.ts` finally block invoking\n * `WorktreeHandle.remove()`) — covers the happy path.\n *\n * 2. Session-end signal sweep (this file, registered via\n * `registerExitHandlers`) — covers Ctrl+C, service-manager stop,\n * and (in `github-router claude` mode) the spawned child's exit.\n * Synchronous `execFileSync` is intentional: exit handlers can't\n * reliably await async work.\n *\n * 3. Boot-time PID+instance sweep (`sweepStaleWorktreesAtBoot`) —\n * covers SIGKILL, OOM, container restart. Walks the ledger of\n * repos this proxy has touched and removes worktree dirs whose\n * `<pid>` is dead OR whose `<instance>` UUID doesn't match the\n * current proxy's UUID.\n *\n * Ledger writes are ATOMIC (temp + rename) per peer review — a\n * concurrent-RMW corruption would silently strand worktrees because\n * the boot sweep can't find their repo roots.\n */\n\nimport { execFileSync } from \"node:child_process\"\nimport { randomBytes, randomUUID } from \"node:crypto\"\nimport fs from \"node:fs/promises\"\nimport path from \"node:path\"\nimport process from \"node:process\"\n\nimport { PATHS, writeRuntimeFileSecure } from \"../paths\"\n\n/**\n * Same regex worktree.ts uses for its per-call age sweep — kept in\n * sync intentionally. `<pid>-<uuid>-<8hex>` strictly.\n */\nconst WORKTREE_DIR_NAME_RE =\n /^(\\d+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-([0-9a-f]{8})$/\n\n/**\n * Cap on the ledger: how many repos we remember across boots, and how\n * old an entry may be before it's pruned. Both are belt-and-suspenders\n * — the per-call age sweep is the primary guard against accumulation\n * inside any single repo.\n */\nconst LEDGER_MAX_ENTRIES = 100\nconst LEDGER_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000\n\nexport interface WorktreeRegistryEntry {\n repoRoot: string\n dir: string\n branch: string\n}\n\n/**\n * Set-like in-memory registry of worktrees this proxy created. Engine\n * passes it to `createWorktree` so per-call cleanup deletes the entry\n * on success; the signal handlers walk what's left at shutdown.\n *\n * Not a bare `Set` because we want to expose only the operations we\n * actually use, and we want a stable testable surface.\n */\nexport class WorktreeRegistry {\n private readonly entries = new Set<WorktreeRegistryEntry>()\n\n add(entry: WorktreeRegistryEntry): void {\n this.entries.add(entry)\n }\n delete(entry: WorktreeRegistryEntry): void {\n this.entries.delete(entry)\n }\n has(entry: WorktreeRegistryEntry): boolean {\n return this.entries.has(entry)\n }\n values(): IterableIterator<WorktreeRegistryEntry> {\n return this.entries.values()\n }\n get size(): number {\n return this.entries.size\n }\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------\n// Per-launch instance UUID\n// ---------------------------------------------------------------------\n\nlet _instanceUuid: string | null = null\n\n/**\n * Stable UUID4 generated once per proxy process. Used in worktree\n * dir/branch names so the boot sweep can reliably distinguish \"this\n * proxy's still-live worktrees\" from \"stranded dirs from a prior\n * proxy that happens to have a recycled PID\" — Docker PID-1 across\n * container restarts is the classic case (peer-review HIGH finding).\n */\nexport function getInstanceUuid(): string {\n if (_instanceUuid === null) {\n _instanceUuid = randomUUID()\n }\n return _instanceUuid\n}\n\n/** Test-only: reset the cached UUID. */\nexport function __resetInstanceUuidForTests(): void {\n _instanceUuid = null\n}\n\n// ---------------------------------------------------------------------\n// Signal handlers + sweepRegistry\n// ---------------------------------------------------------------------\n\nlet _registered = false\nlet _activeRegistry: WorktreeRegistry | null = null\nlet _exitHandler: (() => void) | null = null\nlet _sigintHandler: (() => void) | null = null\nlet _sigtermHandler: (() => void) | null = null\n\n/**\n * Synchronous cleanup of every registry entry. Best-effort:\n * `execFileSync` failures are swallowed (the dir may have been\n * removed already, or git may not be on PATH any more in some\n * environments). After a successful removal we drop the entry from\n * the registry so a second call is a true no-op.\n *\n * Synchronous on purpose — exit handlers can't reliably await async\n * work; the process would die before the promise settled.\n */\nexport function sweepRegistry(): void {\n if (!_activeRegistry) return\n // Snapshot the values first so we can mutate the underlying set\n // during iteration without skipping entries.\n const snapshot = [..._activeRegistry.values()]\n for (const entry of snapshot) {\n try {\n // `-C entry.repoRoot` is load-bearing: without it git resolves\n // the worktree path relative to the proxy's cwd (which is the\n // user's launch dir, typically NOT inside the target repo), and\n // fails with `fatal: '<path>' is not a working tree`. The E2E\n // boot-sweep test (worker-agent-boot-sweep.test.ts) is what\n // caught the missing flag.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", entry.dir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // Already gone, EBUSY, or git not on PATH — best effort.\n }\n try {\n execFileSync(\"git\", [\"-C\", entry.repoRoot, \"branch\", \"-D\", entry.branch], {\n stdio: \"ignore\",\n timeout: 5_000,\n windowsHide: true,\n })\n } catch {\n // Same as above.\n }\n _activeRegistry.delete(entry)\n }\n}\n\n/**\n * Windows ConPTY / node-pty signal behavior:\n *\n * When a ConPTY host (VS Code terminal, Windows Terminal, node-pty) closes\n * the pseudo-console, the ConPTY layer sends CTRL_CLOSE_EVENT to the\n * process group. Node.js translates this into SIGINT (NOT SIGTERM). The\n * process has a ~5-second window before forced termination.\n *\n * Implication: the SIGTERM handler below may NEVER fire in node-pty\n * environments. This is by design — the three-layer cleanup architecture\n * ensures coverage:\n * 1. Per-call cleanup (engine.ts finally block) — happy path\n * 2. SIGINT handler (this file) — ConPTY close, Ctrl+C\n * 3. `exit` handler (this file) — unconditional, fires on any exit\n * 4. Boot-time PID+instance sweep (sweepStaleWorktreesAtBoot) — crash recovery\n *\n * Layers 1+2+3 cover ConPTY; layer 4 covers SIGKILL/OOM/container restart.\n */\n\n/**\n * Wire up SIGINT/SIGTERM/exit handlers that walk the registry and\n * remove every entry. Idempotent: subsequent calls swap the registry\n * pointer but do NOT register additional process listeners (otherwise\n * we'd leak listeners on every `runWorkerAgent`).\n *\n * Signal handlers re-raise the signal after sweeping. Naively running\n * the sweep on SIGINT/SIGTERM and returning would *suppress* the\n * signal: Node defaults to terminating the process on these, but only\n * if no user listener is attached. Once we attach a listener, the\n * default action is cancelled and the process keeps running — which\n * means Ctrl-C would clean worktrees but not actually exit, leaving\n * orphan processes in dev. The `process.kill(pid, sig)` re-raise\n * after removing our own listener restores the default behaviour\n * (the second delivery now hits an empty listener list, so Node\n * terminates with the conventional `128 + signum` exit code).\n */\nexport function registerExitHandlers(registry: WorktreeRegistry): void {\n _activeRegistry = registry\n if (_registered) return\n _registered = true\n _exitHandler = () => sweepRegistry()\n _sigintHandler = () => {\n sweepRegistry()\n if (_sigintHandler) process.off(\"SIGINT\", _sigintHandler)\n process.kill(process.pid, \"SIGINT\")\n }\n _sigtermHandler = () => {\n sweepRegistry()\n if (_sigtermHandler) process.off(\"SIGTERM\", _sigtermHandler)\n process.kill(process.pid, \"SIGTERM\")\n }\n process.on(\"SIGINT\", _sigintHandler)\n process.on(\"SIGTERM\", _sigtermHandler)\n // `exit` handlers can only run synchronous code — exactly what\n // sweepRegistry does. Async work here would never complete.\n process.on(\"exit\", _exitHandler)\n}\n\n/**\n * Test-only: unregister the handlers and reset module state. Tests\n * that want to verify `registerExitHandlers` semantics must clean up\n * after themselves or future tests in the same process inherit the\n * (now stale) registry pointer.\n */\nexport function __unregisterExitHandlersForTests(): void {\n if (_sigintHandler) {\n process.off(\"SIGINT\", _sigintHandler)\n _sigintHandler = null\n }\n if (_sigtermHandler) {\n process.off(\"SIGTERM\", _sigtermHandler)\n _sigtermHandler = null\n }\n if (_exitHandler) {\n process.off(\"exit\", _exitHandler)\n _exitHandler = null\n }\n _registered = false\n _activeRegistry = null\n}\n\n// ---------------------------------------------------------------------\n// Ledger: which repos has this proxy touched?\n// ---------------------------------------------------------------------\n\ninterface LedgerEntry {\n repoRoot: string\n lastSeenMs: number\n}\n\ninterface LedgerFile {\n entries: Array<LedgerEntry>\n}\n\nfunction ledgerPath(): string {\n return path.join(PATHS.APP_DIR, \"worker-repos.json\")\n}\n\nasync function readLedger(): Promise<LedgerFile> {\n let raw: string\n try {\n raw = await fs.readFile(ledgerPath(), \"utf8\")\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n return { entries: [] }\n }\n return { entries: [] }\n }\n try {\n const parsed = JSON.parse(raw) as Partial<LedgerFile>\n if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] }\n const cleaned: Array<LedgerEntry> = []\n for (const e of parsed.entries) {\n if (\n e &&\n typeof e === \"object\" &&\n typeof (e as LedgerEntry).repoRoot === \"string\" &&\n typeof (e as LedgerEntry).lastSeenMs === \"number\"\n ) {\n cleaned.push({\n repoRoot: (e as LedgerEntry).repoRoot,\n lastSeenMs: (e as LedgerEntry).lastSeenMs,\n })\n }\n }\n return { entries: cleaned }\n } catch {\n // Corrupted JSON — start fresh rather than crashing the proxy.\n return { entries: [] }\n }\n}\n\n/**\n * Per-process serializer for ledger writes. Multiple concurrent\n * `recordWorkerRepo` calls (legitimate: several workers may start at\n * once) would otherwise race read-modify-write on the JSON file. Each\n * call chains onto the previous so the on-disk sequence is\n * deterministic from this process's perspective.\n *\n * Cross-process safety is provided by the atomic temp+rename below,\n * which makes the final state of the file always be a well-formed\n * full snapshot from ONE writer — never a partial write or\n * interleaved JSON.\n */\nlet _ledgerChain: Promise<void> = Promise.resolve()\n\n/**\n * Append `repoRoot` to the ledger (or update its `lastSeenMs`).\n * Atomic temp+rename per peer review.\n */\nexport function recordWorkerRepo(repoRoot: string): Promise<void> {\n const next = _ledgerChain.then(async () => {\n await fs.mkdir(PATHS.APP_DIR, { recursive: true })\n const current = await readLedger()\n // Dedup: drop any existing entry for this root before appending\n // the fresh one so the array doesn't grow unbounded with repeats.\n const filtered = current.entries.filter((e) => e.repoRoot !== repoRoot)\n filtered.push({ repoRoot, lastSeenMs: Date.now() })\n // Prune by age and cap entry count (newest wins).\n const now = Date.now()\n const pruned = filtered\n .filter((e) => now - e.lastSeenMs < LEDGER_MAX_AGE_MS)\n .slice(-LEDGER_MAX_ENTRIES)\n const ledger: LedgerFile = { entries: pruned }\n\n // Atomic temp+rename. The temp filename is unique per call\n // (PID + 8 random hex chars) so concurrent processes don't\n // collide on the temp name; the final `rename` is atomic on\n // POSIX and on Windows (both with same filesystem).\n const tmp = `${ledgerPath()}.tmp.${process.pid}.${randomBytes(4).toString(\n \"hex\",\n )}`\n try {\n await writeRuntimeFileSecure(tmp, JSON.stringify(ledger, null, 2))\n await fs.rename(tmp, ledgerPath())\n } catch (err) {\n // Clean up the temp file if rename failed midway.\n await fs.unlink(tmp).catch(() => {})\n throw err\n }\n })\n // Swallow chain-internal errors so one failed write doesn't poison\n // the chain for every subsequent caller. Each call still sees its\n // own rejection (we return `next`, not the catch-handler chain).\n _ledgerChain = next.catch(() => undefined)\n return next\n}\n\nfunction isPidAlive(pid: number): boolean {\n if (!Number.isInteger(pid) || pid <= 0) return false\n try {\n process.kill(pid, 0)\n return true\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code\n // EPERM = process exists but we can't signal it — still alive\n // for our purposes (we just need to know whether to clean up).\n if (code === \"EPERM\") return true\n return false\n }\n}\n\n/**\n * Boot-time sweep. For every repo we recorded in the ledger,\n * enumerate `<repoRoot>/.git/worker-worktrees/` (the conventional\n * location — for repos already inside a worktree, the actual\n * `git-common-dir` may differ, in which case we'll miss this batch\n * and the per-call age sweep will catch them within 7 days) and\n * remove dirs that aren't owned by THIS proxy.\n *\n * Ownership rule: dir is \"ours\" iff its embedded PID is alive AND\n * its embedded UUID equals `getInstanceUuid()`. Either condition\n * failing → remove.\n */\nexport async function sweepStaleWorktreesAtBoot(): Promise<void> {\n const ledger = await readLedger()\n if (ledger.entries.length === 0) return\n const currentUuid = getInstanceUuid()\n for (const entry of ledger.entries) {\n const parent = path.join(entry.repoRoot, \".git\", \"worker-worktrees\")\n let names: Array<string>\n try {\n names = await fs.readdir(parent)\n } catch {\n continue\n }\n for (const name of names) {\n const m = WORKTREE_DIR_NAME_RE.exec(name)\n if (!m) continue\n const pid = Number.parseInt(m[1], 10)\n const uuid = m[2]\n const isOurs = isPidAlive(pid) && uuid === currentUuid\n if (isOurs) continue\n\n const fullDir = path.join(parent, name)\n const branch = `worker/${pid}-${uuid}-${m[3]}`\n try {\n // `-C entry.repoRoot` is load-bearing here too — see the\n // matching comment in `sweepRegistry`. The boot sweep runs\n // BEFORE any worker tool has set cwd, so the proxy's cwd is\n // the user's launch dir, which is almost never inside the\n // target repo.\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"worktree\", \"remove\", \"--force\", fullDir],\n { stdio: \"ignore\", timeout: 10_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n execFileSync(\n \"git\",\n [\"-C\", entry.repoRoot, \"branch\", \"-D\", branch],\n { stdio: \"ignore\", timeout: 5_000, windowsHide: true },\n )\n } catch {\n // ignore\n }\n try {\n await fs.rm(fullDir, { recursive: true, force: true })\n } catch {\n // ignore — git may have removed it already\n }\n }\n }\n}\n\n/** Test-only: clear the ledger file (does NOT remove on-disk worktrees). */\nexport async function __clearLedgerForTests(): Promise<void> {\n await fs.unlink(ledgerPath()).catch(() => {})\n}\n\n/** Test-only: read the ledger as a plain array (no side effects). */\nexport async function __readLedgerForTests(): Promise<Array<LedgerEntry>> {\n return (await readLedger()).entries\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import "./paths-
|
|
2
|
-
import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, o as sweepStaleWorktreesAtBoot, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-
|
|
1
|
+
import "./paths-lwEqM5-i.js";
|
|
2
|
+
import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, o as sweepStaleWorktreesAtBoot, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-DU0UI2t5.js";
|
|
3
3
|
|
|
4
4
|
export { WorktreeRegistry, getInstanceUuid, recordWorkerRepo, registerExitHandlers, sweepRegistry, sweepStaleWorktreesAtBoot };
|
package/dist/main.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { c as writeRuntimeFileSecure, i as removeOwnClaudeConfigMirror, n as ensureClaudeConfigMirror, r as ensurePaths, t as PATHS } from "./paths-
|
|
3
|
-
import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-
|
|
2
|
+
import { c as writeRuntimeFileSecure, i as removeOwnClaudeConfigMirror, n as ensureClaudeConfigMirror, r as ensurePaths, t as PATHS } from "./paths-lwEqM5-i.js";
|
|
3
|
+
import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-DU0UI2t5.js";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { defineCommand, runMain } from "citty";
|
|
6
6
|
import consola from "consola";
|
|
@@ -447,8 +447,8 @@ function normalizeModelId(id) {
|
|
|
447
447
|
* 6. Return as-is with a warning
|
|
448
448
|
*/
|
|
449
449
|
function resolveModel(modelId) {
|
|
450
|
-
const models = state.models?.data;
|
|
451
|
-
if (!models) return modelId;
|
|
450
|
+
const models$1 = state.models?.data;
|
|
451
|
+
if (!models$1) return modelId;
|
|
452
452
|
const oneMMatch = modelId.match(/^(.*)\[1m\]$/i);
|
|
453
453
|
if (oneMMatch) {
|
|
454
454
|
const stripped = oneMMatch[1];
|
|
@@ -456,31 +456,31 @@ function resolveModel(modelId) {
|
|
|
456
456
|
if (!/-1m(?:$|-)/.test(resolved)) consola.warn(`Model "${modelId}" requested 1M context but no -1m backend is in Copilot's catalog for this tier/family; downgrading upstream to "${resolved}" (200K). Claude Code's local context accounting will still assume 1M — expect premature auto-compact. Drop the [1m] suffix (or unset CLAUDE_CODE_DISABLE_1M_CONTEXT if you set it) to silence.`);
|
|
457
457
|
return resolved;
|
|
458
458
|
}
|
|
459
|
-
if (models.some((m) => m.id === modelId)) return modelId;
|
|
459
|
+
if (models$1.some((m) => m.id === modelId)) return modelId;
|
|
460
460
|
const lower = modelId.toLowerCase();
|
|
461
|
-
const ciMatch = models.find((m) => m.id.toLowerCase() === lower);
|
|
461
|
+
const ciMatch = models$1.find((m) => m.id.toLowerCase() === lower);
|
|
462
462
|
if (ciMatch) return ciMatch.id;
|
|
463
463
|
if (lower.includes("opus")) {
|
|
464
|
-
const oneMs = models.filter((m) => m.id.includes("opus") && /-1m(?:$|-)/.test(m.id));
|
|
464
|
+
const oneMs = models$1.filter((m) => m.id.includes("opus") && /-1m(?:$|-)/.test(m.id));
|
|
465
465
|
const versionMatch = lower.match(/opus-(\d+)[.-](\d+)/);
|
|
466
466
|
const requestedVersion = versionMatch ? `${versionMatch[1]}.${versionMatch[2]}` : void 0;
|
|
467
467
|
const oneM = (requestedVersion ? oneMs.find((m) => m.id.includes(`opus-${requestedVersion}-`)) : void 0) ?? (requestedVersion ? void 0 : oneMs[0]);
|
|
468
468
|
if (oneM) return oneM.id;
|
|
469
469
|
}
|
|
470
470
|
if (lower.includes("codex")) {
|
|
471
|
-
const codexModels = models.filter((m) => m.id.includes("codex") && !m.id.includes("mini"));
|
|
471
|
+
const codexModels = models$1.filter((m) => m.id.includes("codex") && !m.id.includes("mini"));
|
|
472
472
|
if (codexModels.length > 0) {
|
|
473
473
|
codexModels.sort((a, b) => b.id.localeCompare(a.id));
|
|
474
474
|
return codexModels[0].id;
|
|
475
475
|
}
|
|
476
476
|
}
|
|
477
477
|
const normalized = normalizeModelId(modelId);
|
|
478
|
-
const normMatch = models.find((m) => normalizeModelId(m.id) === normalized);
|
|
478
|
+
const normMatch = models$1.find((m) => normalizeModelId(m.id) === normalized);
|
|
479
479
|
if (normMatch) return normMatch.id;
|
|
480
480
|
const dateStripped = modelId.replace(/^(claude-[\w.-]+)-20\d{6}$/i, "$1");
|
|
481
481
|
if (dateStripped !== modelId) {
|
|
482
482
|
const retried = resolveModel(dateStripped);
|
|
483
|
-
if (retried !== dateStripped || models.some((m) => m.id === dateStripped)) {
|
|
483
|
+
if (retried !== dateStripped || models$1.some((m) => m.id === dateStripped)) {
|
|
484
484
|
consola.info(`Resolved Anthropic dated slug "${modelId}" → "${retried}" (stripped -YYYYMMDD; pass an explicit catalog id to pin a snapshot)`);
|
|
485
485
|
return retried;
|
|
486
486
|
}
|
|
@@ -490,7 +490,7 @@ function resolveModel(modelId) {
|
|
|
490
490
|
const matchHaiku = /(?:^|-)haiku(?:-|$)/.test(lower);
|
|
491
491
|
if (matchSonnet || matchHaiku) {
|
|
492
492
|
const family = matchSonnet ? "sonnet" : "haiku";
|
|
493
|
-
const familyMembers = models.filter((m) => (/* @__PURE__ */ new RegExp(`(?:^|-)${family}(?:-|$|\\.)`)).test(m.id));
|
|
493
|
+
const familyMembers = models$1.filter((m) => (/* @__PURE__ */ new RegExp(`(?:^|-)${family}(?:-|$|\\.)`)).test(m.id));
|
|
494
494
|
if (familyMembers.length > 0) {
|
|
495
495
|
familyMembers.sort((a, b) => b.id.localeCompare(a.id, void 0, { numeric: true }));
|
|
496
496
|
const best = familyMembers[0].id;
|
|
@@ -499,7 +499,7 @@ function resolveModel(modelId) {
|
|
|
499
499
|
}
|
|
500
500
|
}
|
|
501
501
|
}
|
|
502
|
-
consola.warn(`Model "${modelId}" not found in Copilot model list. Available: ${models.map((m) => m.id).join(", ")}`);
|
|
502
|
+
consola.warn(`Model "${modelId}" not found in Copilot model list. Available: ${models$1.map((m) => m.id).join(", ")}`);
|
|
503
503
|
return modelId;
|
|
504
504
|
}
|
|
505
505
|
/**
|
|
@@ -508,10 +508,10 @@ function resolveModel(modelId) {
|
|
|
508
508
|
*/
|
|
509
509
|
function resolveCodexModel(modelId) {
|
|
510
510
|
const resolved = resolveModel(modelId);
|
|
511
|
-
const models = state.models?.data;
|
|
512
|
-
if (!models) return resolved;
|
|
513
|
-
if (models.some((m) => m.id === resolved)) return resolved;
|
|
514
|
-
const candidates = models.filter((m) => {
|
|
511
|
+
const models$1 = state.models?.data;
|
|
512
|
+
if (!models$1) return resolved;
|
|
513
|
+
if (models$1.some((m) => m.id === resolved)) return resolved;
|
|
514
|
+
const candidates = models$1.filter((m) => {
|
|
515
515
|
const endpoints = m.supported_endpoints ?? [];
|
|
516
516
|
if (m.id.includes("mini") || m.id.includes("nano")) return false;
|
|
517
517
|
return endpoints.length === 0 || endpoints.includes("/responses");
|
|
@@ -971,9 +971,9 @@ function pickClaudeDefault(opusFamily = DEFAULT_OPUS_FAMILY) {
|
|
|
971
971
|
const versionPattern = dotted.replace(/\./g, "[.-]");
|
|
972
972
|
const oneMRegex = new RegExp(`opus-${versionPattern}-1m(?:$|-)`, "i");
|
|
973
973
|
const familyRegex = new RegExp(`opus-${versionPattern}(?:$|[-.])`, "i");
|
|
974
|
-
const models = state.models?.data ?? [];
|
|
975
|
-
const has1m = models.some((m) => oneMRegex.test(m.id));
|
|
976
|
-
if (opusFamily !== DEFAULT_OPUS_FAMILY && state.models && models.length > 0 && !models.some((m) => familyRegex.test(m.id))) consola.warn(`Requested Opus family "${dotted}" not found in Copilot catalog; using "${bareSlug}" anyway (resolveModel may not find a backend for it).`);
|
|
974
|
+
const models$1 = state.models?.data ?? [];
|
|
975
|
+
const has1m = models$1.some((m) => oneMRegex.test(m.id));
|
|
976
|
+
if (opusFamily !== DEFAULT_OPUS_FAMILY && state.models && models$1.length > 0 && !models$1.some((m) => familyRegex.test(m.id))) consola.warn(`Requested Opus family "${dotted}" not found in Copilot catalog; using "${bareSlug}" anyway (resolveModel may not find a backend for it).`);
|
|
977
977
|
if (has1m) {
|
|
978
978
|
consola.info(`Catalog contains opus-${dotted}-1m variant; defaulting ANTHROPIC_MODEL to "${bareSlug}[1m]" so Claude Code accounts for 1M context locally. Set CLAUDE_CODE_DISABLE_1M_CONTEXT=1 to opt out (HIPAA), or pass --model ${bareSlug} to pin 200K.`);
|
|
979
979
|
return `${bareSlug}[1m]`;
|
|
@@ -3197,7 +3197,7 @@ function logAudit$1(record) {
|
|
|
3197
3197
|
try {
|
|
3198
3198
|
const fs$2 = await import("node:fs/promises");
|
|
3199
3199
|
const path$2 = await import("node:path");
|
|
3200
|
-
const { PATHS: PATHS$1 } = await import("./paths-
|
|
3200
|
+
const { PATHS: PATHS$1 } = await import("./paths-nd-94lLq.js");
|
|
3201
3201
|
const dir = path$2.join(PATHS$1.APP_DIR, "browser-mcp");
|
|
3202
3202
|
await fs$2.mkdir(dir, { recursive: true });
|
|
3203
3203
|
const line = JSON.stringify({
|
|
@@ -3651,9 +3651,9 @@ const MODELS = {};
|
|
|
3651
3651
|
//#endregion
|
|
3652
3652
|
//#region src/vendor/pi/ai/models.ts
|
|
3653
3653
|
const modelRegistry = /* @__PURE__ */ new Map();
|
|
3654
|
-
for (const [provider, models] of Object.entries(MODELS)) {
|
|
3654
|
+
for (const [provider, models$1] of Object.entries(MODELS)) {
|
|
3655
3655
|
const providerModels = /* @__PURE__ */ new Map();
|
|
3656
|
-
for (const [id, model] of Object.entries(models)) providerModels.set(id, model);
|
|
3656
|
+
for (const [id, model] of Object.entries(models$1)) providerModels.set(id, model);
|
|
3657
3657
|
modelRegistry.set(provider, providerModels);
|
|
3658
3658
|
}
|
|
3659
3659
|
|
|
@@ -6338,9 +6338,9 @@ function checkAuth(c) {
|
|
|
6338
6338
|
return { ok: true };
|
|
6339
6339
|
}
|
|
6340
6340
|
function geminiAvailable() {
|
|
6341
|
-
const models = state.models?.data;
|
|
6342
|
-
if (!models) return false;
|
|
6343
|
-
return models.some((m) => /^gemini-3\..*pro/i.test(m.id));
|
|
6341
|
+
const models$1 = state.models?.data;
|
|
6342
|
+
if (!models$1) return false;
|
|
6343
|
+
return models$1.some((m) => /^gemini-3\..*pro/i.test(m.id));
|
|
6344
6344
|
}
|
|
6345
6345
|
/**
|
|
6346
6346
|
* Gate for the `stand_in` tool.
|
|
@@ -6364,11 +6364,11 @@ function geminiAvailable() {
|
|
|
6364
6364
|
* land under the dotted slug, so we match by Copilot's actual id shape.
|
|
6365
6365
|
*/
|
|
6366
6366
|
function standInToolEnabled() {
|
|
6367
|
-
const models = state.models?.data;
|
|
6368
|
-
if (!models) return false;
|
|
6369
|
-
const hasGpt55 = models.some((m) => m.id === "gpt-5.5");
|
|
6370
|
-
const hasOpus = models.some((m) => m.id === "claude-opus-4-7" || m.id === "claude-opus-4.7");
|
|
6371
|
-
const hasGeminiPro = models.some((m) => /^gemini-3\..*pro/i.test(m.id));
|
|
6367
|
+
const models$1 = state.models?.data;
|
|
6368
|
+
if (!models$1) return false;
|
|
6369
|
+
const hasGpt55 = models$1.some((m) => m.id === "gpt-5.5");
|
|
6370
|
+
const hasOpus = models$1.some((m) => m.id === "claude-opus-4-7" || m.id === "claude-opus-4.7");
|
|
6371
|
+
const hasGeminiPro = models$1.some((m) => /^gemini-3\..*pro/i.test(m.id));
|
|
6372
6372
|
return hasGpt55 && hasOpus && hasGeminiPro;
|
|
6373
6373
|
}
|
|
6374
6374
|
/**
|
|
@@ -6398,9 +6398,9 @@ function standInToolEnabled() {
|
|
|
6398
6398
|
*/
|
|
6399
6399
|
function workerToolsEnabled() {
|
|
6400
6400
|
if (process.env.GH_ROUTER_DISABLE_WORKER_TOOLS === "1") return false;
|
|
6401
|
-
const models = state.models?.data;
|
|
6402
|
-
if (!models) return false;
|
|
6403
|
-
const found = models.find((m) => m.id === DEFAULT_MODEL);
|
|
6401
|
+
const models$1 = state.models?.data;
|
|
6402
|
+
if (!models$1) return false;
|
|
6403
|
+
const found = models$1.find((m) => m.id === DEFAULT_MODEL);
|
|
6404
6404
|
if (!found) return false;
|
|
6405
6405
|
return found.capabilities?.supports?.tool_calls === true;
|
|
6406
6406
|
}
|
|
@@ -9073,9 +9073,9 @@ function lookupPersona(critic) {
|
|
|
9073
9073
|
return persona;
|
|
9074
9074
|
}
|
|
9075
9075
|
function geminiInCatalog() {
|
|
9076
|
-
const models = state.models?.data;
|
|
9077
|
-
if (!models) return false;
|
|
9078
|
-
return models.some((m) => /^gemini-3\..*pro/i.test(m.id));
|
|
9076
|
+
const models$1 = state.models?.data;
|
|
9077
|
+
if (!models$1) return false;
|
|
9078
|
+
return models$1.some((m) => /^gemini-3\..*pro/i.test(m.id));
|
|
9079
9079
|
}
|
|
9080
9080
|
const ADVISOR_PARAMS = Type.Object({ concern: Type.String({
|
|
9081
9081
|
description: "What you want a second pair of eyes on — your current approach, the blocker you're stuck on, or the decision you're about to commit. Required: the advisor needs a focal point.",
|
|
@@ -11459,7 +11459,7 @@ function initProxyFromEnv() {
|
|
|
11459
11459
|
//#endregion
|
|
11460
11460
|
//#region package.json
|
|
11461
11461
|
var name = "github-router";
|
|
11462
|
-
var version = "0.3.
|
|
11462
|
+
var version = "0.3.42";
|
|
11463
11463
|
|
|
11464
11464
|
//#endregion
|
|
11465
11465
|
//#region src/lib/approval.ts
|
|
@@ -11518,7 +11518,7 @@ async function doCheck(state$1, ticket) {
|
|
|
11518
11518
|
/**
|
|
11519
11519
|
* Format a number with K/M suffix for compact display.
|
|
11520
11520
|
*/
|
|
11521
|
-
function formatTokens(n) {
|
|
11521
|
+
function formatTokens$1(n) {
|
|
11522
11522
|
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
11523
11523
|
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
|
|
11524
11524
|
return String(n);
|
|
@@ -11532,9 +11532,9 @@ function formatTokenInfo(inputTokens, outputTokens, model) {
|
|
|
11532
11532
|
const maxPrompt = model?.capabilities?.limits?.max_prompt_tokens;
|
|
11533
11533
|
if (maxPrompt) {
|
|
11534
11534
|
const pct = (inputTokens / maxPrompt * 100).toFixed(1);
|
|
11535
|
-
parts.push(`in:${formatTokens(inputTokens)}/${formatTokens(maxPrompt)} (${pct}%)`);
|
|
11536
|
-
} else parts.push(`in:${formatTokens(inputTokens)}`);
|
|
11537
|
-
if (outputTokens !== void 0) parts.push(`out:${formatTokens(outputTokens)}`);
|
|
11535
|
+
parts.push(`in:${formatTokens$1(inputTokens)}/${formatTokens$1(maxPrompt)} (${pct}%)`);
|
|
11536
|
+
} else parts.push(`in:${formatTokens$1(inputTokens)}`);
|
|
11537
|
+
if (outputTokens !== void 0) parts.push(`out:${formatTokens$1(outputTokens)}`);
|
|
11538
11538
|
return parts.join(" ");
|
|
11539
11539
|
}
|
|
11540
11540
|
/**
|
|
@@ -12703,7 +12703,7 @@ const modelRoutes = new Hono();
|
|
|
12703
12703
|
modelRoutes.get("/", async (c) => {
|
|
12704
12704
|
try {
|
|
12705
12705
|
if (!state.models) await cacheModels();
|
|
12706
|
-
const models = state.models?.data.map((model) => {
|
|
12706
|
+
const models$1 = state.models?.data.map((model) => {
|
|
12707
12707
|
const { requestHeaders,...rest } = model;
|
|
12708
12708
|
return {
|
|
12709
12709
|
...rest,
|
|
@@ -12717,7 +12717,7 @@ modelRoutes.get("/", async (c) => {
|
|
|
12717
12717
|
});
|
|
12718
12718
|
return c.json({
|
|
12719
12719
|
object: "list",
|
|
12720
|
-
data: models,
|
|
12720
|
+
data: models$1,
|
|
12721
12721
|
has_more: false
|
|
12722
12722
|
});
|
|
12723
12723
|
} catch (error) {
|
|
@@ -13673,6 +13673,154 @@ const debug = defineCommand({
|
|
|
13673
13673
|
}
|
|
13674
13674
|
});
|
|
13675
13675
|
|
|
13676
|
+
//#endregion
|
|
13677
|
+
//#region src/models.ts
|
|
13678
|
+
const models = defineCommand({
|
|
13679
|
+
meta: {
|
|
13680
|
+
name: "models",
|
|
13681
|
+
description: "List available GitHub Copilot models and their capabilities. Pass an optional pattern to filter (case-insensitive substring match on id, name, vendor, family)."
|
|
13682
|
+
},
|
|
13683
|
+
args: {
|
|
13684
|
+
pattern: {
|
|
13685
|
+
type: "positional",
|
|
13686
|
+
required: false,
|
|
13687
|
+
description: "Substring to filter models by (matches id, name, vendor, or family)."
|
|
13688
|
+
},
|
|
13689
|
+
json: {
|
|
13690
|
+
type: "boolean",
|
|
13691
|
+
default: false,
|
|
13692
|
+
description: "Emit raw JSON instead of the pretty layout."
|
|
13693
|
+
}
|
|
13694
|
+
},
|
|
13695
|
+
async run({ args }) {
|
|
13696
|
+
await ensurePaths();
|
|
13697
|
+
await setupGitHubToken();
|
|
13698
|
+
try {
|
|
13699
|
+
await setupCopilotToken();
|
|
13700
|
+
} catch (err) {
|
|
13701
|
+
consola.error("Failed to obtain Copilot token:", err);
|
|
13702
|
+
process.exit(1);
|
|
13703
|
+
}
|
|
13704
|
+
let catalog;
|
|
13705
|
+
try {
|
|
13706
|
+
catalog = await getModels();
|
|
13707
|
+
} catch (err) {
|
|
13708
|
+
consola.error("Failed to fetch Copilot model catalog:", err);
|
|
13709
|
+
process.exit(1);
|
|
13710
|
+
}
|
|
13711
|
+
const all = catalog.data;
|
|
13712
|
+
const pattern = args.pattern?.toString().trim();
|
|
13713
|
+
const filtered = pattern ? filterModels(all, pattern) : all;
|
|
13714
|
+
if (args.json) {
|
|
13715
|
+
process.stdout.write(`${JSON.stringify(filtered, null, 2)}\n`);
|
|
13716
|
+
return;
|
|
13717
|
+
}
|
|
13718
|
+
if (filtered.length === 0) {
|
|
13719
|
+
consola.warn(`No models matched "${pattern}". ${all.length} models available — try a different substring or run without an argument to list everything.`);
|
|
13720
|
+
process.exit(1);
|
|
13721
|
+
}
|
|
13722
|
+
const grouped = groupByVendor(filtered);
|
|
13723
|
+
const lines = [];
|
|
13724
|
+
const header = pattern ? `${filtered.length}/${all.length} models match "${pattern}"` : `${all.length} models available`;
|
|
13725
|
+
lines.push(header);
|
|
13726
|
+
lines.push("");
|
|
13727
|
+
for (const [vendor, list] of grouped) {
|
|
13728
|
+
lines.push(`▾ ${vendor} (${list.length})`);
|
|
13729
|
+
for (const model of list) lines.push(...formatModel(model));
|
|
13730
|
+
lines.push("");
|
|
13731
|
+
}
|
|
13732
|
+
process.stdout.write(lines.join("\n"));
|
|
13733
|
+
}
|
|
13734
|
+
});
|
|
13735
|
+
function filterModels(models$1, pattern) {
|
|
13736
|
+
const needle = pattern.toLowerCase();
|
|
13737
|
+
return models$1.filter((m) => {
|
|
13738
|
+
return [
|
|
13739
|
+
m.id,
|
|
13740
|
+
m.name,
|
|
13741
|
+
m.vendor,
|
|
13742
|
+
m.capabilities.family,
|
|
13743
|
+
m.capabilities.type,
|
|
13744
|
+
m.model_picker_category ?? ""
|
|
13745
|
+
].join(" ").toLowerCase().includes(needle);
|
|
13746
|
+
});
|
|
13747
|
+
}
|
|
13748
|
+
function groupByVendor(models$1) {
|
|
13749
|
+
const map = /* @__PURE__ */ new Map();
|
|
13750
|
+
for (const m of models$1) {
|
|
13751
|
+
const key = m.vendor || "(unknown vendor)";
|
|
13752
|
+
const bucket = map.get(key);
|
|
13753
|
+
if (bucket) bucket.push(m);
|
|
13754
|
+
else map.set(key, [m]);
|
|
13755
|
+
}
|
|
13756
|
+
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
13757
|
+
}
|
|
13758
|
+
function formatModel(model) {
|
|
13759
|
+
const lines = [];
|
|
13760
|
+
const tags = [];
|
|
13761
|
+
if (model.preview) tags.push("preview");
|
|
13762
|
+
if (model.is_chat_default) tags.push("chat-default");
|
|
13763
|
+
if (model.is_chat_fallback) tags.push("chat-fallback");
|
|
13764
|
+
if (model.billing?.is_premium) tags.push("premium");
|
|
13765
|
+
if (model.billing?.restricted_to?.length) tags.push(`restricted:${model.billing.restricted_to.join("/")}`);
|
|
13766
|
+
if (model.policy && model.policy.state !== "enabled") tags.push(`policy:${model.policy.state}`);
|
|
13767
|
+
const tagStr = tags.length > 0 ? ` [${tags.join(", ")}]` : "";
|
|
13768
|
+
lines.push(` • ${model.id}${tagStr}`);
|
|
13769
|
+
if (model.name && model.name !== model.id) lines.push(` name: ${model.name}`);
|
|
13770
|
+
const meta = [`family: ${model.capabilities.family}`, `type: ${model.capabilities.type}`];
|
|
13771
|
+
if (model.capabilities.tokenizer) meta.push(`tokenizer: ${model.capabilities.tokenizer}`);
|
|
13772
|
+
if (model.version) meta.push(`version: ${model.version}`);
|
|
13773
|
+
lines.push(` ${meta.join(" · ")}`);
|
|
13774
|
+
const limits = model.capabilities.limits;
|
|
13775
|
+
const limitParts = [];
|
|
13776
|
+
if (limits.max_context_window_tokens) limitParts.push(`ctx ${formatTokens(limits.max_context_window_tokens)}`);
|
|
13777
|
+
else if (limits.max_prompt_tokens) limitParts.push(`prompt ${formatTokens(limits.max_prompt_tokens)}`);
|
|
13778
|
+
if (limits.max_output_tokens) limitParts.push(`out ${formatTokens(limits.max_output_tokens)}`);
|
|
13779
|
+
if (limits.max_non_streaming_output_tokens && limits.max_non_streaming_output_tokens !== limits.max_output_tokens) limitParts.push(`out-non-stream ${formatTokens(limits.max_non_streaming_output_tokens)}`);
|
|
13780
|
+
if (limits.max_inputs) limitParts.push(`inputs ${limits.max_inputs}`);
|
|
13781
|
+
if (limits.vision?.max_prompt_images) limitParts.push(`images ${limits.vision.max_prompt_images}`);
|
|
13782
|
+
if (limitParts.length > 0) lines.push(` limits: ${limitParts.join(" · ")}`);
|
|
13783
|
+
const supports = model.capabilities.supports;
|
|
13784
|
+
const supportFlags = [];
|
|
13785
|
+
if (supports.tool_calls) supportFlags.push("tools");
|
|
13786
|
+
if (supports.parallel_tool_calls) supportFlags.push("parallel-tools");
|
|
13787
|
+
if (supports.streaming) supportFlags.push("streaming");
|
|
13788
|
+
if (supports.vision) supportFlags.push("vision");
|
|
13789
|
+
if (supports.structured_outputs) supportFlags.push("structured-outputs");
|
|
13790
|
+
if (supports.dimensions) supportFlags.push("dimensions");
|
|
13791
|
+
if (supports.adaptive_thinking) {
|
|
13792
|
+
const min = supports.min_thinking_budget;
|
|
13793
|
+
const max = supports.max_thinking_budget;
|
|
13794
|
+
const range = min !== void 0 && max !== void 0 ? `(${formatTokens(min)}-${formatTokens(max)})` : "";
|
|
13795
|
+
supportFlags.push(`adaptive-thinking${range}`);
|
|
13796
|
+
}
|
|
13797
|
+
if (supports.reasoning_effort && supports.reasoning_effort.length > 0) supportFlags.push(`reasoning:${supports.reasoning_effort.join("/")}`);
|
|
13798
|
+
if (supportFlags.length > 0) lines.push(` supports: ${supportFlags.join(", ")}`);
|
|
13799
|
+
if (model.supported_endpoints && model.supported_endpoints.length > 0) lines.push(` endpoints: ${model.supported_endpoints.join(", ")}`);
|
|
13800
|
+
if (model.billing) {
|
|
13801
|
+
const billParts = [];
|
|
13802
|
+
if (model.billing.is_premium) billParts.push("premium");
|
|
13803
|
+
if (typeof model.billing.multiplier === "number") billParts.push(`×${model.billing.multiplier}`);
|
|
13804
|
+
if (billParts.length > 0) lines.push(` billing: ${billParts.join(" ")}`);
|
|
13805
|
+
}
|
|
13806
|
+
return lines;
|
|
13807
|
+
}
|
|
13808
|
+
/**
|
|
13809
|
+
* Format a token count in a compact human-readable form: `1024` →
|
|
13810
|
+
* `1k`, `4096` → `4k`, `131072` → `128k`, `1048576` → `1M`. Prefer
|
|
13811
|
+
* binary multiples (mebi, kibi) since Claude Code / Copilot context
|
|
13812
|
+
* windows are reported in binary units (`1M context` = 1024 × 1024
|
|
13813
|
+
* tokens). Fall back to decimal (`64k` for `64000`) when the value
|
|
13814
|
+
* is a clean decimal multiple but not binary.
|
|
13815
|
+
*/
|
|
13816
|
+
function formatTokens(n) {
|
|
13817
|
+
if (n >= 1048576 && n % 1048576 === 0) return `${n / 1048576}M`;
|
|
13818
|
+
if (n >= 1024 && n % 1024 === 0) return `${n / 1024}k`;
|
|
13819
|
+
if (n >= 1e6 && n % 1e6 === 0) return `${n / 1e6}M`;
|
|
13820
|
+
if (n >= 1e3 && n % 1e3 === 0) return `${n / 1e3}k`;
|
|
13821
|
+
return `${n}`;
|
|
13822
|
+
}
|
|
13823
|
+
|
|
13676
13824
|
//#endregion
|
|
13677
13825
|
//#region src/lib/shell.ts
|
|
13678
13826
|
function getShell() {
|
|
@@ -13809,6 +13957,7 @@ await runMain(defineCommand({
|
|
|
13809
13957
|
start,
|
|
13810
13958
|
claude,
|
|
13811
13959
|
codex,
|
|
13960
|
+
models,
|
|
13812
13961
|
"check-usage": checkUsage,
|
|
13813
13962
|
debug
|
|
13814
13963
|
}
|