claude-bridge-cli 2.0.8 → 2.0.12

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 +54 -9
  2. package/package.json +1 -1
package/lib/bridge.js CHANGED
@@ -245,7 +245,7 @@ function looksLikeInternal(text) {
245
245
  }
246
246
 
247
247
  function parseSessionFile(filePath) {
248
- let preview = "", aiTitle = "", customTitle = "", msgCount = 0, lastPrompt = "";
248
+ let preview = "", aiTitle = "", customTitle = "", msgCount = 0, lastPrompt = "", cwd = "";
249
249
  try {
250
250
  // For listing, read just the first 64KB and the last 64KB.
251
251
  // First chunk: get preview + ai_title from early events.
@@ -262,6 +262,11 @@ function parseSessionFile(filePath) {
262
262
  if (!line) continue;
263
263
  try {
264
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;
265
270
  if (obj.type === "summary" && obj.summary) preview = preview || obj.summary.slice(0, 200);
266
271
  if (obj.type === "user" && obj.message?.content) {
267
272
  const text = typeof obj.message.content === "string" ? obj.message.content : JSON.stringify(obj.message.content);
@@ -304,7 +309,7 @@ function parseSessionFile(filePath) {
304
309
  fs.closeSync(fd);
305
310
  }
306
311
  } catch {}
307
- 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 };
308
313
  }
309
314
 
310
315
  function projectDirToCwd(name) {
@@ -327,7 +332,7 @@ function listSessions(opts = {}) {
327
332
  const title = titleOverrides[id] || parsed.ai_title;
328
333
  sessions.push({
329
334
  id, project,
330
- cwd: projectDirToCwd(project),
335
+ cwd: parsed.cwd || projectDirToCwd(project),
331
336
  mtime: stat.mtimeMs / 1000,
332
337
  mtime_iso: stat.mtime.toISOString(),
333
338
  preview: parsed.preview,
@@ -376,13 +381,14 @@ function searchSessions(query, limit = 30) {
376
381
  const snippetEnd = Math.min(content.length, idx + q.length + 80);
377
382
  const snippet = content.slice(snippetStart, snippetEnd).replace(/\n/g, " ").trim();
378
383
 
379
- let aiTitle = "", msgCount = 0;
384
+ let aiTitle = "", msgCount = 0, realCwd = "";
380
385
  try {
381
386
  const lines = content.split("\n").filter(Boolean);
382
387
  msgCount = lines.length;
383
388
  for (const line of lines) {
384
389
  try {
385
390
  const obj = JSON.parse(line);
391
+ if (!realCwd && typeof obj.cwd === "string" && obj.cwd) realCwd = obj.cwd;
386
392
  if (obj.type === "result" && obj.result?.metadata?.title?.value) {
387
393
  aiTitle = obj.result.metadata.title.value;
388
394
  }
@@ -394,7 +400,7 @@ function searchSessions(query, limit = 30) {
394
400
 
395
401
  results.push({
396
402
  id, project,
397
- cwd: projectDirToCwd(project),
403
+ cwd: realCwd || projectDirToCwd(project),
398
404
  mtime: stat.mtimeMs / 1000,
399
405
  mtime_iso: stat.mtime.toISOString(),
400
406
  ai_title: titleOverrides[id] || aiTitle,
@@ -620,7 +626,22 @@ function renameSession(sessionId, title) {
620
626
 
621
627
  // ── Run claude -p ──
622
628
 
623
- 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 }) {
624
645
  return new Promise((resolve) => {
625
646
  let finalPrompt = prompt;
626
647
  if (images && images.length) {
@@ -654,22 +675,36 @@ function askClaude(config, { prompt, session_id, images, cwd, plan_mode, allow_t
654
675
  fs.writeFileSync(tmpFile, finalPrompt, "utf8");
655
676
  }
656
677
 
678
+ // Allow list — the caller (extension) can override via `allow_tools`, e.g.
679
+ // after the user clicks "Allow <tool>" on a permission denial, so an MCP
680
+ // tool (mcp__server__tool) or any other non-default tool can be granted for
681
+ // the session. Falls back to the default working set. Mirrors the Python
682
+ // bridge so both behave identically.
683
+ const defaultAllow = ["Read", "Write", "Edit", "Bash", "Grep", "Glob", "WebFetch", "WebSearch",
684
+ "Task", "TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "NotebookEdit"];
685
+ const allow = (Array.isArray(allow_tools) && allow_tools.length &&
686
+ allow_tools.every(x => typeof x === "string")) ? allow_tools : defaultAllow;
657
687
  const settings = JSON.stringify({
658
688
  permissions: {
659
689
  defaultMode: "acceptEdits",
660
- allow: ["Read", "Write", "Edit", "Bash", "Grep", "Glob", "WebFetch", "WebSearch",
661
- "Task", "TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "NotebookEdit"],
690
+ allow,
662
691
  deny: ["AskUserQuestion"],
663
692
  }
664
693
  });
665
694
  const promptArg = useTempFile ? `@${tmpFile}` : finalPrompt;
666
695
  const args = ["-p", promptArg, "--output-format", "json",
667
- "--settings", settings, "--permission-mode", "acceptEdits"];
696
+ "--settings", settings, "--permission-mode", "acceptEdits",
697
+ "--append-system-prompt", ATOMIC_TURN_PROMPT];
668
698
  let resumeCwd = null;
669
699
  if (session_id) {
670
700
  const existing = scanSessionFiles().find(s => s.id === session_id);
671
701
  if (existing) {
672
702
  args.push("--resume", session_id);
703
+ // Fork: replay this session's history into a BRAND-NEW session id instead
704
+ // of appending to the original. claude returns the new id in the result,
705
+ // which the client adopts (leaving the original untouched). --fork-session
706
+ // only works alongside --resume.
707
+ if (fork) args.push("--fork-session");
673
708
  // Scan JSONL for the first event with a cwd field
674
709
  try {
675
710
  const lines = fs.readFileSync(existing.filePath, "utf8").split("\n");
@@ -724,12 +759,22 @@ function askClaude(config, { prompt, session_id, images, cwd, plan_mode, allow_t
724
759
  const newDir = path.join(dataDir(), "images", sid);
725
760
  try { fs.renameSync(oldDir, newDir); } catch {}
726
761
  }
762
+ // Surface tool-permission denials so the extension can show an "Allow
763
+ // <tool>" button (then resend with allow_tools including it). Without
764
+ // this, a tool that needs approval — notably any MCP tool, which isn't
765
+ // in the default allow list — silently fails with no way to grant it in
766
+ // headless -p mode. Mirrors the Python bridge's shape.
767
+ const rawDenials = Array.isArray(result.permission_denials) ? result.permission_denials : [];
768
+ const permission_denials = rawDenials
769
+ .filter(x => x && typeof x === "object")
770
+ .map(x => ({ tool_name: x.tool_name || "?", tool_input: x.tool_input || {} }));
727
771
  resolve({
728
772
  response: result.result || result.assistantMessage || stdout.slice(0, 5000),
729
773
  session_id: sid,
730
774
  cost_usd: result.cost_usd || null,
731
775
  duration_ms: result.duration_ms || null,
732
776
  context: result.context || null,
777
+ permission_denials,
733
778
  });
734
779
  } catch {
735
780
  if (stdout.trim()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-bridge-cli",
3
- "version": "2.0.8",
3
+ "version": "2.0.12",
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": {