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

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 (43) hide show
  1. package/CHANGELOG.md +48 -2
  2. package/dist/types/config/settings-schema.d.ts +50 -2
  3. package/dist/types/edit/hashline/diff.d.ts +6 -1
  4. package/dist/types/edit/hashline/execute.d.ts +1 -2
  5. package/dist/types/edit/hashline/params.d.ts +4 -5
  6. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +23 -0
  7. package/dist/types/lib/xai-http.d.ts +40 -0
  8. package/dist/types/session/agent-session.d.ts +1 -0
  9. package/dist/types/tools/fetch.d.ts +19 -0
  10. package/dist/types/tools/find.d.ts +7 -0
  11. package/dist/types/tools/image-gen.d.ts +6 -2
  12. package/dist/types/tools/index.d.ts +1 -0
  13. package/dist/types/tools/plan-mode-guard.d.ts +5 -6
  14. package/dist/types/tools/tts.d.ts +18 -0
  15. package/package.json +8 -8
  16. package/scripts/build-binary.ts +11 -0
  17. package/src/config/model-registry.ts +41 -9
  18. package/src/config/settings-schema.ts +43 -2
  19. package/src/edit/diff.ts +5 -3
  20. package/src/edit/hashline/diff.ts +11 -4
  21. package/src/edit/hashline/execute.ts +3 -10
  22. package/src/edit/hashline/params.ts +10 -3
  23. package/src/edit/index.ts +9 -12
  24. package/src/edit/renderer.ts +14 -7
  25. package/src/edit/streaming.ts +15 -128
  26. package/src/extensibility/legacy-pi-ai-shim.ts +24 -0
  27. package/src/extensibility/plugins/legacy-pi-compat.ts +47 -3
  28. package/src/lib/xai-http.ts +124 -0
  29. package/src/main.ts +2 -1
  30. package/src/modes/controllers/selector-controller.ts +7 -2
  31. package/src/modes/interactive-mode.ts +1 -1
  32. package/src/modes/rpc/rpc-client.ts +3 -1
  33. package/src/prompts/tools/find.md +3 -2
  34. package/src/sdk.ts +15 -9
  35. package/src/session/agent-session.ts +48 -5
  36. package/src/tools/fetch.ts +145 -74
  37. package/src/tools/find.ts +38 -6
  38. package/src/tools/image-gen.ts +205 -7
  39. package/src/tools/index.ts +1 -0
  40. package/src/tools/plan-mode-guard.ts +14 -6
  41. package/src/tools/read.ts +57 -3
  42. package/src/tools/search.ts +2 -2
  43. package/src/tools/tts.ts +133 -0
@@ -2036,6 +2036,15 @@ export const SETTINGS_SCHEMA = {
2036
2036
  },
2037
2037
  },
2038
2038
 
2039
+ "tts.enabled": {
2040
+ type: "boolean",
2041
+ default: false,
2042
+ ui: {
2043
+ tab: "tools",
2044
+ label: "Text-to-Speech",
2045
+ description: "Enable the tts tool for xAI Grok Voice speech synthesis",
2046
+ },
2047
+ },
2039
2048
  "recipe.enabled": {
2040
2049
  type: "boolean",
2041
2050
  default: true,
@@ -2698,7 +2707,7 @@ export const SETTINGS_SCHEMA = {
2698
2707
  },
2699
2708
  "providers.image": {
2700
2709
  type: "enum",
2701
- values: ["auto", "openai", "gemini", "openrouter"] as const,
2710
+ values: ["auto", "openai", "antigravity", "xai", "gemini", "openrouter"] as const,
2702
2711
  default: "auto",
2703
2712
  ui: {
2704
2713
  tab: "providers",
@@ -2708,9 +2717,19 @@ export const SETTINGS_SCHEMA = {
2708
2717
  {
2709
2718
  value: "auto",
2710
2719
  label: "Auto",
2711
- description: "Priority: GPT model image tool > Antigravity > OpenRouter > Gemini",
2720
+ description: "Priority: GPT model image tool > Antigravity > xAI > OpenRouter > Gemini",
2712
2721
  },
2713
2722
  { value: "openai", label: "OpenAI", description: "Uses the active GPT Responses/Codex model" },
2723
+ {
2724
+ value: "antigravity",
2725
+ label: "Antigravity",
2726
+ description: "Requires google-antigravity OAuth",
2727
+ },
2728
+ {
2729
+ value: "xai",
2730
+ label: "xAI Grok Imagine",
2731
+ description: "Requires xAI Grok OAuth or XAI_API_KEY",
2732
+ },
2714
2733
  { value: "gemini", label: "Gemini", description: "Requires GEMINI_API_KEY" },
2715
2734
  { value: "openrouter", label: "OpenRouter", description: "Requires OPENROUTER_API_KEY" },
2716
2735
  ],
@@ -2748,6 +2767,28 @@ export const SETTINGS_SCHEMA = {
2748
2767
  },
2749
2768
  },
2750
2769
 
2770
+ "providers.openrouterVariant": {
2771
+ type: "enum",
2772
+ values: ["default", "nitro", "floor", "online", "exacto"] as const,
2773
+ default: "default",
2774
+ ui: {
2775
+ tab: "providers",
2776
+ label: "OpenRouter Routing",
2777
+ description:
2778
+ "Default routing-variant suffix appended to OpenRouter model IDs (overridden when the selector already names a variant)",
2779
+ options: [
2780
+ { value: "default", label: "Default", description: "No suffix; use OpenRouter's default routing" },
2781
+ { value: "nitro", label: ":nitro", description: "Prioritize throughput / lowest latency" },
2782
+ { value: "floor", label: ":floor", description: "Prioritize cheapest available provider" },
2783
+ { value: "online", label: ":online", description: "Enable OpenRouter's web-search plugin" },
2784
+ {
2785
+ value: "exacto",
2786
+ label: ":exacto",
2787
+ description: "Cherry-picked high-quality providers (only defined for select models)",
2788
+ },
2789
+ ],
2790
+ },
2791
+ },
2751
2792
  "providers.parallelFetch": {
2752
2793
  type: "boolean",
2753
2794
  default: true,
package/src/edit/diff.ts CHANGED
@@ -58,7 +58,7 @@ function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, conten
58
58
  * Generate a unified diff string with line numbers and context.
59
59
  * Returns both the diff string and the first changed line number (in the new file).
60
60
  */
61
- export function generateDiffString(oldContent: string, newContent: string, contextLines = 4): DiffResult {
61
+ export function generateDiffString(oldContent: string, newContent: string, contextLines = 2): DiffResult {
62
62
  const parts = Diff.diffLines(oldContent, newContent);
63
63
  const output: string[] = [];
64
64
 
@@ -119,8 +119,9 @@ export function generateDiffString(oldContent: string, newContent: string, conte
119
119
  linesToShow = raw.slice(0, contextLimit);
120
120
  }
121
121
 
122
+ // Leading-skip placeholder is omitted: the first emitted line's
123
+ // number already conveys that earlier lines were trimmed.
122
124
  if (leadingSkip > 0) {
123
- output.push(formatNumberedDiffLine(" ", oldLineNum, "..."));
124
125
  oldLineNum += leadingSkip;
125
126
  newLineNum += leadingSkip;
126
127
  }
@@ -143,8 +144,9 @@ export function generateDiffString(oldContent: string, newContent: string, conte
143
144
  }
144
145
  }
145
146
 
147
+ // Trailing-skip placeholder is omitted for the same reason: the
148
+ // final emitted line's number tells the reader the file continues.
146
149
  if (trailingSkip > 0) {
147
- output.push(formatNumberedDiffLine(" ", oldLineNum, "..."));
148
150
  oldLineNum += trailingSkip;
149
151
  newLineNum += trailingSkip;
150
152
  }
@@ -10,7 +10,6 @@
10
10
  * and no auto-generated-file refusal — those belong on the write path.
11
11
  */
12
12
  import {
13
- applyEdits,
14
13
  computeFileHash,
15
14
  Patch as HashlinePatch,
16
15
  normalizeToLF,
@@ -24,6 +23,12 @@ import { readEditFileText } from "../read-file";
24
23
 
25
24
  export interface HashlineDiffOptions {
26
25
  autoDropPureInsertDuplicates?: boolean;
26
+ /**
27
+ * Use the streaming-tolerant applier ({@link PatchSection.applyPartialTo})
28
+ * so trailing in-flight ops do not throw or emit phantom edits. Streaming
29
+ * preview path only.
30
+ */
31
+ streaming?: boolean;
27
32
  }
28
33
 
29
34
  async function readSectionText(absolutePath: string, sectionPath: string): Promise<string> {
@@ -62,7 +67,9 @@ export async function computeHashlineSectionDiff(
62
67
  const normalized = normalizeToLF(content);
63
68
  const hashError = validateSectionHash(section, normalized);
64
69
  if (hashError) return { error: hashError };
65
- const result = applyEdits(normalized, [...section.edits], options);
70
+ const result = options.streaming
71
+ ? section.applyPartialTo(normalized, options)
72
+ : section.applyTo(normalized, options);
66
73
  if (normalized === result.text) return { error: `No changes would be made to ${section.path}.` };
67
74
  return generateDiffString(normalized, result.text);
68
75
  } catch (err) {
@@ -71,13 +78,13 @@ export async function computeHashlineSectionDiff(
71
78
  }
72
79
 
73
80
  export async function computeHashlineDiff(
74
- input: { input: string; path?: string },
81
+ input: { input: string },
75
82
  cwd: string,
76
83
  options: HashlineDiffOptions = {},
77
84
  ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
78
85
  let patch: Patch;
79
86
  try {
80
- patch = HashlinePatch.parse(input.input, { cwd, path: input.path });
87
+ patch = HashlinePatch.parse(input.input, { cwd });
81
88
  } catch (err) {
82
89
  return { error: err instanceof Error ? err.message : String(err) };
83
90
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Coding-agent runner that drives the hashline {@link Patcher} on behalf of
3
- * the `edit` tool. Converts a `{input, path?}` tool-call payload into a
3
+ * the `edit` tool. Converts a `{input}` tool-call payload into a
4
4
  * fully-applied patch, wraps the result in the agent's
5
5
  * {@link AgentToolResult} shape, and attaches LSP diagnostics + `outputMeta`
6
6
  * for the renderer.
@@ -31,7 +31,6 @@ import { type HashlineParams, hashlineEditParamsSchema } from "./params";
31
31
  export interface ExecuteHashlineSingleOptions {
32
32
  session: ToolSession;
33
33
  input: string;
34
- path?: string;
35
34
  signal?: AbortSignal;
36
35
  batchRequest?: LspBatchRequest;
37
36
  writethrough: WritethroughCallback;
@@ -91,16 +90,10 @@ function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsR
91
90
 
92
91
  const warningsBlock = result.warnings.length > 0 ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
93
92
  const previewBlock = preview.preview ? `\n${preview.preview}` : "";
94
- const headline = preview.preview
95
- ? `${result.path}:`
96
- : result.op === "create"
97
- ? `Created ${result.path}`
98
- : `Updated ${result.path}`;
99
-
100
93
  const firstChangedLine = result.firstChangedLine ?? diff.firstChangedLine;
101
94
  return {
102
95
  toolResult: {
103
- content: [{ type: "text", text: `${headline}\n${result.header}${previewBlock}${warningsBlock}` }],
96
+ content: [{ type: "text", text: `${result.header}${previewBlock}${warningsBlock}` }],
104
97
  details: {
105
98
  diff: diff.diff,
106
99
  firstChangedLine,
@@ -122,7 +115,7 @@ function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsR
122
115
  export async function executeHashlineSingle(
123
116
  options: ExecuteHashlineSingleOptions,
124
117
  ): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
125
- const patch = Patch.parse(options.input, { cwd: options.session.cwd, path: options.path });
118
+ const patch = Patch.parse(options.input, { cwd: options.session.cwd });
126
119
  if (patch.sections.length === 0) {
127
120
  throw new Error("No hashline sections found in input.");
128
121
  }
@@ -1,11 +1,18 @@
1
1
  /**
2
2
  * Zod schema for the `edit` tool's hashline mode payload. The schema is
3
3
  * deliberately permissive (`.passthrough()`) so providers can attach extra
4
- * keys without rejection; only `input` is required and `path` is an
5
- * optional fallback used when the input lacks a `¶PATH#HASH` header.
4
+ * keys without rejection; only `input` is required. `_input` is accepted as a
5
+ * provider-emitted alias for `input`.
6
6
  */
7
7
  import * as z from "zod/v4";
8
8
 
9
- export const hashlineEditParamsSchema = z.object({ input: z.string(), path: z.string().optional() }).passthrough();
9
+ export const hashlineEditParamsSchema = z.preprocess(raw => {
10
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
11
+
12
+ const record = raw as Record<string, unknown>;
13
+ if (typeof record.input === "string" || typeof record._input !== "string") return raw;
14
+
15
+ return { ...record, input: record._input };
16
+ }, z.object({ input: z.string() }).passthrough());
10
17
 
11
18
  export type HashlineParams = z.infer<typeof hashlineEditParamsSchema>;
package/src/edit/index.ts CHANGED
@@ -262,19 +262,17 @@ async function executeSinglePathEntries(
262
262
 
263
263
  function extractApprovalPath(args: unknown): string {
264
264
  const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
265
- const targetPath = record.path;
266
- if (typeof targetPath === "string" && targetPath.length > 0) {
267
- return targetPath;
268
- }
269
-
270
265
  const input = typeof record.input === "string" ? record.input : undefined;
271
- if (!input) return "(unknown)";
266
+ if (input) {
267
+ const hashlineMatch = /^(?:¶|§|@)([^\s#]+)/m.exec(input);
268
+ if (hashlineMatch?.[1]) return hashlineMatch[1];
272
269
 
273
- const hashlineMatch = /^(?:¶|§|@)([^\s#]+)/m.exec(input);
274
- if (hashlineMatch?.[1]) return hashlineMatch[1];
270
+ const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input);
271
+ if (applyPatchMatch?.[1]) return applyPatchMatch[1].trim();
272
+ }
275
273
 
276
- const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input);
277
- return applyPatchMatch?.[1]?.trim() || "(unknown)";
274
+ const targetPath = record.path;
275
+ return typeof targetPath === "string" && targetPath.length > 0 ? targetPath : "(unknown)";
278
276
  }
279
277
 
280
278
  export class EditTool implements AgentTool<TInput> {
@@ -430,11 +428,10 @@ export class EditTool implements AgentTool<TInput> {
430
428
  batchRequest: LspBatchRequest | undefined,
431
429
  _onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
432
430
  ) => {
433
- const { input, path } = params as HashlineParams & { path?: string };
431
+ const { input } = params as HashlineParams;
434
432
  return executeHashlineSingle({
435
433
  session: tool.session,
436
434
  input,
437
- path,
438
435
  signal,
439
436
  batchRequest,
440
437
  writethrough: tool.#writethrough,
@@ -235,14 +235,21 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string)
235
235
 
236
236
  function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, label = "streaming"): string {
237
237
  if (!diff) return "";
238
- const lines = diff.split("\n");
239
- const total = lines.length;
240
- const displayLines = lines.slice(-EDIT_STREAMING_PREVIEW_LINES);
241
- const hidden = total - displayLines.length;
238
+ // Hunk-aware truncation keeps the change rows themselves visible and
239
+ // trims surrounding context proportionally so a multi-hunk diff doesn't
240
+ // turn into just the tail of the last hunk while streaming.
241
+ const {
242
+ text: truncatedDiff,
243
+ hiddenHunks,
244
+ hiddenLines,
245
+ } = truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, EDIT_STREAMING_PREVIEW_LINES);
242
246
  let text = "\n\n";
243
- text += renderDiffColored(displayLines.join("\n"), { filePath: rawPath });
244
- if (hidden > 0) {
245
- text += uiTheme.fg("dim", `\n… (${label} +${hidden} lines)`);
247
+ text += renderDiffColored(truncatedDiff, { filePath: rawPath });
248
+ if (hiddenHunks > 0 || hiddenLines > 0) {
249
+ const remainder: string[] = [];
250
+ if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
251
+ if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
252
+ text += uiTheme.fg("dim", `\n… (${label} +${remainder.join(", ")})`);
246
253
  } else {
247
254
  text += uiTheme.fg("dim", `\n(${label})`);
248
255
  }
@@ -20,11 +20,8 @@ import {
20
20
  END_PATCH_MARKER,
21
21
  type PatchSection as HashlineInputSection,
22
22
  Patch as HashlinePatch,
23
- Tokenizer as HashlineTokenizer,
24
23
  } from "@oh-my-pi/hashline";
25
- import { sanitizeText } from "@oh-my-pi/pi-utils";
26
24
  import type { Theme } from "../modes/theme/theme";
27
- import { replaceTabs, truncateToWidth } from "../tools/render-utils";
28
25
  import { type EditMode, resolveEditMode } from "../utils/edit-mode";
29
26
  import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
30
27
  import { computeHashlineDiff, computeHashlineSectionDiff } from "./hashline/diff";
@@ -73,48 +70,6 @@ export interface EditStreamingStrategy<Args = unknown> {
73
70
  renderStreamingFallback(args: Args, uiTheme: Theme): string;
74
71
  }
75
72
 
76
- const STREAMING_FALLBACK_LINES = 12;
77
- const STREAMING_FALLBACK_WIDTH = 80;
78
-
79
- // Streaming-preview classification reuses one tokenizer instance for the
80
- // stateless predicates and `tokenize`/`tokenizeAll` helpers; instances are
81
- // cheap, but keeping a single module-level reference matches the rest of
82
- // the hashline package.
83
- const HASHLINE_TOKENIZER = new HashlineTokenizer();
84
-
85
- function trimHashlineStreamingSyntax(lines: string[]): string[] {
86
- let index = lines.findIndex(line => line.trim().length > 0);
87
- if (index === -1) return [];
88
-
89
- if (HASHLINE_TOKENIZER.tokenize(lines[index]).kind === "envelope-begin") {
90
- index++;
91
- while (index < lines.length && lines[index].trim().length === 0) index++;
92
- }
93
- if (index < lines.length && HASHLINE_TOKENIZER.tokenize(lines[index]).kind === "header") {
94
- index++;
95
- }
96
-
97
- return lines.slice(index).filter(line => !HASHLINE_TOKENIZER.isEnvelopeMarker(line));
98
- }
99
-
100
- function renderHashlineInputFallback(input: string, uiTheme: Theme): string {
101
- const lines = trimHashlineStreamingSyntax(sanitizeText(input).split("\n"));
102
- if (!lines.some(line => line.trim().length > 0)) return "";
103
-
104
- const displayLines = lines.slice(-STREAMING_FALLBACK_LINES);
105
- const hidden = lines.length - displayLines.length;
106
- let text = "\n\n";
107
- text += displayLines
108
- .map(line => uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), STREAMING_FALLBACK_WIDTH)))
109
- .join("\n");
110
- if (hidden > 0) {
111
- text += uiTheme.fg("dim", `\n… (streaming +${hidden} lines)`);
112
- } else {
113
- text += uiTheme.fg("dim", "\n(streaming)");
114
- }
115
- return text;
116
- }
117
-
118
73
  // -----------------------------------------------------------------------------
119
74
  // Partial-JSON handling
120
75
  // -----------------------------------------------------------------------------
@@ -273,7 +228,6 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
273
228
 
274
229
  interface HashlineArgs {
275
230
  input?: string;
276
- path?: string;
277
231
  __partialJson?: string;
278
232
  }
279
233
 
@@ -353,75 +307,6 @@ function buildApplyPatchNaturalOrderPreviews(input: string): PerFileDiffPreview[
353
307
  return previews.length > 0 ? previews : null;
354
308
  }
355
309
 
356
- /**
357
- * Hashline equivalent: emit each payload line as a `+added` line in the
358
- * order the model typed it. We deliberately omit op headers and removal
359
- * targets from the streaming preview because their content lives in the file
360
- * and would require a costly re-apply per tick; the complete unified diff is
361
- * shown once streaming finishes.
362
- */
363
- function buildHashlineNaturalOrderPreviews(
364
- input: string,
365
- defaultPath: string | undefined,
366
- ): PerFileDiffPreview[] | null {
367
- const groups = new Map<string, string[]>();
368
- let currentPath = defaultPath ?? "";
369
- const ensure = (sectionPath: string): string[] => {
370
- let bucket = groups.get(sectionPath);
371
- if (!bucket) {
372
- bucket = [];
373
- groups.set(sectionPath, bucket);
374
- }
375
- return bucket;
376
- };
377
-
378
- // Per-call instance: the streaming preview re-runs each tick with the
379
- // cumulative input, and we need the line counter to start at 1. A
380
- // dedicated tokenizer keeps the shared HASHLINE_TOKENIZER above free
381
- // for stateless predicate use elsewhere in this module.
382
- const streamer = new HashlineTokenizer();
383
- for (const token of streamer.tokenizeAll(input)) {
384
- switch (token.kind) {
385
- case "envelope-begin":
386
- case "envelope-end":
387
- case "abort":
388
- case "op-delete":
389
- continue;
390
- case "blank":
391
- case "raw":
392
- continue;
393
- case "header":
394
- currentPath = token.path;
395
- if (currentPath) ensure(currentPath);
396
- continue;
397
- case "op-insert":
398
- case "op-replace":
399
- // Inline body on the op line itself (`N↓payload`, `A-B:payload`) is
400
- // payload content that just happens to share a line with the op
401
- // header — render it the same as a standalone payload token so
402
- // the very first character the model types after the sigil shows
403
- // up in the streaming preview. Without this, the preview is
404
- // empty until a newline arrives, and the renderer falls back to
405
- // raw input ("A-B: bla bla bla") instead of "+ bla bla bla".
406
- if (!currentPath || token.inlineBody === undefined) continue;
407
- ensure(currentPath).push(`+${token.inlineBody}`);
408
- continue;
409
- case "payload":
410
- if (!currentPath) continue;
411
- ensure(currentPath).push(`+${token.text}`);
412
- continue;
413
- }
414
- }
415
-
416
- if (groups.size === 0) return null;
417
- const previews: PerFileDiffPreview[] = [];
418
- for (const [sectionPath, body] of groups) {
419
- if (body.length === 0) continue;
420
- previews.push({ path: sectionPath, diff: body.join("\n") });
421
- }
422
- return previews.length > 0 ? previews : null;
423
- }
424
-
425
310
  const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
426
311
  extractCompleteEdits(args) {
427
312
  return args;
@@ -430,25 +315,21 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
430
315
  if (typeof args.input !== "string" || args.input.length === 0) return null;
431
316
  const input = trimTrailingPartialLine(args.input, ctx.isStreaming);
432
317
  if (input.length === 0) return null;
433
- if (ctx.isStreaming) {
434
- // Skip the costly per-tick re-apply and avoid `Diff.structuredPatch`
435
- // reordering by showing payload lines in input order.
436
- return buildHashlineNaturalOrderPreviews(input, args.path);
437
- }
438
318
  ctx.signal.throwIfAborted();
439
319
 
440
320
  let sections: readonly HashlineInputSection[];
441
321
  try {
442
- sections = HashlinePatch.parse(input, { cwd: ctx.cwd, path: args.path }).sections;
322
+ sections = HashlinePatch.parse(input, { cwd: ctx.cwd }).sections;
443
323
  } catch {
444
- // Single-section fallback keeps the original error rendering for the
445
- // "haven't typed PATH` yet" case.
446
- const result = await computeHashlineDiff({ input, path: args.path }, ctx.cwd, {
324
+ // While streaming, the trailing op may still be mid-typed and fail
325
+ // to parse; suppress until the next chunk arrives. Once args are
326
+ // complete, surface the error so the model sees what went wrong.
327
+ if (ctx.isStreaming) return null;
328
+ const result = await computeHashlineDiff({ input }, ctx.cwd, {
447
329
  autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
448
330
  });
449
331
  ctx.signal.throwIfAborted();
450
- if ("error" in result && !args.path) return [{ path: "", error: result.error }];
451
- return [toPerFilePreview(args.path ?? "", result)];
332
+ return [toPerFilePreview("", result)];
452
333
  }
453
334
  if (sections.length === 0) return null;
454
335
 
@@ -467,6 +348,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
467
348
  const section = sectionsToProcess[i];
468
349
  const result = await computeHashlineSectionDiff(section, ctx.cwd, {
469
350
  autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
351
+ streaming: ctx.isStreaming,
470
352
  });
471
353
  ctx.signal.throwIfAborted();
472
354
  // In a multi-section preview, ignore parse/apply errors from the
@@ -479,8 +361,13 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
479
361
  }
480
362
  return previews.length > 0 ? previews : null;
481
363
  },
482
- renderStreamingFallback(args, uiTheme) {
483
- return typeof args.input === "string" ? renderHashlineInputFallback(args.input, uiTheme) : "";
364
+ renderStreamingFallback() {
365
+ // Never leak raw hashline syntax (`64:`, `|payload`, `¶path#hash`)
366
+ // to the user — the streaming preview already projects every
367
+ // parseable op onto the real file via applyPartialTo, and an
368
+ // unparseable trailing chunk renders as "no preview yet" rather
369
+ // than a sigil dump.
370
+ return "";
484
371
  },
485
372
  };
486
373
 
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Compatibility shim for legacy extensions importing the package root of
3
+ * `@oh-my-pi/pi-ai` (or one of its aliased scopes like `@earendil-works/pi-ai`
4
+ * or `@mariozechner/pi-ai`).
5
+ *
6
+ * pi-ai 15.1.0 removed the historical TypeBox root exports (`Type`, plus the
7
+ * runtime-relevant half of the `Static`/`TSchema` pair) from the package
8
+ * entrypoint. Legacy extensions still author parameter schemas as
9
+ * `Type.Object({ ... })`, so this file is served by `legacy-pi-compat.ts` in
10
+ * place of the real pi-ai entrypoint whenever a legacy extension imports the
11
+ * bare package root. Subpath imports (`@oh-my-pi/pi-ai/utils/oauth`, etc.)
12
+ * continue to resolve directly against the bundled pi-ai package.
13
+ *
14
+ * The `Type` runtime is borrowed from the Zod-backed TypeBox shim that
15
+ * already serves bare `@sinclair/typebox` imports for the same extension
16
+ * class, keeping the legacy-compat surface internally consistent.
17
+ *
18
+ * Type-level `Static` and `TSchema` continue to come from pi-ai's own
19
+ * `types.ts` via the `export *` below — pi-ai still exports both as types,
20
+ * only the runtime `Type` builder was removed.
21
+ */
22
+
23
+ export * from "@oh-my-pi/pi-ai";
24
+ export { Type } from "./typebox";
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
+ import { isCompiledBinary } from "@oh-my-pi/pi-utils";
5
6
 
6
7
  // Canonical scope for in-process pi packages. Plugins published against any of
7
8
  // the aliased scopes below (mariozechner's original publish, earendil-works'
@@ -56,7 +57,34 @@ const resolvedSpecifierFallbacks = new Map<string, string>();
56
57
  // relying on them must vendor `@sinclair/typebox` directly.
57
58
  const TYPEBOX_SPECIFIER = "@sinclair/typebox";
58
59
  const TYPEBOX_SPECIFIER_FILTER = /^@sinclair\/typebox$/;
59
- const TYPEBOX_SHIM_PATH = path.resolve(import.meta.dir, "../typebox.ts");
60
+
61
+ // In-process compat shim paths. In dev `import.meta.dir` is the source folder of
62
+ // this file, so the dev branches resolve to the real `.ts` source. In compiled
63
+ // binaries `import.meta.dir` collapses to `/$bunfs/root`, so the runtime cannot
64
+ // recover the source layout that way; instead, each shim file is registered as
65
+ // a `--compile` entrypoint in `scripts/build-binary.ts`, which Bun emits into
66
+ // bunfs at a deterministic `--root`-relative path with a `.js` extension. The
67
+ // literals below must stay in sync with that listing — if either path drifts,
68
+ // every legacy plugin loading the shim fails with a missing-module error in
69
+ // release builds (without affecting `bun test`/dev).
70
+ const TYPEBOX_SHIM_PATH = isCompiledBinary()
71
+ ? "/$bunfs/root/packages/coding-agent/src/extensibility/typebox.js"
72
+ : path.resolve(import.meta.dir, "../typebox.ts");
73
+
74
+ // Legacy extensions historically imported `Type` (and `Static`/`TSchema`) from
75
+ // the package root of `@(scope)/pi-ai`. pi-ai 15.1.0 removed the runtime `Type`
76
+ // export (see `packages/ai/CHANGELOG.md`), so the bare canonical specifier no
77
+ // longer satisfies those imports. The override below redirects only the bare
78
+ // pi-ai package root onto a sibling shim that re-exports the canonical surface
79
+ // plus the borrowed `Type` runtime from the Zod-backed TypeBox shim. Subpath
80
+ // imports such as `@oh-my-pi/pi-ai/utils/oauth` continue to resolve directly
81
+ // against the bundled pi-ai package.
82
+ const LEGACY_PI_AI_SHIM_PATH = isCompiledBinary()
83
+ ? "/$bunfs/root/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.js"
84
+ : path.resolve(import.meta.dir, "../legacy-pi-ai-shim.ts");
85
+ const LEGACY_PI_PACKAGE_ROOT_OVERRIDES: Record<string, string> = {
86
+ [`${CANONICAL_PI_SCOPE}/pi-ai`]: LEGACY_PI_AI_SHIM_PATH,
87
+ };
60
88
 
61
89
  let isLegacyPiSpecifierShimInstalled = false;
62
90
 
@@ -85,6 +113,22 @@ function getResolvedSpecifier(specifier: string): string {
85
113
  return resolved;
86
114
  }
87
115
 
116
+ /**
117
+ * Resolve a canonical `@oh-my-pi/*` specifier to a filesystem path, preferring
118
+ * a bundled compat shim when one is registered for the package root.
119
+ *
120
+ * Falls back to `getResolvedSpecifier` (which may throw under compiled binary
121
+ * mode); callers handle that the same way they would for non-overridden
122
+ * specifiers.
123
+ */
124
+ function resolveCanonicalPiSpecifier(remappedSpecifier: string): string {
125
+ const override = LEGACY_PI_PACKAGE_ROOT_OVERRIDES[remappedSpecifier];
126
+ if (override) {
127
+ return override;
128
+ }
129
+ return getResolvedSpecifier(remappedSpecifier);
130
+ }
131
+
88
132
  function toImportSpecifier(resolvedPath: string): string {
89
133
  return url.pathToFileURL(resolvedPath).href;
90
134
  }
@@ -99,7 +143,7 @@ function rewriteLegacyPiImports(source: string): string {
99
143
  }
100
144
 
101
145
  try {
102
- return `${prefix}${toImportSpecifier(getResolvedSpecifier(remappedSpecifier))}${suffix}`;
146
+ return `${prefix}${toImportSpecifier(resolveCanonicalPiSpecifier(remappedSpecifier))}${suffix}`;
103
147
  } catch {
104
148
  // Resolution failed — typically in compiled binary mode where
105
149
  // Bun.resolveSync cannot walk up from /$bunfs/root to find the
@@ -250,7 +294,7 @@ function resolveLegacyPiSpecifier(args: { path: string; importer: string }): { p
250
294
  // Primary: resolve the canonical @oh-my-pi/* specifier from the host binary
251
295
  // location. Works in dev mode and in source-link installs.
252
296
  try {
253
- return { path: getResolvedSpecifier(remappedSpecifier) };
297
+ return { path: resolveCanonicalPiSpecifier(remappedSpecifier) };
254
298
  } catch {
255
299
  // Fallback for compiled binary mode: the bundled packages live inside
256
300
  // /$bunfs/root and aren't reachable by filesystem resolution. Try the