@oh-my-pi/pi-coding-agent 16.3.11 → 16.3.13

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 (113) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.js +3176 -3087
  3. package/dist/types/advisor/runtime.d.ts +11 -0
  4. package/dist/types/config/keybindings.d.ts +9 -4
  5. package/dist/types/config/model-registry.d.ts +4 -0
  6. package/dist/types/config/settings-schema.d.ts +6 -0
  7. package/dist/types/config/settings.d.ts +3 -1
  8. package/dist/types/discovery/helpers.d.ts +9 -0
  9. package/dist/types/exec/bash-executor.d.ts +1 -0
  10. package/dist/types/extensibility/extensions/types.d.ts +11 -2
  11. package/dist/types/extensibility/shared-events.d.ts +2 -2
  12. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +1 -0
  13. package/dist/types/internal-urls/registry-helpers.d.ts +7 -5
  14. package/dist/types/mnemopi/state.d.ts +7 -3
  15. package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -0
  16. package/dist/types/modes/components/model-selector.d.ts +2 -1
  17. package/dist/types/modes/components/read-tool-group.d.ts +1 -0
  18. package/dist/types/modes/github-ref-autocomplete.d.ts +35 -0
  19. package/dist/types/modes/interactive-mode.d.ts +3 -1
  20. package/dist/types/modes/rpc/rpc-client.d.ts +11 -5
  21. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  22. package/dist/types/modes/types.d.ts +3 -1
  23. package/dist/types/modes/utils/context-usage.d.ts +0 -12
  24. package/dist/types/modes/workflow.d.ts +5 -1
  25. package/dist/types/session/agent-session.d.ts +8 -4
  26. package/dist/types/system-prompt.d.ts +1 -1
  27. package/dist/types/tools/bash-interactive.d.ts +1 -1
  28. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  29. package/dist/types/tools/bash.d.ts +2 -1
  30. package/dist/types/tools/browser/launch.d.ts +1 -0
  31. package/dist/types/tools/grep.d.ts +2 -0
  32. package/dist/types/tools/index.d.ts +4 -0
  33. package/dist/types/tools/path-utils.d.ts +24 -0
  34. package/dist/types/tools/read.d.ts +3 -0
  35. package/dist/types/tools/renderers.d.ts +12 -5
  36. package/dist/types/tools/ssh.d.ts +4 -1
  37. package/dist/types/tools/write.d.ts +1 -0
  38. package/dist/types/utils/local-date.d.ts +2 -0
  39. package/package.json +12 -12
  40. package/src/advisor/__tests__/advisor.test.ts +145 -0
  41. package/src/advisor/runtime.ts +19 -0
  42. package/src/config/api-key-resolver.ts +7 -2
  43. package/src/config/keybindings.ts +62 -10
  44. package/src/config/model-registry.ts +94 -20
  45. package/src/config/settings-schema.ts +11 -1
  46. package/src/config/settings.ts +59 -21
  47. package/src/discovery/builtin.ts +2 -1
  48. package/src/discovery/claude-plugins.ts +167 -46
  49. package/src/discovery/helpers.ts +16 -1
  50. package/src/edit/renderer.ts +20 -6
  51. package/src/eval/js/worker-core.ts +163 -6
  52. package/src/exec/bash-executor.ts +14 -9
  53. package/src/extensibility/extensions/runner.ts +1 -0
  54. package/src/extensibility/extensions/types.ts +13 -2
  55. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -2
  56. package/src/extensibility/plugins/marketplace/fetcher.ts +15 -14
  57. package/src/extensibility/shared-events.ts +2 -2
  58. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +68 -0
  59. package/src/internal-urls/docs-index.generated.txt +1 -1
  60. package/src/internal-urls/registry-helpers.ts +9 -6
  61. package/src/mnemopi/state.ts +19 -5
  62. package/src/modes/acp/acp-agent.ts +69 -8
  63. package/src/modes/acp/acp-event-mapper.ts +1 -1
  64. package/src/modes/components/model-selector.ts +30 -6
  65. package/src/modes/components/read-tool-group.ts +5 -1
  66. package/src/modes/components/settings-defs.ts +1 -1
  67. package/src/modes/components/status-line/component.ts +14 -2
  68. package/src/modes/components/tool-execution.ts +28 -24
  69. package/src/modes/controllers/command-controller.ts +13 -23
  70. package/src/modes/controllers/event-controller.ts +12 -12
  71. package/src/modes/controllers/extension-ui-controller.test.ts +16 -0
  72. package/src/modes/controllers/extension-ui-controller.ts +7 -35
  73. package/src/modes/controllers/input-controller.ts +23 -57
  74. package/src/modes/controllers/mcp-command-controller.ts +10 -9
  75. package/src/modes/controllers/selector-controller.ts +16 -5
  76. package/src/modes/github-ref-autocomplete.ts +75 -0
  77. package/src/modes/interactive-mode.ts +97 -12
  78. package/src/modes/prompt-action-autocomplete.ts +35 -0
  79. package/src/modes/rpc/rpc-client.ts +42 -13
  80. package/src/modes/rpc/rpc-mode.ts +21 -19
  81. package/src/modes/types.ts +3 -0
  82. package/src/modes/utils/context-usage.ts +58 -5
  83. package/src/modes/utils/hotkeys-markdown.ts +2 -1
  84. package/src/modes/utils/ui-helpers.ts +2 -2
  85. package/src/modes/workflow.ts +14 -8
  86. package/src/prompts/agents/plan.md +0 -1
  87. package/src/prompts/agents/reviewer.md +0 -1
  88. package/src/prompts/system/plan-mode-active.md +5 -2
  89. package/src/prompts/system/system-prompt.md +1 -2
  90. package/src/prompts/system/workflow-notice.md +69 -50
  91. package/src/prompts/tools/bash.md +18 -7
  92. package/src/prompts/tools/grep.md +2 -1
  93. package/src/prompts/tools/memory-edit.md +2 -0
  94. package/src/prompts/tools/read.md +4 -3
  95. package/src/sdk.ts +11 -0
  96. package/src/session/agent-session.ts +136 -19
  97. package/src/system-prompt.ts +3 -2
  98. package/src/tools/bash-interactive.ts +1 -1
  99. package/src/tools/bash-skill-urls.ts +39 -7
  100. package/src/tools/bash.ts +69 -39
  101. package/src/tools/browser/launch.ts +31 -4
  102. package/src/tools/grep.ts +105 -21
  103. package/src/tools/image-gen.ts +1 -1
  104. package/src/tools/index.ts +11 -0
  105. package/src/tools/memory-edit.ts +3 -1
  106. package/src/tools/path-utils.ts +46 -1
  107. package/src/tools/read.ts +135 -57
  108. package/src/tools/renderers.ts +13 -5
  109. package/src/tools/ssh.ts +10 -3
  110. package/src/tools/tts.ts +1 -1
  111. package/src/tools/write.ts +26 -0
  112. package/src/utils/local-date.ts +7 -0
  113. package/src/utils/open.ts +36 -10
@@ -1276,7 +1276,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1276
1276
  const xaiCreds = await resolveXAIHttpCredentials(ctx.modelRegistry, resolvedModel);
1277
1277
  if (!xaiCreds) {
1278
1278
  throw new Error(
1279
- "No xAI credentials. Run /login → xAI Grok OAuth (SuperGrok Subscription) or set XAI_API_KEY.",
1279
+ "No xAI credentials. Run /login → xAI Grok OAuth (SuperGrok or X Premium+) or set XAI_API_KEY.",
1280
1280
  );
1281
1281
  }
1282
1282
 
@@ -224,6 +224,10 @@ export interface ToolSession {
224
224
  getAgentId?: () => string | null;
225
225
  /** Look up a registered tool by name (used by the eval js backend's tool bridge). */
226
226
  getToolByName?: (name: string) => AgentTool | undefined;
227
+ /** Return whether a built-in tool is active in this turn's tool set. */
228
+ isToolActive?: (name: string) => boolean;
229
+ /** Update the active built-in tool predicate when a session changes tools mid-run. */
230
+ setActiveToolNames?: (names: Iterable<string>) => void;
227
231
  /** Agent registry for IRC routing across live sessions. */
228
232
  agentRegistry?: AgentRegistry;
229
233
  /** Get artifacts directory for artifact:// URLs */
@@ -647,6 +651,13 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
647
651
  ...(goalModeActive ? ([["goal", HIDDEN_TOOLS.goal]] as const) : []),
648
652
  ];
649
653
 
654
+ const activeToolNames = new Set(baseEntries.map(([name]) => name));
655
+ if (session.setActiveToolNames) {
656
+ session.setActiveToolNames(activeToolNames);
657
+ } else {
658
+ session.isToolActive = name => activeToolNames.has(name);
659
+ }
660
+
650
661
  const baseResults = await Promise.all(
651
662
  baseEntries.map(async ([name, factory]) => {
652
663
  const tool = await logger.time(`createTools:${name}`, factory as ToolFactory, session);
@@ -50,7 +50,9 @@ export class MemoryEditTool implements AgentTool<typeof memoryEditSchema> {
50
50
  const text =
51
51
  result.status === "not_found"
52
52
  ? `Memory ${params.id} was not found${location}.`
53
- : `Memory ${params.id} ${result.status}${location}.`;
53
+ : result.status === "not_editable"
54
+ ? `Memory ${params.id} is a read-only fact${location}; it cannot be edited. Read it with memory://${params.id}.`
55
+ : `Memory ${params.id} ${result.status}${location}.`;
54
56
  return {
55
57
  content: [{ type: "text", text }],
56
58
  details: result,
@@ -315,6 +315,46 @@ export function splitPathAndSel(rawPath: string): { path: string; sel?: string }
315
315
  return { path: basePath, sel };
316
316
  }
317
317
 
318
+ /**
319
+ * Three-way probe for whether the exact filesystem entry named by `filePath`
320
+ * exists. `stat` (used earlier) failed for reasons other than "no such file"
321
+ * (dangling symlink, `EACCES` on a parent, transient I/O), and each of those
322
+ * silently reinterpreted a real literal path such as `test:1-2` as `test`
323
+ * plus selector `1-2` (issue #4618). `lstat` inspects the entry itself, so a
324
+ * dangling symlink is still detected as present; ambiguous errors resolve to
325
+ * `"unknown"` so callers keep the raw path instead of guessing.
326
+ */
327
+ export async function probeLiteralPathExists(filePath: string, cwd: string): Promise<"exists" | "missing" | "unknown"> {
328
+ const resolved = resolveReadPath(filePath, cwd);
329
+ try {
330
+ await fs.promises.lstat(resolved);
331
+ return "exists";
332
+ } catch (err) {
333
+ if (isEnoent(err) || isEnotdir(err)) return "missing";
334
+ return "unknown";
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Async sibling of {@link splitPathAndSel} that prefers a literal filesystem
340
+ * path over selector interpretation. Filenames whose tail matches the selector
341
+ * grammar (e.g. `test:1-2`, `log:raw`) are legal on POSIX; without this the
342
+ * strict splitter peels the tail and both `read` and `grep` refuse to open the
343
+ * real file (issue #4618). The literal wins on a confirmed `lstat`, and also
344
+ * on `"unknown"` (`EACCES` on a parent, transient I/O), so an unreachable
345
+ * literal is never silently reinterpreted as `path + selector`. Only a
346
+ * definitive `ENOENT`/`ENOTDIR` falls back to the strict split.
347
+ */
348
+ export async function splitPathAndSelPreferringLiteral(
349
+ rawPath: string,
350
+ cwd: string,
351
+ ): Promise<{ path: string; sel?: string }> {
352
+ const strict = splitPathAndSel(rawPath);
353
+ if (strict.sel === undefined) return strict;
354
+ const probe = await probeLiteralPathExists(rawPath, cwd);
355
+ return probe === "missing" ? strict : { path: rawPath };
356
+ }
357
+
318
358
  /**
319
359
  * Variant of {@link splitPathAndSel} for internal URLs (`scheme://...`).
320
360
  *
@@ -669,7 +709,12 @@ export async function splitDelimitedPathEntry(
669
709
  const normalizedEntry = normalizePathLikeInput(entry);
670
710
  if (!hasTopLevelPathDelimiter(normalizedEntry)) return null;
671
711
  if (isInternalUrlPath(normalizedEntry)) return null;
672
-
712
+ // A real POSIX file may contain the delimiter and a selector-shaped tail
713
+ // (`a;b:1-2`, `a b:1-2`). Preserve the raw entry whenever the full literal
714
+ // resolves — or is only ambiguous — so downstream literal-preferring
715
+ // splitters see it before delimiter expansion peels or splits (issue #4618
716
+ // reviewer feedback: delimited expansion ran before the literal check).
717
+ if ((await probeLiteralPathExists(normalizedEntry, cwd)) !== "missing") return null;
673
718
  const splitter = options.splitter ?? parseSearchPath;
674
719
  const peeledEntry = splitPathAndSel(normalizedEntry).path;
675
720
  if (!hasGlobPathChars(peeledEntry) && (await delimitedPathPartResolves(normalizedEntry, cwd, splitter))) {
package/src/tools/read.ts CHANGED
@@ -99,10 +99,12 @@ import {
99
99
  type LineRange,
100
100
  parseLineRanges,
101
101
  pathTargetsSsh,
102
+ probeLiteralPathExists,
102
103
  resolveReadPath,
103
104
  splitDelimitedPathEntry,
104
105
  splitInternalUrlSel,
105
106
  splitPathAndSel,
107
+ splitPathAndSelPreferringLiteral,
106
108
  } from "./path-utils";
107
109
  import { formatBytes, replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
108
110
  import {
@@ -746,7 +748,10 @@ function splitPdfImageMemberReadPath(readPath: string): { pdfPath: string; membe
746
748
 
747
749
  const readSchema = type({
748
750
  path: type("string").describe(
749
- 'Local path, internal URI (e.g. "omp://", "issue://123", "pr://123"), or URL; append :<sel> for line ranges or raw mode (e.g. "src/foo.ts:50-100")',
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',
750
755
  ),
751
756
  });
752
757
 
@@ -2113,6 +2118,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2113
2118
  _toolContext?: AgentToolContext,
2114
2119
  ): Promise<AgentToolResult<ReadToolDetails>> {
2115
2120
  let { path: readPath } = params;
2121
+ let explicitSelector = params.selector?.trim() || undefined;
2122
+ let explicitParsedSelector = explicitSelector === undefined ? undefined : parseSel(explicitSelector);
2123
+ if (explicitSelector !== undefined && explicitParsedSelector?.kind === "none") {
2124
+ throw invalidSelector(explicitSelector);
2125
+ }
2116
2126
  if (readPath.startsWith("file://")) {
2117
2127
  readPath = expandPath(readPath);
2118
2128
  }
@@ -2133,40 +2143,55 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2133
2143
  if (!this.session.settings.get("fetch.enabled")) {
2134
2144
  throw new ToolError("URL reads are disabled by settings.");
2135
2145
  }
2136
- if (parsedUrlTarget.ranges !== undefined) {
2146
+ if (explicitParsedSelector?.kind === "conflicts") {
2147
+ throw new ToolError("The explicit read selector `conflicts` is only supported for local files.");
2148
+ }
2149
+ const urlRaw =
2150
+ explicitParsedSelector === undefined ? parsedUrlTarget.raw : isRawSelector(explicitParsedSelector);
2151
+ const urlRanges =
2152
+ explicitParsedSelector?.kind === "lines" ? explicitParsedSelector.ranges : parsedUrlTarget.ranges;
2153
+ if (urlRanges !== undefined && urlRanges.length > 1) {
2137
2154
  const cached = await loadReadUrlCacheEntry(
2138
2155
  this.session,
2139
- { path: parsedUrlTarget.path, raw: parsedUrlTarget.raw },
2156
+ { path: parsedUrlTarget.path, raw: urlRaw },
2140
2157
  signal,
2141
2158
  { ensureArtifact: true, preferCached: true },
2142
2159
  );
2143
- return this.#buildInMemoryMultiRangeResult(cached.output, parsedUrlTarget.ranges, {
2160
+ return this.#buildInMemoryMultiRangeResult(cached.output, urlRanges, {
2144
2161
  details: { ...cached.details },
2145
2162
  sourceUrl: cached.details.finalUrl,
2146
2163
  entityLabel: "URL output",
2147
- raw: parsedUrlTarget.raw,
2164
+ raw: urlRaw,
2148
2165
  immutable: true,
2149
2166
  });
2150
2167
  }
2151
- if (parsedUrlTarget.offset !== undefined || parsedUrlTarget.limit !== undefined) {
2168
+ const urlRange = urlRanges?.[0];
2169
+ const urlOffset = explicitParsedSelector?.kind === "lines" ? urlRange?.startLine : parsedUrlTarget.offset;
2170
+ const urlLimit =
2171
+ explicitParsedSelector?.kind === "lines" && urlRange
2172
+ ? urlRange.endLine !== undefined
2173
+ ? urlRange.endLine - urlRange.startLine + 1
2174
+ : undefined
2175
+ : parsedUrlTarget.limit;
2176
+ if (urlOffset !== undefined || urlLimit !== undefined) {
2152
2177
  const cached = await loadReadUrlCacheEntry(
2153
2178
  this.session,
2154
- { path: parsedUrlTarget.path, raw: parsedUrlTarget.raw },
2179
+ { path: parsedUrlTarget.path, raw: urlRaw },
2155
2180
  signal,
2156
2181
  {
2157
2182
  ensureArtifact: true,
2158
2183
  preferCached: true,
2159
2184
  },
2160
2185
  );
2161
- return this.#buildInMemoryTextResult(cached.output, parsedUrlTarget.offset, parsedUrlTarget.limit, {
2186
+ return this.#buildInMemoryTextResult(cached.output, urlOffset, urlLimit, {
2162
2187
  details: { ...cached.details },
2163
2188
  sourceUrl: cached.details.finalUrl,
2164
2189
  entityLabel: "URL output",
2165
- raw: parsedUrlTarget.raw,
2190
+ raw: urlRaw,
2166
2191
  immutable: true,
2167
2192
  });
2168
2193
  }
2169
- return executeReadUrl(this.session, { path: parsedUrlTarget.path, raw: parsedUrlTarget.raw }, signal);
2194
+ return executeReadUrl(this.session, { path: parsedUrlTarget.path, raw: urlRaw }, signal);
2170
2195
  }
2171
2196
 
2172
2197
  // Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://, omp://, issue://, pr://).
@@ -2174,8 +2199,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2174
2199
  // off the URL and surfaced via parseSel rather than confusing handlers.
2175
2200
  const internalRouter = InternalUrlRouter.instance();
2176
2201
  if (internalRouter.canHandle(readPath)) {
2177
- const internalTarget = splitInternalUrlSel(readPath);
2178
- const parsed = parseSel(internalTarget.sel);
2202
+ const internalTarget =
2203
+ explicitSelector === undefined ? splitInternalUrlSel(readPath) : { path: readPath, sel: explicitSelector };
2204
+ const parsed = explicitParsedSelector ?? parseSel(internalTarget.sel);
2179
2205
  if (internalTarget.sel !== undefined && parsed.kind === "none") {
2180
2206
  throw new ToolError(
2181
2207
  `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).`,
@@ -2192,7 +2218,16 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2192
2218
  skills: this.session.skills,
2193
2219
  });
2194
2220
  if (localFile) {
2195
- readPath = internalTarget.sel === undefined ? localFile.path : `${localFile.path}:${internalTarget.sel}`;
2221
+ readPath = localFile.path;
2222
+ // Promote the URL-embedded selector into the explicit-selector state so
2223
+ // downstream literal-preferring routing does NOT re-split the synthesized
2224
+ // `${localFile.path}:${sel}` string — a sibling literal file at that name
2225
+ // would otherwise shadow the intended local:// URL selector semantics
2226
+ // (issue #4618 reviewer feedback on c493d12).
2227
+ if (explicitSelector === undefined && internalTarget.sel !== undefined) {
2228
+ explicitSelector = internalTarget.sel;
2229
+ explicitParsedSelector = parsed;
2230
+ }
2196
2231
  } else {
2197
2232
  return this.#handleInternalUrl(internalTarget.path, parsed, signal);
2198
2233
  }
@@ -2205,48 +2240,68 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2205
2240
  // resolution share misses instead of re-globbing the workspace.
2206
2241
  const suffixCache: SuffixMatchCache = new Map();
2207
2242
 
2208
- const archivePath = await this.#resolveArchiveReadPath(readPath, suffixCache, signal);
2209
- if (archivePath) {
2210
- const archiveSubPath = splitPathAndSel(archivePath.archiveSubPath);
2211
- const archiveParsed = parseSel(archiveSubPath.sel);
2212
- return this.#readArchive(
2213
- readPath,
2214
- archiveParsed,
2215
- { ...archivePath, archiveSubPath: archiveSubPath.path },
2216
- signal,
2217
- );
2218
- }
2243
+ // Prefer a literal filesystem match over selector interpretation so real
2244
+ // POSIX filenames containing selector-looking suffixes win over structured
2245
+ // archive / sqlite / pdf-image dispatch. With explicit `selector`, `path`
2246
+ // is exact: `path: "test:1-2", selector: "1-2"` means "lines 1-2 from
2247
+ // the literal file test:1-2", without recursively depending on whether a
2248
+ // longer `test:1-2:1-2` filename also exists (issue #4618).
2249
+ const literalSplit =
2250
+ explicitSelector === undefined
2251
+ ? await splitPathAndSelPreferringLiteral(readPath, this.session.cwd)
2252
+ : { path: readPath, sel: explicitSelector };
2253
+ const rawPathIsLiteral =
2254
+ explicitSelector !== undefined
2255
+ ? readPath.includes(":") && (await probeLiteralPathExists(readPath, this.session.cwd)) !== "missing"
2256
+ : literalSplit.sel === undefined && splitPathAndSel(readPath).sel !== undefined;
2257
+
2258
+ if (!rawPathIsLiteral) {
2259
+ const archivePath = await this.#resolveArchiveReadPath(readPath, suffixCache, signal);
2260
+ if (archivePath) {
2261
+ const archiveSubPath =
2262
+ explicitSelector === undefined
2263
+ ? splitPathAndSel(archivePath.archiveSubPath)
2264
+ : { path: archivePath.archiveSubPath, sel: explicitSelector };
2265
+ const archiveParsed = parseSel(archiveSubPath.sel);
2266
+ return this.#readArchive(
2267
+ readPath,
2268
+ archiveParsed,
2269
+ { ...archivePath, archiveSubPath: archiveSubPath.path },
2270
+ signal,
2271
+ );
2272
+ }
2219
2273
 
2220
- const sqlitePath = await this.#resolveSqliteReadPath(readPath, suffixCache, signal);
2221
- if (sqlitePath) {
2222
- return this.#readSqlite(sqlitePath, signal);
2223
- }
2274
+ const sqlitePath = await this.#resolveSqliteReadPath(readPath, suffixCache, signal);
2275
+ if (sqlitePath) {
2276
+ return this.#readSqlite(sqlitePath, signal);
2277
+ }
2224
2278
 
2225
- const pdfImageMemberPath = splitPdfImageMemberReadPath(readPath);
2226
- if (pdfImageMemberPath) {
2227
- let absolutePdfPath = resolveReadPath(pdfImageMemberPath.pdfPath, this.session.cwd);
2228
- let suffixResolution: { from: string; to: string } | undefined;
2229
- try {
2230
- const stat = await Bun.file(absolutePdfPath).stat();
2231
- if (stat.isDirectory())
2232
- throw new ToolError(`Path '${pdfImageMemberPath.pdfPath}' is a directory, not a PDF file`);
2233
- } catch (error) {
2234
- if (!isNotFoundError(error) || isRemoteMountPath(absolutePdfPath)) throw error;
2235
- const suffixMatch = await this.#findSuffixMatchCached(suffixCache, pdfImageMemberPath.pdfPath, signal);
2236
- if (!suffixMatch) throw new ToolError(`Path '${pdfImageMemberPath.pdfPath}' not found`);
2237
- absolutePdfPath = suffixMatch.absolutePath;
2238
- suffixResolution = { from: pdfImageMemberPath.pdfPath, to: suffixMatch.displayPath };
2279
+ const pdfImageMemberPath = splitPdfImageMemberReadPath(readPath);
2280
+ if (pdfImageMemberPath) {
2281
+ let absolutePdfPath = resolveReadPath(pdfImageMemberPath.pdfPath, this.session.cwd);
2282
+ let suffixResolution: { from: string; to: string } | undefined;
2283
+ try {
2284
+ const stat = await Bun.file(absolutePdfPath).stat();
2285
+ if (stat.isDirectory())
2286
+ throw new ToolError(`Path '${pdfImageMemberPath.pdfPath}' is a directory, not a PDF file`);
2287
+ } catch (error) {
2288
+ if (!isNotFoundError(error) || isRemoteMountPath(absolutePdfPath)) throw error;
2289
+ const suffixMatch = await this.#findSuffixMatchCached(suffixCache, pdfImageMemberPath.pdfPath, signal);
2290
+ if (!suffixMatch) throw new ToolError(`Path '${pdfImageMemberPath.pdfPath}' not found`);
2291
+ absolutePdfPath = suffixMatch.absolutePath;
2292
+ suffixResolution = { from: pdfImageMemberPath.pdfPath, to: suffixMatch.displayPath };
2293
+ }
2294
+ return this.#readPdfImageMember(
2295
+ absolutePdfPath,
2296
+ pdfImageMemberPath.pdfPath,
2297
+ pdfImageMemberPath.member,
2298
+ suffixResolution,
2299
+ signal,
2300
+ );
2239
2301
  }
2240
- return this.#readPdfImageMember(
2241
- absolutePdfPath,
2242
- pdfImageMemberPath.pdfPath,
2243
- pdfImageMemberPath.member,
2244
- suffixResolution,
2245
- signal,
2246
- );
2247
2302
  }
2248
2303
 
2249
- const localTarget = splitPathAndSel(readPath);
2304
+ const localTarget = literalSplit;
2250
2305
  const localReadPath = localTarget.path;
2251
2306
  const parsed = parseSel(localTarget.sel);
2252
2307
 
@@ -3256,6 +3311,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
3256
3311
  interface ReadRenderArgs {
3257
3312
  path?: unknown;
3258
3313
  file_path?: unknown;
3314
+ selector?: unknown;
3259
3315
  sel?: string;
3260
3316
  // Legacy fields from old schema — tolerated for in-flight tool calls during transition
3261
3317
  offset?: number;
@@ -3320,10 +3376,20 @@ function formatReadPathLink(
3320
3376
 
3321
3377
  export const readToolRenderer = {
3322
3378
  renderCall(args: ReadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
3323
- const rawPath =
3379
+ const baseRawPath =
3324
3380
  typeof args.file_path === "string" ? args.file_path : typeof args.path === "string" ? args.path : "";
3325
- if (isReadableUrlPath(rawPath)) {
3326
- return renderReadUrlCall({ path: rawPath, raw: args.raw }, _options, uiTheme);
3381
+ const explicitSelector =
3382
+ typeof args.selector === "string"
3383
+ ? args.selector.trim().replace(/^:+/, "")
3384
+ : args.sel?.trim().replace(/^:+/, "");
3385
+ const rawPath =
3386
+ explicitSelector && explicitSelector.length > 0 ? `${baseRawPath}:${explicitSelector}` : baseRawPath;
3387
+ if (isReadableUrlPath(baseRawPath)) {
3388
+ return renderReadUrlCall(
3389
+ { path: rawPath, raw: args.raw || explicitSelector?.toLowerCase() === "raw" },
3390
+ _options,
3391
+ uiTheme,
3392
+ );
3327
3393
  }
3328
3394
 
3329
3395
  const offset = args.offset;
@@ -3347,9 +3413,9 @@ export const readToolRenderer = {
3347
3413
  args?: ReadRenderArgs,
3348
3414
  ): Component {
3349
3415
  const urlDetails = result.details as ReadUrlToolDetails | undefined;
3350
- const rawPathForKind =
3416
+ const baseRawPathForKind =
3351
3417
  typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
3352
- if (urlDetails?.kind === "url" || isReadableUrlPath(rawPathForKind)) {
3418
+ if (urlDetails?.kind === "url" || isReadableUrlPath(baseRawPathForKind)) {
3353
3419
  return renderReadUrlResult(
3354
3420
  result as {
3355
3421
  content: Array<{ type: string; text?: string }>;
@@ -3364,8 +3430,14 @@ export const readToolRenderer = {
3364
3430
  if (result.isError) {
3365
3431
  const rawErrorText = result.content?.find(c => c.type === "text")?.text ?? "";
3366
3432
  const errorText = (rawErrorText || "Unknown error").replace(/^Error:\s*/, "");
3367
- const rawPath =
3433
+ const baseRawPath =
3368
3434
  typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
3435
+ const explicitSelector =
3436
+ typeof args?.selector === "string"
3437
+ ? args.selector.trim().replace(/^:+/, "")
3438
+ : args?.sel?.trim().replace(/^:+/, "");
3439
+ const rawPath =
3440
+ explicitSelector && explicitSelector.length > 0 ? `${baseRawPath}:${explicitSelector}` : baseRawPath;
3369
3441
  const filePath =
3370
3442
  formatReadPathLink(rawPath, { offset: args?.offset, sourcePath: readSourceFsPath(result.details) }) ||
3371
3443
  shortenPath(rawPath);
@@ -3392,8 +3464,14 @@ export const readToolRenderer = {
3392
3464
  // echo next to the styled warning line below.
3393
3465
  const contentText = details?.displayContent?.text ?? stripOutputNotice(rawText, details?.meta);
3394
3466
  const imageContent = result.content?.find(c => c.type === "image");
3395
- const rawPath =
3467
+ const baseRawPath =
3396
3468
  typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
3469
+ const explicitSelector =
3470
+ typeof args?.selector === "string"
3471
+ ? args.selector.trim().replace(/^:+/, "")
3472
+ : args?.sel?.trim().replace(/^:+/, "");
3473
+ const rawPath =
3474
+ explicitSelector && explicitSelector.length > 0 ? `${baseRawPath}:${explicitSelector}` : baseRawPath;
3397
3475
  const renderPath = splitReadRenderPath(rawPath);
3398
3476
  const lang = getLanguageFromPath(renderPath.path);
3399
3477
 
@@ -32,6 +32,15 @@ import { sshToolRenderer } from "./ssh";
32
32
  import { todoToolRenderer } from "./todo";
33
33
  import { writeToolRenderer } from "./write";
34
34
 
35
+ /**
36
+ * Per-renderer opt-in for a full viewport replay when the first result
37
+ * replaces a painted pending-call render. A predicate receives the painted
38
+ * call args and render options so the repaint stays scoped to the pending
39
+ * shapes that actually re-anchor (an over-eager replay wipes native
40
+ * scrollback on direct terminals).
41
+ */
42
+ export type FirstResultViewportRepaint = boolean | ((args: unknown, options: RenderResultOptions) => boolean);
43
+
35
44
  export type ToolRenderer = {
36
45
  renderCall: (args: unknown, options: RenderResultOptions, theme: Theme) => Component;
37
46
  renderResult: (
@@ -55,12 +64,11 @@ export type ToolRenderer = {
55
64
  */
56
65
  animatedPartialResult?: boolean | ((args: unknown) => boolean);
57
66
  /**
58
- * Whether replacing a streamed pending placeholder with the first result
59
- * requires a full viewport repaint. Use for merged renderers whose pending
60
- * streamed args may have committed placeholder rows that the result render
61
- * re-anchors instead of preserving.
67
+ * Whether replacing a pending call render with the first result requires a
68
+ * full viewport repaint. Use for merged renderers whose pending rows can be
69
+ * re-anchored instead of preserved by the result render.
62
70
  */
63
- forceFirstResultViewportRepaint?: boolean;
71
+ forceFirstResultViewportRepaint?: FirstResultViewportRepaint;
64
72
  /**
65
73
  * Whether settling a provisional partial result into the final render requires
66
74
  * a full viewport repaint. Use when the result renderer changes chrome or
package/src/tools/ssh.ts CHANGED
@@ -244,6 +244,13 @@ interface SshRenderArgs {
244
244
  timeout?: number;
245
245
  }
246
246
 
247
+ /** Whether the painted call args still carry the streamed raw-JSON buffer —
248
+ * the shape that renders the `⏳ SSH: […]` / `$ …` placeholder. */
249
+ function hasStreamedRenderArgs(args: unknown): boolean {
250
+ if (args == null || typeof args !== "object" || !("__partialJson" in args)) return false;
251
+ return typeof args.__partialJson === "string";
252
+ }
253
+
247
254
  interface SshRenderContext {
248
255
  /** Visual lines for truncated output (pre-computed by tool-execution) */
249
256
  visualLines?: string[];
@@ -388,9 +395,9 @@ export const sshToolRenderer = {
388
395
  mergeCallAndResult: true,
389
396
  // Streamed args can initially render the SSH placeholder (`⏳ SSH: […]` /
390
397
  // `$ …`), then the first partial result inserts the `Output` section and
391
- // re-anchors the frame. Force a full repaint at that seam so placeholder rows
392
- // do not survive in viewport/native scrollback.
393
- forceFirstResultViewportRepaint: true,
398
+ // re-anchors the frame. Force a full repaint only at that streamed-placeholder
399
+ // seam so placeholder rows do not survive in viewport/native scrollback.
400
+ forceFirstResultViewportRepaint: hasStreamedRenderArgs,
394
401
  // The provisional pending-result frame settles into the final `⇄ SSH: [host]`
395
402
  // frame, so clear/replay the viewport at that topology flip too.
396
403
  forceResultViewportRepaintOnSettle: true,
package/src/tools/tts.ts CHANGED
@@ -103,7 +103,7 @@ async function synthesizeXai(
103
103
  content: [
104
104
  {
105
105
  type: "text",
106
- text: "No xAI credentials. Run /login → xAI Grok OAuth (SuperGrok Subscription) or set XAI_API_KEY.",
106
+ text: "No xAI credentials. Run /login → xAI Grok OAuth (SuperGrok or X Premium+) or set XAI_API_KEY.",
107
107
  },
108
108
  ],
109
109
  };
@@ -985,6 +985,24 @@ function countLines(text: string): number {
985
985
  return text.split("\n").length;
986
986
  }
987
987
 
988
+ /** Bounded newline scan: whether `text` spans more than `maxLines` lines.
989
+ * Runs on every live compose (the repaint predicate below), so it must not
990
+ * materialize the split the way `countLines` does. */
991
+ function exceedsLineCount(text: string, maxLines: number): boolean {
992
+ if (!text) return false;
993
+ let lines = 1;
994
+ for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) {
995
+ if (++lines > maxLines) return true;
996
+ }
997
+ return false;
998
+ }
999
+
1000
+ function writeContentOf(args: unknown): string {
1001
+ if (args == null || typeof args !== "object" || !("content" in args)) return "";
1002
+ const content = args.content;
1003
+ return typeof content === "string" ? content : "";
1004
+ }
1005
+
988
1006
  function formatLineCountSuffix(lineCount: number, uiTheme: Theme): string {
989
1007
  if (lineCount <= 0) return "";
990
1008
  return uiTheme.fg("dim", ` · ${lineCount} line${lineCount === 1 ? "" : "s"}`);
@@ -1218,4 +1236,12 @@ export const writeToolRenderer = {
1218
1236
  });
1219
1237
  },
1220
1238
  mergeCallAndResult: true,
1239
+ // The collapsed pending preview follows the streaming edge with a tail
1240
+ // window once the content outgrows it (`… (N earlier lines)` + last rows);
1241
+ // the first partial result re-anchors the frame to the top of the file, so
1242
+ // tail rows already committed to viewport/native scrollback would survive
1243
+ // as stale content above the new frame without a full replay. Expanded and
1244
+ // short previews stay top-anchored and skip the (scrollback-wiping) reset.
1245
+ forceFirstResultViewportRepaint: (args: unknown, options: RenderResultOptions) =>
1246
+ !options.expanded && exceedsLineCount(writeContentOf(args), WRITE_STREAMING_PREVIEW_LINES),
1221
1247
  };
@@ -0,0 +1,7 @@
1
+ /** formatLocalCalendarDate formats a Date as YYYY-MM-DD in the host local timezone. */
2
+ export function formatLocalCalendarDate(date: Date = new Date()): string {
3
+ const year = date.getFullYear();
4
+ const month = String(date.getMonth() + 1).padStart(2, "0");
5
+ const day = String(date.getDate()).padStart(2, "0");
6
+ return `${year}-${month}-${day}`;
7
+ }
package/src/utils/open.ts CHANGED
@@ -32,22 +32,48 @@ function getExistingWslLocalPath(urlOrPath: string): string | undefined {
32
32
  }
33
33
 
34
34
  /**
35
- * Resolve the Windows `rundll32.exe` command used to hand a URL/path to the
36
- * user's registered protocol handler. Anchoring to `%SystemRoot%\System32`
37
- * (rather than relying on `rundll32` being on `PATH`) survives environments
38
- * where the machine `PATH` no longer references `System32` — a common
39
- * real-world misconfiguration where `System32\Wbem` / `WindowsPowerShell` /
40
- * `OpenSSH` survive but `System32` itself is dropped. Bare `rundll32` on
41
- * such boxes throws `Executable not found in $PATH: "rundll32"` from
42
- * `Bun.spawn` before ShellExecute ever sees the URL.
35
+ * Resolve the Windows opener used to hand a URL/path to the user's registered
36
+ * protocol handler. PowerShell's `Start-Process` goes through ShellExecute
37
+ * like the previous `rundll32 url.dll,FileProtocolHandler`, with two
38
+ * advantages that make the delayed-failure telemetry in {@link openPath}
39
+ * actually observable on Windows:
40
+ *
41
+ * - `rundll32` exits 0 unconditionally, so no launch failure ever reaches the
42
+ * non-zero-exit logging below. `Start-Process` surfaces the failures
43
+ * ShellExecute itself reports — missing target file, no handler executable,
44
+ * access denied — as exit code 1 (verified live: a nonexistent file path
45
+ * exits 1; `$ErrorActionPreference='Stop'` additionally promotes any
46
+ * non-terminating error classes). Known limitation shared by every opener:
47
+ * an unregistered URL scheme exits 0 because Windows "handles" it by
48
+ * offering the app-picker.
49
+ * - `-EncodedCommand` carries the target as a UTF-16LE/base64 payload, so no
50
+ * cmd/PowerShell metacharacter parsing ever sees it (OAuth authorize URLs
51
+ * carry `&`); inside the decoded script the target is a single-quoted
52
+ * literal (no `$` expansion) with embedded quotes doubled.
53
+ *
54
+ * PowerShell is anchored to `%SystemRoot%\System32` for the same reason the
55
+ * previous revision anchored `rundll32`: machine PATHs that dropped
56
+ * `System32` are a real-world occurrence, and bare names throw
57
+ * `Executable not found in $PATH` from `Bun.spawn`. A bare-name fallback
58
+ * remains for exotic SystemRoot layouts.
43
59
  */
44
60
  function windowsOpenerCommand(target: string): string[] {
45
61
  const systemRoot = process.env.SystemRoot?.trim() || process.env.SYSTEMROOT?.trim() || "C:\\Windows";
46
62
  // `path.win32` (not the platform-adaptive `path.join`) keeps Windows path
47
63
  // separators when tests run under a POSIX host and matches Windows call
48
64
  // conventions on the real target.
49
- const rundll32 = path.win32.join(systemRoot, "System32", "rundll32.exe");
50
- return [rundll32, "url.dll,FileProtocolHandler", target];
65
+ const absolute = path.win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
66
+ const powershell = fs.existsSync(absolute) ? absolute : "powershell.exe";
67
+ const script = `$ErrorActionPreference='Stop';Start-Process '${target.replaceAll("'", "''")}'`;
68
+ return [
69
+ powershell,
70
+ "-NoProfile",
71
+ "-NonInteractive",
72
+ "-WindowStyle",
73
+ "Hidden",
74
+ "-EncodedCommand",
75
+ Buffer.from(script, "utf16le").toString("base64"),
76
+ ];
51
77
  }
52
78
  /** Open a URL or file path in the default browser/application. Best-effort, never throws. */
53
79
  export function openPath(urlOrPath: string): void {