bermudis-pi-goodies 0.27.7 → 0.27.9

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/README.md CHANGED
@@ -8,7 +8,7 @@ extensions. One entry point, thirteen independent features.
8
8
  | `copy-with-model` | `/copy-with-model` | Copy last assistant message to the clipboard in a code fence tagged with the model name. |
9
9
  | `copy-trajectory` | `/copy-trajectory [thinking]` | Copy the whole conversation (user + assistant text, tool calls stripped) to the clipboard; `thinking` also includes assistant thinking blocks. |
10
10
  | `name-with-ai` | `/name-with-ai [name]` | Generate a short session name from the first user message (or set one manually). |
11
- | `prefer-tools` | hook (no command) | Blocks legacy tools: `rm` → `trash <path>` (and `trash-rm`/`trash-empty`, which delete permanently), bare `python`/`pip`/`pytest`/`mypy` → `uv`. On `glm-5.*` models it also blocks bash `rg` (they invent ripgrep flags) — the built-in grep tool still works. |
11
+ | `prefer-tools` | hook (no command) | Blocks legacy tools: `rm` → `trash <path>` (and `trash-rm`/`trash-empty`, which delete permanently), `python`/`pip`/`pytest`/`mypy` are not blocked (removed 2026-10-24 — steering to `uv` is AGENTS.md's job, not the hook's). Bash `rg` is allowed, except the replace flag: `-r` (including clusters like `-rn`) is blocked because it is `--replace` and silently replaces matched text — use `--replace` explicitly if you mean it. The built-in grep tool still works. |
12
12
  | `keep-model-on-new` | hook (no command) | Keep the active model when `/new` starts a fresh session instead of reverting to pi's saved default model. |
13
13
  | `model-thinking` | `/model-thinking` | Per-model default thinking levels: save the current level as this model's default and get it back on every switch to that model, instead of pi's global default. |
14
14
  | `clean-tui` | tool overrides (no command) | Collapse built-in tool output for a cleaner TUI: back-to-back same-tool calls share one block (e.g. `read ×2`) until a boundary — visible text (assistant prose or a typed user message) or a thinking block (even an empty one, as OpenAI emits between tool calls) — so reasoning-per-call models render one block per call. Images stay visible without expanding, expand a row with ctrl+o to see the full command and results/diffs. Long bash commands get an AI-generated summary once you pick a model with `/goodies summary-model <provider/model>` (see "Smart summaries" below) — expanding a row swaps the summary back out for the raw command. While enabled, also flips `@bermudi/pi-codex`'s `apply_patch`/`web_search` into the same burst style. Long thinking runs can show a live plain-English line above the editor (`/goodies thinking-summaries on`) instead of a static `Thinking...` — see "Smart summaries". |
@@ -25,7 +25,7 @@ extensions. One entry point, thirteen independent features.
25
25
  First install, pinned to a published version:
26
26
 
27
27
  ```bash
28
- pi install npm:bermudis-pi-goodies@0.27.7
28
+ pi install npm:bermudis-pi-goodies@0.27.9
29
29
  ```
30
30
 
31
31
  Picking up a new release on an existing install (`pi install` is not the
@@ -42,8 +42,13 @@ the balance footer). State persists to `~/.pi/agent/goodies.json`.
42
42
  While enabled, the tool overrides carry the built-ins' full definitions —
43
43
  schema, execution, and system-prompt contributions (`promptSnippet`,
44
44
  `promptGuidelines`) — so the model sees exactly what vanilla pi would show it;
45
- the extension is purely presentational. Every feature toggle, clean-tui
46
- included, takes effect on the next `/reload` or new session.
45
+ the extension is purely presentational. They re-register with
46
+ `defaultActive: false`: registering a tool name in pi activates it by
47
+ default, which used to force `find`/`grep`/`ls` (pi's bash-less fallback
48
+ trio) into every session — registration is now strictly a renderer swap and
49
+ the active tool set stays exactly what vanilla pi would pick. Every feature
50
+ toggle, clean-tui included, takes effect on the next `/reload` or new
51
+ session.
47
52
  Kilo's provider and its balance footer are bundled here.
48
53
 
49
54
  ## Smart summaries for long bash commands
package/clean-tui.ts CHANGED
@@ -1383,6 +1383,19 @@ export default function cleanTui(pi: ExtensionAPI): void {
1383
1383
  const definition = {
1384
1384
  ...native, // retain promptSnippet/Guidelines, schema, execution mode, preparation and all future metadata
1385
1385
  ...burst,
1386
+ // Renderer override ONLY — registration must never touch activation.
1387
+ // pi activates any direct-exposure tool on registration unless
1388
+ // defaultActive is false (agent-session _isActivatedOnRegistration),
1389
+ // so re-registering the built-in names here used to force-activate
1390
+ // find/grep/ls — pi keeps them for bash-less read-only sessions — in
1391
+ // every session, and symmetrically would force bash into one. With
1392
+ // defaultActive: false the definitions sit in the registry with their
1393
+ // burst renderers for whenever the tool IS active: already-active
1394
+ // tools stay active through re-registration (the refresh seeds the
1395
+ // next active set from the previous one), inactive ones stay inactive
1396
+ // until pi's own gating (base defaults, --tools/defaultTools, /tools,
1397
+ // setActiveTools) names them.
1398
+ defaultActive: false,
1386
1399
  async execute(
1387
1400
  toolCallId: string,
1388
1401
  params: never,
package/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * - copy-with-model /copy-with-model copy last reply tagged with the model
7
7
  * - copy-trajectory /copy-trajectory copy the whole conversation (text only) to the clipboard
8
8
  * - name-with-ai /name-with-ai generate a session name via the model
9
- * - prefer-tools hook block legacy tools (trash/uv; blocks rg on glm-5.*)
9
+ * - prefer-tools hook block legacy tools (trash; blocks rg's -r replace flag)
10
10
  * - keep-model hook preserve the active model across /new
11
11
  * - model-thinking /model-thinking per-model default thinking levels, applied on switch
12
12
  * - clean-tui tool overrides collapse built-in tool output; keep a one-line call header, hide results/diffs until expanded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.27.7",
3
+ "version": "0.27.9",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/bermudi/agent-extensions.git",
package/prefer-tools.ts CHANGED
@@ -13,12 +13,17 @@
13
13
  * rm -> trash <path> (moves to the trash can; recoverable)
14
14
  * trash-rm -> blocked (permanently deletes from the trash can)
15
15
  * trash-empty -> blocked (permanently empties the trash can)
16
- * python/pip/pytest/ -> uv
17
- * mypy
18
16
  *
19
- * Model-conditional: on glm-5.* models, `rg` is also blocked — they invent
20
- * ripgrep flags. The built-in grep tool (structured args, no raw flags) and
21
- * bash `grep -rn` still work.
17
+ * python/pip/pytest/mypy are deliberately NOT blocked (removed 2026-10-24):
18
+ * steering those is AGENTS.md's job ("uv always"), not the hook's — telling
19
+ * "inline" python apart from script-file python would need a bash-semantics
20
+ * matrix, and the uv block fired mostly as friction after the fact.
21
+ *
22
+ * `rg` is allowed, but its replace flag is not: `-r` is ripgrep's
23
+ * `--replace`, so `rg -rn` silently replaces matches with "n" instead of
24
+ * showing line numbers. Any short-option cluster containing `r` is blocked;
25
+ * spell out `--replace` if you actually mean it. The built-in grep tool
26
+ * (structured args, no raw flags) and bash `grep -rn` still work.
22
27
  */
23
28
  import {
24
29
  isToolCallEventType,
@@ -41,32 +46,16 @@ const RULES: Rule[] = [
41
46
  reason:
42
47
  "trash-rm and trash-empty are blocked — they delete permanently from the trash can. Use `trash <path>` to move files to the trash (recoverable).",
43
48
  },
44
- {
45
- names: ["python", "python3", "pip", "pip3", "pytest", "mypy"],
46
- reason:
47
- "bare python/pip/pytest/mypy are blocked — use `uv` (e.g. `uv run python`, `uv add`, `uv pip install <pkg>`, `uv run pytest`/`mypy`)",
48
- },
49
49
  ];
50
50
 
51
51
  /**
52
- * glm-5.* model ids hallucinate ripgrep flags; bash `rg` is blocked for them.
53
- * Matches "glm-5" as a family token: not preceded by an alphanumeric (so
54
- * Meta's xglm-564M doesn't match) and not followed by a digit (so glm-50,
55
- * glm-500 don't match). glm-5, glm-5.5, glm-5-turbo, glm-5:free,
56
- * provider-prefixed "kilo/glm-5" all match. glm-4.x is unaffected.
52
+ * `rg -r` is ripgrep's `--replace`, not "recursive": it replaces matched text
53
+ * in the output, so `rg -rn` replaces with "n" and shows no line numbers
54
+ * (machine lore: it once spawned a phantom `[ln]` log tag). Block the short
55
+ * flag in any cluster, but allow the explicit long form as the escape hatch.
57
56
  */
58
- export function isRipgrepBlockedModel(modelId: string | undefined): boolean {
59
- return (
60
- typeof modelId === "string" && /(^|[^a-z0-9])glm-5(?![0-9])/i.test(modelId)
61
- );
62
- }
63
-
64
- /** Extra rule appended when the active model is glm-5.*. */
65
- export const RG_RULE: Rule = {
66
- names: ["rg"],
67
- reason:
68
- 'rg is blocked for this model — it invents ripgrep flags. Use `grep -rn "pattern" path` instead (or pi\'s built-in grep tool).',
69
- };
57
+ export const RG_REPLACE_REASON =
58
+ '`rg -r` is `--replace` — it silently replaces matched text in the output (`rg -rn` replaces with "n" instead of showing line numbers). If you really mean it, use `--replace` explicitly.';
70
59
 
71
60
  const COMMAND_PREFIX_KEYWORDS = new Set([
72
61
  "if",
@@ -86,8 +75,8 @@ const WORD_STOP = " \t\n\r|&;<>()\"'`$";
86
75
  * `NAME=value` (or `NAME+=value`) in command position is an env-var
87
76
  * assignment, never the command itself — bash runs the word *after* it.
88
77
  * Without this check the lexer treated `FOO=bar` as the command, found no
89
- * rule match, demoted command position, and `rm`/`python` sailed through as
90
- * a mere argument (`FOO=bar rm x`, `PYTHONDONTWRITEBYTECODE=1 python x.py`).
78
+ * rule match, demoted command position, and `rm` sailed through as
79
+ * a mere argument (`FOO=bar rm x`).
91
80
  * The name must be a portable shell identifier, matching bash's own rule —
92
81
  * `1=2 x` is not an assignment in bash either (it is a command name, so the
93
82
  * real command never runs). readWord stops at `$`/quotes, so `FOO="a b"` and
@@ -97,19 +86,34 @@ function isAssignmentWord(word: string): boolean {
97
86
  return /^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(word);
98
87
  }
99
88
 
100
- function matchCommand(
101
- name: string,
102
- rules: readonly Rule[],
103
- ): string | undefined {
104
- const base = name.includes("/")
105
- ? name.slice(name.lastIndexOf("/") + 1)
106
- : name;
107
- for (const rule of rules) {
89
+ function commandBase(name: string): string {
90
+ return name.includes("/") ? name.slice(name.lastIndexOf("/") + 1) : name;
91
+ }
92
+
93
+ function matchCommand(name: string): string | undefined {
94
+ const base = commandBase(name);
95
+ for (const rule of RULES) {
108
96
  if (rule.names.includes(base)) return rule.reason;
109
97
  }
110
98
  return undefined;
111
99
  }
112
100
 
101
+ /**
102
+ * Match a command word, plus the one argument-sensitive rule: an `rg`
103
+ * invocation that uses the replace flag. `argStart` is the offset just past
104
+ * the command word, where its arguments begin.
105
+ */
106
+ function matchCommandAt(
107
+ command: string,
108
+ word: string,
109
+ argStart: number,
110
+ ): string | undefined {
111
+ if (commandBase(word) === "rg" && rgUsesReplaceFlag(command, argStart)) {
112
+ return RG_REPLACE_REASON;
113
+ }
114
+ return matchCommand(word);
115
+ }
116
+
113
117
  function readWord(s: string, i: number): { word: string; next: number } {
114
118
  // Build the word char by char so backslash escapes strip the backslash:
115
119
  // `\rm` → `rm`, `foo\ bar` → `foo bar`. The old slice-based version kept
@@ -253,7 +257,6 @@ function scanExecutedSubs(
253
257
  s: string,
254
258
  start: number,
255
259
  end: number,
256
- rules: readonly Rule[],
257
260
  mode: "plain" | "double",
258
261
  ): string | undefined {
259
262
  let i = start;
@@ -273,7 +276,7 @@ function scanExecutedSubs(
273
276
  if (c === '"') {
274
277
  const stop = readQuote(s, i, '"', true);
275
278
  const innerEnd = s[stop - 1] === '"' ? stop - 1 : stop;
276
- const reason = scanExecutedSubs(s, i + 1, innerEnd, rules, "double");
279
+ const reason = scanExecutedSubs(s, i + 1, innerEnd, "double");
277
280
  if (reason) return reason;
278
281
  i = stop;
279
282
  continue;
@@ -293,7 +296,7 @@ function scanExecutedSubs(
293
296
  if (c === "`") {
294
297
  const stop = readQuote(s, i, "`", true);
295
298
  const innerEnd = s[stop - 1] === "`" ? stop - 1 : stop;
296
- const reason = detectLegacyTool(s.slice(i + 1, innerEnd), rules);
299
+ const reason = detectLegacyTool(s.slice(i + 1, innerEnd));
297
300
  if (reason) return reason;
298
301
  i = stop;
299
302
  continue;
@@ -306,7 +309,7 @@ function scanExecutedSubs(
306
309
  const stop = skipBalancedParens(s, i, 3);
307
310
  const innerEnd =
308
311
  s[stop - 1] === ")" && s[stop - 2] === ")" ? stop - 2 : stop;
309
- const reason = scanExecutedSubs(s, i + 3, innerEnd, rules, mode);
312
+ const reason = scanExecutedSubs(s, i + 3, innerEnd, mode);
310
313
  if (reason) return reason;
311
314
  i = stop;
312
315
  continue;
@@ -314,7 +317,7 @@ function scanExecutedSubs(
314
317
  if (c === "$" && s.startsWith("$(", i)) {
315
318
  const stop = skipBalancedParens(s, i, 2);
316
319
  const innerEnd = s[stop - 1] === ")" ? stop - 1 : stop;
317
- const reason = detectLegacyTool(s.slice(i + 2, innerEnd), rules);
320
+ const reason = detectLegacyTool(s.slice(i + 2, innerEnd));
318
321
  if (reason) return reason;
319
322
  i = stop;
320
323
  continue;
@@ -327,7 +330,7 @@ function scanExecutedSubs(
327
330
  // substitution runs (`"${X:-'$(rm)'}"` executes).
328
331
  const stop = skipBalancedBraces(s, i);
329
332
  const innerEnd = s[stop - 1] === "}" ? stop - 1 : stop;
330
- const reason = scanExecutedSubs(s, i + 2, innerEnd, rules, mode);
333
+ const reason = scanExecutedSubs(s, i + 2, innerEnd, mode);
331
334
  if (reason) return reason;
332
335
  i = stop;
333
336
  continue;
@@ -340,7 +343,6 @@ function scanExecutedSubs(
340
343
  function readDollar(
341
344
  s: string,
342
345
  i: number,
343
- rules: readonly Rule[],
344
346
  ): { next: number; reason?: string } | null {
345
347
  if (i >= s.length || s[i] !== "$") return null;
346
348
 
@@ -354,14 +356,14 @@ function readDollar(
354
356
  const innerEnd = s[end - 1] === ")" && s[end - 2] === ")" ? end - 2 : end;
355
357
  return {
356
358
  next: end,
357
- reason: scanExecutedSubs(s, i + 3, innerEnd, rules, "plain"),
359
+ reason: scanExecutedSubs(s, i + 3, innerEnd, "plain"),
358
360
  };
359
361
  }
360
362
  if (s.startsWith("(", i + 1)) {
361
363
  const end = skipBalancedParens(s, i, 2);
362
364
  const closeParen = end > 0 && s[end - 1] === ")" ? 1 : 0;
363
365
  const inner = s.slice(i + 2, end - closeParen);
364
- const reason = detectLegacyTool(inner, rules);
366
+ const reason = detectLegacyTool(inner);
365
367
  return { next: end, reason };
366
368
  }
367
369
  if (s.startsWith("{", i + 1)) {
@@ -370,7 +372,7 @@ function readDollar(
370
372
  const innerEnd = s[end - 1] === "}" ? end - 1 : end;
371
373
  return {
372
374
  next: end,
373
- reason: scanExecutedSubs(s, i + 2, innerEnd, rules, "plain"),
375
+ reason: scanExecutedSubs(s, i + 2, innerEnd, "plain"),
374
376
  };
375
377
  }
376
378
  if (i + 1 < s.length && /[0-9?@*#\-!$]/.test(s[i + 1])) {
@@ -447,10 +449,7 @@ function readHeredocDelimiter(
447
449
  * backslash suppresses expansion (`\$(rm)` is literal). Returns the matched
448
450
  * rule's reason if a blocked tool is found, undefined otherwise.
449
451
  */
450
- function scanHeredocLineForCommandSubs(
451
- line: string,
452
- rules: readonly Rule[],
453
- ): string | undefined {
452
+ function scanHeredocLineForCommandSubs(line: string): string | undefined {
454
453
  let i = 0;
455
454
  while (i < line.length) {
456
455
  const c = line[i];
@@ -461,7 +460,7 @@ function scanHeredocLineForCommandSubs(
461
460
  if (line.startsWith("$(", i)) {
462
461
  const end = skipBalancedParens(line, i, 2);
463
462
  const inner = line.slice(i + 2, end - 1);
464
- const reason = detectLegacyTool(inner, rules);
463
+ const reason = detectLegacyTool(inner);
465
464
  if (reason) return reason;
466
465
  i = end;
467
466
  continue;
@@ -469,7 +468,7 @@ function scanHeredocLineForCommandSubs(
469
468
  if (c === "`") {
470
469
  const end = readQuote(line, i, "`", true);
471
470
  const inner = line.slice(i + 1, end - 1);
472
- const reason = detectLegacyTool(inner, rules);
471
+ const reason = detectLegacyTool(inner);
473
472
  if (reason) return reason;
474
473
  i = end;
475
474
  continue;
@@ -485,7 +484,6 @@ function skipHeredocBody(
485
484
  delimiter: string,
486
485
  indented: boolean,
487
486
  quoted: boolean,
488
- rules: readonly Rule[],
489
487
  ): { next: number; reason?: string } {
490
488
  let pos = i;
491
489
  while (pos <= s.length) {
@@ -499,7 +497,7 @@ function skipHeredocBody(
499
497
  // In unquoted heredocs, command substitutions execute — scan for
500
498
  // blocked tools inside $(...) and backticks.
501
499
  if (!quoted) {
502
- const reason = scanHeredocLineForCommandSubs(line, rules);
500
+ const reason = scanHeredocLineForCommandSubs(line);
503
501
  if (reason) return { next: end, reason };
504
502
  }
505
503
  if (nl === -1) break;
@@ -530,11 +528,157 @@ const SUDO_OPTS_WITH_ARG = new Set([
530
528
  "--user",
531
529
  ]);
532
530
 
533
- export function detectLegacyTool(
534
- command: string,
535
- extraRules: readonly Rule[] = [],
536
- ): string | undefined {
537
- const rules = extraRules.length > 0 ? [...RULES, ...extraRules] : RULES;
531
+ /**
532
+ * Short rg options that consume a value: the rest of the cluster is their
533
+ * value, or — at cluster end — the next word is. `r` is handled separately
534
+ * (and blocked) before this set is consulted.
535
+ */
536
+ const RG_SHORT_VALUE_OPTS = new Set([
537
+ "A",
538
+ "B",
539
+ "C",
540
+ "d",
541
+ "E",
542
+ "e",
543
+ "f",
544
+ "g",
545
+ "j",
546
+ "M",
547
+ "m",
548
+ "t",
549
+ "T",
550
+ ]);
551
+
552
+ /**
553
+ * Long rg options that consume the next word as a value. Values are skipped
554
+ * before flag inspection, so `--field-match-separator -r` (ripgrep accepts
555
+ * `-r` there as literal output text) is not mistaken for the replace flag.
556
+ */
557
+ const RG_LONG_VALUE_OPTS = new Set([
558
+ "--after-context",
559
+ "--before-context",
560
+ "--context",
561
+ "--encoding",
562
+ "--field-context-separator",
563
+ "--field-match-separator",
564
+ "--file",
565
+ "--glob",
566
+ "--iglob",
567
+ "--max-columns",
568
+ "--max-count",
569
+ "--max-depth",
570
+ "--max-filesize",
571
+ "--path-separator",
572
+ "--pre",
573
+ "--regexp",
574
+ "--replace",
575
+ "--sort",
576
+ "--sortr",
577
+ "--threads",
578
+ "--type",
579
+ "--type-add",
580
+ "--type-clear",
581
+ "--type-not",
582
+ ]);
583
+
584
+ /**
585
+ * Read one shell word after an `rg` command, keeping quoted content as part
586
+ * of the token so `rg "-r"` and `rg '-rn'` are caught like their unquoted
587
+ * twins. Stops at whitespace or an unquoted operator.
588
+ */
589
+ function readArgWord(s: string, i: number): { word: string; next: number } {
590
+ let word = "";
591
+ while (i < s.length) {
592
+ const c = s[i];
593
+ if (c === " " || c === "\t" || c === "\r" || c === "\n") break;
594
+ if (readOperator(s, i)) break;
595
+ if (c === "'" || c === '"') {
596
+ const end = readQuote(s, i, c, c === '"');
597
+ const innerEnd = s[end - 1] === c ? end - 1 : end;
598
+ word += s.slice(i + 1, innerEnd);
599
+ i = end;
600
+ continue;
601
+ }
602
+ if (c === "\\" && i + 1 < s.length) {
603
+ word += s[i + 1];
604
+ i += 2;
605
+ continue;
606
+ }
607
+ if (c === "$") {
608
+ const d = readDollar(s, i);
609
+ if (d) {
610
+ word += s.slice(i, d.next);
611
+ i = d.next;
612
+ continue;
613
+ }
614
+ }
615
+ word += c;
616
+ i++;
617
+ }
618
+ return { word, next: i };
619
+ }
620
+
621
+ /**
622
+ * True when the `rg` invocation starting at `start` uses the replace flag in
623
+ * short form, including clusters (`-rn`, `-rln`, `-nr`). The explicit
624
+ * `--replace` long form stays allowed. Value-taking options are skipped so a
625
+ * pattern/replacement that merely looks like `-r` is not a false hit.
626
+ */
627
+ function rgUsesReplaceFlag(s: string, start: number): boolean {
628
+ let i = start;
629
+ let skipValue = false;
630
+ while (i < s.length) {
631
+ const c = s[i];
632
+ if (c === " " || c === "\t" || c === "\r") {
633
+ i++;
634
+ continue;
635
+ }
636
+ if (c === "\n") break;
637
+ // Unquoted `#` at the start of a word begins a shell comment: bash never
638
+ // passes it (or anything after it) to rg, so `rg -n foo . # avoid -rn`
639
+ // must not be blocked for what its comment merely mentions. Mid-word `#`
640
+ // (`rg foo#bar`) stays inside the word read below, matching bash.
641
+ if (c === "#") break;
642
+ const op = readOperator(s, i);
643
+ if (op) {
644
+ if (op.type !== "redirect") break;
645
+ // Redirections may appear among a command's arguments; consume the
646
+ // target (`>out`) and keep looking at the real rg options after it.
647
+ i = op.next;
648
+ while (i < s.length && (s[i] === " " || s[i] === "\t")) i++;
649
+ if (i < s.length) i = readArgWord(s, i).next;
650
+ continue;
651
+ }
652
+ const { word, next } = readArgWord(s, i);
653
+ if (next === i) {
654
+ i++;
655
+ continue;
656
+ }
657
+ i = next;
658
+ if (skipValue) {
659
+ skipValue = false;
660
+ continue;
661
+ }
662
+ if (word === "--") break;
663
+ if (word.startsWith("--")) {
664
+ if (!word.includes("=") && RG_LONG_VALUE_OPTS.has(word)) skipValue = true;
665
+ continue;
666
+ }
667
+ if (!word.startsWith("-") || word === "-") continue;
668
+ for (let k = 1; k < word.length; k++) {
669
+ const flag = word[k];
670
+ if (flag === "r") return true;
671
+ if (RG_SHORT_VALUE_OPTS.has(flag)) {
672
+ // The rest of the cluster (or the next word) is this option's value.
673
+ if (k === word.length - 1) skipValue = true;
674
+ break;
675
+ }
676
+ }
677
+ }
678
+ return false;
679
+ }
680
+
681
+ export function detectLegacyTool(command: string): string | undefined {
538
682
  let i = 0;
539
683
  let commandPos = true;
540
684
  // Set once an env-var assignment word is seen in the current simple
@@ -560,7 +704,6 @@ export function detectLegacyTool(
560
704
  heredoc.delimiter,
561
705
  heredoc.indented,
562
706
  heredoc.quoted,
563
- rules,
564
707
  );
565
708
  if (result.reason) return result.reason;
566
709
  i = result.next;
@@ -671,17 +814,11 @@ export function detectLegacyTool(
671
814
  // their unquoted twin.
672
815
  if (quote === '"') {
673
816
  const innerEnd = command[end - 1] === '"' ? end - 1 : end;
674
- const reason = scanExecutedSubs(
675
- command,
676
- i + 1,
677
- innerEnd,
678
- rules,
679
- "double",
680
- );
817
+ const reason = scanExecutedSubs(command, i + 1, innerEnd, "double");
681
818
  if (reason) return reason;
682
819
  } else if (quote === "`") {
683
820
  const innerEnd = command[end - 1] === "`" ? end - 1 : end;
684
- const reason = detectLegacyTool(command.slice(i + 1, innerEnd), rules);
821
+ const reason = detectLegacyTool(command.slice(i + 1, innerEnd));
685
822
  if (reason) return reason;
686
823
  }
687
824
  i = end;
@@ -694,7 +831,7 @@ export function detectLegacyTool(
694
831
  }
695
832
 
696
833
  if (c === "$") {
697
- const d = readDollar(command, i, rules);
834
+ const d = readDollar(command, i);
698
835
  if (d) {
699
836
  if (d.reason) return d.reason;
700
837
  i = d.next;
@@ -730,7 +867,7 @@ export function detectLegacyTool(
730
867
  continue;
731
868
  }
732
869
  // Env-var assignment prefix: skip it and stay in command position so
733
- // the word after it (`rm`, `python`, …) is the one checked. Under
870
+ // the word after it (`rm`, …) is the one checked. Under
734
871
  // sudo it likewise keeps sudoNext armed (`sudo FOO=bar rm x`).
735
872
  if (isAssignmentWord(word) && !skipNextWord) {
736
873
  assignmentPrefix = true;
@@ -746,7 +883,7 @@ export function detectLegacyTool(
746
883
  skipNextWord = false;
747
884
  continue;
748
885
  }
749
- const reason = matchCommand(word, rules);
886
+ const reason = matchCommandAt(command, word, i);
750
887
  if (reason) return reason;
751
888
  sudoNext = false;
752
889
  } else {
@@ -754,7 +891,7 @@ export function detectLegacyTool(
754
891
  skipNextWord = false;
755
892
  continue;
756
893
  }
757
- const reason = matchCommand(word, rules);
894
+ const reason = matchCommandAt(command, word, i);
758
895
  if (reason) return reason;
759
896
  }
760
897
  if (!COMMAND_PREFIX_KEYWORDS.has(word)) {
@@ -769,13 +906,10 @@ export function detectLegacyTool(
769
906
  }
770
907
 
771
908
  export default function preferTools(pi: ExtensionAPI) {
772
- pi.on("tool_call", async (event, ctx) => {
909
+ pi.on("tool_call", async (event) => {
773
910
  if (!isToolCallEventType("bash", event)) return;
774
911
 
775
- // Model-conditional rule: glm-5.* gets bash `rg` blocked (live model,
776
- // read per call — model switches mid-session take effect immediately).
777
- const extraRules = isRipgrepBlockedModel(ctx.model?.id) ? [RG_RULE] : [];
778
- const reason = detectLegacyTool(event.input.command ?? "", extraRules);
912
+ const reason = detectLegacyTool(event.input.command ?? "");
779
913
  if (reason) {
780
914
  return { block: true, reason };
781
915
  }
@@ -1733,10 +1733,19 @@ export default function providerBalance(
1733
1733
  return;
1734
1734
  }
1735
1735
 
1736
- // Do not render a cached value while credentials are being resolved. The
1737
- // same provider can represent a different account after login/logout.
1738
- identityPending = true;
1739
- requestRender?.();
1736
+ // Hide the balance while credentials are being resolved — but only when
1737
+ // nothing is displayed yet (cold start, provider switch, post-logout).
1738
+ // A same-account refresh keeps its last value on screen: blanking on
1739
+ // every refresh would flicker the footer for the full duration of
1740
+ // keychain reads and OAuth token refreshes (seconds under lock
1741
+ // contention). If resolution reveals a different account, the
1742
+ // displayedCacheKey mismatch below still clears it, so a foreign
1743
+ // reading is never painted. Same principle as the post-fetch re-check
1744
+ // below: never blank a displayed same-account value.
1745
+ if (!balance) {
1746
+ identityPending = true;
1747
+ requestRender?.();
1748
+ }
1740
1749
 
1741
1750
  // Preserve a known same-account value during routine credential refresh,
1742
1751
  // but clear it if identity resolution fails or reveals another account.
@@ -1831,6 +1840,14 @@ export default function providerBalance(
1831
1840
  );
1832
1841
  const observedAtMs = now();
1833
1842
  if (generation !== refreshGeneration) return;
1843
+ if (nextBalance.length === 0) {
1844
+ // An empty reading would render as an empty string — visually
1845
+ // identical to the balance vanishing — and parseCachedBalance already
1846
+ // refuses to serve empty entries from the cache. Treat it as a failed
1847
+ // observation: the catch below keeps the previous display and records
1848
+ // the failure.
1849
+ throw new Error("provider returned no balance segments");
1850
+ }
1834
1851
 
1835
1852
  // Login/logout does not emit model_select. Re-resolve identity before
1836
1853
  // committing so an account switch during the request cannot paint the
@@ -1910,7 +1927,14 @@ export default function providerBalance(
1910
1927
  companionTokenAfter,
1911
1928
  );
1912
1929
  }
1913
- // Recovery detected: clear the marker so sibling idle polls resume.
1930
+ // Recovery detected: tell the log once, then clear the marker so
1931
+ // sibling idle polls resume.
1932
+ if (readBalanceFailureAt(cacheKey, now(), cacheDir) !== null) {
1933
+ logGoodiesEvent({
1934
+ type: "provider_balance_refresh_recovered",
1935
+ provider: providerId,
1936
+ });
1937
+ }
1914
1938
  clearBalanceFailureMarker(cacheKey, cacheDir);
1915
1939
  const persisted = writeCachedBalance(
1916
1940
  cacheKey,
@@ -1929,12 +1953,28 @@ export default function providerBalance(
1929
1953
  return;
1930
1954
  }
1931
1955
  identityPending = false;
1932
- clearBalance();
1956
+ // A failed fetch does not make the displayed value wrong — it stays
1957
+ // TTL-gated at render time. Keep a same-account reading on screen
1958
+ // instead of blanking the footer for the whole backoff window; clear
1959
+ // only when the failure landed before any account was identified.
1960
+ if (cacheKey === undefined || cacheKey !== displayedCacheKey) {
1961
+ clearBalance();
1962
+ }
1933
1963
  // Record the failure machine-wide so sibling sessions' idle polls back
1934
1964
  // off instead of stacking their own timeouts onto the same outage.
1965
+ const firstFailureOfStreak =
1966
+ cacheKey === undefined ||
1967
+ readBalanceFailureAt(cacheKey, now(), cacheDir) === null;
1935
1968
  if (cacheKey !== undefined) {
1936
1969
  writeBalanceFailureMarker(cacheKey, now(), cacheDir);
1937
1970
  }
1971
+ if (firstFailureOfStreak) {
1972
+ logGoodiesEvent({
1973
+ type: "provider_balance_refresh_failed",
1974
+ provider: providerId,
1975
+ message: describeError(error),
1976
+ });
1977
+ }
1938
1978
  // This is a best-effort background refresh. Writing to stdout/stderr while
1939
1979
  // Pi owns the terminal corrupts the TUI (the text appears in the editor),
1940
1980
  // so expose failures to other extensions without producing terminal output.
package/review.ts CHANGED
@@ -54,6 +54,15 @@
54
54
  * ctx.ui.select dialogs when ctx.mode !== "tui", the end-review loader
55
55
  * only renders in TUI mode, and the review widget renders as string
56
56
  * lines outside the TUI
57
+ * - /end-review "Return and fix findings" fast path: the review branch's
58
+ * final assistant response already IS the handoff, so capture it verbatim
59
+ * (getFinalReviewMessage: walk the leaf path back to the common ancestor
60
+ * with the origin, the same boundary pi's summarizer would use), return
61
+ * with summarize:false (no LLM call), and queue the fix follow-up with the
62
+ * review embedded in the prompt (buildFastFixPrompt). Upstream f1de050
63
+ * (still its tip as of 2026-10-24) pays a full branch-summary round trip
64
+ * first via navigateWithSummary; "Return and summarize" keeps that flow.
65
+ * If upstream grows a comparable fast path, prefer the port.
57
66
  */
58
67
 
59
68
  import type {
@@ -75,6 +84,7 @@ import {
75
84
  } from "@earendil-works/pi-tui";
76
85
  import path from "node:path";
77
86
  import { promises as fs } from "node:fs";
87
+ import type { TextContent } from "@earendil-works/pi-ai";
78
88
  import { describeError } from "./json-file.ts";
79
89
 
80
90
  // State to track fresh session review (where we branched from).
@@ -173,6 +183,76 @@ function applyReviewSettings(ctx: ExtensionContext) {
173
183
  reviewCustomInstructions = state.customInstructions?.trim() || undefined;
174
184
  }
175
185
 
186
+ // Fix-findings follow-up instructions shared by the fast-path prompt.
187
+ const REVIEW_FIX_INSTRUCTIONS = `Instructions:
188
+ 1. Treat the review's findings as a checklist.
189
+ 2. Fix in priority order: P0, P1, then P2 (include P3 if quick and safe).
190
+ 3. If a finding is invalid/already fixed/not possible right now, briefly explain why and continue.
191
+ 4. Treat "Human Reviewer Callouts (Non-Blocking)" as informational only; do not convert them into fix tasks unless there is a separate explicit finding.
192
+ 5. Follow fail-fast error handling: do not add local catch/fallback recovery unless this scope is an explicit boundary that can safely translate the failure.
193
+ 6. If you add or keep a \`try/catch\`, explain the expected failure mode and either rethrow with context or return a boundary-safe error response.
194
+ 7. JSON parsing/decoding should fail loudly by default; avoid silent fallback parsing.
195
+ 8. Run relevant tests/checks for touched code where practical.
196
+ 9. End with: fixed items, deferred/skipped items (with reasons), and verification results.`;
197
+
198
+ /**
199
+ * Build the fix-findings follow-up for the fast path: the verbatim review
200
+ * rides in the prompt, so no branch-summary LLM round trip is needed.
201
+ */
202
+ export function buildFastFixPrompt(review: string): string {
203
+ return `Implement the findings in the following review. The review ran on a separate branch of this session that is no longer in context — the review text below is the complete handoff.
204
+
205
+ ${REVIEW_FIX_INSTRUCTIONS}
206
+
207
+ <review>
208
+ ${review}
209
+ </review>`;
210
+ }
211
+
212
+ /**
213
+ * Capture the review branch's final assistant response (the review itself).
214
+ *
215
+ * The review branch diverged from the main branch, so the origin entry is
216
+ * NOT on the leaf's path — the boundary is the common ancestor of the two
217
+ * paths (the same boundary pi's branch summarizer collects entries for).
218
+ * Returns the newest non-empty assistant text between the leaf and that
219
+ * boundary, or undefined when the review branch holds no completed review
220
+ * response (or provenance cannot be established — stale origin, disjoint
221
+ * paths), in which case the caller must not pretend there is a review.
222
+ */
223
+ export function getFinalReviewMessage(
224
+ ctx: Pick<ExtensionCommandContext, "sessionManager">,
225
+ originId: string,
226
+ ): string | undefined {
227
+ const leafPath = ctx.sessionManager.getBranch(); // leaf → root
228
+ const originPathIds = new Set(
229
+ ctx.sessionManager.getBranch(originId).map((entry) => entry.id),
230
+ );
231
+ if (originPathIds.size === 0) {
232
+ return undefined; // unknown origin: refuse instead of grabbing main-branch text
233
+ }
234
+ // Both paths end at the root, so the first shared entry on the leaf path
235
+ // is the common ancestor. Review-branch entries strictly precede it.
236
+ const boundaryIndex = leafPath.findIndex((entry) =>
237
+ originPathIds.has(entry.id),
238
+ );
239
+ if (boundaryIndex === -1) {
240
+ return undefined; // disjoint paths (orphaned entries): provenance unprovable
241
+ }
242
+ for (let i = 0; i < boundaryIndex; i++) {
243
+ const entry = leafPath[i];
244
+ if (entry.type !== "message") continue;
245
+ const message = entry.message;
246
+ if (message.role !== "assistant") continue;
247
+ const text = message.content
248
+ .filter((part): part is TextContent => part.type === "text")
249
+ .map((part) => part.text)
250
+ .join("\n\n");
251
+ if (text.trim()) return text;
252
+ }
253
+ return undefined;
254
+ }
255
+
176
256
  // Review target types (matching Codex's approach)
177
257
  type ReviewTarget =
178
258
  | { type: "uncommitted" }
@@ -1984,19 +2064,6 @@ These are informational callouts for humans and are not fix items by themselves.
1984
2064
 
1985
2065
  Preserve exact file paths, function names, and error messages where available.`;
1986
2066
 
1987
- const REVIEW_FIX_FINDINGS_PROMPT = `Use the latest review summary in this session and implement the review findings now.
1988
-
1989
- Instructions:
1990
- 1. Treat the summary's Findings/Fix Queue as a checklist.
1991
- 2. Fix in priority order: P0, P1, then P2 (include P3 if quick and safe).
1992
- 3. If a finding is invalid/already fixed/not possible right now, briefly explain why and continue.
1993
- 4. Treat "Human Reviewer Callouts (Non-Blocking)" as informational only; do not convert them into fix tasks unless there is a separate explicit finding.
1994
- 5. Follow fail-fast error handling: do not add local catch/fallback recovery unless this scope is an explicit boundary that can safely translate the failure.
1995
- 6. If you add or keep a \`try/catch\`, explain the expected failure mode and either rethrow with context or return a boundary-safe error response.
1996
- 7. JSON parsing/decoding should fail loudly by default; avoid silent fallback parsing.
1997
- 8. Run relevant tests/checks for touched code where practical.
1998
- 9. End with: fixed items, deferred/skipped items (with reasons), and verification results.`;
1999
-
2000
2067
  type EndReviewAction = "returnOnly" | "returnAndFix" | "returnAndSummarize";
2001
2068
  type EndReviewActionResult = "ok" | "cancelled" | "error";
2002
2069
  type EndReviewActionOptions = {
@@ -2171,6 +2238,48 @@ Instructions:
2171
2238
  return "ok";
2172
2239
  }
2173
2240
 
2241
+ if (action === "returnAndFix") {
2242
+ // Fast path: the review's final assistant response already is the
2243
+ // handoff — carry it across the branch boundary verbatim instead of
2244
+ // paying a branch-summary LLM round trip before the fixing turn can
2245
+ // even start. Returning is a pure tree move (summarize: false).
2246
+ const reviewText = getFinalReviewMessage(ctx, originId);
2247
+ if (!reviewText) {
2248
+ ctx.ui.notify(
2249
+ "No completed review response found on the review branch — nothing to fix. Use /end-review → Return and summarize if you still want a branch summary.",
2250
+ "error",
2251
+ );
2252
+ return "error";
2253
+ }
2254
+
2255
+ try {
2256
+ const result = await ctx.navigateTree(originId, { summarize: false });
2257
+ if (result.cancelled) {
2258
+ ctx.ui.notify(
2259
+ "Navigation cancelled. Use /end-review to try again.",
2260
+ "info",
2261
+ );
2262
+ return "cancelled";
2263
+ }
2264
+ } catch (error) {
2265
+ ctx.ui.notify(`Failed to return: ${describeError(error)}`, "error");
2266
+ return "error";
2267
+ }
2268
+
2269
+ clearReviewState(ctx);
2270
+ pi.sendUserMessage(buildFastFixPrompt(reviewText), {
2271
+ deliverAs: "followUp",
2272
+ });
2273
+ if (notifySuccess) {
2274
+ ctx.ui.notify(
2275
+ "Review complete! Returned and queued a follow-up to fix findings.",
2276
+ "info",
2277
+ );
2278
+ }
2279
+ return "ok";
2280
+ }
2281
+
2282
+ // action === "returnAndSummarize"
2174
2283
  const summaryResult = await navigateWithSummary(
2175
2284
  ctx,
2176
2285
  originId,
@@ -2199,22 +2308,11 @@ Instructions:
2199
2308
 
2200
2309
  clearReviewState(ctx);
2201
2310
 
2202
- if (action === "returnAndSummarize") {
2203
- if (!ctx.ui.getEditorText().trim()) {
2204
- ctx.ui.setEditorText("Act on the review findings");
2205
- }
2206
- if (notifySuccess) {
2207
- ctx.ui.notify("Review complete! Returned and summarized.", "info");
2208
- }
2209
- return "ok";
2311
+ if (!ctx.ui.getEditorText().trim()) {
2312
+ ctx.ui.setEditorText("Act on the review findings");
2210
2313
  }
2211
-
2212
- pi.sendUserMessage(REVIEW_FIX_FINDINGS_PROMPT, { deliverAs: "followUp" });
2213
2314
  if (notifySuccess) {
2214
- ctx.ui.notify(
2215
- "Review complete! Returned and queued a follow-up to fix findings.",
2216
- "info",
2217
- );
2315
+ ctx.ui.notify("Review complete! Returned and summarized.", "info");
2218
2316
  }
2219
2317
  return "ok";
2220
2318
  }