github-router 0.3.27 → 0.3.29

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.
@@ -0,0 +1,878 @@
1
+ import consola from "consola";
2
+ import { randomBytes, randomUUID } from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import process$1 from "node:process";
7
+ import { execFileSync } from "node:child_process";
8
+
9
+ //#region src/lib/paths.ts
10
+ function appDir() {
11
+ return path.join(os.homedir(), ".local", "share", "github-router");
12
+ }
13
+ const PATHS = {
14
+ get APP_DIR() {
15
+ return appDir();
16
+ },
17
+ get GITHUB_TOKEN_PATH() {
18
+ return path.join(appDir(), "github_token");
19
+ },
20
+ get ERROR_LOG_PATH() {
21
+ return path.join(appDir(), "error.log");
22
+ },
23
+ get CODEX_HOME() {
24
+ return path.join(appDir(), "codex-isolated");
25
+ },
26
+ get CLAUDE_RUNTIME_DIR() {
27
+ return path.join(appDir(), "runtime");
28
+ },
29
+ get CLAUDE_CONFIG_DIR() {
30
+ return path.join(appDir(), "claude-config", claudeConfigDirSuffix());
31
+ }
32
+ };
33
+ /**
34
+ * Per-launch suffix for `PATHS.CLAUDE_CONFIG_DIR`. Lazily generated on
35
+ * first access and cached for the lifetime of the process so every
36
+ * caller (env-var injection in `getClaudeCodeEnvVars`,
37
+ * `ensureClaudeConfigMirror` provisioning, peer-agent `.md` writes
38
+ * under `<dir>/agents/`, the shutdown cleanup) resolves the same path.
39
+ *
40
+ * Shape: `<pid>-<8 hex>`. The PID prefix is what
41
+ * `sweepStaleClaudeConfigMirrors` keys off to drop orphans from
42
+ * crashed prior sessions; the 8-hex random suffix prevents collision
43
+ * if a future caller (tests, internal relaunch) ever clears the cache
44
+ * within a single PID lifetime.
45
+ *
46
+ * NOT exported — every consumer should go through `PATHS.CLAUDE_CONFIG_DIR`
47
+ * so the homedir-mock pattern used in the test suite keeps working.
48
+ */
49
+ let _claudeConfigDirSuffix;
50
+ function claudeConfigDirSuffix() {
51
+ if (_claudeConfigDirSuffix === void 0) _claudeConfigDirSuffix = `${process.pid}-${randomBytes(4).toString("hex")}`;
52
+ return _claudeConfigDirSuffix;
53
+ }
54
+ async function ensurePaths() {
55
+ await fs.mkdir(PATHS.APP_DIR, { recursive: true });
56
+ await fs.mkdir(PATHS.CODEX_HOME, { recursive: true });
57
+ await fs.mkdir(PATHS.CLAUDE_RUNTIME_DIR, { recursive: true });
58
+ await chmodIfPossible(PATHS.CLAUDE_RUNTIME_DIR, 448);
59
+ await ensureFile(PATHS.GITHUB_TOKEN_PATH);
60
+ await sweepStaleRuntimeFiles().catch((err) => {
61
+ consola.debug("Runtime sweep skipped:", err);
62
+ });
63
+ await sweepStaleClaudeConfigMirrors().catch((err) => {
64
+ consola.debug("Per-launch claude-config sweep skipped:", err);
65
+ });
66
+ await sweepStalePeerAgentMdFiles().catch((err) => {
67
+ consola.debug("Peer-agent .md sweep skipped:", err);
68
+ });
69
+ await (async () => {
70
+ await (await import("./lifecycle-De6QsSv8.js")).sweepStaleWorktreesAtBoot();
71
+ })().catch((err) => {
72
+ consola.debug("Worker worktree boot sweep skipped:", err);
73
+ });
74
+ }
75
+ const CLAUDE_HOME_POLICY = new Map([
76
+ [".credentials.json", "ISOLATED"],
77
+ [".credentials.json.lock", "ISOLATED"],
78
+ [".oauth_refresh.lock", "ISOLATED"],
79
+ [".github-router-managed", "ISOLATED"],
80
+ ["statsig", "ISOLATED"],
81
+ ["cache", "ISOLATED"],
82
+ ["logs", "ISOLATED"],
83
+ ["paste-cache", "ISOLATED"],
84
+ ["jobs", "ISOLATED"],
85
+ ["daemon", "ISOLATED"],
86
+ ["daemon.log", "ISOLATED"],
87
+ ["projects", "SHARED"],
88
+ ["sessions", "SHARED"],
89
+ ["tasks", "SHARED"],
90
+ ["todos", "SHARED"],
91
+ ["transcripts", "SHARED"],
92
+ ["shell-snapshots", "SHARED"],
93
+ ["shell_snapshots", "SHARED"],
94
+ ["plans", "SHARED"],
95
+ ["file-history", "SHARED"],
96
+ ["backups", "SHARED"]
97
+ ]);
98
+ function policyFor(name) {
99
+ return CLAUDE_HOME_POLICY.get(name) ?? "MIRRORED";
100
+ }
101
+ /**
102
+ * Names with `SHARED` policy, materialized once for iteration in
103
+ * `ensureClaudeConfigMirror`'s post-copy phase.
104
+ */
105
+ const SHARED_TOPLEVEL_NAMES = Array.from(CLAUDE_HOME_POLICY.entries()).filter(([, kind]) => kind === "SHARED").map(([name]) => name);
106
+ /**
107
+ * Marker file written into the router-owned CLAUDE_CONFIG_DIR so users
108
+ * (and our own future sweeps) can identify that the dir is managed by
109
+ * github-router. Content is informational only; no logic depends on
110
+ * its presence.
111
+ */
112
+ const MANAGED_MARKER_FILENAME = ".github-router-managed";
113
+ /**
114
+ * Synthetic Console OAuth credential the router writes into its own
115
+ * `CLAUDE_CONFIG_DIR/.credentials.json` so spawned Claude Code (and
116
+ * any teammates it spawns) can authenticate without a real user
117
+ * `/login`.
118
+ *
119
+ * Schema verified verbatim from `claude` v2.1.140 binary, function
120
+ * `guH` (the credentials-save mutation). Fields:
121
+ * - `accessToken` — sent as `Authorization: Bearer ...` to the
122
+ * proxy. Proxy accepts any bearer (per CLAUDE.md "doesn't enforce
123
+ * auth").
124
+ * - `refreshToken` — only used by Claude Code's reactive refresh
125
+ * path (function `nH8`), which fires on 401 from upstream. The
126
+ * proxy maintains the no-401 invariant on the Anthropic-shape
127
+ * boundary, so this is never invoked. Synthetic value is fine.
128
+ * - `expiresAt` — far-future (2099-01-01 ms epoch). Sidesteps the
129
+ * proactive refresh path (`R8H(expiresAt)` returns false).
130
+ * - `scopes` — claude-ai-shaped so `tB(scopes)` returns true,
131
+ * making `Hq()` true (full feature surface, not "inference only").
132
+ * - `subscriptionType` — `"max"`. Pure client-side label
133
+ * (`e7()` / `Zc_()` / `CZ1()`); no server validation since
134
+ * `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` suppresses
135
+ * subscription-validation calls. Picks the most-permissive gating.
136
+ */
137
+ const SYNTHETIC_CREDENTIAL = { claudeAiOauth: {
138
+ accessToken: "github-router-synthetic",
139
+ refreshToken: "github-router-synthetic",
140
+ expiresAt: 40709088e5,
141
+ scopes: ["user:inference", "user:profile"],
142
+ subscriptionType: "max",
143
+ rateLimitTier: null,
144
+ clientId: "github-router"
145
+ } };
146
+ /**
147
+ * Snapshot-copy the user's `~/.claude/` into the router-owned
148
+ * CLAUDE_CONFIG_DIR (real files, not symlinks — symlinks don't isolate
149
+ * writes), classifying each top-level entry per `CLAUDE_HOME_POLICY`:
150
+ * ISOLATED entries are skipped, MIRRORED entries are copied, and
151
+ * SHARED entries become directory symlinks back to `~/.claude/<X>` so
152
+ * chat history (in `projects/<cwd-hash>/<session-uuid>.jsonl`) and
153
+ * other durable user state flow between proxy and plain-`claude`
154
+ * sessions. Then writes the synthetic `.credentials.json` so spawned
155
+ * Claude Code (and teammates that inherit `CLAUDE_CONFIG_DIR`)
156
+ * authenticate.
157
+ *
158
+ * Idempotent: only re-copies files whose source `mtime` is newer than
159
+ * target; SHARED-symlink creation no-ops when the symlink already
160
+ * points at the right target. Concurrent-safe: `mkdir({recursive:true})`
161
+ * is idempotent; symlinks are created via atomic temp+rename so two
162
+ * parallel github-router-claude startups can't race to EEXIST; the
163
+ * credentials write uses temp-file + atomic rename so Claude Code's
164
+ * `EZ1()` mtime watcher never sees a partial write.
165
+ *
166
+ * Walks with `lstat` (does NOT follow symlinks during traversal — a
167
+ * symlink-into-`/` would otherwise let the walk escape). Symlink leaves
168
+ * in the source tree are skipped during the MIRRORED copy walk (per the
169
+ * symlink-confused-deputy security finding); SHARED symlinks are
170
+ * created on the mirror side only, pointing at predetermined targets
171
+ * inside the user's real `~/.claude/`.
172
+ *
173
+ * Caller is expected to invoke this after `ensurePaths()` and before
174
+ * spawning Claude Code (`launchChild`). The mirror must exist before
175
+ * the child reads it. Currently called from the `claude` subcommand
176
+ * entry point only; `start` and `codex` subcommands don't need it.
177
+ */
178
+ async function ensureClaudeConfigMirror(opts = {}) {
179
+ const realHome = opts.realHome ?? os.homedir();
180
+ const sourceDir = path.join(realHome, ".claude");
181
+ const targetDir = PATHS.CLAUDE_CONFIG_DIR;
182
+ await fs.mkdir(targetDir, {
183
+ recursive: true,
184
+ mode: 448
185
+ });
186
+ await chmodIfPossible(targetDir, 448);
187
+ let sourceExists = false;
188
+ try {
189
+ sourceExists = (await fs.stat(sourceDir)).isDirectory();
190
+ } catch (err) {
191
+ if (err.code !== "ENOENT") consola.debug(`ensureClaudeConfigMirror: cannot stat ${sourceDir}:`, err);
192
+ }
193
+ if (sourceExists) await mirrorDirRecursive(sourceDir, targetDir, "");
194
+ await fs.mkdir(path.join(targetDir, "agents"), { recursive: true });
195
+ for (const name of SHARED_TOPLEVEL_NAMES) await ensureSharedSymlink(name, sourceDir, targetDir).catch((err) => {
196
+ consola.debug(`ensureClaudeConfigMirror: SHARED symlink for ${name} skipped:`, err);
197
+ });
198
+ const credentialsPath = path.join(targetDir, ".credentials.json");
199
+ const desiredJson = JSON.stringify(SYNTHETIC_CREDENTIAL, null, 2);
200
+ let needsWrite = true;
201
+ try {
202
+ needsWrite = (await fs.readFile(credentialsPath, "utf8")).trim() !== desiredJson.trim();
203
+ } catch (err) {
204
+ if (err.code !== "ENOENT") consola.debug(`ensureClaudeConfigMirror: cannot read existing credentials:`, err);
205
+ }
206
+ if (needsWrite) {
207
+ const tempPath = `${credentialsPath}.${process.pid}.tmp`;
208
+ try {
209
+ await fs.writeFile(tempPath, desiredJson + "\n", {
210
+ mode: 384,
211
+ flag: "wx"
212
+ });
213
+ await fs.rename(tempPath, credentialsPath);
214
+ } catch (err) {
215
+ if (err.code === "EEXIST") consola.debug("ensureClaudeConfigMirror: concurrent credentials-write detected, skipping");
216
+ else {
217
+ await fs.unlink(tempPath).catch(() => {});
218
+ throw err;
219
+ }
220
+ }
221
+ }
222
+ await chmodIfPossible(credentialsPath, 384);
223
+ const markerPath = path.join(targetDir, MANAGED_MARKER_FILENAME);
224
+ let markerExists = false;
225
+ try {
226
+ const markerStat = await fs.lstat(markerPath);
227
+ if (markerStat.isFile()) markerExists = true;
228
+ else {
229
+ consola.warn(`ensureClaudeConfigMirror: ${markerPath} exists but is not a regular file (mode=${markerStat.mode.toString(8)}); refusing to overwrite. Inspect and remove manually if safe.`);
230
+ markerExists = true;
231
+ }
232
+ } catch (err) {
233
+ if (err.code !== "ENOENT") {
234
+ consola.debug(`ensureClaudeConfigMirror: cannot lstat marker:`, err);
235
+ markerExists = true;
236
+ }
237
+ }
238
+ if (!markerExists) {
239
+ const body = `Managed by github-router. Created ${(/* @__PURE__ */ new Date()).toISOString()}. Safe to delete (will be recreated).\n`;
240
+ await fs.writeFile(markerPath, body, {
241
+ mode: 384,
242
+ flag: "wx"
243
+ }).catch((err) => {
244
+ consola.debug(`ensureClaudeConfigMirror: marker write skipped:`, err);
245
+ });
246
+ }
247
+ }
248
+ /**
249
+ * Recursive snapshot-copy helper for `ensureClaudeConfigMirror`. Walks
250
+ * `sourceDir/relPath` and mirrors each entry into `targetDir/relPath`.
251
+ * - Top-level entries are dispatched on `policyFor(name)`:
252
+ * - `ISOLATED` → skipped entirely (no presence in mirror).
253
+ * - `SHARED` → skipped from the copy walk; handled by
254
+ * `ensureSharedSymlink` in the post-copy phase.
255
+ * - `MIRRORED` → copied as today.
256
+ * - Symlinks are skipped (not recreated) so the walk never follows out
257
+ * of `sourceDir` and we don't reintroduce a confused-deputy vector.
258
+ * - Files copy only if source mtime > target mtime (idempotent).
259
+ */
260
+ async function mirrorDirRecursive(sourceDir, targetDir, relPath) {
261
+ const sourcePath = path.join(sourceDir, relPath);
262
+ let entries;
263
+ try {
264
+ entries = await fs.readdir(sourcePath);
265
+ } catch (err) {
266
+ if (err.code === "ENOENT") return;
267
+ consola.debug(`mirrorDirRecursive: cannot readdir ${sourcePath}:`, err);
268
+ return;
269
+ }
270
+ for (const name of entries) {
271
+ if (relPath === "") {
272
+ const policy = policyFor(name);
273
+ if (policy === "ISOLATED" || policy === "SHARED") continue;
274
+ }
275
+ const childRel = relPath === "" ? name : path.join(relPath, name);
276
+ const childSource = path.join(sourceDir, childRel);
277
+ const childTarget = path.join(targetDir, childRel);
278
+ let stats;
279
+ try {
280
+ stats = await fs.lstat(childSource);
281
+ } catch (err) {
282
+ consola.debug(`mirrorDirRecursive: cannot lstat ${childSource}:`, err);
283
+ continue;
284
+ }
285
+ if (stats.isSymbolicLink()) {
286
+ consola.debug(`mirrorDirRecursive: skipping symlink ${childSource} (security policy)`);
287
+ continue;
288
+ }
289
+ if (stats.isDirectory()) {
290
+ await fs.mkdir(childTarget, { recursive: true });
291
+ await mirrorDirRecursive(sourceDir, targetDir, childRel);
292
+ continue;
293
+ }
294
+ if (stats.isFile()) {
295
+ let needsCopy = true;
296
+ try {
297
+ const targetStat = await fs.lstat(childTarget);
298
+ if (targetStat.isFile() && targetStat.mtimeMs >= stats.mtimeMs) needsCopy = false;
299
+ } catch (err) {
300
+ if (err.code !== "ENOENT") consola.debug(`mirrorDirRecursive: lstat target ${childTarget}:`, err);
301
+ }
302
+ if (!needsCopy) continue;
303
+ try {
304
+ await fs.copyFile(childSource, childTarget, fs.constants.COPYFILE_FICLONE);
305
+ } catch (err) {
306
+ consola.debug(`mirrorDirRecursive: copy ${childSource} → ${childTarget}:`, err);
307
+ }
308
+ continue;
309
+ }
310
+ }
311
+ }
312
+ /**
313
+ * Create or refresh a directory symlink `<mirrorDir>/<name>` →
314
+ * `<sourceDir>/<name>` (i.e. `~/.local/share/github-router/claude-config/<X>`
315
+ * → `~/.claude/<X>`). Idempotent and concurrent-safe.
316
+ *
317
+ * Behavior depending on what's already at `<mirrorDir>/<name>`:
318
+ * - Symlink with the correct target → no-op.
319
+ * - Symlink with the wrong target → replace atomically.
320
+ * - Empty real directory (legacy mirror leftover with no proxy-session
321
+ * writes accumulated yet) → `rmdir` and replace with the symlink.
322
+ * Safe by definition: `fs.rmdir` only succeeds on empty dirs (POSIX),
323
+ * so there is nothing to lose. Smooths the upgrade path for users
324
+ * whose legacy mirror dirs were never written to.
325
+ * - Non-empty real directory or regular file → loud-warn and skip.
326
+ * Auto-deleting would destroy proxy-session writes from the prior
327
+ * version. The user is told the exact path and remediation.
328
+ * - ENOENT → create symlink atomically.
329
+ *
330
+ * Atomic-creation: symlinks are first written at a unique side-path
331
+ * (`<mirrorDir>/<name>.tmp.<pid>.<8 hex>`) and then `fs.rename()`d into
332
+ * place. POSIX `rename` is atomic and replaces an existing symlink in
333
+ * a single step, so two concurrent `github-router claude` startups can't
334
+ * race to `EEXIST` — the loser's rename just overwrites the winner's
335
+ * symlink with an identical one. Gemini-critic 3-lab-review finding.
336
+ *
337
+ * Pre-creates `~/.claude/<name>/` as a real directory if missing so
338
+ * Claude Code's writes through the symlink don't fail with ENOENT.
339
+ */
340
+ async function ensureSharedSymlink(name, sourceDir, mirrorDir) {
341
+ const sourcePath = path.join(sourceDir, name);
342
+ const mirrorPath = path.join(mirrorDir, name);
343
+ try {
344
+ await fs.mkdir(sourcePath, { recursive: true });
345
+ } catch (err) {
346
+ consola.warn(`ensureSharedSymlink(${name}): cannot mkdir source ${sourcePath}:`, err);
347
+ return;
348
+ }
349
+ let existing = null;
350
+ try {
351
+ existing = await fs.lstat(mirrorPath);
352
+ } catch (err) {
353
+ if (err.code !== "ENOENT") {
354
+ consola.warn(`ensureSharedSymlink(${name}): cannot lstat ${mirrorPath}:`, err);
355
+ return;
356
+ }
357
+ }
358
+ if (existing?.isSymbolicLink()) {
359
+ const sourceReal = await fs.realpath(sourcePath).catch(() => null);
360
+ if (sourceReal === null) {
361
+ consola.warn(`ensureSharedSymlink(${name}): cannot resolve source ${sourcePath} — skipping junction creation to avoid silent every-startup churn. Inspect the source dir's permissions / OneDrive sync state and re-launch.`);
362
+ return;
363
+ }
364
+ const currentReal = await fs.realpath(mirrorPath).catch(() => null);
365
+ if (currentReal !== null && currentReal === sourceReal) return;
366
+ } else if (existing?.isDirectory()) try {
367
+ await fs.rmdir(mirrorPath);
368
+ } catch (err) {
369
+ consola.warn(`ensureClaudeConfigMirror: ${mirrorPath} is a non-empty real directory from an older github-router version; refusing to clobber. If you want chat-history continuity for "${name}", move its contents into ${sourcePath}/ then delete ${mirrorPath}; the mirror will create a symlink (junction on Windows) on next launch. (rmdir error: ${err.code ?? "unknown"})`);
370
+ return;
371
+ }
372
+ else if (existing) {
373
+ consola.warn(`ensureClaudeConfigMirror: ${mirrorPath} is a regular file at a SHARED symlink slot; refusing to clobber. Inspect and remove manually if safe; the mirror will create a symlink on next launch.`);
374
+ return;
375
+ }
376
+ const tempPath = `${mirrorPath}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
377
+ try {
378
+ await fs.symlink(sourcePath, tempPath, process.platform === "win32" ? "junction" : "dir");
379
+ } catch (err) {
380
+ consola.warn(`ensureSharedSymlink(${name}): symlink ${tempPath} failed:`, err);
381
+ return;
382
+ }
383
+ if (process.platform === "win32" && existing?.isSymbolicLink()) await fs.unlink(mirrorPath).catch(() => {});
384
+ try {
385
+ await fs.rename(tempPath, mirrorPath);
386
+ } catch (err) {
387
+ consola.warn(`ensureSharedSymlink(${name}): rename ${tempPath} → ${mirrorPath} failed:`, err);
388
+ await fs.unlink(tempPath).catch(() => {});
389
+ }
390
+ }
391
+ async function ensureFile(filePath) {
392
+ try {
393
+ await fs.access(filePath, fs.constants.W_OK);
394
+ } catch {
395
+ await fs.writeFile(filePath, "");
396
+ await fs.chmod(filePath, 384);
397
+ }
398
+ }
399
+ async function chmodIfPossible(target, mode) {
400
+ if (process.platform === "win32") return;
401
+ try {
402
+ await fs.chmod(target, mode);
403
+ } catch (err) {
404
+ consola.debug(`chmod ${target} ${mode.toString(8)} failed:`, err);
405
+ }
406
+ }
407
+ /**
408
+ * Write a runtime tempfile securely.
409
+ *
410
+ * - Mode `0o600` so other local users (multi-tenant boxes, shared
411
+ * dev containers) can't read the per-launch nonce or runtime URL.
412
+ * - `flag: "wx"` (O_CREAT | O_EXCL | O_WRONLY) refuses to overwrite
413
+ * an existing path. POSIX open(2) with O_EXCL also rejects
414
+ * pre-placed symlinks, killing the symlink-clobber attack vector.
415
+ * - The caller's responsibility to pick a path NOT yet in use.
416
+ * We intentionally do NOT pre-unlink: an `lstat` + `unlink` +
417
+ * `open(O_EXCL)` sequence still has a TOCTOU window where an
418
+ * attacker can drop a symlink between unlink and open. Letting
419
+ * `wx` fail is the safer behavior — surfaces the conflict
420
+ * instead of silently following.
421
+ */
422
+ async function writeRuntimeFileSecure(filePath, content) {
423
+ await fs.writeFile(filePath, content, {
424
+ mode: 384,
425
+ flag: "wx"
426
+ });
427
+ }
428
+ /**
429
+ * Sweep stale runtime tempfiles. Removes files whose embedded PID is no
430
+ * longer a live process. A proxy crash (`kill -9`, OS reboot) leaves
431
+ * orphans that would otherwise accumulate forever — and worse, a stale
432
+ * config pointing at a now-recycled port could route MCP traffic to
433
+ * whatever process bound that port next.
434
+ *
435
+ * Naming convention: `peer-mcp-<pid>.json` and `peer-agents-<pid>.json`.
436
+ * Files not matching either pattern are left alone — this directory
437
+ * is shared with future runtime artifacts.
438
+ *
439
+ * We deliberately do NOT age-prune files whose PID is alive. A
440
+ * legitimately long-running proxy can have a tempfile older than any
441
+ * arbitrary threshold; deleting it out from under the live process
442
+ * breaks the spawned Claude Code child's MCP/agent wiring with no clean
443
+ * recovery. PID-wraparound risk is mitigated by (a) PID reuse on Linux
444
+ * being slow under typical loads, and (b) the file is only consulted by
445
+ * github-router itself — an unrelated process that inherits the PID
446
+ * never reads it.
447
+ */
448
+ async function sweepStaleRuntimeFiles() {
449
+ const dir = PATHS.CLAUDE_RUNTIME_DIR;
450
+ let entries;
451
+ try {
452
+ entries = await fs.readdir(dir);
453
+ } catch (err) {
454
+ if (err.code === "ENOENT") return;
455
+ throw err;
456
+ }
457
+ for (const name of entries) {
458
+ const match = /^peer-(?:mcp|agents)-(\d+)(?:-[0-9a-f]+)?\.json$/.exec(name);
459
+ if (!match) continue;
460
+ const pid = Number.parseInt(match[1], 10);
461
+ const filePath = path.join(dir, name);
462
+ if (isPidAlive$1(pid)) continue;
463
+ await fs.unlink(filePath).catch(() => {});
464
+ }
465
+ }
466
+ function isPidAlive$1(pid) {
467
+ if (!Number.isInteger(pid) || pid <= 0) return false;
468
+ try {
469
+ process.kill(pid, 0);
470
+ return true;
471
+ } catch (err) {
472
+ if (err.code === "EPERM") return true;
473
+ return false;
474
+ }
475
+ }
476
+ /**
477
+ * Sweep stale peer-* subagent .md files from the router-owned
478
+ * `CLAUDE_CONFIG_DIR/agents/`. Phase 2.5 writes one .md per peer agent
479
+ * into Claude Code's agents directory (now our config dir's `agents/`
480
+ * subdir, since `getClaudeCodeEnvVars` points `CLAUDE_CONFIG_DIR` at
481
+ * `PATHS.CLAUDE_CONFIG_DIR`) so they appear in Claude Code's Task
482
+ * `subagent_type` enum. Files are named `peer-<pid>-<rand>-<agentName>.md`
483
+ * so this sweep can drop orphans from crashed prior proxy sessions
484
+ * without touching the user's own .md files (which were copied into
485
+ * the same dir during `ensureClaudeConfigMirror`).
486
+ *
487
+ * Same liveness rule as `sweepStaleRuntimeFiles`: only delete when the
488
+ * file's embedded PID is no longer alive. Live PIDs keep their files —
489
+ * a long-running proxy doesn't lose its agent registrations.
490
+ *
491
+ * Regex tightening (Phase 2.6, codex-critic + gemini-critic 2-lab finding):
492
+ * the original sweep regex `^peer-(\d+)(?:-[0-9a-f]+)?-.+\.md$` was too
493
+ * permissive — a user-authored `peer-12345-meeting-notes.md` matches
494
+ * (`12345` = "PID", `-meeting-notes` = trailing `.+`) and would be
495
+ * silently unlinked when 12345 happens to be a dead PID (overwhelmingly
496
+ * likely). Tightened to require BOTH the 8-hex-char random suffix AND
497
+ * an exact-match persona name suffix, eliminating the risk for any
498
+ * realistic user filename.
499
+ */
500
+ async function sweepStalePeerAgentMdFiles() {
501
+ const dir = path.join(PATHS.CLAUDE_CONFIG_DIR, "agents");
502
+ let entries;
503
+ try {
504
+ entries = await fs.readdir(dir);
505
+ } catch (err) {
506
+ if (err.code === "ENOENT") return;
507
+ throw err;
508
+ }
509
+ for (const name of entries) {
510
+ const match = PEER_AGENT_MD_FILENAME.exec(name);
511
+ if (!match) continue;
512
+ if (isPidAlive$1(Number.parseInt(match[1], 10))) continue;
513
+ await fs.unlink(path.join(dir, name)).catch(() => {});
514
+ }
515
+ }
516
+ /**
517
+ * Strict regex matching only files this proxy writes:
518
+ * peer-<pid>-<8 hex>-<exact persona/coordinator name>.md
519
+ * The persona-name allowlist is the load-bearing protection against
520
+ * deleting user files. Update this list whenever a new persona is added
521
+ * to `PERSONAS_READ` / `PERSONAS_WRITE` in `peer-mcp-personas.ts` or a
522
+ * new coordinator-style agent is added in `codex-mcp-config.ts`.
523
+ */
524
+ const PEER_AGENT_MD_FILENAME = /^peer-(\d+)-[0-9a-f]{8}-(?:codex-critic|codex-reviewer|gemini-critic|codex-implementer|peer-review-coordinator)\.md$/;
525
+ /**
526
+ * Strict regex matching only per-launch claude-config mirror dirs this
527
+ * proxy creates: `<pid>-<8 hex>`. Anchored to the entire entry name so
528
+ * user-authored siblings under `<appDir>/claude-config/` (if any) are
529
+ * untouchable. The PID prefix is what `sweepStaleClaudeConfigMirrors`
530
+ * keys off; the 8-hex random suffix matches `randomBytes(4)` exactly
531
+ * (no `?` — files created by a different shape are not ours).
532
+ */
533
+ const CLAUDE_CONFIG_MIRROR_DIR = /^(\d+)-[0-9a-f]{8}$/;
534
+ /**
535
+ * Sweep stale per-launch CLAUDE_CONFIG_DIR mirrors left behind by
536
+ * crashed prior proxy sessions. Symmetric to `sweepStalePeerAgentMdFiles`
537
+ * — same liveness rule (only delete when the embedded PID is dead),
538
+ * same strict regex (the dir-name allowlist is the load-bearing
539
+ * protection against deleting user-authored siblings).
540
+ *
541
+ * Scans `<appDir>/claude-config/` (the parent of the per-launch dirs).
542
+ * Each entry whose name matches `<pid>-<8 hex>` AND whose PID is no
543
+ * longer alive is removed recursively. `fs.rm({recursive: true})`
544
+ * walks the tree calling `unlink` on symlinks/junctions rather than
545
+ * following them, so the SHARED junctions back to `~/.claude/<X>`
546
+ * are removed without touching their targets.
547
+ *
548
+ * Tolerates missing parent dir (first-ever launch, or user wiped it).
549
+ */
550
+ async function sweepStaleClaudeConfigMirrors() {
551
+ const parent = path.join(appDir(), "claude-config");
552
+ let entries;
553
+ try {
554
+ entries = await fs.readdir(parent);
555
+ } catch (err) {
556
+ if (err.code === "ENOENT") return;
557
+ throw err;
558
+ }
559
+ for (const name of entries) {
560
+ const match = CLAUDE_CONFIG_MIRROR_DIR.exec(name);
561
+ if (!match) continue;
562
+ if (isPidAlive$1(Number.parseInt(match[1], 10))) continue;
563
+ await fs.rm(path.join(parent, name), {
564
+ recursive: true,
565
+ force: true
566
+ }).catch((err) => {
567
+ consola.debug(`sweepStaleClaudeConfigMirrors: cannot rm ${name}:`, err);
568
+ });
569
+ }
570
+ }
571
+ /**
572
+ * Remove THIS launch's per-launch CLAUDE_CONFIG_DIR on shutdown.
573
+ * Best-effort: a failure here must not block process exit (the caller
574
+ * wraps this in a `.catch`-equivalent via `launchChild`'s onShutdown
575
+ * try/catch). Symmetric to `writePeerMcpRuntimeFiles`'s `cleanup()`:
576
+ * we own this dir for the lifetime of the proxy, so removing it on
577
+ * normal shutdown is correct; the boot-time sweep handles the
578
+ * abnormal-exit case.
579
+ *
580
+ * `fs.rm({recursive: true})` removes SHARED junctions via unlink
581
+ * (does NOT follow them into the user's real `~/.claude/<X>`).
582
+ */
583
+ async function removeOwnClaudeConfigMirror() {
584
+ const dir = PATHS.CLAUDE_CONFIG_DIR;
585
+ await fs.rm(dir, {
586
+ recursive: true,
587
+ force: true
588
+ }).catch((err) => {
589
+ consola.debug(`removeOwnClaudeConfigMirror: rm ${dir} skipped:`, err);
590
+ });
591
+ }
592
+
593
+ //#endregion
594
+ //#region src/lib/worker-agent/lifecycle.ts
595
+ /**
596
+ * Same regex worktree.ts uses for its per-call age sweep — kept in
597
+ * sync intentionally. `<pid>-<uuid>-<8hex>` strictly.
598
+ */
599
+ const WORKTREE_DIR_NAME_RE = /^(\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})$/;
600
+ /**
601
+ * Cap on the ledger: how many repos we remember across boots, and how
602
+ * old an entry may be before it's pruned. Both are belt-and-suspenders
603
+ * — the per-call age sweep is the primary guard against accumulation
604
+ * inside any single repo.
605
+ */
606
+ const LEDGER_MAX_ENTRIES = 100;
607
+ const LEDGER_MAX_AGE_MS = 720 * 60 * 60 * 1e3;
608
+ /**
609
+ * Set-like in-memory registry of worktrees this proxy created. Engine
610
+ * passes it to `createWorktree` so per-call cleanup deletes the entry
611
+ * on success; the signal handlers walk what's left at shutdown.
612
+ *
613
+ * Not a bare `Set` because we want to expose only the operations we
614
+ * actually use, and we want a stable testable surface.
615
+ */
616
+ var WorktreeRegistry = class {
617
+ entries = /* @__PURE__ */ new Set();
618
+ add(entry) {
619
+ this.entries.add(entry);
620
+ }
621
+ delete(entry) {
622
+ this.entries.delete(entry);
623
+ }
624
+ has(entry) {
625
+ return this.entries.has(entry);
626
+ }
627
+ values() {
628
+ return this.entries.values();
629
+ }
630
+ get size() {
631
+ return this.entries.size;
632
+ }
633
+ clear() {
634
+ this.entries.clear();
635
+ }
636
+ };
637
+ let _instanceUuid = null;
638
+ /**
639
+ * Stable UUID4 generated once per proxy process. Used in worktree
640
+ * dir/branch names so the boot sweep can reliably distinguish "this
641
+ * proxy's still-live worktrees" from "stranded dirs from a prior
642
+ * proxy that happens to have a recycled PID" — Docker PID-1 across
643
+ * container restarts is the classic case (peer-review HIGH finding).
644
+ */
645
+ function getInstanceUuid() {
646
+ if (_instanceUuid === null) _instanceUuid = randomUUID();
647
+ return _instanceUuid;
648
+ }
649
+ let _registered = false;
650
+ let _activeRegistry = null;
651
+ let _exitHandler = null;
652
+ let _sigintHandler = null;
653
+ let _sigtermHandler = null;
654
+ /**
655
+ * Synchronous cleanup of every registry entry. Best-effort:
656
+ * `execFileSync` failures are swallowed (the dir may have been
657
+ * removed already, or git may not be on PATH any more in some
658
+ * environments). After a successful removal we drop the entry from
659
+ * the registry so a second call is a true no-op.
660
+ *
661
+ * Synchronous on purpose — exit handlers can't reliably await async
662
+ * work; the process would die before the promise settled.
663
+ */
664
+ function sweepRegistry() {
665
+ if (!_activeRegistry) return;
666
+ const snapshot = [..._activeRegistry.values()];
667
+ for (const entry of snapshot) {
668
+ try {
669
+ execFileSync("git", [
670
+ "-C",
671
+ entry.repoRoot,
672
+ "worktree",
673
+ "remove",
674
+ "--force",
675
+ entry.dir
676
+ ], {
677
+ stdio: "ignore",
678
+ timeout: 1e4,
679
+ windowsHide: true
680
+ });
681
+ } catch {}
682
+ try {
683
+ execFileSync("git", [
684
+ "-C",
685
+ entry.repoRoot,
686
+ "branch",
687
+ "-D",
688
+ entry.branch
689
+ ], {
690
+ stdio: "ignore",
691
+ timeout: 5e3,
692
+ windowsHide: true
693
+ });
694
+ } catch {}
695
+ _activeRegistry.delete(entry);
696
+ }
697
+ }
698
+ /**
699
+ * Wire up SIGINT/SIGTERM/exit handlers that walk the registry and
700
+ * remove every entry. Idempotent: subsequent calls swap the registry
701
+ * pointer but do NOT register additional process listeners (otherwise
702
+ * we'd leak listeners on every `runWorkerAgent`).
703
+ *
704
+ * Signal handlers re-raise the signal after sweeping. Naively running
705
+ * the sweep on SIGINT/SIGTERM and returning would *suppress* the
706
+ * signal: Node defaults to terminating the process on these, but only
707
+ * if no user listener is attached. Once we attach a listener, the
708
+ * default action is cancelled and the process keeps running — which
709
+ * means Ctrl-C would clean worktrees but not actually exit, leaving
710
+ * orphan processes in dev. The `process.kill(pid, sig)` re-raise
711
+ * after removing our own listener restores the default behaviour
712
+ * (the second delivery now hits an empty listener list, so Node
713
+ * terminates with the conventional `128 + signum` exit code).
714
+ */
715
+ function registerExitHandlers(registry) {
716
+ _activeRegistry = registry;
717
+ if (_registered) return;
718
+ _registered = true;
719
+ _exitHandler = () => sweepRegistry();
720
+ _sigintHandler = () => {
721
+ sweepRegistry();
722
+ if (_sigintHandler) process$1.off("SIGINT", _sigintHandler);
723
+ process$1.kill(process$1.pid, "SIGINT");
724
+ };
725
+ _sigtermHandler = () => {
726
+ sweepRegistry();
727
+ if (_sigtermHandler) process$1.off("SIGTERM", _sigtermHandler);
728
+ process$1.kill(process$1.pid, "SIGTERM");
729
+ };
730
+ process$1.on("SIGINT", _sigintHandler);
731
+ process$1.on("SIGTERM", _sigtermHandler);
732
+ process$1.on("exit", _exitHandler);
733
+ }
734
+ function ledgerPath() {
735
+ return path.join(PATHS.APP_DIR, "worker-repos.json");
736
+ }
737
+ async function readLedger() {
738
+ let raw;
739
+ try {
740
+ raw = await fs.readFile(ledgerPath(), "utf8");
741
+ } catch (err) {
742
+ if (err.code === "ENOENT") return { entries: [] };
743
+ return { entries: [] };
744
+ }
745
+ try {
746
+ const parsed = JSON.parse(raw);
747
+ if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] };
748
+ const cleaned = [];
749
+ for (const e of parsed.entries) if (e && typeof e === "object" && typeof e.repoRoot === "string" && typeof e.lastSeenMs === "number") cleaned.push({
750
+ repoRoot: e.repoRoot,
751
+ lastSeenMs: e.lastSeenMs
752
+ });
753
+ return { entries: cleaned };
754
+ } catch {
755
+ return { entries: [] };
756
+ }
757
+ }
758
+ /**
759
+ * Per-process serializer for ledger writes. Multiple concurrent
760
+ * `recordWorkerRepo` calls (legitimate: several workers may start at
761
+ * once) would otherwise race read-modify-write on the JSON file. Each
762
+ * call chains onto the previous so the on-disk sequence is
763
+ * deterministic from this process's perspective.
764
+ *
765
+ * Cross-process safety is provided by the atomic temp+rename below,
766
+ * which makes the final state of the file always be a well-formed
767
+ * full snapshot from ONE writer — never a partial write or
768
+ * interleaved JSON.
769
+ */
770
+ let _ledgerChain = Promise.resolve();
771
+ /**
772
+ * Append `repoRoot` to the ledger (or update its `lastSeenMs`).
773
+ * Atomic temp+rename per peer review.
774
+ */
775
+ function recordWorkerRepo(repoRoot) {
776
+ const next = _ledgerChain.then(async () => {
777
+ await fs.mkdir(PATHS.APP_DIR, { recursive: true });
778
+ const filtered = (await readLedger()).entries.filter((e) => e.repoRoot !== repoRoot);
779
+ filtered.push({
780
+ repoRoot,
781
+ lastSeenMs: Date.now()
782
+ });
783
+ const now = Date.now();
784
+ const ledger = { entries: filtered.filter((e) => now - e.lastSeenMs < LEDGER_MAX_AGE_MS).slice(-LEDGER_MAX_ENTRIES) };
785
+ const tmp = `${ledgerPath()}.tmp.${process$1.pid}.${randomBytes(4).toString("hex")}`;
786
+ try {
787
+ await writeRuntimeFileSecure(tmp, JSON.stringify(ledger, null, 2));
788
+ await fs.rename(tmp, ledgerPath());
789
+ } catch (err) {
790
+ await fs.unlink(tmp).catch(() => {});
791
+ throw err;
792
+ }
793
+ });
794
+ _ledgerChain = next.catch(() => void 0);
795
+ return next;
796
+ }
797
+ function isPidAlive(pid) {
798
+ if (!Number.isInteger(pid) || pid <= 0) return false;
799
+ try {
800
+ process$1.kill(pid, 0);
801
+ return true;
802
+ } catch (err) {
803
+ if (err.code === "EPERM") return true;
804
+ return false;
805
+ }
806
+ }
807
+ /**
808
+ * Boot-time sweep. For every repo we recorded in the ledger,
809
+ * enumerate `<repoRoot>/.git/worker-worktrees/` (the conventional
810
+ * location — for repos already inside a worktree, the actual
811
+ * `git-common-dir` may differ, in which case we'll miss this batch
812
+ * and the per-call age sweep will catch them within 7 days) and
813
+ * remove dirs that aren't owned by THIS proxy.
814
+ *
815
+ * Ownership rule: dir is "ours" iff its embedded PID is alive AND
816
+ * its embedded UUID equals `getInstanceUuid()`. Either condition
817
+ * failing → remove.
818
+ */
819
+ async function sweepStaleWorktreesAtBoot() {
820
+ const ledger = await readLedger();
821
+ if (ledger.entries.length === 0) return;
822
+ const currentUuid = getInstanceUuid();
823
+ for (const entry of ledger.entries) {
824
+ const parent = path.join(entry.repoRoot, ".git", "worker-worktrees");
825
+ let names;
826
+ try {
827
+ names = await fs.readdir(parent);
828
+ } catch {
829
+ continue;
830
+ }
831
+ for (const name of names) {
832
+ const m = WORKTREE_DIR_NAME_RE.exec(name);
833
+ if (!m) continue;
834
+ const pid = Number.parseInt(m[1], 10);
835
+ const uuid = m[2];
836
+ if (isPidAlive(pid) && uuid === currentUuid) continue;
837
+ const fullDir = path.join(parent, name);
838
+ const branch = `worker/${pid}-${uuid}-${m[3]}`;
839
+ try {
840
+ execFileSync("git", [
841
+ "-C",
842
+ entry.repoRoot,
843
+ "worktree",
844
+ "remove",
845
+ "--force",
846
+ fullDir
847
+ ], {
848
+ stdio: "ignore",
849
+ timeout: 1e4,
850
+ windowsHide: true
851
+ });
852
+ } catch {}
853
+ try {
854
+ execFileSync("git", [
855
+ "-C",
856
+ entry.repoRoot,
857
+ "branch",
858
+ "-D",
859
+ branch
860
+ ], {
861
+ stdio: "ignore",
862
+ timeout: 5e3,
863
+ windowsHide: true
864
+ });
865
+ } catch {}
866
+ try {
867
+ await fs.rm(fullDir, {
868
+ recursive: true,
869
+ force: true
870
+ });
871
+ } catch {}
872
+ }
873
+ }
874
+ }
875
+
876
+ //#endregion
877
+ export { sweepRegistry as a, ensureClaudeConfigMirror as c, writeRuntimeFileSecure as d, registerExitHandlers as i, ensurePaths as l, getInstanceUuid as n, sweepStaleWorktreesAtBoot as o, recordWorkerRepo as r, PATHS as s, WorktreeRegistry as t, removeOwnClaudeConfigMirror as u };
878
+ //# sourceMappingURL=lifecycle-BrNqqJZH.js.map