@oh-my-pi/pi-coding-agent 16.5.1 → 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 (87) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.js +3442 -3408
  3. package/dist/types/config/settings-schema.d.ts +10 -0
  4. package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
  5. package/dist/types/eval/backend.d.ts +3 -3
  6. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
  7. package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
  8. package/dist/types/goals/guided-setup.d.ts +12 -0
  9. package/dist/types/internal-urls/history-protocol.d.ts +3 -2
  10. package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
  11. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  12. package/dist/types/mcp/oauth-flow.d.ts +2 -0
  13. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
  14. package/dist/types/modes/components/agent-hub.d.ts +10 -0
  15. package/dist/types/modes/components/dynamic-border.d.ts +5 -3
  16. package/dist/types/modes/components/login-dialog.d.ts +2 -0
  17. package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
  18. package/dist/types/modes/components/read-tool-group.d.ts +0 -2
  19. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  20. package/dist/types/modes/interactive-mode.d.ts +1 -0
  21. package/dist/types/modes/types.d.ts +1 -0
  22. package/dist/types/session/messages.d.ts +15 -0
  23. package/dist/types/tools/grep.d.ts +0 -2
  24. package/dist/types/tools/read.d.ts +0 -4
  25. package/package.json +12 -12
  26. package/src/advisor/__tests__/advisor.test.ts +136 -49
  27. package/src/advisor/runtime.ts +16 -34
  28. package/src/autoresearch/dashboard.ts +2 -2
  29. package/src/cli/config-cli.ts +15 -3
  30. package/src/config/settings-schema.ts +10 -0
  31. package/src/cursor.ts +2 -0
  32. package/src/discovery/claude-plugins.ts +9 -3
  33. package/src/discovery/omp-plugins.ts +6 -2
  34. package/src/discovery/substitute-plugin-root.ts +32 -0
  35. package/src/eval/__tests__/prelude-agent.test.ts +20 -0
  36. package/src/eval/backend.ts +3 -3
  37. package/src/eval/py/__tests__/prelude.test.ts +72 -0
  38. package/src/eval/py/prelude.py +28 -1
  39. package/src/exec/bash-executor.ts +30 -43
  40. package/src/extensibility/extensions/wrapper.ts +18 -18
  41. package/src/extensibility/plugins/bun-git-cache.ts +91 -0
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
  43. package/src/extensibility/plugins/manager.ts +7 -7
  44. package/src/goals/guided-setup.ts +29 -1
  45. package/src/internal-urls/history-protocol.ts +95 -15
  46. package/src/internal-urls/registry-helpers.ts +50 -1
  47. package/src/launch/broker.ts +38 -25
  48. package/src/mcp/oauth-discovery.ts +20 -1
  49. package/src/mcp/oauth-flow.ts +3 -1
  50. package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
  51. package/src/modes/components/agent-dashboard.ts +2 -2
  52. package/src/modes/components/agent-hub.ts +15 -2
  53. package/src/modes/components/agent-transcript-viewer.ts +2 -2
  54. package/src/modes/components/chat-transcript-builder.ts +4 -3
  55. package/src/modes/components/dynamic-border.ts +9 -6
  56. package/src/modes/components/extensions/extension-list.ts +2 -2
  57. package/src/modes/components/hook-selector.ts +10 -4
  58. package/src/modes/components/login-dialog.ts +5 -0
  59. package/src/modes/components/mcp-add-wizard.ts +5 -0
  60. package/src/modes/components/plan-review-overlay.ts +11 -11
  61. package/src/modes/components/read-tool-group.ts +1 -8
  62. package/src/modes/controllers/input-controller.ts +4 -2
  63. package/src/modes/controllers/mcp-command-controller.ts +6 -7
  64. package/src/modes/controllers/selector-controller.ts +9 -2
  65. package/src/modes/controllers/todo-command-controller.ts +18 -14
  66. package/src/modes/interactive-mode.ts +7 -3
  67. package/src/modes/prompt-action-autocomplete.ts +6 -1
  68. package/src/modes/types.ts +1 -1
  69. package/src/prompts/system/system-prompt.md +1 -0
  70. package/src/prompts/tools/eval.md +2 -2
  71. package/src/prompts/tools/grep.md +1 -2
  72. package/src/prompts/tools/read.md +2 -4
  73. package/src/sdk.ts +28 -31
  74. package/src/session/agent-session.ts +44 -22
  75. package/src/session/messages.test.ts +66 -0
  76. package/src/session/messages.ts +37 -0
  77. package/src/system-prompt.test.ts +36 -0
  78. package/src/system-prompt.ts +1 -1
  79. package/src/tools/browser/registry.ts +17 -3
  80. package/src/tools/eval.ts +14 -9
  81. package/src/tools/gh.ts +3 -1
  82. package/src/tools/grep.ts +5 -45
  83. package/src/tools/path-utils.ts +7 -1
  84. package/src/tools/read.ts +23 -74
  85. package/src/utils/title-generator.ts +10 -6
  86. package/src/web/search/providers/perplexity-auth.ts +20 -11
  87. package/src/web/search/providers/perplexity.ts +14 -2
package/src/tools/grep.ts CHANGED
@@ -48,7 +48,6 @@ import {
48
48
  type LineRange,
49
49
  parseLineRanges,
50
50
  pathTargetsSsh,
51
- probeLiteralPathExists,
52
51
  type ResolvedSearchTarget,
53
52
  resolveReadPath,
54
53
  resolveToolSearchScope,
@@ -79,9 +78,6 @@ const searchSchema = type({
79
78
  "path?": searchPathEntry.describe(
80
79
  'file, directory, glob, internal URL, or "<file>:<lines>" selector to search; pass several as a semicolon-delimited list ("src; tests"). Omitted -> searches the workspace root (".")',
81
80
  ),
82
- "selector?": type("string").describe(
83
- 'line selector applied to every searched file (e.g. "50-100", "50+10", "50-100,200-300"); never a path like "/"',
84
- ),
85
81
  "case?": type("boolean").describe("case-sensitive search"),
86
82
  "gitignore?": type("boolean").describe("respect gitignore"),
87
83
  "skip?": type("number")
@@ -126,7 +122,6 @@ interface GrepPathSpec {
126
122
  clean: string;
127
123
  literalFilesystemMatch?: boolean;
128
124
  ranges?: [LineRange, ...LineRange[]];
129
- rangeSource?: "explicit" | "path";
130
125
  }
131
126
 
132
127
  /**
@@ -154,39 +149,9 @@ function isReadSelectorGrammar(sel: string): boolean {
154
149
  return lower === "raw" || lower === "conflicts" || parseLineRanges(sel) !== null;
155
150
  }
156
151
 
157
- async function parsePathSpecs(
158
- rawEntries: readonly string[],
159
- cwd: string,
160
- explicitSelector?: string,
161
- ): Promise<GrepPathSpec[]> {
162
- const normalizedSelector = explicitSelector?.trim() || undefined;
163
- const explicitRanges = normalizedSelector === undefined ? undefined : parseLineRanges(normalizedSelector);
164
- if (normalizedSelector !== undefined && !explicitRanges) {
165
- throw new ToolError(
166
- `selector "${normalizedSelector}" is invalid — use line ranges like "50-100", "50+10", or "50-100,200-300" without a leading colon`,
167
- );
168
- }
152
+ async function parsePathSpecs(rawEntries: readonly string[], cwd: string): Promise<GrepPathSpec[]> {
169
153
  const specs: GrepPathSpec[] = [];
170
154
  for (const entry of rawEntries) {
171
- if (explicitRanges) {
172
- // Separate selector parameter makes `path` deterministic: first try the
173
- // exact local filesystem path (with read-path normalization), then let
174
- // archive/internal/URL resolution handle non-literal structured paths.
175
- const rawPathHasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(entry);
176
- const probe = rawPathHasScheme ? "missing" : await probeLiteralPathExists(entry, cwd);
177
- // `"unknown"` covers EACCES/IO where we cannot confirm existence — treat
178
- // it as a literal so a real file such as `test:1-2` under an unreadable
179
- // parent is never silently reinterpreted as `test` + selector.
180
- const literalMatch = probe !== "missing";
181
- specs.push({
182
- original: entry,
183
- clean: literalMatch && !rawPathHasScheme ? resolveReadPath(entry, cwd) : entry,
184
- literalFilesystemMatch: literalMatch,
185
- ranges: explicitRanges,
186
- rangeSource: "explicit",
187
- });
188
- continue;
189
- }
190
155
  // Internal URLs (`artifact://`, `skill://`, …) use the URL-aware splitter,
191
156
  // which peels selector-shaped tails only for selector-capable schemes and
192
157
  // leaves opaque ones (`mcp://`) intact. Unlike filesystem paths, their
@@ -212,7 +177,6 @@ async function parsePathSpecs(
212
177
  const literalFilesystemMatch = strictSplit.sel !== undefined && split.sel === undefined;
213
178
  let clean = literalFilesystemMatch ? resolveReadPath(entry, cwd) : entry;
214
179
  let ranges: [LineRange, ...LineRange[]] | undefined;
215
- let rangeSource: "path" | undefined;
216
180
  if (!literalFilesystemMatch && split.sel) {
217
181
  const parsed = parseLineRanges(split.sel);
218
182
  if (!parsed) {
@@ -225,14 +189,12 @@ async function parsePathSpecs(
225
189
  }
226
190
  clean = split.path;
227
191
  ranges = parsed;
228
- rangeSource = "path";
229
192
  }
230
193
  specs.push({
231
194
  original: entry,
232
195
  clean,
233
196
  literalFilesystemMatch,
234
197
  ranges,
235
- rangeSource: ranges ? rangeSource : undefined,
236
198
  });
237
199
  }
238
200
  return specs;
@@ -950,7 +912,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
950
912
  _onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
951
913
  _toolContext?: AgentToolContext,
952
914
  ): Promise<AgentToolResult<GrepToolDetails>> {
953
- const { pattern, path: rawPath, selector, case: caseSensitive, gitignore, skip } = params;
915
+ const { pattern, path: rawPath, case: caseSensitive, gitignore, skip } = params;
954
916
 
955
917
  return untilAborted(signal, async () => {
956
918
  // Preserve the pattern verbatim — leading/trailing whitespace is
@@ -968,7 +930,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
968
930
  const scopedPaths = toPathList(rawPath);
969
931
  const effectivePaths = scopedPaths.length > 0 ? scopedPaths : ["."];
970
932
  const rawEntries = await expandDelimitedPathEntries(effectivePaths, this.session.cwd);
971
- const pathSpecs = await parsePathSpecs(rawEntries, this.session.cwd, selector);
933
+ const pathSpecs = await parsePathSpecs(rawEntries, this.session.cwd);
972
934
  const materializedExternalPaths = new Map<string, string>();
973
935
  const materializeExternalUrlForSearch = async (rawPath: string) => {
974
936
  const target = parseReadUrlTarget(rawPath);
@@ -1002,7 +964,6 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1002
964
  const searchablePaths = internalResolution.paths;
1003
965
  const { virtualResources, virtualPathSet, virtualInputIndexes } = internalResolution;
1004
966
  const rangesByAbsPath = new Map<string, LineRange[]>();
1005
- const globalRanges = pathSpecs.find(spec => spec.rangeSource === "explicit")?.ranges;
1006
967
 
1007
968
  if (
1008
969
  archiveUnreadable.length > 0 &&
@@ -1062,7 +1023,6 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1062
1023
  for (let idx = 0; idx < pathSpecs.length; idx++) {
1063
1024
  const spec = pathSpecs[idx];
1064
1025
  if (!spec.ranges) continue;
1065
- if (spec.rangeSource === "explicit") continue;
1066
1026
  if (virtualInputIndexes.has(idx)) continue;
1067
1027
  const resolved = internalResolution.resolvedPathsByInput[idx];
1068
1028
  if (!resolved) continue;
@@ -1268,11 +1228,11 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1268
1228
  throw err;
1269
1229
  }
1270
1230
  result = mergeGrepResults(result, virtualResult, nativeMaxCount);
1271
- if (rangesByAbsPath.size > 0 || globalRanges) {
1231
+ if (rangesByAbsPath.size > 0) {
1272
1232
  const filteredMatches: GrepMatch[] = [];
1273
1233
  for (const match of result.matches) {
1274
1234
  const abs = matchAbsolutePath(match.path, searchPath);
1275
- const ranges = rangesByAbsPath.get(abs) ?? globalRanges;
1235
+ const ranges = rangesByAbsPath.get(abs);
1276
1236
  if (!ranges) {
1277
1237
  // Path has no line-range constraint (e.g. a peer entry without `:N-M`).
1278
1238
  filteredMatches.push(match);
@@ -147,8 +147,14 @@ export function expandTilde(filePath: string, home?: string): string {
147
147
  }
148
148
 
149
149
  export function expandPath(filePath: string): string {
150
+ // Some models intermittently prefix an otherwise-valid path with a stray
151
+ // `:` (e.g. `:/abs/path`, `:../rel`). No real path starts with `:` and it
152
+ // never begins a selector against an absolute/relative path, so strip it
153
+ // before resolution — mirroring the `@`-prefix normalization above and the
154
+ // implicit stripping `write` already tolerates (issue #5508).
155
+ const deColoned = /^:(?=[/~]|\.\.?\/)/.test(filePath) ? filePath.slice(1) : filePath;
150
156
  const normalized = stripWindowsExtendedLengthPathPrefix(
151
- stripFileUrl(normalizeUnicodeSpaces(normalizeAtPrefix(filePath))),
157
+ stripFileUrl(normalizeUnicodeSpaces(normalizeAtPrefix(deColoned))),
152
158
  );
153
159
  return expandTilde(normalized);
154
160
  }
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
 
@@ -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
 
@@ -44,17 +44,26 @@ export async function getApiConfigs(
44
44
  const useResponses = $env.PI_PERPLEXITY_RESPONSES === "1";
45
45
  const configs: ApiConfig[] = [];
46
46
 
47
- const perplexityKey = await authStorage.getApiKey("perplexity", sessionId, options);
48
- if (perplexityKey) {
49
- configs.push({
50
- type: "api_key",
51
- apiKey: perplexityKey,
52
- provider: "perplexity",
53
- chatBaseUrl: PERPLEXITY_CHAT_BASE_URL,
54
- responsesBaseUrl: PERPLEXITY_RESPONSES_BASE_URL,
55
- modelPrefix: "",
56
- useResponses,
57
- });
47
+ // A Perplexity OAuth session and a real API key are mutually exclusive here:
48
+ // when the active credential origin is OAuth, `getApiKey("perplexity")`
49
+ // returns the OAuth session JWT (OAuth wins in AuthStorage.getApiKey), not an
50
+ // api.perplexity.ai key. Emitting it as a direct api-key config makes the
51
+ // search loop send the session token as a Bearer to the direct API endpoint,
52
+ // which rejects it with 401 and masks the real (transport) failure — see #5315.
53
+ // Skip the direct config in that case; the OAuth ask-endpoint method covers it.
54
+ if (authStorage.getCredentialOrigin("perplexity")?.kind !== "oauth") {
55
+ const perplexityKey = await authStorage.getApiKey("perplexity", sessionId, options);
56
+ if (perplexityKey) {
57
+ configs.push({
58
+ type: "api_key",
59
+ apiKey: perplexityKey,
60
+ provider: "perplexity",
61
+ chatBaseUrl: PERPLEXITY_CHAT_BASE_URL,
62
+ responsesBaseUrl: PERPLEXITY_RESPONSES_BASE_URL,
63
+ modelPrefix: "",
64
+ useResponses,
65
+ });
66
+ }
58
67
  }
59
68
 
60
69
  const openrouterKey = await authStorage.getApiKey("openrouter", sessionId, options);
@@ -604,7 +604,7 @@ async function callPerplexityAsk(
604
604
  requestParams.send_back_text_in_streaming_api = true;
605
605
  }
606
606
 
607
- const response = await (params.fetch ?? fetch)(PERPLEXITY_OAUTH_ASK_URL, {
607
+ const requestInit = {
608
608
  method: "POST",
609
609
  headers,
610
610
  body: JSON.stringify({
@@ -612,7 +612,19 @@ async function callPerplexityAsk(
612
612
  params: requestParams,
613
613
  }),
614
614
  signal: withHardTimeout(params.signal),
615
- });
615
+ };
616
+
617
+ // The consumer ask endpoint intermittently drops the socket before sending an
618
+ // HTTP response (#5315). Retry the transport exactly once; once we hold an
619
+ // HTTP response (handled below) the outcome — including non-2xx — is final and
620
+ // never retried, so a real 401/429 is never papered over by a second attempt.
621
+ let response: Response;
622
+ try {
623
+ response = await (params.fetch ?? fetch)(PERPLEXITY_OAUTH_ASK_URL, requestInit);
624
+ } catch (error) {
625
+ if (params.signal?.aborted) throw error;
626
+ response = await (params.fetch ?? fetch)(PERPLEXITY_OAUTH_ASK_URL, requestInit);
627
+ }
616
628
 
617
629
  if (!response.ok) {
618
630
  const errorText = await response.text();