@oh-my-pi/pi-coding-agent 13.14.0 → 13.15.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 (90) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/package.json +10 -8
  3. package/src/autoresearch/command-initialize.md +34 -0
  4. package/src/autoresearch/command-resume.md +17 -0
  5. package/src/autoresearch/contract.ts +332 -0
  6. package/src/autoresearch/dashboard.ts +447 -0
  7. package/src/autoresearch/git.ts +243 -0
  8. package/src/autoresearch/helpers.ts +458 -0
  9. package/src/autoresearch/index.ts +693 -0
  10. package/src/autoresearch/prompt.md +227 -0
  11. package/src/autoresearch/resume-message.md +16 -0
  12. package/src/autoresearch/state.ts +386 -0
  13. package/src/autoresearch/tools/init-experiment.ts +310 -0
  14. package/src/autoresearch/tools/log-experiment.ts +833 -0
  15. package/src/autoresearch/tools/run-experiment.ts +640 -0
  16. package/src/autoresearch/types.ts +218 -0
  17. package/src/cli/args.ts +8 -2
  18. package/src/cli/initial-message.ts +58 -0
  19. package/src/config/keybindings.ts +417 -212
  20. package/src/config/model-registry.ts +1 -0
  21. package/src/config/model-resolver.ts +57 -9
  22. package/src/config/settings-schema.ts +38 -10
  23. package/src/config/settings.ts +1 -4
  24. package/src/exec/bash-executor.ts +7 -5
  25. package/src/export/html/template.css +43 -13
  26. package/src/export/html/template.generated.ts +1 -1
  27. package/src/export/html/template.html +1 -0
  28. package/src/export/html/template.js +107 -0
  29. package/src/extensibility/extensions/types.ts +31 -8
  30. package/src/internal-urls/docs-index.generated.ts +1 -1
  31. package/src/lsp/index.ts +1 -1
  32. package/src/main.ts +44 -44
  33. package/src/mcp/oauth-discovery.ts +1 -1
  34. package/src/modes/acp/acp-agent.ts +957 -0
  35. package/src/modes/acp/acp-event-mapper.ts +531 -0
  36. package/src/modes/acp/acp-mode.ts +13 -0
  37. package/src/modes/acp/index.ts +2 -0
  38. package/src/modes/components/agent-dashboard.ts +5 -4
  39. package/src/modes/components/bash-execution.ts +40 -11
  40. package/src/modes/components/custom-editor.ts +47 -47
  41. package/src/modes/components/extensions/extension-dashboard.ts +2 -1
  42. package/src/modes/components/history-search.ts +2 -1
  43. package/src/modes/components/hook-editor.ts +2 -1
  44. package/src/modes/components/hook-input.ts +8 -7
  45. package/src/modes/components/hook-selector.ts +15 -10
  46. package/src/modes/components/keybinding-hints.ts +9 -9
  47. package/src/modes/components/login-dialog.ts +3 -3
  48. package/src/modes/components/mcp-add-wizard.ts +2 -1
  49. package/src/modes/components/model-selector.ts +14 -3
  50. package/src/modes/components/oauth-selector.ts +2 -1
  51. package/src/modes/components/python-execution.ts +2 -3
  52. package/src/modes/components/session-selector.ts +2 -1
  53. package/src/modes/components/settings-selector.ts +2 -1
  54. package/src/modes/components/status-line-segment-editor.ts +2 -1
  55. package/src/modes/components/tool-execution.ts +4 -5
  56. package/src/modes/components/tree-selector.ts +3 -2
  57. package/src/modes/components/user-message-selector.ts +3 -8
  58. package/src/modes/components/user-message.ts +16 -0
  59. package/src/modes/controllers/command-controller.ts +0 -2
  60. package/src/modes/controllers/extension-ui-controller.ts +89 -4
  61. package/src/modes/controllers/input-controller.ts +29 -23
  62. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  63. package/src/modes/index.ts +1 -0
  64. package/src/modes/interactive-mode.ts +17 -5
  65. package/src/modes/print-mode.ts +1 -1
  66. package/src/modes/prompt-action-autocomplete.ts +7 -7
  67. package/src/modes/rpc/rpc-mode.ts +7 -2
  68. package/src/modes/rpc/rpc-types.ts +1 -0
  69. package/src/modes/theme/theme.ts +53 -44
  70. package/src/modes/types.ts +9 -2
  71. package/src/modes/utils/hotkeys-markdown.ts +19 -19
  72. package/src/modes/utils/keybinding-matchers.ts +21 -0
  73. package/src/modes/utils/ui-helpers.ts +1 -1
  74. package/src/patch/hashline.ts +139 -127
  75. package/src/patch/index.ts +77 -59
  76. package/src/patch/shared.ts +19 -11
  77. package/src/prompts/tools/hashline.md +43 -116
  78. package/src/sdk.ts +34 -17
  79. package/src/session/agent-session.ts +123 -30
  80. package/src/session/session-manager.ts +32 -31
  81. package/src/session/streaming-output.ts +87 -37
  82. package/src/tools/ask.ts +56 -30
  83. package/src/tools/bash-interactive.ts +2 -6
  84. package/src/tools/bash-interceptor.ts +1 -39
  85. package/src/tools/bash-skill-urls.ts +1 -1
  86. package/src/tools/browser.ts +1 -1
  87. package/src/tools/gemini-image.ts +1 -1
  88. package/src/tools/python.ts +2 -2
  89. package/src/tools/resolve.ts +1 -1
  90. package/src/utils/child-process.ts +88 -0
@@ -3,7 +3,12 @@ import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-m
3
3
  import type { Component, Container, Loader, Spacer, Text, TUI } from "@oh-my-pi/pi-tui";
4
4
  import type { KeybindingsManager } from "../config/keybindings";
5
5
  import type { Settings } from "../config/settings";
6
- import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../extensibility/extensions";
6
+ import type {
7
+ ExtensionUIContext,
8
+ ExtensionUIDialogOptions,
9
+ ExtensionWidgetContent,
10
+ ExtensionWidgetOptions,
11
+ } from "../extensibility/extensions";
7
12
  import type { CompactOptions } from "../extensibility/extensions/types";
8
13
  import type { MCPManager } from "../mcp";
9
14
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
@@ -59,6 +64,8 @@ export interface InteractiveModeContext {
59
64
  btwContainer: Container;
60
65
  editor: CustomEditor;
61
66
  editorContainer: Container;
67
+ hookWidgetContainerAbove: Container;
68
+ hookWidgetContainerBelow: Container;
62
69
  statusLine: StatusLineComponent;
63
70
 
64
71
  // Session access
@@ -226,7 +233,7 @@ export interface InteractiveModeContext {
226
233
  reason: "start" | "switch" | "branch" | "tree" | "shutdown",
227
234
  previousSessionFile?: string,
228
235
  ): Promise<void>;
229
- setHookWidget(key: string, content: unknown): void;
236
+ setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void;
230
237
  setHookStatus(key: string, text: string | undefined): void;
231
238
  showHookSelector(
232
239
  title: string,
@@ -1,10 +1,10 @@
1
- import type { AppAction, KeybindingsManager } from "../../config/keybindings";
1
+ import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
2
2
 
3
3
  export interface HotkeysMarkdownBindings {
4
4
  keybindings: Pick<KeybindingsManager, "getDisplayString">;
5
5
  }
6
6
 
7
- function appKey(bindings: HotkeysMarkdownBindings, action: AppAction): string {
7
+ function appKey(bindings: HotkeysMarkdownBindings, action: AppKeybinding): string {
8
8
  return bindings.keybindings.getDisplayString(action) || "Disabled";
9
9
  }
10
10
 
@@ -26,29 +26,29 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
26
26
  "| `Ctrl+W` / `Option+Backspace` | Delete word backwards |",
27
27
  "| `Ctrl+U` | Delete to start of line |",
28
28
  "| `Ctrl+K` | Delete to end of line |",
29
- `| \`${appKey(bindings, "copyLine")}\` | Copy current line |`,
30
- `| \`${appKey(bindings, "copyPrompt")}\` | Copy whole prompt |`,
29
+ `| \`${appKey(bindings, "app.clipboard.copyLine")}\` | Copy current line |`,
30
+ `| \`${appKey(bindings, "app.clipboard.copyPrompt")}\` | Copy whole prompt |`,
31
31
  "",
32
32
  "**Other**",
33
33
  "| Key | Action |",
34
34
  "|-----|--------|",
35
35
  "| `Tab` | Path completion / accept autocomplete |",
36
- `| \`${appKey(bindings, "interrupt")}\` | Cancel autocomplete / interrupt active work |`,
37
- `| \`${appKey(bindings, "clear")}\` | Clear editor (first) / exit (second) |`,
38
- `| \`${appKey(bindings, "exit")}\` | Exit (when editor is empty) |`,
39
- `| \`${appKey(bindings, "suspend")}\` | Suspend to background |`,
40
- `| \`${appKey(bindings, "cycleThinkingLevel")}\` | Cycle thinking level |`,
41
- `| \`${appKey(bindings, "cycleModelForward")}\` | Cycle role models (slow/default/smol) |`,
42
- `| \`${appKey(bindings, "cycleModelBackward")}\` | Cycle role models (temporary) |`,
36
+ `| \`${appKey(bindings, "app.interrupt")}\` | Cancel autocomplete / interrupt active work |`,
37
+ `| \`${appKey(bindings, "app.clear")}\` | Clear editor (first) / exit (second) |`,
38
+ `| \`${appKey(bindings, "app.exit")}\` | Exit (when editor is empty) |`,
39
+ `| \`${appKey(bindings, "app.suspend")}\` | Suspend to background |`,
40
+ `| \`${appKey(bindings, "app.thinking.cycle")}\` | Cycle thinking level |`,
41
+ `| \`${appKey(bindings, "app.model.cycleForward")}\` | Cycle role models (slow/default/smol) |`,
42
+ `| \`${appKey(bindings, "app.model.cycleBackward")}\` | Cycle role models (temporary) |`,
43
43
  "| `Alt+P` | Select model (temporary) |",
44
- `| \`${appKey(bindings, "selectModel")}\` | Select model (set roles) |`,
45
- `| \`${appKey(bindings, "togglePlanMode")}\` | Toggle plan mode |`,
46
- `| \`${appKey(bindings, "historySearch")}\` | Search prompt history |`,
47
- `| \`${appKey(bindings, "expandTools")}\` | Toggle tool output expansion |`,
48
- `| \`${appKey(bindings, "toggleThinking")}\` | Toggle thinking block visibility |`,
49
- `| \`${appKey(bindings, "externalEditor")}\` | Edit message in external editor |`,
50
- `| \`${appKey(bindings, "pasteImage")}\` | Paste image from clipboard |`,
51
- `| \`${appKey(bindings, "toggleSTT")}\` | Toggle speech-to-text recording |`,
44
+ `| \`${appKey(bindings, "app.model.select")}\` | Select model (set roles) |`,
45
+ `| \`${appKey(bindings, "app.plan.toggle")}\` | Toggle plan mode |`,
46
+ `| \`${appKey(bindings, "app.history.search")}\` | Search prompt history |`,
47
+ `| \`${appKey(bindings, "app.tools.expand")}\` | Toggle tool output expansion |`,
48
+ `| \`${appKey(bindings, "app.thinking.toggle")}\` | Toggle thinking block visibility |`,
49
+ `| \`${appKey(bindings, "app.editor.external")}\` | Edit message in external editor |`,
50
+ `| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image from clipboard |`,
51
+ `| \`${appKey(bindings, "app.stt.toggle")}\` | Toggle speech-to-text recording |`,
52
52
  "| `#` | Open prompt actions |",
53
53
  "| `/` | Slash commands |",
54
54
  "| `!` | Run bash command |",
@@ -0,0 +1,21 @@
1
+ import { getKeybindings, matchesKey } from "@oh-my-pi/pi-tui";
2
+
3
+ /**
4
+ * Match the coding-agent interrupt key.
5
+ *
6
+ * Interactive mode installs a keybinding manager that exposes `app.interrupt`
7
+ * globally, but some isolated component tests still run with only TUI
8
+ * keybindings registered. In that case, fall back to raw Escape matching.
9
+ */
10
+ export function matchesAppInterrupt(data: string): boolean {
11
+ const keybindings = getKeybindings();
12
+ const interruptKeys = keybindings.getKeys("app.interrupt");
13
+ if (interruptKeys.length > 0) {
14
+ return keybindings.matches(data, "app.interrupt");
15
+ }
16
+ return matchesKey(data, "escape") || matchesKey(data, "esc");
17
+ }
18
+
19
+ export function matchesSelectCancel(data: string): boolean {
20
+ return getKeybindings().matches(data, "tui.select.cancel");
21
+ }
@@ -474,7 +474,7 @@ export class UiHelpers {
474
474
  const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
475
475
  this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
476
476
  }
477
- const dequeueKey = this.ctx.keybindings.getDisplayString("dequeue") || "Alt+Up";
477
+ const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up";
478
478
  const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to edit`);
479
479
  this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
480
480
  }
@@ -16,9 +16,12 @@ import type { HashMismatch } from "./types";
16
16
 
17
17
  export type Anchor = { line: number; hash: string };
18
18
  export type HashlineEdit =
19
- | { op: "replace"; pos: Anchor; end?: Anchor; lines: string[] }
20
- | { op: "append"; pos?: Anchor; lines: string[] }
21
- | { op: "prepend"; pos?: Anchor; lines: string[] };
19
+ | { op: "replace_line"; pos: Anchor; lines: string[] }
20
+ | { op: "replace_range"; pos: Anchor; end: Anchor; lines: string[] }
21
+ | { op: "append_at"; pos: Anchor; lines: string[] }
22
+ | { op: "prepend_at"; pos: Anchor; lines: string[] }
23
+ | { op: "append_file"; lines: string[] }
24
+ | { op: "prepend_file"; lines: string[] };
22
25
 
23
26
  const NIBBLE_STR = "ZPMQVRWSNKTXJBYH";
24
27
 
@@ -455,18 +458,6 @@ function maybeWarnSuspiciousUnicodeEscapePlaceholder(edits: HashlineEdit[], warn
455
458
  // Edit Application
456
459
  // ═══════════════════════════════════════════════════════════════════════════
457
460
 
458
- const MIN_AUTOCORRECT_LENGTH = 2;
459
-
460
- function shouldAutocorrect(line: string, otherLine: string): boolean {
461
- if (!line || line !== otherLine) return false;
462
- line = line.trim();
463
- if (line.length < MIN_AUTOCORRECT_LENGTH) {
464
- // if brace, we allow
465
- return line.endsWith("}") || line.endsWith(")");
466
- }
467
- return true;
468
- }
469
-
470
461
  /**
471
462
  * Apply an array of hashline edits to file content.
472
463
  *
@@ -513,28 +504,29 @@ export function applyHashlineEdits(
513
504
  }
514
505
  for (const edit of edits) {
515
506
  switch (edit.op) {
516
- case "replace": {
517
- if (edit.end) {
518
- const startValid = validateRef(edit.pos);
519
- const endValid = validateRef(edit.end);
520
- if (!startValid || !endValid) continue;
521
- if (edit.pos.line > edit.end.line) {
522
- throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`);
523
- }
524
- } else {
525
- if (!validateRef(edit.pos)) continue;
507
+ case "replace_line": {
508
+ if (!validateRef(edit.pos)) continue;
509
+ break;
510
+ }
511
+ case "replace_range": {
512
+ const startValid = validateRef(edit.pos);
513
+ const endValid = validateRef(edit.end);
514
+ if (!startValid || !endValid) continue;
515
+ if (edit.pos.line > edit.end.line) {
516
+ throw new Error(`Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`);
526
517
  }
527
518
  break;
528
519
  }
529
- case "append": {
530
- if (edit.pos && !validateRef(edit.pos)) continue;
520
+ case "append_at":
521
+ case "prepend_at": {
522
+ if (!validateRef(edit.pos)) continue;
531
523
  if (edit.lines.length === 0) {
532
524
  edit.lines = [""]; // insert an empty line
533
525
  }
534
526
  break;
535
527
  }
536
- case "prepend": {
537
- if (edit.pos && !validateRef(edit.pos)) continue;
528
+ case "append_file":
529
+ case "prepend_file": {
538
530
  if (edit.lines.length === 0) {
539
531
  edit.lines = [""]; // insert an empty line
540
532
  }
@@ -547,6 +539,38 @@ export function applyHashlineEdits(
547
539
  }
548
540
  maybeAutocorrectEscapedTabIndentation(edits, warnings);
549
541
  maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
542
+
543
+ // Warn when a replace_range/replace_line's last inserted line duplicates the next surviving line.
544
+ // This catches the common boundary-overreach pattern where the agent includes a closing delimiter
545
+ // in the replacement but sets `end` to the line before the delimiter, causing duplication.
546
+ for (const edit of edits) {
547
+ let endLine: number;
548
+ switch (edit.op) {
549
+ case "replace_line":
550
+ endLine = edit.pos.line;
551
+ break;
552
+ case "replace_range":
553
+ endLine = edit.end.line;
554
+ break;
555
+ default:
556
+ continue;
557
+ }
558
+ if (edit.lines.length === 0) continue;
559
+ const nextSurvivingIdx = endLine; // 0-indexed: endLine (1-indexed) is the next line after `end`
560
+ if (nextSurvivingIdx >= originalFileLines.length) continue;
561
+ const nextSurvivingLine = originalFileLines[nextSurvivingIdx];
562
+ const lastInsertedLine = edit.lines[edit.lines.length - 1];
563
+ const trimmedNext = nextSurvivingLine.trim();
564
+ const trimmedLast = lastInsertedLine.trim();
565
+ // Only warn for non-trivial lines to avoid false positives on blank lines or bare punctuation
566
+ if (trimmedLast.length > 0 && trimmedLast === trimmedNext) {
567
+ const tag = formatLineTag(endLine + 1, nextSurvivingLine);
568
+ warnings.push(
569
+ `Possible boundary duplication: your last replacement line \`${trimmedLast}\` is identical to the next surviving line ${tag}. ` +
570
+ `If you meant to replace the entire block, set \`end\` to ${tag} instead.`,
571
+ );
572
+ }
573
+ }
550
574
  // Deduplicate identical edits targeting the same line(s)
551
575
  const seenEditKeys = new Map<string, number>();
552
576
  const dedupIndices = new Set<number>();
@@ -554,25 +578,22 @@ export function applyHashlineEdits(
554
578
  const edit = edits[i];
555
579
  let lineKey: string;
556
580
  switch (edit.op) {
557
- case "replace":
558
- if (!edit.end) {
559
- lineKey = `s:${edit.pos.line}`;
560
- } else {
561
- lineKey = `r:${edit.pos.line}:${edit.end.line}`;
562
- }
581
+ case "replace_line":
582
+ lineKey = `s:${edit.pos.line}`;
563
583
  break;
564
- case "append":
565
- if (edit.pos) {
566
- lineKey = `i:${edit.pos.line}`;
567
- break;
568
- }
584
+ case "replace_range":
585
+ lineKey = `r:${edit.pos.line}:${edit.end.line}`;
586
+ break;
587
+ case "append_at":
588
+ lineKey = `i:${edit.pos.line}`;
589
+ break;
590
+ case "prepend_at":
591
+ lineKey = `ib:${edit.pos.line}`;
592
+ break;
593
+ case "append_file":
569
594
  lineKey = "ieof";
570
595
  break;
571
- case "prepend":
572
- if (edit.pos) {
573
- lineKey = `ib:${edit.pos.line}`;
574
- break;
575
- }
596
+ case "prepend_file":
576
597
  lineKey = "ibef";
577
598
  break;
578
599
  }
@@ -594,20 +615,28 @@ export function applyHashlineEdits(
594
615
  let sortLine: number;
595
616
  let precedence: number;
596
617
  switch (edit.op) {
597
- case "replace":
598
- if (!edit.end) {
599
- sortLine = edit.pos.line;
600
- } else {
601
- sortLine = edit.end.line;
602
- }
618
+ case "replace_line":
619
+ sortLine = edit.pos.line;
620
+ precedence = 0;
621
+ break;
622
+ case "replace_range":
623
+ sortLine = edit.end.line;
603
624
  precedence = 0;
604
625
  break;
605
- case "append":
606
- sortLine = edit.pos ? edit.pos.line : fileLines.length + 1;
626
+ case "append_at":
627
+ sortLine = edit.pos.line;
607
628
  precedence = 1;
608
629
  break;
609
- case "prepend":
610
- sortLine = edit.pos ? edit.pos.line : 0;
630
+ case "prepend_at":
631
+ sortLine = edit.pos.line;
632
+ precedence = 2;
633
+ break;
634
+ case "append_file":
635
+ sortLine = fileLines.length + 1;
636
+ precedence = 1;
637
+ break;
638
+ case "prepend_file":
639
+ sortLine = 0;
611
640
  precedence = 2;
612
641
  break;
613
642
  }
@@ -619,99 +648,82 @@ export function applyHashlineEdits(
619
648
  // Apply edits bottom-up
620
649
  for (const { edit, idx } of annotated) {
621
650
  switch (edit.op) {
622
- case "replace": {
623
- if (!edit.end) {
624
- const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line);
625
- const newLines = edit.lines;
626
- if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
627
- noopEdits.push({
628
- editIndex: idx,
629
- loc: `${edit.pos.line}#${edit.pos.hash}`,
630
- current: origLines.join("\n"),
631
- });
632
- break;
633
- }
634
- fileLines.splice(edit.pos.line - 1, 1, ...newLines);
635
- trackFirstChanged(edit.pos.line);
636
- } else {
637
- const count = edit.end.line - edit.pos.line + 1;
638
- const newLines = [...edit.lines];
639
- const trailingReplacementLine = newLines[newLines.length - 1]?.trimEnd();
640
- const nextSurvivingLine = fileLines[edit.end.line]?.trimEnd();
641
- if (
642
- shouldAutocorrect(trailingReplacementLine, nextSurvivingLine) &&
643
- // Safety: only correct when end-line content differs from the duplicate.
644
- // If end already points to the boundary, matching next line is coincidence.
645
- fileLines[edit.end.line - 1]?.trimEnd() !== trailingReplacementLine
646
- ) {
647
- newLines.pop();
648
- warnings.push(
649
- `Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed trailing replacement line "${trailingReplacementLine}" that duplicated next surviving line`,
650
- );
651
- }
652
- const leadingReplacementLine = newLines[0]?.trimEnd();
653
- const prevSurvivingLine = fileLines[edit.pos.line - 2]?.trimEnd();
654
- if (
655
- shouldAutocorrect(leadingReplacementLine, prevSurvivingLine) &&
656
- // Safety: only correct when pos-line content differs from the duplicate.
657
- // If pos already points to the boundary, matching prev line is coincidence.
658
- fileLines[edit.pos.line - 1]?.trimEnd() !== leadingReplacementLine
659
- ) {
660
- newLines.shift();
661
- warnings.push(
662
- `Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed leading replacement line "${leadingReplacementLine}" that duplicated preceding surviving line`,
663
- );
664
- }
665
- fileLines.splice(edit.pos.line - 1, count, ...newLines);
666
- trackFirstChanged(edit.pos.line);
651
+ case "replace_line": {
652
+ const origLines = originalFileLines.slice(edit.pos.line - 1, edit.pos.line);
653
+ const newLines = edit.lines;
654
+ if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
655
+ noopEdits.push({
656
+ editIndex: idx,
657
+ loc: `${edit.pos.line}#${edit.pos.hash}`,
658
+ current: origLines.join("\n"),
659
+ });
660
+ break;
667
661
  }
662
+ fileLines.splice(edit.pos.line - 1, 1, ...newLines);
663
+ trackFirstChanged(edit.pos.line);
664
+ break;
665
+ }
666
+ case "replace_range": {
667
+ const count = edit.end.line - edit.pos.line + 1;
668
+ fileLines.splice(edit.pos.line - 1, count, ...edit.lines);
669
+ trackFirstChanged(edit.pos.line);
668
670
  break;
669
671
  }
670
- case "append": {
672
+ case "append_at": {
671
673
  const inserted = edit.lines;
672
674
  if (inserted.length === 0) {
673
675
  noopEdits.push({
674
676
  editIndex: idx,
675
- loc: edit.pos ? `${edit.pos.line}#${edit.pos.hash}` : "EOF",
676
- current: edit.pos ? originalFileLines[edit.pos.line - 1] : "",
677
+ loc: `${edit.pos.line}#${edit.pos.hash}`,
678
+ current: originalFileLines[edit.pos.line - 1],
677
679
  });
678
680
  break;
679
681
  }
680
- if (edit.pos) {
681
- fileLines.splice(edit.pos.line, 0, ...inserted);
682
- trackFirstChanged(edit.pos.line + 1);
683
- } else {
684
- if (fileLines.length === 1 && fileLines[0] === "") {
685
- fileLines.splice(0, 1, ...inserted);
686
- trackFirstChanged(1);
687
- } else {
688
- fileLines.splice(fileLines.length, 0, ...inserted);
689
- trackFirstChanged(fileLines.length - inserted.length + 1);
690
- }
691
- }
682
+ fileLines.splice(edit.pos.line, 0, ...inserted);
683
+ trackFirstChanged(edit.pos.line + 1);
692
684
  break;
693
685
  }
694
- case "prepend": {
686
+ case "prepend_at": {
695
687
  const inserted = edit.lines;
696
688
  if (inserted.length === 0) {
697
689
  noopEdits.push({
698
690
  editIndex: idx,
699
- loc: edit.pos ? `${edit.pos.line}#${edit.pos.hash}` : "BOF",
700
- current: edit.pos ? originalFileLines[edit.pos.line - 1] : "",
691
+ loc: `${edit.pos.line}#${edit.pos.hash}`,
692
+ current: originalFileLines[edit.pos.line - 1],
701
693
  });
702
694
  break;
703
695
  }
704
- if (edit.pos) {
705
- fileLines.splice(edit.pos.line - 1, 0, ...inserted);
706
- trackFirstChanged(edit.pos.line);
707
- } else {
708
- if (fileLines.length === 1 && fileLines[0] === "") {
709
- fileLines.splice(0, 1, ...inserted);
710
- } else {
711
- fileLines.splice(0, 0, ...inserted);
712
- }
696
+ fileLines.splice(edit.pos.line - 1, 0, ...inserted);
697
+ trackFirstChanged(edit.pos.line);
698
+ break;
699
+ }
700
+ case "append_file": {
701
+ const inserted = edit.lines;
702
+ if (inserted.length === 0) {
703
+ noopEdits.push({ editIndex: idx, loc: "EOF", current: "" });
704
+ break;
705
+ }
706
+ if (fileLines.length === 1 && fileLines[0] === "") {
707
+ fileLines.splice(0, 1, ...inserted);
713
708
  trackFirstChanged(1);
709
+ } else {
710
+ fileLines.splice(fileLines.length, 0, ...inserted);
711
+ trackFirstChanged(fileLines.length - inserted.length + 1);
712
+ }
713
+ break;
714
+ }
715
+ case "prepend_file": {
716
+ const inserted = edit.lines;
717
+ if (inserted.length === 0) {
718
+ noopEdits.push({ editIndex: idx, loc: "BOF", current: "" });
719
+ break;
720
+ }
721
+ if (fileLines.length === 1 && fileLines[0] === "") {
722
+ fileLines.splice(0, 1, ...inserted);
723
+ } else {
724
+ fileLines.splice(0, 0, ...inserted);
714
725
  }
726
+ trackFirstChanged(1);
715
727
  break;
716
728
  }
717
729
  }
@@ -174,16 +174,35 @@ export function hashlineParseText(edit: string[] | string | null): string[] {
174
174
  return stripNewLinePrefixes(edit);
175
175
  }
176
176
 
177
+ const linesSchema = Type.Union([
178
+ Type.Array(Type.String(), { description: "content (preferred format)" }),
179
+ Type.String(),
180
+ Type.Null(),
181
+ ]);
182
+
183
+ const locSchema = Type.Union(
184
+ [
185
+ Type.Literal("append"),
186
+ Type.Literal("prepend"),
187
+ Type.Object({ append: Type.String({ description: "anchor" }) }),
188
+ Type.Object({ prepend: Type.String({ description: "anchor" }) }),
189
+ Type.Object({
190
+ line: Type.String({ description: "anchor" }),
191
+ }),
192
+ Type.Object({
193
+ block: Type.Object({
194
+ pos: Type.String({ description: "anchor" }),
195
+ end: Type.String({ description: "limit position" }),
196
+ }),
197
+ }),
198
+ ],
199
+ { description: "insert location" },
200
+ );
201
+
177
202
  const hashlineEditSchema = Type.Object(
178
203
  {
179
- op: StringEnum(["replace", "append", "prepend"]),
180
- pos: Type.Optional(Type.String({ description: "anchor" })),
181
- end: Type.Optional(Type.String({ description: "limit position" })),
182
- lines: Type.Union([
183
- Type.Array(Type.String(), { description: "content (preferred format)" }),
184
- Type.String(),
185
- Type.Null(),
186
- ]),
204
+ loc: locSchema,
205
+ content: linesSchema,
187
206
  },
188
207
  { additionalProperties: false },
189
208
  );
@@ -206,45 +225,48 @@ export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
206
225
  // ═══════════════════════════════════════════════════════════════════════════
207
226
 
208
227
  /**
209
- * Map flat tool-schema edits (tag/end) into typed HashlineEdit objects.
228
+ * Map loc/content tool-schema edits into typed HashlineEdit objects.
210
229
  *
211
- * Resilient: as long as at least one anchor exists, we execute.
212
- * - replace + tag only → single-line replace
213
- * - replace + tag + end → range replace
214
- * - append + tag or end append after that anchor
215
- * - prepend + tag or end prepend before that anchor
216
- * - no anchors file-level append/prepend (only for those ops)
217
- *
218
- * Unknown ops default to "replace".
230
+ * Each edit entry has a `loc` (where to edit) and `content` (what to insert/replace).
231
+ * loc can be:
232
+ * - "append" / "prepend" file-level insert
233
+ * - { append: anchor } / { prepend: anchor } insert relative to anchor
234
+ * - { replace_line: anchor } replace one line
235
+ * - { replace_block: { pos, end } } replace inclusive range
219
236
  */
220
237
  function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
221
238
  const result: HashlineEdit[] = [];
222
239
  for (const edit of edits) {
223
- const lines = hashlineParseText(edit.lines);
224
- const tag = edit.pos ? tryParseTag(edit.pos) : undefined;
225
- const end = edit.end ? tryParseTag(edit.end) : undefined;
226
-
227
- // Normalize op default unknown values to "replace"
228
- const op = edit.op === "append" || edit.op === "prepend" ? edit.op : "replace";
229
- switch (op) {
230
- case "replace": {
231
- if (tag && end) {
232
- result.push({ op: "replace", pos: tag, end, lines });
233
- } else if (tag || end) {
234
- result.push({ op: "replace", pos: tag || end!, lines });
235
- } else {
236
- throw new Error("Replace requires at least one anchor (tag or end).");
237
- }
238
- break;
239
- }
240
- case "append": {
241
- result.push({ op: "append", pos: tag ?? end, lines });
242
- break;
243
- }
244
- case "prepend": {
245
- result.push({ op: "prepend", pos: end ?? tag, lines });
246
- break;
240
+ const lines = hashlineParseText(edit.content);
241
+ const loc = edit.loc;
242
+
243
+ if (loc === "append") {
244
+ result.push({ op: "append_file", lines });
245
+ } else if (loc === "prepend") {
246
+ result.push({ op: "prepend_file", lines });
247
+ } else if (typeof loc === "object") {
248
+ if ("append" in loc) {
249
+ const anchor = tryParseTag(loc.append);
250
+ if (!anchor) throw new Error("append requires a valid anchor.");
251
+ result.push({ op: "append_at", pos: anchor, lines });
252
+ } else if ("prepend" in loc) {
253
+ const anchor = tryParseTag(loc.prepend);
254
+ if (!anchor) throw new Error("prepend requires a valid anchor.");
255
+ result.push({ op: "prepend_at", pos: anchor, lines });
256
+ } else if ("line" in loc) {
257
+ const anchor = tryParseTag(loc.line);
258
+ if (!anchor) throw new Error("line requires a valid anchor.");
259
+ result.push({ op: "replace_line", pos: anchor, lines });
260
+ } else if ("block" in loc) {
261
+ const posAnchor = tryParseTag(loc.block.pos);
262
+ const endAnchor = tryParseTag(loc.block.end);
263
+ if (!posAnchor || !endAnchor) throw new Error("block requires valid pos and end anchors.");
264
+ result.push({ op: "replace_range", pos: posAnchor, end: endAnchor, lines });
265
+ } else {
266
+ throw new Error("Unknown loc shape. Expected append, prepend, line, or block.");
247
267
  }
268
+ } else {
269
+ throw new Error(`Invalid loc value: ${JSON.stringify(loc)}`);
248
270
  }
249
271
  }
250
272
  return result;
@@ -552,12 +574,10 @@ export class EditTool implements AgentTool<TInput> {
552
574
  const lines: string[] = [];
553
575
  for (const edit of edits) {
554
576
  // For file creation, only anchorless appends/prepends are valid
555
- if ((edit.op === "append" || edit.op === "prepend") && !edit.pos && !edit.end) {
556
- if (edit.op === "prepend") {
557
- lines.unshift(...hashlineParseText(edit.lines));
558
- } else {
559
- lines.push(...hashlineParseText(edit.lines));
560
- }
577
+ if (edit.loc === "append") {
578
+ lines.push(...hashlineParseText(edit.content));
579
+ } else if (edit.loc === "prepend") {
580
+ lines.unshift(...hashlineParseText(edit.content));
561
581
  } else {
562
582
  throw new Error(`File not found: ${path}`);
563
583
  }
@@ -582,7 +602,7 @@ export class EditTool implements AgentTool<TInput> {
582
602
  const originalNormalized = normalizeToLF(text);
583
603
  let normalizedText = originalNormalized;
584
604
 
585
- // Apply anchor-based edits first (replace, append, prepend)
605
+ // Apply anchor-based edits first (replace, append_at, prepend_at)
586
606
  const anchorResult = applyHashlineEdits(normalizedText, anchorEdits);
587
607
  normalizedText = anchorResult.lines;
588
608
 
@@ -612,20 +632,18 @@ export class EditTool implements AgentTool<TInput> {
612
632
  for (const edit of anchorEdits) {
613
633
  refs.length = 0;
614
634
  switch (edit.op) {
615
- case "replace":
616
- if (edit.end) {
617
- refs.push(edit.end, edit.pos);
618
- } else {
619
- refs.push(edit.pos);
620
- }
635
+ case "replace_line":
636
+ refs.push(edit.pos);
621
637
  break;
622
- case "append":
623
- if (edit.pos) refs.push(edit.pos);
638
+ case "replace_range":
639
+ refs.push(edit.end, edit.pos);
624
640
  break;
625
- case "prepend":
626
- if (edit.pos) refs.push(edit.pos);
641
+ case "append_at":
642
+ case "prepend_at":
643
+ refs.push(edit.pos);
627
644
  break;
628
- default:
645
+ case "append_file":
646
+ case "prepend_file":
629
647
  break;
630
648
  }
631
649