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.
- package/lib/bridge.js +128 -12
- 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
|
-
|
|
477
|
-
|
|
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
|
|
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
|
-
|
|
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