@oh-my-pi/pi-coding-agent 13.5.1 → 13.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.5.3] - 2026-03-01
6
+
7
+ ### Added
8
+
9
+ - Auto-include `ast_grep` and `ast_edit` tools when their text-based counterparts (`grep`, `edit`) are requested and the AST tools are enabled
10
+ - Enforced tool decision in plan mode—agent now requires calling either `ask` or `exit_plan_mode` when a turn ends without a required tool call
11
+ - Auto-correction of escaped tab indentation in edits (enabled by default, controllable via `PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS` environment variable)
12
+ - Warning when suspicious Unicode escape placeholder `\uDDDD` is detected in edit content
13
+
14
+ ### Changed
15
+
16
+ - Updated bash tool description to conditionally show `ast_grep` and `ast_edit` guidance based on tool availability in the session
17
+ - Replaced timeout-based cancellation with AbortSignal-based cancellation in the `ask` tool for more reliable user interaction handling
18
+ - Updated `ask` tool to distinguish between user-initiated cancellation and timeout-driven auto-selection, with only user cancellation aborting the turn
19
+ - Updated hashline documentation to clarify that `\t` in JSON represents a real tab character, not a literal backslash-t sequence
20
+
21
+ ### Fixed
22
+
23
+ - Fixed race condition in dialog overlay handling where multiple concurrent resolutions could occur
24
+ - Cancelling the `ask` tool now aborts the current turn instead of returning a normal cancelled selection, while timeout-driven auto-cancel still returns without aborting
25
+
26
+ ## [13.5.2] - 2026-03-01
27
+
28
+ ### Added
29
+
30
+ - Added `checkpoint` tool to create context checkpoints before exploratory work, allowing you to investigate with many intermediate tool calls and minimize context cost afterward
31
+ - Added `rewind` tool to end an active checkpoint and replace intermediate exploration messages with a concise investigation report
32
+ - Added `checkpoint.enabled` setting to control availability of the checkpoint and rewind tools
33
+ - Added `render_mermaid` tool to convert Mermaid graph source into ASCII diagram output
34
+ - Added `renderMermaid.enabled` setting to control availability of the render_mermaid tool
35
+
36
+ ### Changed
37
+
38
+ - Changed Mermaid rendering from PNG images to ASCII diagrams in theme rendering
39
+ - Changed `prerenderMermaid()` function to synchronously render ASCII instead of asynchronously rendering PNG
40
+
5
41
  ## [13.5.0] - 2026-03-01
6
42
 
7
43
  ### Added
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": "13.5.1",
4
+ "version": "13.5.3",
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",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.5.1",
45
- "@oh-my-pi/pi-agent-core": "13.5.1",
46
- "@oh-my-pi/pi-ai": "13.5.1",
47
- "@oh-my-pi/pi-natives": "13.5.1",
48
- "@oh-my-pi/pi-tui": "13.5.1",
49
- "@oh-my-pi/pi-utils": "13.5.1",
44
+ "@oh-my-pi/omp-stats": "13.5.3",
45
+ "@oh-my-pi/pi-agent-core": "13.5.3",
46
+ "@oh-my-pi/pi-ai": "13.5.3",
47
+ "@oh-my-pi/pi-natives": "13.5.3",
48
+ "@oh-my-pi/pi-tui": "13.5.3",
49
+ "@oh-my-pi/pi-utils": "13.5.3",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
@@ -255,7 +255,8 @@ handlebars.registerHelper("SECTION_SEPERATOR", (name: unknown): string => sectio
255
255
  */
256
256
  function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; text: string; ref: string } {
257
257
  const num = typeof lineNum === "number" ? lineNum : Number.parseInt(String(lineNum), 10);
258
- const text = typeof content === "string" ? content : String(content ?? "");
258
+ const raw = typeof content === "string" ? content : String(content ?? "");
259
+ const text = raw.replace(/\\t/g, "\t").replace(/\\n/g, "\n").replace(/\\r/g, "\r");
259
260
  const ref = `${num}#${computeLineHash(num, text)}`;
260
261
  return { num, text, ref };
261
262
  }
@@ -460,11 +460,29 @@ export const SETTINGS_SCHEMA = {
460
460
  description: "Enable the ast_edit tool for structural AST rewrites",
461
461
  },
462
462
  },
463
+ "renderMermaid.enabled": {
464
+ type: "boolean",
465
+ default: false,
466
+ ui: {
467
+ tab: "tools",
468
+ label: "Enable Render Mermaid",
469
+ description: "Enable the render_mermaid tool for Mermaid-to-ASCII rendering",
470
+ },
471
+ },
463
472
  "notebook.enabled": {
464
473
  type: "boolean",
465
474
  default: true,
466
475
  ui: { tab: "tools", label: "Enable Notebook", description: "Enable the notebook tool for notebook editing" },
467
476
  },
477
+ "checkpoint.enabled": {
478
+ type: "boolean",
479
+ default: false,
480
+ ui: {
481
+ tab: "tools",
482
+ label: "Enable Checkpoint/Rewind",
483
+ description: "Enable the checkpoint and rewind tools for context checkpointing",
484
+ },
485
+ },
468
486
  "fetch.enabled": {
469
487
  type: "boolean",
470
488
  default: true,
@@ -27,6 +27,11 @@ const DEBUG_MENU_ITEMS: SelectItem[] = [
27
27
  { value: "memory", label: "Report: memory issue", description: "Heap snapshot + bundle" },
28
28
  { value: "logs", label: "View: recent logs", description: "Show last 50 log entries" },
29
29
  { value: "system", label: "View: system info", description: "Show environment details" },
30
+ {
31
+ value: "transcript",
32
+ label: "Export: TUI transcript",
33
+ description: "Write visible TUI conversation to a temp txt",
34
+ },
30
35
  { value: "clear-cache", label: "Clear: artifact cache", description: "Remove old session artifacts" },
31
36
  ];
32
37
 
@@ -95,6 +100,9 @@ export class DebugSelectorComponent extends Container {
95
100
  case "system":
96
101
  await this.#handleViewSystemInfo();
97
102
  break;
103
+ case "transcript":
104
+ await this.#handleTranscriptExport();
105
+ break;
98
106
  case "clear-cache":
99
107
  await this.#handleClearCache();
100
108
  break;
@@ -323,6 +331,9 @@ export class DebugSelectorComponent extends Container {
323
331
  this.ctx.ui.requestRender();
324
332
  }
325
333
 
334
+ async #handleTranscriptExport(): Promise<void> {
335
+ await this.ctx.handleDebugTranscriptCommand();
336
+ }
326
337
  async #handleOpenArtifacts(): Promise<void> {
327
338
  const sessionFile = this.ctx.sessionManager.getSessionFile();
328
339
  if (!sessionFile) {
@@ -16,6 +16,61 @@ export function findApiKey(): string | null {
16
16
  return $env.EXA_API_KEY;
17
17
  }
18
18
 
19
+ function asRecord(value: unknown): Record<string, unknown> | null {
20
+ if (typeof value !== "object" || value === null) return null;
21
+ return value as Record<string, unknown>;
22
+ }
23
+
24
+ function parseJsonContent(text: string): unknown | null {
25
+ try {
26
+ return JSON.parse(text);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Normalize tools/call payloads across MCP servers.
34
+ *
35
+ * Exa currently returns different shapes depending on deployment/environment:
36
+ * - direct payload in result
37
+ * - structured payload under result.structuredContent / result.data / result.result
38
+ * - JSON payload embedded as text in result.content[]
39
+ */
40
+ function normalizeMcpToolPayload(payload: unknown): unknown {
41
+ const candidates: unknown[] = [];
42
+ const root = asRecord(payload);
43
+
44
+ if (root) {
45
+ if (root.structuredContent !== undefined) candidates.push(root.structuredContent);
46
+ if (root.data !== undefined) candidates.push(root.data);
47
+ if (root.result !== undefined) candidates.push(root.result);
48
+ candidates.push(root);
49
+
50
+ const content = root.content;
51
+ if (Array.isArray(content)) {
52
+ for (const item of content) {
53
+ const part = asRecord(item);
54
+ if (!part) continue;
55
+ const text = part.text;
56
+ if (typeof text !== "string" || text.trim().length === 0) continue;
57
+ const parsed = parseJsonContent(text);
58
+ if (parsed !== null) candidates.push(parsed);
59
+ }
60
+ }
61
+ } else {
62
+ candidates.push(payload);
63
+ }
64
+
65
+ for (const candidate of candidates) {
66
+ if (isSearchResponse(candidate)) {
67
+ return candidate;
68
+ }
69
+ }
70
+
71
+ return payload;
72
+ }
73
+
19
74
  /** Fetch available tools from Exa MCP */
20
75
  export async function fetchExaTools(apiKey: string | null, toolNames: string[]): Promise<MCPTool[]> {
21
76
  const params = new URLSearchParams();
@@ -65,7 +120,7 @@ export async function callExaTool(
65
120
  throw new Error(`MCP error: ${response.error.message}`);
66
121
  }
67
122
 
68
- return response.result;
123
+ return normalizeMcpToolPayload(response.result);
69
124
  }
70
125
 
71
126
  /** Call a tool on Websets MCP */
@@ -85,7 +140,7 @@ export async function callWebsetsTool(
85
140
  throw new Error(`MCP error: ${response.error.message}`);
86
141
  }
87
142
 
88
- return response.result;
143
+ return normalizeMcpToolPayload(response.result);
89
144
  }
90
145
 
91
146
  /** Format search results for LLM */
@@ -28,6 +28,7 @@ import type { AuthStorage } from "../../session/auth-storage";
28
28
  import { createCompactionSummaryMessage } from "../../session/messages";
29
29
  import { outputMeta } from "../../tools/output-meta";
30
30
  import { resolveToCwd } from "../../tools/path-utils";
31
+ import { replaceTabs } from "../../tools/render-utils";
31
32
  import { getChangelogPath, parseChangelog } from "../../utils/changelog";
32
33
  import { openPath } from "../../utils/open";
33
34
 
@@ -70,6 +71,25 @@ export class CommandController {
70
71
  }
71
72
  }
72
73
 
74
+ async handleDebugTranscriptCommand(): Promise<void> {
75
+ try {
76
+ const width = Math.max(1, this.ctx.ui.terminal.columns);
77
+ const renderedLines = this.ctx.chatContainer.render(width).map(line => replaceTabs(Bun.stripANSI(line)));
78
+ const rendered = renderedLines.join("\n").trimEnd();
79
+ if (!rendered) {
80
+ this.ctx.showError("No messages to dump yet.");
81
+ return;
82
+ }
83
+ const tmpPath = path.join(os.tmpdir(), `${Snowflake.next()}-tmp.txt`);
84
+ await Bun.write(tmpPath, `${rendered}\n`);
85
+ this.ctx.showStatus(`Debug transcript written to:\n${tmpPath}`);
86
+ } catch (error: unknown) {
87
+ this.ctx.showError(
88
+ `Failed to write debug transcript: ${error instanceof Error ? error.message : "Unknown error"}`,
89
+ );
90
+ }
91
+ }
92
+
73
93
  async handleShareCommand(): Promise<void> {
74
94
  const tmpFile = path.join(os.tmpdir(), `${Snowflake.next()}.html`);
75
95
  const cleanupTempFile = async () => {
@@ -41,7 +41,7 @@ export class ExtensionUiController {
41
41
  const uiContext: ExtensionUIContext = {
42
42
  select: (title, options, dialogOptions) => this.showHookSelector(title, options, dialogOptions),
43
43
  confirm: (title, message, _dialogOptions) => this.showHookConfirm(title, message),
44
- input: (title, placeholder, _dialogOptions) => this.showHookInput(title, placeholder),
44
+ input: (title, placeholder, dialogOptions) => this.showHookInput(title, placeholder, dialogOptions),
45
45
  notify: (message, type) => this.showHookNotify(message, type),
46
46
  onTerminalInput: handler => this.addExtensionTerminalInputListener(handler),
47
47
  setStatus: (key, text) => this.setHookStatus(key, text),
@@ -561,6 +561,20 @@ export class ExtensionUiController {
561
561
  dialogOptions?: ExtensionUIDialogOptions,
562
562
  ): Promise<string | undefined> {
563
563
  const { promise, resolve } = Promise.withResolvers<string | undefined>();
564
+ let settled = false;
565
+ const onAbort = () => {
566
+ this.hideHookSelector();
567
+ if (!settled) {
568
+ settled = true;
569
+ resolve(undefined);
570
+ }
571
+ };
572
+ const finish = (value: string | undefined) => {
573
+ if (settled) return;
574
+ settled = true;
575
+ dialogOptions?.signal?.removeEventListener("abort", onAbort);
576
+ resolve(value);
577
+ };
564
578
  this.#hookSelectorOverlay?.hide();
565
579
  this.#hookSelectorOverlay = undefined;
566
580
  const maxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12));
@@ -569,11 +583,11 @@ export class ExtensionUiController {
569
583
  options,
570
584
  option => {
571
585
  this.hideHookSelector();
572
- resolve(option);
586
+ finish(option);
573
587
  },
574
588
  () => {
575
589
  this.hideHookSelector();
576
- resolve(undefined);
590
+ finish(undefined);
577
591
  },
578
592
  {
579
593
  initialIndex: dialogOptions?.initialIndex,
@@ -584,9 +598,15 @@ export class ExtensionUiController {
584
598
  },
585
599
  );
586
600
  this.#hookSelectorOverlay = this.ctx.ui.showOverlay(this.ctx.hookSelector, this.#dialogOverlayOptions);
601
+ if (dialogOptions?.signal) {
602
+ if (dialogOptions.signal.aborted) {
603
+ onAbort();
604
+ } else {
605
+ dialogOptions.signal.addEventListener("abort", onAbort, { once: true });
606
+ }
607
+ }
587
608
  return promise;
588
609
  }
589
-
590
610
  /**
591
611
  * Hide the hook selector.
592
612
  */
@@ -610,8 +630,26 @@ export class ExtensionUiController {
610
630
  /**
611
631
  * Show a text input for hooks.
612
632
  */
613
- showHookInput(title: string, placeholder?: string): Promise<string | undefined> {
633
+ showHookInput(
634
+ title: string,
635
+ placeholder?: string,
636
+ dialogOptions?: ExtensionUIDialogOptions,
637
+ ): Promise<string | undefined> {
614
638
  const { promise, resolve } = Promise.withResolvers<string | undefined>();
639
+ let settled = false;
640
+ const onAbort = () => {
641
+ this.hideHookInput();
642
+ if (!settled) {
643
+ settled = true;
644
+ resolve(undefined);
645
+ }
646
+ };
647
+ const finish = (value: string | undefined) => {
648
+ if (settled) return;
649
+ settled = true;
650
+ dialogOptions?.signal?.removeEventListener("abort", onAbort);
651
+ resolve(value);
652
+ };
615
653
  this.#hookInputOverlay?.hide();
616
654
  this.#hookInputOverlay = undefined;
617
655
  this.ctx.hookInput = new HookInputComponent(
@@ -619,14 +657,21 @@ export class ExtensionUiController {
619
657
  placeholder,
620
658
  value => {
621
659
  this.hideHookInput();
622
- resolve(value);
660
+ finish(value);
623
661
  },
624
662
  () => {
625
663
  this.hideHookInput();
626
- resolve(undefined);
664
+ finish(undefined);
627
665
  },
628
666
  );
629
667
  this.#hookInputOverlay = this.ctx.ui.showOverlay(this.ctx.hookInput, this.#dialogOverlayOptions);
668
+ if (dialogOptions?.signal) {
669
+ if (dialogOptions.signal.aborted) {
670
+ onAbort();
671
+ } else {
672
+ dialogOptions.signal.addEventListener("abort", onAbort, { once: true });
673
+ }
674
+ }
630
675
  return promise;
631
676
  }
632
677
 
@@ -947,6 +947,10 @@ export class InteractiveMode implements InteractiveModeContext {
947
947
  return this.#commandController.handleDumpCommand();
948
948
  }
949
949
 
950
+ handleDebugTranscriptCommand(): Promise<void> {
951
+ return this.#commandController.handleDebugTranscriptCommand();
952
+ }
953
+
950
954
  handleShareCommand(): Promise<void> {
951
955
  return this.#commandController.handleShareCommand();
952
956
  }
@@ -1,88 +1,48 @@
1
- import {
2
- extractMermaidBlocks,
3
- type MermaidImage,
4
- type MermaidRenderOptions,
5
- renderMermaidToPng,
6
- } from "@oh-my-pi/pi-tui";
7
- import { logger } from "@oh-my-pi/pi-utils";
1
+ import { extractMermaidBlocks, logger, renderMermaidAsciiSafe } from "@oh-my-pi/pi-utils";
8
2
 
9
- const cache = new Map<bigint, MermaidImage>();
10
- const pending = new Map<bigint, Promise<MermaidImage | null>>();
3
+ const cache = new Map<bigint, string>();
11
4
  const failed = new Set<bigint>();
12
5
 
13
- const defaultOptions: MermaidRenderOptions = {
14
- theme: "dark",
15
- backgroundColor: "transparent",
16
- };
17
-
18
6
  let onRenderNeeded: (() => void) | null = null;
19
7
 
20
8
  /**
21
- * Set callback to trigger TUI re-render when mermaid images become available.
9
+ * Set callback to trigger TUI re-render when mermaid ASCII renders become available.
22
10
  */
23
11
  export function setMermaidRenderCallback(callback: (() => void) | null): void {
24
12
  onRenderNeeded = callback;
25
13
  }
26
14
 
27
15
  /**
28
- * Get a pre-rendered mermaid image by hash.
16
+ * Get a pre-rendered mermaid ASCII diagram by hash.
29
17
  * Returns null if not cached or rendering failed.
30
18
  */
31
- export function getMermaidImage(hash: bigint): MermaidImage | null {
19
+ export function getMermaidAscii(hash: bigint): string | null {
32
20
  return cache.get(hash) ?? null;
33
21
  }
34
22
 
35
23
  /**
36
- * Pre-render all mermaid blocks in markdown text.
37
- * Renders in parallel, deduplicates concurrent requests.
38
- * Calls render callback when new images are cached.
24
+ * Render all mermaid blocks in markdown text.
25
+ * Caches results and calls render callback when new diagrams are available.
39
26
  */
40
- export async function prerenderMermaid(
41
- markdown: string,
42
- options: MermaidRenderOptions = defaultOptions,
43
- ): Promise<void> {
27
+ export function prerenderMermaid(markdown: string): void {
44
28
  const blocks = extractMermaidBlocks(markdown);
45
29
  if (blocks.length === 0) return;
46
30
 
47
- const promises: Promise<boolean>[] = [];
31
+ let hasNew = false;
48
32
 
49
33
  for (const { source, hash } of blocks) {
50
34
  if (cache.has(hash) || failed.has(hash)) continue;
51
35
 
52
- let promise = pending.get(hash);
53
- if (!promise) {
54
- promise = renderMermaidToPng(source, options);
55
- pending.set(hash, promise);
36
+ const ascii = renderMermaidAsciiSafe(source);
37
+ if (ascii) {
38
+ cache.set(hash, ascii);
39
+ hasNew = true;
40
+ } else {
41
+ failed.add(hash);
56
42
  }
57
-
58
- promises.push(
59
- promise
60
- .then(image => {
61
- pending.delete(hash);
62
- if (image) {
63
- cache.set(hash, image);
64
- failed.delete(hash);
65
- return true;
66
- }
67
- failed.add(hash);
68
- return false;
69
- })
70
- .catch(error => {
71
- pending.delete(hash);
72
- failed.add(hash);
73
- logger.warn("Mermaid render failed", {
74
- hash,
75
- error: error instanceof Error ? error.message : String(error),
76
- });
77
- return false;
78
- }),
79
- );
80
43
  }
81
44
 
82
- const results = await Promise.all(promises);
83
- const newImages = results.some(added => added);
84
-
85
- if (newImages && onRenderNeeded) {
45
+ if (hasNew && onRenderNeeded) {
86
46
  try {
87
47
  onRenderNeeded();
88
48
  } catch (error) {
@@ -107,5 +67,4 @@ export function hasPendingMermaid(markdown: string): boolean {
107
67
  export function clearMermaidCache(): void {
108
68
  cache.clear();
109
69
  failed.clear();
110
- pending.clear();
111
70
  }
@@ -16,7 +16,7 @@ import chalk from "chalk";
16
16
  import darkThemeJson from "./dark.json" with { type: "json" };
17
17
  import { defaultThemes } from "./defaults";
18
18
  import lightThemeJson from "./light.json" with { type: "json" };
19
- import { getMermaidImage } from "./mermaid-cache";
19
+ import { getMermaidAscii } from "./mermaid-cache";
20
20
 
21
21
  // ============================================================================
22
22
  // Symbol Presets
@@ -2340,7 +2340,7 @@ export function getMarkdownTheme(): MarkdownTheme {
2340
2340
  underline: (text: string) => theme.underline(text),
2341
2341
  strikethrough: (text: string) => chalk.strikethrough(text),
2342
2342
  symbols: getSymbolTheme(),
2343
- getMermaidImage,
2343
+ getMermaidAscii,
2344
2344
  highlightCode: (code: string, lang?: string): string[] => {
2345
2345
  const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
2346
2346
  try {
@@ -154,6 +154,7 @@ export interface InteractiveModeContext {
154
154
  handleChangelogCommand(showFull?: boolean): Promise<void>;
155
155
  handleHotkeysCommand(): void;
156
156
  handleDumpCommand(): Promise<void>;
157
+ handleDebugTranscriptCommand(): Promise<void>;
157
158
  handleClearCommand(): Promise<void>;
158
159
  handleForkCommand(): Promise<void>;
159
160
  handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void>;
@@ -411,6 +411,45 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
411
411
  }
412
412
  }
413
413
 
414
+ function isEscapedTabAutocorrectEnabled(): boolean {
415
+ const value = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
416
+ if (value === "0") return false;
417
+ if (value === "1") return true;
418
+ return true;
419
+ }
420
+
421
+ function maybeAutocorrectEscapedTabIndentation(edits: HashlineEdit[], warnings: string[]): void {
422
+ if (!isEscapedTabAutocorrectEnabled()) return;
423
+ for (const edit of edits) {
424
+ if (edit.lines.length === 0) continue;
425
+ const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
426
+ if (!hasEscapedTabs) continue;
427
+ const hasRealTabs = edit.lines.some(line => line.includes("\t"));
428
+ if (hasRealTabs) continue;
429
+ let correctedCount = 0;
430
+ const corrected = edit.lines.map(line =>
431
+ line.replace(/^((?:\\t)+)/, escaped => {
432
+ correctedCount += escaped.length / 2;
433
+ return "\t".repeat(escaped.length / 2);
434
+ }),
435
+ );
436
+ if (correctedCount === 0) continue;
437
+ edit.lines = corrected;
438
+ warnings.push(
439
+ `Auto-corrected escaped tab indentation in edit: converted leading \\t sequence(s) to real tab characters`,
440
+ );
441
+ }
442
+ }
443
+
444
+ function maybeWarnSuspiciousUnicodeEscapePlaceholder(edits: HashlineEdit[], warnings: string[]): void {
445
+ for (const edit of edits) {
446
+ if (edit.lines.length === 0) continue;
447
+ if (!edit.lines.some(line => /\\uDDDD/i.test(line))) continue;
448
+ warnings.push(
449
+ `Detected literal \\uDDDD in edit content; no autocorrection applied. Verify whether this should be a real Unicode escape or plain text.`,
450
+ );
451
+ }
452
+ }
414
453
  // ═══════════════════════════════════════════════════════════════════════════
415
454
  // Edit Application
416
455
  // ═══════════════════════════════════════════════════════════════════════════
@@ -493,6 +532,8 @@ export function applyHashlineEdits(
493
532
  if (mismatches.length > 0) {
494
533
  throw new HashlineMismatchError(mismatches, fileLines);
495
534
  }
535
+ maybeAutocorrectEscapedTabIndentation(edits, warnings);
536
+ maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
496
537
  // Deduplicate identical edits targeting the same line(s)
497
538
  const seenEditKeys = new Map<string, number>();
498
539
  const dedupIndices = new Set<number>();
@@ -2,11 +2,12 @@
2
2
  Plan mode active. You **MUST** perform READ-ONLY operations only.
3
3
 
4
4
  You **MUST NOT**:
5
- - Creating/editing/deleting files (except plan file below)
6
- - Running state-changing commands (git commit, npm install, etc.)
7
- - Making any system changes
5
+ - Create, edit, or delete files (except plan file below)
6
+ - Run state-changing commands (git commit, npm install, etc.)
7
+ - Make any system changes
8
8
 
9
- Supersedes all other instructions.
9
+ To implement: call `{{exitToolName}}` → user approves → new session starts with full write access to execute the plan.
10
+ You **MUST NOT** ask the user to exit plan mode for you; you **MUST** call `{{exitToolName}}` yourself.
10
11
  </critical>
11
12
 
12
13
  ## Plan File
@@ -32,7 +33,7 @@ Plan execution runs in fresh context (session cleared). You **MUST** make the pl
32
33
  3. Decide:
33
34
  - **Different task** → Overwrite plan
34
35
  - **Same task, continuing** → Update and clean outdated sections
35
- 4. Call `exit_plan_mode` when complete
36
+ 4. Call `{{exitToolName}}` when complete
36
37
  </procedure>
37
38
  {{/if}}
38
39
 
@@ -43,7 +44,7 @@ Plan execution runs in fresh context (session cleared). You **MUST** make the pl
43
44
  ### 1. Explore
44
45
  You **MUST** use `find`, `grep`, `read`, `ls` to understand the codebase.
45
46
  ### 2. Interview
46
- You **MUST** use `ask` to clarify:
47
+ You **MUST** use `{{askToolName}}` to clarify:
47
48
  - Ambiguous requirements
48
49
  - Technical decisions and tradeoffs
49
50
  - Preferences: UI/UX, performance, edge cases
@@ -78,7 +79,7 @@ You **MUST** focus on the request and associated code. You **SHOULD** launch par
78
79
  You **MUST** draft an approach based on exploration. You **MUST** consider trade-offs briefly, then choose.
79
80
 
80
81
  ### Phase 3: Review
81
- You **MUST** read critical files. You **MUST** verify plan matches original request. You **SHOULD** use `ask` to clarify remaining questions.
82
+ You **MUST** read critical files. You **MUST** verify plan matches original request. You **SHOULD** use `{{askToolName}}` to clarify remaining questions.
82
83
 
83
84
  ### Phase 4: Update Plan
84
85
  You **MUST** update `{{planFilePath}}` (`{{editToolName}}` for changes, `{{writeToolName}}` only if creating from scratch):
@@ -93,14 +94,14 @@ You **MUST** ask questions throughout. You **MUST NOT** make large assumptions a
93
94
  {{/if}}
94
95
 
95
96
  <directives>
96
- - You **MUST** use `ask` only for clarifying requirements or choosing approaches
97
+ - You **MUST** use `{{askToolName}}` only for clarifying requirements or choosing approaches
97
98
  </directives>
98
99
 
99
100
  <critical>
100
101
  Your turn ends ONLY by:
101
- 1. Using `ask` gather information, OR
102
- 2. Calling `exit_plan_mode` when ready
102
+ 1. Using `{{askToolName}}` to gather information, OR
103
+ 2. Calling `{{exitToolName}}` when ready — this triggers user approval, then a new implementation session with full tool access
103
104
 
104
- You **MUST NOT** ask plan approval via text or `ask`; you **MUST** use `exit_plan_mode`.
105
+ You **MUST NOT** ask plan approval via text or `{{askToolName}}`; you **MUST** use `{{exitToolName}}`.
105
106
  You **MUST** keep going until complete.
106
107
  </critical>
@@ -2,11 +2,9 @@
2
2
  Plan mode active. You **MUST** perform READ-ONLY operations only.
3
3
 
4
4
  You **MUST NOT**:
5
- - Creating, editing, deleting, moving, or copying files
6
- - Running state-changing commands
7
- - Making any changes to system
8
-
9
- Supersedes all other instructions.
5
+ - Create, edit, delete, move, or copy files
6
+ - Run state-changing commands
7
+ - Make any changes to the system
10
8
  </critical>
11
9
 
12
10
  <role>
@@ -0,0 +1,9 @@
1
+ <system-reminder>
2
+ Plan mode turn ended without a required tool call.
3
+
4
+ You **MUST** choose exactly one next action now:
5
+ 1. Call `{{askToolName}}` to gather required clarification, OR
6
+ 2. Call `{{exitToolName}}` to finish planning and request approval
7
+
8
+ You **MUST NOT** output plain text in this turn.
9
+ </system-reminder>