@oh-my-pi/pi-coding-agent 15.4.3 → 15.5.0

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 (133) hide show
  1. package/CHANGELOG.md +75 -5
  2. package/dist/types/cli/args.d.ts +2 -0
  3. package/dist/types/cli/auth-broker-cli.d.ts +1 -1
  4. package/dist/types/commands/launch.d.ts +8 -0
  5. package/dist/types/config/settings-schema.d.ts +42 -1
  6. package/dist/types/edit/index.d.ts +2 -0
  7. package/dist/types/extensibility/custom-tools/types.d.ts +8 -2
  8. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  9. package/dist/types/lsp/index.d.ts +9 -1
  10. package/dist/types/mcp/client.d.ts +2 -1
  11. package/dist/types/mcp/oauth-discovery.d.ts +4 -3
  12. package/dist/types/mcp/timeout.d.ts +9 -0
  13. package/dist/types/mcp/types.d.ts +1 -1
  14. package/dist/types/sdk.d.ts +2 -0
  15. package/dist/types/session/streaming-output.d.ts +1 -1
  16. package/dist/types/task/index.d.ts +2 -0
  17. package/dist/types/task/types.d.ts +4 -0
  18. package/dist/types/tools/approval.d.ts +46 -0
  19. package/dist/types/tools/ask.d.ts +1 -0
  20. package/dist/types/tools/ast-edit.d.ts +2 -0
  21. package/dist/types/tools/ast-grep.d.ts +1 -0
  22. package/dist/types/tools/bash.d.ts +11 -1
  23. package/dist/types/tools/browser.d.ts +2 -0
  24. package/dist/types/tools/calculator.d.ts +1 -0
  25. package/dist/types/tools/checkpoint.d.ts +2 -0
  26. package/dist/types/tools/debug.d.ts +9 -1
  27. package/dist/types/tools/eval.d.ts +2 -0
  28. package/dist/types/tools/find.d.ts +10 -0
  29. package/dist/types/tools/gh.d.ts +2 -1
  30. package/dist/types/tools/hindsight-recall.d.ts +1 -0
  31. package/dist/types/tools/hindsight-reflect.d.ts +1 -0
  32. package/dist/types/tools/hindsight-retain.d.ts +1 -0
  33. package/dist/types/tools/inspect-image.d.ts +1 -0
  34. package/dist/types/tools/irc.d.ts +1 -0
  35. package/dist/types/tools/job.d.ts +1 -0
  36. package/dist/types/tools/read.d.ts +1 -0
  37. package/dist/types/tools/recipe/index.d.ts +1 -0
  38. package/dist/types/tools/render-mermaid.d.ts +1 -0
  39. package/dist/types/tools/resolve.d.ts +1 -0
  40. package/dist/types/tools/search-tool-bm25.d.ts +1 -0
  41. package/dist/types/tools/search.d.ts +1 -0
  42. package/dist/types/tools/ssh.d.ts +2 -0
  43. package/dist/types/tools/todo-write.d.ts +1 -0
  44. package/dist/types/tools/write.d.ts +2 -0
  45. package/dist/types/tools/yield.d.ts +1 -0
  46. package/dist/types/web/search/index.d.ts +1 -0
  47. package/package.json +7 -7
  48. package/src/cli/args.ts +14 -0
  49. package/src/cli/auth-broker-cli.ts +171 -22
  50. package/src/commands/auth-broker.ts +3 -0
  51. package/src/commands/launch.ts +16 -0
  52. package/src/config/mcp-schema.json +2 -2
  53. package/src/config/model-registry.ts +19 -4
  54. package/src/config/settings-schema.ts +59 -1
  55. package/src/config/settings.ts +2 -1
  56. package/src/dap/session.ts +35 -2
  57. package/src/discovery/builtin.ts +2 -2
  58. package/src/discovery/mcp-json.ts +1 -1
  59. package/src/edit/index.ts +26 -0
  60. package/src/edit/modes/patch.ts +1 -1
  61. package/src/edit/streaming.ts +12 -2
  62. package/src/exec/bash-executor.ts +6 -2
  63. package/src/extensibility/custom-commands/bundled/review/index.ts +18 -14
  64. package/src/extensibility/custom-tools/types.ts +16 -2
  65. package/src/extensibility/extensions/wrapper.ts +36 -1
  66. package/src/extensibility/hooks/types.ts +8 -1
  67. package/src/hashline/apply.ts +47 -2
  68. package/src/internal-urls/docs-index.generated.ts +8 -7
  69. package/src/lsp/edits.ts +82 -29
  70. package/src/lsp/index.ts +38 -1
  71. package/src/lsp/utils.ts +1 -1
  72. package/src/main.ts +6 -0
  73. package/src/mcp/client.ts +8 -6
  74. package/src/mcp/oauth-discovery.ts +120 -32
  75. package/src/mcp/oauth-flow.ts +34 -6
  76. package/src/mcp/timeout.ts +59 -0
  77. package/src/mcp/transports/http.ts +42 -44
  78. package/src/mcp/transports/stdio.ts +8 -5
  79. package/src/mcp/types.ts +1 -1
  80. package/src/modes/components/hook-editor.ts +11 -3
  81. package/src/modes/components/mcp-add-wizard.ts +6 -2
  82. package/src/modes/components/model-selector.ts +33 -11
  83. package/src/modes/controllers/command-controller.ts +6 -4
  84. package/src/modes/controllers/mcp-command-controller.ts +8 -4
  85. package/src/prompts/review-custom-request.md +22 -0
  86. package/src/prompts/review-headless-request.md +16 -0
  87. package/src/prompts/review-request.md +2 -3
  88. package/src/prompts/system/project-prompt.md +4 -0
  89. package/src/prompts/tools/debug.md +1 -0
  90. package/src/prompts/tools/find.md +4 -2
  91. package/src/prompts/tools/hashline.md +1 -0
  92. package/src/sdk.ts +47 -73
  93. package/src/session/agent-session.ts +93 -27
  94. package/src/session/streaming-output.ts +1 -1
  95. package/src/slash-commands/helpers/usage-report.ts +3 -1
  96. package/src/task/executor.ts +11 -0
  97. package/src/task/index.ts +19 -0
  98. package/src/task/render.ts +12 -2
  99. package/src/task/types.ts +4 -0
  100. package/src/tools/approval.ts +185 -0
  101. package/src/tools/ask.ts +1 -0
  102. package/src/tools/ast-edit.ts +25 -1
  103. package/src/tools/ast-grep.ts +1 -0
  104. package/src/tools/bash.ts +69 -1
  105. package/src/tools/browser/tab-supervisor.ts +1 -1
  106. package/src/tools/browser.ts +15 -0
  107. package/src/tools/calculator.ts +1 -0
  108. package/src/tools/checkpoint.ts +2 -0
  109. package/src/tools/debug.ts +38 -0
  110. package/src/tools/eval.ts +15 -0
  111. package/src/tools/find.ts +17 -8
  112. package/src/tools/gh.ts +21 -1
  113. package/src/tools/hindsight-recall.ts +1 -0
  114. package/src/tools/hindsight-reflect.ts +1 -0
  115. package/src/tools/hindsight-retain.ts +1 -0
  116. package/src/tools/image-gen.ts +1 -0
  117. package/src/tools/inspect-image.ts +1 -0
  118. package/src/tools/irc.ts +1 -0
  119. package/src/tools/job.ts +1 -0
  120. package/src/tools/path-utils.ts +14 -1
  121. package/src/tools/read.ts +1 -0
  122. package/src/tools/recipe/index.ts +1 -0
  123. package/src/tools/render-mermaid.ts +1 -0
  124. package/src/tools/report-tool-issue.ts +1 -0
  125. package/src/tools/resolve.ts +1 -0
  126. package/src/tools/review.ts +1 -0
  127. package/src/tools/search-tool-bm25.ts +1 -0
  128. package/src/tools/search.ts +1 -0
  129. package/src/tools/ssh.ts +8 -0
  130. package/src/tools/todo-write.ts +1 -0
  131. package/src/tools/write.ts +12 -1
  132. package/src/tools/yield.ts +1 -0
  133. package/src/web/search/index.ts +2 -0
@@ -1574,7 +1574,8 @@ export const SETTINGS_SCHEMA = {
1574
1574
  ui: {
1575
1575
  tab: "editing",
1576
1576
  label: "Hashline Duplicate Insert Drop",
1577
- description: "Drop 2+ pure-insert payload lines that duplicate adjacent file context",
1577
+ description:
1578
+ "Drop payload lines that duplicate adjacent file context — 2+-line context echoes on `↑`/`↓` inserts, and a single boundary line at either edge of an `A-B:` replacement",
1578
1579
  },
1579
1580
  },
1580
1581
  "edit.blockAutoGenerated": {
@@ -1789,6 +1790,53 @@ export const SETTINGS_SCHEMA = {
1789
1790
  // Tools
1790
1791
  // ────────────────────────────────────────────────────────────────────────
1791
1792
 
1793
+ // Tool approval policies
1794
+ "tools.approval": {
1795
+ type: "record",
1796
+ default: {},
1797
+ ui: {
1798
+ tab: "tools",
1799
+ label: "Tool Approval Policies",
1800
+ description:
1801
+ "Per-tool approval policies. Set to 'allow' to auto-approve, 'prompt' to require confirmation, or 'deny' to block. Overrides are honored in every approval mode.",
1802
+ },
1803
+ },
1804
+
1805
+ // Default tool approval mode (interaction tab, but governs the tool wrapper).
1806
+ // "always-ask" — auto-approves read-tier tools only; prompts for write/exec.
1807
+ // "write" — auto-approves read and write-tier tools; prompts for exec.
1808
+ // "yolo" — auto-approves every tier unless a tool declares `override: true`.
1809
+ "tools.approvalMode": {
1810
+ type: "enum",
1811
+ values: ["always-ask", "write", "yolo"] as const,
1812
+ default: "yolo",
1813
+ ui: {
1814
+ tab: "interaction",
1815
+ label: "Tool Approval",
1816
+ description:
1817
+ "Default approval behaviour for tool calls. 'Always ask' auto-approves read-only tools only. 'Write' auto-approves read and workspace-write tools. 'Yolo' auto-approves every tier unless a tool declares a safety override. `tools.approval.<tool>` overrides are honored in every mode.",
1818
+ options: [
1819
+ {
1820
+ value: "always-ask",
1821
+ label: "Always ask",
1822
+ description: "Auto-approve read-only tools; require confirmation for write and exec tools.",
1823
+ },
1824
+ {
1825
+ value: "write",
1826
+ label: "Write",
1827
+ description:
1828
+ "Auto-approve read-only and write tools; require confirmation for exec tools such as bash, eval, browser, task, recipe, and ssh.",
1829
+ },
1830
+ {
1831
+ value: "yolo",
1832
+ label: "Yolo",
1833
+ description:
1834
+ "Auto-approve read, write, and exec tools. Safety overrides declared by tools (for example critical bash patterns) still require confirmation.",
1835
+ },
1836
+ ],
1837
+ },
1838
+ },
1839
+
1792
1840
  // Todo tool
1793
1841
  "todo.enabled": {
1794
1842
  type: "boolean",
@@ -2477,6 +2525,16 @@ export const SETTINGS_SCHEMA = {
2477
2525
  },
2478
2526
  },
2479
2527
 
2528
+ "task.showResolvedModelBadge": {
2529
+ type: "boolean",
2530
+ default: false,
2531
+ ui: {
2532
+ tab: "appearance",
2533
+ label: "Show Resolved Model Badge",
2534
+ description: "Display the actual model ID used by each subagent in the task widget status line",
2535
+ },
2536
+ },
2537
+
2480
2538
  // Skills
2481
2539
  "skills.enabled": { type: "boolean", default: true },
2482
2540
 
@@ -100,7 +100,6 @@ function setByPath(obj: RawSettings, segments: string[], value: unknown): void {
100
100
  }
101
101
 
102
102
  const PATH_SCOPED_ARRAY_SETTINGS = new Set<SettingPath>(["enabledModels", "disabledProviders"]);
103
-
104
103
  type PathScopedStringArrayEntry = {
105
104
  path?: unknown;
106
105
  paths?: unknown;
@@ -218,6 +217,8 @@ export class Settings {
218
217
  for (const [key, value] of Object.entries(options.overrides)) {
219
218
  setByPath(this.#overrides, key.split("."), value);
220
219
  }
220
+
221
+ this.#overrides = this.#migrateRawSettings(this.#overrides);
221
222
  }
222
223
  }
223
224
 
@@ -108,14 +108,28 @@ function toErrorMessage(value: unknown): string {
108
108
  interface DapStartRequestFailure {
109
109
  rejected: boolean;
110
110
  error?: unknown;
111
+ /**
112
+ * Resolves (never rejects) when the underlying launch/attach request
113
+ * settles either way. Set by {@link trackDapStartRequest} on each call,
114
+ * so a single failure object must not be reused across launch attempts.
115
+ * Consumed by {@link throwPreferredDapStartError} to bound how long to
116
+ * wait for a delayed adapter-side rejection before falling back to the
117
+ * cascade error from configurationDone.
118
+ */
119
+ settled?: Promise<void>;
111
120
  }
112
121
 
113
122
  function trackDapStartRequest<T>(promise: Promise<T>, failure: DapStartRequestFailure): Promise<T> {
114
- return promise.catch(error => {
123
+ const tracked = promise.catch(error => {
115
124
  failure.rejected = true;
116
125
  failure.error = error;
117
126
  throw error;
118
127
  });
128
+ failure.settled = tracked.then(
129
+ () => {},
130
+ () => {},
131
+ );
132
+ return tracked;
119
133
  }
120
134
 
121
135
  function combineDapStartErrors(command: "launch" | "attach", startError: unknown, configurationError: unknown): Error {
@@ -134,12 +148,27 @@ async function throwPreferredDapStartError(
134
148
  startFailure: DapStartRequestFailure,
135
149
  configurationError: unknown,
136
150
  ): Promise<never> {
137
- await Promise.resolve();
151
+ await Promise.race([startFailure.settled ?? Promise.resolve(), timers.setTimeout(50)]);
138
152
  if (startFailure.rejected) {
139
153
  throw combineDapStartErrors(command, startFailure.error, configurationError);
140
154
  }
141
155
  throw configurationError;
142
156
  }
157
+
158
+ const DEBUGPY_MISSING_MODULE_RE = /No module named ['"]?debugpy['"]?/;
159
+
160
+ /**
161
+ * Map a generic adapter-side failure into the targeted `pip install debugpy`
162
+ * hint when the adapter is debugpy and stderr/the wrapping error mentions
163
+ * the missing module. Returns null when the heuristic does not apply, so the
164
+ * caller can rethrow the original error untouched.
165
+ */
166
+ function mapDebugpyMissingModule(adapterName: string, error: unknown): Error | null {
167
+ if (adapterName !== "debugpy") return null;
168
+ if (!DEBUGPY_MISSING_MODULE_RE.test(toErrorMessage(error))) return null;
169
+ return new Error("adapter 'debugpy' is not available: install with 'pip install debugpy'");
170
+ }
171
+
143
172
  function normalizePath(filePath: string): string {
144
173
  return path.resolve(filePath);
145
174
  }
@@ -274,6 +303,8 @@ export class DapSessionManager {
274
303
  return buildSummary(session);
275
304
  } catch (error) {
276
305
  await this.#disposeSession(session);
306
+ const mapped = mapDebugpyMissingModule(options.adapter.name, error);
307
+ if (mapped) throw mapped;
277
308
  throw error;
278
309
  }
279
310
  }
@@ -330,6 +361,8 @@ export class DapSessionManager {
330
361
  return buildSummary(session);
331
362
  } catch (error) {
332
363
  await this.#disposeSession(session);
364
+ const mapped = mapDebugpyMissingModule(options.adapter.name, error);
365
+ if (mapped) throw mapped;
333
366
  throw error;
334
367
  }
335
368
  }
@@ -132,7 +132,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
132
132
  if (serverConfig.timeout === undefined || serverConfig.timeout === null) {
133
133
  timeout = undefined;
134
134
  } else if (typeof serverConfig.timeout === "number") {
135
- if (Number.isFinite(serverConfig.timeout) && serverConfig.timeout > 0) {
135
+ if (Number.isFinite(serverConfig.timeout) && serverConfig.timeout >= 0) {
136
136
  timeout = serverConfig.timeout;
137
137
  } else {
138
138
  logger.warn(`MCP server "${serverName}": invalid timeout ${serverConfig.timeout}, ignoring`);
@@ -140,7 +140,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
140
140
  }
141
141
  } else if (typeof serverConfig.timeout === "string") {
142
142
  const parsed = Number(serverConfig.timeout);
143
- if (Number.isFinite(parsed) && parsed > 0) {
143
+ if (Number.isFinite(parsed) && parsed >= 0) {
144
144
  timeout = parsed;
145
145
  } else {
146
146
  logger.warn(`MCP server "${serverName}": invalid timeout "${serverConfig.timeout}", ignoring`);
@@ -74,7 +74,7 @@ function transformMCPConfig(config: MCPConfigFile, source: SourceMeta): MCPServe
74
74
  if (
75
75
  typeof serverConfig.timeout === "number" &&
76
76
  Number.isFinite(serverConfig.timeout) &&
77
- serverConfig.timeout > 0
77
+ serverConfig.timeout >= 0
78
78
  ) {
79
79
  timeout = serverConfig.timeout;
80
80
  } else {
package/src/edit/index.ts CHANGED
@@ -20,6 +20,8 @@ import hashlineDescription from "../prompts/tools/hashline.md" with { type: "tex
20
20
  import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
21
21
  import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
22
22
  import type { ToolSession } from "../tools";
23
+ import { truncateForPrompt } from "../tools/approval";
24
+ import { isInternalUrlPath } from "../tools/path-utils";
23
25
  import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
24
26
  import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch";
25
27
  import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
@@ -266,7 +268,31 @@ async function executeSinglePathEntries(
266
268
  };
267
269
  }
268
270
 
271
+ function extractApprovalPath(args: unknown): string {
272
+ const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
273
+ const targetPath = record.path;
274
+ if (typeof targetPath === "string" && targetPath.length > 0) {
275
+ return targetPath;
276
+ }
277
+
278
+ const input = typeof record.input === "string" ? record.input : undefined;
279
+ if (!input) return "(unknown)";
280
+
281
+ const hashlineMatch = /^(?:¶|§|@)([^\s#]+)/m.exec(input);
282
+ if (hashlineMatch?.[1]) return hashlineMatch[1];
283
+
284
+ const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input);
285
+ return applyPatchMatch?.[1]?.trim() || "(unknown)";
286
+ }
287
+
269
288
  export class EditTool implements AgentTool<TInput> {
289
+ readonly approval = (args: unknown) => {
290
+ const targetPath = extractApprovalPath(args);
291
+ return targetPath !== "(unknown)" && isInternalUrlPath(targetPath) ? "read" : "write";
292
+ };
293
+ readonly formatApprovalDetails = (args: unknown): string[] => [
294
+ `File: ${truncateForPrompt(extractApprovalPath(args))}`,
295
+ ];
270
296
  readonly name = "edit";
271
297
  readonly label = "Edit";
272
298
  readonly loadMode = "essential";
@@ -1763,7 +1763,7 @@ export async function executePatchSingle(
1763
1763
  postEditContent.length === preEditContent.length &&
1764
1764
  postEditContent.every((b, i) => b === preEditContent[i]);
1765
1765
  if (unchanged) {
1766
- throw new ToolError(`edit appeared successful but file content did not change on disk: ${resolvedPath}`, {
1766
+ throw new ToolError(`edit appeared successful but file content did not change on disk: ${path}`, {
1767
1767
  path: resolvedPath,
1768
1768
  });
1769
1769
  }
@@ -386,14 +386,24 @@ function buildHashlineNaturalOrderPreviews(
386
386
  case "envelope-begin":
387
387
  case "envelope-end":
388
388
  case "abort":
389
- case "op-insert":
390
- case "op-replace":
391
389
  case "op-delete":
392
390
  continue;
393
391
  case "header":
394
392
  currentPath = token.path;
395
393
  if (currentPath) ensure(currentPath);
396
394
  continue;
395
+ case "op-insert":
396
+ case "op-replace":
397
+ // Inline body on the op line itself (`N↓payload`, `A-B:payload`) is
398
+ // payload content that just happens to share a line with the op
399
+ // header — render it the same as a standalone payload token so
400
+ // the very first character the model types after the sigil shows
401
+ // up in the streaming preview. Without this, the preview is
402
+ // empty until a newline arrives, and the renderer falls back to
403
+ // raw input ("A-B: bla bla bla") instead of "+ bla bla bla".
404
+ if (!currentPath || token.inlineBody === undefined) continue;
405
+ ensure(currentPath).push(`+${token.inlineBody}`);
406
+ continue;
397
407
  case "blank":
398
408
  if (!currentPath) continue;
399
409
  ensure(currentPath).push("+");
@@ -4,7 +4,8 @@
4
4
  * Uses brush-core via native bindings for shell execution.
5
5
  */
6
6
  import * as fs from "node:fs/promises";
7
- import { executeShell, type MinimizerOptions, Shell } from "@oh-my-pi/pi-natives";
7
+ import { ExponentialYield } from "@oh-my-pi/pi-agent-core/utils/yield";
8
+ import { executeShell, type MinimizerOptions, Shell, type ShellRunResult } from "@oh-my-pi/pi-natives";
8
9
  import { Settings, type ShellMinimizerSettings } from "../config/settings";
9
10
  import { OutputSink } from "../session/streaming-output";
10
11
  import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
@@ -196,7 +197,10 @@ export async function executeBash(command: string, options?: BashExecutorOptions
196
197
  },
197
198
  );
198
199
 
199
- const winner = await Promise.race([
200
+ const ey = new ExponentialYield();
201
+ const winner = await ey.race<
202
+ { kind: "result"; result: ShellRunResult } | { kind: "timeout" } | { kind: "abort" }
203
+ >([
200
204
  runPromise.then(result => ({ kind: "result" as const, result })),
201
205
  timeoutDeferred.promise.then(kind => ({ kind })),
202
206
  abortDeferred.promise.then(kind => ({ kind })),
@@ -14,6 +14,8 @@
14
14
  import { prompt } from "@oh-my-pi/pi-utils";
15
15
  import type { CustomCommand, CustomCommandAPI } from "../../../../extensibility/custom-commands/types";
16
16
  import type { HookCommandContext } from "../../../../extensibility/hooks/types";
17
+ import reviewCustomRequestTemplate from "../../../../prompts/review-custom-request.md" with { type: "text" };
18
+ import reviewHeadlessRequestTemplate from "../../../../prompts/review-headless-request.md" with { type: "text" };
17
19
  import reviewRequestTemplate from "../../../../prompts/review-request.md" with { type: "text" };
18
20
  import * as git from "../../../../utils/git";
19
21
 
@@ -225,6 +227,14 @@ function buildReviewPrompt(mode: string, stats: DiffStats, rawDiff: string, addi
225
227
  });
226
228
  }
227
229
 
230
+ function buildCustomReviewPrompt(instructions: string): string {
231
+ return prompt.render(reviewCustomRequestTemplate, { instructions });
232
+ }
233
+
234
+ function buildHeadlessReviewPrompt(focus?: string): string {
235
+ return prompt.render(reviewHeadlessRequestTemplate, { focus });
236
+ }
237
+
228
238
  export class ReviewCommand implements CustomCommand {
229
239
  name = "review";
230
240
  description = "Launch interactive code review";
@@ -233,8 +243,7 @@ export class ReviewCommand implements CustomCommand {
233
243
 
234
244
  async execute(args: string[], ctx: HookCommandContext): Promise<string | undefined> {
235
245
  if (!ctx.hasUI) {
236
- const base = "Use the Task tool to run the 'reviewer' agent to review recent code changes.";
237
- return args.length > 0 ? `${base} Focus: ${args.join(" ")}` : base;
246
+ return buildHeadlessReviewPrompt(args.length > 0 ? args.join(" ") : undefined);
238
247
  }
239
248
 
240
249
  // Inline args act as additional instructions appended to the generated prompt.
@@ -379,7 +388,12 @@ export class ReviewCommand implements CustomCommand {
379
388
 
380
389
  case 4: {
381
390
  // Custom instructions - still uses the old approach since user provides context
382
- const instructions = await ctx.ui.editor("Enter custom review instructions", "Review the following:\n\n");
391
+ const instructions = await ctx.ui.editor(
392
+ "Enter custom review instructions",
393
+ "Review the following:\n\n",
394
+ undefined,
395
+ { promptStyle: true },
396
+ );
383
397
  if (!instructions?.trim()) return undefined;
384
398
 
385
399
  // For custom, we still try to get current diff for context
@@ -402,17 +416,7 @@ export class ReviewCommand implements CustomCommand {
402
416
  );
403
417
  }
404
418
 
405
- // No diff available, just pass instructions
406
- return `## Code Review Request
407
-
408
- ### Mode
409
- Custom review instructions
410
-
411
- ### Instructions
412
-
413
- ${instructions}
414
-
415
- Use the Task tool with \`agent: "reviewer"\` to execute this review.`;
419
+ return buildCustomReviewPrompt(instructions);
416
420
  }
417
421
 
418
422
  default:
@@ -4,7 +4,13 @@
4
4
  * Custom tools are TypeScript modules that define additional tools for the agent.
5
5
  * They can provide custom rendering for tool calls and results in the TUI.
6
6
  */
7
- import type { AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
7
+ import type {
8
+ AgentToolResult,
9
+ AgentToolUpdateCallback,
10
+ ToolApproval,
11
+ ToolApprovalDecision,
12
+ ToolTier,
13
+ } from "@oh-my-pi/pi-agent-core";
8
14
  import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
9
15
  import type { Model, Static, TSchema } from "@oh-my-pi/pi-ai";
10
16
  import type { Component } from "@oh-my-pi/pi-tui";
@@ -23,7 +29,7 @@ export type CustomToolUIContext = HookUIContext;
23
29
  // Re-export for backward compatibility
24
30
  export type { ExecOptions, ExecResult } from "../../exec/exec";
25
31
  /** Re-export for custom tools to use in execute signature */
26
- export type { AgentToolResult, AgentToolUpdateCallback };
32
+ export type { AgentToolResult, AgentToolUpdateCallback, ToolApproval, ToolApprovalDecision, ToolTier };
27
33
 
28
34
  /** Pending action entry consumed by the hidden resolve tool */
29
35
  export interface CustomToolPendingAction {
@@ -80,6 +86,8 @@ export interface CustomToolContext {
80
86
  abort(): void;
81
87
  /** Settings instance for the current session. Prefer over the global singleton. */
82
88
  settings?: Settings;
89
+ /** Whether to auto-approve all destructive tool operations (--auto-approve CLI flag) */
90
+ autoApprove?: boolean;
83
91
  }
84
92
 
85
93
  /** Session event passed to onSession callback */
@@ -191,6 +199,12 @@ export interface CustomTool<TParams extends TSchema = TSchema, TDetails = any> {
191
199
  mcpServerName?: string;
192
200
  /** Original MCP tool name for discovery/search metadata. */
193
201
  mcpToolName?: string;
202
+
203
+ /** Capability tier declaration used by approval gates. Omitted means "exec". */
204
+ approval?: ToolApproval;
205
+
206
+ /** Lines appended after the standard approval prompt header. */
207
+ formatApprovalDetails?: (args: unknown) => string | string[] | undefined;
194
208
  /**
195
209
  * Execute the tool.
196
210
  * @param toolCallId - Unique ID for this tool call
@@ -3,7 +3,9 @@
3
3
  */
4
4
  import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
5
  import type { ImageContent, Static, TextContent, TSchema } from "@oh-my-pi/pi-ai";
6
+ import type { Settings } from "../../config/settings";
6
7
  import type { Theme } from "../../modes/theme/theme";
8
+ import { type ApprovalMode, formatApprovalPrompt, requiresApproval } from "../../tools/approval";
7
9
  import { applyToolProxy } from "../tool-proxy";
8
10
  import type { ExtensionRunner } from "./runner";
9
11
  import type { RegisteredTool, ToolCallEventResult } from "./types";
@@ -108,7 +110,40 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
108
110
  onUpdate?: AgentToolUpdateCallback<TDetails, TParameters>,
109
111
  context?: AgentToolContext,
110
112
  ) {
111
- // Emit tool_call event - extensions can block execution
113
+ // 1. Check approval policy (before extension handlers).
114
+ // CLI `--auto-approve` / `--yolo` forces yolo mode for the session, but
115
+ // tool-level safety overrides still prompt. User `tools.approval.<tool>`
116
+ // policies are honored in every mode.
117
+ const cliAutoApprove = context?.autoApprove === true;
118
+ const settings: Settings | undefined = context?.settings;
119
+ const configuredMode = (settings?.get("tools.approvalMode") ?? "yolo") as ApprovalMode;
120
+ const approvalMode: ApprovalMode = cliAutoApprove ? "yolo" : configuredMode;
121
+ const userPolicies = (settings?.get("tools.approval") ?? {}) as Record<string, unknown>;
122
+ const approvalCheck = requiresApproval(this.tool, params, approvalMode, userPolicies);
123
+
124
+ if (approvalCheck.required) {
125
+ // Check if UI is available
126
+ if (!this.runner.hasUI()) {
127
+ throw new Error(
128
+ `Tool "${this.tool.name}" requires approval but no interactive UI available.\n` +
129
+ `Options:\n` +
130
+ ` 1. Set tools.approvalMode: yolo in /settings\n` +
131
+ ` 2. Add tools.approval.${this.tool.name}: allow to config\n` +
132
+ ` 3. Use an interactive UI to approve the tool call`,
133
+ );
134
+ }
135
+
136
+ const uiContext = this.runner.getUIContext();
137
+ const choice = await uiContext.select(formatApprovalPrompt(this.tool, params, approvalCheck.reason), [
138
+ "Approve",
139
+ "Deny",
140
+ ]);
141
+ if (choice !== "Approve") {
142
+ throw new Error(`Tool call denied by user: ${this.tool.name}`);
143
+ }
144
+ }
145
+
146
+ // 2. Emit tool_call event - extensions can block execution
112
147
  if (this.runner.hasHandlers("tool_call")) {
113
148
  try {
114
149
  const callResult = (await this.runner.emitToolCall({
@@ -139,9 +139,16 @@ export interface HookUIContext {
139
139
  * Supports Ctrl+G to open external editor ($VISUAL or $EDITOR).
140
140
  * @param title - Title describing what is being edited
141
141
  * @param prefill - Optional initial text
142
+ * @param options - Optional dialog controls such as an abort signal
143
+ * @param editorOptions - Optional editor behavior; `promptStyle` makes Enter submit and Shift+Enter insert a newline
142
144
  * @returns Edited text, or undefined if cancelled (Escape)
143
145
  */
144
- editor(title: string, prefill?: string, options?: { signal?: AbortSignal }): Promise<string | undefined>;
146
+ editor(
147
+ title: string,
148
+ prefill?: string,
149
+ options?: { signal?: AbortSignal },
150
+ editorOptions?: { promptStyle?: boolean },
151
+ ): Promise<string | undefined>;
145
152
 
146
153
  /**
147
154
  * Get the current theme for styling text with ANSI codes.
@@ -264,6 +264,44 @@ function countMatchingSingleStructuralSuffixBoundary(
264
264
  return shouldDropSingleStructuralBoundary(replacement, replacement.slice(0, -1), expectedBalance) ? 1 : 0;
265
265
  }
266
266
 
267
+ /**
268
+ * Single-line non-structural boundary duplicate detector for replacement
269
+ * groups. Mirrors the same boundary check the pure-insert absorber uses for
270
+ * `ANCHOR↓` (leading) / `ANCHOR↑` (trailing) inserts, but applied to the
271
+ * top/bottom edges of an `A-B:payload` range. Catches mistakes like
272
+ * `103-138:const X = …` where line 102 already reads `const X = …` and the
273
+ * user really meant `103-138!` (delete only).
274
+ *
275
+ * Gated by `options.autoDropPureInsertDuplicates`: the existing 2+-line block
276
+ * absorb already runs unconditionally, and the structural single-line
277
+ * absorber is balance-validated; a non-structural single-line duplicate is
278
+ * ambiguous (could be an intentional `2:foo` over a line that happens to
279
+ * sit next to another `foo`), so we only fire when the user has opted in.
280
+ */
281
+ function countMatchingSingleNonStructuralPrefixDuplicate(
282
+ fileLines: string[],
283
+ startLine: number,
284
+ replacement: string[],
285
+ ): number {
286
+ if (replacement.length === 0 || startLine <= 1) return 0;
287
+ const line = replacement[0];
288
+ if (isStructuralClosingBoundaryLine(line)) return 0;
289
+ if (fileLines[startLine - 2] !== line) return 0;
290
+ return 1;
291
+ }
292
+
293
+ function countMatchingSingleNonStructuralSuffixDuplicate(
294
+ fileLines: string[],
295
+ endLine: number,
296
+ replacement: string[],
297
+ ): number {
298
+ if (replacement.length === 0 || endLine >= fileLines.length) return 0;
299
+ const line = replacement[replacement.length - 1];
300
+ if (isStructuralClosingBoundaryLine(line)) return 0;
301
+ if (fileLines[endLine] !== line) return 0;
302
+ return 1;
303
+ }
304
+
267
305
  function hasExternalTargets(lines: Iterable<number>, externalTargetLines: Set<number>): boolean {
268
306
  for (const line of lines) {
269
307
  if (externalTargetLines.has(line)) return true;
@@ -552,12 +590,19 @@ function absorbReplacementBoundaryDuplicates(
552
590
  const deletedBalance = computeDelimiterBalance(
553
591
  group.deletes.map(deleteEdit => fileLines[deleteEdit.anchor.line - 1] ?? ""),
554
592
  );
593
+ const optInSingleLineAbsorb = options.autoDropPureInsertDuplicates === true;
555
594
  const prefixCount =
556
595
  countMatchingPrefixBlock(fileLines, startLine, group.replacement) ||
557
- countMatchingSingleStructuralPrefixBoundary(fileLines, startLine, group.replacement, deletedBalance);
596
+ countMatchingSingleStructuralPrefixBoundary(fileLines, startLine, group.replacement, deletedBalance) ||
597
+ (optInSingleLineAbsorb
598
+ ? countMatchingSingleNonStructuralPrefixDuplicate(fileLines, startLine, group.replacement)
599
+ : 0);
558
600
  const suffixCount =
559
601
  countMatchingSuffixBlock(fileLines, endLine, group.replacement) ||
560
- countMatchingSingleStructuralSuffixBoundary(fileLines, endLine, group.replacement, deletedBalance);
602
+ countMatchingSingleStructuralSuffixBoundary(fileLines, endLine, group.replacement, deletedBalance) ||
603
+ (optInSingleLineAbsorb
604
+ ? countMatchingSingleNonStructuralSuffixDuplicate(fileLines, endLine, group.replacement)
605
+ : 0);
561
606
  const prefixLines = contiguousRange(startLine - prefixCount, prefixCount);
562
607
  const suffixLines = contiguousRange(endLine + 1, suffixCount);
563
608
  const safePrefixCount = hasExternalTargets(prefixLines, allTargetLines) ? 0 : prefixCount;