@oh-my-pi/pi-coding-agent 15.5.7 → 15.5.8

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 (63) hide show
  1. package/CHANGELOG.md +53 -1
  2. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  3. package/dist/types/commands/auth-gateway.d.ts +3 -0
  4. package/dist/types/config/settings-schema.d.ts +10 -10
  5. package/dist/types/edit/file-snapshot-store.d.ts +9 -6
  6. package/dist/types/edit/hashline/diff.d.ts +4 -5
  7. package/dist/types/edit/streaming.d.ts +2 -1
  8. package/dist/types/eval/py/index.d.ts +1 -0
  9. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  10. package/dist/types/extensibility/shared-events.d.ts +1 -1
  11. package/dist/types/internal-urls/index.d.ts +1 -0
  12. package/dist/types/internal-urls/vault-protocol.d.ts +93 -0
  13. package/dist/types/mcp/transports/http.d.ts +9 -0
  14. package/dist/types/modes/components/tool-execution.d.ts +2 -1
  15. package/dist/types/session/agent-session.d.ts +3 -1
  16. package/dist/types/tools/match-line-format.d.ts +2 -2
  17. package/dist/types/tools/render-utils.d.ts +3 -1
  18. package/dist/types/tools/write.d.ts +2 -0
  19. package/dist/types/utils/file-mentions.d.ts +2 -0
  20. package/package.json +8 -8
  21. package/src/cli/args.ts +2 -0
  22. package/src/cli/auth-broker-cli.ts +2 -1
  23. package/src/cli/auth-gateway-cli.ts +210 -9
  24. package/src/commands/auth-gateway.ts +7 -1
  25. package/src/config/settings-schema.ts +12 -11
  26. package/src/edit/file-snapshot-store.ts +9 -6
  27. package/src/edit/hashline/diff.ts +26 -13
  28. package/src/edit/hashline/execute.ts +13 -9
  29. package/src/edit/renderer.ts +9 -9
  30. package/src/edit/streaming.ts +4 -6
  31. package/src/eval/py/index.ts +1 -1
  32. package/src/extensibility/custom-tools/types.ts +1 -1
  33. package/src/extensibility/shared-events.ts +1 -1
  34. package/src/internal-urls/docs-index.generated.ts +7 -7
  35. package/src/internal-urls/index.ts +1 -0
  36. package/src/internal-urls/router.ts +2 -0
  37. package/src/internal-urls/vault-protocol.ts +936 -0
  38. package/src/main.ts +1 -2
  39. package/src/mcp/transports/http.ts +29 -2
  40. package/src/modes/components/tool-execution.ts +6 -4
  41. package/src/modes/controllers/event-controller.ts +10 -3
  42. package/src/modes/interactive-mode.ts +10 -2
  43. package/src/modes/utils/ui-helpers.ts +2 -1
  44. package/src/prompts/system/system-prompt.md +3 -0
  45. package/src/prompts/tools/ast-edit.md +1 -1
  46. package/src/prompts/tools/ast-grep.md +1 -1
  47. package/src/prompts/tools/read.md +3 -3
  48. package/src/prompts/tools/search.md +1 -1
  49. package/src/sdk.ts +26 -1
  50. package/src/session/agent-session.ts +82 -11
  51. package/src/system-prompt.ts +2 -0
  52. package/src/tools/ast-edit.ts +10 -7
  53. package/src/tools/ast-grep.ts +12 -11
  54. package/src/tools/eval.ts +28 -3
  55. package/src/tools/match-line-format.ts +2 -2
  56. package/src/tools/path-utils.ts +2 -0
  57. package/src/tools/plan-mode-guard.ts +6 -1
  58. package/src/tools/read.ts +70 -55
  59. package/src/tools/render-utils.ts +15 -0
  60. package/src/tools/search.ts +12 -12
  61. package/src/tools/write.ts +61 -6
  62. package/src/utils/file-mentions.ts +11 -5
  63. package/src/web/search/providers/codex.ts +2 -1
@@ -1,5 +1,7 @@
1
+ import { constants } from "node:fs";
2
+ import { access } from "node:fs/promises";
1
3
  import * as path from "node:path";
2
- import { computeFileHash, formatHashlineHeader } from "@oh-my-pi/hashline";
4
+ import { formatHashlineHeader } from "@oh-my-pi/hashline";
3
5
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
6
  import { type AstFindMatch, astGrep } from "@oh-my-pi/pi-natives";
5
7
  import type { Component } from "@oh-my-pi/pi-tui";
@@ -219,14 +221,14 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
219
221
  }
220
222
 
221
223
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
222
- const hashContexts = new Map<string, { absolutePath: string; fileHash: string }>();
224
+ const hashContexts = new Map<string, { absolutePath: string; tag?: string }>();
225
+ const snapshotStore = useHashLines ? getFileSnapshotStore(this.session) : undefined;
223
226
  if (useHashLines) {
224
227
  for (const relativePath of fileList) {
225
228
  const absolutePath = path.resolve(this.session.cwd, relativePath);
226
229
  try {
227
- const fullText = await Bun.file(absolutePath).text();
228
- const fileHash = computeFileHash(fullText);
229
- hashContexts.set(relativePath, { absolutePath, fileHash });
230
+ await access(absolutePath, constants.R_OK);
231
+ hashContexts.set(relativePath, { absolutePath });
230
232
  } catch {
231
233
  // Best-effort: if a file disappears between ast-grep and rendering, emit plain line output.
232
234
  }
@@ -268,9 +270,8 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
268
270
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
269
271
  }
270
272
  if (hashContext && cacheEntries.length > 0) {
271
- getFileSnapshotStore(this.session).recordSparse(hashContext.absolutePath, cacheEntries, {
272
- fileHash: hashContext.fileHash,
273
- });
273
+ const tag = snapshotStore?.recordSparse(hashContext.absolutePath, cacheEntries);
274
+ if (tag) hashContext.tag = tag;
274
275
  }
275
276
  return { model: modelOut, display: displayOut };
276
277
  };
@@ -282,7 +283,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
282
283
  return {
283
284
  modelLines: rendered.model,
284
285
  displayLines: rendered.display,
285
- headerSuffix: hashContext ? `#${hashContext.fileHash}` : "",
286
+ headerSuffix: hashContext?.tag ? `#${hashContext.tag}` : "",
286
287
  skip: rendered.model.length === 0,
287
288
  };
288
289
  });
@@ -297,8 +298,8 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
297
298
  displayLines.push("");
298
299
  }
299
300
  const hashContext = hashContexts.get(relativePath);
300
- if (hashContext) {
301
- outputLines.push(formatHashlineHeader(relativePath, hashContext.fileHash));
301
+ if (hashContext?.tag) {
302
+ outputLines.push(formatHashlineHeader(relativePath, hashContext.tag));
302
303
  }
303
304
  outputLines.push(...rendered.model);
304
305
  displayLines.push(...rendered.display);
package/src/tools/eval.ts CHANGED
@@ -14,6 +14,7 @@ import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
14
14
  import evalDescription from "../prompts/tools/eval.md" with { type: "text" };
15
15
  import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output";
16
16
  import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "../tui";
17
+ import { formatDimensionNote, resizeImage } from "../utils/image-resize";
17
18
  import { resolveEvalBackends, type ToolSession } from ".";
18
19
  import { truncateForPrompt } from "./approval";
19
20
  import {
@@ -403,6 +404,7 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
403
404
 
404
405
  const cellStatusEvents: EvalStatusEvent[] = [];
405
406
  const cellDisplayOutputs: EvalDisplayOutput[] = [];
407
+ const cellImageNotes: string[] = [];
406
408
  let cellHasMarkdown = false;
407
409
  for (const output of result.displayOutputs) {
408
410
  if (output.type === "json") {
@@ -410,8 +412,26 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
410
412
  cellDisplayOutputs.push(output);
411
413
  }
412
414
  if (output.type === "image") {
413
- images.push({ type: "image", data: output.data, mimeType: output.mimeType });
414
- cellDisplayOutputs.push(output);
415
+ const resized = await resizeImage({
416
+ type: "image",
417
+ data: output.data,
418
+ mimeType: output.mimeType,
419
+ });
420
+ const image: ImageContent = {
421
+ type: "image",
422
+ data: resized.data,
423
+ mimeType: resized.mimeType,
424
+ };
425
+ images.push(image);
426
+ cellDisplayOutputs.push({
427
+ type: "image",
428
+ data: image.data,
429
+ mimeType: image.mimeType,
430
+ });
431
+ const dimensionNote = formatDimensionNote(resized);
432
+ if (dimensionNote) {
433
+ cellImageNotes.push(`display image ${cellImageNotes.length + 1}: ${dimensionNote}`);
434
+ }
415
435
  }
416
436
  if (output.type === "status") {
417
437
  statusEvents.push(output.event);
@@ -423,9 +443,14 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
423
443
  }
424
444
 
425
445
  const stdoutTrimmed = result.output.trim();
446
+ const imageText = cellImageNotes.join("\n");
426
447
  const displayText = formatDisplayOutputsForText(cellDisplayOutputs);
448
+ const visibleDisplayText =
449
+ displayText && imageText ? `${displayText}\n\n${imageText}` : displayText || imageText;
427
450
  const cellOutput =
428
- stdoutTrimmed && displayText ? `${stdoutTrimmed}\n\n${displayText}` : stdoutTrimmed || displayText;
451
+ stdoutTrimmed && visibleDisplayText
452
+ ? `${stdoutTrimmed}\n\n${visibleDisplayText}`
453
+ : stdoutTrimmed || visibleDisplayText;
429
454
  cellResult.output = cellOutput;
430
455
  cellResult.exitCode = result.exitCode;
431
456
  cellResult.durationMs = durationMs;
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Matched lines are prefixed with `*`; context lines are prefixed with a single
5
5
  * space so line numbers align in column. In hashline mode the line uses the
6
- * editable `LINE:content` shape under a file-hash header; in plain mode it keeps
7
- * the legacy `LINE|content` display-only shape. Line numbers are never padded.
6
+ * editable `LINE:content` shape under a snapshot-tag header; in plain mode it
7
+ * keeps the legacy `LINE|content` display-only shape. Line numbers are never padded.
8
8
  */
9
9
  export function formatMatchLine(
10
10
  lineNumber: number,
@@ -28,6 +28,7 @@ const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
28
28
  pr: true,
29
29
  rule: true,
30
30
  skill: true,
31
+ vault: true,
31
32
  };
32
33
  // Schemes whose resource URIs are server-defined and may legitimately end
33
34
  // with selector-shaped tails (e.g. `:raw`, `:conflicts`, `:1-50`, `/:raw`).
@@ -45,6 +46,7 @@ const TOP_LEVEL_INTERNAL_URL_PREFIXES = [
45
46
  "rule://",
46
47
  "local://",
47
48
  "mcp://",
49
+ "vault://",
48
50
  ] as const;
49
51
 
50
52
  function normalizeUnicodeSpaces(str: string): string {
@@ -1,9 +1,10 @@
1
1
  import * as path from "node:path";
2
- import { resolveLocalUrlToPath } from "../internal-urls";
2
+ import { resolveLocalUrlToPath, resolveVaultUrlToPath } from "../internal-urls";
3
3
  import type { ToolSession } from ".";
4
4
  import { normalizeLocalScheme, resolveToCwd } from "./path-utils";
5
5
  import { ToolError } from "./tool-errors";
6
6
 
7
+ const VAULT_SCHEME_PREFIX = "vault:";
7
8
  const LOCAL_SCHEME_PREFIX = "local:";
8
9
  const PLAN_ALIAS_FILE = "PLAN.md";
9
10
  const LOCAL_PLAN_ALIAS = "local://PLAN.md";
@@ -17,6 +18,10 @@ function resolveRawPath(session: ToolSession, targetPath: string): string {
17
18
  });
18
19
  }
19
20
 
21
+ if (normalized.startsWith(VAULT_SCHEME_PREFIX)) {
22
+ return resolveVaultUrlToPath(normalized);
23
+ }
24
+
20
25
  return resolveToCwd(normalized, session.cwd);
21
26
  }
22
27
 
package/src/tools/read.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
- import { computeFileHash, formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "@oh-my-pi/hashline";
4
+ import { formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "@oh-my-pi/hashline";
5
5
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
6
6
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
7
7
  import { glob, type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives";
@@ -118,39 +118,50 @@ function prependLineNumbers(text: string, startNum: number): string {
118
118
 
119
119
  interface HashlineHeaderContext {
120
120
  header: string;
121
- fileHash: string;
122
- fullText: string;
121
+ tag: string;
122
+ fullText?: string;
123
123
  }
124
124
 
125
- function buildHashlineHeaderContext(displayPath: string, fullText: string): HashlineHeaderContext {
125
+ function recordFullHashlineContext(
126
+ session: ToolSession,
127
+ absolutePath: string | undefined,
128
+ displayPath: string,
129
+ fullText: string,
130
+ ): HashlineHeaderContext | undefined {
131
+ if (!absolutePath || !path.isAbsolute(absolutePath)) return undefined;
126
132
  const normalized = normalizeToLF(fullText);
127
- const fileHash = computeFileHash(normalized);
133
+ const tag = getFileSnapshotStore(session).recordContiguous(absolutePath, 1, normalized.split("\n"), {
134
+ fullText: normalized,
135
+ });
128
136
  return {
129
- header: formatHashlineHeader(displayPath, fileHash),
130
- fileHash,
137
+ header: formatHashlineHeader(displayPath, tag),
138
+ tag,
131
139
  fullText: normalized,
132
140
  };
133
141
  }
134
142
 
135
- async function readHashlineHeaderContext(absolutePath: string, cwd: string): Promise<HashlineHeaderContext> {
143
+ async function readHashlineHeaderContext(
144
+ session: ToolSession,
145
+ absolutePath: string,
146
+ cwd: string,
147
+ ): Promise<HashlineHeaderContext> {
136
148
  const fullText = await Bun.file(absolutePath).text();
137
- return buildHashlineHeaderContext(formatPathRelativeToCwd(absolutePath, cwd), fullText);
149
+ const context = recordFullHashlineContext(
150
+ session,
151
+ absolutePath,
152
+ formatPathRelativeToCwd(absolutePath, cwd),
153
+ fullText,
154
+ );
155
+ if (!context) throw new ToolError(`Cannot record hashline snapshot for non-absolute path: ${absolutePath}`);
156
+ return context;
138
157
  }
139
158
 
140
- function prependHashlineHeader(text: string, context: HashlineHeaderContext | undefined): string {
141
- return context ? `${context.header}\n${text}` : text;
159
+ function hashlineHeaderContext(displayPath: string, tag: string): HashlineHeaderContext {
160
+ return { header: formatHashlineHeader(displayPath, tag), tag };
142
161
  }
143
162
 
144
- function recordHashlineSnapshot(
145
- session: ToolSession,
146
- absolutePath: string | undefined,
147
- context: HashlineHeaderContext | undefined,
148
- ): void {
149
- if (!context || !absolutePath || !path.isAbsolute(absolutePath)) return;
150
- getFileSnapshotStore(session).recordContiguous(absolutePath, 1, context.fullText.split("\n"), {
151
- fullText: context.fullText,
152
- fileHash: context.fileHash,
153
- });
163
+ function prependHashlineHeader(text: string, context: HashlineHeaderContext | undefined): string {
164
+ return context ? `${context.header}\n${text}` : text;
154
165
  }
155
166
 
156
167
  function formatTextWithMode(
@@ -841,9 +852,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
841
852
  const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
842
853
  const hashContext =
843
854
  shouldAddHashLines && options.sourcePath
844
- ? buildHashlineHeaderContext(formatPathRelativeToCwd(options.sourcePath, this.session.cwd), text)
855
+ ? recordFullHashlineContext(
856
+ this.session,
857
+ options.sourcePath,
858
+ formatPathRelativeToCwd(options.sourcePath, this.session.cwd),
859
+ text,
860
+ )
845
861
  : undefined;
846
- recordHashlineSnapshot(this.session, options.sourcePath, hashContext);
847
862
  let emittedHashlineHeader = false;
848
863
  const formatText = (content: string, startNum: number): string => {
849
864
  details.displayContent = { text: content, startLine: startNum };
@@ -934,9 +949,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
934
949
  const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
935
950
  const hashContext =
936
951
  shouldAddHashLines && options.sourcePath
937
- ? buildHashlineHeaderContext(formatPathRelativeToCwd(options.sourcePath, this.session.cwd), text)
952
+ ? recordFullHashlineContext(
953
+ this.session,
954
+ options.sourcePath,
955
+ formatPathRelativeToCwd(options.sourcePath, this.session.cwd),
956
+ text,
957
+ )
938
958
  : undefined;
939
- recordHashlineSnapshot(this.session, options.sourcePath, hashContext);
940
959
  let emittedHashlineHeader = false;
941
960
 
942
961
  const resultBuilder = toolResult(details);
@@ -1014,11 +1033,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1014
1033
 
1015
1034
  const shouldAddHashLines = !rawSelector && displayMode.hashLines;
1016
1035
  const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1017
- const hashContext = shouldAddHashLines
1018
- ? await readHashlineHeaderContext(absolutePath, this.session.cwd)
1019
- : undefined;
1020
- recordHashlineSnapshot(this.session, absolutePath, hashContext);
1021
- let emittedHashlineHeader = false;
1036
+ const sparseSnapshotEntries: Array<readonly [number, string]> = [];
1022
1037
  const maxColumns = resolveOutputMaxColumns(this.session.settings);
1023
1038
 
1024
1039
  const blocks: string[] = [];
@@ -1058,22 +1073,19 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1058
1073
  }
1059
1074
  }
1060
1075
 
1061
- if (collectedLines.length > 0) {
1062
- getFileSnapshotStore(this.session).recordContiguous(
1063
- absolutePath,
1064
- range.startLine,
1065
- collectedLines,
1066
- hashContext ? { fullText: hashContext.fullText, fileHash: hashContext.fileHash } : {},
1067
- );
1076
+ for (let index = 0; index < collectedLines.length; index++) {
1077
+ sparseSnapshotEntries.push([range.startLine + index, collectedLines[index]]);
1068
1078
  }
1069
1079
 
1070
1080
  const blockText = collectedLines.join("\n");
1071
- const formatted = formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers);
1072
- blocks.push(hashContext && !emittedHashlineHeader ? prependHashlineHeader(formatted, hashContext) : formatted);
1073
- if (hashContext) emittedHashlineHeader = true;
1081
+ blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
1074
1082
  }
1075
1083
 
1076
1084
  let outputText = blocks.join("\n\n…\n\n");
1085
+ if (shouldAddHashLines && sparseSnapshotEntries.length > 0 && outputText) {
1086
+ const tag = getFileSnapshotStore(this.session).recordSparse(absolutePath, sparseSnapshotEntries);
1087
+ outputText = `${formatHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
1088
+ }
1077
1089
  if (notices.length > 0) {
1078
1090
  outputText = outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n");
1079
1091
  }
@@ -1726,9 +1738,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1726
1738
  renderedSummary.elidedLines,
1727
1739
  );
1728
1740
  const summaryHashContext = displayMode.hashLines
1729
- ? await readHashlineHeaderContext(absolutePath, this.session.cwd)
1741
+ ? await readHashlineHeaderContext(this.session, absolutePath, this.session.cwd)
1730
1742
  : undefined;
1731
- recordHashlineSnapshot(this.session, absolutePath, summaryHashContext);
1732
1743
  const bodyText = footer ? `${renderedSummary.text}\n\n${footer}` : renderedSummary.text;
1733
1744
  const modelText = prependHashlineHeader(bodyText, summaryHashContext);
1734
1745
  details = {
@@ -1875,17 +1886,19 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1875
1886
 
1876
1887
  const shouldAddHashLines = !rawSelector && displayMode.hashLines;
1877
1888
  const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1878
- const hashContext = shouldAddHashLines
1879
- ? await readHashlineHeaderContext(absolutePath, this.session.cwd)
1880
- : undefined;
1881
-
1882
- if (collectedLines.length > 0 && !firstLineExceedsLimit) {
1883
- getFileSnapshotStore(this.session).recordContiguous(
1884
- absolutePath,
1885
- startLineDisplay,
1886
- collectedLines,
1887
- hashContext ? { fullText: hashContext.fullText, fileHash: hashContext.fileHash } : {},
1888
- );
1889
+ let hashContext: HashlineHeaderContext | undefined;
1890
+ if (shouldAddHashLines && collectedLines.length > 0 && !firstLineExceedsLimit) {
1891
+ const store = getFileSnapshotStore(this.session);
1892
+ const tag =
1893
+ offset === undefined && limit === undefined && !wasTruncated && columnTruncated === 0
1894
+ ? (() => {
1895
+ const normalized = normalizeToLF(selectedContent);
1896
+ return store.recordContiguous(absolutePath, 1, normalized.split("\n"), {
1897
+ fullText: normalized,
1898
+ });
1899
+ })()
1900
+ : store.recordContiguous(absolutePath, startLineDisplay, collectedLines);
1901
+ hashContext = hashlineHeaderContext(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag);
1889
1902
  }
1890
1903
 
1891
1904
  let capturedDisplayContent: { text: string; startLine: number } | undefined;
@@ -2031,9 +2044,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2031
2044
 
2032
2045
  const rawText = region.lines.join("\n");
2033
2046
  const hashContext = shouldAddHashLines
2034
- ? await readHashlineHeaderContext(entry.absolutePath, this.session.cwd)
2047
+ ? hashlineHeaderContext(
2048
+ formatPathRelativeToCwd(entry.absolutePath, this.session.cwd),
2049
+ getFileSnapshotStore(this.session).recordContiguous(entry.absolutePath, region.startLine, region.lines),
2050
+ )
2035
2051
  : undefined;
2036
- recordHashlineSnapshot(this.session, entry.absolutePath, hashContext);
2037
2052
  const formattedBody = formatTextWithMode(rawText, region.startLine, shouldAddHashLines, shouldAddLineNumbers);
2038
2053
  const formattedText = prependHashlineHeader(formattedBody, hashContext);
2039
2054
 
@@ -469,7 +469,22 @@ export function truncateDiffByHunk(
469
469
  diffText: string,
470
470
  maxHunks: number,
471
471
  maxLines: number,
472
+ options?: { fromTail?: boolean },
472
473
  ): { text: string; hiddenHunks: number; hiddenLines: number } {
474
+ if (options?.fromTail) {
475
+ // Streaming previews want to track the tail of the diff as new hunks
476
+ // arrive. Reversing the line buffer reuses the head-mode logic without
477
+ // duplicating the segment-budget bookkeeping: hunk runs survive
478
+ // reversal (a continuous `+`/`-` block stays contiguous) and so do the
479
+ // per-line `+`/`-` markers, so getDiffStats yields identical counts.
480
+ const reversed = (diffText ?? "").split("\n").reverse().join("\n");
481
+ const result = truncateDiffByHunk(reversed, maxHunks, maxLines);
482
+ return {
483
+ text: result.text.split("\n").reverse().join("\n"),
484
+ hiddenHunks: result.hiddenHunks,
485
+ hiddenLines: result.hiddenLines,
486
+ };
487
+ }
473
488
  const lines = diffText ? diffText.split("\n") : [];
474
489
  const totalStats = getDiffStats(diffText);
475
490
 
@@ -1,7 +1,8 @@
1
- import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
1
+ import { constants } from "node:fs";
2
+ import { access, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
2
3
  import { tmpdir } from "node:os";
3
4
  import * as path from "node:path";
4
- import { computeFileHash, formatHashlineHeader } from "@oh-my-pi/hashline";
5
+ import { formatHashlineHeader } from "@oh-my-pi/hashline";
5
6
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
6
7
  import { type GrepMatch, GrepOutputMode, type GrepResult, grep } from "@oh-my-pi/pi-natives";
7
8
  import type { Component } from "@oh-my-pi/pi-tui";
@@ -609,16 +610,16 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
609
610
  matchesByFile.get(relativePath)!.push(match);
610
611
  }
611
612
  const displayLines: string[] = [];
612
- const hashContexts = new Map<string, { absolutePath: string; fileHash: string }>();
613
+ const hashContexts = new Map<string, { absolutePath: string; tag?: string }>();
614
+ const snapshotStore = baseDisplayMode.hashLines ? getFileSnapshotStore(this.session) : undefined;
613
615
  if (baseDisplayMode.hashLines) {
614
616
  for (const relativePath of fileList) {
615
617
  if (archiveDisplaySet.has(relativePath)) continue;
616
618
  const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
617
619
  if (immutableSourcePaths.has(absoluteFilePath)) continue;
618
620
  try {
619
- const fullText = await Bun.file(absoluteFilePath).text();
620
- const fileHash = computeFileHash(fullText);
621
- hashContexts.set(relativePath, { absolutePath: absoluteFilePath, fileHash });
621
+ await access(absoluteFilePath, constants.R_OK);
622
+ hashContexts.set(relativePath, { absolutePath: absoluteFilePath });
622
623
  } catch {
623
624
  // Best-effort: if the file disappeared between grep and render, fall back to plain line output.
624
625
  }
@@ -671,9 +672,8 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
671
672
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
672
673
  }
673
674
  if (cacheEntries.length > 0 && hashContext) {
674
- getFileSnapshotStore(this.session).recordSparse(hashContext.absolutePath, cacheEntries, {
675
- fileHash: hashContext.fileHash,
676
- });
675
+ const tag = snapshotStore?.recordSparse(hashContext.absolutePath, cacheEntries);
676
+ if (tag) hashContext.tag = tag;
677
677
  }
678
678
  return { model: modelOut, display: displayOut };
679
679
  };
@@ -684,7 +684,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
684
684
  return {
685
685
  modelLines: rendered.model,
686
686
  displayLines: rendered.display,
687
- headerSuffix: hashContext ? `#${hashContext.fileHash}` : "",
687
+ headerSuffix: hashContext?.tag ? `#${hashContext.tag}` : "",
688
688
  skip: rendered.model.length === 0,
689
689
  };
690
690
  });
@@ -699,8 +699,8 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
699
699
  displayLines.push("");
700
700
  }
701
701
  const hashContext = hashContexts.get(relativePath);
702
- if (hashContext) {
703
- outputLines.push(formatHashlineHeader(relativePath, hashContext.fileHash));
702
+ if (hashContext?.tag) {
703
+ outputLines.push(formatHashlineHeader(relativePath, hashContext.tag));
704
704
  }
705
705
  outputLines.push(...rendered.model);
706
706
  displayLines.push(...rendered.display);
@@ -1,12 +1,14 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
+
4
5
  import { stripHashlinePrefixes } from "@oh-my-pi/hashline";
5
6
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
6
7
  import type { Component } from "@oh-my-pi/pi-tui";
7
8
  import { Text } from "@oh-my-pi/pi-tui";
8
9
  import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
9
10
  import * as z from "zod/v4";
11
+
10
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
13
  import { InternalUrlRouter } from "../internal-urls";
12
14
  import { parseInternalUrl } from "../internal-urls/parse";
@@ -53,6 +55,8 @@ import {
53
55
  import { ToolError } from "./tool-errors";
54
56
  import { toolResult } from "./tool-result";
55
57
 
58
+ const LOOSE_HASHLINE_HEADER_RE = /^\s*¶\S+#[^ \t\r\n]*\s*$/;
59
+
56
60
  let fflateModulePromise: Promise<typeof import("fflate")> | undefined;
57
61
  async function loadFflate(): Promise<typeof import("fflate")> {
58
62
  if (!fflateModulePromise) fflateModulePromise = import("fflate");
@@ -70,6 +74,33 @@ export type WriteToolInput = z.infer<typeof writeSchema>;
70
74
  export interface WriteToolDetails {
71
75
  diagnostics?: FileDiagnosticsResult;
72
76
  meta?: OutputMeta;
77
+ /** Set when the file was auto-chmod'd because content begins with a `#!` shebang. */
78
+ madeExecutable?: boolean;
79
+ }
80
+
81
+ /**
82
+ * Strip hashline display prefixes from write content.
83
+ *
84
+ * Includes a fallback for loosely-formed section headers that still carry
85
+ * line-number prefixes (for example legacy or malformed hashline echoes).
86
+ */
87
+ function stripWriteContentWithPotentialLooseHeader(lines: string[]): { text: string; stripped: boolean } {
88
+ const cleaned = stripHashlinePrefixes(lines);
89
+ if (cleaned !== lines) {
90
+ return { text: cleaned.join("\n"), stripped: true };
91
+ }
92
+
93
+ const headerIndex = lines.findIndex(line => line.trim().length > 0);
94
+ if (headerIndex === -1 || !LOOSE_HASHLINE_HEADER_RE.test(lines[headerIndex])) {
95
+ return { text: lines.join("\n"), stripped: false };
96
+ }
97
+
98
+ const linesWithoutHeader = lines.slice(0, headerIndex).concat(lines.slice(headerIndex + 1));
99
+ const cleanedWithoutHeader = stripHashlinePrefixes(linesWithoutHeader);
100
+ if (cleanedWithoutHeader === linesWithoutHeader) {
101
+ return { text: lines.join("\n"), stripped: false };
102
+ }
103
+ return { text: cleanedWithoutHeader.join("\n"), stripped: true };
73
104
  }
74
105
 
75
106
  /**
@@ -82,10 +113,7 @@ function stripWriteContent(session: ToolSession, content: string): { text: strin
82
113
  if (!resolveFileDisplayMode(session).hashLines) {
83
114
  return { text: content, stripped: false };
84
115
  }
85
- const lines = content.split("\n");
86
- const cleaned = stripHashlinePrefixes(lines);
87
- if (cleaned === lines) return { text: content, stripped: false };
88
- return { text: cleaned.join("\n"), stripped: true };
116
+ return stripWriteContentWithPotentialLooseHeader(content.split("\n"));
89
117
  }
90
118
 
91
119
  /**
@@ -103,6 +131,28 @@ function appendNoteToResult(result: AgentToolResult<WriteToolDetails>, note: str
103
131
  }
104
132
  }
105
133
 
134
+ /**
135
+ * If `content` begins with a `#!` shebang, ensure the file is executable.
136
+ *
137
+ * Mirrors `chmod a+x` (adds user/group/other execute bits to existing mode).
138
+ * Errors are swallowed: chmod failure (e.g. Windows ACL, read-only mount)
139
+ * MUST NOT fail an otherwise successful write. Returns whether the mode
140
+ * actually changed so the caller can surface a note.
141
+ */
142
+ async function maybeMarkExecutableForShebang(absolutePath: string, content: string): Promise<boolean> {
143
+ if (!content.startsWith("#!")) return false;
144
+ try {
145
+ const stat = await fs.stat(absolutePath);
146
+ const currentMode = stat.mode & 0o7777;
147
+ const newMode = currentMode | 0o111;
148
+ if (newMode === currentMode) return false;
149
+ await fs.chmod(absolutePath, newMode);
150
+ return true;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
106
156
  // ═══════════════════════════════════════════════════════════════════════════
107
157
  // Tool Class
108
158
  // ═══════════════════════════════════════════════════════════════════════════
@@ -772,6 +822,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
772
822
 
773
823
  const diagnostics = await this.#writethrough(absolutePath, cleanContent, signal, undefined, batchRequest);
774
824
  invalidateFsScanAfterWrite(absolutePath);
825
+ const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, cleanContent);
775
826
 
776
827
  const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
777
828
  let resultText = `Successfully wrote ${cleanContent.length} bytes to ${displayPath}`;
@@ -781,7 +832,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
781
832
  if (!diagnostics) {
782
833
  return {
783
834
  content: [{ type: "text", text: resultText }],
784
- details: {},
835
+ details: { madeExecutable: madeExecutable || undefined },
785
836
  };
786
837
  }
787
838
 
@@ -789,6 +840,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
789
840
  content: [{ type: "text", text: resultText }],
790
841
  details: {
791
842
  diagnostics,
843
+ madeExecutable: madeExecutable || undefined,
792
844
  meta: outputMeta()
793
845
  .diagnostics(diagnostics.summary, diagnostics.messages ?? [])
794
846
  .get(),
@@ -915,13 +967,16 @@ export const writeToolRenderer = {
915
967
  const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
916
968
  const lineCount = countLines(fileContent);
917
969
  const lineSuffix = formatLineCountSuffix(lineCount, uiTheme);
970
+ const execSuffix = result.details?.madeExecutable
971
+ ? `${uiTheme.fg("dim", " · ")}${uiTheme.fg("success", "made executable!")}`
972
+ : "";
918
973
 
919
974
  // Build header with status icon
920
975
  const header = renderStatusLine(
921
976
  {
922
977
  icon: "success",
923
978
  title: "Write",
924
- description: `${langIcon} ${pathDisplay}${lineSuffix}`,
979
+ description: `${langIcon} ${pathDisplay}${lineSuffix}${execSuffix}`,
925
980
  },
926
981
  uiTheme,
927
982
  );
@@ -7,12 +7,13 @@
7
7
  */
8
8
  import * as fs from "node:fs/promises";
9
9
  import path from "node:path";
10
- import { computeFileHash, formatHashlineHeader, formatNumberedLines } from "@oh-my-pi/hashline";
10
+ import { formatHashlineHeader, formatNumberedLines, type SnapshotStore } from "@oh-my-pi/hashline";
11
11
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
12
12
  import type { ImageContent } from "@oh-my-pi/pi-ai";
13
13
  import { glob } from "@oh-my-pi/pi-natives";
14
14
  import { fuzzyMatch } from "@oh-my-pi/pi-tui";
15
15
  import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
16
+ import { normalizeToLF } from "../edit/normalize";
16
17
  import type { FileMentionMessage } from "../session/messages";
17
18
  import {
18
19
  DEFAULT_MAX_BYTES,
@@ -277,7 +278,7 @@ export function extractFileMentions(text: string): string[] {
277
278
  export async function generateFileMentionMessages(
278
279
  filePaths: string[],
279
280
  cwd: string,
280
- options?: { autoResizeImages?: boolean; useHashLines?: boolean },
281
+ options?: { autoResizeImages?: boolean; useHashLines?: boolean; snapshotStore?: SnapshotStore },
281
282
  ): Promise<AgentMessage[]> {
282
283
  if (filePaths.length === 0) return [];
283
284
 
@@ -354,9 +355,14 @@ export async function generateFileMentionMessages(
354
355
  }
355
356
 
356
357
  const content = await Bun.file(absolutePath).text();
357
- let { output, lineCount } = buildTextOutput(content);
358
- if (options?.useHashLines) {
359
- output = `${formatHashlineHeader(resolvedPath, computeFileHash(content))}\n${formatNumberedLines(output)}`;
358
+ const snapshotStore = options?.useHashLines ? options.snapshotStore : undefined;
359
+ const normalized = snapshotStore ? normalizeToLF(content) : content;
360
+ let { output, lineCount } = buildTextOutput(normalized);
361
+ if (snapshotStore) {
362
+ const tag = snapshotStore.recordContiguous(absolutePath, 1, normalized.split("\n"), {
363
+ fullText: normalized,
364
+ });
365
+ output = `${formatHashlineHeader(resolvedPath, tag)}\n${formatNumberedLines(output)}`;
360
366
  }
361
367
  files.push({ path: resolvedPath, content: output, lineCount });
362
368
  } catch {
@@ -19,8 +19,9 @@ import { classifyProviderHttpError, withHardTimeout } from "./utils";
19
19
 
20
20
  const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
21
21
  const CODEX_RESPONSES_PATH = "/codex/responses";
22
- const FALLBACK_MODEL = "gpt-5.4";
22
+ const FALLBACK_MODEL = "gpt-5.5";
23
23
  const DEFAULT_MODEL_PREFERENCES = [
24
+ "gpt-5.5",
24
25
  "gpt-5.4",
25
26
  "gpt-5-codex",
26
27
  "gpt-5",