github-router 0.3.36 → 0.3.38

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.
@@ -1,4 +1,4 @@
1
- import "./paths-Cr2gfGiA.js";
2
- import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, o as sweepStaleWorktreesAtBoot, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-3OXRVrtQ.js";
1
+ import "./paths-C-GyxwCW.js";
2
+ import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, o as sweepStaleWorktreesAtBoot, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-Ho67_Rew.js";
3
3
 
4
4
  export { WorktreeRegistry, getInstanceUuid, recordWorkerRepo, registerExitHandlers, sweepRegistry, sweepStaleWorktreesAtBoot };
@@ -1,4 +1,4 @@
1
- import { c as writeRuntimeFileSecure, t as PATHS } from "./paths-Cr2gfGiA.js";
1
+ import { c as writeRuntimeFileSecure, t as PATHS } from "./paths-C-GyxwCW.js";
2
2
  import { randomBytes, randomUUID } from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
@@ -110,6 +110,24 @@ function sweepRegistry() {
110
110
  }
111
111
  }
112
112
  /**
113
+ * Windows ConPTY / node-pty signal behavior:
114
+ *
115
+ * When a ConPTY host (VS Code terminal, Windows Terminal, node-pty) closes
116
+ * the pseudo-console, the ConPTY layer sends CTRL_CLOSE_EVENT to the
117
+ * process group. Node.js translates this into SIGINT (NOT SIGTERM). The
118
+ * process has a ~5-second window before forced termination.
119
+ *
120
+ * Implication: the SIGTERM handler below may NEVER fire in node-pty
121
+ * environments. This is by design — the three-layer cleanup architecture
122
+ * ensures coverage:
123
+ * 1. Per-call cleanup (engine.ts finally block) — happy path
124
+ * 2. SIGINT handler (this file) — ConPTY close, Ctrl+C
125
+ * 3. `exit` handler (this file) — unconditional, fires on any exit
126
+ * 4. Boot-time PID+instance sweep (sweepStaleWorktreesAtBoot) — crash recovery
127
+ *
128
+ * Layers 1+2+3 cover ConPTY; layer 4 covers SIGKILL/OOM/container restart.
129
+ */
130
+ /**
113
131
  * Wire up SIGINT/SIGTERM/exit handlers that walk the registry and
114
132
  * remove every entry. Idempotent: subsequent calls swap the registry
115
133
  * pointer but do NOT register additional process listeners (otherwise
@@ -289,4 +307,4 @@ async function sweepStaleWorktreesAtBoot() {
289
307
 
290
308
  //#endregion
291
309
  export { sweepRegistry as a, registerExitHandlers as i, getInstanceUuid as n, sweepStaleWorktreesAtBoot as o, recordWorkerRepo as r, WorktreeRegistry as t };
292
- //# sourceMappingURL=lifecycle-3OXRVrtQ.js.map
310
+ //# sourceMappingURL=lifecycle-Ho67_Rew.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lifecycle-Ho67_Rew.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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"}
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-Cr2gfGiA.js";
3
- import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-3OXRVrtQ.js";
2
+ import { c as writeRuntimeFileSecure, i as removeOwnClaudeConfigMirror, n as ensureClaudeConfigMirror, r as ensurePaths, t as PATHS } from "./paths-C-GyxwCW.js";
3
+ import { a as sweepRegistry, i as registerExitHandlers, n as getInstanceUuid, r as recordWorkerRepo, t as WorktreeRegistry } from "./lifecycle-Ho67_Rew.js";
4
4
  import { createRequire } from "node:module";
5
5
  import { defineCommand, runMain } from "citty";
6
6
  import consola from "consola";
@@ -3055,10 +3055,11 @@ async function bridgeCall(endpoint, tool, args, timeoutMs, signal) {
3055
3055
  const id = randomUUID();
3056
3056
  const ws = new WebSocket(`ws://127.0.0.1:${endpoint.port}`, { headers: { authorization: `Bearer ${endpoint.token}` } });
3057
3057
  let settled = false;
3058
+ let timer = void 0;
3058
3059
  const finish = (fn) => {
3059
3060
  if (settled) return;
3060
3061
  settled = true;
3061
- clearTimeout(timer);
3062
+ if (timer !== void 0) clearTimeout(timer);
3062
3063
  if (signal) signal.removeEventListener("abort", onAbort);
3063
3064
  try {
3064
3065
  ws.close();
@@ -3073,7 +3074,7 @@ async function bridgeCall(endpoint, tool, args, timeoutMs, signal) {
3073
3074
  }
3074
3075
  signal.addEventListener("abort", onAbort, { once: true });
3075
3076
  }
3076
- const timer = setTimeout(() => finish(() => reject(/* @__PURE__ */ new Error(`timeout after ${timeoutMs}ms`))), timeoutMs);
3077
+ timer = setTimeout(() => finish(() => reject(/* @__PURE__ */ new Error(`timeout after ${timeoutMs}ms`))), timeoutMs);
3077
3078
  ws.on("open", () => {
3078
3079
  if (settled) {
3079
3080
  try {
@@ -3190,7 +3191,7 @@ function logAudit$1(record) {
3190
3191
  try {
3191
3192
  const fs$2 = await import("node:fs/promises");
3192
3193
  const path$2 = await import("node:path");
3193
- const { PATHS: PATHS$1 } = await import("./paths-Cf3OVCaJ.js");
3194
+ const { PATHS: PATHS$1 } = await import("./paths-DZwqh1p5.js");
3194
3195
  const dir = path$2.join(PATHS$1.APP_DIR, "browser-mcp");
3195
3196
  await fs$2.mkdir(dir, { recursive: true });
3196
3197
  const line = JSON.stringify({
@@ -5181,6 +5182,117 @@ async function acquireWorkerSlot(signal) {
5181
5182
  };
5182
5183
  }
5183
5184
 
5185
+ //#endregion
5186
+ //#region src/lib/diagnose-response.ts
5187
+ const PREVIEW_LIMIT = 200;
5188
+ async function parseJsonOrDiagnose(response, routePath) {
5189
+ const cloned = response.clone();
5190
+ try {
5191
+ return await response.json();
5192
+ } catch (error) {
5193
+ const contentType = response.headers.get("content-type") ?? "(none)";
5194
+ const bodyText = await cloned.text().catch(() => "(unreadable)");
5195
+ const preview = bodyText.length > PREVIEW_LIMIT ? bodyText.slice(0, PREVIEW_LIMIT) + "...(truncated)" : bodyText;
5196
+ consola.error(`Upstream JSON parse failed at ${routePath}: status=${response.status} content-type="${contentType}" body[0..${PREVIEW_LIMIT}]=${JSON.stringify(preview)}`);
5197
+ throw error;
5198
+ }
5199
+ }
5200
+
5201
+ //#endregion
5202
+ //#region src/lib/response-cap.ts
5203
+ /**
5204
+ * Hard byte cap for non-streaming upstream response bodies.
5205
+ *
5206
+ * Anthropic responses with large tool_use blocks can legitimately reach
5207
+ * several MB, but a multi-GB body is either a buggy upstream or a malicious
5208
+ * one. Buffering it would OOM the proxy and crash all in-flight requests.
5209
+ *
5210
+ * Applies to /v1/messages, /v1/chat/completions, and /v1/responses.
5211
+ */
5212
+ const MAX_RESPONSE_BODY_BYTES = 10 * 1024 * 1024;
5213
+ /**
5214
+ * Read a Response body with a hard byte cap, then parse as JSON.
5215
+ *
5216
+ * Falls back to the fast path (response.json()) when Content-Length is
5217
+ * present and within the cap, avoiding the streaming-reader overhead for
5218
+ * the vast majority of normal responses.
5219
+ *
5220
+ * When the cap is hit:
5221
+ * - the reader is cancelled to release the upstream socket
5222
+ * - a structured Anthropic-format error is returned to the caller
5223
+ * (the caller wraps it in c.json(), not throws — the client gets a
5224
+ * clean 413 error, not an unhandled-rejection crash)
5225
+ *
5226
+ * Returns `{ ok: true, value }` on success or `{ ok: false, errorResponse, status }`
5227
+ * on cap exceeded.
5228
+ */
5229
+ async function readResponseBodyCapped(response, routePath, capBytes = MAX_RESPONSE_BODY_BYTES) {
5230
+ const contentLengthHeader = response.headers.get("content-length");
5231
+ const contentLength = contentLengthHeader ? parseInt(contentLengthHeader, 10) : NaN;
5232
+ if (!isNaN(contentLength) && contentLength <= capBytes) return {
5233
+ ok: true,
5234
+ value: await parseJsonOrDiagnose(response, routePath)
5235
+ };
5236
+ const reader = response.body?.getReader();
5237
+ if (!reader) return {
5238
+ ok: true,
5239
+ value: await parseJsonOrDiagnose(response, routePath)
5240
+ };
5241
+ const chunks = [];
5242
+ let totalBytes = 0;
5243
+ let capped = false;
5244
+ try {
5245
+ while (true) {
5246
+ const { done, value } = await reader.read();
5247
+ if (done) break;
5248
+ if (!value) continue;
5249
+ totalBytes += value.byteLength;
5250
+ if (totalBytes > capBytes) {
5251
+ capped = true;
5252
+ try {
5253
+ await reader.cancel("size_cap");
5254
+ } catch {}
5255
+ break;
5256
+ }
5257
+ chunks.push(value);
5258
+ }
5259
+ } catch (err) {
5260
+ if (!capped) consola.warn(`readResponseBodyCapped: read error at ${routePath}:`, err);
5261
+ }
5262
+ if (capped) {
5263
+ consola.warn(`Non-streaming upstream response at ${routePath} exceeded ${capBytes} bytes (10 MiB cap); dropping body to prevent OOM. Check upstream health.`);
5264
+ return {
5265
+ ok: false,
5266
+ status: 502,
5267
+ errorResponse: {
5268
+ type: "error",
5269
+ error: {
5270
+ type: "api_error",
5271
+ message: `Upstream response body exceeded the 10 MiB size cap for non-streaming ${routePath}. The upstream may be misbehaving. Try enabling streaming (stream: true) which handles large responses chunk-by-chunk.`
5272
+ }
5273
+ }
5274
+ };
5275
+ }
5276
+ const merged = new Uint8Array(totalBytes);
5277
+ let offset = 0;
5278
+ for (const chunk of chunks) {
5279
+ merged.set(chunk, offset);
5280
+ offset += chunk.byteLength;
5281
+ }
5282
+ const text = new TextDecoder().decode(merged);
5283
+ try {
5284
+ return {
5285
+ ok: true,
5286
+ value: JSON.parse(text)
5287
+ };
5288
+ } catch (err) {
5289
+ const preview = text.slice(0, 200);
5290
+ const contentType = response.headers.get("content-type") ?? "(none)";
5291
+ consola.error(`Upstream JSON parse failed at ${routePath}: status=${response.status} content-type="${contentType}" body[0..200]=${JSON.stringify(preview)}`);
5292
+ throw err;
5293
+ }
5294
+ }
5295
+
5184
5296
  //#endregion
5185
5297
  //#region src/services/copilot/create-chat-completions.ts
5186
5298
  const createChatCompletions = async (payload, modelHeaders, callerSignal) => {
@@ -5222,7 +5334,12 @@ const createChatCompletions = async (payload, modelHeaders, callerSignal) => {
5222
5334
  }));
5223
5335
  }
5224
5336
  if (payload.stream) return events(response);
5225
- return await response.json();
5337
+ const cappedResult = await readResponseBodyCapped(response, "/v1/chat/completions", MAX_RESPONSE_BODY_BYTES);
5338
+ if (!cappedResult.ok) throw new HTTPError("Upstream /v1/chat/completions response exceeded 10 MiB size cap", new Response(JSON.stringify(cappedResult.errorResponse), {
5339
+ status: cappedResult.status,
5340
+ headers: { "content-type": "application/json" }
5341
+ }));
5342
+ return cappedResult.value;
5226
5343
  };
5227
5344
 
5228
5345
  //#endregion
@@ -5883,7 +6000,12 @@ const createResponses = async (payload, modelHeaders, callerSignal) => {
5883
6000
  throw new HTTPError("Failed to create responses", response);
5884
6001
  }
5885
6002
  if (payload.stream) return events(response);
5886
- return await response.json();
6003
+ const cappedResult = await readResponseBodyCapped(response, "/v1/responses", MAX_RESPONSE_BODY_BYTES);
6004
+ if (!cappedResult.ok) throw new HTTPError("Upstream /v1/responses response exceeded 10 MiB size cap", new Response(JSON.stringify(cappedResult.errorResponse), {
6005
+ status: cappedResult.status,
6006
+ headers: { "content-type": "application/json" }
6007
+ }));
6008
+ return cappedResult.value;
5887
6009
  };
5888
6010
  function detectVision(input) {
5889
6011
  if (typeof input === "string") return false;
@@ -6847,6 +6969,34 @@ async function readWithInactivityTimeout(reader, timeoutMs) {
6847
6969
  }
6848
6970
  }
6849
6971
  /**
6972
+ * Race an `AsyncIterableIterator.next()` call against an inactivity timeout.
6973
+ *
6974
+ * Follows the same pattern as `readWithInactivityTimeout` (including the
6975
+ * noop catcher to avoid Node 24 unhandled-rejection crashes) but works
6976
+ * with typed iterators that yield parsed objects rather than raw bytes.
6977
+ *
6978
+ * On timeout, throws an `InactivityTimeout` error (same classification as
6979
+ * the byte-reader variant — surfaced to the consumer as `timeout_error` via
6980
+ * `buildOpenAIErrorEvent`).
6981
+ *
6982
+ * @param iterator - An AsyncIterableIterator whose `.next()` we want to race.
6983
+ * @param timeoutMs - Milliseconds before the timeout fires.
6984
+ */
6985
+ async function readIteratorWithTimeout(iterator, timeoutMs) {
6986
+ let timeoutHandle;
6987
+ const timeoutPromise = new Promise((_, reject) => {
6988
+ timeoutHandle = setTimeout(() => {
6989
+ reject(Object.assign(/* @__PURE__ */ new Error("upstream_inactive"), { name: "InactivityTimeout" }));
6990
+ }, timeoutMs);
6991
+ });
6992
+ timeoutPromise.catch(() => {});
6993
+ try {
6994
+ return await Promise.race([iterator.next(), timeoutPromise]);
6995
+ } finally {
6996
+ if (timeoutHandle !== void 0) clearTimeout(timeoutHandle);
6997
+ }
6998
+ }
6999
+ /**
6850
7000
  * Build the SSE wire bytes for an Anthropic-format streaming error event.
6851
7001
  * Per Anthropic streaming spec, errors are sent as:
6852
7002
  * event: error
@@ -7162,7 +7312,7 @@ function sseEvent(type, data) {
7162
7312
  function buildAdvisorStream(opts) {
7163
7313
  const advisorModel = opts.advisorModel ?? ADVISOR_DEFAULT_MODEL;
7164
7314
  const advisorEffort = opts.advisorEffort ?? ADVISOR_DEFAULT_EFFORT;
7165
- const aborter = new AbortController();
7315
+ const aborter = opts.externalAborter ?? new AbortController();
7166
7316
  let conversation = [...opts.initialConversation];
7167
7317
  return new ReadableStream({
7168
7318
  async start(controller) {
@@ -8086,6 +8236,33 @@ function resolvePathOrThrow(rawPath, workspace, opts = {}) {
8086
8236
  * with the truncated text + a flag. The caller appends the
8087
8237
  * truncation marker to the formatted output.
8088
8238
  */
8239
+ /**
8240
+ * Platform-aware kill for child processes. On Windows `child.kill()`
8241
+ * does NOT reliably terminate descendant processes — we use
8242
+ * `taskkill /T /F /PID` instead (same pattern as `bash.ts:killProcessTree`
8243
+ * and `code-search.ts:killChild`). EBUSY is swallowed because taskkill
8244
+ * occasionally races with the child's own teardown.
8245
+ */
8246
+ function killChildTree(child) {
8247
+ if (!child.pid || child.killed) return;
8248
+ if (process$1.platform === "win32") {
8249
+ try {
8250
+ spawnSync("taskkill", [
8251
+ "/T",
8252
+ "/F",
8253
+ "/PID",
8254
+ String(child.pid)
8255
+ ], {
8256
+ stdio: "ignore",
8257
+ windowsHide: true
8258
+ });
8259
+ } catch {}
8260
+ return;
8261
+ }
8262
+ try {
8263
+ child.kill("SIGTERM");
8264
+ } catch {}
8265
+ }
8089
8266
  async function runRipgrep(args, cwd, signal) {
8090
8267
  const RG_STDOUT_CAP = 10 * 1024 * 1024;
8091
8268
  const { rgPath } = resolveRipgrep();
@@ -8112,9 +8289,7 @@ async function runRipgrep(args, cwd, signal) {
8112
8289
  let truncated = false;
8113
8290
  let settled = false;
8114
8291
  const onAbort = () => {
8115
- if (child.pid && !child.killed) try {
8116
- child.kill("SIGTERM");
8117
- } catch {}
8292
+ if (child.pid && !child.killed) killChildTree(child);
8118
8293
  };
8119
8294
  if (signal.aborted) onAbort();
8120
8295
  else signal.addEventListener("abort", onAbort, { once: true });
@@ -8127,9 +8302,7 @@ async function runRipgrep(args, cwd, signal) {
8127
8302
  stdoutBytes += slice.length;
8128
8303
  if (chunk.length > room) {
8129
8304
  truncated = true;
8130
- if (child.pid && !child.killed) try {
8131
- child.kill("SIGTERM");
8132
- } catch {}
8305
+ if (child.pid && !child.killed) killChildTree(child);
8133
8306
  }
8134
8307
  });
8135
8308
  child.stderr?.setEncoding("utf8");
@@ -8984,7 +9157,7 @@ async function createWorktree(workspaceAbs, opts) {
8984
9157
  "HEAD"
8985
9158
  ])).stdout;
8986
9159
  } catch {}
8987
- const lineCount = stat$1.split("\n").filter((l) => l.length > 0).length;
9160
+ const lineCount = stat$1.split(/\r?\n/).filter((l) => l.length > 0).length;
8988
9161
  return `[diff truncated at 256KB; ${Math.max(0, lineCount - 1)} files changed]\n${stat$1}`;
8989
9162
  };
8990
9163
  return {
@@ -10948,7 +11121,7 @@ function initProxyFromEnv() {
10948
11121
  //#endregion
10949
11122
  //#region package.json
10950
11123
  var name = "github-router";
10951
- var version = "0.3.36";
11124
+ var version = "0.3.38";
10952
11125
 
10953
11126
  //#endregion
10954
11127
  //#region src/lib/approval.ts
@@ -11365,7 +11538,7 @@ async function handleCompletion$1(c) {
11365
11538
  return c.json(response);
11366
11539
  }
11367
11540
  const iterator = response[Symbol.asyncIterator]();
11368
- const firstResult = await iterator.next();
11541
+ const firstResult = await readIteratorWithTimeout(iterator, UPSTREAM_INACTIVITY_TIMEOUT_MS);
11369
11542
  if (firstResult.done) consola.warn(`Upstream /chat/completions returned an empty stream at ${c.req.path}`);
11370
11543
  let pendingFirstChunk = firstResult.done ? void 0 : firstResult.value;
11371
11544
  let upstreamFinished = firstResult.done;
@@ -11405,7 +11578,7 @@ async function handleCompletion$1(c) {
11405
11578
  return;
11406
11579
  }
11407
11580
  try {
11408
- const result = await iterator.next();
11581
+ const result = await readIteratorWithTimeout(iterator, UPSTREAM_INACTIVITY_TIMEOUT_MS);
11409
11582
  if (consumerCancelled) {
11410
11583
  safeClose(controller);
11411
11584
  return;
@@ -11726,22 +11899,6 @@ function sanitizeAnthropicBody(rawBody) {
11726
11899
  return JSON.stringify(parsed);
11727
11900
  }
11728
11901
 
11729
- //#endregion
11730
- //#region src/lib/diagnose-response.ts
11731
- const PREVIEW_LIMIT = 200;
11732
- async function parseJsonOrDiagnose(response, routePath) {
11733
- const cloned = response.clone();
11734
- try {
11735
- return await response.json();
11736
- } catch (error) {
11737
- const contentType = response.headers.get("content-type") ?? "(none)";
11738
- const bodyText = await cloned.text().catch(() => "(unreadable)");
11739
- const preview = bodyText.length > PREVIEW_LIMIT ? bodyText.slice(0, PREVIEW_LIMIT) + "...(truncated)" : bodyText;
11740
- consola.error(`Upstream JSON parse failed at ${routePath}: status=${response.status} content-type="${contentType}" body[0..${PREVIEW_LIMIT}]=${JSON.stringify(preview)}`);
11741
- throw error;
11742
- }
11743
- }
11744
-
11745
11902
  //#endregion
11746
11903
  //#region src/routes/messages/count-tokens-handler.ts
11747
11904
  const isWebSearchTool$1 = (tool) => typeof tool.type === "string" && tool.type.startsWith("web_search") || tool.name === "web_search";
@@ -11912,89 +12069,6 @@ function stripAnthropicOnlyFields$1(body) {
11912
12069
 
11913
12070
  //#endregion
11914
12071
  //#region src/routes/messages/handler.ts
11915
- const NON_STREAMING_BODY_CAP_BYTES = 10 * 1024 * 1024;
11916
- /**
11917
- * Read a Response body with a hard byte cap, then parse as JSON.
11918
- *
11919
- * Falls back to the fast path (response.json()) when Content-Length is
11920
- * present and within the cap, avoiding the streaming-reader overhead for
11921
- * the vast majority of normal responses.
11922
- *
11923
- * When the cap is hit:
11924
- * - the reader is cancelled to release the upstream socket
11925
- * - a structured Anthropic-format error is returned to the caller
11926
- * (the caller wraps it in c.json(), not throws — the client gets a
11927
- * clean 413 error, not an unhandled-rejection crash)
11928
- *
11929
- * Returns `{ ok: true, value }` on success or `{ ok: false, errorResponse }`
11930
- * on cap exceeded.
11931
- */
11932
- async function readResponseBodyCapped(response, routePath, capBytes) {
11933
- const contentLengthHeader = response.headers.get("content-length");
11934
- const contentLength = contentLengthHeader ? parseInt(contentLengthHeader, 10) : NaN;
11935
- if (!isNaN(contentLength) && contentLength <= capBytes) return {
11936
- ok: true,
11937
- value: await parseJsonOrDiagnose(response, routePath)
11938
- };
11939
- const reader = response.body?.getReader();
11940
- if (!reader) return {
11941
- ok: true,
11942
- value: await parseJsonOrDiagnose(response, routePath)
11943
- };
11944
- const chunks = [];
11945
- let totalBytes = 0;
11946
- let capped = false;
11947
- try {
11948
- while (true) {
11949
- const { done, value } = await reader.read();
11950
- if (done) break;
11951
- if (!value) continue;
11952
- totalBytes += value.byteLength;
11953
- if (totalBytes > capBytes) {
11954
- capped = true;
11955
- try {
11956
- await reader.cancel("size_cap");
11957
- } catch {}
11958
- break;
11959
- }
11960
- chunks.push(value);
11961
- }
11962
- } catch (err) {
11963
- if (!capped) consola.warn(`readResponseBodyCapped: read error at ${routePath}:`, err);
11964
- }
11965
- if (capped) {
11966
- consola.warn(`Non-streaming upstream response at ${routePath} exceeded ${capBytes} bytes (10 MiB cap); dropping body to prevent OOM. Check upstream health.`);
11967
- return {
11968
- ok: false,
11969
- status: 502,
11970
- errorResponse: {
11971
- type: "error",
11972
- error: {
11973
- type: "api_error",
11974
- message: "Upstream response body exceeded the 10 MiB size cap for non-streaming /v1/messages. The upstream may be misbehaving. Try enabling streaming (stream: true) which handles large responses chunk-by-chunk."
11975
- }
11976
- }
11977
- };
11978
- }
11979
- const merged = new Uint8Array(totalBytes);
11980
- let offset = 0;
11981
- for (const chunk of chunks) {
11982
- merged.set(chunk, offset);
11983
- offset += chunk.byteLength;
11984
- }
11985
- const text = new TextDecoder().decode(merged);
11986
- try {
11987
- return {
11988
- ok: true,
11989
- value: JSON.parse(text)
11990
- };
11991
- } catch (err) {
11992
- const preview = text.slice(0, 200);
11993
- const contentType = response.headers.get("content-type") ?? "(none)";
11994
- consola.error(`Upstream JSON parse failed at ${routePath}: status=${response.status} content-type="${contentType}" body[0..200]=${JSON.stringify(preview)}`);
11995
- throw err;
11996
- }
11997
- }
11998
12072
  const isWebSearchTool = (tool) => typeof tool.type === "string" && tool.type.startsWith("web_search") || tool.name === "web_search";
11999
12073
  /**
12000
12074
  * Extract whitelisted beta headers from the incoming request to forward
@@ -12134,12 +12208,13 @@ async function handleCompletion(c) {
12134
12208
  const modelId = resolvedModel ?? originalModel;
12135
12209
  if (modelId) logEndpointMismatch(modelId, "/v1/messages");
12136
12210
  const effectiveBetas = applyDefaultBetas(betaHeaders, resolvedModel ?? originalModel);
12211
+ const advisorAborter = advisorEnabled ? new AbortController() : void 0;
12137
12212
  let response;
12138
12213
  try {
12139
12214
  response = await createMessages(resolvedBody, {
12140
12215
  ...selectedModel?.requestHeaders,
12141
12216
  ...effectiveBetas
12142
- });
12217
+ }, advisorAborter?.signal);
12143
12218
  } catch (error) {
12144
12219
  if (error instanceof HTTPError) {
12145
12220
  const errorBody = await error.response.clone().text().catch(() => "");
@@ -12197,7 +12272,8 @@ async function handleCompletion(c) {
12197
12272
  requestHeaders: {
12198
12273
  ...selectedModel?.requestHeaders,
12199
12274
  ...effectiveBetas
12200
- }
12275
+ },
12276
+ externalAborter: advisorAborter
12201
12277
  }), {
12202
12278
  status: response.status,
12203
12279
  headers: streamHeaders
@@ -12208,7 +12284,7 @@ async function handleCompletion(c) {
12208
12284
  headers: streamHeaders
12209
12285
  });
12210
12286
  }
12211
- const cappedResult = await readResponseBodyCapped(response, c.req.path, NON_STREAMING_BODY_CAP_BYTES);
12287
+ const cappedResult = await readResponseBodyCapped(response, c.req.path, MAX_RESPONSE_BODY_BYTES);
12212
12288
  if (!cappedResult.ok) return c.json(cappedResult.errorResponse, cappedResult.status);
12213
12289
  const responseBody = cappedResult.value;
12214
12290
  logRequest({
@@ -12561,7 +12637,7 @@ async function handleResponses(c) {
12561
12637
  let firstChunk;
12562
12638
  let upstreamFinished = false;
12563
12639
  while (true) {
12564
- const r = await iterator.next();
12640
+ const r = await readIteratorWithTimeout(iterator, UPSTREAM_INACTIVITY_TIMEOUT_MS);
12565
12641
  if (r.done) {
12566
12642
  upstreamFinished = true;
12567
12643
  break;
@@ -12613,7 +12689,7 @@ async function handleResponses(c) {
12613
12689
  return;
12614
12690
  }
12615
12691
  try {
12616
- const result = await iterator.next();
12692
+ const result = await readIteratorWithTimeout(iterator, UPSTREAM_INACTIVITY_TIMEOUT_MS);
12617
12693
  if (consumerCancelled) {
12618
12694
  safeClose(controller);
12619
12695
  return;
@@ -12722,11 +12798,14 @@ async function handleResponsesCompact(c) {
12722
12798
  if (!state.copilotToken) throw new Error("Copilot token not found");
12723
12799
  if (state.manualApprove) await awaitApproval();
12724
12800
  const body = await c.req.json();
12725
- const response = await fetch(`${copilotBaseUrl(state)}/responses/compact`, {
12801
+ const compactUrl = `${copilotBaseUrl(state)}/responses/compact`;
12802
+ const doFetch = () => fetch(compactUrl, {
12726
12803
  method: "POST",
12727
12804
  headers: copilotHeaders(state),
12728
- body: JSON.stringify(body)
12805
+ body: JSON.stringify(body),
12806
+ signal: AbortSignal.timeout(UPSTREAM_FETCH_TIMEOUT_MS || 3e5)
12729
12807
  });
12808
+ const response = await tryRefreshAndRetry(doFetch, "/responses/compact");
12730
12809
  if (response.ok) {
12731
12810
  logRequest({
12732
12811
  method: "POST",
@@ -12737,6 +12816,7 @@ async function handleResponsesCompact(c) {
12737
12816
  }
12738
12817
  if (response.status === 404) {
12739
12818
  consola.debug("Copilot API does not support /responses/compact, using synthetic compaction");
12819
+ await response.body?.cancel().catch(() => {});
12740
12820
  return await syntheticCompact(c, body, startTime);
12741
12821
  }
12742
12822
  logRequest({