@oh-my-pi/pi-coding-agent 14.5.9 → 14.5.11

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 (37) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +7 -15
  3. package/scripts/build-binary.ts +1 -1
  4. package/src/cli/update-cli.ts +25 -1
  5. package/src/config/model-registry.ts +21 -19
  6. package/src/config/settings-schema.ts +11 -16
  7. package/src/discovery/claude-plugins.ts +28 -3
  8. package/src/edit/modes/atom.ts +50 -19
  9. package/src/edit/modes/hashline.ts +171 -110
  10. package/src/export/html/template.generated.ts +1 -1
  11. package/src/export/html/template.js +14 -2
  12. package/src/extensibility/extensions/runner.ts +34 -1
  13. package/src/extensibility/extensions/types.ts +8 -0
  14. package/src/internal-urls/docs-index.generated.ts +54 -54
  15. package/src/lsp/client.ts +27 -35
  16. package/src/memories/index.ts +5 -0
  17. package/src/modes/components/settings-defs.ts +1 -1
  18. package/src/modes/controllers/selector-controller.ts +2 -2
  19. package/src/modes/controllers/todo-command-controller.ts +22 -74
  20. package/src/modes/interactive-mode.ts +36 -9
  21. package/src/modes/theme/theme.ts +10 -1
  22. package/src/modes/types.ts +1 -3
  23. package/src/modes/utils/ui-helpers.ts +19 -6
  24. package/src/prompts/system/auto-continue.md +1 -0
  25. package/src/prompts/system/eager-todo.md +1 -1
  26. package/src/prompts/tools/github.md +3 -3
  27. package/src/prompts/tools/todo-write.md +19 -19
  28. package/src/sdk.ts +13 -2
  29. package/src/session/agent-session.ts +196 -96
  30. package/src/session/session-manager.ts +19 -2
  31. package/src/tools/bash.ts +9 -4
  32. package/src/tools/gh.ts +267 -119
  33. package/src/tools/todo-write.ts +157 -195
  34. package/src/utils/git.ts +61 -2
  35. package/src/web/search/providers/searxng.ts +71 -13
  36. package/examples/custom-tools/todo/index.ts +0 -211
  37. package/examples/extensions/todo.ts +0 -295
package/CHANGELOG.md CHANGED
@@ -2,6 +2,55 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.5.11] - 2026-04-30
6
+ ### Breaking Changes
7
+
8
+ - `todo_write`: renamed `replace` op to `init` and reshaped its input to `list: [{phase: string, items: string[]}]`. Tasks no longer accept a `status` field; all start `pending` and the first auto-promotes to `in_progress`. The `append` op's `items` is now `string[]` (was `{id, label}[]`)
9
+ - `todo_write`: removed the synthetic `task-N` / `phase-N` ids — task identity is now its `content` and phase identity is its `name`. The `task` field on `start`/`done`/`drop`/`note` and the `phase` field on `done`/`drop`/`rm`/`append` take those values directly
10
+ - `todo_write`: phase names no longer accept a numeric/roman prefix (`I.`, `1.`, `Phase 1:`, …). The renderer numbers phases visually (Ⅰ. Ⅱ. Ⅲ. …) and the model-facing state stores the bare noun phrase
11
+
12
+ ### Changed
13
+
14
+ - Changed `/todo` task and phase operations to target items by fuzzy content or phase name matching instead of numeric IDs
15
+ - Changed initial todo markdown export template heading from `# I. Todos` to `# Todos`
16
+
17
+ ### Fixed
18
+
19
+ - Fixed todo auto-clear scheduling to identify completed tasks by phase and content so only the matching task is cleared after delays
20
+
21
+ ## [14.5.10] - 2026-04-30
22
+
23
+ ### Breaking Changes
24
+
25
+ - Removed the `worktree` parameter from `github` `pr_checkout`. Worktrees are now always written to `~/.omp/wt/<encoded-primary-repo>/pr-<number>/`, derived from the primary repository path
26
+ - Stopped reading the `branch` parameter for `github` `pr_checkout`. The local branch is now always `pr-<number>`; the `branch` schema field is still accepted by `pr_push`, `repo_view`, and `run_watch`
27
+
28
+ ### Added
29
+
30
+ - Added `checkouts` summary entries to `pr_checkout` results, including each checkout's branch, worktree path, remote, and reuse status
31
+ - Added combined summaries for `pr_view` and `pr_diff` when `pr` is an array, so multi-request responses now include all requested pull requests in one return
32
+ - Added array support to the `pr` parameter on `github` `pr_view`, `pr_diff`, and `pr_checkout` so a single call can fetch, diff, or check out multiple pull requests in one batch
33
+ - Added a per-repo serialization lock (`withRepoLock`) so concurrent `pr_checkout` calls against the same repository no longer race on git's internal `.git/config.lock`, commit-graph, and worktree lock files
34
+
35
+ ### Changed
36
+
37
+ - Changed the diff preview shown after edits so changed lines are never collapsed: removed runs and the global preview budget no longer truncate, only unchanged context still collapses
38
+ - Changed adjacent `-`/`+` pairs in edit previews to fold into a single `*<line><hash>|<new-content>` modification line so 1:1 line replacements stay compact
39
+ - Changed `git.remote.add` to be idempotent when the remote already exists with the same URL (instead of failing with `remote ... already exists`), and to surface a clear error when the existing URL differs
40
+ - Changed `pr_checkout` to run `gh pr view` calls in parallel for batch invocations while serializing the in-repo git mutations to keep the operation race-free
41
+ - Changed `pr_checkout` to auto-derive the worktree location and local branch name (see Breaking Changes), removing the per-call overrides that previously let callers pin a worktree path or local branch
42
+
43
+ ### Removed
44
+
45
+ - Removed the `./hooks` and `./hooks/*` package export entries
46
+ - Removed the `Suspicious duplicate` warning emitted after edits — it produced too many false positives (e.g. legitimate adjacent `\t});\n\t});`); the auto-fix path that uses bracket balance to safely de-duplicate is unchanged
47
+
48
+ ### Fixed
49
+
50
+ - Fixed bash interceptor rules to also check the original command before `cd` normalization, so leading `cd ... &&` wrappers no longer bypass interception
51
+ - Fixed LSP client shutdown to properly await the language server's exit instead of fire-and-forget, preventing premature process termination on SIGINT and SIGTERM
52
+ - Fixed concurrent bash commands being tracked independently so aborting one no longer silently drops tracking of others
53
+
5
54
  ## [14.5.9] - 2026-04-30
6
55
 
7
56
  ### Added
@@ -82,6 +131,7 @@
82
131
 
83
132
  ### Added
84
133
 
134
+ - Added the `after_provider_response` extension event for observing provider response status, headers, and request IDs.
85
135
  - Added internal URL support to the `search` tool, allowing `artifact://`-style paths that resolve to local files to be searched directly
86
136
  - Added IRC relay observation in the main agent UI so every IRC exchange between agents is rendered in the main transcript, even when the main agent is not a direct participant
87
137
  - Added stateful `href`/`hrefr` prompt helpers that can reuse anchors remembered from prior `hline` helper calls
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "14.5.9",
4
+ "version": "14.5.11",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.20.0",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "@oh-my-pi/omp-stats": "14.5.9",
50
- "@oh-my-pi/pi-agent-core": "14.5.9",
51
- "@oh-my-pi/pi-ai": "14.5.9",
52
- "@oh-my-pi/pi-natives": "14.5.9",
53
- "@oh-my-pi/pi-tui": "14.5.9",
54
- "@oh-my-pi/pi-utils": "14.5.9",
49
+ "@oh-my-pi/omp-stats": "14.5.11",
50
+ "@oh-my-pi/pi-agent-core": "14.5.11",
51
+ "@oh-my-pi/pi-ai": "14.5.11",
52
+ "@oh-my-pi/pi-natives": "14.5.11",
53
+ "@oh-my-pi/pi-tui": "14.5.11",
54
+ "@oh-my-pi/pi-utils": "14.5.11",
55
55
  "@puppeteer/browsers": "^2.13.0",
56
56
  "@sinclair/typebox": "^0.34.49",
57
57
  "@xterm/headless": "^6.0.0",
@@ -516,14 +516,6 @@
516
516
  "types": "./src/web/search/providers/*.ts",
517
517
  "import": "./src/web/search/providers/*.ts"
518
518
  },
519
- "./hooks": {
520
- "types": "./src/extensibility/hooks/index.ts",
521
- "import": "./src/extensibility/hooks/index.ts"
522
- },
523
- "./hooks/*": {
524
- "types": "./src/extensibility/hooks/*.ts",
525
- "import": "./src/extensibility/hooks/*.ts"
526
- },
527
519
  "./*.js": "./src/*.ts"
528
520
  }
529
521
  }
@@ -34,7 +34,7 @@ async function main(): Promise<void> {
34
34
  "build",
35
35
  "--compile",
36
36
  "--define",
37
- "PI_COMPILED=true",
37
+ 'process.env.PI_COMPILED="true"',
38
38
  "--external",
39
39
  "mupdf",
40
40
  "--root",
@@ -53,13 +53,37 @@ function normalizePathForComparison(filePath: string): string {
53
53
  return normalized;
54
54
  }
55
55
 
56
- function isPathInDirectory(filePath: string, directoryPath: string): boolean {
56
+ function tryRealpath(p: string): string | undefined {
57
+ try {
58
+ return fs.realpathSync.native(p);
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ function isPathInDirectoryLexical(filePath: string, directoryPath: string): boolean {
57
65
  const normalizedPath = normalizePathForComparison(path.resolve(filePath));
58
66
  const normalizedDirectory = normalizePathForComparison(path.resolve(directoryPath));
59
67
  const relativePath = path.relative(normalizedDirectory, normalizedPath);
60
68
  return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
61
69
  }
62
70
 
71
+ function isPathInDirectory(filePath: string, directoryPath: string): boolean {
72
+ if (isPathInDirectoryLexical(filePath, directoryPath)) return true;
73
+ // Layer realpath resolution on top of the lexical guard. On Windows, ~/.bun
74
+ // is a junction when Bun is installed via Scoop, so `bun pm bin -g` and the
75
+ // PATH-resolved omp path can refer to the same directory through different
76
+ // strings. path.resolve does not traverse junctions/symlinks; realpath does.
77
+ // Resolve the file's parent directory to tolerate the file itself not yet
78
+ // existing (e.g. a fresh install path) while still catching link-traversed
79
+ // equality once the directory exists.
80
+ const fileDir = tryRealpath(path.dirname(path.resolve(filePath)));
81
+ const dirReal = tryRealpath(path.resolve(directoryPath));
82
+ if (!fileDir || !dirReal) return false;
83
+ const resolvedFile = path.join(fileDir, path.basename(filePath));
84
+ return isPathInDirectoryLexical(resolvedFile, dirReal);
85
+ }
86
+
63
87
  type UpdateTarget = { method: "bun" } | { method: "binary"; path: string };
64
88
 
65
89
  function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
@@ -145,8 +145,16 @@ const OpenAICompatSchema = Type.Object({
145
145
  maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])),
146
146
  supportsUsageInStreaming: Type.Optional(Type.Boolean()),
147
147
  requiresToolResultName: Type.Optional(Type.Boolean()),
148
+ requiresMistralToolIds: Type.Optional(Type.Boolean()),
148
149
  requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()),
149
150
  requiresThinkingAsText: Type.Optional(Type.Boolean()),
151
+ reasoningContentField: Type.Optional(
152
+ Type.Union([Type.Literal("reasoning_content"), Type.Literal("reasoning"), Type.Literal("reasoning_text")]),
153
+ ),
154
+ requiresReasoningContentForToolCalls: Type.Optional(Type.Boolean()),
155
+ requiresAssistantContentForToolCalls: Type.Optional(Type.Boolean()),
156
+ supportsToolChoice: Type.Optional(Type.Boolean()),
157
+ disableReasoningOnForcedToolChoice: Type.Optional(Type.Boolean()),
150
158
  thinkingFormat: Type.Optional(
151
159
  Type.Union([
152
160
  Type.Literal("openai"),
@@ -183,6 +191,7 @@ const ModelThinkingSchema = Type.Object({
183
191
  minLevel: EffortSchema,
184
192
  maxLevel: EffortSchema,
185
193
  mode: ThinkingControlModeSchema,
194
+ defaultLevel: Type.Optional(EffortSchema),
186
195
  });
187
196
 
188
197
  // Schema for custom model definition
@@ -558,27 +567,20 @@ function resolveOAuthAccountIdForAccessToken(
558
567
  return undefined;
559
568
  }
560
569
 
561
- function mergeCompat(
562
- baseCompat: Model<Api>["compat"],
563
- overrideCompat: ModelOverride["compat"],
564
- ): Model<Api>["compat"] | undefined {
570
+ function mergeCompat<TBase extends object, TOverride extends object>(
571
+ baseCompat: TBase | null | undefined,
572
+ overrideCompat: TOverride | null | undefined,
573
+ ): (TBase & TOverride) | TBase | TOverride | undefined {
574
+ if (!baseCompat) return overrideCompat ?? undefined;
565
575
  if (!overrideCompat) return baseCompat;
566
- const base = baseCompat ?? {};
567
- const override = overrideCompat;
568
- const merged: NonNullable<Model<Api>["compat"]> = { ...base, ...override };
569
- if (baseCompat?.reasoningEffortMap || overrideCompat.reasoningEffortMap) {
570
- merged.reasoningEffortMap = { ...baseCompat?.reasoningEffortMap, ...overrideCompat.reasoningEffortMap };
571
- }
572
- if (baseCompat?.openRouterRouting || overrideCompat.openRouterRouting) {
573
- merged.openRouterRouting = { ...baseCompat?.openRouterRouting, ...overrideCompat.openRouterRouting };
574
- }
575
- if (baseCompat?.vercelGatewayRouting || overrideCompat.vercelGatewayRouting) {
576
- merged.vercelGatewayRouting = { ...baseCompat?.vercelGatewayRouting, ...overrideCompat.vercelGatewayRouting };
577
- }
578
- if (baseCompat?.extraBody || overrideCompat.extraBody) {
579
- merged.extraBody = { ...baseCompat?.extraBody, ...overrideCompat.extraBody };
576
+
577
+ const merged: Record<string, unknown> = { ...(baseCompat as Record<string, unknown>) };
578
+ for (const [key, overrideValue] of Object.entries(overrideCompat)) {
579
+ const baseValue = (baseCompat as Record<string, unknown>)[key];
580
+ merged[key] =
581
+ isRecord(baseValue) && isRecord(overrideValue) ? mergeCompat(baseValue, overrideValue) : overrideValue;
580
582
  }
581
- return merged;
583
+ return merged as TBase & TOverride;
582
584
  }
583
585
 
584
586
  function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<Api> {
@@ -1793,38 +1793,33 @@ export const SETTINGS_SCHEMA = {
1793
1793
  ui: {
1794
1794
  tab: "providers",
1795
1795
  label: "SearXNG Endpoint",
1796
- description: "Base URL of the SearXNG instance (e.g. https://searx.example.org)",
1796
+ description: "Self-hosted search base URL",
1797
1797
  },
1798
1798
  },
1799
1799
 
1800
1800
  "searxng.token": {
1801
1801
  type: "string",
1802
1802
  default: undefined,
1803
- ui: {
1804
- tab: "providers",
1805
- label: "SearXNG Token",
1806
- description: "Optional bearer token for SearXNG authentication",
1807
- },
1803
+ },
1804
+
1805
+ "searxng.basicUsername": {
1806
+ type: "string",
1807
+ default: undefined,
1808
+ },
1809
+
1810
+ "searxng.basicPassword": {
1811
+ type: "string",
1812
+ default: undefined,
1808
1813
  },
1809
1814
 
1810
1815
  "searxng.categories": {
1811
1816
  type: "string",
1812
1817
  default: undefined,
1813
- ui: {
1814
- tab: "providers",
1815
- label: "SearXNG Categories",
1816
- description: "Comma-separated categories filter (e.g. general,news,science)",
1817
- },
1818
1818
  },
1819
1819
 
1820
1820
  "searxng.language": {
1821
1821
  type: "string",
1822
1822
  default: undefined,
1823
- ui: {
1824
- tab: "providers",
1825
- label: "SearXNG Language",
1826
- description: "Language code for search results (e.g. en, zh-CN)",
1827
- },
1828
1823
  },
1829
1824
 
1830
1825
  "commit.mapReduceEnabled": { type: "boolean", default: true },
@@ -265,10 +265,28 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
265
265
  }
266
266
 
267
267
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
268
- const config = parsed as { mcpServers?: Record<string, unknown> };
269
- if (!config.mcpServers || typeof config.mcpServers !== "object") continue;
268
+ const obj = parsed as Record<string, unknown>;
269
+
270
+ // Two shapes are supported:
271
+ // nested: { "mcpServers": { name: cfg, ... } } (OMP/Claude Code project shape)
272
+ // flat: { name: cfg, ... } (Claude marketplace plugin shape)
273
+ // If "mcpServers" is present and an object, treat it as the canonical map.
274
+ // Otherwise, treat the whole object as the server map.
275
+ let servers: Record<string, unknown>;
276
+ if (
277
+ obj.mcpServers !== undefined &&
278
+ obj.mcpServers !== null &&
279
+ typeof obj.mcpServers === "object" &&
280
+ !Array.isArray(obj.mcpServers)
281
+ ) {
282
+ servers = obj.mcpServers as Record<string, unknown>;
283
+ } else if (!("mcpServers" in obj)) {
284
+ servers = obj;
285
+ } else {
286
+ continue;
287
+ }
270
288
 
271
- for (const [serverName, serverCfg] of Object.entries(config.mcpServers)) {
289
+ for (const [serverName, serverCfg] of Object.entries(servers)) {
272
290
  if (!serverCfg || typeof serverCfg !== "object" || Array.isArray(serverCfg)) continue;
273
291
  const raw = serverCfg as {
274
292
  enabled?: boolean;
@@ -283,6 +301,13 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
283
301
  oauth?: MCPServer["oauth"];
284
302
  type?: string;
285
303
  };
304
+ // Require either command (stdio) or url (HTTP/SSE) — Claude marketplace plugins
305
+ // occasionally ship .mcp.json entries with neither, which would register a useless
306
+ // server and surface as a connection error at runtime.
307
+ if (typeof raw.command !== "string" && typeof raw.url !== "string") {
308
+ warnings.push(`[claude-plugins] Skipping MCP server "${serverName}" in ${mcpPath}: missing command or url`);
309
+ continue;
310
+ }
286
311
  const namespacedName = root.plugin ? `${root.plugin}:${serverName}` : serverName;
287
312
  const server: MCPServer = {
288
313
  name: namespacedName,
@@ -518,11 +518,26 @@ function isReplaceStart(line: string): boolean {
518
518
  return /^[1-9]\d*[a-z]{2}[ \t]*[=|]/.test(stripped);
519
519
  }
520
520
 
521
+ // Lookahead used by the blank-line forgiveness rule below: returns true when
522
+ // the first non-blank line at or after `start` is a `\TEXT` continuation.
523
+ function nextNonBlankIsBackslash(lines: readonly string[], start: number): boolean {
524
+ for (let j = start; j < lines.length; j++) {
525
+ const peek = lines[j].endsWith("\r") ? lines[j].slice(0, -1) : lines[j];
526
+ if (peek.length === 0) continue;
527
+ return peek.startsWith("\\");
528
+ }
529
+ return false;
530
+ }
531
+
521
532
  // Explicit continuation uses `\TEXT` after a replacement op (`Lid=FIRST` or
522
533
  // `LidA..LidB=FIRST`). The leading backslash is the continuation marker; the
523
534
  // rest of the line is inserted literally, so `\\TEXT` inserts a line starting
524
- // with `\TEXT`. Raw unprefixed continuation remains an undocumented
525
- // best-effort recovery for range replacements only, kept for old transcripts.
535
+ // with `\TEXT`. As a forgiveness rule, a literal blank line inside an active
536
+ // replacement that is itself followed (possibly through more blanks) by another
537
+ // `\TEXT` continuation is treated as an implicit `\` blank insert — authors
538
+ // frequently drop a real blank between `\TEXT` lines instead of writing `\`.
539
+ // Raw unprefixed continuation remains an undocumented best-effort recovery for
540
+ // range replacements only, kept for old transcripts.
526
541
  function preprocessRangeReplaceContinuation(diff: string): string {
527
542
  const lines = diff.split("\n");
528
543
  let inRangeReplace = false;
@@ -541,6 +556,14 @@ function preprocessRangeReplaceContinuation(diff: string): string {
541
556
  continue;
542
557
  }
543
558
 
559
+ // Forgiveness: a blank line inside an active replacement that is followed
560
+ // by another `\TEXT` continuation is treated as an implicit `\` blank
561
+ // insert. Keeps the replacement open across the blank.
562
+ if (inReplace && line.length === 0 && nextNonBlankIsBackslash(lines, i + 1)) {
563
+ lines[i] = `+${RANGE_CONTINUATION_SENTINEL}`;
564
+ continue;
565
+ }
566
+
544
567
  if (inRangeReplace) {
545
568
  if (line.length === 0 || OP_LINE_HEAD_RE.test(line)) {
546
569
  inRangeReplace = isRangeReplaceStart(line);
@@ -949,6 +972,9 @@ function getAnchorForAnchorEdit(edit: IndexedAnchorEdit["edit"]): Anchor {
949
972
  // missed one delete on the front or back of the deletion range, leaving a
950
973
  // stale copy of a line the agent already re-emitted (e.g. inserting a new
951
974
  // closing `}` while the original `}` was never deleted, producing `}\n}`).
975
+ // A single edit may damage multiple unrelated segments (e.g. two block
976
+ // rewrites that each missed their trailing `}`), so detection and auto-fix
977
+ // operate on every new adjacent duplicate at once.
952
978
  //
953
979
  // Auto-fix is gated on bracket balance: we only remove the duplicate line if
954
980
  // its removal restores the original file's `{}`/`()`/`[]` delta. That makes
@@ -1008,23 +1034,28 @@ function detectAndAutoFixDuplicates(
1008
1034
 
1009
1035
  const formatPreview = (text: string): string => JSON.stringify(text.length > 60 ? `${text.slice(0, 60)}…` : text);
1010
1036
 
1011
- // Auto-fix only when there is exactly one new adjacent duplicate AND the
1012
- // edit shifted bracket balance. Removing one of the two identical lines
1013
- // must restore the original delta exactly.
1014
- if (newDupPositions.length === 1) {
1015
- const pos = newDupPositions[0];
1016
- const origBalance = computeBalance(originalLines);
1017
- const finalBalance = computeBalance(finalLines);
1018
- if (!balancesEqual(origBalance, finalBalance)) {
1019
- const trial = finalLines.slice(0, pos).concat(finalLines.slice(pos + 1));
1020
- if (balancesEqual(computeBalance(trial), origBalance)) {
1021
- return {
1022
- fixed: trial,
1023
- warnings: [
1024
- `Auto-fixed: removed duplicate line ${pos + 1} (${formatPreview(finalLines[pos])}); the edit left two adjacent identical lines and bracket balance was off. Verify the result.`,
1025
- ],
1026
- };
1027
- }
1037
+ // Auto-fix when removing one line from each new adjacent duplicate pair
1038
+ // collectively restores the original bracket balance. The balance check is
1039
+ // the safety gate: if we over- or under-correct (e.g. when 3+ adjacent
1040
+ // identical lines confuse the per-pair scan), the trial balance will not
1041
+ // match and we fall through to warnings.
1042
+ const origBalance = computeBalance(originalLines);
1043
+ const finalBalance = computeBalance(finalLines);
1044
+ if (!balancesEqual(origBalance, finalBalance)) {
1045
+ const trial = finalLines.slice();
1046
+ // Remove in reverse so earlier indices remain valid.
1047
+ for (let i = newDupPositions.length - 1; i >= 0; i--) {
1048
+ trial.splice(newDupPositions[i], 1);
1049
+ }
1050
+ if (balancesEqual(computeBalance(trial), origBalance)) {
1051
+ const previews = newDupPositions.map(pos => `${pos + 1} (${formatPreview(finalLines[pos])})`).join(", ");
1052
+ const noun = newDupPositions.length === 1 ? "duplicate line" : "duplicate lines";
1053
+ return {
1054
+ fixed: trial,
1055
+ warnings: [
1056
+ `Auto-fixed: removed ${noun} ${previews}; the edit left adjacent identical lines and bracket balance was off. Verify the result.`,
1057
+ ],
1058
+ };
1028
1059
  }
1029
1060
  }
1030
1061