@oh-my-pi/pi-coding-agent 13.5.2 → 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,27 @@
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
+
5
26
  ## [13.5.2] - 2026-03-01
6
27
 
7
28
  ### 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.2",
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.2",
45
- "@oh-my-pi/pi-agent-core": "13.5.2",
46
- "@oh-my-pi/pi-ai": "13.5.2",
47
- "@oh-my-pi/pi-natives": "13.5.2",
48
- "@oh-my-pi/pi-tui": "13.5.2",
49
- "@oh-my-pi/pi-utils": "13.5.2",
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
  }
@@ -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 */
@@ -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
 
@@ -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>
@@ -33,10 +33,12 @@ You **MUST** use specialized tools instead of bash for ALL file operations:
33
33
  |`rg 'pattern' dir/`|`grep(pattern="pattern", path="dir/")`|
34
34
  |`find dir -name '*.ts'`|`find(pattern="dir/**/*.ts")`|
35
35
  |`ls dir/`|`read(path="dir/")`|
36
- |`cat <<'EOF' > file`|`write(path="file", content="...")`|
37
- |`sed -i 's/old/new/' file`|`edit(path="file", edits=[...])`|
38
- - If `ast_grep` / `ast_edit` tools are available in the session, you **MUST** use them for structural code search/rewrites instead of bash `grep`/`sed`/`awk`/`perl` pipelines
39
- - Bash is for command execution, not syntax-aware code transformation; prefer `ast_grep` for discovery and `ast_edit` for codemods
36
+ |`cat <<'EOF' > file`|`write(path="file", content="")`|
37
+ |`sed -i 's/old/new/' file`|`edit(path="file", edits=[])`|
38
+
39
+ {{#if hasAstEdit}}|`sed -i 's/oldFn(/newFn(/' src/*.ts`|`ast_edit({ops:[{pat:"oldFn($$$A)", out:"newFn($$$A)"}], path:"src/"})`|{{/if}}
40
+ {{#if hasAstGrep}}- You **MUST** use `ast_grep` for structural code search instead of bash `grep`/`awk`/`perl` pipelines{{/if}}
41
+ {{#if hasAstEdit}}- You **MUST** use `ast_edit` for structural rewrites instead of bash `sed`/`awk`/`perl` pipelines{{/if}}
40
42
  - You **MUST NOT** use Bash for these operations like read, grep, find, edit, write, where specialized tools exist.
41
43
  - You **MUST NOT** use `2>&1` | `2>/dev/null` pattern, stdout and stderr are already merged.
42
44
  - You **MUST NOT** use `| head -n 50` or `| tail -n 100` pattern, use `head` and `tail` parameters instead.
@@ -49,6 +49,7 @@ Every edit has `op`, `pos`, and `lines`. Range replaces also have `end`. Both `p
49
49
  - You **MUST NOT** set `end` to an interior line and then re-add the boundary token in `lines`; that duplicates the next surviving line.
50
50
  - To remove a line while keeping its neighbors, **delete** it (`lines: null`). You **MUST NOT** replace it with the content of an adjacent line — that line still exists and will be duplicated.
51
51
  4. **Match surrounding indentation:** Leading whitespace in `lines` **MUST** be copied verbatim from adjacent lines in the `read` output. Do not infer or reconstruct indentation from memory — count the actual leading spaces on the lines immediately above and below the insertion or replacement point.
52
+ 5. **Preserve idiomatic sibling spacing:** When inserting declarations between top-level siblings, you **MUST** preserve existing blank-line separators. If siblings are separated by one blank line, include a trailing `""` in `lines` so inserted code keeps the same spacing.
52
53
  </rules>
53
54
 
54
55
  <recovery>
@@ -121,18 +122,17 @@ Range — add `end`:
121
122
  {{hlinefull 62 " return null;"}}
122
123
  {{hlinefull 63 " }"}}
123
124
  ```
125
+ Target only the inner lines that change — leave unchanged boundaries out of the range.
124
126
  ```
125
127
  {
126
128
  path: "…",
127
129
  edits: [{
128
130
  op: "replace",
129
- pos: {{hlinejsonref 60 " } catch (err) {"}},
130
- end: {{hlinejsonref 63 " }"}},
131
+ pos: {{hlinejsonref 61 " console.error(err);"}},
132
+ end: {{hlinejsonref 62 " return null;"}},
131
133
  lines: [
132
- " } catch (err) {",
133
134
  " if (isEnoent(err)) return null;",
134
- " throw err;",
135
- " }"
135
+ " throw err;"
136
136
  ]
137
137
  }]
138
138
  }
@@ -141,39 +141,24 @@ Range — add `end`:
141
141
 
142
142
  <example name="inclusive end avoids duplicate boundary">
143
143
  ```ts
144
- {{hlinefull 70 "if (ok) {"}}
145
- {{hlinefull 71 " run();"}}
146
- {{hlinefull 72 "}"}}
147
- {{hlinefull 73 "after();"}}
144
+ {{hlinefull 70 "\tif (user.isAdmin) {"}}
145
+ {{hlinefull 71 "\t\tdeleteRecord(id);"}}
146
+ {{hlinefull 72 "\t}"}}
147
+ {{hlinefull 73 "\tafter();"}}
148
148
  ```
149
- Bad`end` stops before `}` while `lines` already includes `}`:
149
+ The block grows by one line and the condition changes two single-line ops would be needed otherwise. Since `}` appears in `lines`, `end` must include `72`:
150
150
  ```
151
151
  {
152
152
  path: "…",
153
153
  edits: [{
154
154
  op: "replace",
155
- pos: {{hlinejsonref 70 "if (ok) {"}},
156
- end: {{hlinejsonref 71 " run();"}},
155
+ pos: {{hlinejsonref 70 "\tif (user.isAdmin) {"}},
156
+ end: {{hlinejsonref 72 "\t}"}},
157
157
  lines: [
158
- "if (ok) {",
159
- " runSafe();",
160
- "}"
161
- ]
162
- }]
163
- }
164
- ```
165
- Good — include original `}` in the replaced range when replacement keeps `}`:
166
- ```
167
- {
168
- path: "…",
169
- edits: [{
170
- op: "replace",
171
- pos: {{hlinejsonref 70 "if (ok) {"}},
172
- end: {{hlinejsonref 72 "}"}},
173
- lines: [
174
- "if (ok) {",
175
- " runSafe();",
176
- "}"
158
+ "\tif (user.isAdmin && confirmed) {",
159
+ "\t\tauditLog(id);",
160
+ "\t\tdeleteRecord(id);",
161
+ "\t}"
177
162
  ]
178
163
  }]
179
164
  }
@@ -191,6 +176,7 @@ Also apply the same rule to `);`, `],`, and `},` closers: if replacement include
191
176
  {{hlinefull 49 " runY();"}}
192
177
  {{hlinefull 50 "}"}}
193
178
  ```
179
+ Use a trailing `""` to preserve the blank line between top-level sibling declarations.
194
180
  ```
195
181
  {
196
182
  path: "…",
@@ -206,20 +192,6 @@ Also apply the same rule to `);`, `],`, and `},` closers: if replacement include
206
192
  }]
207
193
  }
208
194
  ```
209
- Result:
210
- ```ts
211
- {{hlinefull 44 "function x() {"}}
212
- {{hlinefull 45 " runX();"}}
213
- {{hlinefull 46 "}"}}
214
- {{hlinefull 47 ""}}
215
- {{hlinefull 48 "function z() {"}}
216
- {{hlinefull 49 " runZ();"}}
217
- {{hlinefull 50 "}"}}
218
- {{hlinefull 51 ""}}
219
- {{hlinefull 52 "function y() {"}}
220
- {{hlinefull 53 " runY();"}}
221
- {{hlinefull 54 "}"}}
222
- ```
223
195
  </example>
224
196
 
225
197
  <example name="anchor to structure, not whitespace">
@@ -249,30 +221,15 @@ Good — anchors to structural line:
249
221
  </example>
250
222
 
251
223
  <example name="indentation must match context">
252
- Leading whitespace in `lines` **MUST** be copied from the `read` output, not reconstructed from memory. Check the actual indent of neighboring lines.
224
+ Leading whitespace in `lines` **MUST** be copied from the `read` output, not reconstructed from memory. If the file uses tabs, use `\t` in JSON — you **MUST NOT** use `\\t`, which produces a literal backslash-t in the file.
253
225
  ```ts
254
226
  {{hlinefull 10 "class Foo {"}}
255
- {{hlinefull 11 " bar() {"}}
256
- {{hlinefull 12 " return 1;"}}
257
- {{hlinefull 13 " }"}}
227
+ {{hlinefull 11 "\tbar() {"}}
228
+ {{hlinefull 12 "\t\treturn 1;"}}
229
+ {{hlinefull 13 "\t}"}}
258
230
  {{hlinefull 14 "}"}}
259
231
  ```
260
- Badindent guessed as 4 spaces instead of 2 (as seen on lines 11–13):
261
- ```
262
- {
263
- path: "…",
264
- edits: [{
265
- op: "prepend",
266
- pos: {{hlinejsonref 14 "}"}},
267
- lines: [
268
- " baz() {",
269
- " return 2;",
270
- " }"
271
- ]
272
- }]
273
- }
274
- ```
275
- Good — indent matches the 2-space style visible on adjacent lines:
232
+ Good`\t` in JSON is a real tab, matching the file's indentation:
276
233
  ```
277
234
  {
278
235
  path: "…",
@@ -280,9 +237,9 @@ Good — indent matches the 2-space style visible on adjacent lines:
280
237
  op: "prepend",
281
238
  pos: {{hlinejsonref 14 "}"}},
282
239
  lines: [
283
- " baz() {",
284
- " return 2;",
285
- " }"
240
+ "\tbaz() {",
241
+ "\t\treturn 2;",
242
+ "\t}"
286
243
  ]
287
244
  }]
288
245
  }
@@ -294,5 +251,5 @@ Good — indent matches the 2-space style visible on adjacent lines:
294
251
  - Every tag **MUST** be copied exactly from fresh tool result as `N#ID`.
295
252
  - You **MUST** re-read after each edit call before issuing another on same file.
296
253
  - Formatting is a batch operation. You **MUST NOT** use this tool to reformat, reindent, or adjust whitespace — run the project's formatter instead. If the only change is whitespace, it is formatting; do not touch it.
297
- - `lines` entries **MUST** be literal file content with real space indentation. (`\\t` in JSON inserts a literal backslash-t into the file, not a tab.)
254
+ - `lines` entries **MUST** be literal file content with indentation copied exactly from the `read` output. If the file uses tabs, use `\t` in JSON (a real tab character) — you **MUST NOT** use `\\t` (two characters: backslash + t), which produces the literal string `\t` in the file.
298
255
  </critical>
@@ -82,6 +82,9 @@ import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from
82
82
  import type { PlanModeState } from "../plan-mode/state";
83
83
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
84
84
  import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
85
+ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool-decision-reminder.md" with {
86
+ type: "text",
87
+ };
85
88
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
86
89
  import type { SecretObfuscator } from "../secrets/obfuscator";
87
90
  import type { CheckpointState } from "../tools/checkpoint";
@@ -733,9 +736,13 @@ export class AgentSession {
733
736
  }
734
737
 
735
738
  // Check auto-retry and auto-compaction after agent completes
736
- if (event.type === "agent_end" && this.#lastAssistantMessage) {
737
- const msg = this.#lastAssistantMessage;
739
+ if (event.type === "agent_end") {
740
+ const fallbackAssistant = [...event.messages]
741
+ .reverse()
742
+ .find((message): message is AssistantMessage => message.role === "assistant");
743
+ const msg = this.#lastAssistantMessage ?? fallbackAssistant;
738
744
  this.#lastAssistantMessage = undefined;
745
+ if (!msg) return;
739
746
 
740
747
  if (this.#skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) {
741
748
  this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
@@ -1901,6 +1908,9 @@ export class AgentSession {
1901
1908
  : { role: "user" as const, content: userContent, timestamp: Date.now() };
1902
1909
 
1903
1910
  await this.#promptWithMessage(message, expandedText, options);
1911
+ if (!options?.synthetic) {
1912
+ await this.#enforcePlanModeToolDecision();
1913
+ }
1904
1914
  }
1905
1915
 
1906
1916
  async promptCustomMessage<T = unknown>(
@@ -3329,6 +3339,52 @@ Be thorough - include exact file paths, function names, error messages, and tech
3329
3339
  this.#checkpointState = undefined;
3330
3340
  this.#pendingRewindReport = undefined;
3331
3341
  }
3342
+ async #enforcePlanModeToolDecision(): Promise<void> {
3343
+ if (!this.#planModeState?.enabled) {
3344
+ return;
3345
+ }
3346
+ const assistantMessage = this.#findLastAssistantMessage();
3347
+ if (!assistantMessage) {
3348
+ return;
3349
+ }
3350
+ if (assistantMessage.stopReason === "error" || assistantMessage.stopReason === "aborted") {
3351
+ return;
3352
+ }
3353
+
3354
+ const calledRequiredTool = assistantMessage.content.some(
3355
+ content => content.type === "toolCall" && (content.name === "ask" || content.name === "exit_plan_mode"),
3356
+ );
3357
+ if (calledRequiredTool) {
3358
+ return;
3359
+ }
3360
+
3361
+ const askTool = this.#toolRegistry.get("ask");
3362
+ const exitPlanModeTool = this.#toolRegistry.get("exit_plan_mode");
3363
+ if (!askTool || !exitPlanModeTool) {
3364
+ logger.warn("Plan mode enforcement skipped because ask/exit tools are unavailable", {
3365
+ activeToolNames: this.agent.state.tools.map(tool => tool.name),
3366
+ });
3367
+ return;
3368
+ }
3369
+ const forcedTools = [askTool, exitPlanModeTool];
3370
+
3371
+ const reminder = renderPromptTemplate(planModeToolDecisionReminderPrompt, {
3372
+ askToolName: "ask",
3373
+ exitToolName: "exit_plan_mode",
3374
+ });
3375
+
3376
+ const previousTools = this.agent.state.tools;
3377
+ this.agent.setTools(forcedTools);
3378
+ try {
3379
+ await this.prompt(reminder, {
3380
+ synthetic: true,
3381
+ expandPromptTemplates: false,
3382
+ toolChoice: "required",
3383
+ });
3384
+ } finally {
3385
+ this.agent.setTools(previousTools);
3386
+ }
3387
+ }
3332
3388
  /**
3333
3389
  * Check if agent stopped with incomplete todos and prompt to continue.
3334
3390
  */
package/src/tools/ask.ts CHANGED
@@ -14,9 +14,11 @@
14
14
  * - Use recommended: <index> to mark the default option; "(Recommended)" suffix is added automatically
15
15
  * - Questions may time out and auto-select the recommended option (configurable, disabled in plan mode)
16
16
  */
17
+
17
18
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
18
19
  import type { Component } from "@oh-my-pi/pi-tui";
19
20
  import { TERMINAL, Text } from "@oh-my-pi/pi-tui";
21
+ import { ptree, untilAborted } from "@oh-my-pi/pi-utils";
20
22
  import { type Static, Type } from "@sinclair/typebox";
21
23
  import { renderPromptTemplate } from "../config/prompt-templates";
22
24
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -25,6 +27,7 @@ import askDescription from "../prompts/tools/ask.md" with { type: "text" };
25
27
  import { renderStatusLine } from "../tui";
26
28
  import type { ToolSession } from ".";
27
29
  import { formatErrorMessage, formatMeta, formatTitle } from "./render-utils";
30
+ import { ToolAbortError } from "./tool-errors";
28
31
 
29
32
  // =============================================================================
30
33
  // Types
@@ -110,14 +113,9 @@ interface UIContext {
110
113
  select(
111
114
  prompt: string,
112
115
  options: string[],
113
- options_?: { initialIndex?: number; timeout?: number; outline?: boolean },
116
+ options_?: { initialIndex?: number; signal?: AbortSignal; outline?: boolean },
114
117
  ): Promise<string | undefined>;
115
- input(prompt: string): Promise<string | undefined>;
116
- }
117
-
118
- interface AskQuestionOptions {
119
- /** Timeout in milliseconds, null/undefined to disable */
120
- timeout?: number | null;
118
+ input(prompt: string, options_?: { signal?: AbortSignal }): Promise<string | undefined>;
121
119
  }
122
120
 
123
121
  async function askSingleQuestion(
@@ -126,9 +124,8 @@ async function askSingleQuestion(
126
124
  optionLabels: string[],
127
125
  multi: boolean,
128
126
  recommended?: number,
129
- options?: AskQuestionOptions,
127
+ signal?: AbortSignal,
130
128
  ): Promise<SelectionResult> {
131
- const timeout = options?.timeout ?? undefined;
132
129
  const doneLabel = getDoneOptionLabel();
133
130
  let selectedOptions: string[] = [];
134
131
  let customInput: string | undefined;
@@ -152,22 +149,27 @@ async function askSingleQuestion(
152
149
  opts.push(OTHER_OPTION);
153
150
 
154
151
  const prefix = selected.size > 0 ? `(${selected.size} selected) ` : "";
155
- const selectionStart = Date.now();
156
- const choice = await ui.select(`${prefix}${question}`, opts, {
157
- initialIndex: cursorIndex,
158
- timeout: timeout ?? undefined,
159
- outline: true,
160
- });
161
- const elapsed = Date.now() - selectionStart;
162
- const timedOut = timeout != null && elapsed >= timeout;
152
+ const choice = signal
153
+ ? await untilAborted(signal, () =>
154
+ ui.select(`${prefix}${question}`, opts, {
155
+ initialIndex: cursorIndex,
156
+ signal,
157
+ outline: true,
158
+ }),
159
+ )
160
+ : await ui.select(`${prefix}${question}`, opts, {
161
+ initialIndex: cursorIndex,
162
+ signal,
163
+ outline: true,
164
+ });
163
165
 
164
166
  if (choice === undefined || choice === doneLabel) break;
165
167
 
166
168
  if (choice === OTHER_OPTION) {
167
- if (!timedOut) {
168
- const input = await ui.input("Enter your response:");
169
- if (input) customInput = input;
170
- }
169
+ const input = signal
170
+ ? await untilAborted(signal, () => ui.input("Enter your response:", { signal }))
171
+ : await ui.input("Enter your response:", { signal });
172
+ if (input) customInput = input;
171
173
  break;
172
174
  }
173
175
 
@@ -192,21 +194,28 @@ async function askSingleQuestion(
192
194
  selected.add(opt);
193
195
  }
194
196
  }
195
-
196
- if (timedOut) {
197
- break;
198
- }
199
197
  }
200
198
  selectedOptions = Array.from(selected);
201
199
  } else {
202
200
  const displayLabels = addRecommendedSuffix(optionLabels, recommended);
203
- const choice = await ui.select(question, [...displayLabels, OTHER_OPTION], {
204
- timeout: timeout ?? undefined,
205
- initialIndex: recommended,
206
- outline: true,
207
- });
201
+ const choice = signal
202
+ ? await untilAborted(signal, () =>
203
+ ui.select(question, [...displayLabels, OTHER_OPTION], {
204
+ initialIndex: recommended,
205
+ signal,
206
+ outline: true,
207
+ }),
208
+ )
209
+ : await ui.select(question, [...displayLabels, OTHER_OPTION], {
210
+ initialIndex: recommended,
211
+ signal,
212
+ outline: true,
213
+ });
214
+
208
215
  if (choice === OTHER_OPTION) {
209
- const input = await ui.input("Enter your response:");
216
+ const input = signal
217
+ ? await untilAborted(signal, () => ui.input("Enter your response:", { signal }))
218
+ : await ui.input("Enter your response:", { signal });
210
219
  if (input) customInput = input;
211
220
  } else if (choice) {
212
221
  selectedOptions = [stripRecommendedSuffix(choice)];
@@ -265,7 +274,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
265
274
  async execute(
266
275
  _toolCallId: string,
267
276
  params: AskParams,
268
- _signal?: AbortSignal,
277
+ signal?: AbortSignal,
269
278
  _onUpdate?: AgentToolUpdateCallback<AskToolDetails>,
270
279
  context?: AgentToolContext,
271
280
  ): Promise<AgentToolResult<AskToolDetails>> {
@@ -277,7 +286,11 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
277
286
  };
278
287
  }
279
288
 
280
- const { ui } = context;
289
+ const extensionUi = context.ui;
290
+ const ui: UIContext = {
291
+ select: (prompt, options, dialogOptions) => extensionUi.select(prompt, options, dialogOptions),
292
+ input: (prompt, dialogOptions) => extensionUi.input(prompt, undefined, dialogOptions),
293
+ };
281
294
 
282
295
  // Determine timeout based on settings and plan mode
283
296
  const planModeEnabled = this.session.getPlanModeState?.()?.enabled ?? false;
@@ -296,18 +309,41 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
296
309
  };
297
310
  }
298
311
 
312
+ const askQuestion = async (q: AskParams["questions"][number]) => {
313
+ const optionLabels = q.options.map(o => o.label);
314
+ const timeoutSignal = timeout == null ? undefined : AbortSignal.timeout(timeout);
315
+ const questionSignal = ptree.combineSignals(signal, timeoutSignal);
316
+ try {
317
+ const { selectedOptions, customInput } = await askSingleQuestion(
318
+ ui,
319
+ q.question,
320
+ optionLabels,
321
+ q.multi ?? false,
322
+ q.recommended,
323
+ questionSignal,
324
+ );
325
+ return { optionLabels, selectedOptions, customInput, timedOut: false };
326
+ } catch (error) {
327
+ if (error instanceof Error && error.name === "AbortError") {
328
+ if (signal?.aborted) {
329
+ throw new ToolAbortError("Ask input was cancelled");
330
+ }
331
+ if (timeoutSignal?.aborted) {
332
+ return { optionLabels, selectedOptions: [], customInput: undefined, timedOut: true };
333
+ }
334
+ }
335
+ throw error;
336
+ }
337
+ };
338
+
299
339
  if (params.questions.length === 1) {
300
340
  const [q] = params.questions;
301
- const optionLabels = q.options.map(o => o.label);
302
- const { selectedOptions, customInput } = await askSingleQuestion(
303
- ui,
304
- q.question,
305
- optionLabels,
306
- q.multi ?? false,
307
- q.recommended,
308
- { timeout },
309
- );
341
+ const { optionLabels, selectedOptions, customInput, timedOut } = await askQuestion(q);
310
342
 
343
+ if (!timedOut && selectedOptions.length === 0 && !customInput) {
344
+ context.abort();
345
+ throw new ToolAbortError("Ask tool was cancelled by the user");
346
+ }
311
347
  const details: AskToolDetails = {
312
348
  question: q.question,
313
349
  options: optionLabels,
@@ -333,16 +369,12 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
333
369
  const results: QuestionResult[] = [];
334
370
 
335
371
  for (const q of params.questions) {
336
- const optionLabels = q.options.map(o => o.label);
337
- const { selectedOptions, customInput } = await askSingleQuestion(
338
- ui,
339
- q.question,
340
- optionLabels,
341
- q.multi ?? false,
342
- q.recommended,
343
- { timeout },
344
- );
372
+ const { optionLabels, selectedOptions, customInput, timedOut } = await askQuestion(q);
345
373
 
374
+ if (!timedOut && selectedOptions.length === 0 && !customInput) {
375
+ context.abort();
376
+ throw new ToolAbortError("Ask tool was cancelled by the user");
377
+ }
346
378
  results.push({
347
379
  id: q.id,
348
380
  question: q.question,
package/src/tools/bash.ts CHANGED
@@ -98,7 +98,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
98
98
  constructor(private readonly session: ToolSession) {
99
99
  this.#asyncEnabled = this.session.settings.get("async.enabled");
100
100
  this.parameters = this.#asyncEnabled ? bashSchemaWithAsync : bashSchemaBase;
101
- this.description = renderPromptTemplate(bashDescription, { asyncEnabled: this.#asyncEnabled });
101
+ this.description = renderPromptTemplate(bashDescription, {
102
+ asyncEnabled: this.#asyncEnabled,
103
+ hasAstGrep: this.session.settings.get("astGrep.enabled"),
104
+ hasAstEdit: this.session.settings.get("astEdit.enabled"),
105
+ });
102
106
  }
103
107
 
104
108
  #formatResultOutput(result: BashResult | BashInteractiveResult, headLines?: number, tailLines?: number): string {
@@ -282,6 +282,24 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
282
282
  ) {
283
283
  requestedTools.push("bash");
284
284
  }
285
+
286
+ // Auto-include AST counterparts when their text-based sibling is present
287
+ if (requestedTools) {
288
+ if (
289
+ requestedTools.includes("grep") &&
290
+ !requestedTools.includes("ast_grep") &&
291
+ session.settings.get("astGrep.enabled")
292
+ ) {
293
+ requestedTools.push("ast_grep");
294
+ }
295
+ if (
296
+ requestedTools.includes("edit") &&
297
+ !requestedTools.includes("ast_edit") &&
298
+ session.settings.get("astEdit.enabled")
299
+ ) {
300
+ requestedTools.push("ast_edit");
301
+ }
302
+ }
285
303
  const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
286
304
  const isToolAllowed = (name: string) => {
287
305
  if (name === "lsp") return enableLsp;
@@ -86,9 +86,8 @@ export function formatPromptContent(content: string, options: PromptFormatOption
86
86
 
87
87
  for (let i = 0; i < lines.length; i++) {
88
88
  let line = lines[i].trimEnd();
89
- const trimmed = line.trimStart();
90
-
91
- if (CODE_FENCE.test(trimmed)) {
89
+ let trimmedStart = line.trimStart();
90
+ if (CODE_FENCE.test(trimmedStart)) {
92
91
  inCodeBlock = !inCodeBlock;
93
92
  result.push(line);
94
93
  continue;
@@ -102,30 +101,29 @@ export function formatPromptContent(content: string, options: PromptFormatOption
102
101
  if (replaceAsciiSymbols) {
103
102
  line = replaceCommonAsciiSymbols(line);
104
103
  }
104
+ trimmedStart = line.trimStart();
105
+ const trimmed = line.trim();
105
106
 
106
- const isOpeningXml = OPENING_XML.test(trimmed) && !trimmed.endsWith("/>");
107
- if (isOpeningXml && line.length === trimmed.length) {
108
- const match = OPENING_XML.exec(trimmed);
107
+ const isOpeningXml = OPENING_XML.test(trimmedStart) && !trimmedStart.endsWith("/>");
108
+ if (isOpeningXml && line.length === trimmedStart.length) {
109
+ const match = OPENING_XML.exec(trimmedStart);
109
110
  if (match) topLevelTags.push(match[1]);
110
111
  }
111
112
 
112
- const closingMatch = CLOSING_XML.exec(trimmed);
113
+ const closingMatch = CLOSING_XML.exec(trimmedStart);
113
114
  if (closingMatch) {
114
115
  const tagName = closingMatch[1];
115
116
  if (topLevelTags.length > 0 && topLevelTags[topLevelTags.length - 1] === tagName) {
116
- line = trimmed;
117
117
  topLevelTags.pop();
118
- } else {
119
- line = line.trimEnd();
120
118
  }
121
- } else if (isPreRender && trimmed.startsWith("{{")) {
122
- line = trimmed;
123
- } else if (TABLE_SEP.test(trimmed)) {
124
- line = compactTableSep(trimmed);
125
- } else if (TABLE_ROW.test(trimmed)) {
126
- line = compactTableRow(trimmed);
127
- } else {
128
- line = line.trimEnd();
119
+ } else if (isPreRender && trimmedStart.startsWith("{{")) {
120
+ /* keep indentation as-is in pre-render for Handlebars markers */
121
+ } else if (TABLE_SEP.test(trimmedStart)) {
122
+ const leadingWhitespace = line.slice(0, line.length - trimmedStart.length);
123
+ line = `${leadingWhitespace}${compactTableSep(trimmedStart)}`;
124
+ } else if (TABLE_ROW.test(trimmedStart)) {
125
+ const leadingWhitespace = line.slice(0, line.length - trimmedStart.length);
126
+ line = `${leadingWhitespace}${compactTableRow(trimmedStart)}`;
129
127
  }
130
128
 
131
129
  if (shouldBoldRfc2119) {