claude-bridge-cli 2.0.11 → 2.0.17

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.
Files changed (5) hide show
  1. package/README.md +74 -74
  2. package/bin/cli.js +391 -391
  3. package/lib/bridge.js +1127 -1019
  4. package/lib/relay-client.js +152 -152
  5. package/package.json +34 -26
package/lib/bridge.js CHANGED
@@ -1,1019 +1,1127 @@
1
- "use strict";
2
-
3
- const http = require("node:http");
4
- const { spawn, execSync } = require("node:child_process");
5
- const fs = require("node:fs");
6
- const path = require("node:path");
7
- const crypto = require("node:crypto");
8
- const os = require("node:os");
9
-
10
- const running = new Map();
11
-
12
- function homeDir() {
13
- return process.env.HOME || process.env.USERPROFILE || os.homedir();
14
- }
15
-
16
- function dataDir() {
17
- const d = path.join(homeDir(), ".claude-bridge");
18
- fs.mkdirSync(d, { recursive: true });
19
- return d;
20
- }
21
-
22
- function readJson(filePath, def) {
23
- try { return JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { return def; }
24
- }
25
- function writeJson(filePath, data) {
26
- const tmp = filePath + ".tmp";
27
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
28
- fs.renameSync(tmp, filePath);
29
- }
30
-
31
- function projectsDir() {
32
- return path.join(homeDir(), ".claude", "projects");
33
- }
34
-
35
- // ── Persisted turn-completion markers ──────────────────────────────────────
36
- // A turn is "complete" ONLY when its claude process actually resolved with a
37
- // parseable result. We persist that fact to disk (keyed by session id) so the
38
- // completion signal survives a bridge restart. Without it, getSessionMessages
39
- // falls back to the in-memory `running` set — which is wiped on restart — and
40
- // then promotes the trailing JSONL assistant line to `final_text`. For a turn
41
- // orphaned by a restart, that trailing line is often a mid-turn tool-call
42
- // preamble ("Running the type-check: I'll report once…") which then gets
43
- // mis-rendered as the final answer. The marker lets us tell genuine completion
44
- // apart from an interrupted/orphaned turn.
45
- function completionDir() {
46
- const d = path.join(dataDir(), "completions");
47
- fs.mkdirSync(d, { recursive: true });
48
- return d;
49
- }
50
- function completionMarkerPath(sid) {
51
- return path.join(completionDir(), encodeURIComponent(sid) + ".json");
52
- }
53
- function writeCompletionMarker(sid, finalText) {
54
- if (!sid || sid === "pending") return;
55
- try {
56
- writeJson(completionMarkerPath(sid), {
57
- session_id: sid,
58
- stop_reason: "end_turn",
59
- final_text: typeof finalText === "string" ? finalText : "",
60
- completed_at: Date.now(),
61
- });
62
- } catch {}
63
- // Best-effort prune of stale markers (>30d) so the dir can't grow forever.
64
- try {
65
- const dir = completionDir();
66
- const cutoff = Date.now() - 30 * 24 * 3600 * 1000;
67
- for (const f of fs.readdirSync(dir)) {
68
- const fp = path.join(dir, f);
69
- try { if (fs.statSync(fp).mtimeMs < cutoff) fs.unlinkSync(fp); } catch {}
70
- }
71
- } catch {}
72
- }
73
- function readCompletionMarker(sid) {
74
- if (!sid) return null;
75
- return readJson(completionMarkerPath(sid), null);
76
- }
77
-
78
- // ── Image handling ──
79
-
80
- const ALLOWED_IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp"]);
81
- const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
82
-
83
- function sanitizeFilename(name, fallbackExt = "png") {
84
- let base = path.basename(name || "image").replace(/[^a-zA-Z0-9._-]/g, "_");
85
- const ext = path.extname(base).slice(1).toLowerCase();
86
- if (!ext || !ALLOWED_IMAGE_EXTS.has(ext)) base += "." + fallbackExt;
87
- return base.slice(0, 200);
88
- }
89
-
90
- function saveImages(images, sessionId) {
91
- if (!Array.isArray(images) || !images.length) return [];
92
- const dir = path.join(dataDir(), "images", sessionId || "unsorted");
93
- fs.mkdirSync(dir, { recursive: true });
94
- const saved = [];
95
- const ts = Date.now();
96
- for (let i = 0; i < images.length; i++) {
97
- const img = images[i];
98
- if (!img || !img.data_base64) continue;
99
- const buf = Buffer.from(img.data_base64, "base64");
100
- if (buf.length > MAX_IMAGE_BYTES) continue;
101
- const name = sanitizeFilename(img.name || `image-${i}.png`);
102
- const filename = `${ts}-${i}-${name}`;
103
- const filePath = path.join(dir, filename);
104
- fs.writeFileSync(filePath, buf, { mode: 0o600 });
105
- saved.push(filePath);
106
- }
107
- return saved;
108
- }
109
-
110
- // ── Per-session file exchange (the Files drawer) ──────────────────────────
111
- // Same per-session folder used for images doubles as a two-way file drawer:
112
- // the user uploads ANY file here (Claude reads it), and Claude drops files here
113
- // (the user downloads them). Old files pruned on access.
114
- const MAX_FILE_BYTES = 45 * 1024 * 1024;
115
- const FILE_PRUNE_DAYS = 14;
116
- const FILE_NAME_RE = /^[A-Za-z0-9._ ()+\-]+$/;
117
- const EXT_MIME = {
118
- drawio: "application/xml", pdf: "application/pdf", json: "application/json",
119
- csv: "text/csv", txt: "text/plain", md: "text/markdown", xml: "application/xml",
120
- svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
121
- gif: "image/gif", webp: "image/webp", zip: "application/zip", gz: "application/gzip",
122
- tar: "application/x-tar", html: "text/html", css: "text/css", js: "text/javascript",
123
- ts: "text/plain", py: "text/x-python", cs: "text/plain", java: "text/x-java",
124
- yml: "text/yaml", yaml: "text/yaml", sql: "text/plain", log: "text/plain", sh: "text/x-sh",
125
- };
126
- function mimeFor(name) {
127
- const e = path.extname(name || "").slice(1).toLowerCase();
128
- return EXT_MIME[e] || "application/octet-stream";
129
- }
130
- function sanitizeAnyFilename(name) {
131
- let base = path.basename((name || "").trim())
132
- .replace(/[^A-Za-z0-9._ ()+\-]/g, "_")
133
- .replace(/^[._ ]+|[._ ]+$/g, "");
134
- return (base || "upload.bin").slice(0, 200);
135
- }
136
- function sessionFilesDir(sid) {
137
- if (!/^[A-Za-z0-9._-]+$/.test(sid || "")) throw new Error("bad session id");
138
- return path.join(dataDir(), "images", sid);
139
- }
140
- function pruneSessionFiles(dir) {
141
- const cutoff = Date.now() - FILE_PRUNE_DAYS * 86400 * 1000;
142
- let entries; try { entries = fs.readdirSync(dir); } catch { return; }
143
- for (const f of entries) {
144
- const fp = path.join(dir, f);
145
- try { const st = fs.statSync(fp); if (st.isFile() && st.mtimeMs < cutoff) fs.unlinkSync(fp); } catch {}
146
- }
147
- }
148
- function listSessionFiles(sid) {
149
- const dir = sessionFilesDir(sid);
150
- pruneSessionFiles(dir);
151
- let entries; try { entries = fs.readdirSync(dir); } catch { return []; }
152
- const out = [];
153
- for (const f of entries.sort()) {
154
- const fp = path.join(dir, f);
155
- try {
156
- const st = fs.statSync(fp);
157
- if (st.isFile()) out.push({ name: f, size: st.size, mtime: Math.floor(st.mtimeMs / 1000), mime: mimeFor(f) });
158
- } catch {}
159
- }
160
- return out;
161
- }
162
- function readSessionFile(sid, name) {
163
- const dir = sessionFilesDir(sid);
164
- const safe = path.basename(name || "");
165
- if (!safe || safe !== name || !FILE_NAME_RE.test(safe)) throw new Error("bad file name");
166
- const fp = path.join(dir, safe);
167
- if (!fs.existsSync(fp) || !fs.statSync(fp).isFile()) { const e = new Error("file not found"); e.notFound = true; throw e; }
168
- if (fs.statSync(fp).size > MAX_FILE_BYTES) throw new Error("file exceeds limit — too large to transfer");
169
- const buf = fs.readFileSync(fp);
170
- return { name: safe, size: buf.length, mime: mimeFor(safe), data_base64: buf.toString("base64") };
171
- }
172
- function saveSessionFile(sid, name, dataB64) {
173
- const dir = sessionFilesDir(sid);
174
- fs.mkdirSync(dir, { recursive: true });
175
- pruneSessionFiles(dir);
176
- if (typeof dataB64 !== "string") throw new Error("missing data_base64");
177
- const mm = dataB64.match(/^data:[^;]+;base64,([\s\S]+)$/);
178
- if (mm) dataB64 = mm[1];
179
- const buf = Buffer.from(dataB64, "base64");
180
- if (buf.length > MAX_FILE_BYTES) throw new Error("file too large");
181
- const final = `${Date.now()}-${sanitizeAnyFilename(name || "upload.bin")}`;
182
- const fp = path.join(dir, final);
183
- fs.writeFileSync(fp, buf, { mode: 0o600 });
184
- return { name: final, size: buf.length, mime: mimeFor(final), path: fp };
185
- }
186
- function deleteSessionFile(sid, name) {
187
- const dir = sessionFilesDir(sid);
188
- const safe = path.basename(name || "");
189
- if (!safe || safe !== name || !FILE_NAME_RE.test(safe)) throw new Error("bad file name");
190
- const fp = path.join(dir, safe);
191
- if (!fs.existsSync(fp) || !fs.statSync(fp).isFile()) { const e = new Error("file not found"); e.notFound = true; throw e; }
192
- fs.unlinkSync(fp);
193
- return { deleted: safe };
194
- }
195
-
196
- function splitUserTextAndImages(text) {
197
- const m = text.match(/\nThe user attached \d+ image\(s\) at these absolute paths\. Use the Read tool to view them:\n([\s\S]+)$/);
198
- if (!m) return { cleanText: text, imagePaths: [] };
199
- const cleanText = text.slice(0, m.index).trimEnd();
200
- const paths = m[1].split("\n").map(l => l.replace(/^- /, "").trim()).filter(Boolean);
201
- const imageData = [];
202
- for (const p of paths) {
203
- try {
204
- const buf = fs.readFileSync(p);
205
- const ext = path.extname(p).slice(1).toLowerCase();
206
- const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg"
207
- : ext === "gif" ? "image/gif"
208
- : ext === "webp" ? "image/webp"
209
- : "image/png";
210
- imageData.push({ name: path.basename(p), data_base64: buf.toString("base64"), mime });
211
- } catch {}
212
- }
213
- return { cleanText, imagePaths: paths, imageData };
214
- }
215
-
216
- // ── Session scanning ──
217
-
218
- function scanSessionFiles() {
219
- const base = projectsDir();
220
- if (!fs.existsSync(base)) return [];
221
- const results = [];
222
- let dirs;
223
- try { dirs = fs.readdirSync(base); } catch { return []; }
224
- for (const project of dirs) {
225
- const projDir = path.join(base, project);
226
- let stat;
227
- try { stat = fs.statSync(projDir); } catch { continue; }
228
- if (!stat.isDirectory()) continue;
229
- let files;
230
- try { files = fs.readdirSync(projDir); } catch { continue; }
231
- for (const file of files) {
232
- if (!file.endsWith(".jsonl")) continue;
233
- const id = file.replace(".jsonl", "");
234
- const filePath = path.join(projDir, file);
235
- results.push({ id, project, filePath });
236
- }
237
- }
238
- return results;
239
- }
240
-
241
- function looksLikeInternal(text) {
242
- if (!text) return false;
243
- const t = text.slice(0, 500);
244
- return /^\s*\{/.test(t) && /"tool_use_id"|"tool_result"|"is_error"/.test(t);
245
- }
246
-
247
- function parseSessionFile(filePath) {
248
- let preview = "", aiTitle = "", customTitle = "", msgCount = 0, lastPrompt = "", cwd = "";
249
- try {
250
- // For listing, read just the first 64KB and the last 64KB.
251
- // First chunk: get preview + ai_title from early events.
252
- // Last chunk: get last_prompt and custom-title overrides (latest wins).
253
- // Message count is approximated from file size.
254
- const stat = fs.statSync(filePath);
255
- const CHUNK = 64 * 1024;
256
- const fd = fs.openSync(filePath, "r");
257
- try {
258
- const headBuf = Buffer.alloc(Math.min(CHUNK, stat.size));
259
- fs.readSync(fd, headBuf, 0, headBuf.length, 0);
260
- const head = headBuf.toString("utf8");
261
- for (const line of head.split("\n")) {
262
- if (!line) continue;
263
- try {
264
- const obj = JSON.parse(line);
265
- // The real working directory is recorded on the session's events;
266
- // capture the first one. Far more accurate than decoding the project
267
- // dir name (which is lossy — it can't recover ":" or distinguish a
268
- // path separator from a literal "-", e.g. Windows "C:\GIT\…").
269
- if (!cwd && typeof obj.cwd === "string" && obj.cwd) cwd = obj.cwd;
270
- if (obj.type === "summary" && obj.summary) preview = preview || obj.summary.slice(0, 200);
271
- if (obj.type === "user" && obj.message?.content) {
272
- const text = typeof obj.message.content === "string" ? obj.message.content : JSON.stringify(obj.message.content);
273
- if (!preview && !looksLikeInternal(text)) preview = text.slice(0, 200);
274
- }
275
- if (obj.type === "result" && obj.result?.metadata?.title?.value) aiTitle = aiTitle || obj.result.metadata.title.value;
276
- if (obj.type === "ai-title") aiTitle = obj.aiTitle || obj.title || aiTitle;
277
- if (obj.type === "custom-title") customTitle = obj.customTitle || obj.title || customTitle;
278
- } catch {}
279
- }
280
- if (stat.size > CHUNK) {
281
- const tailBuf = Buffer.alloc(CHUNK);
282
- fs.readSync(fd, tailBuf, 0, CHUNK, stat.size - CHUNK);
283
- const tail = tailBuf.toString("utf8");
284
- const tailLines = tail.split("\n");
285
- if (tailLines.length > 1) tailLines.shift();
286
- for (const line of tailLines) {
287
- if (!line) continue;
288
- try {
289
- const obj = JSON.parse(line);
290
- if (obj.type === "user" && obj.message?.content) {
291
- const text = typeof obj.message.content === "string" ? obj.message.content : JSON.stringify(obj.message.content);
292
- if (!looksLikeInternal(text)) lastPrompt = text.slice(0, 200);
293
- }
294
- if (obj.type === "ai-title") aiTitle = obj.aiTitle || obj.title || aiTitle;
295
- if (obj.type === "custom-title") customTitle = obj.customTitle || obj.title || customTitle;
296
- } catch {}
297
- }
298
- }
299
- // Count newlines accurately by streaming chunks.
300
- const COUNT_BUF = Buffer.alloc(64 * 1024);
301
- let pos = 0;
302
- while (pos < stat.size) {
303
- const got = fs.readSync(fd, COUNT_BUF, 0, COUNT_BUF.length, pos);
304
- if (got <= 0) break;
305
- for (let i = 0; i < got; i++) if (COUNT_BUF[i] === 0x0A) msgCount++;
306
- pos += got;
307
- }
308
- } finally {
309
- fs.closeSync(fd);
310
- }
311
- } catch {}
312
- return { preview, ai_title: customTitle || aiTitle, message_count: msgCount, last_prompt: lastPrompt, cwd };
313
- }
314
-
315
- function projectDirToCwd(name) {
316
- return name.replace(/-/g, "/");
317
- }
318
-
319
- function listSessions(opts = {}) {
320
- const dd = dataDir();
321
- const files = scanSessionFiles();
322
- const titleOverrides = readJson(path.join(dd, "title-overrides.json"), {});
323
- const starred = new Set(readJson(path.join(dd, "starred.json"), []));
324
- const sessions = [];
325
-
326
- for (const { id, project, filePath } of files) {
327
- let stat;
328
- try { stat = fs.statSync(filePath); } catch { continue; }
329
- const parsed = parseSessionFile(filePath);
330
- if (!opts.includeTiny && parsed.message_count < 3) continue;
331
- if (opts.project && project !== opts.project) continue;
332
- const title = titleOverrides[id] || parsed.ai_title;
333
- sessions.push({
334
- id, project,
335
- cwd: parsed.cwd || projectDirToCwd(project),
336
- mtime: stat.mtimeMs / 1000,
337
- mtime_iso: stat.mtime.toISOString(),
338
- preview: parsed.preview,
339
- ai_title: title,
340
- message_count: parsed.message_count,
341
- last_prompt: parsed.last_prompt,
342
- size_bytes: stat.size,
343
- starred: starred.has(id),
344
- in_progress: running.has(id),
345
- });
346
- }
347
-
348
- sessions.sort((a, b) => (b.starred ? 1 : 0) - (a.starred ? 1 : 0) || b.mtime - a.mtime);
349
- return sessions.slice(0, opts.limit || 400);
350
- }
351
-
352
- // ── Search ──
353
-
354
- function searchSessions(query, limit = 30) {
355
- if (!query || query.length < 2) return [];
356
- const q = query.toLowerCase();
357
- const dd = dataDir();
358
- const titleOverrides = readJson(path.join(dd, "title-overrides.json"), {});
359
- const starred = new Set(readJson(path.join(dd, "starred.json"), []));
360
- const files = scanSessionFiles();
361
-
362
- files.sort((a, b) => {
363
- try {
364
- return fs.statSync(b.filePath).mtimeMs - fs.statSync(a.filePath).mtimeMs;
365
- } catch { return 0; }
366
- });
367
-
368
- const results = [];
369
- for (const { id, project, filePath } of files.slice(0, 200)) {
370
- let stat;
371
- try { stat = fs.statSync(filePath); } catch { continue; }
372
- let content;
373
- try { content = fs.readFileSync(filePath, "utf8"); } catch { continue; }
374
-
375
- const lower = content.toLowerCase();
376
- const idx = lower.indexOf(q);
377
- if (idx < 0) continue;
378
-
379
- const matchCount = lower.split(q).length - 1;
380
- const snippetStart = Math.max(0, idx - 40);
381
- const snippetEnd = Math.min(content.length, idx + q.length + 80);
382
- const snippet = content.slice(snippetStart, snippetEnd).replace(/\n/g, " ").trim();
383
-
384
- let aiTitle = "", msgCount = 0, realCwd = "";
385
- try {
386
- const lines = content.split("\n").filter(Boolean);
387
- msgCount = lines.length;
388
- for (const line of lines) {
389
- try {
390
- const obj = JSON.parse(line);
391
- if (!realCwd && typeof obj.cwd === "string" && obj.cwd) realCwd = obj.cwd;
392
- if (obj.type === "result" && obj.result?.metadata?.title?.value) {
393
- aiTitle = obj.result.metadata.title.value;
394
- }
395
- if (obj.type === "ai-title" && obj.title) aiTitle = obj.title;
396
- if (obj.type === "custom-title" && obj.title) aiTitle = obj.title;
397
- } catch {}
398
- }
399
- } catch {}
400
-
401
- results.push({
402
- id, project,
403
- cwd: realCwd || projectDirToCwd(project),
404
- mtime: stat.mtimeMs / 1000,
405
- mtime_iso: stat.mtime.toISOString(),
406
- ai_title: titleOverrides[id] || aiTitle,
407
- snippet,
408
- match_count: matchCount,
409
- message_count: msgCount,
410
- starred: starred.has(id),
411
- });
412
- if (results.length >= limit) break;
413
- }
414
- return results;
415
- }
416
-
417
- // ── Session messages ──
418
-
419
- const INTERNAL_USER_PATTERNS = [
420
- "<system-reminder>", "<command-name>", "<command-message>",
421
- "<local-command-stdout>", "<command-stderr>", "<local-command-stderr>",
422
- "Caveat: The messages below were generated by",
423
- ];
424
-
425
- function extractUserText(content) {
426
- // Only extract type:"text" parts. tool_result records don't have a text
427
- // part so they return empty string and get filtered out.
428
- let raw = "";
429
- if (typeof content === "string") raw = content;
430
- else if (Array.isArray(content)) {
431
- for (const part of content) {
432
- if (part && typeof part === "object" && part.type === "text") {
433
- raw = part.text || "";
434
- break;
435
- }
436
- }
437
- }
438
- return resolveAtFileRefs(raw);
439
- }
440
-
441
- // When the bridge sends a multi-line prompt to claude on Windows it writes
442
- // the prompt to a temp file and passes "@C:\...\claude-prompt-NNN.txt" as
443
- // the -p arg. claude records that literal @path in the session JSONL as
444
- // the user message. When we read the session back we want to show the
445
- // actual content, not the path — otherwise history looks like a wall of
446
- // temp-file paths. If the file still exists on disk, inline its contents;
447
- // otherwise leave the @path alone so the user at least sees something.
448
- function resolveAtFileRefs(text) {
449
- if (!text || typeof text !== "string") return text;
450
- // Match a leading @<path> that points at a claude-prompt-*.txt temp file.
451
- // Restrict to the temp-file pattern we generate ourselves — never inline
452
- // arbitrary user-typed @file references (which are a real Claude Code
453
- // feature that should remain as-is).
454
- const trimmed = text.trim();
455
- if (!trimmed.startsWith("@")) return text;
456
- const m = trimmed.match(/^@(.+?claude-prompt-\d+-[a-z0-9]+\.txt)\s*$/i);
457
- if (!m) return text;
458
- const filePath = m[1];
459
- try {
460
- if (fs.existsSync(filePath)) {
461
- const body = fs.readFileSync(filePath, "utf8");
462
- if (body && body.length) return body;
463
- }
464
- } catch {}
465
- return text;
466
- }
467
-
468
- function shouldSkipUserText(text) {
469
- if (!text) return true;
470
- const t = text.replace(/^\s+/, "");
471
- if (INTERNAL_USER_PATTERNS.some(p => t.startsWith(p))) return true;
472
- return false;
473
- }
474
-
475
- function getSessionMessages(sessionId) {
476
- const base = projectsDir();
477
- let dirs;
478
- try { dirs = fs.readdirSync(base); } catch { return { error: "session not found" }; }
479
- for (const project of dirs) {
480
- const filePath = path.join(base, project, sessionId + ".jsonl");
481
- if (!fs.existsSync(filePath)) continue;
482
- const messages = [];
483
- for (const line of fs.readFileSync(filePath, "utf8").split("\n").filter(Boolean)) {
484
- try {
485
- const obj = JSON.parse(line);
486
- // Skip transcript-only and meta records (replays, recaps)
487
- if (obj.isMeta === true) continue;
488
- if (obj.isVisibleInTranscriptOnly === true) continue;
489
- if (obj.type === "user" && obj.message?.content) {
490
- const text = extractUserText(obj.message.content);
491
- if (shouldSkipUserText(text)) continue;
492
- const split = splitUserTextAndImages(text);
493
- messages.push({
494
- role: "user",
495
- text: split.cleanText,
496
- images: split.imageData || [],
497
- timestamp: obj.timestamp,
498
- });
499
- } else if (obj.type === "assistant" && obj.message?.content) {
500
- const parts = Array.isArray(obj.message.content) ? obj.message.content : [obj.message.content];
501
- const text = parts.map(p => typeof p === "string" ? p : p.text || "").join("");
502
- if (!text) continue;
503
- // Merge consecutive assistant turns into one (tool_use cycles).
504
- // Track the trailing event's stop_reason — `end_turn`/`stop_sequence`
505
- // means the turn finished; `tool_use` means it stopped on a tool-call
506
- // preamble (i.e. mid-action). This is the authoritative, in-transcript
507
- // completion signal and works for every session, old or new.
508
- const sr = obj.message?.stop_reason || null;
509
- if (messages.length && messages[messages.length - 1].role === "assistant") {
510
- messages[messages.length - 1].text = (messages[messages.length - 1].text + "\n" + text).trim();
511
- messages[messages.length - 1].timestamp = obj.timestamp || messages[messages.length - 1].timestamp;
512
- messages[messages.length - 1]._stop_reason = sr;
513
- } else {
514
- messages.push({ role: "assistant", text, timestamp: obj.timestamp, _stop_reason: sr });
515
- }
516
- } else if (obj.type === "result" && obj.result?.assistantMessage) {
517
- const text = typeof obj.result.assistantMessage === "string" ? obj.result.assistantMessage : "";
518
- if (text) messages.push({ role: "assistant", text, timestamp: obj.timestamp });
519
- }
520
- } catch {}
521
- }
522
- // Match the Python bridge's shape so the app's reconnect/resume loader can
523
- // detect completion. Without `status` (and end-of-turn markers on the
524
- // trailing assistant), the loader never finalizes — it shows "still working"
525
- // forever even though Claude is done (the stuck-loader bug on this bridge).
526
- const inProgress = running.has(sessionId);
527
- const last_event_ts = messages.length ? (messages[messages.length - 1].timestamp || null) : null;
528
- let status = inProgress ? "in_progress" : "complete";
529
- let interrupted = false;
530
- if (!inProgress && messages.length) {
531
- const last = messages[messages.length - 1];
532
- // Completion is decided by two independent signals, either of which is
533
- // sufficient:
534
- // 1. Transcript: the trailing assistant event's stop_reason is
535
- // `end_turn`/`stop_sequence` (claude closed the turn). Authoritative
536
- // and present in every session, so old sessions stay "complete".
537
- // 2. Marker: a persisted completion record exists AND was written no
538
- // earlier than the trailing event (nothing newer ran after it).
539
- // If NEITHER holds — e.g. the trailing line is a `tool_use` preamble and
540
- // no fresh marker exists because the bridge was restarted mid-turn — the
541
- // turn was orphaned. We must NOT fabricate an end_turn on that line.
542
- const transcriptDone = last._stop_reason === "end_turn" || last._stop_reason === "stop_sequence";
543
- const marker = readCompletionMarker(sessionId);
544
- const lastTs = last_event_ts ? Date.parse(last_event_ts) : 0;
545
- const markerGenuine = marker && (!lastTs || lastTs <= (marker.completed_at + 2000));
546
- const genuine = transcriptDone || markerGenuine;
547
- if (last.role === "assistant" && last.text) {
548
- if (genuine) {
549
- last.stop_reason = "end_turn";
550
- last.final_text = last.text;
551
- } else {
552
- // Interrupted/orphaned: finalize honestly instead of passing the
553
- // trailing preamble off as the answer (and instead of leaving the
554
- // client's loader spinning forever with no end marker).
555
- status = "interrupted";
556
- interrupted = true;
557
- last.interrupted = true;
558
- last.stop_reason = "interrupted";
559
- last.final_text = last.text +
560
- "\n\n⚠️ This turn was interrupted before it finished (the bridge was " +
561
- "likely restarted mid-turn). The text above may be a partial step, not " +
562
- "the final result. Reply \"continue\" to resume.";
563
- }
564
- }
565
- }
566
- for (const m of messages) delete m._stop_reason; // internal-only signal
567
- return {
568
- session_id: sessionId,
569
- messages,
570
- in_progress: inProgress,
571
- status,
572
- interrupted,
573
- last_event_ts,
574
- };
575
- }
576
- return { error: "session not found" };
577
- }
578
-
579
- // ── Session delete / wipe ──
580
-
581
- function deleteSession(sessionId) {
582
- const base = projectsDir();
583
- let dirs;
584
- try { dirs = fs.readdirSync(base); } catch { return { error: "not found" }; }
585
- for (const project of dirs) {
586
- const filePath = path.join(base, project, sessionId + ".jsonl");
587
- if (!fs.existsSync(filePath)) continue;
588
- fs.unlinkSync(filePath);
589
- const imgDir = path.join(dataDir(), "images", sessionId);
590
- try { fs.rmSync(imgDir, { recursive: true, force: true }); } catch {}
591
- return { deleted: true, session_id: sessionId };
592
- }
593
- return { error: "not found" };
594
- }
595
-
596
- function wipeAllSessions() {
597
- const base = projectsDir();
598
- let removed = 0;
599
- try {
600
- for (const project of fs.readdirSync(base)) {
601
- const projDir = path.join(base, project);
602
- if (!fs.statSync(projDir).isDirectory()) continue;
603
- for (const file of fs.readdirSync(projDir)) {
604
- if (!file.endsWith(".jsonl")) continue;
605
- try { fs.unlinkSync(path.join(projDir, file)); removed++; } catch {}
606
- }
607
- }
608
- } catch {}
609
- const dd = dataDir();
610
- for (const f of ["marks.json", "bindings.json", "title-overrides.json", "starred.json"]) {
611
- try { fs.unlinkSync(path.join(dd, f)); } catch {}
612
- }
613
- try { fs.rmSync(path.join(dd, "images"), { recursive: true, force: true }); } catch {}
614
- return { removed_jsonls: removed };
615
- }
616
-
617
- // ── Session rename ──
618
-
619
- function renameSession(sessionId, title) {
620
- const dd = dataDir();
621
- const overrides = readJson(path.join(dd, "title-overrides.json"), {});
622
- overrides[sessionId] = (title || "").slice(0, 500);
623
- writeJson(path.join(dd, "title-overrides.json"), overrides);
624
- return { ok: true, title: overrides[sessionId] };
625
- }
626
-
627
- // ── Run claude -p ──
628
-
629
- // Injected into every turn (--append-system-prompt) so Claude never ends a turn
630
- // promising async follow-up it can't keep: a bridge turn is one-shot and atomic —
631
- // nothing re-invokes Claude after it stops, so "I'll report once the build
632
- // completes" is a promise that never resolves (it leaves the UI dead-ended).
633
- const ATOMIC_TURN_PROMPT =
634
- "You are running inside a one-shot, non-interactive bridge: THIS TURN IS ATOMIC. " +
635
- "It ends the moment you stop producing output, and nothing re-invokes you afterward. " +
636
- "You cannot do work in the background, report back later, be re-triggered when an " +
637
- "external job (build/CI/deploy/long command) finishes, or continue on your own. " +
638
- "Therefore NEVER end a turn by promising future follow-up such as 'I'll report once " +
639
- "it completes', 'I'll continue when CI is done', or 'waiting on X to finish'. Instead, " +
640
- "run any work to completion within this turn and report the actual result now. If a " +
641
- "task genuinely cannot finish in this turn, say so plainly and tell the user the exact " +
642
- "command(s) to run or the next message to send to continue — do not imply you will resume.";
643
-
644
- function askClaude(config, { prompt, session_id, images, cwd, plan_mode, allow_tools, model, fork }) {
645
- return new Promise((resolve) => {
646
- let finalPrompt = prompt;
647
- if (images && images.length) {
648
- const saved = saveImages(images, session_id || "pending");
649
- if (saved.length) {
650
- finalPrompt += `\nThe user attached ${saved.length} image(s) at these absolute paths. Use the Read tool to view them:\n` +
651
- saved.map(p => `- ${p}`).join("\n");
652
- }
653
- }
654
-
655
- const isWin = process.platform === "win32";
656
- const isCmdShim = isWin && config.claudeBin.endsWith(".cmd");
657
-
658
- // Pass the prompt via a temp file (@file, read by claude itself) instead of as
659
- // a command-line argument when:
660
- // 1) Prompt exceeds Windows cmd.exe's ~8K arg limit (threshold 6000).
661
- // 2) We're on Windows with a .cmd shim — period. The shim runs through
662
- // `cmd.exe /c`, which RE-PARSES the prompt with CMD's own rules: a double
663
- // quote (") or a metacharacter (% & | < > ^) breaks the command line and
664
- // surfaces as the cryptic "The system cannot find the file specified";
665
- // a newline silently truncates the prompt at line 1. Node's argv quoting
666
- // can't protect against cmd.exe's second parse. Passing @file sidesteps ALL
667
- // of it, so do it for EVERY cmd-shim prompt — not just multi-line ones.
668
- // (Previously only triggered on \r|\n, so quoted single-line prompts failed.)
669
- const useTempFile =
670
- finalPrompt.length > 6000 ||
671
- (isWin && isCmdShim);
672
- let tmpFile = null;
673
- if (useTempFile) {
674
- tmpFile = path.join(os.tmpdir(), `claude-prompt-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
675
- fs.writeFileSync(tmpFile, finalPrompt, "utf8");
676
- }
677
-
678
- const settings = JSON.stringify({
679
- permissions: {
680
- defaultMode: "acceptEdits",
681
- allow: ["Read", "Write", "Edit", "Bash", "Grep", "Glob", "WebFetch", "WebSearch",
682
- "Task", "TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "NotebookEdit"],
683
- deny: ["AskUserQuestion"],
684
- }
685
- });
686
- const promptArg = useTempFile ? `@${tmpFile}` : finalPrompt;
687
- const args = ["-p", promptArg, "--output-format", "json",
688
- "--settings", settings, "--permission-mode", "acceptEdits",
689
- "--append-system-prompt", ATOMIC_TURN_PROMPT];
690
- let resumeCwd = null;
691
- if (session_id) {
692
- const existing = scanSessionFiles().find(s => s.id === session_id);
693
- if (existing) {
694
- args.push("--resume", session_id);
695
- // Fork: replay this session's history into a BRAND-NEW session id instead
696
- // of appending to the original. claude returns the new id in the result,
697
- // which the client adopts (leaving the original untouched). --fork-session
698
- // only works alongside --resume.
699
- if (fork) args.push("--fork-session");
700
- // Scan JSONL for the first event with a cwd field
701
- try {
702
- const lines = fs.readFileSync(existing.filePath, "utf8").split("\n");
703
- for (const line of lines) {
704
- if (!line) continue;
705
- try {
706
- const parsed = JSON.parse(line);
707
- if (parsed.cwd) { resumeCwd = parsed.cwd; break; }
708
- } catch {}
709
- }
710
- } catch {}
711
- } else {
712
- args.push("--session-id", session_id);
713
- }
714
- }
715
- if (plan_mode) args.push("--plan");
716
- // Optional model override from the extension's model picker (alias or id).
717
- if (model && String(model).trim()) args.push("--model", String(model).trim());
718
-
719
- const bin = isCmdShim ? process.env.COMSPEC || "cmd.exe" : config.claudeBin;
720
- const fullArgs = isCmdShim ? ["/c", config.claudeBin, ...args] : args;
721
-
722
- const proc = spawn(bin, fullArgs, {
723
- cwd: cwd || resumeCwd || config.cwd,
724
- timeout: config.timeout,
725
- env: { ...process.env },
726
- stdio: ["ignore", "pipe", "pipe"],
727
- windowsHide: true,
728
- });
729
-
730
- let stdout = "", stderr = "";
731
- proc.stdout.on("data", (d) => { stdout += d; });
732
- proc.stderr.on("data", (d) => { stderr += d; });
733
-
734
- if (session_id) running.set(session_id, proc);
735
-
736
- proc.on("close", (code) => {
737
- if (tmpFile) { try { fs.unlinkSync(tmpFile); } catch {} }
738
- if (session_id) running.delete(session_id);
739
- try {
740
- const result = JSON.parse(stdout);
741
- const sid = result.session_id || session_id || crypto.randomUUID();
742
- // Persist genuine completion so it survives a bridge restart. This is
743
- // the ONLY place a clean end-of-turn is recorded the error/partial
744
- // branch below deliberately writes no marker, so an interrupted turn
745
- // stays distinguishable.
746
- const finalText = result.result || result.assistantMessage || "";
747
- writeCompletionMarker(sid, finalText);
748
- if (session_id && session_id !== sid) writeCompletionMarker(session_id, finalText);
749
- if (images && images.length && session_id === "pending" && sid !== "pending") {
750
- const oldDir = path.join(dataDir(), "images", "pending");
751
- const newDir = path.join(dataDir(), "images", sid);
752
- try { fs.renameSync(oldDir, newDir); } catch {}
753
- }
754
- resolve({
755
- response: result.result || result.assistantMessage || stdout.slice(0, 5000),
756
- session_id: sid,
757
- cost_usd: result.cost_usd || null,
758
- duration_ms: result.duration_ms || null,
759
- context: result.context || null,
760
- });
761
- } catch {
762
- if (stdout.trim()) {
763
- resolve({ response: stdout.trim(), session_id: session_id || crypto.randomUUID() });
764
- } else {
765
- resolve({ error: stderr.trim() || `claude exited with code ${code}` });
766
- }
767
- }
768
- });
769
-
770
- proc.on("error", (err) => {
771
- if (tmpFile) { try { fs.unlinkSync(tmpFile); } catch {} }
772
- if (session_id) running.delete(session_id);
773
- resolve({ error: err.message });
774
- });
775
- });
776
- }
777
-
778
- // ── Stop session ──
779
-
780
- function stopSession(sessionId) {
781
- const proc = running.get(sessionId);
782
- if (!proc) return { stopped: false, reason: "not_running" };
783
- if (process.platform === "win32" && proc.pid) {
784
- try { execSync(`taskkill /f /t /pid ${proc.pid}`, { stdio: "ignore" }); } catch {}
785
- } else {
786
- try { proc.kill("SIGINT"); } catch {}
787
- setTimeout(() => { try { proc.kill("SIGTERM"); } catch {} }, 3000);
788
- }
789
- running.delete(sessionId);
790
- return { stopped: true };
791
- }
792
-
793
- // ── HTTP server ──
794
-
795
- function startBridge(config) {
796
- const dd = dataDir();
797
-
798
- const server = http.createServer(async (req, res) => {
799
- res.setHeader("Access-Control-Allow-Origin", "*");
800
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
801
- res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Confirm-Wipe");
802
- if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
803
-
804
- const send = (code, obj) => {
805
- res.writeHead(code, { "Content-Type": "application/json" });
806
- res.end(JSON.stringify(obj));
807
- };
808
-
809
- const auth = req.headers.authorization || "";
810
- if (req.url !== "/health" && auth !== `Bearer ${config.bearerToken}`) {
811
- send(401, { error: "unauthorized" });
812
- return;
813
- }
814
-
815
- const url = new URL(req.url, `http://${req.headers.host}`);
816
- let m;
817
-
818
- // Health
819
- if (url.pathname === "/health") {
820
- send(200, { ok: true }); return;
821
- }
822
-
823
- // Ask
824
- if (url.pathname === "/ask" && req.method === "POST") {
825
- const body = await readBody(req);
826
- const result = await askClaude(config, body);
827
- send(result.error ? 500 : 200, result);
828
- return;
829
- }
830
-
831
- // Sessions list
832
- if (url.pathname === "/sessions" && req.method === "GET") {
833
- const includeTiny = url.searchParams.get("include_tiny") === "1";
834
- const project = url.searchParams.get("project") || "";
835
- const sessions = listSessions({ includeTiny, project });
836
- send(200, { sessions });
837
- return;
838
- }
839
-
840
- // Search
841
- if (url.pathname === "/sessions/search" && req.method === "GET") {
842
- const q = url.searchParams.get("q") || "";
843
- const limit = parseInt(url.searchParams.get("limit") || "30", 10);
844
- const results = searchSessions(q, limit);
845
- send(200, { results });
846
- return;
847
- }
848
-
849
- // Starred list
850
- if (url.pathname === "/sessions/starred" && req.method === "GET") {
851
- send(200, { starred: readJson(path.join(dd, "starred.json"), []) });
852
- return;
853
- }
854
-
855
- // Wipe all sessions
856
- if (url.pathname === "/sessions/all" && req.method === "DELETE") {
857
- if (req.headers["x-confirm-wipe"] !== "yes-i-am-sure") {
858
- send(400, { error: "missing X-Confirm-Wipe header" });
859
- return;
860
- }
861
- send(200, wipeAllSessions());
862
- return;
863
- }
864
-
865
- // Session messages
866
- m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/messages$/);
867
- if (m && req.method === "GET") {
868
- const result = getSessionMessages(m[1]);
869
- send(result.error ? 404 : 200, result);
870
- return;
871
- }
872
-
873
- // Star toggle
874
- m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/star$/);
875
- if (m && req.method === "POST") {
876
- const starred = readJson(path.join(dd, "starred.json"), []);
877
- const sid = m[1];
878
- const idx = starred.indexOf(sid);
879
- if (idx >= 0) { starred.splice(idx, 1); writeJson(path.join(dd, "starred.json"), starred); send(200, { starred: false, session_id: sid }); }
880
- else { starred.unshift(sid); writeJson(path.join(dd, "starred.json"), starred); send(200, { starred: true, session_id: sid }); }
881
- return;
882
- }
883
-
884
- // Stop
885
- m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/stop$/);
886
- if (m && req.method === "POST") {
887
- send(200, stopSession(m[1]));
888
- return;
889
- }
890
-
891
- // Marks
892
- m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/marks$/);
893
- if (m) {
894
- const marksFile = path.join(dd, "marks.json");
895
- const allMarks = readJson(marksFile, {});
896
- if (req.method === "GET") {
897
- send(200, { marks: allMarks[m[1]] || {} });
898
- return;
899
- }
900
- if (req.method === "PATCH" || req.method === "POST") {
901
- const body = await readBody(req);
902
- const sess = allMarks[m[1]] || {};
903
- if (body.key && body.mark) {
904
- sess[body.key] = { ...(sess[body.key] || {}), ...body.mark };
905
- } else {
906
- for (const [k, v] of Object.entries(body)) {
907
- sess[k] = { ...(sess[k] || {}), ...v };
908
- }
909
- }
910
- allMarks[m[1]] = sess;
911
- writeJson(marksFile, allMarks);
912
- send(200, { ok: true });
913
- return;
914
- }
915
- }
916
-
917
- // AutoMode cross-device state + single-runner lease (per session, like marks)
918
- m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/automode$/);
919
- if (m) {
920
- const amFile = path.join(dd, "automode-state.json");
921
- const allAm = readJson(amFile, {});
922
- if (req.method === "GET") {
923
- send(200, { automode: allAm[m[1]] || {} });
924
- return;
925
- }
926
- if (req.method === "PATCH" || req.method === "POST") {
927
- const body = await readBody(req);
928
- const allowed = ["to_claude", "to_gpt", "gpt_ready", "claude_ready", "active_client", "active_ts"];
929
- const cur = allAm[m[1]] || {};
930
- for (const k of allowed) if (k in body) cur[k] = body[k];
931
- allAm[m[1]] = cur;
932
- writeJson(amFile, allAm);
933
- send(200, { automode: cur });
934
- return;
935
- }
936
- }
937
-
938
- // Title rename
939
- m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/title$/);
940
- if (m && (req.method === "PATCH" || req.method === "POST")) {
941
- const body = await readBody(req);
942
- send(200, renameSession(m[1], body.title));
943
- return;
944
- }
945
-
946
- // Delete single session
947
- m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)$/);
948
- if (m && req.method === "DELETE") {
949
- const result = deleteSession(m[1]);
950
- send(result.error ? 404 : 200, result);
951
- return;
952
- }
953
-
954
- // ChatGPT bindings
955
- if (url.pathname === "/chatgpt-bindings") {
956
- const bindingsFile = path.join(dd, "bindings.json");
957
- if (req.method === "GET") {
958
- send(200, { bindings: readJson(bindingsFile, {}) });
959
- return;
960
- }
961
- if (req.method === "POST") {
962
- const body = await readBody(req);
963
- const bindings = readJson(bindingsFile, {});
964
- if (body.conv_id) {
965
- if (body.session_id) bindings[body.conv_id] = body.session_id;
966
- else delete bindings[body.conv_id];
967
- writeJson(bindingsFile, bindings);
968
- }
969
- send(200, { bindings });
970
- return;
971
- }
972
- }
973
-
974
- // Session files — list (GET) / upload (POST)
975
- m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/files$/);
976
- if (m) {
977
- if (req.method === "GET") {
978
- try { send(200, { files: listSessionFiles(m[1]) }); } catch (e) { send(400, { error: String(e.message || e) }); }
979
- return;
980
- }
981
- if (req.method === "POST") {
982
- const body = await readBody(req);
983
- try { send(200, saveSessionFile(m[1], body.name, body.data_base64 || body.data)); }
984
- catch (e) { send(400, { error: String(e.message || e) }); }
985
- return;
986
- }
987
- }
988
- // Session files download one (GET) / remove one (DELETE)
989
- m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/files\/(.+)$/);
990
- if (m && req.method === "GET") {
991
- try { send(200, readSessionFile(m[1], decodeURIComponent(m[2]))); }
992
- catch (e) { send(e.notFound ? 404 : 400, { error: String(e.message || e) }); }
993
- return;
994
- }
995
- if (m && req.method === "DELETE") {
996
- try { send(200, deleteSessionFile(m[1], decodeURIComponent(m[2]))); }
997
- catch (e) { send(e.notFound ? 404 : 400, { error: String(e.message || e) }); }
998
- return;
999
- }
1000
-
1001
- send(404, { error: "not found" });
1002
- });
1003
-
1004
- return new Promise((resolve) => {
1005
- server.listen(config.port, config.host, () => resolve(server));
1006
- });
1007
- }
1008
-
1009
- function readBody(req) {
1010
- return new Promise((resolve) => {
1011
- let data = "";
1012
- req.on("data", (c) => { data += c; });
1013
- req.on("end", () => {
1014
- try { resolve(JSON.parse(data)); } catch { resolve({}); }
1015
- });
1016
- });
1017
- }
1018
-
1019
- module.exports = { startBridge };
1
+ "use strict";
2
+
3
+ const http = require("node:http");
4
+ const { spawn, execSync } = require("node:child_process");
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const crypto = require("node:crypto");
8
+ const os = require("node:os");
9
+
10
+ const running = new Map();
11
+
12
+ function homeDir() {
13
+ return process.env.HOME || process.env.USERPROFILE || os.homedir();
14
+ }
15
+
16
+ // ── MCP auto-allow ───────────────────────────────────────────────────────────
17
+ // Headless `claude -p` has no interactive permission prompt, so any tool not in
18
+ // `permissions.allow` is auto-DENIED ("Claude requested permissions to use
19
+ // mcp__x__y, but you haven't granted it yet"). MCP servers the user configured
20
+ // in their CLI therefore LOAD fine (claude mcp list shows "✓ Connected") but
21
+ // every call fails — which reads as "MCP doesn't work through the extension".
22
+ // Verified against a probe server: allow ["mcp__probe"] works; ["mcp__*"] does
23
+ // NOT (no wildcard support), so real server names must be enumerated.
24
+ // Opt out with CLAUDE_BRIDGE_MCP_AUTOALLOW=0.
25
+ function discoverMcpServers(cwd) {
26
+ const names = new Set();
27
+ const base = process.env.CLAUDE_CONFIG_DIR || homeDir();
28
+ try {
29
+ const cfg = JSON.parse(fs.readFileSync(path.join(base, ".claude.json"), "utf8"));
30
+ for (const k of Object.keys(cfg.mcpServers || {})) names.add(k);
31
+ const projects = cfg.projects || {};
32
+ if (cwd && projects[cwd]) {
33
+ for (const k of Object.keys(projects[cwd].mcpServers || {})) names.add(k);
34
+ } else {
35
+ // Unknown/!matching cwd: union every project's local servers so a session
36
+ // resumed in any directory still gets its tools granted.
37
+ for (const p of Object.values(projects)) {
38
+ for (const k of Object.keys((p && p.mcpServers) || {})) names.add(k);
39
+ }
40
+ }
41
+ } catch {}
42
+ if (cwd) {
43
+ try {
44
+ const proj = JSON.parse(fs.readFileSync(path.join(cwd, ".mcp.json"), "utf8"));
45
+ for (const k of Object.keys(proj.mcpServers || {})) names.add(k);
46
+ } catch {}
47
+ }
48
+ return [...names].filter(Boolean).sort();
49
+ }
50
+
51
+ function withMcpAllow(allow, cwd) {
52
+ if (process.env.CLAUDE_BRIDGE_MCP_AUTOALLOW === "0") return allow;
53
+ const out = [...allow];
54
+ for (const name of discoverMcpServers(cwd)) {
55
+ const rule = "mcp__" + name;
56
+ if (!out.includes(rule)) out.push(rule);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function dataDir() {
62
+ const d = path.join(homeDir(), ".claude-bridge");
63
+ fs.mkdirSync(d, { recursive: true });
64
+ return d;
65
+ }
66
+
67
+ function readJson(filePath, def) {
68
+ try { return JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { return def; }
69
+ }
70
+ function writeJson(filePath, data) {
71
+ const tmp = filePath + ".tmp";
72
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
73
+ fs.renameSync(tmp, filePath);
74
+ }
75
+
76
+ function projectsDir() {
77
+ return path.join(homeDir(), ".claude", "projects");
78
+ }
79
+
80
+ // ── Persisted turn-completion markers ──────────────────────────────────────
81
+ // A turn is "complete" ONLY when its claude process actually resolved with a
82
+ // parseable result. We persist that fact to disk (keyed by session id) so the
83
+ // completion signal survives a bridge restart. Without it, getSessionMessages
84
+ // falls back to the in-memory `running` set — which is wiped on restart — and
85
+ // then promotes the trailing JSONL assistant line to `final_text`. For a turn
86
+ // orphaned by a restart, that trailing line is often a mid-turn tool-call
87
+ // preamble ("Running the type-check: I'll report once…") which then gets
88
+ // mis-rendered as the final answer. The marker lets us tell genuine completion
89
+ // apart from an interrupted/orphaned turn.
90
+ function completionDir() {
91
+ const d = path.join(dataDir(), "completions");
92
+ fs.mkdirSync(d, { recursive: true });
93
+ return d;
94
+ }
95
+ function completionMarkerPath(sid) {
96
+ return path.join(completionDir(), encodeURIComponent(sid) + ".json");
97
+ }
98
+ function writeCompletionMarker(sid, finalText) {
99
+ if (!sid || sid === "pending") return;
100
+ try {
101
+ writeJson(completionMarkerPath(sid), {
102
+ session_id: sid,
103
+ stop_reason: "end_turn",
104
+ final_text: typeof finalText === "string" ? finalText : "",
105
+ completed_at: Date.now(),
106
+ });
107
+ } catch {}
108
+ // Best-effort prune of stale markers (>30d) so the dir can't grow forever.
109
+ try {
110
+ const dir = completionDir();
111
+ const cutoff = Date.now() - 30 * 24 * 3600 * 1000;
112
+ for (const f of fs.readdirSync(dir)) {
113
+ const fp = path.join(dir, f);
114
+ try { if (fs.statSync(fp).mtimeMs < cutoff) fs.unlinkSync(fp); } catch {}
115
+ }
116
+ } catch {}
117
+ }
118
+ function readCompletionMarker(sid) {
119
+ if (!sid) return null;
120
+ return readJson(completionMarkerPath(sid), null);
121
+ }
122
+
123
+ // ── Image handling ──
124
+
125
+ const ALLOWED_IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp"]);
126
+ const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
127
+
128
+ function sanitizeFilename(name, fallbackExt = "png") {
129
+ let base = path.basename(name || "image").replace(/[^a-zA-Z0-9._-]/g, "_");
130
+ const ext = path.extname(base).slice(1).toLowerCase();
131
+ if (!ext || !ALLOWED_IMAGE_EXTS.has(ext)) base += "." + fallbackExt;
132
+ return base.slice(0, 200);
133
+ }
134
+
135
+ function saveImages(images, sessionId) {
136
+ if (!Array.isArray(images) || !images.length) return [];
137
+ const dir = path.join(dataDir(), "images", sessionId || "unsorted");
138
+ fs.mkdirSync(dir, { recursive: true });
139
+ const saved = [];
140
+ const ts = Date.now();
141
+ for (let i = 0; i < images.length; i++) {
142
+ const img = images[i];
143
+ if (!img || !img.data_base64) continue;
144
+ const buf = Buffer.from(img.data_base64, "base64");
145
+ if (buf.length > MAX_IMAGE_BYTES) continue;
146
+ const name = sanitizeFilename(img.name || `image-${i}.png`);
147
+ const filename = `${ts}-${i}-${name}`;
148
+ const filePath = path.join(dir, filename);
149
+ fs.writeFileSync(filePath, buf, { mode: 0o600 });
150
+ saved.push(filePath);
151
+ }
152
+ return saved;
153
+ }
154
+
155
+ // ── Per-session file exchange (the Files drawer) ──────────────────────────
156
+ // Same per-session folder used for images doubles as a two-way file drawer:
157
+ // the user uploads ANY file here (Claude reads it), and Claude drops files here
158
+ // (the user downloads them). Old files pruned on access.
159
+ const MAX_FILE_BYTES = 45 * 1024 * 1024;
160
+ const FILE_PRUNE_DAYS = 14;
161
+ const FILE_NAME_RE = /^[A-Za-z0-9._ ()+\-]+$/;
162
+ const EXT_MIME = {
163
+ drawio: "application/xml", pdf: "application/pdf", json: "application/json",
164
+ csv: "text/csv", txt: "text/plain", md: "text/markdown", xml: "application/xml",
165
+ svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
166
+ gif: "image/gif", webp: "image/webp", zip: "application/zip", gz: "application/gzip",
167
+ tar: "application/x-tar", html: "text/html", css: "text/css", js: "text/javascript",
168
+ ts: "text/plain", py: "text/x-python", cs: "text/plain", java: "text/x-java",
169
+ yml: "text/yaml", yaml: "text/yaml", sql: "text/plain", log: "text/plain", sh: "text/x-sh",
170
+ };
171
+ function mimeFor(name) {
172
+ const e = path.extname(name || "").slice(1).toLowerCase();
173
+ return EXT_MIME[e] || "application/octet-stream";
174
+ }
175
+ function sanitizeAnyFilename(name) {
176
+ let base = path.basename((name || "").trim())
177
+ .replace(/[^A-Za-z0-9._ ()+\-]/g, "_")
178
+ .replace(/^[._ ]+|[._ ]+$/g, "");
179
+ return (base || "upload.bin").slice(0, 200);
180
+ }
181
+ function sessionFilesDir(sid) {
182
+ if (!/^[A-Za-z0-9._-]+$/.test(sid || "")) throw new Error("bad session id");
183
+ return path.join(dataDir(), "images", sid);
184
+ }
185
+ function pruneSessionFiles(dir) {
186
+ const cutoff = Date.now() - FILE_PRUNE_DAYS * 86400 * 1000;
187
+ let entries; try { entries = fs.readdirSync(dir); } catch { return; }
188
+ for (const f of entries) {
189
+ const fp = path.join(dir, f);
190
+ try { const st = fs.statSync(fp); if (st.isFile() && st.mtimeMs < cutoff) fs.unlinkSync(fp); } catch {}
191
+ }
192
+ }
193
+ function listSessionFiles(sid) {
194
+ const dir = sessionFilesDir(sid);
195
+ pruneSessionFiles(dir);
196
+ let entries; try { entries = fs.readdirSync(dir); } catch { return []; }
197
+ const out = [];
198
+ for (const f of entries.sort()) {
199
+ const fp = path.join(dir, f);
200
+ try {
201
+ const st = fs.statSync(fp);
202
+ if (st.isFile()) out.push({ name: f, size: st.size, mtime: Math.floor(st.mtimeMs / 1000), mime: mimeFor(f) });
203
+ } catch {}
204
+ }
205
+ return out;
206
+ }
207
+ function readSessionFile(sid, name) {
208
+ const dir = sessionFilesDir(sid);
209
+ const safe = path.basename(name || "");
210
+ if (!safe || safe !== name || !FILE_NAME_RE.test(safe)) throw new Error("bad file name");
211
+ const fp = path.join(dir, safe);
212
+ if (!fs.existsSync(fp) || !fs.statSync(fp).isFile()) { const e = new Error("file not found"); e.notFound = true; throw e; }
213
+ if (fs.statSync(fp).size > MAX_FILE_BYTES) throw new Error("file exceeds limit — too large to transfer");
214
+ const buf = fs.readFileSync(fp);
215
+ return { name: safe, size: buf.length, mime: mimeFor(safe), data_base64: buf.toString("base64") };
216
+ }
217
+ function saveSessionFile(sid, name, dataB64) {
218
+ const dir = sessionFilesDir(sid);
219
+ fs.mkdirSync(dir, { recursive: true });
220
+ pruneSessionFiles(dir);
221
+ if (typeof dataB64 !== "string") throw new Error("missing data_base64");
222
+ const mm = dataB64.match(/^data:[^;]+;base64,([\s\S]+)$/);
223
+ if (mm) dataB64 = mm[1];
224
+ const buf = Buffer.from(dataB64, "base64");
225
+ if (buf.length > MAX_FILE_BYTES) throw new Error("file too large");
226
+ const final = `${Date.now()}-${sanitizeAnyFilename(name || "upload.bin")}`;
227
+ const fp = path.join(dir, final);
228
+ fs.writeFileSync(fp, buf, { mode: 0o600 });
229
+ return { name: final, size: buf.length, mime: mimeFor(final), path: fp };
230
+ }
231
+ function deleteSessionFile(sid, name) {
232
+ const dir = sessionFilesDir(sid);
233
+ const safe = path.basename(name || "");
234
+ if (!safe || safe !== name || !FILE_NAME_RE.test(safe)) throw new Error("bad file name");
235
+ const fp = path.join(dir, safe);
236
+ if (!fs.existsSync(fp) || !fs.statSync(fp).isFile()) { const e = new Error("file not found"); e.notFound = true; throw e; }
237
+ fs.unlinkSync(fp);
238
+ return { deleted: safe };
239
+ }
240
+
241
+ function splitUserTextAndImages(text) {
242
+ const m = text.match(/\nThe user attached \d+ image\(s\) at these absolute paths\. Use the Read tool to view them:\n([\s\S]+)$/);
243
+ if (!m) return { cleanText: text, imagePaths: [] };
244
+ const cleanText = text.slice(0, m.index).trimEnd();
245
+ const paths = m[1].split("\n").map(l => l.replace(/^- /, "").trim()).filter(Boolean);
246
+ const imageData = [];
247
+ for (const p of paths) {
248
+ try {
249
+ const buf = fs.readFileSync(p);
250
+ const ext = path.extname(p).slice(1).toLowerCase();
251
+ const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg"
252
+ : ext === "gif" ? "image/gif"
253
+ : ext === "webp" ? "image/webp"
254
+ : "image/png";
255
+ imageData.push({ name: path.basename(p), data_base64: buf.toString("base64"), mime });
256
+ } catch {}
257
+ }
258
+ return { cleanText, imagePaths: paths, imageData };
259
+ }
260
+
261
+ // ── Session scanning ──
262
+
263
+ function scanSessionFiles() {
264
+ const base = projectsDir();
265
+ if (!fs.existsSync(base)) return [];
266
+ const results = [];
267
+ let dirs;
268
+ try { dirs = fs.readdirSync(base); } catch { return []; }
269
+ for (const project of dirs) {
270
+ const projDir = path.join(base, project);
271
+ let stat;
272
+ try { stat = fs.statSync(projDir); } catch { continue; }
273
+ if (!stat.isDirectory()) continue;
274
+ let files;
275
+ try { files = fs.readdirSync(projDir); } catch { continue; }
276
+ for (const file of files) {
277
+ if (!file.endsWith(".jsonl")) continue;
278
+ const id = file.replace(".jsonl", "");
279
+ const filePath = path.join(projDir, file);
280
+ results.push({ id, project, filePath });
281
+ }
282
+ }
283
+ return results;
284
+ }
285
+
286
+ function looksLikeInternal(text) {
287
+ if (!text) return false;
288
+ const t = text.slice(0, 500);
289
+ return /^\s*\{/.test(t) && /"tool_use_id"|"tool_result"|"is_error"/.test(t);
290
+ }
291
+
292
+ function parseSessionFile(filePath) {
293
+ let preview = "", aiTitle = "", customTitle = "", msgCount = 0, lastPrompt = "", cwd = "";
294
+ try {
295
+ // For listing, read just the first 64KB and the last 64KB.
296
+ // First chunk: get preview + ai_title from early events.
297
+ // Last chunk: get last_prompt and custom-title overrides (latest wins).
298
+ // Message count is approximated from file size.
299
+ const stat = fs.statSync(filePath);
300
+ const CHUNK = 64 * 1024;
301
+ const fd = fs.openSync(filePath, "r");
302
+ try {
303
+ const headBuf = Buffer.alloc(Math.min(CHUNK, stat.size));
304
+ fs.readSync(fd, headBuf, 0, headBuf.length, 0);
305
+ const head = headBuf.toString("utf8");
306
+ for (const line of head.split("\n")) {
307
+ if (!line) continue;
308
+ try {
309
+ const obj = JSON.parse(line);
310
+ // The real working directory is recorded on the session's events;
311
+ // capture the first one. Far more accurate than decoding the project
312
+ // dir name (which is lossy it can't recover ":" or distinguish a
313
+ // path separator from a literal "-", e.g. Windows "C:\GIT\…").
314
+ if (!cwd && typeof obj.cwd === "string" && obj.cwd) cwd = obj.cwd;
315
+ if (obj.type === "summary" && obj.summary) preview = preview || obj.summary.slice(0, 200);
316
+ if (obj.type === "user" && obj.message?.content) {
317
+ const text = typeof obj.message.content === "string" ? obj.message.content : JSON.stringify(obj.message.content);
318
+ if (!preview && !looksLikeInternal(text)) preview = text.slice(0, 200);
319
+ }
320
+ if (obj.type === "result" && obj.result?.metadata?.title?.value) aiTitle = aiTitle || obj.result.metadata.title.value;
321
+ if (obj.type === "ai-title") aiTitle = obj.aiTitle || obj.title || aiTitle;
322
+ if (obj.type === "custom-title") customTitle = obj.customTitle || obj.title || customTitle;
323
+ } catch {}
324
+ }
325
+ if (stat.size > CHUNK) {
326
+ const tailBuf = Buffer.alloc(CHUNK);
327
+ fs.readSync(fd, tailBuf, 0, CHUNK, stat.size - CHUNK);
328
+ const tail = tailBuf.toString("utf8");
329
+ const tailLines = tail.split("\n");
330
+ if (tailLines.length > 1) tailLines.shift();
331
+ for (const line of tailLines) {
332
+ if (!line) continue;
333
+ try {
334
+ const obj = JSON.parse(line);
335
+ if (obj.type === "user" && obj.message?.content) {
336
+ const text = typeof obj.message.content === "string" ? obj.message.content : JSON.stringify(obj.message.content);
337
+ if (!looksLikeInternal(text)) lastPrompt = text.slice(0, 200);
338
+ }
339
+ if (obj.type === "ai-title") aiTitle = obj.aiTitle || obj.title || aiTitle;
340
+ if (obj.type === "custom-title") customTitle = obj.customTitle || obj.title || customTitle;
341
+ } catch {}
342
+ }
343
+ }
344
+ // Count newlines accurately by streaming chunks.
345
+ const COUNT_BUF = Buffer.alloc(64 * 1024);
346
+ let pos = 0;
347
+ while (pos < stat.size) {
348
+ const got = fs.readSync(fd, COUNT_BUF, 0, COUNT_BUF.length, pos);
349
+ if (got <= 0) break;
350
+ for (let i = 0; i < got; i++) if (COUNT_BUF[i] === 0x0A) msgCount++;
351
+ pos += got;
352
+ }
353
+ } finally {
354
+ fs.closeSync(fd);
355
+ }
356
+ } catch {}
357
+ return { preview, ai_title: customTitle || aiTitle, message_count: msgCount, last_prompt: lastPrompt, cwd };
358
+ }
359
+
360
+ function projectDirToCwd(name) {
361
+ return name.replace(/-/g, "/");
362
+ }
363
+
364
+ function listSessions(opts = {}) {
365
+ const dd = dataDir();
366
+ const files = scanSessionFiles();
367
+ const titleOverrides = readJson(path.join(dd, "title-overrides.json"), {});
368
+ const starred = new Set(readJson(path.join(dd, "starred.json"), []));
369
+ const sessions = [];
370
+
371
+ for (const { id, project, filePath } of files) {
372
+ let stat;
373
+ try { stat = fs.statSync(filePath); } catch { continue; }
374
+ const parsed = parseSessionFile(filePath);
375
+ if (!opts.includeTiny && parsed.message_count < 3) continue;
376
+ if (opts.project && project !== opts.project) continue;
377
+ const title = titleOverrides[id] || parsed.ai_title;
378
+ sessions.push({
379
+ id, project,
380
+ cwd: parsed.cwd || projectDirToCwd(project),
381
+ mtime: stat.mtimeMs / 1000,
382
+ mtime_iso: stat.mtime.toISOString(),
383
+ preview: parsed.preview,
384
+ ai_title: title,
385
+ message_count: parsed.message_count,
386
+ last_prompt: parsed.last_prompt,
387
+ size_bytes: stat.size,
388
+ starred: starred.has(id),
389
+ in_progress: running.has(id),
390
+ });
391
+ }
392
+
393
+ sessions.sort((a, b) => (b.starred ? 1 : 0) - (a.starred ? 1 : 0) || b.mtime - a.mtime);
394
+ return sessions.slice(0, opts.limit || 400);
395
+ }
396
+
397
+ // ── Search ──
398
+
399
+ function searchSessions(query, limit = 30) {
400
+ if (!query || query.length < 2) return [];
401
+ const q = query.toLowerCase();
402
+ const dd = dataDir();
403
+ const titleOverrides = readJson(path.join(dd, "title-overrides.json"), {});
404
+ const starred = new Set(readJson(path.join(dd, "starred.json"), []));
405
+ const files = scanSessionFiles();
406
+
407
+ files.sort((a, b) => {
408
+ try {
409
+ return fs.statSync(b.filePath).mtimeMs - fs.statSync(a.filePath).mtimeMs;
410
+ } catch { return 0; }
411
+ });
412
+
413
+ const results = [];
414
+ for (const { id, project, filePath } of files.slice(0, 200)) {
415
+ let stat;
416
+ try { stat = fs.statSync(filePath); } catch { continue; }
417
+ let content;
418
+ try { content = fs.readFileSync(filePath, "utf8"); } catch { continue; }
419
+
420
+ const lower = content.toLowerCase();
421
+ const idx = lower.indexOf(q);
422
+ if (idx < 0) continue;
423
+
424
+ const matchCount = lower.split(q).length - 1;
425
+ const snippetStart = Math.max(0, idx - 40);
426
+ const snippetEnd = Math.min(content.length, idx + q.length + 80);
427
+ const snippet = content.slice(snippetStart, snippetEnd).replace(/\n/g, " ").trim();
428
+
429
+ let aiTitle = "", msgCount = 0, realCwd = "";
430
+ try {
431
+ const lines = content.split("\n").filter(Boolean);
432
+ msgCount = lines.length;
433
+ for (const line of lines) {
434
+ try {
435
+ const obj = JSON.parse(line);
436
+ if (!realCwd && typeof obj.cwd === "string" && obj.cwd) realCwd = obj.cwd;
437
+ if (obj.type === "result" && obj.result?.metadata?.title?.value) {
438
+ aiTitle = obj.result.metadata.title.value;
439
+ }
440
+ if (obj.type === "ai-title" && obj.title) aiTitle = obj.title;
441
+ if (obj.type === "custom-title" && obj.title) aiTitle = obj.title;
442
+ } catch {}
443
+ }
444
+ } catch {}
445
+
446
+ results.push({
447
+ id, project,
448
+ cwd: realCwd || projectDirToCwd(project),
449
+ mtime: stat.mtimeMs / 1000,
450
+ mtime_iso: stat.mtime.toISOString(),
451
+ ai_title: titleOverrides[id] || aiTitle,
452
+ snippet,
453
+ match_count: matchCount,
454
+ message_count: msgCount,
455
+ starred: starred.has(id),
456
+ });
457
+ if (results.length >= limit) break;
458
+ }
459
+ return results;
460
+ }
461
+
462
+ // ── Session messages ──
463
+
464
+ const INTERNAL_USER_PATTERNS = [
465
+ "<system-reminder>", "<command-name>", "<command-message>",
466
+ "<local-command-stdout>", "<command-stderr>", "<local-command-stderr>",
467
+ "Caveat: The messages below were generated by",
468
+ ];
469
+
470
+ function extractUserText(content) {
471
+ // Only extract type:"text" parts. tool_result records don't have a text
472
+ // part so they return empty string and get filtered out.
473
+ let raw = "";
474
+ if (typeof content === "string") raw = content;
475
+ else if (Array.isArray(content)) {
476
+ for (const part of content) {
477
+ if (part && typeof part === "object" && part.type === "text") {
478
+ raw = part.text || "";
479
+ break;
480
+ }
481
+ }
482
+ }
483
+ return resolveAtFileRefs(raw);
484
+ }
485
+
486
+ // When the bridge sends a multi-line prompt to claude on Windows it writes
487
+ // the prompt to a temp file and passes "@C:\...\claude-prompt-NNN.txt" as
488
+ // the -p arg. claude records that literal @path in the session JSONL as
489
+ // the user message. When we read the session back we want to show the
490
+ // actual content, not the path — otherwise history looks like a wall of
491
+ // temp-file paths. If the file still exists on disk, inline its contents;
492
+ // otherwise leave the @path alone so the user at least sees something.
493
+ function resolveAtFileRefs(text) {
494
+ if (!text || typeof text !== "string") return text;
495
+ // Match a leading @<path> that points at a claude-prompt-*.txt temp file.
496
+ // Restrict to the temp-file pattern we generate ourselves — never inline
497
+ // arbitrary user-typed @file references (which are a real Claude Code
498
+ // feature that should remain as-is).
499
+ const trimmed = text.trim();
500
+ if (!trimmed.startsWith("@")) return text;
501
+ const m = trimmed.match(/^@(.+?claude-prompt-\d+-[a-z0-9]+\.txt)\s*$/i);
502
+ if (!m) return text;
503
+ const filePath = m[1];
504
+ try {
505
+ if (fs.existsSync(filePath)) {
506
+ const body = fs.readFileSync(filePath, "utf8");
507
+ if (body && body.length) return body;
508
+ }
509
+ } catch {}
510
+ // Legacy: the temp file was already cleaned up (pre-2.0.13 behavior deleted
511
+ // them at turn end) show an honest placeholder instead of a confusing path.
512
+ return "(your message text is unavailable — it was passed via a temp file that was cleaned up; fixed for new messages in bridge 2.0.13)";
513
+ return text;
514
+ }
515
+
516
+ function shouldSkipUserText(text) {
517
+ if (!text) return true;
518
+ const t = text.replace(/^\s+/, "");
519
+ if (INTERNAL_USER_PATTERNS.some(p => t.startsWith(p))) return true;
520
+ return false;
521
+ }
522
+
523
+ function getSessionMessages(sessionId) {
524
+ const base = projectsDir();
525
+ let dirs;
526
+ try { dirs = fs.readdirSync(base); } catch { return { error: "session not found" }; }
527
+ for (const project of dirs) {
528
+ const filePath = path.join(base, project, sessionId + ".jsonl");
529
+ if (!fs.existsSync(filePath)) continue;
530
+ const messages = [];
531
+ for (const line of fs.readFileSync(filePath, "utf8").split("\n").filter(Boolean)) {
532
+ try {
533
+ const obj = JSON.parse(line);
534
+ // Skip transcript-only and meta records (replays, recaps)
535
+ if (obj.isMeta === true) continue;
536
+ if (obj.isVisibleInTranscriptOnly === true) continue;
537
+ if (obj.type === "user" && obj.message?.content) {
538
+ const text = extractUserText(obj.message.content);
539
+ if (shouldSkipUserText(text)) continue;
540
+ const split = splitUserTextAndImages(text);
541
+ messages.push({
542
+ role: "user",
543
+ text: split.cleanText,
544
+ images: split.imageData || [],
545
+ timestamp: obj.timestamp,
546
+ });
547
+ } else if (obj.type === "assistant" && obj.message?.content) {
548
+ const parts = Array.isArray(obj.message.content) ? obj.message.content : [obj.message.content];
549
+ const text = parts.map(p => typeof p === "string" ? p : p.text || "").join("");
550
+ if (!text) continue;
551
+ // Merge consecutive assistant turns into one (tool_use cycles).
552
+ // Track the trailing event's stop_reason `end_turn`/`stop_sequence`
553
+ // means the turn finished; `tool_use` means it stopped on a tool-call
554
+ // preamble (i.e. mid-action). This is the authoritative, in-transcript
555
+ // completion signal and works for every session, old or new.
556
+ const sr = obj.message?.stop_reason || null;
557
+ const mdl = obj.message?.model || null; // claude records the model per reply
558
+ if (messages.length && messages[messages.length - 1].role === "assistant") {
559
+ messages[messages.length - 1].text = (messages[messages.length - 1].text + "\n" + text).trim();
560
+ messages[messages.length - 1].timestamp = obj.timestamp || messages[messages.length - 1].timestamp;
561
+ messages[messages.length - 1]._stop_reason = sr;
562
+ if (mdl) messages[messages.length - 1].model = mdl;
563
+ } else {
564
+ messages.push({ role: "assistant", text, timestamp: obj.timestamp, _stop_reason: sr, model: mdl });
565
+ }
566
+ } else if (obj.type === "result" && obj.result?.assistantMessage) {
567
+ const text = typeof obj.result.assistantMessage === "string" ? obj.result.assistantMessage : "";
568
+ if (text) messages.push({ role: "assistant", text, timestamp: obj.timestamp });
569
+ }
570
+ } catch {}
571
+ }
572
+ // Match the Python bridge's shape so the app's reconnect/resume loader can
573
+ // detect completion. Without `status` (and end-of-turn markers on the
574
+ // trailing assistant), the loader never finalizes — it shows "still working"
575
+ // forever even though Claude is done (the stuck-loader bug on this bridge).
576
+ const inProgress = running.has(sessionId);
577
+ const last_event_ts = messages.length ? (messages[messages.length - 1].timestamp || null) : null;
578
+ let status = inProgress ? "in_progress" : "complete";
579
+ let interrupted = false;
580
+ if (!inProgress && messages.length) {
581
+ const last = messages[messages.length - 1];
582
+ // Completion is decided by two independent signals, either of which is
583
+ // sufficient:
584
+ // 1. Transcript: the trailing assistant event's stop_reason is
585
+ // `end_turn`/`stop_sequence` (claude closed the turn). Authoritative
586
+ // and present in every session, so old sessions stay "complete".
587
+ // 2. Marker: a persisted completion record exists AND was written no
588
+ // earlier than the trailing event (nothing newer ran after it).
589
+ // If NEITHER holds — e.g. the trailing line is a `tool_use` preamble and
590
+ // no fresh marker exists because the bridge was restarted mid-turn — the
591
+ // turn was orphaned. We must NOT fabricate an end_turn on that line.
592
+ const transcriptDone = last._stop_reason === "end_turn" || last._stop_reason === "stop_sequence";
593
+ const marker = readCompletionMarker(sessionId);
594
+ const lastTs = last_event_ts ? Date.parse(last_event_ts) : 0;
595
+ const markerGenuine = marker && (!lastTs || lastTs <= (marker.completed_at + 2000));
596
+ const genuine = transcriptDone || markerGenuine;
597
+ if (last.role === "assistant" && last.text) {
598
+ if (genuine) {
599
+ last.stop_reason = "end_turn";
600
+ last.final_text = last.text;
601
+ } else {
602
+ // Interrupted/orphaned: finalize honestly instead of passing the
603
+ // trailing preamble off as the answer (and instead of leaving the
604
+ // client's loader spinning forever with no end marker).
605
+ status = "interrupted";
606
+ interrupted = true;
607
+ last.interrupted = true;
608
+ last.stop_reason = "interrupted";
609
+ last.final_text = last.text +
610
+ "\n\n⚠️ This turn was interrupted before it finished (the bridge was " +
611
+ "likely restarted mid-turn). The text above may be a partial step, not " +
612
+ "the final result. Reply \"continue\" to resume.";
613
+ }
614
+ }
615
+ }
616
+ for (const m of messages) delete m._stop_reason; // internal-only signal
617
+ return {
618
+ session_id: sessionId,
619
+ messages,
620
+ in_progress: inProgress,
621
+ status,
622
+ interrupted,
623
+ last_event_ts,
624
+ };
625
+ }
626
+ return { error: "session not found" };
627
+ }
628
+
629
+ // ── Session delete / wipe ──
630
+
631
+ function deleteSession(sessionId) {
632
+ const base = projectsDir();
633
+ let dirs;
634
+ try { dirs = fs.readdirSync(base); } catch { return { error: "not found" }; }
635
+ for (const project of dirs) {
636
+ const filePath = path.join(base, project, sessionId + ".jsonl");
637
+ if (!fs.existsSync(filePath)) continue;
638
+ fs.unlinkSync(filePath);
639
+ const imgDir = path.join(dataDir(), "images", sessionId);
640
+ try { fs.rmSync(imgDir, { recursive: true, force: true }); } catch {}
641
+ return { deleted: true, session_id: sessionId };
642
+ }
643
+ return { error: "not found" };
644
+ }
645
+
646
+ function wipeAllSessions() {
647
+ const base = projectsDir();
648
+ let removed = 0;
649
+ try {
650
+ for (const project of fs.readdirSync(base)) {
651
+ const projDir = path.join(base, project);
652
+ if (!fs.statSync(projDir).isDirectory()) continue;
653
+ for (const file of fs.readdirSync(projDir)) {
654
+ if (!file.endsWith(".jsonl")) continue;
655
+ try { fs.unlinkSync(path.join(projDir, file)); removed++; } catch {}
656
+ }
657
+ }
658
+ } catch {}
659
+ const dd = dataDir();
660
+ for (const f of ["marks.json", "bindings.json", "title-overrides.json", "starred.json"]) {
661
+ try { fs.unlinkSync(path.join(dd, f)); } catch {}
662
+ }
663
+ try { fs.rmSync(path.join(dd, "images"), { recursive: true, force: true }); } catch {}
664
+ return { removed_jsonls: removed };
665
+ }
666
+
667
+ // ── Session rename ──
668
+
669
+ function renameSession(sessionId, title) {
670
+ const dd = dataDir();
671
+ const overrides = readJson(path.join(dd, "title-overrides.json"), {});
672
+ overrides[sessionId] = (title || "").slice(0, 500);
673
+ writeJson(path.join(dd, "title-overrides.json"), overrides);
674
+ return { ok: true, title: overrides[sessionId] };
675
+ }
676
+
677
+ // ── Run claude -p ──
678
+
679
+ // Injected into every turn (--append-system-prompt) so Claude never ends a turn
680
+ // promising async follow-up it can't keep: a bridge turn is one-shot and atomic —
681
+ // nothing re-invokes Claude after it stops, so "I'll report once the build
682
+ // completes" is a promise that never resolves (it leaves the UI dead-ended).
683
+ const ATOMIC_TURN_PROMPT =
684
+ "You are running inside a one-shot, non-interactive bridge: THIS TURN IS ATOMIC. " +
685
+ "It ends the moment you stop producing output, and nothing re-invokes you afterward. " +
686
+ "You cannot do work in the background, report back later, be re-triggered when an " +
687
+ "external job (build/CI/deploy/long command) finishes, or continue on your own. " +
688
+ "Therefore NEVER end a turn by promising future follow-up such as 'I'll report once " +
689
+ "it completes', 'I'll continue when CI is done', or 'waiting on X to finish'. Instead, " +
690
+ "run any work to completion within this turn and report the actual result now. If a " +
691
+ "task genuinely cannot finish in this turn, say so plainly and tell the user the exact " +
692
+ "command(s) to run or the next message to send to continue — do not imply you will resume.";
693
+
694
+ function askClaude(config, { prompt, session_id, images, cwd, plan_mode, allow_tools, model, fork }) {
695
+ return new Promise((resolve) => {
696
+ let finalPrompt = prompt;
697
+ if (images && images.length) {
698
+ const saved = saveImages(images, session_id || "pending");
699
+ if (saved.length) {
700
+ finalPrompt += `\nThe user attached ${saved.length} image(s) at these absolute paths. Use the Read tool to view them:\n` +
701
+ saved.map(p => `- ${p}`).join("\n");
702
+ }
703
+ }
704
+
705
+ const isWin = process.platform === "win32";
706
+ const isCmdShim = isWin && config.claudeBin.endsWith(".cmd");
707
+
708
+ // Pass the prompt via a temp file (@file, read by claude itself) instead of as
709
+ // a command-line argument when:
710
+ // 1) Prompt exceeds Windows cmd.exe's ~8K arg limit (threshold 6000).
711
+ // 2) We're on Windows with a .cmd shim — period. The shim runs through
712
+ // `cmd.exe /c`, which RE-PARSES the prompt with CMD's own rules: a double
713
+ // quote (") or a metacharacter (% & | < > ^) breaks the command line and
714
+ // surfaces as the cryptic "The system cannot find the file specified";
715
+ // a newline silently truncates the prompt at line 1. Node's argv quoting
716
+ // can't protect against cmd.exe's second parse. Passing @file sidesteps ALL
717
+ // of it, so do it for EVERY cmd-shim prompt — not just multi-line ones.
718
+ // (Previously only triggered on \r|\n, so quoted single-line prompts failed.)
719
+ const useTempFile =
720
+ finalPrompt.length > 6000 ||
721
+ (isWin && isCmdShim);
722
+ let tmpFile = null;
723
+ if (useTempFile) {
724
+ // PERSISTENT prompts dir — NOT os.tmpdir(). The session JSONL records the
725
+ // literal "@<path>" as the user message, and getSessionMessages resolves
726
+ // it by reading the file back. Deleting the file after the turn (the old
727
+ // behavior) made every reload show the raw @C:\...\claude-prompt-*.txt
728
+ // path instead of the user's text. Files are pruned after 30 days.
729
+ const promptsDir = path.join(dataDir(), "prompts");
730
+ fs.mkdirSync(promptsDir, { recursive: true });
731
+ try {
732
+ const cutoff = Date.now() - 30 * 24 * 3600 * 1000;
733
+ for (const f of fs.readdirSync(promptsDir)) {
734
+ const fp = path.join(promptsDir, f);
735
+ try { if (fs.statSync(fp).mtimeMs < cutoff) fs.unlinkSync(fp); } catch {}
736
+ }
737
+ } catch {}
738
+ tmpFile = path.join(promptsDir, `claude-prompt-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
739
+ fs.writeFileSync(tmpFile, finalPrompt, "utf8");
740
+ }
741
+
742
+ // Allow list the caller (extension) can override via `allow_tools`, e.g.
743
+ // after the user clicks "Allow <tool>" on a permission denial, so an MCP
744
+ // tool (mcp__server__tool) or any other non-default tool can be granted for
745
+ // the session. Falls back to the default working set. Mirrors the Python
746
+ // bridge so both behave identically.
747
+ const defaultAllow = ["Read", "Write", "Edit", "Bash", "Grep", "Glob", "WebFetch", "WebSearch",
748
+ "Task", "TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "NotebookEdit"];
749
+ const allow = (Array.isArray(allow_tools) && allow_tools.length &&
750
+ allow_tools.every(x => typeof x === "string")) ? allow_tools : defaultAllow;
751
+ const settings = JSON.stringify({
752
+ permissions: {
753
+ defaultMode: "acceptEdits",
754
+ allow,
755
+ deny: ["AskUserQuestion"],
756
+ }
757
+ });
758
+ const promptArg = useTempFile ? `@${tmpFile}` : finalPrompt;
759
+ const args = ["-p", promptArg, "--output-format", "json",
760
+ "--settings", settings, "--permission-mode", "acceptEdits",
761
+ "--append-system-prompt", ATOMIC_TURN_PROMPT];
762
+ let resumeCwd = null;
763
+ if (session_id) {
764
+ const existing = scanSessionFiles().find(s => s.id === session_id);
765
+ if (existing) {
766
+ args.push("--resume", session_id);
767
+ // Fork: replay this session's history into a BRAND-NEW session id instead
768
+ // of appending to the original. claude returns the new id in the result,
769
+ // which the client adopts (leaving the original untouched). --fork-session
770
+ // only works alongside --resume.
771
+ if (fork) args.push("--fork-session");
772
+ // Scan JSONL for the first event with a cwd field
773
+ try {
774
+ const lines = fs.readFileSync(existing.filePath, "utf8").split("\n");
775
+ for (const line of lines) {
776
+ if (!line) continue;
777
+ try {
778
+ const parsed = JSON.parse(line);
779
+ if (parsed.cwd) { resumeCwd = parsed.cwd; break; }
780
+ } catch {}
781
+ }
782
+ } catch {}
783
+ } else {
784
+ args.push("--session-id", session_id);
785
+ }
786
+ }
787
+ if (plan_mode) args.push("--plan");
788
+ // GLOBAL SKILLS. Verified against this CLI: skills in ~/.claude/skills are
789
+ // NOT discovered (not by the Skill tool, not as /slash, not even with
790
+ // --setting-sources user), while a skill inside a PLUGIN dir loads from any
791
+ // cwd. So personal always-on skills live in <data>/global-skills as a plugin
792
+ // and are attached to every turn — global instead of per-project.
793
+ try {
794
+ const gsDir = process.env.CLAUDE_BRIDGE_GLOBAL_SKILLS_DIR ||
795
+ path.join(dataDir(), "global-skills");
796
+ if (fs.existsSync(path.join(gsDir, "skills"))) args.push("--plugin-dir", gsDir);
797
+ } catch {}
798
+ // Optional model override from the extension's model picker (alias or id).
799
+ if (model && String(model).trim()) args.push("--model", String(model).trim());
800
+
801
+ // Effective cwd is only final here (resumeCwd comes from the session JSONL),
802
+ // and local-scope MCP servers are keyed BY directory — so grant MCP against
803
+ // the real run cwd, patching the already-built --settings in place.
804
+ const effCwd = cwd || resumeCwd || config.cwd;
805
+ try {
806
+ const mcpAllow = withMcpAllow(allow, effCwd);
807
+ if (mcpAllow.length !== allow.length) {
808
+ const si = args.indexOf("--settings");
809
+ if (si !== -1) {
810
+ const s = JSON.parse(args[si + 1]);
811
+ s.permissions.allow = mcpAllow;
812
+ args[si + 1] = JSON.stringify(s);
813
+ }
814
+ }
815
+ } catch {}
816
+
817
+ const bin = isCmdShim ? process.env.COMSPEC || "cmd.exe" : config.claudeBin;
818
+ const fullArgs = isCmdShim ? ["/c", config.claudeBin, ...args] : args;
819
+
820
+ const proc = spawn(bin, fullArgs, {
821
+ cwd: effCwd,
822
+ timeout: config.timeout,
823
+ env: { ...process.env },
824
+ stdio: ["ignore", "pipe", "pipe"],
825
+ windowsHide: true,
826
+ });
827
+
828
+ let stdout = "", stderr = "";
829
+ proc.stdout.on("data", (d) => { stdout += d; });
830
+ proc.stderr.on("data", (d) => { stderr += d; });
831
+
832
+ if (session_id) running.set(session_id, proc);
833
+
834
+ proc.on("close", (code) => {
835
+ // tmpFile is intentionally KEPT — history rendering reads it back (see promptsDir note).
836
+ if (session_id) running.delete(session_id);
837
+ try {
838
+ const result = JSON.parse(stdout);
839
+ const sid = result.session_id || session_id || crypto.randomUUID();
840
+ // Persist genuine completion so it survives a bridge restart. This is
841
+ // the ONLY place a clean end-of-turn is recorded — the error/partial
842
+ // branch below deliberately writes no marker, so an interrupted turn
843
+ // stays distinguishable.
844
+ const finalText = result.result || result.assistantMessage || "";
845
+ writeCompletionMarker(sid, finalText);
846
+ if (session_id && session_id !== sid) writeCompletionMarker(session_id, finalText);
847
+ if (images && images.length && session_id === "pending" && sid !== "pending") {
848
+ const oldDir = path.join(dataDir(), "images", "pending");
849
+ const newDir = path.join(dataDir(), "images", sid);
850
+ try { fs.renameSync(oldDir, newDir); } catch {}
851
+ }
852
+ // Surface tool-permission denials so the extension can show an "Allow
853
+ // <tool>" button (then resend with allow_tools including it). Without
854
+ // this, a tool that needs approval — notably any MCP tool, which isn't
855
+ // in the default allow list — silently fails with no way to grant it in
856
+ // headless -p mode. Mirrors the Python bridge's shape.
857
+ const rawDenials = Array.isArray(result.permission_denials) ? result.permission_denials : [];
858
+ const permission_denials = rawDenials
859
+ .filter(x => x && typeof x === "object")
860
+ .map(x => ({ tool_name: x.tool_name || "?", tool_input: x.tool_input || {} }));
861
+ resolve({
862
+ response: result.result || result.assistantMessage || stdout.slice(0, 5000),
863
+ session_id: sid,
864
+ cost_usd: result.cost_usd || null,
865
+ duration_ms: result.duration_ms || null,
866
+ context: result.context || null,
867
+ permission_denials,
868
+ });
869
+ } catch {
870
+ if (stdout.trim()) {
871
+ resolve({ response: stdout.trim(), session_id: session_id || crypto.randomUUID() });
872
+ } else {
873
+ resolve({ error: stderr.trim() || `claude exited with code ${code}` });
874
+ }
875
+ }
876
+ });
877
+
878
+ proc.on("error", (err) => {
879
+ // tmpFile is intentionally KEPT history rendering reads it back (see promptsDir note).
880
+ if (session_id) running.delete(session_id);
881
+ resolve({ error: err.message });
882
+ });
883
+ });
884
+ }
885
+
886
+ // ── Stop session ──
887
+
888
+ function stopSession(sessionId) {
889
+ const proc = running.get(sessionId);
890
+ if (!proc) return { stopped: false, reason: "not_running" };
891
+ if (process.platform === "win32" && proc.pid) {
892
+ try { execSync(`taskkill /f /t /pid ${proc.pid}`, { stdio: "ignore" }); } catch {}
893
+ } else {
894
+ try { proc.kill("SIGINT"); } catch {}
895
+ setTimeout(() => { try { proc.kill("SIGTERM"); } catch {} }, 3000);
896
+ }
897
+ running.delete(sessionId);
898
+ return { stopped: true };
899
+ }
900
+
901
+ // ── HTTP server ──
902
+
903
+ function startBridge(config) {
904
+ const dd = dataDir();
905
+
906
+ const server = http.createServer(async (req, res) => {
907
+ res.setHeader("Access-Control-Allow-Origin", "*");
908
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
909
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Confirm-Wipe");
910
+ if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
911
+
912
+ const send = (code, obj) => {
913
+ res.writeHead(code, { "Content-Type": "application/json" });
914
+ res.end(JSON.stringify(obj));
915
+ };
916
+
917
+ const auth = req.headers.authorization || "";
918
+ if (req.url !== "/health" && auth !== `Bearer ${config.bearerToken}`) {
919
+ send(401, { error: "unauthorized" });
920
+ return;
921
+ }
922
+
923
+ const url = new URL(req.url, `http://${req.headers.host}`);
924
+ let m;
925
+
926
+ // Health
927
+ if (url.pathname === "/health") {
928
+ send(200, { ok: true }); return;
929
+ }
930
+
931
+ // Ask
932
+ if (url.pathname === "/ask" && req.method === "POST") {
933
+ const body = await readBody(req);
934
+ const result = await askClaude(config, body);
935
+ send(result.error ? 500 : 200, result);
936
+ return;
937
+ }
938
+
939
+ // Sessions list
940
+ if (url.pathname === "/sessions" && req.method === "GET") {
941
+ const includeTiny = url.searchParams.get("include_tiny") === "1";
942
+ const project = url.searchParams.get("project") || "";
943
+ const sessions = listSessions({ includeTiny, project });
944
+ send(200, { sessions });
945
+ return;
946
+ }
947
+
948
+ // Search
949
+ if (url.pathname === "/sessions/search" && req.method === "GET") {
950
+ const q = url.searchParams.get("q") || "";
951
+ const limit = parseInt(url.searchParams.get("limit") || "30", 10);
952
+ const results = searchSessions(q, limit);
953
+ send(200, { results });
954
+ return;
955
+ }
956
+
957
+ // Starred list
958
+ if (url.pathname === "/sessions/starred" && req.method === "GET") {
959
+ send(200, { starred: readJson(path.join(dd, "starred.json"), []) });
960
+ return;
961
+ }
962
+
963
+ // Wipe all sessions
964
+ if (url.pathname === "/sessions/all" && req.method === "DELETE") {
965
+ if (req.headers["x-confirm-wipe"] !== "yes-i-am-sure") {
966
+ send(400, { error: "missing X-Confirm-Wipe header" });
967
+ return;
968
+ }
969
+ send(200, wipeAllSessions());
970
+ return;
971
+ }
972
+
973
+ // Session messages
974
+ m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/messages$/);
975
+ if (m && req.method === "GET") {
976
+ const result = getSessionMessages(m[1]);
977
+ send(result.error ? 404 : 200, result);
978
+ return;
979
+ }
980
+
981
+ // Star toggle
982
+ m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/star$/);
983
+ if (m && req.method === "POST") {
984
+ const starred = readJson(path.join(dd, "starred.json"), []);
985
+ const sid = m[1];
986
+ const idx = starred.indexOf(sid);
987
+ if (idx >= 0) { starred.splice(idx, 1); writeJson(path.join(dd, "starred.json"), starred); send(200, { starred: false, session_id: sid }); }
988
+ else { starred.unshift(sid); writeJson(path.join(dd, "starred.json"), starred); send(200, { starred: true, session_id: sid }); }
989
+ return;
990
+ }
991
+
992
+ // Stop
993
+ m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/stop$/);
994
+ if (m && req.method === "POST") {
995
+ send(200, stopSession(m[1]));
996
+ return;
997
+ }
998
+
999
+ // Marks
1000
+ m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/marks$/);
1001
+ if (m) {
1002
+ const marksFile = path.join(dd, "marks.json");
1003
+ const allMarks = readJson(marksFile, {});
1004
+ if (req.method === "GET") {
1005
+ send(200, { marks: allMarks[m[1]] || {} });
1006
+ return;
1007
+ }
1008
+ if (req.method === "PATCH" || req.method === "POST") {
1009
+ const body = await readBody(req);
1010
+ const sess = allMarks[m[1]] || {};
1011
+ if (body.key && body.mark) {
1012
+ sess[body.key] = { ...(sess[body.key] || {}), ...body.mark };
1013
+ } else {
1014
+ for (const [k, v] of Object.entries(body)) {
1015
+ sess[k] = { ...(sess[k] || {}), ...v };
1016
+ }
1017
+ }
1018
+ allMarks[m[1]] = sess;
1019
+ writeJson(marksFile, allMarks);
1020
+ send(200, { ok: true });
1021
+ return;
1022
+ }
1023
+ }
1024
+
1025
+ // AutoMode cross-device state + single-runner lease (per session, like marks)
1026
+ m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/automode$/);
1027
+ if (m) {
1028
+ const amFile = path.join(dd, "automode-state.json");
1029
+ const allAm = readJson(amFile, {});
1030
+ if (req.method === "GET") {
1031
+ send(200, { automode: allAm[m[1]] || {} });
1032
+ return;
1033
+ }
1034
+ if (req.method === "PATCH" || req.method === "POST") {
1035
+ const body = await readBody(req);
1036
+ const allowed = ["to_claude", "to_gpt", "gpt_ready", "claude_ready", "active_client", "active_ts"];
1037
+ const cur = allAm[m[1]] || {};
1038
+ for (const k of allowed) if (k in body) cur[k] = body[k];
1039
+ allAm[m[1]] = cur;
1040
+ writeJson(amFile, allAm);
1041
+ send(200, { automode: cur });
1042
+ return;
1043
+ }
1044
+ }
1045
+
1046
+ // Title rename
1047
+ m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/title$/);
1048
+ if (m && (req.method === "PATCH" || req.method === "POST")) {
1049
+ const body = await readBody(req);
1050
+ send(200, renameSession(m[1], body.title));
1051
+ return;
1052
+ }
1053
+
1054
+ // Delete single session
1055
+ m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)$/);
1056
+ if (m && req.method === "DELETE") {
1057
+ const result = deleteSession(m[1]);
1058
+ send(result.error ? 404 : 200, result);
1059
+ return;
1060
+ }
1061
+
1062
+ // ChatGPT bindings
1063
+ if (url.pathname === "/chatgpt-bindings") {
1064
+ const bindingsFile = path.join(dd, "bindings.json");
1065
+ if (req.method === "GET") {
1066
+ send(200, { bindings: readJson(bindingsFile, {}) });
1067
+ return;
1068
+ }
1069
+ if (req.method === "POST") {
1070
+ const body = await readBody(req);
1071
+ const bindings = readJson(bindingsFile, {});
1072
+ if (body.conv_id) {
1073
+ if (body.session_id) bindings[body.conv_id] = body.session_id;
1074
+ else delete bindings[body.conv_id];
1075
+ writeJson(bindingsFile, bindings);
1076
+ }
1077
+ send(200, { bindings });
1078
+ return;
1079
+ }
1080
+ }
1081
+
1082
+ // Session files — list (GET) / upload (POST)
1083
+ m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/files$/);
1084
+ if (m) {
1085
+ if (req.method === "GET") {
1086
+ try { send(200, { files: listSessionFiles(m[1]) }); } catch (e) { send(400, { error: String(e.message || e) }); }
1087
+ return;
1088
+ }
1089
+ if (req.method === "POST") {
1090
+ const body = await readBody(req);
1091
+ try { send(200, saveSessionFile(m[1], body.name, body.data_base64 || body.data)); }
1092
+ catch (e) { send(400, { error: String(e.message || e) }); }
1093
+ return;
1094
+ }
1095
+ }
1096
+ // Session files — download one (GET) / remove one (DELETE)
1097
+ m = url.pathname.match(/^\/sessions\/([A-Za-z0-9._-]+)\/files\/(.+)$/);
1098
+ if (m && req.method === "GET") {
1099
+ try { send(200, readSessionFile(m[1], decodeURIComponent(m[2]))); }
1100
+ catch (e) { send(e.notFound ? 404 : 400, { error: String(e.message || e) }); }
1101
+ return;
1102
+ }
1103
+ if (m && req.method === "DELETE") {
1104
+ try { send(200, deleteSessionFile(m[1], decodeURIComponent(m[2]))); }
1105
+ catch (e) { send(e.notFound ? 404 : 400, { error: String(e.message || e) }); }
1106
+ return;
1107
+ }
1108
+
1109
+ send(404, { error: "not found" });
1110
+ });
1111
+
1112
+ return new Promise((resolve) => {
1113
+ server.listen(config.port, config.host, () => resolve(server));
1114
+ });
1115
+ }
1116
+
1117
+ function readBody(req) {
1118
+ return new Promise((resolve) => {
1119
+ let data = "";
1120
+ req.on("data", (c) => { data += c; });
1121
+ req.on("end", () => {
1122
+ try { resolve(JSON.parse(data)); } catch { resolve({}); }
1123
+ });
1124
+ });
1125
+ }
1126
+
1127
+ module.exports = { startBridge };