@oh-my-pi/pi-coding-agent 16.1.14 → 16.1.16

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 (111) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/cli.js +3705 -4015
  3. package/dist/types/advisor/runtime.d.ts +3 -0
  4. package/dist/types/cli/args.d.ts +2 -5
  5. package/dist/types/cli/flag-tables.d.ts +2 -2
  6. package/dist/types/cli/session-picker.d.ts +4 -2
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/settings-schema.d.ts +32 -1
  9. package/dist/types/eval/agent-bridge.d.ts +19 -0
  10. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  11. package/dist/types/eval/js/shared/types.d.ts +1 -1
  12. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  13. package/dist/types/eval/py/executor.d.ts +1 -1
  14. package/dist/types/export/share.d.ts +8 -1
  15. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  16. package/dist/types/main.d.ts +2 -0
  17. package/dist/types/mcp/transports/stdio.d.ts +12 -1
  18. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  19. package/dist/types/modes/components/session-selector.d.ts +25 -0
  20. package/dist/types/modes/components/status-line/context-thresholds.d.ts +4 -3
  21. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  22. package/dist/types/secrets/obfuscator.d.ts +3 -3
  23. package/dist/types/task/isolation-runner.d.ts +128 -0
  24. package/dist/types/task/worktree.d.ts +14 -1
  25. package/dist/types/thinking.d.ts +15 -0
  26. package/dist/types/tools/eval-render.d.ts +3 -0
  27. package/dist/types/tools/eval.d.ts +11 -17
  28. package/dist/types/tools/todo.d.ts +26 -28
  29. package/dist/types/tui/output-block.d.ts +8 -0
  30. package/dist/types/utils/image-resize.d.ts +2 -0
  31. package/dist/types/utils/shell-snapshot.d.ts +10 -0
  32. package/dist/types/web/search/providers/exa.d.ts +2 -0
  33. package/dist/types/web/search/providers/perplexity.d.ts +17 -3
  34. package/package.json +12 -12
  35. package/scripts/build-binary.ts +18 -4
  36. package/src/advisor/__tests__/advisor.test.ts +114 -0
  37. package/src/advisor/runtime.ts +129 -1
  38. package/src/cli/args.ts +4 -5
  39. package/src/cli/flag-tables.ts +3 -3
  40. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  41. package/src/cli/gallery-fixtures/shell.ts +15 -23
  42. package/src/cli/session-picker.ts +17 -3
  43. package/src/commands/launch.ts +3 -3
  44. package/src/config/model-registry.ts +12 -4
  45. package/src/config/settings-schema.ts +37 -1
  46. package/src/edit/renderer.ts +34 -12
  47. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  48. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  49. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  50. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  51. package/src/eval/agent-bridge.ts +301 -71
  52. package/src/eval/jl/prelude.jl +32 -227
  53. package/src/eval/jl/runner.jl +38 -12
  54. package/src/eval/js/shared/helpers.ts +1 -114
  55. package/src/eval/js/shared/prelude.txt +13 -27
  56. package/src/eval/js/shared/runtime.ts +0 -6
  57. package/src/eval/js/shared/types.ts +1 -1
  58. package/src/eval/js/worker-protocol.ts +1 -1
  59. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  60. package/src/eval/py/executor.ts +1 -1
  61. package/src/eval/py/prelude.py +47 -105
  62. package/src/eval/py/runner.py +0 -6
  63. package/src/eval/rb/prelude.rb +21 -189
  64. package/src/eval/rb/runner.rb +116 -9
  65. package/src/exec/bash-executor.ts +44 -0
  66. package/src/export/html/tool-views.generated.js +29 -29
  67. package/src/export/share.ts +51 -28
  68. package/src/internal-urls/docs-index.generated.txt +1 -1
  69. package/src/internal-urls/local-protocol.ts +100 -53
  70. package/src/main.ts +15 -4
  71. package/src/mcp/transports/stdio.ts +20 -4
  72. package/src/modes/acp/acp-event-mapper.ts +7 -2
  73. package/src/modes/components/custom-editor.test.ts +22 -0
  74. package/src/modes/components/custom-editor.ts +10 -1
  75. package/src/modes/components/footer.ts +4 -3
  76. package/src/modes/components/plugin-settings.ts +7 -1
  77. package/src/modes/components/session-selector.ts +143 -29
  78. package/src/modes/components/status-line/component.ts +5 -1
  79. package/src/modes/components/status-line/context-thresholds.ts +11 -3
  80. package/src/modes/components/status-line/segments.ts +1 -1
  81. package/src/modes/components/status-line/types.ts +1 -0
  82. package/src/modes/controllers/command-controller.ts +6 -0
  83. package/src/modes/rpc/rpc-mode.ts +6 -0
  84. package/src/modes/utils/copy-targets.ts +7 -2
  85. package/src/prompts/system/system-prompt.md +6 -6
  86. package/src/prompts/system/workflow-notice.md +3 -3
  87. package/src/prompts/tools/bash.md +18 -2
  88. package/src/prompts/tools/eval.md +19 -19
  89. package/src/prompts/tools/search.md +1 -0
  90. package/src/prompts/tools/todo.md +1 -1
  91. package/src/secrets/obfuscator.ts +3 -9
  92. package/src/session/agent-session.ts +264 -52
  93. package/src/slash-commands/builtin-registry.ts +2 -1
  94. package/src/task/index.ts +61 -207
  95. package/src/task/isolation-runner.ts +354 -0
  96. package/src/task/worktree.ts +46 -9
  97. package/src/thinking.ts +20 -0
  98. package/src/tools/ask.ts +44 -38
  99. package/src/tools/bash.ts +9 -2
  100. package/src/tools/browser/tab-worker.ts +1 -1
  101. package/src/tools/eval-render.ts +34 -27
  102. package/src/tools/eval.ts +100 -103
  103. package/src/tools/index.ts +8 -1
  104. package/src/tools/read.ts +136 -60
  105. package/src/tools/todo.ts +60 -64
  106. package/src/tui/code-cell.ts +1 -1
  107. package/src/tui/output-block.ts +11 -0
  108. package/src/utils/image-resize.ts +30 -0
  109. package/src/utils/shell-snapshot.ts +63 -1
  110. package/src/web/search/providers/exa.ts +85 -1
  111. package/src/web/search/providers/perplexity.ts +18 -6
package/src/tools/eval.ts CHANGED
@@ -38,8 +38,6 @@ const EVAL_LANGUAGE_NAME: Record<EvalLanguageToken, string> = {
38
38
  rb: "Ruby",
39
39
  jl: "Julia",
40
40
  };
41
- const EVAL_CELLS_DESCRIPTION =
42
- "cells executed in order. State persists within each language across cells and tool calls.";
43
41
 
44
42
  /** Join names as an English "or" list: ["A"]→"A", ["A","B"]→"A or B", 3+→"A, B, or C". */
45
43
  function joinWithOr(items: readonly string[]): string {
@@ -56,12 +54,12 @@ function describeCodeField(langs: readonly EvalLanguageToken[]): string {
56
54
  const replLangs = langs.filter(lang => lang === "rb" || lang === "jl");
57
55
  // No persistent REPL backends → keep the original py/js phrasing verbatim so the
58
56
  // default (rb/jl off) wire schema stays byte-identical to the pre-feature one.
59
- if (replLangs.length === 0) return "cell body, verbatim. Use top-level await freely.";
57
+ if (replLangs.length === 0) return "code to run in this eval call, verbatim. Use top-level await freely.";
60
58
  const awaitLangs = langs.filter(lang => lang === "py" || lang === "js");
61
59
  const clauses: string[] = [];
62
60
  if (awaitLangs.length > 0) clauses.push(`Top-level \`await\` is available in ${awaitLangs.join("/")}`);
63
61
  clauses.push(`${replLangs.join("/")} auto-display the last expression like a REPL`);
64
- return `cell body, verbatim. ${clauses.join("; ")}.`;
62
+ return `code to run in this eval call, verbatim. ${clauses.join("; ")}.`;
65
63
  }
66
64
 
67
65
  /** One-line discovery summary listing the runtimes available this session. */
@@ -86,29 +84,24 @@ function enabledEvalLanguages(backends: EvalBackendsAllowance): EvalLanguageToke
86
84
 
87
85
  const evalCellCommonFields = {
88
86
  "title?": type("string").describe('short label shown in transcript (e.g. "imports", "load config")'),
89
- "timeout?": type("number").describe("per-cell timeout in seconds"),
90
- "reset?": type("boolean").describe(
91
- "wipe this cell's language kernel before running. Other languages are untouched.",
92
- ),
87
+ "timeout?": type("number").describe("timeout for this eval call in seconds"),
88
+ "reset?": type("boolean").describe("wipe this language's kernel before running. Other languages are untouched."),
93
89
  };
94
90
 
95
91
  /**
96
- * Per-cell input. Each cell runs in order; state persists within a language
97
- * across cells and across tool calls. This static schema carries the full
98
- * language union for typing; {@link buildEvalSchema} narrows the wire copy per
99
- * session so disabled backends are never advertised to the model.
92
+ * Per-call input: a single cell. State persists within a language across
93
+ * separate eval calls and across tool calls, so each call is one logical step
94
+ * and later calls reuse what earlier ones defined. This static schema carries
95
+ * the full language union for typing; {@link buildEvalSchema} narrows the wire
96
+ * copy per session so disabled backends are never advertised to the model.
100
97
  */
101
- const evalCellSchema = type({
98
+ export const evalSchema = type({
102
99
  language: type("'py' | 'js' | 'rb' | 'jl'").describe(describeLanguageField(EVAL_LANGUAGE_ORDER)),
103
- code: type("string").describe(describeCodeField(EVAL_LANGUAGE_ORDER)),
104
100
  ...evalCellCommonFields,
105
- });
106
- export type EvalCellInput = typeof evalCellSchema.infer;
107
-
108
- export const evalSchema = type({
109
- cells: evalCellSchema.array().atLeastLength(1).describe(EVAL_CELLS_DESCRIPTION),
101
+ code: type("string").describe(describeCodeField(EVAL_LANGUAGE_ORDER)),
110
102
  });
111
103
  export type EvalToolParams = typeof evalSchema.infer;
104
+ export type EvalCellInput = EvalToolParams;
112
105
 
113
106
  /**
114
107
  * Build a session-scoped copy of the eval schema whose `language` enum and field
@@ -118,14 +111,11 @@ export type EvalToolParams = typeof evalSchema.infer;
118
111
  * {@link evalSchema} (full union) remains the type-level source of truth.
119
112
  */
120
113
  function buildEvalSchema(langs: readonly EvalLanguageToken[]): typeof evalSchema {
121
- const cellSchema = type({
114
+ const schema = type({
122
115
  language: type.enumerated(...langs).describe(describeLanguageField(langs)),
123
116
  code: type("string").describe(describeCodeField(langs)),
124
117
  ...evalCellCommonFields,
125
118
  });
126
- const schema = type({
127
- cells: cellSchema.array().atLeastLength(1).describe(EVAL_CELLS_DESCRIPTION),
128
- });
129
119
  return schema as unknown as typeof evalSchema;
130
120
  }
131
121
 
@@ -290,22 +280,15 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
290
280
  readonly approval = "exec" as const;
291
281
  readonly formatApprovalDetails = (args: unknown): string[] => {
292
282
  const params = args as Partial<EvalToolParams>;
293
- const cells = Array.isArray(params.cells) ? params.cells : [];
294
- const firstCell = cells[0] as Partial<EvalCellInput> | undefined;
295
- if (!firstCell) return [];
296
283
  const language =
297
- typeof firstCell.language === "string" ? formatEvalInputLanguage(firstCell.language) : "javascript (default)";
298
- const code = typeof firstCell.code === "string" ? firstCell.code : "";
299
- const lines = [`Language: ${language}`, `Code:\n${truncateForPrompt(code)}`];
300
- if (cells.length > 1) {
301
- lines.push(`+${cells.length - 1} more cell${cells.length === 2 ? "" : "s"}`);
302
- }
303
- return lines;
284
+ typeof params.language === "string" ? formatEvalInputLanguage(params.language) : "javascript (default)";
285
+ const code = typeof params.code === "string" ? params.code : "";
286
+ return [`Language: ${language}`, `Code:\n${truncateForPrompt(code)}`];
304
287
  };
305
288
  get summary(): string {
306
289
  return summarizeEvalLanguages(this.#enabledLanguages());
307
290
  }
308
- readonly loadMode = "discoverable";
291
+ readonly loadMode = "essential";
309
292
  readonly label = "Eval";
310
293
  get description(): string {
311
294
  if (!this.session) return getEvalToolDescription();
@@ -320,25 +303,53 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
320
303
  spawns: spawnsAllowed,
321
304
  });
322
305
  }
323
- readonly examples: readonly ToolExample<typeof evalSchema.infer>[] = [
306
+ /** All reuse-chain examples; the `examples` getter filters by enabled languages. */
307
+ private static readonly ALL_EXAMPLES: readonly ToolExample<typeof evalSchema.infer>[] = [
324
308
  {
309
+ caption: "First call — set up once",
325
310
  call: {
326
- cells: [
327
- {
328
- language: "py",
329
- title: "imports",
330
- timeout: 10,
331
- code: "import json\nfrom pathlib import Path",
332
- },
333
- {
334
- language: "py",
335
- title: "load config",
336
- code: "data = json.loads(read('package.json'))\ndisplay(data)",
337
- },
338
- ],
311
+ language: "py",
312
+ title: "imports",
313
+ code: "import json\nfrom pathlib import Path",
314
+ },
315
+ },
316
+ {
317
+ caption: "Second call — reuse, do NOT re-import",
318
+ call: {
319
+ language: "py",
320
+ title: "load config",
321
+ code: "data = json.loads(read('package.json'))\ndisplay(data)",
322
+ },
323
+ },
324
+ {
325
+ caption: "Third call — reuse the loaded config",
326
+ call: {
327
+ language: "py",
328
+ title: "scan deps",
329
+ code: "display(sorted(data['dependencies']))",
330
+ },
331
+ },
332
+ {
333
+ caption: "Ruby first call — set up once",
334
+ call: {
335
+ language: "rb",
336
+ title: "setup",
337
+ code: "require 'json'\npkg_path = 'package.json'",
338
+ },
339
+ },
340
+ {
341
+ caption: "Ruby second call — reuse, do NOT re-require",
342
+ call: {
343
+ language: "rb",
344
+ title: "load config",
345
+ code: "pkg = JSON.parse(read(pkg_path))\ndisplay(pkg.keys.sort)",
339
346
  },
340
347
  },
341
348
  ];
349
+ get examples(): readonly ToolExample<typeof evalSchema.infer>[] {
350
+ const langs = new Set(this.#enabledLanguages());
351
+ return EvalTool.ALL_EXAMPLES.filter(ex => "call" in ex && langs.has(ex.call.language as EvalLanguageToken));
352
+ }
342
353
  get parameters(): typeof evalSchema {
343
354
  const langs = this.#enabledLanguages();
344
355
  if (langs.length === 0 || langs.length === EVAL_LANGUAGE_ORDER.length) return evalSchema;
@@ -352,13 +363,9 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
352
363
  readonly concurrency = "exclusive";
353
364
  readonly strict = true;
354
365
  readonly intent = (args: Partial<typeof evalSchema.infer>): string | undefined => {
355
- const cells = Array.isArray(args.cells) ? args.cells : [];
356
- const first = cells.find(c => c && typeof c === "object");
357
- if (!first) return "evaluating";
358
- const title = typeof first.title === "string" ? first.title : undefined;
359
- const language = typeof first.language === "string" ? formatEvalInputLanguage(first.language) : "javascript";
360
- const label = title || `running ${language}`;
361
- return cells.length > 1 ? `${label} (+${cells.length - 1})` : label;
366
+ const title = typeof args.title === "string" ? args.title : undefined;
367
+ const language = typeof args.language === "string" ? formatEvalInputLanguage(args.language) : "javascript";
368
+ return title || `running ${language}`;
362
369
  };
363
370
 
364
371
  readonly #proxyExecutor?: EvalProxyExecutor;
@@ -398,27 +405,25 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
398
405
  const session = this.session;
399
406
  const excludeWebP = webpExclusionForModel(session.getActiveModel?.());
400
407
 
401
- const cells: ResolvedEvalCell[] = [];
402
- for (let i = 0; i < params.cells.length; i++) {
403
- const cell = params.cells[i];
404
- const language: EvalLanguage =
405
- cell.language === "py"
406
- ? "python"
407
- : cell.language === "rb"
408
- ? "ruby"
409
- : cell.language === "jl"
410
- ? "julia"
411
- : "js";
412
- const resolved = await resolveBackend(session, language);
413
- cells.push({
414
- index: i,
415
- title: cell.title,
416
- code: cell.code,
417
- timeoutMs: (cell.timeout ?? 30) * 1000,
418
- reset: cell.reset ?? false,
408
+ const cellLanguage: EvalLanguage =
409
+ params.language === "py"
410
+ ? "python"
411
+ : params.language === "rb"
412
+ ? "ruby"
413
+ : params.language === "jl"
414
+ ? "julia"
415
+ : "js";
416
+ const resolved = await resolveBackend(session, cellLanguage);
417
+ const cells: ResolvedEvalCell[] = [
418
+ {
419
+ index: 0,
420
+ title: params.title,
421
+ code: params.code,
422
+ timeoutMs: (params.timeout ?? 30) * 1000,
423
+ reset: params.reset ?? false,
419
424
  resolved,
420
- });
421
- }
425
+ },
426
+ ];
422
427
  const languages = uniqueEvalLanguages(cells);
423
428
  const notice = detailsNotice(cells);
424
429
  const sessionAbortController = new AbortController();
@@ -453,6 +458,13 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
453
458
  status: "pending",
454
459
  }));
455
460
  const cellOutputs: string[] = [];
461
+ // The cell currently inside backend.execute(). Streamed stdout is
462
+ // appended to its rendered `output` live so a long-running cell (e.g. a
463
+ // sleep loop) shows progress instead of nothing until it returns. A
464
+ // dedicated per-cell tail buffer keeps attribution correct and avoids
465
+ // double-counting against the aggregate `tailBuffer`; on completion the
466
+ // authoritative `cellResult.output` (below) overwrites this live tail.
467
+ let activeLiveCell: { result: EvalCellResult; buf: TailBuffer } | undefined;
456
468
 
457
469
  const appendTail = (text: string) => {
458
470
  tailBuffer.append(text);
@@ -502,6 +514,10 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
502
514
  maxColumns: resolveOutputMaxColumns(session.settings),
503
515
  onChunk: chunk => {
504
516
  appendTail(chunk);
517
+ if (activeLiveCell) {
518
+ activeLiveCell.buf.append(chunk);
519
+ activeLiveCell.result.output = activeLiveCell.buf.text();
520
+ }
505
521
  pushUpdate();
506
522
  },
507
523
  });
@@ -529,6 +545,7 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
529
545
  cellResult.statusEvents = undefined;
530
546
  cellResult.exitCode = undefined;
531
547
  cellResult.durationMs = undefined;
548
+ activeLiveCell = { result: cellResult, buf: new TailBuffer(DEFAULT_MAX_BYTES * 2) };
532
549
  pushUpdate();
533
550
 
534
551
  const startTime = Date.now();
@@ -562,6 +579,7 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
562
579
  });
563
580
  } finally {
564
581
  idle.dispose();
582
+ activeLiveCell = undefined;
565
583
  }
566
584
  const durationMs = Date.now() - startTime;
567
585
 
@@ -623,24 +641,9 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
623
641
  cellResult.statusEvents = cellStatusEvents.length > 0 ? cellStatusEvents : undefined;
624
642
  cellResult.hasMarkdown = cellHasMarkdown || undefined;
625
643
 
626
- let combinedCellOutput = "";
627
- if (cells.length > 1) {
628
- const cellHeader = `[${i + 1}/${cells.length}]`;
629
- const cellTitle = cell.title ? ` ${cell.title}` : "";
630
- if (cellOutput) {
631
- combinedCellOutput = `${cellHeader}${cellTitle}\n${cellOutput}`;
632
- } else {
633
- combinedCellOutput = `${cellHeader}${cellTitle} (ok)`;
634
- }
635
- cellOutputs.push(combinedCellOutput);
636
- } else if (cellOutput) {
637
- combinedCellOutput = cellOutput;
638
- cellOutputs.push(combinedCellOutput);
639
- }
640
-
641
- if (combinedCellOutput) {
642
- const prefix = cellOutputs.length > 1 ? "\n\n" : "";
643
- appendTail(`${prefix}${combinedCellOutput}`);
644
+ if (cellOutput) {
645
+ cellOutputs.push(cellOutput);
646
+ appendTail(cellOutput);
644
647
  }
645
648
 
646
649
  if (result.cancelled) {
@@ -648,10 +651,7 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
648
651
  pushUpdate();
649
652
  const errorMsg = result.output || "Command aborted";
650
653
  const combinedOutput = cellOutputs.join("\n\n");
651
- const outputText =
652
- cells.length > 1
653
- ? `${combinedOutput}\n\nCell ${i + 1} aborted: ${errorMsg}`
654
- : combinedOutput || errorMsg;
654
+ const outputText = combinedOutput || errorMsg;
655
655
 
656
656
  const summaryForMeta = await summarizeFinal(combinedOutput, finalizeOutput);
657
657
  const details: EvalToolDetails = {
@@ -674,12 +674,9 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
674
674
  cellResult.status = "error";
675
675
  pushUpdate();
676
676
  const combinedOutput = cellOutputs.join("\n\n");
677
- const outputText =
678
- cells.length > 1
679
- ? `${combinedOutput}\n\nCell ${i + 1} failed (exit code ${result.exitCode}). Earlier cells succeeded—their state persists. Fix only cell ${i + 1}.`
680
- : combinedOutput
681
- ? `${combinedOutput}\n\nCommand exited with code ${result.exitCode}`
682
- : `Command exited with code ${result.exitCode}`;
677
+ const outputText = combinedOutput
678
+ ? `${combinedOutput}\n\nCommand exited with code ${result.exitCode}`
679
+ : `Command exited with code ${result.exitCode}`;
683
680
 
684
681
  const summaryForMeta = await summarizeFinal(combinedOutput, finalizeOutput);
685
682
  const details: EvalToolDetails = {
@@ -377,7 +377,14 @@ export type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool |
377
377
  export type BuiltinToolLoadMode = "essential" | "discoverable";
378
378
 
379
379
  /** Default essential tool names when tools.essentialOverride is empty. */
380
- export const DEFAULT_ESSENTIAL_TOOL_NAMES: readonly string[] = ["read", "bash", "edit", "write", "find"] as const;
380
+ export const DEFAULT_ESSENTIAL_TOOL_NAMES: readonly string[] = [
381
+ "read",
382
+ "bash",
383
+ "edit",
384
+ "write",
385
+ "find",
386
+ "eval",
387
+ ] as const;
381
388
 
382
389
  /**
383
390
  * Resolve the active essential built-in tool names from settings.
package/src/tools/read.ts CHANGED
@@ -8,7 +8,7 @@ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
8
8
  import { glob, type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives";
9
9
  import type { Component } from "@oh-my-pi/pi-tui";
10
10
  import { Text } from "@oh-my-pi/pi-tui";
11
- import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
11
+ import { getRemoteDir, type ImageMetadata, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
12
12
  import { type } from "arktype";
13
13
  import { LRUCache } from "lru-cache/raw";
14
14
  import {
@@ -22,7 +22,7 @@ import {
22
22
  import { normalizeToLF } from "../edit/normalize";
23
23
  import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
24
24
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
25
- import { InternalUrlRouter } from "../internal-urls";
25
+ import { InternalUrlRouter, resolveLocalUrlToFile } from "../internal-urls";
26
26
  import { parseInternalUrl } from "../internal-urls/parse";
27
27
  import type { InternalUrl } from "../internal-urls/types";
28
28
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
@@ -1112,6 +1112,79 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1112
1112
  .done();
1113
1113
  }
1114
1114
 
1115
+ /**
1116
+ * Build content blocks for an on-disk image file: an `inspect_image`
1117
+ * metadata note when inspection is enabled, otherwise the decoded image
1118
+ * block. Shared by the plain-file read path and the `local://` image fast
1119
+ * path so both honor `inspect_image.enabled`, the size cap, and auto-resize
1120
+ * identically. Too-large / unsupported images surface as {@link ToolError}.
1121
+ */
1122
+ async #loadImageContent(options: {
1123
+ readPath: string;
1124
+ absolutePath: string;
1125
+ mimeType: string;
1126
+ imageMetadata: ImageMetadata | null;
1127
+ fileSize: number;
1128
+ }): Promise<{ content: Array<TextContent | ImageContent>; details: ReadToolDetails; sourcePath: string }> {
1129
+ const { readPath, absolutePath, mimeType, imageMetadata, fileSize } = options;
1130
+ if (this.#inspectImageEnabled) {
1131
+ const outputMime = imageMetadata?.mimeType ?? mimeType;
1132
+ const metadataLines = [
1133
+ "Image metadata:",
1134
+ `- MIME: ${outputMime}`,
1135
+ `- Bytes: ${fileSize} (${formatBytes(fileSize)})`,
1136
+ imageMetadata?.width !== undefined && imageMetadata.height !== undefined
1137
+ ? `- Dimensions: ${imageMetadata.width}x${imageMetadata.height}`
1138
+ : "- Dimensions: unknown",
1139
+ imageMetadata?.channels !== undefined ? `- Channels: ${imageMetadata.channels}` : "- Channels: unknown",
1140
+ imageMetadata?.hasAlpha === true
1141
+ ? "- Alpha: yes"
1142
+ : imageMetadata?.hasAlpha === false
1143
+ ? "- Alpha: no"
1144
+ : "- Alpha: unknown",
1145
+ "",
1146
+ `If you want to analyze the image, call inspect_image with path="${formatPathRelativeToCwd(
1147
+ absolutePath,
1148
+ this.session.cwd,
1149
+ )}" and a question describing what to inspect and the desired output format.`,
1150
+ ];
1151
+ return { content: [{ type: "text", text: metadataLines.join("\n") }], details: {}, sourcePath: absolutePath };
1152
+ }
1153
+
1154
+ if (fileSize > MAX_IMAGE_SIZE) {
1155
+ const sizeStr = formatBytes(fileSize);
1156
+ const maxStr = formatBytes(MAX_IMAGE_SIZE);
1157
+ throw new ToolError(`Image file too large: ${sizeStr} exceeds ${maxStr} limit.`);
1158
+ }
1159
+ try {
1160
+ const imageInput = await loadImageInput({
1161
+ path: readPath,
1162
+ cwd: this.session.cwd,
1163
+ autoResize: this.#autoResizeImages,
1164
+ maxBytes: MAX_IMAGE_SIZE,
1165
+ resolvedPath: absolutePath,
1166
+ detectedMimeType: mimeType,
1167
+ excludeWebP: webpExclusionForModel(this.session.getActiveModel?.()),
1168
+ });
1169
+ if (!imageInput) {
1170
+ throw new ToolError(`Read image file [${mimeType}] failed: unsupported image format.`);
1171
+ }
1172
+ return {
1173
+ content: [
1174
+ { type: "text", text: imageInput.textNote },
1175
+ { type: "image", data: imageInput.data, mimeType: imageInput.mimeType },
1176
+ ],
1177
+ details: {},
1178
+ sourcePath: imageInput.resolvedPath,
1179
+ };
1180
+ } catch (error) {
1181
+ if (error instanceof ImageInputTooLargeError) {
1182
+ throw new ToolError(error.message);
1183
+ }
1184
+ throw error;
1185
+ }
1186
+ }
1187
+
1115
1188
  #buildInMemoryTextResult(
1116
1189
  text: string,
1117
1190
  offset: number | undefined,
@@ -2132,64 +2205,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2132
2205
  | undefined;
2133
2206
 
2134
2207
  if (mimeType) {
2135
- if (this.#inspectImageEnabled) {
2136
- const metadata = imageMetadata;
2137
- const outputMime = metadata?.mimeType ?? mimeType;
2138
- const outputBytes = fileSize;
2139
- const metadataLines = [
2140
- "Image metadata:",
2141
- `- MIME: ${outputMime}`,
2142
- `- Bytes: ${outputBytes} (${formatBytes(outputBytes)})`,
2143
- metadata?.width !== undefined && metadata.height !== undefined
2144
- ? `- Dimensions: ${metadata.width}x${metadata.height}`
2145
- : "- Dimensions: unknown",
2146
- metadata?.channels !== undefined ? `- Channels: ${metadata.channels}` : "- Channels: unknown",
2147
- metadata?.hasAlpha === true
2148
- ? "- Alpha: yes"
2149
- : metadata?.hasAlpha === false
2150
- ? "- Alpha: no"
2151
- : "- Alpha: unknown",
2152
- "",
2153
- `If you want to analyze the image, call inspect_image with path="${formatPathRelativeToCwd(
2154
- absolutePath,
2155
- this.session.cwd,
2156
- )}" and a question describing what to inspect and the desired output format.`,
2157
- ];
2158
- content = [{ type: "text", text: metadataLines.join("\n") }];
2159
- details = {};
2160
- sourcePath = absolutePath;
2161
- } else {
2162
- if (fileSize > MAX_IMAGE_SIZE) {
2163
- const sizeStr = formatBytes(fileSize);
2164
- const maxStr = formatBytes(MAX_IMAGE_SIZE);
2165
- throw new ToolError(`Image file too large: ${sizeStr} exceeds ${maxStr} limit.`);
2166
- }
2167
- try {
2168
- const imageInput = await loadImageInput({
2169
- path: readPath,
2170
- cwd: this.session.cwd,
2171
- autoResize: this.#autoResizeImages,
2172
- maxBytes: MAX_IMAGE_SIZE,
2173
- resolvedPath: absolutePath,
2174
- detectedMimeType: mimeType,
2175
- excludeWebP: webpExclusionForModel(this.session.getActiveModel?.()),
2176
- });
2177
- if (!imageInput) {
2178
- throw new ToolError(`Read image file [${mimeType}] failed: unsupported image format.`);
2179
- }
2180
- content = [
2181
- { type: "text", text: imageInput.textNote },
2182
- { type: "image", data: imageInput.data, mimeType: imageInput.mimeType },
2183
- ];
2184
- details = {};
2185
- sourcePath = imageInput.resolvedPath;
2186
- } catch (error) {
2187
- if (error instanceof ImageInputTooLargeError) {
2188
- throw new ToolError(error.message);
2189
- }
2190
- throw error;
2191
- }
2192
- }
2208
+ ({ content, details, sourcePath } = await this.#loadImageContent({
2209
+ readPath,
2210
+ absolutePath,
2211
+ mimeType,
2212
+ imageMetadata,
2213
+ fileSize,
2214
+ }));
2193
2215
  } else if (isNotebookPath(absolutePath) && !isRawSelector(parsed)) {
2194
2216
  const notebookText = await readEditableNotebookText(absolutePath, localReadPath);
2195
2217
  if (isMultiRange(parsed) && parsed.kind === "lines") {
@@ -2727,6 +2749,17 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2727
2749
  hasExtraction = hasPathExtraction || hasQueryExtraction;
2728
2750
  }
2729
2751
 
2752
+ // local:// files are real on-disk paths. Detect image files and emit a
2753
+ // decoded image block before the text-only resource contract UTF-8
2754
+ // decodes the binary into mojibake. The fast path returns null for
2755
+ // non-images, directories, listings, or any resolution failure, so the
2756
+ // text path below reproduces the router's not-found / symlink-escape
2757
+ // behavior unchanged.
2758
+ if (scheme === "local") {
2759
+ const imageResult = await this.#tryReadLocalImage(urlMeta, signal);
2760
+ if (imageResult) return imageResult;
2761
+ }
2762
+
2730
2763
  // Reject line selectors when query extraction is used
2731
2764
  if (hasExtraction && parsedSel.kind !== "none" && parsedSel.kind !== "raw") {
2732
2765
  throw new ToolError("Cannot combine query extraction with line selectors");
@@ -2770,6 +2803,49 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2770
2803
  });
2771
2804
  }
2772
2805
 
2806
+ /**
2807
+ * Fast path for `local://` image files. Resolves the URL to its real
2808
+ * on-disk path with the same realpath + containment checks as
2809
+ * {@link LocalProtocolHandler.resolve} (via {@link resolveLocalUrlToFile}),
2810
+ * and — only when the target is a genuine image — emits a decoded image
2811
+ * block. Returns null for non-images, directories, listings, or any
2812
+ * resolution failure (not-found, symlink escape) so the caller falls back to
2813
+ * normal text resolution, which reproduces the router's errors. Errors from
2814
+ * a confirmed image (too large / unsupported) propagate rather than
2815
+ * degrading into a corrupted text read.
2816
+ */
2817
+ async #tryReadLocalImage(url: InternalUrl, signal?: AbortSignal): Promise<AgentToolResult<ReadToolDetails> | null> {
2818
+ let file: { path: string; size: number } | null;
2819
+ try {
2820
+ file = await resolveLocalUrlToFile(url, {
2821
+ cwd: this.session.cwd,
2822
+ settings: this.session.settings,
2823
+ signal,
2824
+ localProtocolOptions: this.session.localProtocolOptions,
2825
+ });
2826
+ } catch {
2827
+ // Not found / containment escape / no session — let the text path
2828
+ // surface the router's canonical error.
2829
+ return null;
2830
+ }
2831
+ if (!file) return null;
2832
+
2833
+ const imageMetadata = await readImageMetadata(file.path);
2834
+ const mimeType = imageMetadata?.mimeType;
2835
+ if (!mimeType) return null;
2836
+
2837
+ const { content, details, sourcePath } = await this.#loadImageContent({
2838
+ readPath: url.href,
2839
+ absolutePath: file.path,
2840
+ mimeType,
2841
+ imageMetadata,
2842
+ fileSize: file.size,
2843
+ });
2844
+ const resultBuilder = toolResult(details).content(content).sourceInternal(url.href);
2845
+ if (sourcePath) resultBuilder.sourcePath(sourcePath);
2846
+ return resultBuilder.done();
2847
+ }
2848
+
2773
2849
  /** Read directory contents as a formatted listing */
2774
2850
  async #readDirectory(
2775
2851
  absolutePath: string,