@oh-my-pi/pi-coding-agent 16.3.0 → 16.3.2

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 (86) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/cli.js +3610 -3566
  3. package/dist/types/config/settings-schema.d.ts +10 -0
  4. package/dist/types/discovery/helpers.d.ts +2 -0
  5. package/dist/types/irc/bus.d.ts +5 -4
  6. package/dist/types/session/agent-session.d.ts +13 -13
  7. package/dist/types/task/discovery.d.ts +5 -2
  8. package/dist/types/task/renderer.d.ts +1 -0
  9. package/dist/types/tools/ast-grep.d.ts +4 -2
  10. package/dist/types/tools/browser/render.d.ts +2 -0
  11. package/dist/types/tools/debug.d.ts +1 -0
  12. package/dist/types/tools/eval-render.d.ts +2 -0
  13. package/dist/types/tools/glob.d.ts +4 -2
  14. package/dist/types/tools/grep.d.ts +4 -3
  15. package/dist/types/tools/path-utils.d.ts +7 -0
  16. package/dist/types/tools/renderers.d.ts +11 -0
  17. package/dist/types/tools/ssh.d.ts +2 -1
  18. package/dist/types/tts/index.d.ts +2 -0
  19. package/dist/types/tts/speakable.d.ts +47 -0
  20. package/dist/types/tts/speech-enhancer.d.ts +46 -0
  21. package/dist/types/tts/streaming-player.d.ts +1 -2
  22. package/dist/types/tts/tts-client.d.ts +11 -10
  23. package/dist/types/tts/tts-protocol.d.ts +7 -0
  24. package/dist/types/tts/vocalizer.d.ts +15 -8
  25. package/dist/types/utils/git.d.ts +2 -0
  26. package/package.json +12 -12
  27. package/src/advisor/__tests__/advisor.test.ts +1 -1
  28. package/src/cli/gallery-fixtures/fs.ts +2 -2
  29. package/src/cli/gallery-fixtures/search.ts +2 -2
  30. package/src/cli.ts +27 -5
  31. package/src/config/model-resolver.ts +31 -10
  32. package/src/config/settings-schema.ts +11 -0
  33. package/src/cursor.ts +1 -1
  34. package/src/discovery/helpers.ts +8 -0
  35. package/src/export/html/tool-views.generated.js +34 -34
  36. package/src/extensibility/custom-tools/loader.ts +3 -3
  37. package/src/extensibility/extensions/loader.ts +10 -3
  38. package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
  39. package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
  40. package/src/extensibility/plugins/loader.ts +30 -1
  41. package/src/irc/bus.ts +5 -4
  42. package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
  43. package/src/modes/components/assistant-message.ts +1 -1
  44. package/src/modes/components/move-overlay.ts +35 -23
  45. package/src/modes/components/tool-execution.ts +45 -12
  46. package/src/modes/components/tree-selector.ts +10 -3
  47. package/src/modes/controllers/event-controller.ts +16 -0
  48. package/src/prompts/goals/goal-mode-context.md +4 -0
  49. package/src/prompts/goals/goal-todo-context.md +10 -2
  50. package/src/prompts/system/speech-rewrite.md +15 -0
  51. package/src/prompts/system/tiny-title-system.md +1 -1
  52. package/src/prompts/system/title-system-marker.md +2 -1
  53. package/src/prompts/system/title-system.md +2 -1
  54. package/src/prompts/tools/ast-grep.md +3 -3
  55. package/src/prompts/tools/glob.md +1 -1
  56. package/src/prompts/tools/grep.md +1 -1
  57. package/src/sdk.ts +8 -5
  58. package/src/session/agent-session.ts +163 -49
  59. package/src/session/session-history-format.ts +6 -2
  60. package/src/task/discovery.ts +25 -2
  61. package/src/task/executor.ts +24 -4
  62. package/src/task/render.ts +26 -12
  63. package/src/task/renderer.ts +1 -0
  64. package/src/task/worktree.ts +144 -16
  65. package/src/tiny/text.ts +109 -17
  66. package/src/tools/ast-grep.ts +20 -17
  67. package/src/tools/bash.ts +16 -8
  68. package/src/tools/browser/render.ts +2 -0
  69. package/src/tools/debug.ts +1 -0
  70. package/src/tools/eval-render.ts +5 -2
  71. package/src/tools/gh-renderer.ts +3 -0
  72. package/src/tools/glob.ts +24 -20
  73. package/src/tools/grep.ts +18 -36
  74. package/src/tools/path-utils.ts +55 -10
  75. package/src/tools/read.ts +9 -2
  76. package/src/tools/renderers.ts +11 -0
  77. package/src/tools/ssh.ts +17 -7
  78. package/src/tts/index.ts +2 -0
  79. package/src/tts/speakable.ts +382 -0
  80. package/src/tts/speech-enhancer.ts +204 -0
  81. package/src/tts/streaming-player.ts +71 -16
  82. package/src/tts/tts-client.ts +11 -10
  83. package/src/tts/tts-protocol.ts +14 -5
  84. package/src/tts/tts-worker.ts +52 -49
  85. package/src/tts/vocalizer.ts +277 -46
  86. package/src/utils/git.ts +8 -0
@@ -882,6 +882,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
882
882
  const finalOutputChunks: string[] = [];
883
883
  const RECENT_OUTPUT_TAIL_BYTES = 8 * 1024;
884
884
  let recentOutputTail = "";
885
+ let tailLastLineRepresentable = false;
885
886
  let resolved = false;
886
887
  let abortSent = false;
887
888
  let abortReason: AbortReason | undefined;
@@ -1060,17 +1061,35 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1060
1061
  };
1061
1062
 
1062
1063
  const updateRecentOutputLines = () => {
1063
- const lines = recentOutputTail.split("\n").filter(line => line.trim());
1064
- progress.recentOutput = lines.slice(-8).reverse();
1064
+ const lines = recentOutputTail.split("\n");
1065
+ const filtered = lines.filter(line => line.trim());
1066
+ progress.recentOutput = filtered.slice(-8).reverse();
1067
+ // The tail's last raw segment (after its final newline) is "represented"
1068
+ // in recentOutput only when it trims non-empty — an empty/whitespace-only
1069
+ // trailing segment is filtered out, so recentOutput[0] is then the line
1070
+ // before it, not the tail's true last line.
1071
+ tailLastLineRepresentable = lines[lines.length - 1].trim().length > 0;
1065
1072
  };
1066
1073
 
1067
1074
  const appendRecentOutputTail = (text: string) => {
1068
1075
  if (!text) return;
1069
1076
  recentOutputTail += text;
1070
- if (recentOutputTail.length > RECENT_OUTPUT_TAIL_BYTES) {
1077
+ const truncated = recentOutputTail.length > RECENT_OUTPUT_TAIL_BYTES;
1078
+ if (truncated) {
1071
1079
  recentOutputTail = recentOutputTail.slice(-RECENT_OUTPUT_TAIL_BYTES);
1072
1080
  }
1073
- updateRecentOutputLines();
1081
+ // Fast path: a token without a newline only extends the current last line.
1082
+ // This runs on every text_delta token (hundreds/thousands per second while
1083
+ // streaming), so skip re-splitting the whole (up to 8KB) tail unless the line
1084
+ // structure actually changed. Requires no truncation AND the tail's last line
1085
+ // already represented (trims non-empty) — otherwise boundaries shift and a
1086
+ // full recompute is required. Appending to a non-empty line keeps it non-empty,
1087
+ // so the flag stays valid across consecutive fast-path tokens.
1088
+ if (truncated || text.includes("\n") || !tailLastLineRepresentable || progress.recentOutput.length === 0) {
1089
+ updateRecentOutputLines();
1090
+ } else {
1091
+ progress.recentOutput = [progress.recentOutput[0] + text, ...progress.recentOutput.slice(1)];
1092
+ }
1074
1093
  };
1075
1094
 
1076
1095
  const replaceRecentOutputFromContent = (content: unknown[]) => {
@@ -1090,6 +1109,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1090
1109
 
1091
1110
  const resetRecentOutput = () => {
1092
1111
  recentOutputTail = "";
1112
+ tailLastLineRepresentable = false;
1093
1113
  progress.recentOutput = [];
1094
1114
  };
1095
1115
 
@@ -151,9 +151,9 @@ function normalizeReportFindings(value: unknown): ReportFindingDetails[] {
151
151
  const REVIEWER_ARRAY_LABELS: ReadonlySet<string> = new Set(["findings"]);
152
152
 
153
153
  function extractIncrementalReviewResult(
154
- value: unknown,
154
+ items: RenderYieldItem[],
155
155
  ): { summary: SubmitReviewDetails; findings: ReportFindingDetails[] } | undefined {
156
- const yieldItems: YieldItem[] = normalizeYieldData(value).map(item => ({
156
+ const yieldItems: YieldItem[] = items.map(item => ({
157
157
  data: item.data,
158
158
  type: item.type,
159
159
  status: item.status === "aborted" ? "aborted" : item.status === "success" ? "success" : undefined,
@@ -962,7 +962,7 @@ function renderAgentProgress(
962
962
  // yield sections. Fall back to the legacy `report_finding` side-channel.
963
963
  if (progress.status === "completed") {
964
964
  const completeData = normalizeYieldData(progress.extractedToolData.yield);
965
- const incrementalReview = extractIncrementalReviewResult(progress.extractedToolData.yield);
965
+ const incrementalReview = extractIncrementalReviewResult(completeData);
966
966
  const reportFindingData = normalizeReportFindings(progress.extractedToolData.report_finding);
967
967
  if (incrementalReview) {
968
968
  lines.push(
@@ -1253,7 +1253,7 @@ function renderAgentResult(
1253
1253
  // is not a function` when the slot is a plain object (see issue #1987).
1254
1254
  const completeData = normalizeYieldData(result.extractedToolData?.yield);
1255
1255
  const reportFindingData = normalizeReportFindings(result.extractedToolData?.report_finding);
1256
- const incrementalReview = extractIncrementalReviewResult(result.extractedToolData?.yield);
1256
+ const incrementalReview = extractIncrementalReviewResult(completeData);
1257
1257
 
1258
1258
  if (incrementalReview) {
1259
1259
  lines.push(
@@ -1491,9 +1491,27 @@ export function renderResult(
1491
1491
  }
1492
1492
 
1493
1493
  const hasResults = Boolean(details.results && details.results.length > 0);
1494
- const aborted = hasResults && details.results.some(r => r.aborted);
1495
- const failed = hasResults && details.results.some(r => !r.aborted && r.exitCode !== 0);
1496
- const mergeFailed = hasResults && details.results.some(r => !r.aborted && r.exitCode === 0 && Boolean(r.error));
1494
+ // Single pass over details.results derives the header booleans AND the footer
1495
+ // counts/totals. This block re-runs ~30×/sec via the 33ms spinner render; the
1496
+ // previous form did `.some()` here plus `.filter()` + `.reduce()` again
1497
+ // inside the frame below (7+ full passes per tick).
1498
+ let abortedCount = 0;
1499
+ let failCount = 0;
1500
+ let mergeFailedCount = 0;
1501
+ let successCount = 0;
1502
+ let requestTotal = 0;
1503
+ if (hasResults) {
1504
+ for (const r of details.results) {
1505
+ requestTotal += r.requests ?? 0;
1506
+ if (r.aborted) abortedCount++;
1507
+ else if (r.exitCode !== 0) failCount++;
1508
+ else if (r.error) mergeFailedCount++;
1509
+ else successCount++;
1510
+ }
1511
+ }
1512
+ const aborted = abortedCount > 0;
1513
+ const failed = failCount > 0;
1514
+ const mergeFailed = mergeFailedCount > 0;
1497
1515
  const isError = aborted || failed;
1498
1516
  const agentCount = hasResults ? details.results.length : (details.progress?.length ?? 0);
1499
1517
  const icon: ToolUIStatus = options.isPartial ? "running" : isError ? "error" : mergeFailed ? "warning" : "success";
@@ -1552,16 +1570,12 @@ export function renderResult(
1552
1570
  );
1553
1571
  }
1554
1572
 
1555
- const abortedCount = details.results.filter(r => r.aborted).length;
1556
- const mergeFailedCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && r.error).length;
1557
- const successCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && !r.error).length;
1558
- const failCount = details.results.length - successCount - mergeFailedCount - abortedCount;
1559
1573
  const summaryParts: string[] = [];
1560
1574
  if (abortedCount > 0) summaryParts.push(theme.fg("error", `${abortedCount} aborted`));
1561
1575
  if (successCount > 0) summaryParts.push(theme.fg("success", `${successCount} succeeded`));
1562
1576
  if (mergeFailedCount > 0) summaryParts.push(theme.fg("warning", `${mergeFailedCount} merge failed`));
1563
1577
  if (failCount > 0) summaryParts.push(theme.fg("error", `${failCount} failed`));
1564
- const totalRequests = details.results.reduce((sum, r) => sum + (r.requests ?? 0), 0);
1578
+ const totalRequests = requestTotal;
1565
1579
  if (totalRequests > 0) summaryParts.push(theme.fg("dim", `${formatNumber(totalRequests)} req`));
1566
1580
  summaryParts.push(theme.fg("dim", formatDuration(details.totalDurationMs)));
1567
1581
  // Wrap the run summary in the theme's bracket glyphs (dim chrome, colored
@@ -11,4 +11,5 @@ export const taskToolRenderer = {
11
11
  renderCall,
12
12
  renderResult,
13
13
  mergeCallAndResult: true,
14
+ animatedPartialResult: true,
14
15
  } as const;
@@ -481,41 +481,148 @@ function baselineHasRootWip(baseline: RepoBaseline): boolean {
481
481
  return !!(baseline.staged.trim() || baseline.unstaged.trim() || baseline.untrackedPatch.trim());
482
482
  }
483
483
 
484
+ /**
485
+ * Baseline WIP context needed to safely apply a delta patch whose hunks were
486
+ * captured against `HEAD + WIP` (see {@link captureRepoDeltaPatch}). Passed
487
+ * whenever {@link baselineHasRootWip} is true so
488
+ * {@link commitPatchToBranchWorktree} can replay the WIP into the temp
489
+ * worktree first, then rewind WIP-only files after applying the delta.
490
+ */
491
+ interface BaselineWipContext {
492
+ readonly staged: string;
493
+ readonly unstaged: string;
494
+ readonly untrackedPatch: string;
495
+ /** Untracked file paths present in the baseline (never in HEAD). */
496
+ readonly untracked: readonly string[];
497
+ }
498
+
499
+ function collectWipPatches(wip: BaselineWipContext | undefined): string[] {
500
+ if (!wip) return [];
501
+ return [wip.staged, wip.unstaged, wip.untrackedPatch].filter(p => p.trim());
502
+ }
503
+
484
504
  async function commitPatchToBranchWorktree(
485
505
  tmpDir: string,
486
506
  taskId: string,
487
507
  patchText: string,
488
508
  message: string,
489
509
  author?: git.CommitAuthor,
510
+ baselineWip?: BaselineWipContext,
490
511
  ): Promise<void> {
512
+ // Try the two clean paths first — they yield an agent-only commit and are
513
+ // the happy case when the temp worktree can resolve the patch against
514
+ // HEAD directly:
515
+ //
516
+ // 1. Plain apply — works when WIP context happens to match HEAD (e.g.
517
+ // WIP-touched files that the delta patch doesn't reference).
518
+ // 2. `--3way` — works when the WIP-side blob is tracked in HEAD and
519
+ // lives in the shared ODB (captureDeltaPatch seeded it while writing
520
+ // the synthetic baseline tree). The 3-way merge subtracts WIP,
521
+ // producing an agent-only commit even when WIP and agent modify the
522
+ // same tracked file at unrelated lines.
523
+ //
524
+ // If both fail (untracked WIP files, staged-new WIP files, or overlap that
525
+ // --3way can't resolve — see #4136), replay the WIP into the worktree
526
+ // first so the delta's context lines match, then rewind WIP-only files so
527
+ // they don't leak into the commit. Files touched by BOTH WIP and delta
528
+ // keep their combined state; the parent's stash-pop reconciles the WIP
529
+ // side via 3-way merge on merge-back.
530
+ let plainErr: git.GitCommandError | undefined;
491
531
  try {
492
532
  await git.patch.applyText(tmpDir, patchText);
493
533
  } catch (err) {
494
534
  if (!(err instanceof git.GitCommandError)) throw err;
495
- // Plain apply rejects when the parent checkout carries unrelated dirty
496
- // context near the task's edits; retry with --3way before giving up.
535
+ plainErr = err;
536
+ }
537
+ if (plainErr) {
538
+ let threeWayErr: git.GitCommandError | undefined;
497
539
  try {
498
540
  await git.patch.applyText(tmpDir, patchText, { threeWay: true });
499
- } catch (threeWayErr) {
500
- if (threeWayErr instanceof git.GitCommandError) {
541
+ } catch (err) {
542
+ if (!(err instanceof git.GitCommandError)) throw err;
543
+ threeWayErr = err;
544
+ }
545
+ if (threeWayErr) {
546
+ const wipPatches = collectWipPatches(baselineWip);
547
+ if (wipPatches.length === 0 || !baselineWip) {
501
548
  const stderr = threeWayErr.result.stderr.slice(0, 2000);
502
549
  logger.error("commitToBranch: git apply --3way failed", {
503
550
  taskId,
504
551
  exitCode: threeWayErr.result.exitCode,
505
552
  stderr,
506
- initialStderr: err.result.stderr.slice(0, 2000),
553
+ initialStderr: plainErr.result.stderr.slice(0, 2000),
507
554
  patchSize: patchText.length,
508
555
  patchHead: patchText.slice(0, 500),
509
556
  });
510
557
  throw new Error(`git apply --3way failed for task ${taskId}: ${stderr}`);
511
558
  }
512
- throw threeWayErr;
559
+ try {
560
+ // `git apply --3way` leaves conflict markers in `U` files when
561
+ // it can't resolve; reset the worktree so the WIP-seeded retry
562
+ // starts from a clean HEAD tree.
563
+ await git.reset(tmpDir, { hard: true, target: "HEAD" });
564
+ await applyDeltaOverBaselineWip(tmpDir, taskId, patchText, wipPatches, baselineWip);
565
+ } catch (wipErr) {
566
+ if (!(wipErr instanceof git.GitCommandError)) throw wipErr;
567
+ const stderr = wipErr.result.stderr.slice(0, 2000);
568
+ logger.error("commitToBranch: git apply with baseline WIP failed", {
569
+ taskId,
570
+ exitCode: wipErr.result.exitCode,
571
+ stderr,
572
+ threeWayStderr: threeWayErr.result.stderr.slice(0, 2000),
573
+ initialStderr: plainErr.result.stderr.slice(0, 2000),
574
+ patchSize: patchText.length,
575
+ patchHead: patchText.slice(0, 500),
576
+ });
577
+ throw new Error(`git apply with baseline WIP failed for task ${taskId}: ${stderr}`);
578
+ }
513
579
  }
514
580
  }
581
+
515
582
  await git.stage.files(tmpDir);
516
583
  await git.commit(tmpDir, message, author ? { author } : {});
517
584
  }
518
585
 
586
+ /**
587
+ * Replay baseline WIP into the temp worktree so the delta patch's HEAD+WIP
588
+ * context matches, apply the delta, then rewind files WIP touched but the
589
+ * delta didn't — HEAD-tracked files are restored via `git restore`, untracked
590
+ * or staged-new WIP files are removed from the worktree. The commit that
591
+ * follows reflects agent's delta plus any overlap with WIP; parent's
592
+ * stash-pop reconciles the WIP side on merge-back.
593
+ */
594
+ async function applyDeltaOverBaselineWip(
595
+ tmpDir: string,
596
+ _taskId: string,
597
+ patchText: string,
598
+ wipPatches: readonly string[],
599
+ baselineWip: BaselineWipContext,
600
+ ): Promise<void> {
601
+ for (const wip of wipPatches) {
602
+ await git.patch.applyText(tmpDir, wip);
603
+ }
604
+ await git.patch.applyText(tmpDir, patchText);
605
+
606
+ const wipFiles = new Set(wipPatches.flatMap(patchTouchedFiles));
607
+ const deltaFiles = new Set(patchTouchedFiles(patchText));
608
+ const wipOnly = [...wipFiles].filter(f => !deltaFiles.has(f));
609
+ if (wipOnly.length === 0) return;
610
+
611
+ // Any wipOnly file baselined as untracked cannot be in HEAD.
612
+ // Everything else may or may not — verify against HEAD's tree.
613
+ const untrackedSet = new Set(baselineWip.untracked);
614
+ const candidates = wipOnly.filter(f => !untrackedSet.has(f));
615
+ const inHead = candidates.length > 0 ? new Set(await git.ls.tree(tmpDir, "HEAD", candidates)) : new Set<string>();
616
+ const toRestore = candidates.filter(f => inHead.has(f));
617
+ const toRemove = wipOnly.filter(f => !toRestore.includes(f));
618
+ if (toRestore.length > 0) {
619
+ await git.restore(tmpDir, { source: "HEAD", staged: true, worktree: true, files: toRestore });
620
+ }
621
+ for (const rel of toRemove) {
622
+ await fs.rm(path.join(tmpDir, rel), { force: true });
623
+ }
624
+ }
625
+
519
626
  interface FilteredAgentReplayOptions {
520
627
  baseline: WorktreeBaseline;
521
628
  branchName: string;
@@ -542,6 +649,7 @@ async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Pro
542
649
  opts.baseline.root.untrackedPatch,
543
650
  ]);
544
651
  let previousFilteredTree = baselineSha;
652
+ let filteredCommitsApplied = 0;
545
653
 
546
654
  for (const commitSha of agentCommits) {
547
655
  const taskStatePatch = await git.diff.tree(opts.isolationDir, dirtyBaselineTree, `${commitSha}^{tree}`, {
@@ -562,18 +670,37 @@ async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Pro
562
670
  details.message || commitSha,
563
671
  details.author,
564
672
  );
673
+ filteredCommitsApplied++;
565
674
  }
566
675
  previousFilteredTree = currentFilteredTree;
567
676
  }
568
-
569
- const finalFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [opts.rootPatch]);
570
- const leftoverPatch = await git.diff.tree(opts.repoRoot, previousFilteredTree, finalFilteredTree, {
571
- allowFailure: true,
572
- binary: true,
573
- });
574
- if (leftoverPatch.trim()) {
575
- const msg = (opts.commitMessage && (await opts.commitMessage(leftoverPatch))) || opts.fallbackMessage;
576
- await commitPatchToBranchWorktree(tmpDir, opts.taskId, leftoverPatch, msg);
677
+ if (filteredCommitsApplied === 0) {
678
+ // No filtered commit landed — tmpDir is still pinned at baselineSha.
679
+ // The `finalFilteredTree = writeSyntheticTree(HEAD, [rootPatch])`
680
+ // path here fails hard whenever rootPatch's WIP-context can't be
681
+ // applied to a HEAD-only index (untracked WIP + agent modifies,
682
+ // staged-new WIP + agent modifies — see #4136). Bypass the synthesis
683
+ // entirely and collapse the isolation output onto a single commit
684
+ // with WIP seed, matching the no-agent-commit path in commitToBranch.
685
+ // This also handles the "agent committed only baseline WIP" corner
686
+ // case where every filtered patch collapsed to empty.
687
+ if (opts.rootPatch.trim()) {
688
+ const msg = (opts.commitMessage && (await opts.commitMessage(opts.rootPatch))) || opts.fallbackMessage;
689
+ await commitPatchToBranchWorktree(tmpDir, opts.taskId, opts.rootPatch, msg, undefined, opts.baseline.root);
690
+ }
691
+ } else {
692
+ // A filtered commit landed; tmpDir has advanced past baselineSha and
693
+ // previousFilteredTree is HEAD-derived, so writeSyntheticTree +
694
+ // leftoverPatch stay HEAD-based and no WIP seed is needed.
695
+ const finalFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [opts.rootPatch]);
696
+ const leftoverPatch = await git.diff.tree(opts.repoRoot, previousFilteredTree, finalFilteredTree, {
697
+ allowFailure: true,
698
+ binary: true,
699
+ });
700
+ if (leftoverPatch.trim()) {
701
+ const msg = (opts.commitMessage && (await opts.commitMessage(leftoverPatch))) || opts.fallbackMessage;
702
+ await commitPatchToBranchWorktree(tmpDir, opts.taskId, leftoverPatch, msg);
703
+ }
577
704
  }
578
705
  } finally {
579
706
  await git.worktree.tryRemove(opts.repoRoot, tmpDir);
@@ -674,7 +801,8 @@ export async function commitToBranch(
674
801
  await git.worktree.add(repoRoot, tmpDir, branchName);
675
802
 
676
803
  const msg = (commitMessage && (await commitMessage(rootPatch))) || fallbackMessage;
677
- await commitPatchToBranchWorktree(tmpDir, taskId, rootPatch, msg);
804
+ const wip = baselineHasRootWip(baseline.root) ? baseline.root : undefined;
805
+ await commitPatchToBranchWorktree(tmpDir, taskId, rootPatch, msg, undefined, wip);
678
806
  } finally {
679
807
  await git.worktree.tryRemove(repoRoot, tmpDir);
680
808
  await fs.rm(tmpDir, { recursive: true, force: true });
package/src/tiny/text.ts CHANGED
@@ -152,6 +152,34 @@ const FILLER_TITLE_TOKENS = new Set<string>([
152
152
  ]);
153
153
 
154
154
  const TITLE_WORD = /[\p{L}\p{N}]+/gu;
155
+ const COMMON_TITLE_ACRONYMS = new Set<string>([
156
+ "API",
157
+ "CLI",
158
+ "CPU",
159
+ "CRUD",
160
+ "CSS",
161
+ "DNS",
162
+ "ETL",
163
+ "GPU",
164
+ "HTML",
165
+ "HTTP",
166
+ "HTTPS",
167
+ "ID",
168
+ "JSON",
169
+ "LLM",
170
+ "REST",
171
+ "SDK",
172
+ "SSH",
173
+ "TCP",
174
+ "TLS",
175
+ "TUI",
176
+ "UI",
177
+ "URI",
178
+ "URL",
179
+ "UX",
180
+ "XML",
181
+ "YAML",
182
+ ]);
155
183
 
156
184
  /**
157
185
  * True when a first user message is too low-signal to title (greeting, ack,
@@ -191,51 +219,115 @@ export function normalizeGeneratedTitle(value: string | null | undefined, source
191
219
  * Reconcile a generated title's casing against the user's own message.
192
220
  *
193
221
  * The title prompt asks for sentence case, but small title models still mangle
194
- * casing two ways: they sprout stray interior capitals on ordinary words
195
- * (`daemon` → `dAemon`) and they flatten proper nouns the user cares about
196
- * (`TinyVMM` → `tinyvmm`). The user's message is the source of truth, so per
197
- * title token:
222
+ * casing three ways: they sprout stray interior capitals on ordinary words
223
+ * (`daemon` → `dAemon`), they flatten proper nouns the user cased distinctively
224
+ * (`TinyVMM` → `tinyvmm`), and they title-case ALL-CAPS acronyms as if they
225
+ * were sentence words (`CNPG` → `Cnpg`). The user's message is the source of
226
+ * truth, so per title token:
198
227
  * 1. typed verbatim in the message → keep it (the user established the casing);
199
228
  * 2. else the message has the same word with *distinctive* mixed casing
200
229
  * (`TinyVMM`, `iOS`, `IDs`) → adopt the user's casing (restoration);
201
- * 3. else it's a camelCase artifact (lowercase word + stray interior capital,
230
+ * 3. else the model produced a plain title-cased artifact (`Cnpg`) whose
231
+ * lowercased form is a likely ALL-CAPS acronym in a non-shouty source
232
+ * (`CNPG`, `API`, `ETL`) → restore the source acronym;
233
+ * 4. else it's a camelCase artifact (lowercase word + stray interior capital,
202
234
  * `dAemon`) the user never wrote → lowercase it;
203
- * 4. else leave it — preserves model-cased proper nouns like `GitHub`, `OAuth`.
235
+ * 5. else leave it — preserves model-cased proper nouns like `GitHub`, `OAuth`.
204
236
  *
205
- * Restoration is limited to distinctively *mixed*-cased source tokens: a sentence
206
- * that merely *starts* with `For` can't force a mid-title `for` to `For`, and
207
- * emphatic all-caps (`ALL ERROR HANDLING`) is never re-shouted over sentence case.
237
+ * Restoration is limited to avoid three failure modes: a sentence that merely
238
+ * *starts* with `For` can't force a mid-title `for` to `For` (distinctive
239
+ * requires interior mixed casing); emphatic all-caps input (`ALL ERROR
240
+ * HANDLING`, `FIX the BUG NOW`) is never re-shouted — see {@link isShoutySource};
241
+ * and ordinary all-caps English words (`FIX`, `WORK`, `BUG`) are not treated as
242
+ * restorable acronyms unless they carry a stronger acronym signal.
208
243
  */
209
244
  function reconcileTitleCasing(title: string, sourceText: string): string {
210
245
  const verbatim = new Set<string>();
211
246
  const distinctive = new Map<string, string>();
247
+ const acronyms = new Map<string, string>();
248
+ const shouty = isShoutySource(sourceText);
212
249
  for (const [token] of sourceText.matchAll(TITLE_WORD)) {
213
250
  verbatim.add(token);
214
251
  if (isDistinctiveCasing(token)) {
215
252
  const lower = token.toLowerCase();
216
253
  if (!distinctive.has(lower)) distinctive.set(lower, token);
254
+ } else if (!shouty && isAllCapsAcronym(token)) {
255
+ const lower = token.toLowerCase();
256
+ if (!acronyms.has(lower)) acronyms.set(lower, token);
217
257
  }
218
258
  }
219
259
  return title.replace(TITLE_WORD, token => {
220
260
  if (verbatim.has(token)) return token;
221
- const restored = distinctive.get(token.toLowerCase());
261
+ const lower = token.toLowerCase();
262
+ const restored = distinctive.get(lower);
222
263
  if (restored) return restored;
223
- return isCamelArtifact(token) ? token.toLowerCase() : token;
264
+ if (isTitleCasedArtifact(token)) {
265
+ const acronym = acronyms.get(lower);
266
+ if (acronym) return acronym;
267
+ }
268
+ return isCamelArtifact(token) ? lower : token;
224
269
  });
225
270
  }
226
271
 
227
272
  /** Mixed-case identifier the user cased deliberately (`TinyVMM`, `iOS`, `IDs`):
228
273
  * an interior/repeated capital plus at least one lowercase letter. Only these
229
- * are restored when the model flattens them.
230
- *
231
- * Pure all-caps is intentionally excluded. The model preserves its own acronyms
232
- * verbatim regardless, so restoring all-caps from the source would only ever
233
- * re-shout emphatic input (`ALL ERROR HANDLING`, `FIX THE BUG`) over the
234
- * sentence case the prompt asks for. */
274
+ * are restored when the model flattens them. */
235
275
  function isDistinctiveCasing(token: string): boolean {
236
276
  return /\p{Ll}/u.test(token) && /\p{L}\p{Lu}/u.test(token);
237
277
  }
238
278
 
279
+ /** Multi-letter ALL-CAPS source token with a stronger acronym signal than a
280
+ * plain emphasized word. Consonant-only tokens (`CNPG`, `SQL`, `JWT`) are
281
+ * restored, digit-bearing identifiers are restored, and common technical
282
+ * acronyms (`API`, `JSON`, `URL`) are allowlisted. Ordinary emphasized words
283
+ * (`FIX`, `WORK`, `BUG`) contain vowels and are not restored from source. */
284
+ function isAllCapsAcronym(token: string): boolean {
285
+ if (!isAllCapsWord(token)) return false;
286
+ const upper = token.toUpperCase();
287
+ if (COMMON_TITLE_ACRONYMS.has(upper)) return true;
288
+ if (/\p{N}/u.test(token)) return true;
289
+ return !/[AEIOU]/.test(upper);
290
+ }
291
+
292
+ /** Multi-letter ALL-CAPS word in the source. Used for shout detection, not for
293
+ * acronym restoration — shouted English words (`FIX`, `WORK`) still count as
294
+ * shouty even though they are not restorable acronyms. Requires an actual
295
+ * uppercase letter so caseless scripts (CJK) never register as shouting and
296
+ * disable acronym restoration for those messages. */
297
+ function isAllCapsWord(token: string): boolean {
298
+ const letters = token.match(/\p{L}/gu);
299
+ if (!letters || letters.length < 2) return false;
300
+ return /\p{Lu}/u.test(token) && !/\p{Ll}/u.test(token);
301
+ }
302
+
303
+ /** Plain title-cased word (`Cnpg`, `Etl`): starts uppercase, has one-or-more
304
+ * lowercase letters, no interior uppercase. This is the artifact a title model
305
+ * produces when it sentence-cases an unfamiliar ALL-CAPS acronym; PascalCase
306
+ * proper nouns like `GitHub`/`OAuth` have an interior capital and are
307
+ * excluded so we don't misidentify them. */
308
+ function isTitleCasedArtifact(token: string): boolean {
309
+ if (!/^\p{Lu}/u.test(token)) return false;
310
+ if (!/\p{Ll}/u.test(token)) return false;
311
+ return !/\p{Lu}/u.test(token.slice(1));
312
+ }
313
+
314
+ /** True when the source text is shouting — ≥2 consecutive multi-letter
315
+ * ALL-CAPS tokens (`FIX the BUG NOW` has `BUG NOW`; `ALL ERROR HANDLING`
316
+ * has all three adjacent). Acronym restoration is disabled for shouty input
317
+ * so we don't re-shout emphatic prose the model correctly de-shouted. */
318
+ function isShoutySource(sourceText: string): boolean {
319
+ let run = 0;
320
+ for (const [token] of sourceText.matchAll(TITLE_WORD)) {
321
+ if (isAllCapsWord(token)) {
322
+ run += 1;
323
+ if (run >= 2) return true;
324
+ } else {
325
+ run = 0;
326
+ }
327
+ }
328
+ return false;
329
+ }
330
+
239
331
  /** A lowercase word carrying a stray interior capital (`dAemon`, `cReate`): the
240
332
  * model-mangled shape we flatten when the user never wrote it. PascalCase proper
241
333
  * nouns (`GitHub`, `OAuth`) start uppercase and are left untouched. */
@@ -19,7 +19,7 @@ import { createFileRecorder, formatResultPath } from "./file-recorder";
19
19
  import { classifyGroupedLines, formatGroupedFiles, groupLineIndicesByBlank } from "./grouped-file-output";
20
20
  import { formatMatchLine } from "./match-line-format";
21
21
  import type { OutputMeta } from "./output-meta";
22
- import { resolveToolSearchScope } from "./path-utils";
22
+ import { resolveToolSearchScope, toPathList } from "./path-utils";
23
23
  import {
24
24
  appendParseErrorsBulletList,
25
25
  capParseErrors,
@@ -37,11 +37,9 @@ import { toolResult } from "./tool-result";
37
37
 
38
38
  const astGrepSchema = type({
39
39
  pat: type("string").describe("ast pattern"),
40
- paths: type("string")
41
- .describe("file, directory, glob, or internal URL to search")
42
- .array()
43
- .atLeastLength(1)
44
- .describe("files, directories, globs, or internal URLs to search"),
40
+ "path?": type("string").describe(
41
+ 'file, directory, glob, or internal URL to search; pass several as a semicolon-delimited list ("src; tests"). Omitted -> searches the workspace root (".")',
42
+ ),
45
43
  "skip?": type("number").describe("matches to skip"),
46
44
  });
47
45
 
@@ -159,23 +157,23 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
159
157
  readonly examples: readonly ToolExample<typeof astGrepSchema.inferIn>[] = [
160
158
  {
161
159
  caption: "Search TypeScript files under src",
162
- call: { pat: "console.log($$$)", paths: ["src/**/*.ts"] },
160
+ call: { pat: "console.log($$$)", path: "src/**/*.ts" },
163
161
  },
164
162
  {
165
163
  caption: "Named imports from a specific package",
166
- call: { pat: 'import { $$$IMPORTS } from "react"', paths: ["src/**/*.ts"] },
164
+ call: { pat: 'import { $$$IMPORTS } from "react"', path: "src/**/*.ts" },
167
165
  },
168
166
  {
169
167
  caption: "Arrow functions assigned to a const",
170
- call: { pat: "const $NAME = ($$$ARGS) => $BODY", paths: ["src/utils/**/*.ts"] },
168
+ call: { pat: "const $NAME = ($$$ARGS) => $BODY", path: "src/utils/**/*.ts" },
171
169
  },
172
170
  {
173
171
  caption: "Method call on any object, ignoring method name with `$_`",
174
- call: { pat: "logger.$_($$$ARGS)", paths: ["src/**/*.ts"] },
172
+ call: { pat: "logger.$_($$$ARGS)", path: "src/**/*.ts" },
175
173
  },
176
174
  {
177
175
  caption: "Loosest existence check for a symbol in one file",
178
- call: { pat: "processItems", paths: ["src/worker.ts"] },
176
+ call: { pat: "processItems", path: "src/worker.ts" },
179
177
  },
180
178
  ];
181
179
  readonly loadMode = "discoverable";
@@ -201,8 +199,10 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
201
199
  if (!Number.isFinite(skip) || skip < 0) {
202
200
  throw new ToolError("skip must be a non-negative number");
203
201
  }
202
+ const scopedPaths = toPathList(params.path);
203
+ const rawPaths = scopedPaths.length > 0 ? scopedPaths : ["."];
204
204
  const scope = await resolveToolSearchScope({
205
- rawPaths: params.paths,
205
+ rawPaths,
206
206
  cwd: this.session.cwd,
207
207
  internalUrlAction: "search",
208
208
  settings: this.session.settings,
@@ -275,7 +275,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
275
275
 
276
276
  if (result.matches.length === 0) {
277
277
  const noMatchMessage = cappedParseErrors.length
278
- ? "No matches found. Parse issues mean the query may be mis-scoped; narrow `paths` before concluding absence."
278
+ ? "No matches found. Parse issues mean the query may be mis-scoped; narrow `path` before concluding absence."
279
279
  : "No matches found";
280
280
  const parseMessage = cappedParseErrors.length
281
281
  ? `\n${formatParseErrors(cappedParseErrors, parseErrorsTotal).join("\n")}`
@@ -375,7 +375,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
375
375
  displayContent: displayLines.join("\n"),
376
376
  };
377
377
  if (result.limitReached) {
378
- outputLines.push("", "Result limit reached; narrow paths or increase limit.");
378
+ outputLines.push("", "Result limit reached; narrow path or increase limit.");
379
379
  }
380
380
  if (cappedParseErrors.length) {
381
381
  outputLines.push("", ...formatParseErrors(cappedParseErrors, parseErrorsTotal));
@@ -392,6 +392,8 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
392
392
 
393
393
  interface AstGrepRenderArgs {
394
394
  pat?: string;
395
+ path?: string | string[];
396
+ /** Legacy pre-`path` argument name; kept so historical transcripts still render a scope. */
395
397
  paths?: string[];
396
398
  skip?: number;
397
399
  }
@@ -402,7 +404,8 @@ export const astGrepToolRenderer = {
402
404
  inline: true,
403
405
  renderCall(args: AstGrepRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
404
406
  const meta: string[] = [];
405
- if (args.paths?.length) meta.push(`in ${args.paths.join(", ")}`);
407
+ const scopePaths = toPathList(args.path ?? args.paths);
408
+ if (scopePaths.length) meta.push(`in ${scopePaths.join(", ")}`);
406
409
  if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
407
410
 
408
411
  const description = args.pat ?? "?";
@@ -436,7 +439,7 @@ export const astGrepToolRenderer = {
436
439
  const header = renderStatusLine({ icon: "warning", title: "AST Grep", description, meta }, uiTheme);
437
440
  const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
438
441
  if (details?.parseErrors?.length) {
439
- lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `paths` before concluding absence"));
442
+ lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `path` before concluding absence"));
440
443
  appendParseErrorsBulletList(lines, details.parseErrors, uiTheme, details.parseErrorsTotal);
441
444
  }
442
445
  return new Text(lines.join("\n"), 0, 0);
@@ -487,7 +490,7 @@ export const astGrepToolRenderer = {
487
490
 
488
491
  const extraLines: string[] = [];
489
492
  if (limitReached) {
490
- extraLines.push(uiTheme.fg("warning", "limit reached; narrow paths or increase limit"));
493
+ extraLines.push(uiTheme.fg("warning", "limit reached; narrow path or increase limit"));
491
494
  }
492
495
  if (details?.parseErrors?.length) {
493
496
  extraLines.push(