claude-bridge-cli 2.0.7 → 2.0.11

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 (2) hide show
  1. package/lib/bridge.js +128 -12
  2. package/package.json +1 -1
package/lib/bridge.js CHANGED
@@ -32,6 +32,49 @@ function projectsDir() {
32
32
  return path.join(homeDir(), ".claude", "projects");
33
33
  }
34
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
+
35
78
  // ── Image handling ──
36
79
 
37
80
  const ALLOWED_IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp"]);
@@ -202,7 +245,7 @@ function looksLikeInternal(text) {
202
245
  }
203
246
 
204
247
  function parseSessionFile(filePath) {
205
- let preview = "", aiTitle = "", customTitle = "", msgCount = 0, lastPrompt = "";
248
+ let preview = "", aiTitle = "", customTitle = "", msgCount = 0, lastPrompt = "", cwd = "";
206
249
  try {
207
250
  // For listing, read just the first 64KB and the last 64KB.
208
251
  // First chunk: get preview + ai_title from early events.
@@ -219,6 +262,11 @@ function parseSessionFile(filePath) {
219
262
  if (!line) continue;
220
263
  try {
221
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;
222
270
  if (obj.type === "summary" && obj.summary) preview = preview || obj.summary.slice(0, 200);
223
271
  if (obj.type === "user" && obj.message?.content) {
224
272
  const text = typeof obj.message.content === "string" ? obj.message.content : JSON.stringify(obj.message.content);
@@ -261,7 +309,7 @@ function parseSessionFile(filePath) {
261
309
  fs.closeSync(fd);
262
310
  }
263
311
  } catch {}
264
- return { preview, ai_title: customTitle || aiTitle, message_count: msgCount, last_prompt: lastPrompt };
312
+ return { preview, ai_title: customTitle || aiTitle, message_count: msgCount, last_prompt: lastPrompt, cwd };
265
313
  }
266
314
 
267
315
  function projectDirToCwd(name) {
@@ -284,7 +332,7 @@ function listSessions(opts = {}) {
284
332
  const title = titleOverrides[id] || parsed.ai_title;
285
333
  sessions.push({
286
334
  id, project,
287
- cwd: projectDirToCwd(project),
335
+ cwd: parsed.cwd || projectDirToCwd(project),
288
336
  mtime: stat.mtimeMs / 1000,
289
337
  mtime_iso: stat.mtime.toISOString(),
290
338
  preview: parsed.preview,
@@ -333,13 +381,14 @@ function searchSessions(query, limit = 30) {
333
381
  const snippetEnd = Math.min(content.length, idx + q.length + 80);
334
382
  const snippet = content.slice(snippetStart, snippetEnd).replace(/\n/g, " ").trim();
335
383
 
336
- let aiTitle = "", msgCount = 0;
384
+ let aiTitle = "", msgCount = 0, realCwd = "";
337
385
  try {
338
386
  const lines = content.split("\n").filter(Boolean);
339
387
  msgCount = lines.length;
340
388
  for (const line of lines) {
341
389
  try {
342
390
  const obj = JSON.parse(line);
391
+ if (!realCwd && typeof obj.cwd === "string" && obj.cwd) realCwd = obj.cwd;
343
392
  if (obj.type === "result" && obj.result?.metadata?.title?.value) {
344
393
  aiTitle = obj.result.metadata.title.value;
345
394
  }
@@ -351,7 +400,7 @@ function searchSessions(query, limit = 30) {
351
400
 
352
401
  results.push({
353
402
  id, project,
354
- cwd: projectDirToCwd(project),
403
+ cwd: realCwd || projectDirToCwd(project),
355
404
  mtime: stat.mtimeMs / 1000,
356
405
  mtime_iso: stat.mtime.toISOString(),
357
406
  ai_title: titleOverrides[id] || aiTitle,
@@ -451,12 +500,18 @@ function getSessionMessages(sessionId) {
451
500
  const parts = Array.isArray(obj.message.content) ? obj.message.content : [obj.message.content];
452
501
  const text = parts.map(p => typeof p === "string" ? p : p.text || "").join("");
453
502
  if (!text) continue;
454
- // Merge consecutive assistant turns into one (tool_use cycles)
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;
455
509
  if (messages.length && messages[messages.length - 1].role === "assistant") {
456
510
  messages[messages.length - 1].text = (messages[messages.length - 1].text + "\n" + text).trim();
457
511
  messages[messages.length - 1].timestamp = obj.timestamp || messages[messages.length - 1].timestamp;
512
+ messages[messages.length - 1]._stop_reason = sr;
458
513
  } else {
459
- messages.push({ role: "assistant", text, timestamp: obj.timestamp });
514
+ messages.push({ role: "assistant", text, timestamp: obj.timestamp, _stop_reason: sr });
460
515
  }
461
516
  } else if (obj.type === "result" && obj.result?.assistantMessage) {
462
517
  const text = typeof obj.result.assistantMessage === "string" ? obj.result.assistantMessage : "";
@@ -470,18 +525,51 @@ function getSessionMessages(sessionId) {
470
525
  // forever even though Claude is done (the stuck-loader bug on this bridge).
471
526
  const inProgress = running.has(sessionId);
472
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;
473
530
  if (!inProgress && messages.length) {
474
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;
475
547
  if (last.role === "assistant" && last.text) {
476
- last.stop_reason = "end_turn";
477
- last.final_text = 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
+ }
478
564
  }
479
565
  }
566
+ for (const m of messages) delete m._stop_reason; // internal-only signal
480
567
  return {
481
568
  session_id: sessionId,
482
569
  messages,
483
570
  in_progress: inProgress,
484
- status: inProgress ? "in_progress" : "complete",
571
+ status,
572
+ interrupted,
485
573
  last_event_ts,
486
574
  };
487
575
  }
@@ -538,7 +626,22 @@ function renameSession(sessionId, title) {
538
626
 
539
627
  // ── Run claude -p ──
540
628
 
541
- function askClaude(config, { prompt, session_id, images, cwd, plan_mode, allow_tools, model }) {
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 }) {
542
645
  return new Promise((resolve) => {
543
646
  let finalPrompt = prompt;
544
647
  if (images && images.length) {
@@ -582,12 +685,18 @@ function askClaude(config, { prompt, session_id, images, cwd, plan_mode, allow_t
582
685
  });
583
686
  const promptArg = useTempFile ? `@${tmpFile}` : finalPrompt;
584
687
  const args = ["-p", promptArg, "--output-format", "json",
585
- "--settings", settings, "--permission-mode", "acceptEdits"];
688
+ "--settings", settings, "--permission-mode", "acceptEdits",
689
+ "--append-system-prompt", ATOMIC_TURN_PROMPT];
586
690
  let resumeCwd = null;
587
691
  if (session_id) {
588
692
  const existing = scanSessionFiles().find(s => s.id === session_id);
589
693
  if (existing) {
590
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");
591
700
  // Scan JSONL for the first event with a cwd field
592
701
  try {
593
702
  const lines = fs.readFileSync(existing.filePath, "utf8").split("\n");
@@ -630,6 +739,13 @@ function askClaude(config, { prompt, session_id, images, cwd, plan_mode, allow_t
630
739
  try {
631
740
  const result = JSON.parse(stdout);
632
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);
633
749
  if (images && images.length && session_id === "pending" && sid !== "pending") {
634
750
  const oldDir = path.join(dataDir(), "images", "pending");
635
751
  const newDir = path.join(dataDir(), "images", sid);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-bridge-cli",
3
- "version": "2.0.7",
3
+ "version": "2.0.11",
4
4
  "description": "Use Claude Code from your browser. Runs a local server that connects your browser tools to the Claude CLI.",
5
5
  "main": "lib/bridge.js",
6
6
  "bin": {