@oh-my-pi/pi-coding-agent 16.2.13 → 16.3.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 (166) hide show
  1. package/CHANGELOG.md +108 -7
  2. package/dist/cli.js +6033 -5982
  3. package/dist/types/advisor/config.d.ts +4 -2
  4. package/dist/types/collab/host.d.ts +16 -0
  5. package/dist/types/collab/protocol.d.ts +9 -3
  6. package/dist/types/commit/model-selection.d.ts +5 -0
  7. package/dist/types/config/model-resolver.d.ts +12 -10
  8. package/dist/types/config/settings-schema.d.ts +28 -5
  9. package/dist/types/edit/modes/patch.d.ts +11 -0
  10. package/dist/types/eval/agent-bridge.d.ts +5 -1
  11. package/dist/types/exec/bash-executor.d.ts +1 -0
  12. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +86 -2
  13. package/dist/types/extensibility/skills.d.ts +2 -1
  14. package/dist/types/mnemopi/state.d.ts +12 -0
  15. package/dist/types/modes/components/agent-hub.d.ts +9 -5
  16. package/dist/types/modes/components/status-line/component.test.d.ts +1 -0
  17. package/dist/types/modes/components/usage-row.d.ts +1 -1
  18. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +6 -0
  20. package/dist/types/modes/controllers/streaming-reveal.d.ts +17 -1
  21. package/dist/types/modes/controllers/tool-args-reveal.d.ts +30 -3
  22. package/dist/types/modes/interactive-mode.d.ts +12 -7
  23. package/dist/types/modes/rpc/rpc-client.d.ts +5 -0
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +64 -1
  25. package/dist/types/modes/session-teardown.d.ts +63 -0
  26. package/dist/types/modes/session-teardown.test.d.ts +1 -0
  27. package/dist/types/modes/theme/theme.d.ts +9 -4
  28. package/dist/types/modes/types.d.ts +2 -3
  29. package/dist/types/modes/utils/copy-targets.d.ts +2 -0
  30. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +1 -0
  31. package/dist/types/sdk.d.ts +9 -0
  32. package/dist/types/session/agent-session.d.ts +46 -13
  33. package/dist/types/session/exit-diagnostics.d.ts +48 -0
  34. package/dist/types/session/session-loader.d.ts +5 -0
  35. package/dist/types/task/executor.d.ts +12 -5
  36. package/dist/types/task/parallel.d.ts +1 -0
  37. package/dist/types/task/render.test.d.ts +1 -0
  38. package/dist/types/thinking.d.ts +2 -0
  39. package/dist/types/tools/checkpoint.d.ts +8 -0
  40. package/dist/types/tools/eval-render.d.ts +1 -0
  41. package/dist/types/tools/fetch.d.ts +11 -1
  42. package/dist/types/tools/index.d.ts +3 -1
  43. package/dist/types/tools/irc.d.ts +1 -0
  44. package/dist/types/tools/output-meta.d.ts +7 -0
  45. package/dist/types/tools/output-schema-validator.d.ts +7 -4
  46. package/dist/types/tools/path-utils.d.ts +16 -0
  47. package/dist/types/tools/render-utils.d.ts +4 -1
  48. package/dist/types/utils/git.d.ts +47 -2
  49. package/dist/types/web/search/provider.d.ts +7 -0
  50. package/dist/types/web/search/types.d.ts +1 -1
  51. package/package.json +12 -12
  52. package/src/advisor/config.ts +4 -2
  53. package/src/cli/bench-cli.ts +7 -2
  54. package/src/collab/guest.ts +94 -5
  55. package/src/collab/host.ts +80 -3
  56. package/src/collab/protocol.ts +9 -2
  57. package/src/commit/model-selection.ts +8 -2
  58. package/src/config/keybindings.ts +3 -1
  59. package/src/config/model-discovery.ts +66 -2
  60. package/src/config/model-registry.ts +7 -3
  61. package/src/config/model-resolver.ts +51 -23
  62. package/src/config/settings-schema.ts +44 -14
  63. package/src/edit/hashline/diff.ts +13 -2
  64. package/src/edit/index.ts +58 -8
  65. package/src/edit/modes/patch.ts +53 -18
  66. package/src/edit/streaming.ts +7 -6
  67. package/src/eval/__tests__/agent-bridge.test.ts +57 -1
  68. package/src/eval/agent-bridge.ts +15 -5
  69. package/src/exec/bash-executor.ts +7 -12
  70. package/src/extensibility/legacy-pi-coding-agent-shim.ts +534 -16
  71. package/src/extensibility/skills.ts +29 -6
  72. package/src/goals/guided-setup.ts +4 -3
  73. package/src/internal-urls/docs-index.generated.txt +1 -1
  74. package/src/lsp/client.ts +11 -14
  75. package/src/main.ts +38 -10
  76. package/src/mnemopi/state.ts +43 -7
  77. package/src/modes/acp/acp-agent.ts +1 -1
  78. package/src/modes/components/agent-hub.ts +10 -2
  79. package/src/modes/components/agent-transcript-viewer.ts +39 -7
  80. package/src/modes/components/assistant-message.ts +16 -10
  81. package/src/modes/components/chat-transcript-builder.ts +11 -1
  82. package/src/modes/components/custom-editor.test.ts +20 -0
  83. package/src/modes/components/hook-selector.ts +44 -25
  84. package/src/modes/components/model-selector.ts +1 -1
  85. package/src/modes/components/status-line/component.test.ts +44 -0
  86. package/src/modes/components/status-line/component.ts +9 -1
  87. package/src/modes/components/status-line/segments.ts +3 -3
  88. package/src/modes/components/usage-row.ts +16 -2
  89. package/src/modes/controllers/command-controller.ts +6 -21
  90. package/src/modes/controllers/event-controller.ts +11 -9
  91. package/src/modes/controllers/extension-ui-controller.ts +84 -3
  92. package/src/modes/controllers/input-controller.ts +16 -13
  93. package/src/modes/controllers/selector-controller.ts +3 -8
  94. package/src/modes/controllers/streaming-reveal.ts +75 -53
  95. package/src/modes/controllers/tool-args-reveal.ts +340 -10
  96. package/src/modes/interactive-mode.ts +122 -79
  97. package/src/modes/rpc/rpc-client.ts +21 -0
  98. package/src/modes/rpc/rpc-mode.ts +197 -46
  99. package/src/modes/session-teardown.test.ts +219 -0
  100. package/src/modes/session-teardown.ts +82 -0
  101. package/src/modes/setup-wizard/scenes/theme.ts +2 -2
  102. package/src/modes/skill-command.ts +7 -20
  103. package/src/modes/theme/theme.ts +29 -15
  104. package/src/modes/types.ts +2 -3
  105. package/src/modes/utils/copy-targets.ts +12 -0
  106. package/src/modes/utils/ui-helpers.ts +19 -2
  107. package/src/plan-mode/approved-plan-prompt.test.ts +36 -0
  108. package/src/prompts/advisor/system.md +1 -1
  109. package/src/prompts/agents/tester.md +6 -2
  110. package/src/prompts/skills/autoload.md +8 -0
  111. package/src/prompts/skills/user-invocation.md +11 -0
  112. package/src/prompts/steering/parent-irc.md +5 -0
  113. package/src/prompts/system/irc-incoming.md +2 -0
  114. package/src/prompts/system/mid-run-todo-nudge.md +3 -0
  115. package/src/prompts/system/plan-mode-approved.md +7 -10
  116. package/src/prompts/system/plan-mode-compact-instructions.md +2 -1
  117. package/src/prompts/system/plan-mode-reference.md +3 -4
  118. package/src/prompts/system/rewind-report.md +6 -0
  119. package/src/prompts/system/subagent-system-prompt.md +3 -0
  120. package/src/prompts/tools/irc.md +2 -1
  121. package/src/prompts/tools/job.md +2 -2
  122. package/src/prompts/tools/rewind.md +3 -2
  123. package/src/prompts/tools/task.md +4 -0
  124. package/src/sdk.ts +18 -4
  125. package/src/session/agent-session.ts +660 -114
  126. package/src/session/exit-diagnostics.ts +202 -0
  127. package/src/session/session-context.ts +25 -13
  128. package/src/session/session-loader.ts +58 -25
  129. package/src/session/session-manager.ts +0 -1
  130. package/src/session/session-persistence.ts +30 -4
  131. package/src/session/settings-stream-fn.ts +14 -0
  132. package/src/slash-commands/builtin-registry.ts +35 -3
  133. package/src/system-prompt.ts +15 -1
  134. package/src/task/executor.ts +31 -8
  135. package/src/task/index.ts +31 -9
  136. package/src/task/isolation-runner.ts +18 -2
  137. package/src/task/parallel.ts +7 -2
  138. package/src/task/render.test.ts +121 -0
  139. package/src/task/render.ts +48 -2
  140. package/src/task/worktree.ts +12 -13
  141. package/src/task/yield-assembly.ts +8 -5
  142. package/src/thinking.ts +5 -0
  143. package/src/tools/ask.ts +188 -9
  144. package/src/tools/ast-edit.ts +7 -0
  145. package/src/tools/ast-grep.ts +11 -0
  146. package/src/tools/bash.ts +6 -30
  147. package/src/tools/checkpoint.ts +15 -1
  148. package/src/tools/eval-render.ts +15 -0
  149. package/src/tools/fetch.ts +82 -18
  150. package/src/tools/gh.ts +1 -1
  151. package/src/tools/grep.ts +45 -27
  152. package/src/tools/index.ts +3 -1
  153. package/src/tools/irc.ts +24 -9
  154. package/src/tools/output-meta.ts +50 -0
  155. package/src/tools/output-schema-validator.ts +152 -15
  156. package/src/tools/path-utils.ts +55 -10
  157. package/src/tools/read.ts +1 -1
  158. package/src/tools/render-utils.ts +5 -3
  159. package/src/tools/yield.ts +41 -1
  160. package/src/utils/commit-message-generator.ts +2 -2
  161. package/src/utils/git.ts +271 -29
  162. package/src/utils/thinking-display.ts +13 -0
  163. package/src/web/search/index.ts +9 -28
  164. package/src/web/search/provider.ts +26 -1
  165. package/src/web/search/providers/duckduckgo.ts +1 -1
  166. package/src/web/search/types.ts +5 -1
@@ -233,7 +233,7 @@ interface StringDef {
233
233
 
234
234
  interface NumberDef {
235
235
  type: "number";
236
- default: number;
236
+ default: number | undefined;
237
237
  ui?: UiNumber;
238
238
  }
239
239
 
@@ -1388,6 +1388,18 @@ export const SETTINGS_SCHEMA = {
1388
1388
  },
1389
1389
  },
1390
1390
 
1391
+ "providers.anthropic.serverSideFallback": {
1392
+ type: "boolean",
1393
+ default: false,
1394
+ ui: {
1395
+ tab: "model",
1396
+ group: "Retry & Fallback",
1397
+ label: "Anthropic Server-Side Fallback (Fable 5)",
1398
+ description:
1399
+ "When a Claude Fable 5 / Mythos 5 request is blocked by Anthropic's safety classifier, retry it on Claude Opus 4.8 server-side (Anthropic `server-side-fallback-2026-06-01` beta). Opt-in — leaving this off preserves the pre-fallback behavior for every request.",
1400
+ },
1401
+ },
1402
+
1391
1403
  // ────────────────────────────────────────────────────────────────────────
1392
1404
  // Interaction
1393
1405
  // ────────────────────────────────────────────────────────────────────────
@@ -1997,7 +2009,11 @@ export const SETTINGS_SCHEMA = {
1997
2009
  },
1998
2010
  },
1999
2011
 
2000
- "compaction.reserveTokens": { type: "number", default: 16384 },
2012
+ // No default: an unset reserve tells the compaction layer the user never
2013
+ // chose one, so small-window recovery may swap in the proportional reserve
2014
+ // (see resolveBudgetReserveTokens). A materialized 16384 here would make
2015
+ // every session look explicitly configured.
2016
+ "compaction.reserveTokens": { type: "number", default: undefined },
2001
2017
 
2002
2018
  "compaction.keepRecentTokens": { type: "number", default: 20000 },
2003
2019
 
@@ -4129,7 +4145,7 @@ export const SETTINGS_SCHEMA = {
4129
4145
  group: "Subagents",
4130
4146
  label: "Soft Subagent Request Budget",
4131
4147
  description:
4132
- "Soft per-subagent request budget (assistant requests per run). Crossing it injects one steering notice asking the subagent to wrap up; at 1.5x the budget the run is aborted gracefully, salvaging partial output. 0 disables the guard. Bundled explore/sonic agents use a lower built-in budget.",
4148
+ "Soft per-subagent request budget (assistant requests per run). Crossing it can inject a steering notice when task.softRequestBudgetNotice is enabled; at 1.5x the budget the run is aborted gracefully, salvaging partial output. 0 disables the guard. Bundled explore/sonic agents use a lower built-in budget.",
4133
4149
  options: [
4134
4150
  { value: "0", label: "Disabled" },
4135
4151
  { value: "40", label: "40 requests" },
@@ -4139,6 +4155,18 @@ export const SETTINGS_SCHEMA = {
4139
4155
  },
4140
4156
  },
4141
4157
 
4158
+ "task.softRequestBudgetNotice": {
4159
+ type: "boolean",
4160
+ default: false,
4161
+ ui: {
4162
+ tab: "tasks",
4163
+ group: "Subagents",
4164
+ label: "Soft Request Budget Notice",
4165
+ description:
4166
+ "Inject one steering notice when a subagent crosses its soft request budget. Off by default; enabling it asks the child to wrap up before the 1.5x graceful abort guard.",
4167
+ },
4168
+ },
4169
+
4142
4170
  "task.disabledAgents": {
4143
4171
  type: "array",
4144
4172
  default: [] as string[],
@@ -4930,17 +4958,19 @@ export type SettingValue<P extends SettingPath> = Schema[P] extends { type: "boo
4930
4958
  ? boolean
4931
4959
  : Schema[P] extends { type: "string" }
4932
4960
  ? string | undefined
4933
- : Schema[P] extends { type: "number" }
4934
- ? number
4935
- : Schema[P] extends { type: "enum"; values: infer V }
4936
- ? V extends readonly string[]
4937
- ? V[number]
4938
- : never
4939
- : Schema[P] extends { type: "array"; default: infer D }
4940
- ? D
4941
- : Schema[P] extends { type: "record"; default: infer D }
4961
+ : Schema[P] extends { type: "number"; default: undefined }
4962
+ ? number | undefined
4963
+ : Schema[P] extends { type: "number" }
4964
+ ? number
4965
+ : Schema[P] extends { type: "enum"; values: infer V }
4966
+ ? V extends readonly string[]
4967
+ ? V[number]
4968
+ : never
4969
+ : Schema[P] extends { type: "array"; default: infer D }
4942
4970
  ? D
4943
- : never;
4971
+ : Schema[P] extends { type: "record"; default: infer D }
4972
+ ? D
4973
+ : never;
4944
4974
 
4945
4975
  /** Get the default value for a setting path */
4946
4976
  export function getDefault<P extends SettingPath>(path: P): SettingValue<P> {
@@ -5002,7 +5032,7 @@ export interface CompactionSettings {
5002
5032
  strategy: "context-full" | "handoff" | "shake" | "snapcompact" | "off";
5003
5033
  thresholdPercent: number;
5004
5034
  thresholdTokens: number;
5005
- reserveTokens: number;
5035
+ reserveTokens: number | undefined;
5006
5036
  keepRecentTokens: number;
5007
5037
  midTurnEnabled: boolean;
5008
5038
  handoffSaveToDisk: boolean;
@@ -180,7 +180,8 @@ function resolvePreviewEdits(args: {
180
180
  }): readonly Edit[] {
181
181
  const { section, absolutePath, normalized, snapshots, expected, liveMatches, edits } = args;
182
182
  if (!hasBlockEdit(edits)) return edits;
183
- const baseText = expected === undefined || liveMatches ? normalized : snapshots.byHash(absolutePath, expected)?.text;
183
+ const baseText =
184
+ expected === undefined || liveMatches ? normalized : snapshots.byHashExact(absolutePath, expected)?.text;
184
185
  if (baseText === undefined) {
185
186
  throw createMismatchError(section, absolutePath, normalized, snapshots, expected ?? "");
186
187
  }
@@ -199,7 +200,17 @@ function applyPreviewEdits(args: {
199
200
  if (!options.skipHashValidation && expected === undefined) {
200
201
  throw new Error(missingSnapshotTagMessage(section.path));
201
202
  }
202
- const liveMatches = expected !== undefined && computeFileHash(normalized) === expected;
203
+ // A 16-bit tag can collide across two different file states, so hash
204
+ // equality alone does not prove the live text IS the snapshot the model's
205
+ // anchors were minted against (mirrors Patcher's apply-time guard). When
206
+ // the store retains text for the tag, require it to be unambiguous and
207
+ // byte-identical to live; otherwise fall through to recovery/reject below
208
+ // exactly as if the hash had not matched.
209
+ const liveMatches =
210
+ expected !== undefined &&
211
+ computeFileHash(normalized) === expected &&
212
+ (snapshots.byHash(absolutePath, expected) === null ||
213
+ snapshots.byHashExact(absolutePath, expected)?.text === normalized);
203
214
  const edits = parsePreviewEdits(section, options.streaming);
204
215
  const resolved = resolvePreviewEdits({ section, absolutePath, normalized, snapshots, expected, liveMatches, edits });
205
216
  if (options.skipHashValidation || expected === undefined || liveMatches) return applyEdits(normalized, resolved);
package/src/edit/index.ts CHANGED
@@ -7,6 +7,7 @@ import { prompt } from "@oh-my-pi/pi-utils";
7
7
  import {
8
8
  createLspWritethrough,
9
9
  type FileDiagnosticsResult,
10
+ flushLspWritethroughBatch,
10
11
  type WritethroughCallback,
11
12
  type WritethroughDeferredHandle,
12
13
  writethroughNoop,
@@ -129,6 +130,8 @@ async function executeApplyPatchPerFile(
129
130
  run: (batchRequest: LspBatchRequest | undefined) => Promise<AgentToolResult<EditToolDetails>>;
130
131
  }[],
131
132
  outerBatchRequest: LspBatchRequest | undefined,
133
+ cwd: string,
134
+ signal: AbortSignal | undefined,
132
135
  onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
133
136
  ): Promise<AgentToolResult<EditToolDetails, TInput>> {
134
137
  if (fileEntries.length === 1) {
@@ -138,10 +141,15 @@ async function executeApplyPatchPerFile(
138
141
 
139
142
  const perFileResults: EditToolPerFileResult[] = [];
140
143
  const contentTexts: string[] = [];
144
+ let hasError = false;
141
145
 
142
146
  for (let i = 0; i < fileEntries.length; i++) {
143
147
  const { path, run } = fileEntries[i];
144
148
  const isLast = i === fileEntries.length - 1;
149
+ // Per-file writes join the outer LSP write batch; only the last entry
150
+ // flushes it, so cross-file writes coalesce into a single
151
+ // format+diagnostics pass. The failure path below flushes explicitly
152
+ // when the loop stops early.
145
153
  const batchRequest: LspBatchRequest | undefined = outerBatchRequest
146
154
  ? { id: outerBatchRequest.id, flush: isLast && outerBatchRequest.flush }
147
155
  : undefined;
@@ -169,6 +177,36 @@ async function executeApplyPatchPerFile(
169
177
  const displayErrorText = err instanceof HashlineMismatchError ? err.displayMessage : undefined;
170
178
  perFileResults.push({ path, diff: "", isError: true, errorText, displayErrorText });
171
179
  contentTexts.push(`Error editing ${path}: ${errorText}`);
180
+ hasError = true;
181
+ // Later entries were authored assuming this file's post-state; a
182
+ // partial cascade after failure typically compounds damage. Stop
183
+ // here, report applied vs. skipped, and let the caller re-issue
184
+ // only the failed and unapplied files. Matches
185
+ // `executeSinglePathEntries` semantics.
186
+ if (i > 0) {
187
+ const appliedPaths = fileEntries
188
+ .slice(0, i)
189
+ .map(e => e.path)
190
+ .join(", ");
191
+ contentTexts.push(`Files already applied: ${appliedPaths}.`);
192
+ }
193
+ if (i + 1 < fileEntries.length) {
194
+ const skippedPaths = fileEntries
195
+ .slice(i + 1)
196
+ .map(e => e.path)
197
+ .join(", ");
198
+ contentTexts.push(
199
+ `Files NOT applied: ${skippedPaths}; re-read the affected files and re-issue only the failed and unapplied files.`,
200
+ );
201
+ }
202
+ // Stopping early skips the last-entry flush above; finalize the
203
+ // already-written files so an intervening failure cannot leave them
204
+ // sitting in an unfinalized LSP write batch (mirrors the delete-path
205
+ // flush in executePatchSingle).
206
+ if (outerBatchRequest?.flush) {
207
+ await flushLspWritethroughBatch(outerBatchRequest.id, cwd, signal);
208
+ }
209
+ break;
172
210
  }
173
211
 
174
212
  // Emit partial result after each file so UI shows progressive completion
@@ -197,6 +235,10 @@ async function executeApplyPatchPerFile(
197
235
  firstChangedLine: perFileResults.find(r => r.firstChangedLine)?.firstChangedLine,
198
236
  perFileResults,
199
237
  }),
238
+ // Any per-file failure marks the aggregate result as an error so the
239
+ // agent loop and renderer take the error branch instead of treating
240
+ // a mixed partial application as a successful edit.
241
+ ...(hasError ? { isError: true } : {}),
200
242
  };
201
243
  }
202
244
 
@@ -204,7 +246,9 @@ async function executeSinglePathEntries(
204
246
  path: string,
205
247
  runs: ((batchRequest: LspBatchRequest | undefined) => Promise<AgentToolResult<EditToolDetails>>)[],
206
248
  outerBatchRequest: LspBatchRequest | undefined,
207
- onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
249
+ onUpdate: ((partialResult: AgentToolResult<EditToolDetails, TInput>) => void) | undefined,
250
+ cwd: string,
251
+ signal: AbortSignal | undefined,
208
252
  ): Promise<AgentToolResult<EditToolDetails, TInput>> {
209
253
  if (runs.length === 1) {
210
254
  return runs[0](outerBatchRequest);
@@ -213,7 +257,7 @@ async function executeSinglePathEntries(
213
257
  const contentTexts: string[] = [];
214
258
  const diffTexts: string[] = [];
215
259
  let firstChangedLine: number | undefined;
216
- let errorCount = 0;
260
+ let hasError = false;
217
261
  let metadataPath: string | undefined;
218
262
  let hasFirstOldText = false;
219
263
  let firstOldText: string | undefined;
@@ -264,10 +308,13 @@ async function executeSinglePathEntries(
264
308
  `; re-read the file and re-issue only the failed and unapplied entries.`,
265
309
  );
266
310
  }
267
- errorCount++;
311
+ hasError = true;
268
312
  // Stop at the first failure: later entries were authored against
269
313
  // line numbers/content that assumed this entry succeeded, and
270
314
  // applying them after a failure compounds the damage.
315
+ if (outerBatchRequest?.flush) {
316
+ await flushLspWritethroughBatch(outerBatchRequest.id, cwd, signal);
317
+ }
271
318
  break;
272
319
  }
273
320
 
@@ -278,7 +325,7 @@ async function executeSinglePathEntries(
278
325
  diff: diffTexts.join("\n"),
279
326
  firstChangedLine,
280
327
  },
281
- ...(errorCount > 0 ? { isError: true } : {}),
328
+ ...(hasError ? { isError: true } : {}),
282
329
  });
283
330
  }
284
331
  }
@@ -300,7 +347,7 @@ async function executeSinglePathEntries(
300
347
  // renderer takes the error branch instead of falling through to the
301
348
  // streaming-edit preview (which displays the *proposed* diff and looks
302
349
  // indistinguishable from success).
303
- ...(errorCount > 0 ? { isError: true } : {}),
350
+ ...(hasError ? { isError: true } : {}),
304
351
  };
305
352
  }
306
353
 
@@ -498,11 +545,14 @@ export class EditTool implements AgentTool<TInput> {
498
545
  batchRequest: br,
499
546
  allowFuzzy: tool.#allowFuzzy,
500
547
  fuzzyThreshold: tool.#fuzzyThreshold,
548
+ // The JSON grammar has no `*** Update File`; its `op: "create"`
549
+ // doubles as the documented full-file overwrite (patch.md <avoid>).
550
+ allowCreateOverwrite: true,
501
551
  writethrough: tool.#writethrough,
502
552
  beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
503
553
  }),
504
554
  );
505
- return executeSinglePathEntries(path, runs, batchRequest, onUpdate);
555
+ return executeSinglePathEntries(path, runs, batchRequest, onUpdate, tool.session.cwd, signal);
506
556
  },
507
557
  },
508
558
  apply_patch: {
@@ -542,7 +592,7 @@ export class EditTool implements AgentTool<TInput> {
542
592
  }),
543
593
  };
544
594
  });
545
- return executeApplyPatchPerFile(perFile, batchRequest, onUpdate);
595
+ return executeApplyPatchPerFile(perFile, batchRequest, tool.session.cwd, signal, onUpdate);
546
596
  },
547
597
  },
548
598
  hashline: {
@@ -591,7 +641,7 @@ export class EditTool implements AgentTool<TInput> {
591
641
  beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
592
642
  }),
593
643
  );
594
- return executeSinglePathEntries(path, runs, batchRequest, onUpdate);
644
+ return executeSinglePathEntries(path, runs, batchRequest, onUpdate, tool.session.cwd, signal);
595
645
  },
596
646
  },
597
647
  }[this.mode];
@@ -96,6 +96,14 @@ export interface ApplyPatchOptions {
96
96
  fuzzyThreshold?: number;
97
97
  allowFuzzy?: boolean;
98
98
  fs?: FileSystem;
99
+ /**
100
+ * Permit `op: "create"` to replace an existing file (full-file overwrite).
101
+ * The JSON `patch` edit mode sanctions create-as-overwrite for major
102
+ * restructures (see prompts/tools/patch.md); the Codex `apply_patch`
103
+ * envelope documents `*** Add File` as strictly non-overwriting and must
104
+ * leave this unset.
105
+ */
106
+ allowCreateOverwrite?: boolean;
99
107
  }
100
108
 
101
109
  // ═══════════════════════════════════════════════════════════════════════════
@@ -455,7 +463,8 @@ function trimCommonContext(oldLines: string[], newLines: string[]): HunkVariant
455
463
  }
456
464
 
457
465
  function collapseConsecutiveSharedLines(oldLines: string[], newLines: string[]): HunkVariant | undefined {
458
- const shared = new Set(oldLines.filter(line => newLines.includes(line)));
466
+ const newSet = new Set(newLines);
467
+ const shared = new Set(oldLines.filter(line => newSet.has(line)));
459
468
  const collapse = (lines: string[]): string[] => {
460
469
  const out: string[] = [];
461
470
  let i = 0;
@@ -480,31 +489,32 @@ function collapseConsecutiveSharedLines(oldLines: string[], newLines: string[]):
480
489
  }
481
490
 
482
491
  function collapseRepeatedBlocks(oldLines: string[], newLines: string[]): HunkVariant | undefined {
483
- const shared = new Set(oldLines.filter(line => newLines.includes(line)));
492
+ const newSet = new Set(newLines);
493
+ const shared = new Set(oldLines.filter(line => newSet.has(line)));
484
494
  const collapse = (lines: string[]): string[] => {
485
495
  const output = [...lines];
486
496
  let changed = false;
487
497
  let i = 0;
488
498
  while (i < output.length) {
489
499
  let collapsed = false;
490
- for (let size = Math.floor((output.length - i) / 2); size >= 2; size--) {
491
- const first = output.slice(i, i + size);
492
- const second = output.slice(i + size, i + size * 2);
493
- if (first.length !== second.length || first.length === 0) continue;
494
- if (!first.every(line => shared.has(line))) continue;
495
- let same = true;
496
- for (let idx = 0; idx < size; idx++) {
497
- if (first[idx] !== second[idx]) {
498
- same = false;
500
+ // Only blocks whose lines are all shared are collapsible; if the first line
501
+ // is not shared no size can match, so skip the size search entirely.
502
+ if (shared.has(output[i])) {
503
+ for (let size = Math.floor((output.length - i) / 2); size >= 2; size--) {
504
+ let same = true;
505
+ for (let idx = 0; idx < size; idx++) {
506
+ if (output[i + idx] !== output[i + size + idx] || !shared.has(output[i + idx])) {
507
+ same = false;
508
+ break;
509
+ }
510
+ }
511
+ if (same) {
512
+ output.splice(i + size, size);
513
+ changed = true;
514
+ collapsed = true;
499
515
  break;
500
516
  }
501
517
  }
502
- if (same) {
503
- output.splice(i + size, size);
504
- changed = true;
505
- collapsed = true;
506
- break;
507
- }
508
518
  }
509
519
  if (!collapsed) {
510
520
  i++;
@@ -1479,6 +1489,7 @@ async function applyNormalizedPatch(input: PatchInput, options: ApplyPatchOption
1479
1489
  fs = defaultFileSystem,
1480
1490
  fuzzyThreshold = DEFAULT_FUZZY_THRESHOLD,
1481
1491
  allowFuzzy = true,
1492
+ allowCreateOverwrite = false,
1482
1493
  } = options;
1483
1494
 
1484
1495
  const resolvePath = (p: string): string => resolveToCwd(p, cwd);
@@ -1490,6 +1501,14 @@ async function applyNormalizedPatch(input: PatchInput, options: ApplyPatchOption
1490
1501
  if (destPath === absolutePath) {
1491
1502
  throw new ApplyPatchError("rename path is the same as source path");
1492
1503
  }
1504
+ // The `*** Move to` / rename contract is strictly non-overwriting:
1505
+ // reject before the update path reads or writes anything, so both
1506
+ // source and pre-existing destination remain untouched. Callers who
1507
+ // really need to replace the destination must delete it in an
1508
+ // earlier hunk.
1509
+ if (await fs.exists(destPath)) {
1510
+ throw new ApplyPatchError(`Cannot rename ${input.path} to ${input.rename}: destination already exists.`);
1511
+ }
1493
1512
  }
1494
1513
 
1495
1514
  // Handle CREATE operation
@@ -1497,6 +1516,17 @@ async function applyNormalizedPatch(input: PatchInput, options: ApplyPatchOption
1497
1516
  if (!input.diff) {
1498
1517
  throw new ApplyPatchError("Create operation requires diff (file content)");
1499
1518
  }
1519
+ // The `*** Add File` contract of the apply_patch envelope is strictly
1520
+ // non-overwriting: reject before mkdir/write so pre-existing content
1521
+ // stays intact and the caller can re-issue as an explicit
1522
+ // `*** Update File` (or a delete+add pair) if overwrite is genuinely
1523
+ // intended. The JSON `patch` mode opts out via `allowCreateOverwrite`,
1524
+ // where `op: "create"` doubles as a sanctioned full-file overwrite.
1525
+ if (!allowCreateOverwrite && (await fs.exists(absolutePath))) {
1526
+ throw new ApplyPatchError(
1527
+ `Cannot create ${input.path}: file already exists. Use *** Update File to modify it in place.`,
1528
+ );
1529
+ }
1500
1530
  // Strip + prefixes if present (handles diffs formatted as additions)
1501
1531
  const normalizedContent = normalizeCreateContent(input.diff);
1502
1532
  const content = normalizedContent.endsWith("\n") ? normalizedContent : `${normalizedContent}\n`;
@@ -1602,7 +1632,7 @@ export async function previewPatch(input: PatchInput, options: ApplyPatchOptions
1602
1632
  export async function computePatchDiff(
1603
1633
  input: PatchInput,
1604
1634
  cwd: string,
1605
- options?: { fuzzyThreshold?: number; allowFuzzy?: boolean },
1635
+ options?: { fuzzyThreshold?: number; allowFuzzy?: boolean; allowCreateOverwrite?: boolean },
1606
1636
  ): Promise<
1607
1637
  | {
1608
1638
  diff: string;
@@ -1617,6 +1647,7 @@ export async function computePatchDiff(
1617
1647
  cwd,
1618
1648
  fuzzyThreshold: options?.fuzzyThreshold,
1619
1649
  allowFuzzy: options?.allowFuzzy,
1650
+ allowCreateOverwrite: options?.allowCreateOverwrite,
1620
1651
  });
1621
1652
  const oldContent = result.change.oldContent ?? "";
1622
1653
  const newContent = result.change.newContent ?? "";
@@ -1656,6 +1687,8 @@ export interface ExecutePatchSingleOptions {
1656
1687
  batchRequest?: LspBatchRequest;
1657
1688
  allowFuzzy: boolean;
1658
1689
  fuzzyThreshold: number;
1690
+ /** See {@link ApplyPatchOptions.allowCreateOverwrite}; set by the JSON `patch` mode only. */
1691
+ allowCreateOverwrite?: boolean;
1659
1692
  writethrough: WritethroughCallback;
1660
1693
  beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
1661
1694
  }
@@ -1763,6 +1796,7 @@ export async function executePatchSingle(
1763
1796
  batchRequest,
1764
1797
  allowFuzzy,
1765
1798
  fuzzyThreshold,
1799
+ allowCreateOverwrite,
1766
1800
  writethrough,
1767
1801
  beginDeferredDiagnosticsForPath,
1768
1802
  } = options;
@@ -1808,6 +1842,7 @@ export async function executePatchSingle(
1808
1842
  fs: patchFileSystem,
1809
1843
  fuzzyThreshold,
1810
1844
  allowFuzzy,
1845
+ allowCreateOverwrite,
1811
1846
  });
1812
1847
 
1813
1848
  // Post-write verification: only meaningful for in-place updates where the
@@ -207,19 +207,18 @@ function groupApplyPatchEntriesByPath(entries: readonly ApplyPatchEntry[]): Map<
207
207
  * `create` op), otherwise an empty string (grammar-only payloads).
208
208
  */
209
209
  function extractAddedLines(text: string, fallbackToWhole: boolean): string {
210
- let added: string | undefined;
210
+ const added: string[] = [];
211
211
  let lineStart = 0;
212
212
  while (lineStart <= text.length) {
213
213
  let lineEnd = text.indexOf("\n", lineStart);
214
214
  if (lineEnd === -1) lineEnd = text.length;
215
215
  if (text.charCodeAt(lineStart) === 43 /* + */ && !text.startsWith("+++ ", lineStart)) {
216
- const line = text.slice(lineStart + 1, lineEnd);
217
- added = added === undefined ? line : `${added}\n${line}`;
216
+ added.push(text.slice(lineStart + 1, lineEnd));
218
217
  }
219
218
  lineStart = lineEnd + 1;
220
219
  }
221
- if (added === undefined) return fallbackToWhole ? text : "";
222
- return added;
220
+ if (added.length === 0) return fallbackToWhole ? text : "";
221
+ return added.join("\n");
223
222
  }
224
223
 
225
224
  /**
@@ -394,7 +393,9 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
394
393
  const result = await computePatchDiff(
395
394
  { path: args.path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
396
395
  ctx.cwd,
397
- { fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
396
+ // Match the apply path: JSON-mode `op: "create"` is a sanctioned
397
+ // full-file overwrite, so the preview must not reject it either.
398
+ { fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy, allowCreateOverwrite: true },
398
399
  );
399
400
  ctx.signal.throwIfAborted();
400
401
  return [toPerFilePreview(args.path, result)];
@@ -219,6 +219,48 @@ describe("runEvalAgent", () => {
219
219
  expect(runSpy.mock.calls[0]?.[0].agent.name).toBe("reviewer");
220
220
  });
221
221
 
222
+ it("honors task.maxRecursionDepth on top of the hard eval ceiling", async () => {
223
+ mockAgents();
224
+ const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
225
+
226
+ // task.maxRecursionDepth=0 means "no spawning at all" — even depth 0 (the
227
+ // top-level agent) must be blocked, matching canSpawnAtDepth().
228
+ await expect(
229
+ runEvalAgent(
230
+ { prompt: "hello" },
231
+ {
232
+ session: makeSession({
233
+ settings: Settings.isolated({
234
+ "async.enabled": false,
235
+ "task.isolation.mode": "none",
236
+ "task.maxRecursionDepth": 0,
237
+ }),
238
+ }),
239
+ },
240
+ ),
241
+ ).rejects.toThrow("maximum depth is 0");
242
+
243
+ // task.maxRecursionDepth=1 ("Single") lets the top spawn but a depth-1
244
+ // subagent cannot spawn further — even though the hard ceiling is 3.
245
+ await expect(
246
+ runEvalAgent(
247
+ { prompt: "hello" },
248
+ {
249
+ session: makeSession({
250
+ depth: 1,
251
+ settings: Settings.isolated({
252
+ "async.enabled": false,
253
+ "task.isolation.mode": "none",
254
+ "task.maxRecursionDepth": 1,
255
+ }),
256
+ }),
257
+ },
258
+ ),
259
+ ).rejects.toThrow("maximum depth is 1");
260
+
261
+ expect(runSpy).not.toHaveBeenCalled();
262
+ });
263
+
222
264
  it("throws instead of spawning from plan mode", async () => {
223
265
  mockAgents();
224
266
  const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
@@ -234,7 +276,19 @@ describe("runEvalAgent", () => {
234
276
  const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
235
277
  const abortController = new AbortController();
236
278
  const schema = { type: "object", properties: { ok: { type: "boolean" } } };
237
- const session = makeSession({ depth: 2, activeModel: "p/current", modelString: "p/fallback" });
279
+ const session = makeSession({
280
+ depth: 2,
281
+ activeModel: "p/current",
282
+ modelString: "p/fallback",
283
+ settings: Settings.isolated({
284
+ "async.enabled": false,
285
+ "task.isolation.mode": "none",
286
+ "task.enableLsp": true,
287
+ // Default task.maxRecursionDepth is 2, which would now (correctly)
288
+ // block depth=2 — widen it so the test still exercises depth=2.
289
+ "task.maxRecursionDepth": -1,
290
+ }),
291
+ });
238
292
 
239
293
  await runEvalAgent(
240
294
  { prompt: " hello ", label: "My Agent", model: "p/override", schema },
@@ -249,10 +303,12 @@ describe("runEvalAgent", () => {
249
303
  expect(firstOptions.signal).toBe(abortController.signal);
250
304
  expect(firstOptions.parentActiveModelPattern).toBe("p/current");
251
305
  expect(firstOptions.outputSchema).toBe(schema);
306
+ expect(firstOptions.outputSchemaOverridesAgent).toBe(true);
252
307
  expect(firstOptions.assignment).toBe("hello");
253
308
  expect(firstOptions.description).toBe("My Agent");
254
309
  expect(firstOptions.modelOverride).toEqual(["p/override"]);
255
310
  expect(secondOptions.outputSchema).toBeUndefined();
311
+ expect(secondOptions.outputSchemaOverridesAgent).toBeUndefined();
256
312
  });
257
313
 
258
314
  it("forces LSP off for bridge subagents even when task.enableLsp is on", async () => {
@@ -25,7 +25,7 @@ import {
25
25
  } from "../task/isolation-runner";
26
26
  import { AgentOutputManager } from "../task/output-manager";
27
27
  import { resolveSpawnPolicy } from "../task/spawn-policy";
28
- import type { AgentDefinition, AgentProgress, SingleResult } from "../task/types";
28
+ import { type AgentDefinition, type AgentProgress, canSpawnAtDepth, type SingleResult } from "../task/types";
29
29
  import { type NestedRepoPatch, parseIsolationMode } from "../task/worktree";
30
30
  import type { ToolSession } from "../tools";
31
31
  import { ToolError } from "../tools/tool-errors";
@@ -37,7 +37,11 @@ import "../tools/review";
37
37
  /** Synthetic bridge name reserved for the `agent()` helper across both runtimes. */
38
38
  export const EVAL_AGENT_BRIDGE_NAME = "__agent__";
39
39
 
40
- /** Hard recursion limit for eval-driven subagents. */
40
+ /**
41
+ * Hard recursion ceiling for eval-driven subagents. The user setting
42
+ * `task.maxRecursionDepth` is honored on top of this — whichever is tighter
43
+ * wins, so a maintainer-friendly cap can't get raised by a user setting.
44
+ */
41
45
  export const EVAL_AGENT_MAX_DEPTH = 3;
42
46
 
43
47
  const DEFAULT_AGENT_LABEL = "EvalAgent";
@@ -131,9 +135,15 @@ function parseAgentArgs(args: unknown): EvalAgentArgs {
131
135
 
132
136
  function assertDepthAllowed(session: ToolSession): void {
133
137
  const taskDepth = session.taskDepth ?? 0;
134
- if (taskDepth >= EVAL_AGENT_MAX_DEPTH) {
138
+ // Honor the user's `task.maxRecursionDepth` (mirroring the task tool's gate
139
+ // in tools/index.ts) but never above the hard ceiling. `< 0` means
140
+ // "Unlimited" in the same schema `canSpawnAtDepth` reads, so it falls back
141
+ // to the hard ceiling instead of going past it.
142
+ const settingMax = session.settings.get("task.maxRecursionDepth") ?? 2;
143
+ const effectiveMax = settingMax < 0 ? EVAL_AGENT_MAX_DEPTH : Math.min(settingMax, EVAL_AGENT_MAX_DEPTH);
144
+ if (!canSpawnAtDepth(effectiveMax, taskDepth)) {
135
145
  throw new ToolError(
136
- `agent() cannot spawn another agent at task depth ${taskDepth}; maximum depth is ${EVAL_AGENT_MAX_DEPTH}.`,
146
+ `agent() cannot spawn another agent at task depth ${taskDepth}; maximum depth is ${effectiveMax} (task.maxRecursionDepth=${settingMax}, hard ceiling=${EVAL_AGENT_MAX_DEPTH}).`,
137
147
  );
138
148
  }
139
149
  }
@@ -368,7 +378,7 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
368
378
  modelOverride,
369
379
  parentActiveModelPattern,
370
380
  thinkingLevel: effectiveAgent.thinkingLevel,
371
- outputSchema: structured ? parsed.schema : undefined,
381
+ ...(structured ? { outputSchema: parsed.schema, outputSchemaOverridesAgent: true } : {}),
372
382
  sessionFile,
373
383
  persistArtifacts: Boolean(sessionFile),
374
384
  artifactsDir,