@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +3556 -3504
  3. package/dist/types/advisor/advise-tool.d.ts +12 -1
  4. package/dist/types/advisor/runtime.d.ts +41 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/update-cli.d.ts +4 -1
  7. package/dist/types/cli/usage-cli.d.ts +3 -0
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/config/api-key-resolver.d.ts +2 -2
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +8 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/config/settings-schema.d.ts +10 -0
  14. package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
  15. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
  16. package/dist/types/eval/backend.d.ts +3 -3
  17. package/dist/types/eval/bridge-timeout.d.ts +9 -1
  18. package/dist/types/eval/js/context-manager.d.ts +5 -3
  19. package/dist/types/eval/js/process-entry.d.ts +6 -0
  20. package/dist/types/eval/js/worker-core.d.ts +15 -1
  21. package/dist/types/eval/py/spawn-options.d.ts +10 -0
  22. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  23. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  24. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  25. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  26. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
  27. package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
  28. package/dist/types/goals/guided-setup.d.ts +12 -0
  29. package/dist/types/internal-urls/history-protocol.d.ts +3 -2
  30. package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
  31. package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
  32. package/dist/types/main.d.ts +1 -0
  33. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  34. package/dist/types/mcp/oauth-flow.d.ts +2 -0
  35. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
  36. package/dist/types/modes/components/agent-hub.d.ts +10 -0
  37. package/dist/types/modes/components/dynamic-border.d.ts +5 -3
  38. package/dist/types/modes/components/login-dialog.d.ts +2 -0
  39. package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
  40. package/dist/types/modes/components/read-tool-group.d.ts +0 -2
  41. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  42. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  43. package/dist/types/modes/interactive-mode.d.ts +1 -0
  44. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  45. package/dist/types/modes/orchestrate.d.ts +1 -1
  46. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  47. package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
  48. package/dist/types/modes/types.d.ts +1 -0
  49. package/dist/types/modes/ultrathink.d.ts +1 -1
  50. package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
  51. package/dist/types/modes/workflow.d.ts +1 -1
  52. package/dist/types/session/agent-session.d.ts +6 -0
  53. package/dist/types/session/exit-diagnostics.d.ts +11 -0
  54. package/dist/types/session/messages.d.ts +15 -0
  55. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
  56. package/dist/types/subprocess/worker-client.d.ts +6 -0
  57. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  58. package/dist/types/tools/grep.d.ts +0 -2
  59. package/dist/types/tools/read.d.ts +0 -4
  60. package/dist/types/web/search/provider.d.ts +10 -3
  61. package/dist/types/web/search/providers/codex.d.ts +5 -4
  62. package/package.json +12 -12
  63. package/src/advisor/__tests__/advisor.test.ts +917 -42
  64. package/src/advisor/advise-tool.ts +17 -1
  65. package/src/advisor/runtime.ts +270 -67
  66. package/src/autolearn/controller.ts +15 -3
  67. package/src/autoresearch/dashboard.ts +2 -2
  68. package/src/cli/args.ts +12 -0
  69. package/src/cli/auth-broker-cli.ts +30 -11
  70. package/src/cli/auth-gateway-cli.ts +5 -1
  71. package/src/cli/config-cli.ts +15 -3
  72. package/src/cli/dry-balance-cli.ts +14 -4
  73. package/src/cli/flag-tables.ts +21 -7
  74. package/src/cli/update-cli.ts +62 -11
  75. package/src/cli/usage-cli.ts +58 -5
  76. package/src/cli/usage-error.ts +7 -0
  77. package/src/cli.ts +23 -1
  78. package/src/commands/acp.ts +11 -2
  79. package/src/commands/launch.ts +12 -3
  80. package/src/commands/token.ts +3 -1
  81. package/src/config/api-key-resolver.ts +12 -3
  82. package/src/config/config-file.ts +30 -12
  83. package/src/config/model-registry.ts +7 -7
  84. package/src/config/model-resolver.ts +21 -7
  85. package/src/config/models-config.ts +1 -1
  86. package/src/config/settings-schema.ts +10 -0
  87. package/src/cursor.ts +2 -0
  88. package/src/discovery/claude-plugins.ts +9 -3
  89. package/src/discovery/omp-plugins.ts +6 -2
  90. package/src/discovery/substitute-plugin-root.ts +32 -0
  91. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  92. package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
  93. package/src/eval/__tests__/js-context-manager.test.ts +158 -1
  94. package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
  95. package/src/eval/__tests__/prelude-agent.test.ts +20 -0
  96. package/src/eval/__tests__/process-entry-import.test.ts +27 -0
  97. package/src/eval/agent-bridge.ts +121 -116
  98. package/src/eval/backend.ts +3 -3
  99. package/src/eval/bridge-timeout.ts +20 -2
  100. package/src/eval/executor-base.ts +85 -7
  101. package/src/eval/jl/kernel.ts +2 -1
  102. package/src/eval/js/context-manager.ts +109 -32
  103. package/src/eval/js/process-entry.ts +27 -0
  104. package/src/eval/js/shared/runtime.ts +1 -1
  105. package/src/eval/js/worker-core.ts +70 -9
  106. package/src/eval/js/worker-entry.ts +1 -1
  107. package/src/eval/py/__tests__/prelude.test.ts +72 -0
  108. package/src/eval/py/kernel.ts +2 -1
  109. package/src/eval/py/prelude.py +28 -1
  110. package/src/eval/py/spawn-options.ts +13 -0
  111. package/src/eval/py/tool-bridge.ts +13 -14
  112. package/src/eval/rb/kernel.ts +2 -1
  113. package/src/exec/bash-executor.ts +30 -43
  114. package/src/extensibility/custom-tools/types.ts +3 -0
  115. package/src/extensibility/extensions/runner.ts +3 -0
  116. package/src/extensibility/extensions/types.ts +3 -0
  117. package/src/extensibility/extensions/wrapper.ts +18 -18
  118. package/src/extensibility/plugins/bun-git-cache.ts +91 -0
  119. package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
  120. package/src/extensibility/plugins/manager.ts +28 -7
  121. package/src/goals/guided-setup.ts +29 -1
  122. package/src/internal-urls/history-protocol.ts +95 -15
  123. package/src/internal-urls/memory-protocol.ts +13 -9
  124. package/src/internal-urls/registry-helpers.ts +50 -1
  125. package/src/launch/broker.ts +38 -25
  126. package/src/lsp/client.ts +7 -1
  127. package/src/main.ts +29 -0
  128. package/src/mcp/oauth-discovery.ts +20 -1
  129. package/src/mcp/oauth-flow.ts +3 -1
  130. package/src/mcp/tool-bridge.ts +57 -6
  131. package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
  132. package/src/modes/components/agent-dashboard.ts +2 -2
  133. package/src/modes/components/agent-hub.ts +15 -2
  134. package/src/modes/components/agent-transcript-viewer.ts +2 -2
  135. package/src/modes/components/chat-transcript-builder.ts +26 -4
  136. package/src/modes/components/dynamic-border.ts +9 -6
  137. package/src/modes/components/extensions/extension-list.ts +2 -2
  138. package/src/modes/components/hook-selector.ts +10 -4
  139. package/src/modes/components/login-dialog.ts +5 -0
  140. package/src/modes/components/mcp-add-wizard.ts +5 -0
  141. package/src/modes/components/plan-review-overlay.ts +11 -11
  142. package/src/modes/components/read-tool-group.ts +1 -8
  143. package/src/modes/components/status-line/component.ts +10 -1
  144. package/src/modes/components/transcript-container.ts +110 -7
  145. package/src/modes/controllers/command-controller.ts +12 -4
  146. package/src/modes/controllers/event-controller.ts +80 -15
  147. package/src/modes/controllers/input-controller.ts +4 -2
  148. package/src/modes/controllers/mcp-command-controller.ts +6 -7
  149. package/src/modes/controllers/selector-controller.ts +24 -5
  150. package/src/modes/controllers/todo-command-controller.ts +18 -14
  151. package/src/modes/interactive-mode.ts +7 -3
  152. package/src/modes/magic-keyword-boundary.ts +23 -0
  153. package/src/modes/orchestrate.ts +6 -5
  154. package/src/modes/print-mode.ts +9 -0
  155. package/src/modes/prompt-action-autocomplete.ts +6 -1
  156. package/src/modes/rpc/host-tools.ts +15 -0
  157. package/src/modes/rpc/rpc-mode.ts +123 -48
  158. package/src/modes/types.ts +1 -1
  159. package/src/modes/ultrathink.ts +6 -5
  160. package/src/modes/utils/transcript-render-helpers.ts +54 -0
  161. package/src/modes/utils/ui-helpers.ts +27 -1
  162. package/src/modes/workflow.ts +6 -5
  163. package/src/prompts/advisor/system.md +1 -0
  164. package/src/prompts/system/system-prompt.md +1 -0
  165. package/src/prompts/tools/eval.md +2 -2
  166. package/src/prompts/tools/grep.md +1 -2
  167. package/src/prompts/tools/read.md +2 -4
  168. package/src/sdk.ts +61 -34
  169. package/src/session/agent-session.ts +283 -39
  170. package/src/session/exit-diagnostics.ts +108 -0
  171. package/src/session/messages.test.ts +66 -0
  172. package/src/session/messages.ts +37 -0
  173. package/src/session/streaming-output.ts +40 -12
  174. package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
  175. package/src/slash-commands/helpers/logout.ts +23 -3
  176. package/src/slash-commands/helpers/usage-report.ts +14 -2
  177. package/src/subprocess/worker-client.ts +9 -2
  178. package/src/system-prompt.test.ts +36 -0
  179. package/src/system-prompt.ts +1 -1
  180. package/src/task/executor.ts +8 -0
  181. package/src/task/render.test.ts +36 -0
  182. package/src/task/render.ts +55 -43
  183. package/src/tools/bash-skill-urls.ts +4 -1
  184. package/src/tools/bash.ts +1 -0
  185. package/src/tools/browser/registry.ts +17 -3
  186. package/src/tools/eval.ts +14 -9
  187. package/src/tools/gh.ts +3 -1
  188. package/src/tools/grep.ts +5 -45
  189. package/src/tools/path-utils.ts +7 -1
  190. package/src/tools/read.ts +23 -74
  191. package/src/tools/write.ts +82 -9
  192. package/src/tools/yield.ts +29 -1
  193. package/src/utils/title-generator.ts +10 -6
  194. package/src/web/search/index.ts +39 -22
  195. package/src/web/search/provider.ts +33 -16
  196. package/src/web/search/providers/codex.ts +68 -21
  197. package/src/web/search/providers/perplexity-auth.ts +20 -11
  198. package/src/web/search/providers/perplexity.ts +14 -2
package/src/tools/read.ts CHANGED
@@ -748,10 +748,7 @@ function splitPdfImageMemberReadPath(readPath: string): { pdfPath: string; membe
748
748
 
749
749
  const readSchema = type({
750
750
  path: type("string").describe(
751
- 'Local path, internal URI (e.g. "omp://", "issue://123", "pr://123"), or URL. Inline :<sel> is still accepted for compatibility.',
752
- ),
753
- "selector?": type("string").describe(
754
- 'selector without a leading colon (e.g. "50-100", "raw", "raw:50-100", "conflicts"); keeps `path` literal when filenames contain colons',
751
+ "Local path, internal URI (e.g. memory://, skill://), or URL. Inline selectors are supported.",
755
752
  ),
756
753
  });
757
754
 
@@ -2122,11 +2119,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2122
2119
  _toolContext?: AgentToolContext,
2123
2120
  ): Promise<AgentToolResult<ReadToolDetails>> {
2124
2121
  let { path: readPath } = params;
2125
- let explicitSelector = params.selector?.trim() || undefined;
2126
- let explicitParsedSelector = explicitSelector === undefined ? undefined : parseSel(explicitSelector);
2127
- if (explicitSelector !== undefined && explicitParsedSelector?.kind === "none") {
2128
- throw invalidSelector(explicitSelector);
2129
- }
2130
2122
  if (readPath.startsWith("file://")) {
2131
2123
  readPath = expandPath(readPath);
2132
2124
  }
@@ -2147,13 +2139,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2147
2139
  if (!this.session.settings.get("fetch.enabled")) {
2148
2140
  throw new ToolError("URL reads are disabled by settings.");
2149
2141
  }
2150
- if (explicitParsedSelector?.kind === "conflicts") {
2151
- throw new ToolError("The explicit read selector `conflicts` is only supported for local files.");
2152
- }
2153
- const urlRaw =
2154
- explicitParsedSelector === undefined ? parsedUrlTarget.raw : isRawSelector(explicitParsedSelector);
2155
- const urlRanges =
2156
- explicitParsedSelector?.kind === "lines" ? explicitParsedSelector.ranges : parsedUrlTarget.ranges;
2142
+ const urlRaw = parsedUrlTarget.raw;
2143
+ const urlRanges = parsedUrlTarget.ranges;
2157
2144
  if (urlRanges !== undefined && urlRanges.length > 1) {
2158
2145
  const cached = await loadReadUrlCacheEntry(
2159
2146
  this.session,
@@ -2169,14 +2156,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2169
2156
  immutable: true,
2170
2157
  });
2171
2158
  }
2172
- const urlRange = urlRanges?.[0];
2173
- const urlOffset = explicitParsedSelector?.kind === "lines" ? urlRange?.startLine : parsedUrlTarget.offset;
2174
- const urlLimit =
2175
- explicitParsedSelector?.kind === "lines" && urlRange
2176
- ? urlRange.endLine !== undefined
2177
- ? urlRange.endLine - urlRange.startLine + 1
2178
- : undefined
2179
- : parsedUrlTarget.limit;
2159
+ const urlOffset = parsedUrlTarget.offset;
2160
+ const urlLimit = parsedUrlTarget.limit;
2180
2161
  if (urlOffset !== undefined || urlLimit !== undefined) {
2181
2162
  const cached = await loadReadUrlCacheEntry(
2182
2163
  this.session,
@@ -2202,10 +2183,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2202
2183
  // Use the internal-URL-aware splitter so malformed selectors are peeled
2203
2184
  // off the URL and surfaced via parseSel rather than confusing handlers.
2204
2185
  const internalRouter = InternalUrlRouter.instance();
2186
+ let promotedSelector: string | undefined;
2205
2187
  if (internalRouter.canHandle(readPath)) {
2206
- const internalTarget =
2207
- explicitSelector === undefined ? splitInternalUrlSel(readPath) : { path: readPath, sel: explicitSelector };
2208
- const parsed = explicitParsedSelector ?? parseSel(internalTarget.sel);
2188
+ const internalTarget = splitInternalUrlSel(readPath);
2189
+ const parsed = parseSel(internalTarget.sel);
2209
2190
  if (internalTarget.sel !== undefined && parsed.kind === "none") {
2210
2191
  throw new ToolError(
2211
2192
  `Invalid selector ':${internalTarget.sel}' on '${internalTarget.path}'. Use :N, :N-M, :N+K, :N- (open-ended), a comma-separated list of ranges, :raw, or a range combined with raw (e.g. :raw:50-100).`,
@@ -2223,15 +2204,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2223
2204
  });
2224
2205
  if (localFile) {
2225
2206
  readPath = localFile.path;
2226
- // Promote the URL-embedded selector into the explicit-selector state so
2227
- // downstream literal-preferring routing does NOT re-split the synthesized
2228
- // `${localFile.path}:${sel}` string — a sibling literal file at that name
2229
- // would otherwise shadow the intended local:// URL selector semantics
2230
- // (issue #4618 reviewer feedback on c493d12).
2231
- if (explicitSelector === undefined && internalTarget.sel !== undefined) {
2232
- explicitSelector = internalTarget.sel;
2233
- explicitParsedSelector = parsed;
2234
- }
2207
+ // Preserve a local:// selector separately so a sibling literal file
2208
+ // cannot shadow the URL's selector semantics during filesystem routing.
2209
+ promotedSelector = internalTarget.sel;
2235
2210
  } else {
2236
2211
  return this.#handleInternalUrl(internalTarget.path, parsed, signal);
2237
2212
  }
@@ -2246,16 +2221,14 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2246
2221
 
2247
2222
  // Prefer a literal filesystem match over selector interpretation so real
2248
2223
  // POSIX filenames containing selector-looking suffixes win over structured
2249
- // archive / sqlite / pdf-image dispatch. With explicit `selector`, `path`
2250
- // is exact: `path: "test:1-2", selector: "1-2"` means "lines 1-2 from
2251
- // the literal file test:1-2", without recursively depending on whether a
2252
- // longer `test:1-2:1-2` filename also exists (issue #4618).
2224
+ // archive / sqlite / pdf-image dispatch. A selector promoted from local://
2225
+ // remains separate so it cannot be mistaken for part of the resolved path.
2253
2226
  const literalSplit =
2254
- explicitSelector === undefined
2227
+ promotedSelector === undefined
2255
2228
  ? await splitPathAndSelPreferringLiteral(readPath, this.session.cwd)
2256
- : { path: readPath, sel: explicitSelector };
2229
+ : { path: readPath, sel: promotedSelector };
2257
2230
  const rawPathIsLiteral =
2258
- explicitSelector !== undefined
2231
+ promotedSelector !== undefined
2259
2232
  ? readPath.includes(":") && (await probeLiteralPathExists(readPath, this.session.cwd)) !== "missing"
2260
2233
  : literalSplit.sel === undefined && splitPathAndSel(readPath).sel !== undefined;
2261
2234
 
@@ -2263,9 +2236,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2263
2236
  const archivePath = await this.#resolveArchiveReadPath(readPath, suffixCache, signal);
2264
2237
  if (archivePath) {
2265
2238
  const archiveSubPath =
2266
- explicitSelector === undefined
2239
+ promotedSelector === undefined
2267
2240
  ? splitPathAndSel(archivePath.archiveSubPath)
2268
- : { path: archivePath.archiveSubPath, sel: explicitSelector };
2241
+ : { path: archivePath.archiveSubPath, sel: promotedSelector };
2269
2242
  const archiveParsed = parseSel(archiveSubPath.sel);
2270
2243
  return this.#readArchive(
2271
2244
  readPath,
@@ -3319,8 +3292,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
3319
3292
  interface ReadRenderArgs {
3320
3293
  path?: unknown;
3321
3294
  file_path?: unknown;
3322
- selector?: unknown;
3323
- sel?: string;
3324
3295
  // Legacy fields from old schema — tolerated for in-flight tool calls during transition
3325
3296
  offset?: number;
3326
3297
  limit?: number;
@@ -3384,20 +3355,10 @@ function formatReadPathLink(
3384
3355
 
3385
3356
  export const readToolRenderer = {
3386
3357
  renderCall(args: ReadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
3387
- const baseRawPath =
3388
- typeof args.file_path === "string" ? args.file_path : typeof args.path === "string" ? args.path : "";
3389
- const explicitSelector =
3390
- typeof args.selector === "string"
3391
- ? args.selector.trim().replace(/^:+/, "")
3392
- : args.sel?.trim().replace(/^:+/, "");
3393
3358
  const rawPath =
3394
- explicitSelector && explicitSelector.length > 0 ? `${baseRawPath}:${explicitSelector}` : baseRawPath;
3395
- if (isReadableUrlPath(baseRawPath)) {
3396
- return renderReadUrlCall(
3397
- { path: rawPath, raw: args.raw || explicitSelector?.toLowerCase() === "raw" },
3398
- _options,
3399
- uiTheme,
3400
- );
3359
+ typeof args.file_path === "string" ? args.file_path : typeof args.path === "string" ? args.path : "";
3360
+ if (isReadableUrlPath(rawPath)) {
3361
+ return renderReadUrlCall({ path: rawPath, raw: args.raw }, _options, uiTheme);
3401
3362
  }
3402
3363
 
3403
3364
  const offset = args.offset;
@@ -3438,14 +3399,8 @@ export const readToolRenderer = {
3438
3399
  if (result.isError) {
3439
3400
  const rawErrorText = result.content?.find(c => c.type === "text")?.text ?? "";
3440
3401
  const errorText = (rawErrorText || "Unknown error").replace(/^Error:\s*/, "");
3441
- const baseRawPath =
3442
- typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
3443
- const explicitSelector =
3444
- typeof args?.selector === "string"
3445
- ? args.selector.trim().replace(/^:+/, "")
3446
- : args?.sel?.trim().replace(/^:+/, "");
3447
3402
  const rawPath =
3448
- explicitSelector && explicitSelector.length > 0 ? `${baseRawPath}:${explicitSelector}` : baseRawPath;
3403
+ typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
3449
3404
  const filePath =
3450
3405
  formatReadPathLink(rawPath, { offset: args?.offset, sourcePath: readSourceFsPath(result.details) }) ||
3451
3406
  shortenPath(rawPath);
@@ -3472,14 +3427,8 @@ export const readToolRenderer = {
3472
3427
  // echo next to the styled warning line below.
3473
3428
  const contentText = details?.displayContent?.text ?? stripOutputNotice(rawText, details?.meta);
3474
3429
  const imageContent = result.content?.find(c => c.type === "image");
3475
- const baseRawPath =
3476
- typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
3477
- const explicitSelector =
3478
- typeof args?.selector === "string"
3479
- ? args.selector.trim().replace(/^:+/, "")
3480
- : args?.sel?.trim().replace(/^:+/, "");
3481
3430
  const rawPath =
3482
- explicitSelector && explicitSelector.length > 0 ? `${baseRawPath}:${explicitSelector}` : baseRawPath;
3431
+ typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
3483
3432
  const renderPath = splitReadRenderPath(rawPath);
3484
3433
  const lang = getLanguageFromPath(renderPath.path);
3485
3434
 
@@ -83,27 +83,96 @@ const LOOSE_HASHLINE_HEADER_RE = /^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$/;
83
83
  const EXECUTABLE_NOTICE = "[Notice: Made executable via chmod +x]";
84
84
 
85
85
  const BULK_DIRECTIVE_RE = /^#?(\d+)\s*[:=]\s*(@ours|@theirs|@base|@both)$/;
86
+ /**
87
+ * The head of a per-id directive line — `<id>:` / `<id>=` (optionally `#`-prefixed),
88
+ * regardless of whether its value is a valid `@side` token. Used only to sharpen the
89
+ * error message when a directive block is malformed (e.g. `15: some literal text`).
90
+ */
91
+ const BULK_DIRECTIVE_HEAD_RE = /^#?\d+\s*[:=]/;
92
+
93
+ function truncateDirectiveLine(line: string): string {
94
+ return line.length > 60 ? `${line.slice(0, 57)}…` : line;
95
+ }
86
96
 
87
97
  /**
88
98
  * Parse `conflict://*` per-id directive content: every non-empty line must be
89
- * `<id>: @side` (also accepted: `#<id> = @side`). Returns `null` when the
90
- * content is not directive-shaped (→ uniform bulk mode); throws on duplicate
91
- * ids so a typo never silently drops a resolution.
99
+ * `<id>: @side` (also accepted: `#<id> = @side`), where `@side` is one of
100
+ * `@ours` / `@theirs` / `@base` / `@both`.
101
+ *
102
+ * Returns `null` only when NO line is directive-shaped (→ uniform bulk mode).
103
+ * Throws on duplicate ids, and — critically — on a *partial* directive block:
104
+ * content that mixes valid `<id>: @side` lines with lines that aren't. Without
105
+ * that guard a per-id write carrying any non-token value (a literal or
106
+ * multi-line replacement, e.g. `15: <multi-line content>`) fell through to
107
+ * uniform bulk mode, which pasted the raw directive text verbatim into every
108
+ * block and still reported success. Per-id bulk is token-only; literal or
109
+ * multi-line replacements must go through individual `conflict://<N>` writes.
92
110
  */
93
111
  function parseBulkDirectives(content: string): Map<number, string> | null {
94
112
  const map = new Map<number, string>();
113
+ const stray: string[] = [];
114
+ let sawDirective = false;
95
115
  for (const raw of content.split("\n")) {
96
116
  const line = raw.trim();
97
117
  if (line.length === 0) continue;
98
118
  const match = line.match(BULK_DIRECTIVE_RE);
99
- if (!match) return null;
119
+ if (!match) {
120
+ stray.push(line);
121
+ continue;
122
+ }
123
+ sawDirective = true;
100
124
  const id = Number.parseInt(match[1], 10);
101
125
  if (map.has(id)) {
102
126
  throw new ToolError(`Bulk directive lists conflict #${id} twice — each id may appear once.`);
103
127
  }
104
128
  map.set(id, match[2]);
105
129
  }
106
- return map.size > 0 ? map : null;
130
+ // No directive lines at all not a per-id block; caller uses uniform mode.
131
+ if (!sawDirective) return null;
132
+ if (stray.length > 0) {
133
+ const sample = stray[0]!;
134
+ const tokenHint = BULK_DIRECTIVE_HEAD_RE.test(sample)
135
+ ? `Per-id bulk only accepts the tokens @ours/@theirs/@base/@both — one side per id, single line. `
136
+ : "";
137
+ throw new ToolError(
138
+ `Malformed \`conflict://*\` per-id block: ${stray.length} line(s) are not \`<id>: @side\` directives (first: \`${truncateDirectiveLine(sample)}\`). ` +
139
+ tokenHint +
140
+ `Literal or multi-line replacement content isn't supported in a per-id block — resolve those blocks with individual \`write({ path: "conflict://<N>", content })\` calls (you can issue several at once). ` +
141
+ `For a pure pick-a-side pass, make every non-empty line \`<id>: @ours\` (or @theirs/@base/@both).`,
142
+ );
143
+ }
144
+ return map;
145
+ }
146
+
147
+ /**
148
+ * Resolve per-id directives, preferring the pre-strip `raw` content and falling
149
+ * back to the hashline-stripped `stripped` content.
150
+ *
151
+ * Raw is preferred because the `<id>:` directive heads look exactly like
152
+ * hashline `LINE:` prefixes and would be eaten by stripping. When the two
153
+ * contents are identical (hashline mode off) a single parse decides everything,
154
+ * so a malformed-block error propagates straight through — the previous
155
+ * `?? parseBulkDirectives(...)` chain would have swallowed it and silently
156
+ * degraded to uniform bulk mode, pasting the raw directive text into every
157
+ * block. When they differ, a malformed raw block still defers to a *clean*
158
+ * stripped block, but otherwise surfaces its error rather than degrading.
159
+ */
160
+ function resolveBulkDirectives(raw: string, stripped: string): Map<number, string> | null {
161
+ if (raw === stripped) return parseBulkDirectives(raw);
162
+ let rawResult: Map<number, string> | null;
163
+ try {
164
+ rawResult = parseBulkDirectives(raw);
165
+ } catch (rawError) {
166
+ let fallback: Map<number, string> | null = null;
167
+ try {
168
+ fallback = parseBulkDirectives(stripped);
169
+ } catch {
170
+ fallback = null;
171
+ }
172
+ if (fallback) return fallback;
173
+ throw rawError;
174
+ }
175
+ return rawResult ?? parseBulkDirectives(stripped);
107
176
  }
108
177
 
109
178
  const writeSchema = type({
@@ -716,7 +785,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
716
785
  // winner — one call instead of one write per conflict. Parsed from the
717
786
  // PRE-strip content: hashline prefix stripping would otherwise eat the
718
787
  // `<id>: ` heads as echoed line numbers.
719
- const directives = parseBulkDirectives(rawContent) ?? parseBulkDirectives(replacementContent);
788
+ const directives = resolveBulkDirectives(rawContent, replacementContent);
720
789
  if (directives) {
721
790
  const known = new Set(allEntries.map(entry => entry.id));
722
791
  const unknown = [...directives.keys()].filter(id => !known.has(id));
@@ -898,9 +967,13 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
898
967
  }
899
968
  return { content: [{ type: "text", text: resultText }], details: {} };
900
969
  }
901
- // Schemes without a `write` hook fall through to existing logic
902
- // (local:// resolves to a backing file via plan-mode-guard) or are
903
- // rejected downstream when no backing file exists.
970
+ if (scheme !== "local") {
971
+ throw new ToolError(
972
+ `${scheme}:// URLs are read-only for write; use the protocol-specific tool for mutations.`,
973
+ );
974
+ }
975
+ // local:// is backed by the session-local artifact sandbox and is
976
+ // resolved by resolvePlanPath below so write/read share the same root.
904
977
  }
905
978
 
906
979
  const conflictUri = parseConflictUri(path);
@@ -198,6 +198,14 @@ function wrapYieldParameters(dataSchema: Record<string, unknown>): Record<string
198
198
  */
199
199
  const MAX_SCHEMA_RETRIES = 3;
200
200
 
201
+ /**
202
+ * Max consecutive untyped empty-result submissions before the yield tool fails
203
+ * the child explicitly. Some weak tool callers can acknowledge the required
204
+ * wrapper in prose while repeatedly sending `{ result: {} }`; without a hard
205
+ * stop the parent waits forever.
206
+ */
207
+ const MAX_EMPTY_RESULT_RETRIES = 3;
208
+
201
209
  export class YieldTool implements AgentTool<TSchema, YieldDetails> {
202
210
  readonly name = "yield";
203
211
  readonly approval = "read" as const;
@@ -217,6 +225,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
217
225
  #knownSectionLabels: readonly string[] = [];
218
226
  #isKnownSection?: (label: string) => boolean;
219
227
  #schemaValidationFailures = 0;
228
+ #emptyResultFailures = 0;
220
229
 
221
230
  constructor(session: ToolSession) {
222
231
  let validate: ((value: unknown) => JsonSchemaValidationResult) | undefined;
@@ -322,8 +331,26 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
322
331
  throw new Error("result cannot contain both data and error");
323
332
  }
324
333
  if (errorMessage === undefined && data === undefined && yieldType === undefined) {
334
+ this.#emptyResultFailures++;
335
+ if (this.#emptyResultFailures > MAX_EMPTY_RESULT_RETRIES) {
336
+ const attemptCount = this.#emptyResultFailures;
337
+ this.#emptyResultFailures = 0;
338
+ const error =
339
+ `yield result stayed empty after ${attemptCount} consecutive attempt(s); aborting child instead of retrying forever. ` +
340
+ 'Submit success as `{ "result": { "data": <your output> } }` or failure as `{ "result": { "error": "message" } }`.';
341
+ return {
342
+ content: [{ type: "text", text: `Task aborted: ${error}` }],
343
+ details: {
344
+ data: undefined,
345
+ status: "aborted",
346
+ error,
347
+ type: yieldType,
348
+ },
349
+ };
350
+ }
351
+ const remaining = MAX_EMPTY_RESULT_RETRIES - this.#emptyResultFailures;
325
352
  throw new Error(
326
- 'result must contain either `data` or `error`. Use `{result: {data: <your output>}}` for success or `{result: {error: "message"}}` for failure.',
353
+ `result must contain either \`data\` or \`error\`. Use \`{result: {data: <your output>}}\` for success or \`{result: {error: "message"}}\` for failure. Empty untyped result retries remaining before abort: ${remaining}.`,
327
354
  );
328
355
  }
329
356
 
@@ -370,6 +397,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
370
397
  }
371
398
  }
372
399
 
400
+ this.#emptyResultFailures = 0;
373
401
  const responseText =
374
402
  status === "aborted"
375
403
  ? `Task aborted: ${errorMessage}`
@@ -40,6 +40,8 @@ const THINKING_TAG_ENVELOPE_RE = /<(think|thinking|reasoning)>\s*[\s\S]*?<\/\1>/
40
40
  const THINKING_FENCE_ENVELOPE_RE = /```(?:thinking|reasoning)\b[\s\S]*?```/gi;
41
41
  const LEADING_THINKING_TAG_RE = /^\s*<(think|thinking|reasoning)>\s*[\s\S]*?<\/\1>\s*/i;
42
42
  const LEADING_THINKING_FENCE_RE = /^\s*```(?:thinking|reasoning)\b[\s\S]*?```\s*/i;
43
+ const LEADING_PROSE_THINKING_PREAMBLE_RE =
44
+ /^[ \t]*(?:(?:here(?:['’]s| is)[ \t]+(?:a|the|my)[ \t]+)|my[ \t]+)?(?:thinking|thought|reasoning)[ \t]+process[ \t]*:?[ \t]*(?:\r?\n|$)/i;
43
45
 
44
46
  function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel?: Model<Api>): Model<Api> | undefined {
45
47
  const availableModels = registry.getAvailable();
@@ -250,13 +252,15 @@ function extractGeneratedTitle(contentBlocks: AssistantMessage["content"]): stri
250
252
  }
251
253
  // Stay lenient: prefer the first closed title marker in visible text, then
252
254
  // fall back to a plain sentence after stripping only known leading leaked
253
- // thinking envelopes plus any stray/unclosed title tag fragment.
255
+ // thinking envelopes plus any stray/unclosed title tag fragment. Reject a
256
+ // prose thinking preamble only on the markerless path: a later marked title
257
+ // remains authoritative.
254
258
  const markedTitle = extractVisibleMarkedTitle(textTitle);
255
- const cleanedTextTitle =
256
- markedTitle ??
257
- stripLeadingLeakedThinkingMarkup(textTitle)
258
- .replace(/<\/?title>/gi, "")
259
- .trim();
259
+ if (markedTitle !== undefined) return unwrapJsonTitle(markedTitle);
260
+ const cleanedTextTitle = stripLeadingLeakedThinkingMarkup(textTitle)
261
+ .replace(/<\/?title>/gi, "")
262
+ .trim();
263
+ if (LEADING_PROSE_THINKING_PREAMBLE_RE.test(cleanedTextTitle)) return "";
260
264
  return unwrapJsonTitle(cleanedTextTitle);
261
265
  }
262
266
 
@@ -21,8 +21,10 @@ import {
21
21
  formatSearchProviderFailure,
22
22
  formatSearchProviderFailures,
23
23
  getSearchProvider,
24
- resolveProviderChain,
24
+ getSearchProviderLabel,
25
+ resolveProviderCandidates,
25
26
  type SearchProvider,
27
+ type SearchProviderCandidate,
26
28
  } from "./provider";
27
29
  import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
28
30
  import type { SearchProviderId, SearchResponse } from "./types";
@@ -128,25 +130,18 @@ async function executeSearch(
128
130
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
129
131
  const { authStorage, sessionId, signal } = options;
130
132
  const explicitProvider = params.provider;
131
- let providers: SearchProvider[];
133
+ let candidates: SearchProviderCandidate[];
132
134
  if (explicitProvider && explicitProvider !== "auto") {
133
135
  const provider = await getSearchProvider(explicitProvider);
134
- providers = (await provider.isExplicitlyAvailable(authStorage))
135
- ? [provider]
136
- : await resolveProviderChain(authStorage, "auto");
136
+ candidates = (await provider.isExplicitlyAvailable(authStorage))
137
+ ? [{ id: explicitProvider, explicit: true }]
138
+ : resolveProviderCandidates("auto");
137
139
  } else if (explicitProvider === "auto") {
138
140
  // Explicit `--provider auto` bypasses the configured preferred provider
139
141
  // for this invocation; exclusions still apply.
140
- providers = await resolveProviderChain(authStorage, "auto");
142
+ candidates = resolveProviderCandidates("auto");
141
143
  } else {
142
- providers = await resolveProviderChain(authStorage);
143
- }
144
- if (providers.length === 0) {
145
- const message = "No web search provider configured.";
146
- return {
147
- content: [{ type: "text" as const, text: `Error: ${message}` }],
148
- details: { response: { provider: "none", sources: [] }, error: message },
149
- };
144
+ candidates = resolveProviderCandidates();
150
145
  }
151
146
 
152
147
  // Invariant across providers; read once and tolerate an uninitialized
@@ -166,11 +161,22 @@ async function executeSearch(
166
161
  geminiModel = undefined;
167
162
  }
168
163
 
169
- const failures: Array<{ provider: SearchProvider; error: unknown }> = [];
170
- let lastProvider = providers[0];
171
- for (const provider of providers) {
172
- lastProvider = provider;
164
+ const failures: Array<{ provider: Pick<SearchProvider, "id" | "label">; error: unknown }> = [];
165
+ let availableProviderCount = 0;
166
+ let lastProvider: Pick<SearchProvider, "id" | "label"> | undefined;
167
+ for (const candidate of candidates) {
168
+ let provider: SearchProvider | undefined;
169
+ const providerMeta = { id: candidate.id, label: getSearchProviderLabel(candidate.id) };
170
+ lastProvider = providerMeta;
173
171
  try {
172
+ provider = await getSearchProvider(candidate.id);
173
+ const available = candidate.explicit
174
+ ? await provider.isExplicitlyAvailable(authStorage)
175
+ : await provider.isAvailable(authStorage);
176
+ if (!available) continue;
177
+ availableProviderCount++;
178
+ lastProvider = provider;
179
+
174
180
  const response = await provider.search({
175
181
  query: params.query,
176
182
  limit: params.limit,
@@ -203,20 +209,31 @@ async function executeSearch(
203
209
  // failure and the loop falls through to the next provider (or to the
204
210
  // summary error), masking the cancellation.
205
211
  throwIfAborted(signal);
206
- failures.push({ provider, error });
212
+ failures.push({ provider: provider ?? providerMeta, error });
207
213
  }
208
214
  }
209
215
 
216
+ if (availableProviderCount === 0 && failures.length === 0) {
217
+ const message = "No web search provider configured.";
218
+ return {
219
+ content: [{ type: "text" as const, text: `Error: ${message}` }],
220
+ details: { response: { provider: "none", sources: [] }, error: message },
221
+ };
222
+ }
223
+
210
224
  const lastFailure = failures[failures.length - 1];
211
225
  const baseMessage = lastFailure
212
226
  ? formatSearchProviderFailure(lastFailure.error, lastFailure.provider)
213
- : `Unknown error from ${lastProvider.label}`;
227
+ : `Unknown error from ${lastProvider?.label ?? "web search provider"}`;
214
228
  const message =
215
- providers.length > 1 ? `All web search providers failed: ${formatSearchProviderFailures(failures)}` : baseMessage;
229
+ failures.length > 1 ? `All web search providers failed: ${formatSearchProviderFailures(failures)}` : baseMessage;
216
230
 
217
231
  return {
218
232
  content: [{ type: "text" as const, text: `Error: ${message}` }],
219
- details: { response: { provider: lastProvider.id, sources: [] }, error: message },
233
+ details: {
234
+ response: { provider: lastFailure?.provider.id ?? lastProvider?.id ?? "none", sources: [] },
235
+ error: message,
236
+ },
220
237
  };
221
238
  }
222
239
 
@@ -210,10 +210,34 @@ export function isSearchProviderExcluded(id: SearchProviderId): boolean {
210
210
  return excludedProvIds.has(id);
211
211
  }
212
212
 
213
+ export interface SearchProviderCandidate {
214
+ id: SearchProviderId;
215
+ explicit: boolean;
216
+ }
217
+
218
+ /** Return provider candidates in fallback order without loading their modules. */
219
+ export function resolveProviderCandidates(
220
+ preferredProvider: SearchProviderId | "auto" = preferredProvId,
221
+ ): SearchProviderCandidate[] {
222
+ const candidates: SearchProviderCandidate[] = [];
223
+
224
+ if (preferredProvider !== "auto" && !isSearchProviderExcluded(preferredProvider)) {
225
+ candidates.push({ id: preferredProvider, explicit: true });
226
+ }
227
+
228
+ for (const id of SEARCH_PROVIDER_ORDER) {
229
+ if (id === preferredProvider || isSearchProviderExcluded(id)) continue;
230
+ candidates.push({ id, explicit: false });
231
+ }
232
+
233
+ return candidates;
234
+ }
235
+
213
236
  /**
214
- * Determine which providers are configured and currently available.
215
- * Each candidate is loaded (and its `isAvailable()` called) only as the chain
216
- * is walked, so unconfigured providers never pay the load cost.
237
+ * Resolve the complete available provider chain.
238
+ *
239
+ * This compatibility helper loads every candidate. Search execution should use
240
+ * {@link resolveProviderCandidates} so fallback modules load only when reached.
217
241
  */
218
242
  export async function resolveProviderChain(
219
243
  authStorage: AuthStorage,
@@ -221,19 +245,12 @@ export async function resolveProviderChain(
221
245
  ): Promise<SearchProvider[]> {
222
246
  const providers: SearchProvider[] = [];
223
247
 
224
- if (preferredProvider !== "auto" && !isSearchProviderExcluded(preferredProvider)) {
225
- const provider = await getSearchProvider(preferredProvider);
226
- if (await provider.isExplicitlyAvailable(authStorage)) {
227
- providers.push(provider);
228
- }
229
- }
230
-
231
- for (const id of SEARCH_PROVIDER_ORDER) {
232
- if (id === preferredProvider || isSearchProviderExcluded(id)) continue;
233
- const provider = await getSearchProvider(id);
234
- if (await provider.isAvailable(authStorage)) {
235
- providers.push(provider);
236
- }
248
+ for (const candidate of resolveProviderCandidates(preferredProvider)) {
249
+ const provider = await getSearchProvider(candidate.id);
250
+ const available = candidate.explicit
251
+ ? await provider.isExplicitlyAvailable(authStorage)
252
+ : await provider.isAvailable(authStorage);
253
+ if (available) providers.push(provider);
237
254
  }
238
255
 
239
256
  return providers;