claude-bridge-cli 2.0.7 → 2.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/bridge.js +94 -5
- 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"]);
|
|
@@ -451,12 +494,18 @@ function getSessionMessages(sessionId) {
|
|
|
451
494
|
const parts = Array.isArray(obj.message.content) ? obj.message.content : [obj.message.content];
|
|
452
495
|
const text = parts.map(p => typeof p === "string" ? p : p.text || "").join("");
|
|
453
496
|
if (!text) continue;
|
|
454
|
-
// Merge consecutive assistant turns into one (tool_use cycles)
|
|
497
|
+
// Merge consecutive assistant turns into one (tool_use cycles).
|
|
498
|
+
// Track the trailing event's stop_reason — `end_turn`/`stop_sequence`
|
|
499
|
+
// means the turn finished; `tool_use` means it stopped on a tool-call
|
|
500
|
+
// preamble (i.e. mid-action). This is the authoritative, in-transcript
|
|
501
|
+
// completion signal and works for every session, old or new.
|
|
502
|
+
const sr = obj.message?.stop_reason || null;
|
|
455
503
|
if (messages.length && messages[messages.length - 1].role === "assistant") {
|
|
456
504
|
messages[messages.length - 1].text = (messages[messages.length - 1].text + "\n" + text).trim();
|
|
457
505
|
messages[messages.length - 1].timestamp = obj.timestamp || messages[messages.length - 1].timestamp;
|
|
506
|
+
messages[messages.length - 1]._stop_reason = sr;
|
|
458
507
|
} else {
|
|
459
|
-
messages.push({ role: "assistant", text, timestamp: obj.timestamp });
|
|
508
|
+
messages.push({ role: "assistant", text, timestamp: obj.timestamp, _stop_reason: sr });
|
|
460
509
|
}
|
|
461
510
|
} else if (obj.type === "result" && obj.result?.assistantMessage) {
|
|
462
511
|
const text = typeof obj.result.assistantMessage === "string" ? obj.result.assistantMessage : "";
|
|
@@ -470,18 +519,51 @@ function getSessionMessages(sessionId) {
|
|
|
470
519
|
// forever even though Claude is done (the stuck-loader bug on this bridge).
|
|
471
520
|
const inProgress = running.has(sessionId);
|
|
472
521
|
const last_event_ts = messages.length ? (messages[messages.length - 1].timestamp || null) : null;
|
|
522
|
+
let status = inProgress ? "in_progress" : "complete";
|
|
523
|
+
let interrupted = false;
|
|
473
524
|
if (!inProgress && messages.length) {
|
|
474
525
|
const last = messages[messages.length - 1];
|
|
526
|
+
// Completion is decided by two independent signals, either of which is
|
|
527
|
+
// sufficient:
|
|
528
|
+
// 1. Transcript: the trailing assistant event's stop_reason is
|
|
529
|
+
// `end_turn`/`stop_sequence` (claude closed the turn). Authoritative
|
|
530
|
+
// and present in every session, so old sessions stay "complete".
|
|
531
|
+
// 2. Marker: a persisted completion record exists AND was written no
|
|
532
|
+
// earlier than the trailing event (nothing newer ran after it).
|
|
533
|
+
// If NEITHER holds — e.g. the trailing line is a `tool_use` preamble and
|
|
534
|
+
// no fresh marker exists because the bridge was restarted mid-turn — the
|
|
535
|
+
// turn was orphaned. We must NOT fabricate an end_turn on that line.
|
|
536
|
+
const transcriptDone = last._stop_reason === "end_turn" || last._stop_reason === "stop_sequence";
|
|
537
|
+
const marker = readCompletionMarker(sessionId);
|
|
538
|
+
const lastTs = last_event_ts ? Date.parse(last_event_ts) : 0;
|
|
539
|
+
const markerGenuine = marker && (!lastTs || lastTs <= (marker.completed_at + 2000));
|
|
540
|
+
const genuine = transcriptDone || markerGenuine;
|
|
475
541
|
if (last.role === "assistant" && last.text) {
|
|
476
|
-
|
|
477
|
-
|
|
542
|
+
if (genuine) {
|
|
543
|
+
last.stop_reason = "end_turn";
|
|
544
|
+
last.final_text = last.text;
|
|
545
|
+
} else {
|
|
546
|
+
// Interrupted/orphaned: finalize honestly instead of passing the
|
|
547
|
+
// trailing preamble off as the answer (and instead of leaving the
|
|
548
|
+
// client's loader spinning forever with no end marker).
|
|
549
|
+
status = "interrupted";
|
|
550
|
+
interrupted = true;
|
|
551
|
+
last.interrupted = true;
|
|
552
|
+
last.stop_reason = "interrupted";
|
|
553
|
+
last.final_text = last.text +
|
|
554
|
+
"\n\n⚠️ This turn was interrupted before it finished (the bridge was " +
|
|
555
|
+
"likely restarted mid-turn). The text above may be a partial step, not " +
|
|
556
|
+
"the final result. Reply \"continue\" to resume.";
|
|
557
|
+
}
|
|
478
558
|
}
|
|
479
559
|
}
|
|
560
|
+
for (const m of messages) delete m._stop_reason; // internal-only signal
|
|
480
561
|
return {
|
|
481
562
|
session_id: sessionId,
|
|
482
563
|
messages,
|
|
483
564
|
in_progress: inProgress,
|
|
484
|
-
status
|
|
565
|
+
status,
|
|
566
|
+
interrupted,
|
|
485
567
|
last_event_ts,
|
|
486
568
|
};
|
|
487
569
|
}
|
|
@@ -630,6 +712,13 @@ function askClaude(config, { prompt, session_id, images, cwd, plan_mode, allow_t
|
|
|
630
712
|
try {
|
|
631
713
|
const result = JSON.parse(stdout);
|
|
632
714
|
const sid = result.session_id || session_id || crypto.randomUUID();
|
|
715
|
+
// Persist genuine completion so it survives a bridge restart. This is
|
|
716
|
+
// the ONLY place a clean end-of-turn is recorded — the error/partial
|
|
717
|
+
// branch below deliberately writes no marker, so an interrupted turn
|
|
718
|
+
// stays distinguishable.
|
|
719
|
+
const finalText = result.result || result.assistantMessage || "";
|
|
720
|
+
writeCompletionMarker(sid, finalText);
|
|
721
|
+
if (session_id && session_id !== sid) writeCompletionMarker(session_id, finalText);
|
|
633
722
|
if (images && images.length && session_id === "pending" && sid !== "pending") {
|
|
634
723
|
const oldDir = path.join(dataDir(), "images", "pending");
|
|
635
724
|
const newDir = path.join(dataDir(), "images", sid);
|
package/package.json
CHANGED