pi-bro 0.24.0 → 0.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bro",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
4
4
  "description": "A sidekick for you and your Pi agent: plain-language explanations, side questions, diagrams, PR review and second opinions, without cluttering the agent's context.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,6 +46,8 @@
46
46
  "src/show.ts",
47
47
  "src/advisor.ts",
48
48
  "src/btw.ts",
49
+ "src/elevate.ts",
50
+ "src/elevate-engine.ts",
49
51
  "src/explain-modal.ts",
50
52
  "src/review.ts",
51
53
  "src/review-source.ts",
@@ -64,9 +66,15 @@
64
66
  "CHANGELOG.md",
65
67
  "LICENSE",
66
68
  "THIRD_PARTY_NOTICES.md",
69
+ "docs/README.md",
70
+ "docs/getting-started.md",
67
71
  "docs/user-guide.md",
68
72
  "docs/pig-compatibility.md",
69
- "docs/guided-review.md"
73
+ "docs/guided-review.md",
74
+ "docs/elevate.md",
75
+ "docs/examples.md",
76
+ "docs/features",
77
+ "docs/reference"
70
78
  ],
71
79
  "publishConfig": {
72
80
  "access": "public"
@@ -75,8 +83,9 @@
75
83
  "node": ">=22.19.0"
76
84
  },
77
85
  "scripts": {
86
+ "docs:check": "node scripts/check-docs.mjs && node --test test/docs-quality.test.mjs",
78
87
  "typecheck": "tsc --noEmit",
79
- "test": "npm run typecheck && node .github/scripts/release-utils.mjs selftest && node --test test/review-ux.test.mjs test/review-doctor.test.mjs test/review-versions.test.mjs test/review-regeneration.test.mjs test/review-modal.test.mjs test/review.test.ts test/review-ui.test.mjs test/review-session.test.mjs test/helpers.test.mjs test/prompt.test.ts test/backend.test.ts test/claude.test.ts test/grok.test.ts test/codex.test.ts test/muse.test.ts test/settings.test.ts test/sources.test.ts test/release.test.ts test/show-html.test.ts test/smoke-rpc.test.ts test/ui-capabilities.test.ts test/modal-contract.test.mjs benchmark/*.test.ts && sh ./test/smoke-test.sh",
88
+ "test": "npm run typecheck && npm run docs:check && node .github/scripts/release-utils.mjs selftest && node --test test/elevate-docs.test.mjs test/elevate.test.ts test/review-ux.test.mjs test/review-doctor.test.mjs test/review-versions.test.mjs test/review-regeneration.test.mjs test/review-modal.test.mjs test/review.test.ts test/review-ui.test.mjs test/review-session.test.mjs test/helpers.test.mjs test/prompt.test.ts test/backend.test.ts test/claude.test.ts test/grok.test.ts test/codex.test.ts test/muse.test.ts test/settings.test.ts test/sources.test.ts test/release.test.ts test/show-html.test.ts test/smoke-rpc.test.ts test/ui-capabilities.test.ts test/modal-contract.test.mjs benchmark/*.test.ts && sh ./test/smoke-test.sh",
80
89
  "benchmark:dry-run": "node benchmark/run.ts dry-run",
81
90
  "benchmark:run": "node benchmark/run.ts run",
82
91
  "benchmark:report": "node benchmark/run.ts report",
package/src/advisor.ts CHANGED
@@ -228,8 +228,7 @@ export type AdvisorActivityCallback = (label: string, timestamp: number) => void
228
228
  // Thin wrapper around the shared backend: advisor's stdin/stream-json transport, activity parsing,
229
229
  // and process lifecycle now live in backend.ts (see docs/plans/2026-09-19-bro-advisor-design.md,
230
230
  // "Transport" for why stdin rather than --print). This function keeps its exact existing
231
- // signature/throw contract -- it is called directly by tests and by runAdvisorWithRetries below,
232
- // which owns the 3-attempt retry/backoff policy the backend itself never performs.
231
+ // signature/throw contract -- it is called directly by tests and by runAdvisor below.
233
232
  export class TerminalConsultError extends Error {
234
233
  readonly status: "failure" | "cancelled" | "timeout";
235
234
  constructor(status: "failure" | "cancelled" | "timeout", message: string) {
@@ -262,32 +261,6 @@ export async function runAdvisorConsultation(
262
261
  throw new TerminalConsultError(outcome.status, outcome.message);
263
262
  }
264
263
 
265
- function advisorDelay(ms: number, signal: AbortSignal, onTick?: (remainingMs: number) => void): Promise<void> {
266
- return new Promise((resolve, reject) => {
267
- if (signal.aborted) {
268
- reject(new Error("Canceled."));
269
- return;
270
- }
271
- const startedAt = Date.now();
272
- const interval = onTick ? setInterval(() => onTick(Math.max(0, ms - (Date.now() - startedAt))), 1_000) : undefined;
273
- interval?.unref();
274
- const cleanup = () => {
275
- clearTimeout(timer);
276
- if (interval) clearInterval(interval);
277
- signal.removeEventListener("abort", onAbort);
278
- };
279
- const onAbort = () => {
280
- cleanup();
281
- reject(new Error("Canceled."));
282
- };
283
- const timer = setTimeout(() => {
284
- cleanup();
285
- resolve();
286
- }, ms);
287
- signal.addEventListener("abort", onAbort, { once: true });
288
- });
289
- }
290
-
291
264
  export type AdvisorConsult = (
292
265
  prompt: string,
293
266
  selection: BackendSelection,
@@ -296,14 +269,9 @@ export type AdvisorConsult = (
296
269
  killEscalationMs?: number,
297
270
  onActivity?: AdvisorActivityCallback,
298
271
  ) => Promise<string>;
299
- export type AdvisorDelayFn = (ms: number, signal: AbortSignal, onTick?: (remainingMs: number) => void) => Promise<void>;
300
272
  export type AdvisorToolDetails = {
301
- status: "investigating" | "retrying" | "done";
302
- attempt: number;
303
- of: number;
273
+ status: "investigating" | "done";
304
274
  elapsedMs: number;
305
- error?: string;
306
- retryInMs?: number;
307
275
  backend?: BackendName;
308
276
  model?: string;
309
277
  effort?: string;
@@ -314,136 +282,98 @@ export type AdvisorToolDetails = {
314
282
  snapshotChars?: number;
315
283
  broTruncated?: false;
316
284
  omissions?: string;
317
- // Real backend-reported activity for the current attempt only -- reset to empty whenever a new
318
- // attempt (including a retry) starts, never carried over from a prior failed attempt.
285
+ // Real backend-reported activity for this consultation, bounded to the last 4 labels.
319
286
  activity?: string[];
320
287
  lastActivityAt?: number;
321
288
  // Total count of accepted activity events (tool calls AND user-facing text deltas -- "activity"
322
- // is the honest label, not "tool calls") observed across the whole consultation, including every
323
- // retry attempt. Unlike `activity` above, this never resets on a retry; the final "done" details
324
- // carry the same running total.
289
+ // is the honest label, not "tool calls") observed across the consultation.
325
290
  activityCount?: number;
326
291
  };
327
292
  export type AdvisorProgressCallback = (details: AdvisorToolDetails) => void;
328
- export type AdvisorRunResult = { advice: string; attempts: number; durationMs: number; activityCount: number };
293
+ export type AdvisorRunResult = { advice: string; durationMs: number; activityCount: number };
329
294
 
330
295
  // Bursts of chunked NDJSON activity events are coalesced to this cadence so a fast stream of tool
331
296
  // calls/text deltas doesn't flood onProgress; the 1s elapsed-time heartbeat below is unaffected and
332
297
  // keeps ticking independently.
333
298
  const ADVISOR_ACTIVITY_THROTTLE_MS = 250;
334
299
 
335
- // `consult`/`delayFn` are injectable so tests can swap in a fake process spawn and a fake clock
336
- // instead of spawning real processes and waiting 15 real seconds. Every attempt sends the
337
- // identical prompt/selection/cwd to a fresh, standalone backend process — never resumed via
338
- // --conversation, even across retries.
339
- export async function runAdvisorWithRetries(
300
+ // `consult` is injectable so tests can swap in a fake process spawn instead of spawning real
301
+ // processes. Every consultation runs once against a fresh, standalone backend process — never
302
+ // resumed via --conversation and never automatically retried.
303
+ export async function runAdvisor(
340
304
  prompt: string,
341
305
  selection: BackendSelection,
342
306
  cwd: string,
343
307
  signal: AbortSignal,
344
308
  consult: AdvisorConsult = runAdvisorConsultation,
345
- delayFn: AdvisorDelayFn = advisorDelay,
346
309
  onProgress?: AdvisorProgressCallback,
347
310
  progressIntervalMs = 1_000,
348
311
  activityThrottleMs = ADVISOR_ACTIVITY_THROTTLE_MS,
349
312
  ): Promise<AdvisorRunResult> {
350
- const delays = [5_000, 10_000];
351
- const totalAttempts = delays.length + 1;
313
+ if (signal.aborted) throw new Error("Canceled.");
352
314
  const startedAt = Date.now();
353
- let lastError: unknown;
354
- // Cumulative across every attempt in this consultation, including retries -- unlike `activity`
355
- // below, a retry must never reset this back to zero.
315
+ const activity: string[] = [];
356
316
  let totalActivityCount = 0;
357
- for (let attempt = 0; attempt <= delays.length; attempt++) {
358
- if (signal.aborted) throw new Error("Canceled.");
359
- // Fresh per attempt: a retry must never show the previous attempt's activity trail.
360
- const activity: string[] = [];
361
- let lastActivityAt: number | undefined;
362
- let lastActivityEmitAt = 0;
363
- let pendingActivityTimer: ReturnType<typeof setTimeout> | undefined;
317
+ let lastActivityAt: number | undefined;
318
+ let lastActivityEmitAt = 0;
319
+ let pendingActivityTimer: ReturnType<typeof setTimeout> | undefined;
364
320
 
365
- const running = (): AdvisorToolDetails => ({
366
- status: "investigating",
367
- attempt: attempt + 1,
368
- of: totalAttempts,
369
- elapsedMs: Date.now() - startedAt,
370
- activity: [...activity],
371
- lastActivityAt,
372
- activityCount: totalActivityCount,
373
- });
374
- const emitRunning = () => onProgress?.(running());
375
- emitRunning();
376
- const progressTimer = onProgress ? setInterval(emitRunning, progressIntervalMs) : undefined;
377
- progressTimer?.unref();
321
+ const running = (): AdvisorToolDetails => ({
322
+ status: "investigating",
323
+ elapsedMs: Date.now() - startedAt,
324
+ activity: [...activity],
325
+ lastActivityAt,
326
+ activityCount: totalActivityCount,
327
+ });
328
+ const emitRunning = () => onProgress?.(running());
329
+ emitRunning();
330
+ const progressTimer = onProgress ? setInterval(emitRunning, progressIntervalMs) : undefined;
331
+ progressTimer?.unref();
378
332
 
379
- const stopActivityThrottle = () => {
380
- if (pendingActivityTimer) {
381
- clearTimeout(pendingActivityTimer);
333
+ const stopActivityThrottle = () => {
334
+ if (pendingActivityTimer) {
335
+ clearTimeout(pendingActivityTimer);
336
+ pendingActivityTimer = undefined;
337
+ }
338
+ };
339
+ const onActivity: AdvisorActivityCallback = (label, timestamp) => {
340
+ const normalized = label.replace(/\s+/g, " ").trim();
341
+ if (!normalized) return;
342
+ activity.push(normalized);
343
+ if (activity.length > MAX_ADVISOR_ACTIVITY_LINES) activity.splice(0, activity.length - MAX_ADVISOR_ACTIVITY_LINES);
344
+ totalActivityCount += 1;
345
+ lastActivityAt = timestamp;
346
+ if (!onProgress) return;
347
+ const elapsed = Date.now() - lastActivityEmitAt;
348
+ if (elapsed >= activityThrottleMs) {
349
+ lastActivityEmitAt = Date.now();
350
+ emitRunning();
351
+ return;
352
+ }
353
+ if (!pendingActivityTimer) {
354
+ pendingActivityTimer = setTimeout(() => {
382
355
  pendingActivityTimer = undefined;
383
- }
384
- };
385
- const onActivity: AdvisorActivityCallback = (label, timestamp) => {
386
- const normalized = label.replace(/\s+/g, " ").trim();
387
- if (!normalized) return;
388
- activity.push(normalized);
389
- if (activity.length > MAX_ADVISOR_ACTIVITY_LINES) activity.splice(0, activity.length - MAX_ADVISOR_ACTIVITY_LINES);
390
- totalActivityCount += 1;
391
- lastActivityAt = timestamp;
392
- if (!onProgress) return;
393
- const elapsed = Date.now() - lastActivityEmitAt;
394
- if (elapsed >= activityThrottleMs) {
395
356
  lastActivityEmitAt = Date.now();
396
357
  emitRunning();
397
- return;
398
- }
399
- if (!pendingActivityTimer) {
400
- pendingActivityTimer = setTimeout(() => {
401
- pendingActivityTimer = undefined;
402
- lastActivityEmitAt = Date.now();
403
- emitRunning();
404
- }, activityThrottleMs - elapsed);
405
- pendingActivityTimer.unref?.();
406
- }
407
- };
408
-
409
- try {
410
- const advice = await consult(prompt, selection, cwd, signal, undefined, onActivity);
411
- // `totalActivityCount` is read directly here rather than from the last onProgress snapshot,
412
- // which may lag behind by up to `activityThrottleMs` -- the final count must be exact.
413
- return { advice, attempts: attempt + 1, durationMs: Date.now() - startedAt, activityCount: totalActivityCount };
414
- } catch (error) {
415
- if (progressTimer) clearInterval(progressTimer);
416
- stopActivityThrottle();
417
- if (signal.aborted || errorMessage(error) === "Canceled." ||
418
- (error instanceof TerminalConsultError && error.status !== "failure")) throw error;
419
- lastError = error;
420
- if (attempt === delays.length) break;
421
- const delay = delays[attempt]!;
422
- const retrying = (remainingMs: number): AdvisorToolDetails => ({
423
- status: "retrying",
424
- attempt: attempt + 2,
425
- of: totalAttempts,
426
- elapsedMs: Date.now() - startedAt,
427
- error: errorMessage(error),
428
- retryInMs: remainingMs,
429
- activityCount: totalActivityCount,
430
- });
431
- onProgress?.(retrying(delay));
432
- await delayFn(delay, signal, (remainingMs) => onProgress?.(retrying(remainingMs)));
433
- } finally {
434
- if (progressTimer) clearInterval(progressTimer);
435
- stopActivityThrottle();
358
+ }, activityThrottleMs - elapsed);
359
+ pendingActivityTimer.unref?.();
436
360
  }
361
+ };
362
+
363
+ try {
364
+ const advice = await consult(prompt, selection, cwd, signal, undefined, onActivity);
365
+ // `totalActivityCount` is read directly here rather than from the last onProgress snapshot,
366
+ // which may lag behind by up to `activityThrottleMs` -- the final count must be exact.
367
+ return { advice, durationMs: Date.now() - startedAt, activityCount: totalActivityCount };
368
+ } finally {
369
+ if (progressTimer) clearInterval(progressTimer);
370
+ stopActivityThrottle();
437
371
  }
438
- throw lastError;
439
372
  }
440
373
 
441
374
  export function advisorAttemptLabel(details: AdvisorToolDetails): string {
442
- if (details.status === "retrying") {
443
- return `Bro advisor · retrying in ${Math.ceil((details.retryInMs ?? 0) / 1_000)}s · attempt ${details.attempt}/${details.of}${details.error ? ` — ${details.error}` : ""}`;
444
- }
445
375
  if (details.status === "done") {
446
- return `Bro advisor · ${details.model ?? "unknown model"} · ${details.attempt} attempt${details.attempt === 1 ? "" : "s"} · ${Math.ceil((details.durationMs ?? details.elapsedMs) / 1_000)}s`;
376
+ return `Bro advisor · ${details.model ?? "unknown model"} · ${Math.ceil((details.durationMs ?? details.elapsedMs) / 1_000)}s`;
447
377
  }
448
378
  const latest = details.activity?.at(-1);
449
379
  const backendLabel = details.backend === "claude" ? "Claude" : details.backend === "grok" ? "Grok" : details.backend === "codex" ? "Codex" : details.backend === "muse" ? "Muse" : "Agy";
@@ -452,7 +382,7 @@ export function advisorAttemptLabel(details: AdvisorToolDetails): string {
452
382
  const activityLabel = latest
453
383
  ? `last reported: ${advisorActivityPreview(latest)} (${Math.max(0, Math.floor((Date.now() - (details.lastActivityAt ?? Date.now())) / 1_000))}s ago)`
454
384
  : `awaiting first activity from ${backendLabel}`;
455
- return `Bro advisor · running · ${Math.floor(details.elapsedMs / 1_000)}s · attempt ${details.attempt}/${details.of} · ${activityLabel}`;
385
+ return `Bro advisor · running · ${Math.floor(details.elapsedMs / 1_000)}s · ${activityLabel}`;
456
386
  }
457
387
 
458
388
  export function registerAdvisorTool(pi: ExtensionAPI): void {
@@ -460,8 +390,8 @@ export function registerAdvisorTool(pi: ExtensionAPI): void {
460
390
  name: ADVISOR_TOOL_NAME,
461
391
  label: "Bro advisor",
462
392
  description:
463
- "Consult a fresh, independent process of the configured advisor backend for a second opinion mid-task. It has real, unsandboxed tool access in the current workspace (read files, search, run commands) with permissions auto-approved, and is instructed to investigate before advising and to leave edits to you — that is a behavioral instruction to the advisor, not an enforced restriction, so treat its findings as advice rather than a delegated implementation. No need to prepare a summary or evidence first: Bro automatically captures your system instructions, active tools, and the conversation so far, plus any human-set steering priorities, and sends them to the advisor.",
464
- promptSnippet: "Consult a fresh configured-backend process for a second opinion mid-task; it investigates the workspace itself and returns advice",
393
+ "Consult a fresh, independent process of the configured advisor backend for a second opinion mid-task. It has real, unsandboxed tool access in the current workspace (read files, search, run commands) with permissions auto-approved, and is instructed to use the captured snapshot first, inspect the workspace only when needed, and leave edits to you — that is a behavioral instruction to the advisor, not an enforced restriction, so treat its findings as advice rather than a delegated implementation. No need to prepare a summary or evidence first: Bro automatically captures your system instructions, active tools, and the conversation so far, plus any human-set steering priorities, and sends them to the advisor.",
394
+ promptSnippet: "Consult a fresh configured-backend process for a second opinion mid-task; it reviews the captured session snapshot, can inspect the workspace when needed, and returns advice",
465
395
  promptGuidelines: [
466
396
  "Use bro_advisor when the user asks for an independent opinion, or when one would likely settle real uncertainty or catch a costly mistake. Routine decisions do not require consultation.",
467
397
  "bro_advisor's question parameter is optional — Bro captures context automatically, so you do not need to prepare a summary or evidence before calling.",
@@ -483,21 +413,20 @@ export function registerAdvisorTool(pi: ExtensionAPI): void {
483
413
  const prompt = buildAdvisorPrompt({ durable: durableSteering, session: state.steering }, snapshot.text, params?.question);
484
414
  const hasDurable = Boolean(durableSteering.trim());
485
415
  const hasSession = Boolean(state.steering.trim());
486
- let lastAttempt: AdvisorToolDetails = { status: "investigating", attempt: 1, of: 3, elapsedMs: 0, backend };
416
+ let lastProgress: AdvisorToolDetails = { status: "investigating", elapsedMs: 0, backend };
487
417
  try {
488
- const run = await runAdvisorWithRetries(
418
+ const run = await runAdvisor(
489
419
  prompt,
490
420
  selection,
491
421
  ctx.cwd,
492
422
  signal ?? new AbortController().signal,
493
423
  undefined,
494
- undefined,
495
424
  (details) => {
496
- lastAttempt = { ...details, backend };
497
- const label = advisorAttemptLabel(lastAttempt);
425
+ lastProgress = { ...details, backend };
426
+ const label = advisorAttemptLabel(lastProgress);
498
427
  onUpdate?.({
499
428
  content: [{ type: "text", text: label }],
500
- details: lastAttempt,
429
+ details: lastProgress,
501
430
  });
502
431
  // A host's tool-card renderer may replace onUpdate's partial renderResult with its
503
432
  // own generic placeholder for the whole run (observed with pi-cc-extensions' default
@@ -511,9 +440,8 @@ export function registerAdvisorTool(pi: ExtensionAPI): void {
511
440
  const effort = selection.effort ?? "model default";
512
441
  const omissions = `image/reasoning bodies omitted when present; ${snapshot.hadCompaction ? "Pi compaction summaries replace earlier turns" : "no Pi compaction summary present"}`;
513
442
  const details: AdvisorToolDetails = {
514
- ...lastAttempt,
443
+ ...lastProgress,
515
444
  status: "done",
516
- attempt: run.attempts,
517
445
  backend,
518
446
  model: selection.model,
519
447
  effort,
@@ -524,12 +452,12 @@ export function registerAdvisorTool(pi: ExtensionAPI): void {
524
452
  snapshotChars: snapshot.text.length,
525
453
  broTruncated: false,
526
454
  omissions,
527
- // run.activityCount is the exact final total; lastAttempt's may lag behind by up to
455
+ // run.activityCount is the exact final total; lastProgress's may lag behind by up to
528
456
  // the activity throttle window.
529
457
  activityCount: run.activityCount,
530
458
  };
531
- // The backend qualifier appears only off Agy, keeping the Agy header byte-identical.
532
- const header = `Bro advisor · ${backend === "agy" ? "" : `backend: ${backend} · `}model: ${selection.model} · effort: ${effort} · ${run.attempts} attempt${run.attempts === 1 ? "" : "s"} · ${Math.ceil(run.durationMs / 1_000)}s`;
459
+ // The backend qualifier appears only off Agy, keeping the Agy header concise.
460
+ const header = `Bro advisor · ${backend === "agy" ? "" : `backend: ${backend} · `}model: ${selection.model} · effort: ${effort} · ${Math.ceil(run.durationMs / 1_000)}s`;
533
461
  const steeringSummary = hasDurable && hasSession
534
462
  ? "included (durable+session)"
535
463
  : hasDurable
package/src/backend.ts CHANGED
@@ -1376,11 +1376,10 @@ async function executeMuse(
1376
1376
  }
1377
1377
  }
1378
1378
 
1379
- // Single-attempt executor shared by all features. Never retries (retries are feature-owned,
1380
- // e.g. advisor's 3-attempt backoff in bro.ts); never spawns a pre-aborted request; on cancellation,
1381
- // host deadline, or a protocol failure, stops the whole POSIX process group (SIGTERM, then SIGKILL
1382
- // after a bounded grace period) before resolving. `options` is for offline tests only -- production
1383
- // callers never override the deadline or kill-escalation delay.
1379
+ // Single-attempt executor shared by all features. Never retries; never spawns a pre-aborted
1380
+ // request; on cancellation, host deadline, or a protocol failure, stops the whole POSIX process
1381
+ // group (SIGTERM, then SIGKILL after a bounded grace period) before resolving. `options` is for
1382
+ // offline tests only -- production callers never override the deadline or kill-escalation delay.
1384
1383
  export async function execute(
1385
1384
  request: BackendRequest,
1386
1385
  selection: BackendSelection,
package/src/bro.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  resolveBtwThread,
9
9
  } from "./btw.ts";
10
10
  import { doctorReport, showBroConfigModal } from "./doctor.ts";
11
+ import { openElevateModal } from "./elevate.ts";
11
12
  import {
12
13
  type BroResult,
13
14
  type ModalResult,
@@ -53,6 +54,8 @@ export * from "./show.ts";
53
54
  export * from "./advisor.ts";
54
55
  export * from "./doctor.ts";
55
56
  export * from "./btw.ts";
57
+ export * from "./elevate.ts";
58
+ export * from "./elevate-engine.ts";
56
59
  export * from "./explain-modal.ts";
57
60
 
58
61
  const COMMANDS = [
@@ -67,6 +70,7 @@ const COMMANDS = [
67
70
  { value: "preferences", label: "preferences", description: "View or edit what Bro knows about you and how you like answers" },
68
71
  { value: "guided-review", label: "guided-review", description: "Understand and assess a PR with captured code and optional private questions" },
69
72
  { value: "btw", label: "btw", description: "Open a side conversation (starts conversation-only; /mode toggles full permission)" },
73
+ { value: "elevate", label: "elevate", description: "Discover missing expert considerations, then improve a request" },
70
74
  { value: "config", label: "config", description: "Configure shared defaults and per-capability model/effort overrides" },
71
75
  { value: "advisor", label: "advisor", description: "Check whether the executor's advisor tool is available right now" },
72
76
  { value: "advisor-steer", label: "advisor-steer", description: "View, edit, save, or clear the advisor's persistent steering brief" },
@@ -281,6 +285,13 @@ export default async function bro(pi: ExtensionAPI) {
281
285
  return;
282
286
  }
283
287
 
288
+ if (action === "elevate") {
289
+ if (!hasBroCustomUi(ctx)) { ctx.ui.notify("Use /bro elevate in Pi interactive UI.", "warning"); return; }
290
+ try { await openElevateModal(ctx, value); }
291
+ catch (error) { ctx.ui.notify(withDoctor(error), "error"); }
292
+ return;
293
+ }
294
+
284
295
  if (action === "config") {
285
296
  if (parts.length !== 1) {
286
297
  ctx.ui.notify("Use /bro config.", "warning");
@@ -419,6 +430,20 @@ export default async function bro(pi: ExtensionAPI) {
419
430
  },
420
431
  });
421
432
 
433
+ // Registered after /bro and isolated: a shortcut that cannot register must never remove the
434
+ // working commands. The shortcut can read an unsent draft and uses only context and UI methods
435
+ // shared by ExtensionContext and ExtensionCommandContext.
436
+ try {
437
+ if (typeof pi.registerShortcut === "function") pi.registerShortcut("ctrl+shift+e", {
438
+ description: "Bro Elevate: improve the current editor draft",
439
+ handler: async (ctx) => {
440
+ if (!hasBroCustomUi(ctx)) { ctx.ui.notify("Elevate requires Pi interactive UI.", "warning"); return; }
441
+ try { await openElevateModal(ctx as import("@earendil-works/pi-coding-agent").ExtensionCommandContext); }
442
+ catch (error) { ctx.ui.notify(withDoctor(error), "error"); }
443
+ },
444
+ });
445
+ } catch { /* /bro still works; the command is the supported path to Elevate */ }
446
+
422
447
  // Keep the command available even when Bro cannot create its settings file; Doctor can then explain the problem.
423
448
  await ensureSettingsFile().catch(() => undefined);
424
449
  }