github-router 0.3.28 → 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.
- package/dist/lifecycle-BrNqqJZH.js +878 -0
- package/dist/lifecycle-BrNqqJZH.js.map +1 -0
- package/dist/lifecycle-De6QsSv8.js +3 -0
- package/dist/main.js +6865 -3551
- package/dist/main.js.map +1 -1
- package/package.json +5 -1
|
@@ -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
|