pi-bro 0.17.0 → 0.18.1

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/backend.ts CHANGED
@@ -6,7 +6,7 @@ import { createInterface } from "node:readline";
6
6
 
7
7
  // Shared internal execution boundary for all four Bro features (explain, show, btw, advisor).
8
8
  // This implements docs/plans/2026-09-22-shared-backend-design.md for Agy (all features) and the
9
- // Claude Code CLI (explain/show/advisor; no btw yet) and the Grok CLI (all features): it owns CLI selection, process invocation, progress/outcome normalization, continuation, and
9
+ // Claude Code CLI (all features) and the Grok CLI (all features): it owns CLI selection, process invocation, progress/outcome normalization, continuation, and
10
10
  // single-attempt cleanup. Feature code (bro.ts) keeps retries, UI, source/session capture and
11
11
  // settings.
12
12
 
@@ -493,16 +493,16 @@ async function executeAdvisorStdin(
493
493
  }
494
494
 
495
495
 
496
- // Claude Code supports every feature except btw (it has no continuation wiring yet). Grok supports
497
- // every feature, but its "restricted" access is a prompt instruction, not enforcement (see
498
- // GROK_RESTRICTED_PREFIX).
499
- export function backendSupports(backend: "agy" | "claude" | "grok", feature: BackendFeature): boolean {
500
- return backend === "agy" || backend === "grok" || feature !== "btw";
496
+ // Every backend supports every feature. Grok's "restricted" access is a prompt instruction, not
497
+ // enforcement (see GROK_RESTRICTED_PREFIX); Claude's restricted btw runs tool-less.
498
+ export function backendSupports(_backend: "agy" | "claude" | "grok", _feature: BackendFeature): boolean {
499
+ return true;
501
500
  }
502
501
 
503
502
  type ClaudeEvent = {
504
503
  type?: unknown;
505
504
  subtype?: unknown;
505
+ session_id?: unknown;
506
506
  is_error?: unknown;
507
507
  result?: unknown;
508
508
  parent_tool_use_id?: unknown;
@@ -512,11 +512,14 @@ type ClaudeEvent = {
512
512
  message?: { content?: unknown };
513
513
  };
514
514
 
515
- // Every Claude run is fresh (never resumed, nothing persisted) under --safe-mode, which disables
516
- // CLAUDE.md, skills, plugins, hooks and MCP servers but keeps the user's auth (--bare would break
517
- // OAuth). The prompt goes over stdin. explain/show additionally run tool-less in a scratch cwd;
518
- // advisor runs in the caller's workspace with tools auto-approved. Only the terminal `result`
519
- // event is authoritative; streamed text_delta events are progress only, and thinking is dropped.
515
+ // Every Claude run uses --safe-mode, which disables CLAUDE.md, skills, plugins, hooks and MCP
516
+ // servers but keeps the user's auth (--bare would break OAuth). The prompt goes over stdin.
517
+ // explain/show run fresh and tool-less in a scratch cwd; advisor runs fresh in the caller's
518
+ // workspace with tools auto-approved. btw persists its session (so `--resume` can continue it) and
519
+ // always runs in the caller's workspace, since Claude stores sessions per cwd: restricted turns are
520
+ // tool-less, full turns auto-approve tools, and one session resumes across both. Only the terminal
521
+ // `result` event is authoritative; streamed text_delta events are progress only, and thinking is
522
+ // dropped. btw requires init/result session ids to agree (and to equal the resumed id).
520
523
  async function executeClaude(
521
524
  request: BackendRequest,
522
525
  selection: { model: string; effort?: string },
@@ -526,11 +529,13 @@ async function executeClaude(
526
529
  deadlineMsOverride: number | undefined,
527
530
  ): Promise<BackendOutcome> {
528
531
  const isAdvisor = request.feature === "advisor";
529
- const deadlineMs = deadlineMsOverride ?? (isAdvisor ? 610_000 : 125_000);
530
- const action = isAdvisor ? "complete the advisor consultation" : "simplify the response";
532
+ const isBtw = request.feature === "btw";
533
+ const full = request.access === "workspace-full";
534
+ const deadlineMs = deadlineMsOverride ?? (full ? 610_000 : isBtw ? 130_000 : 125_000);
535
+ const action = isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
531
536
 
532
537
  if (signal.aborted) return { status: "cancelled", message: "Canceled." };
533
- const runDirectory = isAdvisor ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
538
+ const runDirectory = isAdvisor || isBtw ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
534
539
  if (signal.aborted) {
535
540
  if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
536
541
  return { status: "cancelled", message: "Canceled." };
@@ -542,21 +547,22 @@ async function executeClaude(
542
547
  [
543
548
  "-p",
544
549
  "--safe-mode",
545
- "--no-session-persistence",
550
+ ...(isBtw ? [] : ["--no-session-persistence"]),
546
551
  "--disable-slash-commands",
547
552
  "--output-format",
548
553
  "stream-json",
549
554
  "--verbose",
550
555
  "--include-partial-messages",
551
- ...(isAdvisor
556
+ ...(full
552
557
  ? ["--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--dangerously-skip-permissions"]
553
558
  : ["--tools", "", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--permission-mode", "dontAsk"]),
559
+ ...(isBtw && request.continuation ? ["--resume", request.continuation.id] : []),
554
560
  "--model",
555
561
  selection.model,
556
562
  ...(selection.effort ? ["--effort", selection.effort] : []),
557
563
  ],
558
564
  {
559
- cwd: isAdvisor ? request.cwd : runDirectory,
565
+ cwd: runDirectory ?? request.cwd,
560
566
  stdio: ["pipe", "pipe", "pipe"],
561
567
  windowsHide: true,
562
568
  detached: process.platform !== "win32",
@@ -571,6 +577,8 @@ async function executeClaude(
571
577
  let terminalError: string | undefined;
572
578
  let protocolError: string | undefined;
573
579
  let stdoutBuffer = "";
580
+ let initSessionId: string | undefined;
581
+ let resultSessionId: string | undefined;
574
582
 
575
583
  child.stderr?.setEncoding("utf8");
576
584
  child.stderr?.on("data", (chunk: string) => {
@@ -593,6 +601,7 @@ async function executeClaude(
593
601
  throw new Error("Claude emitted invalid stream-json output.");
594
602
  }
595
603
  const topLevel = event.parent_tool_use_id === undefined || event.parent_tool_use_id === null;
604
+ if (topLevel && event.type === "system" && event.subtype === "init" && typeof event.session_id === "string") initSessionId = event.session_id;
596
605
  if (!isAdvisor && topLevel && event.type === "stream_event" && event.event?.type === "content_block_delta") {
597
606
  const delta = event.event.delta;
598
607
  if (delta?.type === "text_delta" && typeof delta.text === "string" && delta.text) {
@@ -612,6 +621,7 @@ async function executeClaude(
612
621
  }
613
622
  }
614
623
  if (event.type !== "result" || !topLevel) return;
624
+ if (typeof event.session_id === "string") resultSessionId = event.session_id;
615
625
  if (event.subtype === "success" && event.is_error === false && (event.stop_reason !== "end_turn" || (event.terminal_reason !== undefined && event.terminal_reason !== "completed"))) {
616
626
  terminalError ??= `Claude did not complete the answer (stop reason: ${String(event.stop_reason)}, terminal reason: ${String(event.terminal_reason)}).`;
617
627
  return;
@@ -667,7 +677,7 @@ async function executeClaude(
667
677
  const cause = attempt.causeOf();
668
678
  if (cause === "cancelled") return { status: "cancelled", message: "Canceled.", partialText };
669
679
  if (cause === "timeout") {
670
- const during = isAdvisor ? "during the advisor consultation" : "while simplifying the response";
680
+ const during = isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
671
681
  return { status: "timeout", message: `Claude timed out ${during}. Run \`/bro doctor\` for setup help.`, partialText };
672
682
  }
673
683
  if (protocolError) return { status: "failure", message: withDoctor(protocolError), partialText };
@@ -688,8 +698,18 @@ async function executeClaude(
688
698
  return { status: "failure", message: withDoctor(`Claude exited without a result event${stderr.trim() ? `: ${stderr.trim()}` : "."}`), partialText };
689
699
  }
690
700
  const text = final.trim();
691
- if (!text) return { status: "failure", message: withDoctor("Claude returned no final answer."), partialText };
692
- return { status: "success", text };
701
+ if (!text) return { status: "failure", message: withDoctor(isBtw ? "Claude returned no answer for the side question." : "Claude returned no final answer."), partialText };
702
+ if (!isBtw) return { status: "success", text };
703
+ // btw continuation: the session id must be reported, consistent, and (on resume) unchanged.
704
+ const sessionId = resultSessionId ?? initSessionId;
705
+ if (!sessionId || (initSessionId !== undefined && initSessionId !== sessionId) || (request.continuation && sessionId !== request.continuation.id)) {
706
+ return {
707
+ status: "failure",
708
+ message: withDoctor(`Claude reported an inconsistent session id (expected ${request.continuation?.id ?? "one id"}, init ${initSessionId ?? "none"}, result ${resultSessionId ?? "none"}).`),
709
+ partialText,
710
+ };
711
+ }
712
+ return { status: "success", text, continuation: { id: sessionId } };
693
713
  } finally {
694
714
  if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
695
715
  }
@@ -979,8 +999,9 @@ export async function execute(
979
999
  return executeGrok(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs);
980
1000
  }
981
1001
  if (selection.backend === "claude") {
982
- if (!backendSupports("claude", request.feature)) {
983
- return { status: "failure", message: "Claude does not support /bro btw yet; switch btw back to Agy in `/bro config`." };
1002
+ // Claude btw always runs (and resumes) in the caller's workspace, even restricted.
1003
+ if (request.feature === "btw" && !request.cwd?.trim()) {
1004
+ return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
984
1005
  }
985
1006
  if (!selection.model.trim() || (selection.effort !== undefined && !CLAUDE_EFFORTS.includes(selection.effort))) {
986
1007
  return { status: "failure", message: "Unsupported Claude selection: check the model and effort (low, medium, high, xhigh or max)." };