@oh-my-pi/pi-coding-agent 16.1.17 → 16.1.19

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 (40) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/cli.js +17872 -17571
  3. package/dist/types/cli/usage-cli.d.ts +14 -0
  4. package/dist/types/commands/token.d.ts +9 -0
  5. package/dist/types/edit/hashline/filesystem.d.ts +1 -18
  6. package/dist/types/extensibility/plugins/legacy-pi-bundled-keys.d.ts +10 -0
  7. package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +10 -0
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +43 -27
  9. package/dist/types/internal-urls/skill-protocol.d.ts +2 -2
  10. package/dist/types/internal-urls/types.d.ts +3 -0
  11. package/dist/types/modes/components/custom-editor.d.ts +0 -2
  12. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +26 -10
  13. package/dist/types/modes/components/extensions/extension-list.d.ts +13 -0
  14. package/dist/types/modes/controllers/input-controller.d.ts +0 -1
  15. package/dist/types/tools/path-utils.d.ts +3 -0
  16. package/package.json +12 -12
  17. package/scripts/build-binary.ts +29 -16
  18. package/scripts/generate-legacy-pi-bundled-registry.ts +404 -0
  19. package/src/cli/usage-cli.ts +35 -4
  20. package/src/commands/token.ts +54 -0
  21. package/src/edit/hashline/filesystem.ts +14 -0
  22. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +987 -0
  23. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +3352 -0
  24. package/src/extensibility/plugins/legacy-pi-compat.ts +264 -131
  25. package/src/internal-urls/local-protocol.ts +116 -9
  26. package/src/internal-urls/skill-protocol.ts +3 -3
  27. package/src/internal-urls/types.ts +3 -0
  28. package/src/modes/components/custom-editor.test.ts +7 -5
  29. package/src/modes/components/custom-editor.ts +6 -16
  30. package/src/modes/components/extensions/extension-dashboard.ts +221 -165
  31. package/src/modes/components/extensions/extension-list.ts +66 -33
  32. package/src/modes/controllers/input-controller.ts +0 -71
  33. package/src/modes/controllers/selector-controller.ts +4 -0
  34. package/src/session/messages.ts +70 -47
  35. package/src/tools/ast-edit.ts +1 -0
  36. package/src/tools/ast-grep.ts +1 -0
  37. package/src/tools/find.ts +1 -0
  38. package/src/tools/path-utils.ts +4 -0
  39. package/src/tools/read.ts +43 -17
  40. package/src/tools/search.ts +4 -0
@@ -40,6 +40,9 @@ export class ExtensionList implements Component {
40
40
  #focused = false;
41
41
  #masterSwitchProvider: string | null = null;
42
42
  #maxVisible: number;
43
+ #hoveredIndex: number | null = null;
44
+ /** Item rows rendered in the last frame, for mouse hit-testing. */
45
+ #visibleCount = 0;
43
46
 
44
47
  constructor(
45
48
  private extensions: Extension[],
@@ -108,6 +111,7 @@ export class ExtensionList implements Component {
108
111
 
109
112
  render(width: number): readonly string[] {
110
113
  const lines: string[] = [];
114
+ this.#visibleCount = 0;
111
115
 
112
116
  // Search bar
113
117
  const searchPrefix = theme.fg("muted", "Search: ");
@@ -136,15 +140,20 @@ export class ExtensionList implements Component {
136
140
  for (let i = startIdx; i < endIdx; i++) {
137
141
  const listItem = this.#listItems[i];
138
142
  const isSelected = this.#focused && i === this.#selectedIndex;
143
+ const isHovered = this.#focused && i === this.#hoveredIndex && !isSelected;
139
144
 
145
+ let rowStr: string;
140
146
  if (listItem.type === "master") {
141
- rows.push(this.#renderMasterSwitch(listItem, isSelected, rowWidth));
147
+ rowStr = this.#renderMasterSwitch(listItem, isSelected, rowWidth);
142
148
  } else if (listItem.type === "kind-header") {
143
- rows.push(this.#renderKindHeader(listItem, isSelected, rowWidth));
149
+ rowStr = this.#renderKindHeader(listItem, isSelected, rowWidth);
144
150
  } else {
145
- rows.push(this.#renderExtensionRow(listItem.item, isSelected, rowWidth, masterDisabled));
151
+ rowStr = this.#renderExtensionRow(listItem.item, isSelected, rowWidth, masterDisabled);
146
152
  }
153
+ if (isHovered) rowStr = theme.bg("selectedBg", rowStr);
154
+ rows.push(rowStr);
147
155
  }
156
+ this.#visibleCount = rows.length;
148
157
 
149
158
  lines.push(
150
159
  ...renderScrollableList(rows, {
@@ -387,6 +396,57 @@ export class ExtensionList implements Component {
387
396
  this.#scrollOffset = next.scrollOffset;
388
397
  }
389
398
 
399
+ /** Toggle the selected item, or flip the provider master switch when on it. */
400
+ #activateSelected(): void {
401
+ const item = this.#listItems[this.#selectedIndex];
402
+ if (item?.type === "master") {
403
+ this.callbacks.onMasterToggle?.(item.providerId);
404
+ } else if (item?.type === "extension") {
405
+ // Only allow toggling if the provider master switch is enabled.
406
+ const masterDisabled = this.#masterSwitchProvider !== null && !isProviderEnabled(this.#masterSwitchProvider);
407
+ if (!masterDisabled) {
408
+ const newEnabled = item.item.state === "disabled";
409
+ this.callbacks.onToggle?.(item.item.id, newEnabled);
410
+ }
411
+ }
412
+ }
413
+
414
+ /** Highlight the row under the pointer (null clears). */
415
+ setHoverIndex(index: number | null): void {
416
+ this.#hoveredIndex = index;
417
+ }
418
+
419
+ /**
420
+ * Map a 0-based line within this component's render to the absolute list-item
421
+ * index, or null when the line is the search banner, a padding row, or outside
422
+ * the visible window. The first two lines are the search banner and a blank
423
+ * separator; item rows follow, windowed at the current scroll offset.
424
+ */
425
+ hitTest(line: number): number | null {
426
+ const rowLine = line - 2;
427
+ if (rowLine < 0 || rowLine >= this.#visibleCount) return null;
428
+ const index = this.#scrollOffset + rowLine;
429
+ return index < this.#listItems.length ? index : null;
430
+ }
431
+
432
+ /** Wheel notch: move the selection (and the inspector) one row. */
433
+ handleWheel(delta: -1 | 1): void {
434
+ if (delta < 0) this.#moveSelectionUp();
435
+ else this.#moveSelectionDown();
436
+ }
437
+
438
+ /** Click: select the row under the pointer, or activate it when already selected. */
439
+ handleClick(line: number): void {
440
+ const index = this.hitTest(line);
441
+ if (index === null) return;
442
+ if (index === this.#selectedIndex) {
443
+ this.#activateSelected();
444
+ return;
445
+ }
446
+ this.#selectedIndex = index;
447
+ this.#notifySelectionChange();
448
+ }
449
+
390
450
  handleInput(data: string): void {
391
451
  // Navigation
392
452
  if (matchesSelectUp(data) || data === "k") {
@@ -399,36 +459,9 @@ export class ExtensionList implements Component {
399
459
  return;
400
460
  }
401
461
 
402
- // Space: Toggle selected item
403
- if (data === " ") {
404
- const item = this.#listItems[this.#selectedIndex];
405
- if (item?.type === "master") {
406
- this.callbacks.onMasterToggle?.(item.providerId);
407
- } else if (item?.type === "extension") {
408
- // Only allow toggling if master is enabled
409
- const masterDisabled =
410
- this.#masterSwitchProvider !== null && !isProviderEnabled(this.#masterSwitchProvider);
411
- if (!masterDisabled) {
412
- const newEnabled = item.item.state === "disabled";
413
- this.callbacks.onToggle?.(item.item.id, newEnabled);
414
- }
415
- }
416
- return;
417
- }
418
-
419
- // Enter: Same as space - toggle selected item
420
- if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
421
- const item = this.#listItems[this.#selectedIndex];
422
- if (item?.type === "master") {
423
- this.callbacks.onMasterToggle?.(item.providerId);
424
- } else if (item?.type === "extension") {
425
- const masterDisabled =
426
- this.#masterSwitchProvider !== null && !isProviderEnabled(this.#masterSwitchProvider);
427
- if (!masterDisabled) {
428
- const newEnabled = item.item.state === "disabled";
429
- this.callbacks.onToggle?.(item.item.id, newEnabled);
430
- }
431
- }
462
+ // Space or Enter: activate the selected row (toggle item / master switch)
463
+ if (data === " " || matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
464
+ this.#activateSelected();
432
465
  return;
433
466
  }
434
467
 
@@ -1,6 +1,5 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
3
  import type { ImageContent } from "@oh-my-pi/pi-ai";
5
4
  import { type AutocompleteProvider, matchesKey, type SlashCommand } from "@oh-my-pi/pi-tui";
6
5
  import { $env, isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils";
@@ -20,7 +19,6 @@ import { isTinyTitleLocalModelKey } from "../../tiny/models";
20
19
  import { isLowSignalTitleInput } from "../../tiny/text";
21
20
  import { tinyTitleClient } from "../../tiny/title-client";
22
21
  import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
23
- import { resolveReadPath } from "../../tools/path-utils";
24
22
  import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
25
23
  import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard";
26
24
  import { EnhancedPasteController } from "../../utils/enhanced-paste";
@@ -104,20 +102,6 @@ function wrapPasteInAttachmentBlock(content: string): string {
104
102
  return `<attachment>\n${content}\n</attachment>`;
105
103
  }
106
104
 
107
- const FILE_URI_REGEX = /^file:\/\//i;
108
-
109
- function pastedFileAttachmentExtension(sourcePath: string): string {
110
- const ext = path.extname(sourcePath);
111
- const bareExt = ext.slice(1);
112
- if (!bareExt || bareExt.length > 32 || !/^[a-z0-9][a-z0-9._-]*$/i.test(bareExt)) return "";
113
- return ext;
114
- }
115
-
116
- function resolvePastedFilePath(filePath: string, cwd: string): string {
117
- if (FILE_URI_REGEX.test(filePath)) return fileURLToPath(filePath);
118
- return resolveReadPath(filePath, cwd);
119
- }
120
-
121
105
  const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000;
122
106
  // A cached model fires its file-load events in a short burst and then goes silent
123
107
  // while onnxruntime builds the session; a genuine download keeps streaming progress
@@ -392,7 +376,6 @@ export class InputController {
392
376
  );
393
377
  this.ctx.editor.onPasteImage = () => this.handleImagePaste();
394
378
  this.ctx.editor.onPasteImagePath = path => this.handleImagePathPaste(path);
395
- this.ctx.editor.onPasteFilePath = path => this.handleFilePathPaste(path);
396
379
  this.ctx.editor.setActionKeys(
397
380
  "app.clipboard.pasteTextRaw",
398
381
  this.ctx.keybindings.getKeys("app.clipboard.pasteTextRaw"),
@@ -1261,37 +1244,6 @@ export class InputController {
1261
1244
  }
1262
1245
  }
1263
1246
 
1264
- async handleFilePathPaste(filePath: string): Promise<void> {
1265
- try {
1266
- const resolvedPath = resolvePastedFilePath(filePath, this.ctx.sessionManager.getCwd());
1267
- const stat = await Bun.file(resolvedPath).stat();
1268
- if (!stat.isFile()) {
1269
- this.ctx.editor.pasteText(filePath);
1270
- this.ctx.ui.requestRender();
1271
- this.ctx.showStatus("Pasted path is not a file");
1272
- return;
1273
- }
1274
-
1275
- const reference = await this.#attachExistingFileAsLocal(resolvedPath);
1276
- this.ctx.editor.insertText(`${reference} `);
1277
- this.ctx.ui.requestRender();
1278
- this.ctx.showStatus(`Attached file as ${reference}`);
1279
- } catch (error) {
1280
- if (isEnoent(error)) {
1281
- this.ctx.editor.pasteText(filePath);
1282
- this.ctx.ui.requestRender();
1283
- this.ctx.showStatus("Pasted file path was not found");
1284
- return;
1285
- }
1286
- logger.warn("failed to attach pasted file path", {
1287
- error: error instanceof Error ? error.message : String(error),
1288
- });
1289
- this.ctx.editor.pasteText(filePath);
1290
- this.ctx.ui.requestRender();
1291
- this.ctx.showError("Failed to attach pasted file path — pasted path inline instead");
1292
- }
1293
- }
1294
-
1295
1247
  async handleImagePathPaste(path: string): Promise<void> {
1296
1248
  try {
1297
1249
  const image = await loadImageInput({
@@ -1462,29 +1414,6 @@ export class InputController {
1462
1414
  this.ctx.ui.requestRender();
1463
1415
  }
1464
1416
 
1465
- async #attachExistingFileAsLocal(sourcePath: string): Promise<string> {
1466
- const localRoot = resolveLocalRoot({
1467
- getArtifactsDir: () => this.ctx.sessionManager.getArtifactsDir(),
1468
- getSessionId: () => this.ctx.sessionManager.getSessionId(),
1469
- });
1470
- await fs.mkdir(localRoot, { recursive: true });
1471
- const ext = pastedFileAttachmentExtension(sourcePath);
1472
- let name: string;
1473
- let filePath: string;
1474
- do {
1475
- this.#attachmentCounter++;
1476
- name = `attachment-${this.#attachmentCounter}${ext}`;
1477
- filePath = path.join(localRoot, name);
1478
- } while (await Bun.file(filePath).exists());
1479
-
1480
- try {
1481
- await fs.link(sourcePath, filePath);
1482
- } catch {
1483
- await fs.copyFile(sourcePath, filePath);
1484
- }
1485
- return `local://${name}`;
1486
- }
1487
-
1488
1417
  /**
1489
1418
  * Save a large paste to the session's `local://` store and insert a clean `local://attachment-N`
1490
1419
  * reference into the editor so the agent can `read` it on demand — instead of inlining the text or
@@ -232,11 +232,15 @@ export class SelectorController {
232
232
  */
233
233
  async showExtensionsDashboard(): Promise<void> {
234
234
  const dashboard = await ExtensionDashboard.create(getProjectDir(), this.ctx.settings, this.ctx.ui.terminal.rows);
235
+ // Fullscreen dashboard on the alternate screen (the /settings idiom): the
236
+ // overlay borrows the terminal's alt buffer and enables mouse tracking for
237
+ // its lifetime, leaving the transcript untouched underneath.
235
238
  const overlay = this.ctx.ui.showOverlay(dashboard, {
236
239
  width: "100%",
237
240
  maxHeight: "100%",
238
241
  anchor: "top-left",
239
242
  margin: 0,
243
+ fullscreen: true,
240
244
  });
241
245
  dashboard.onClose = () => {
242
246
  overlay.hide();
@@ -482,65 +482,88 @@ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: Assis
482
482
  * - Custom extensions and tools
483
483
  */
484
484
  export function convertToLlm(messages: AgentMessage[]): Message[] {
485
- return messages
486
- .map((m): Message | undefined => {
487
- switch (m.role) {
488
- case "bashExecution":
489
- if (m.excludeFromContext) {
490
- return undefined;
491
- }
492
- return {
485
+ return messages.flatMap((m): Message[] => {
486
+ switch (m.role) {
487
+ case "bashExecution":
488
+ if (m.excludeFromContext) {
489
+ return [];
490
+ }
491
+ return [
492
+ {
493
493
  role: "user",
494
494
  content: [{ type: "text", text: bashExecutionToText(m) }],
495
495
  attribution: "user",
496
496
  timestamp: m.timestamp,
497
- };
498
- case "pythonExecution":
499
- if (m.excludeFromContext) {
500
- return undefined;
501
- }
502
- return {
497
+ },
498
+ ];
499
+ case "pythonExecution":
500
+ if (m.excludeFromContext) {
501
+ return [];
502
+ }
503
+ return [
504
+ {
503
505
  role: "user",
504
506
  content: [{ type: "text", text: pythonExecutionToText(m) }],
505
507
  attribution: "user",
506
508
  timestamp: m.timestamp,
507
- };
508
- case "fileMention": {
509
- const fileContents = m.files
510
- .map(file => {
511
- const inner = file.content ? `\n${file.content}\n` : "\n";
512
- return `<file path="${file.path}">${inner}</file>`;
513
- })
514
- .join("\n");
515
- const content: (TextContent | ImageContent)[] = [{ type: "text" as const, text: fileContents }];
516
- for (const file of m.files) {
517
- if (file.image) {
518
- content.push(file.image);
519
- }
520
- }
521
- return {
509
+ },
510
+ ];
511
+ case "fileMention": {
512
+ // One `fileMention` can mix `@notes.md` (text) and `@screenshot.png` (image)
513
+ // in the same turn (`generateFileMentionMessages` packs every `@…` into a
514
+ // single message). Splitting by image presence keeps text-only mentions on
515
+ // the higher-priority `developer` slot while routing image attachments
516
+ // through `user`, the only Responses content slot that legitimately accepts
517
+ // `input_image` (Codex chatgpt.com /codex/responses rejects everything else
518
+ // with `Invalid value: 'input_image'`, #3443).
519
+ const wrap = (file: FileMentionMessage["files"][number]): string => {
520
+ const inner = file.content ? `\n${file.content}\n` : "\n";
521
+ return `<file path="${file.path}">${inner}</file>`;
522
+ };
523
+ const textFiles = m.files.filter(file => !file.image);
524
+ const imageFiles = m.files.filter(file => file.image);
525
+ const out: Message[] = [];
526
+ if (textFiles.length > 0) {
527
+ out.push({
522
528
  role: "developer",
529
+ content: [{ type: "text" as const, text: textFiles.map(wrap).join("\n") }],
530
+ attribution: "user",
531
+ timestamp: m.timestamp,
532
+ });
533
+ }
534
+ if (imageFiles.length > 0) {
535
+ const content: (TextContent | ImageContent)[] = [
536
+ { type: "text" as const, text: imageFiles.map(wrap).join("\n") },
537
+ ];
538
+ for (const file of imageFiles) {
539
+ if (file.image) content.push(file.image);
540
+ }
541
+ out.push({
542
+ role: "user",
523
543
  content,
524
544
  attribution: "user",
525
545
  timestamp: m.timestamp,
526
- };
546
+ });
527
547
  }
528
- case "custom":
529
- case "hookMessage":
530
- case "branchSummary":
531
- case "compactionSummary":
532
- case "user":
533
- case "developer":
534
- case "assistant":
535
- case "toolResult":
536
- // Core roles share one transformer with agent-core —
537
- // duplicating them here is how snapcompact frames once
538
- // silently fell off the provider request.
539
- return convertMessageToLlm(m);
540
- default:
541
- m satisfies never;
542
- return undefined;
548
+ return out;
543
549
  }
544
- })
545
- .filter(m => m !== undefined);
550
+ case "custom":
551
+ case "hookMessage":
552
+ case "branchSummary":
553
+ case "compactionSummary":
554
+ case "user":
555
+ case "developer":
556
+ case "assistant":
557
+ case "toolResult": {
558
+ // Core roles share one transformer with agent-core —
559
+ // duplicating them here is how snapcompact frames once
560
+ // silently fell off the provider request.
561
+ const converted = convertMessageToLlm(m);
562
+ return converted ? [converted] : [];
563
+ }
564
+ default:
565
+ m satisfies never;
566
+ return [];
567
+ }
568
+ });
546
569
  }
@@ -283,6 +283,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
283
283
  settings: this.session.settings,
284
284
  signal,
285
285
  localProtocolOptions: this.session.localProtocolOptions,
286
+ skills: this.session.skills,
286
287
  });
287
288
  const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
288
289
 
@@ -185,6 +185,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
185
185
  settings: this.session.settings,
186
186
  signal,
187
187
  localProtocolOptions: this.session.localProtocolOptions,
188
+ skills: this.session.skills,
188
189
  });
189
190
  const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
190
191
 
package/src/tools/find.ts CHANGED
@@ -166,6 +166,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
166
166
  settings: this.session.settings,
167
167
  signal,
168
168
  localProtocolOptions: this.session.localProtocolOptions,
169
+ skills: this.session.skills,
169
170
  });
170
171
  if (!resource.sourcePath) {
171
172
  throw new ToolError(`Cannot find internal URL without a backing file: ${rawPattern}`);
@@ -3,6 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
5
  import { isEnoent } from "@oh-my-pi/pi-utils";
6
+ import type { Skill } from "../extensibility/skills";
6
7
  import { InternalUrlRouter, type LocalProtocolOptions } from "../internal-urls";
7
8
  import { ToolError } from "./tool-errors";
8
9
 
@@ -986,6 +987,8 @@ export interface ToolScopeOptions {
986
987
  signal?: AbortSignal;
987
988
  /** Calling session's `local://` root mapping — pins resolutions to the calling session. */
988
989
  localProtocolOptions?: LocalProtocolOptions;
990
+ /** Calling session's loaded skills — lets skill:// resolve without process-global state. */
991
+ skills?: readonly Skill[];
989
992
  }
990
993
 
991
994
  export interface ToolScopeResolution {
@@ -1042,6 +1045,7 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
1042
1045
  settings: opts.settings,
1043
1046
  signal: opts.signal,
1044
1047
  localProtocolOptions: opts.localProtocolOptions,
1048
+ skills: opts.skills,
1045
1049
  });
1046
1050
  if (!resource.sourcePath) {
1047
1051
  throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`);
package/src/tools/read.ts CHANGED
@@ -2083,7 +2083,24 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2083
2083
  `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).`,
2084
2084
  );
2085
2085
  }
2086
- return this.#handleInternalUrl(internalTarget.path, parsed, signal);
2086
+ const urlMeta = parseInternalUrl(internalTarget.path);
2087
+ const scheme = urlMeta.protocol.replace(/:$/, "").toLowerCase();
2088
+ if (scheme === "local") {
2089
+ const localFile = await resolveLocalUrlToFile(urlMeta, {
2090
+ cwd: this.session.cwd,
2091
+ settings: this.session.settings,
2092
+ signal,
2093
+ localProtocolOptions: this.session.localProtocolOptions,
2094
+ skills: this.session.skills,
2095
+ });
2096
+ if (localFile) {
2097
+ readPath = internalTarget.sel === undefined ? localFile.path : `${localFile.path}:${internalTarget.sel}`;
2098
+ } else {
2099
+ return this.#handleInternalUrl(internalTarget.path, parsed, signal);
2100
+ }
2101
+ } else {
2102
+ return this.#handleInternalUrl(internalTarget.path, parsed, signal);
2103
+ }
2087
2104
  }
2088
2105
 
2089
2106
  // One suffix-glob memo per read call — archive, sqlite, and plain-path
@@ -2393,23 +2410,31 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2393
2410
  // counts in `truncation` keep reflecting the source, not the trimmed
2394
2411
  // view — column truncation surfaces separately via `.limits()`.
2395
2412
  const rawSelector = isRawSelector(parsed);
2396
- // Binary sniff: NUL bytes in the collected window mean the file is
2397
- // not displayable text (binary, or UTF-16 which has NULs in the
2398
- // ASCII range) — emit a notice instead of mojibake filling the
2399
- // line budget. `:raw` stays an explicit escape hatch.
2413
+ // Binary sniff: NUL bytes mean the file is not displayable text
2414
+ // (binary, or UTF-16 which has NULs in the ASCII range) — emit a
2415
+ // notice instead of mojibake filling the line budget. `:raw`
2416
+ // stays an explicit escape hatch.
2417
+ //
2418
+ // `collectedLines` covers the common case where at least one
2419
+ // physical line terminates within the byte budget. Binary blobs
2420
+ // without newlines (videos, archives, packed JSON) leave it
2421
+ // empty; their bytes only land in `firstLinePreview`, which the
2422
+ // `firstLineExceedsLimit` branch below would otherwise emit
2423
+ // verbatim. Sniffing the preview here keeps the refusal uniform.
2400
2424
  if (!rawSelector) {
2401
- for (const line of collectedLines) {
2402
- if (line.includes("\u0000")) {
2403
- return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
2404
- .text(
2405
- prependSuffixResolutionNotice(
2406
- `[Cannot read binary file '${formatPathRelativeToCwd(absolutePath, this.session.cwd)}' (${formatBytes(fileSize)}); content contains NUL bytes (binary or UTF-16 encoded)]`,
2407
- suffixResolution,
2408
- ),
2409
- )
2410
- .sourcePath(absolutePath)
2411
- .done();
2412
- }
2425
+ const hasNul = (text: string): boolean => text.includes("\u0000");
2426
+ const binaryDetected =
2427
+ collectedLines.some(hasNul) || (firstLinePreview !== undefined && hasNul(firstLinePreview.text));
2428
+ if (binaryDetected) {
2429
+ return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
2430
+ .text(
2431
+ prependSuffixResolutionNotice(
2432
+ `[Cannot read binary file '${formatPathRelativeToCwd(absolutePath, this.session.cwd)}' (${formatBytes(fileSize)}); content contains NUL bytes (binary or UTF-16 encoded)]`,
2433
+ suffixResolution,
2434
+ ),
2435
+ )
2436
+ .sourcePath(absolutePath)
2437
+ .done();
2413
2438
  }
2414
2439
  }
2415
2440
  const maxColumns = resolveOutputMaxColumns(this.session.settings);
@@ -2771,6 +2796,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2771
2796
  settings: this.session.settings,
2772
2797
  signal,
2773
2798
  localProtocolOptions: this.session.localProtocolOptions,
2799
+ skills: this.session.skills,
2774
2800
  });
2775
2801
  const details: ReadToolDetails = { resolvedPath: resource.sourcePath, contentType: resource.contentType };
2776
2802
 
@@ -579,6 +579,7 @@ async function resolveInternalSearchInputs(opts: {
579
579
  signal?: AbortSignal;
580
580
  archiveDisplayMap: ReadonlyMap<string, string>;
581
581
  localProtocolOptions?: LocalProtocolOptions;
582
+ skills?: ResolveContext["skills"];
582
583
  }): Promise<InternalSearchInputResolution> {
583
584
  const internalRouter = InternalUrlRouter.instance();
584
585
  const paths = opts.resolvedPaths.slice();
@@ -592,6 +593,7 @@ async function resolveInternalSearchInputs(opts: {
592
593
  settings: opts.settings,
593
594
  signal: opts.signal,
594
595
  localProtocolOptions: opts.localProtocolOptions,
596
+ skills: opts.skills,
595
597
  };
596
598
 
597
599
  for (let idx = 0; idx < paths.length; idx++) {
@@ -725,6 +727,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
725
727
  signal,
726
728
  archiveDisplayMap,
727
729
  localProtocolOptions: this.session.localProtocolOptions,
730
+ skills: this.session.skills,
728
731
  });
729
732
  const searchablePaths = internalResolution.paths;
730
733
  const { virtualResources, virtualPathSet, virtualInputIndexes } = internalResolution;
@@ -797,6 +800,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
797
800
  settings: this.session.settings,
798
801
  signal,
799
802
  localProtocolOptions: this.session.localProtocolOptions,
803
+ skills: this.session.skills,
800
804
  });
801
805
  searchPath = scope.searchPath;
802
806
  isDirectory = scope.isDirectory;