@oh-my-pi/pi-coding-agent 14.2.1 → 14.4.0

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 (137) hide show
  1. package/CHANGELOG.md +143 -1
  2. package/package.json +19 -19
  3. package/src/autoresearch/prompt.md +1 -1
  4. package/src/cli/args.ts +10 -1
  5. package/src/cli/shell-cli.ts +15 -3
  6. package/src/commit/agentic/prompts/analyze-file.md +1 -1
  7. package/src/config/model-registry.ts +67 -15
  8. package/src/config/prompt-templates.ts +5 -5
  9. package/src/config/settings-schema.ts +63 -4
  10. package/src/cursor.ts +3 -8
  11. package/src/debug/system-info.ts +6 -2
  12. package/src/discovery/claude.ts +58 -36
  13. package/src/discovery/helpers.ts +3 -3
  14. package/src/discovery/opencode.ts +20 -2
  15. package/src/edit/diff.ts +50 -47
  16. package/src/edit/index.ts +87 -57
  17. package/src/edit/line-hash.ts +735 -19
  18. package/src/edit/modes/apply-patch.ts +0 -9
  19. package/src/edit/modes/atom.ts +658 -0
  20. package/src/edit/modes/chunk.ts +144 -78
  21. package/src/edit/modes/hashline.ts +223 -146
  22. package/src/edit/modes/patch.ts +5 -9
  23. package/src/edit/modes/replace.ts +6 -11
  24. package/src/edit/renderer.ts +112 -143
  25. package/src/edit/streaming.ts +385 -0
  26. package/src/exec/bash-executor.ts +58 -5
  27. package/src/export/html/template.generated.ts +1 -1
  28. package/src/export/html/template.js +4 -12
  29. package/src/extensibility/custom-tools/types.ts +2 -0
  30. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  31. package/src/internal-urls/docs-index.generated.ts +7 -7
  32. package/src/internal-urls/pi-protocol.ts +0 -2
  33. package/src/lsp/client.ts +8 -1
  34. package/src/lsp/defaults.json +2 -1
  35. package/src/lsp/index.ts +1 -1
  36. package/src/mcp/render.ts +1 -8
  37. package/src/modes/acp/acp-agent.ts +76 -2
  38. package/src/modes/components/assistant-message.ts +5 -34
  39. package/src/modes/components/diff.ts +23 -14
  40. package/src/modes/components/footer.ts +21 -16
  41. package/src/modes/components/hook-editor.ts +1 -1
  42. package/src/modes/components/settings-defs.ts +6 -1
  43. package/src/modes/components/todo-reminder.ts +1 -8
  44. package/src/modes/components/tool-execution.ts +112 -105
  45. package/src/modes/controllers/input-controller.ts +1 -1
  46. package/src/modes/controllers/selector-controller.ts +1 -1
  47. package/src/modes/interactive-mode.ts +0 -2
  48. package/src/modes/print-mode.ts +8 -0
  49. package/src/modes/theme/mermaid-cache.ts +13 -52
  50. package/src/modes/theme/theme.ts +2 -2
  51. package/src/prompts/agents/librarian.md +1 -1
  52. package/src/prompts/agents/reviewer.md +4 -4
  53. package/src/prompts/ci-green-request.md +1 -1
  54. package/src/prompts/review-request.md +1 -1
  55. package/src/prompts/system/subagent-system-prompt.md +3 -3
  56. package/src/prompts/system/subagent-yield-reminder.md +11 -0
  57. package/src/prompts/system/system-prompt.md +4 -1
  58. package/src/prompts/tools/ask.md +3 -2
  59. package/src/prompts/tools/ast-edit.md +15 -19
  60. package/src/prompts/tools/ast-grep.md +18 -24
  61. package/src/prompts/tools/atom.md +96 -0
  62. package/src/prompts/tools/browser.md +1 -0
  63. package/src/prompts/tools/chunk-edit.md +58 -179
  64. package/src/prompts/tools/debug.md +4 -5
  65. package/src/prompts/tools/exit-plan-mode.md +4 -5
  66. package/src/prompts/tools/find.md +4 -8
  67. package/src/prompts/tools/github.md +18 -0
  68. package/src/prompts/tools/grep.md +8 -8
  69. package/src/prompts/tools/hashline.md +22 -89
  70. package/src/prompts/tools/{gemini-image.md → image-gen.md} +1 -1
  71. package/src/prompts/tools/inspect-image.md +6 -6
  72. package/src/prompts/tools/lsp.md +6 -0
  73. package/src/prompts/tools/patch.md +12 -19
  74. package/src/prompts/tools/python.md +3 -2
  75. package/src/prompts/tools/read-chunk.md +46 -8
  76. package/src/prompts/tools/read.md +9 -6
  77. package/src/prompts/tools/ssh.md +8 -17
  78. package/src/prompts/tools/todo-write.md +54 -41
  79. package/src/sdk.ts +22 -14
  80. package/src/session/agent-session.ts +61 -22
  81. package/src/session/session-manager.ts +228 -57
  82. package/src/session/streaming-output.ts +11 -0
  83. package/src/system-prompt.ts +7 -2
  84. package/src/task/executor.ts +44 -48
  85. package/src/task/render.ts +11 -13
  86. package/src/tools/ask.ts +7 -7
  87. package/src/tools/ast-edit.ts +45 -41
  88. package/src/tools/ast-grep.ts +77 -85
  89. package/src/tools/bash.ts +21 -9
  90. package/src/tools/browser.ts +32 -30
  91. package/src/tools/calculator.ts +4 -4
  92. package/src/tools/cancel-job.ts +1 -1
  93. package/src/tools/checkpoint.ts +2 -2
  94. package/src/tools/debug.ts +41 -37
  95. package/src/tools/exit-plan-mode.ts +1 -1
  96. package/src/tools/find.ts +4 -4
  97. package/src/tools/gh-renderer.ts +12 -4
  98. package/src/tools/gh.ts +514 -712
  99. package/src/tools/grep.ts +115 -130
  100. package/src/tools/{gemini-image.ts → image-gen.ts} +459 -60
  101. package/src/tools/index.ts +14 -32
  102. package/src/tools/inspect-image.ts +3 -3
  103. package/src/tools/json-tree.ts +114 -114
  104. package/src/tools/match-line-format.ts +9 -8
  105. package/src/tools/notebook.ts +8 -7
  106. package/src/tools/poll-tool.ts +2 -1
  107. package/src/tools/python.ts +9 -23
  108. package/src/tools/read.ts +32 -21
  109. package/src/tools/render-mermaid.ts +1 -1
  110. package/src/tools/render-utils.ts +18 -0
  111. package/src/tools/renderers.ts +2 -2
  112. package/src/tools/report-tool-issue.ts +3 -2
  113. package/src/tools/resolve.ts +1 -1
  114. package/src/tools/review.ts +12 -10
  115. package/src/tools/search-tool-bm25.ts +2 -4
  116. package/src/tools/sqlite-reader.ts +116 -3
  117. package/src/tools/ssh.ts +4 -4
  118. package/src/tools/todo-write.ts +172 -147
  119. package/src/tools/vim.ts +14 -15
  120. package/src/tools/write.ts +4 -4
  121. package/src/tools/{submit-result.ts → yield.ts} +11 -13
  122. package/src/utils/edit-mode.ts +2 -1
  123. package/src/utils/file-display-mode.ts +10 -5
  124. package/src/utils/git.ts +9 -5
  125. package/src/utils/shell-snapshot.ts +2 -3
  126. package/src/vim/render.ts +4 -4
  127. package/src/web/search/providers/codex.ts +129 -6
  128. package/src/prompts/system/subagent-submit-reminder.md +0 -11
  129. package/src/prompts/tools/gh-issue-view.md +0 -11
  130. package/src/prompts/tools/gh-pr-checkout.md +0 -12
  131. package/src/prompts/tools/gh-pr-diff.md +0 -12
  132. package/src/prompts/tools/gh-pr-push.md +0 -11
  133. package/src/prompts/tools/gh-pr-view.md +0 -11
  134. package/src/prompts/tools/gh-repo-view.md +0 -11
  135. package/src/prompts/tools/gh-run-watch.md +0 -12
  136. package/src/prompts/tools/gh-search-issues.md +0 -11
  137. package/src/prompts/tools/gh-search-prs.md +0 -11
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Streaming edit preview strategies.
3
+ *
4
+ * Each edit mode owns a strategy that knows how to:
5
+ * - collapse partial-JSON args to the subset safe to preview
6
+ * (`extractCompleteEdits`),
7
+ * - compute unified diff previews for the in-flight args
8
+ * (`computeDiffPreview`), and
9
+ * - render a text placeholder while no diff exists yet
10
+ * (`renderStreamingFallback`).
11
+ *
12
+ * The shared renderer / `ToolExecutionComponent` consult the strategy via
13
+ * the injected `editMode` rather than probing argument shape.
14
+ */
15
+ import type { Theme } from "../modes/theme/theme";
16
+ import { type EditMode, resolveEditMode } from "../utils/edit-mode";
17
+ import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
18
+ import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
19
+ import { type ChunkToolEdit, computeChunkDiff, parseChunkEditPath } from "./modes/chunk";
20
+ import { computeHashlineDiff, type HashlineToolEdit } from "./modes/hashline";
21
+ import { computePatchDiff, type PatchEditEntry } from "./modes/patch";
22
+ import type { ReplaceEditEntry } from "./modes/replace";
23
+
24
+ export interface PerFileDiffPreview {
25
+ path: string;
26
+ diff?: string;
27
+ firstChangedLine?: number;
28
+ error?: string;
29
+ }
30
+
31
+ export interface StreamingDiffContext {
32
+ cwd: string;
33
+ signal: AbortSignal;
34
+ fuzzyThreshold?: number;
35
+ allowFuzzy?: boolean;
36
+ }
37
+
38
+ export interface EditStreamingStrategy<Args = unknown> {
39
+ /**
40
+ * Return the args restricted to edits that are "complete enough" to
41
+ * compute a diff against. Strategies drop the trailing incomplete entry
42
+ * when `partialJson` indicates its closing `}` hasn't arrived yet.
43
+ */
44
+ extractCompleteEdits(args: Args, partialJson: string | undefined): Args;
45
+ /**
46
+ * Compute diff(s) for the given partial args. Returns `null` when args
47
+ * do not yet carry enough structure to compute anything.
48
+ */
49
+ computeDiffPreview(args: Args, ctx: StreamingDiffContext): Promise<PerFileDiffPreview[] | null>;
50
+ /**
51
+ * Rendered inline while the diff hasn't been computed yet (or when the
52
+ * compute returned `null` because args are still too partial).
53
+ */
54
+ renderStreamingFallback(args: Args, uiTheme: Theme): string;
55
+ }
56
+
57
+ // -----------------------------------------------------------------------------
58
+ // Partial-JSON handling
59
+ // -----------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Given an edits array parsed from partial JSON, drop the last entry when the
63
+ * corresponding object in `partialJson` does not yet end with a closed `}`.
64
+ *
65
+ * This guards against `partial-json` silently coercing truncated tails like
66
+ * `"write":nu` / `"write":nul` into `{ write: null }`, which would make the
67
+ * last entry render a spurious null-write error until the value finishes
68
+ * streaming.
69
+ */
70
+ export function dropIncompleteLastEdit<T>(edits: readonly T[], partialJson: string | undefined, listKey: string): T[] {
71
+ if (!Array.isArray(edits) || edits.length === 0) return [...(edits ?? [])];
72
+ if (!partialJson) return [...edits];
73
+
74
+ const keyMarker = `"${listKey}"`;
75
+ const keyIdx = partialJson.indexOf(keyMarker);
76
+ if (keyIdx === -1) return [...edits];
77
+
78
+ // Find the `[` that opens the list value.
79
+ let i = partialJson.indexOf("[", keyIdx + keyMarker.length);
80
+ if (i === -1) return [...edits];
81
+ i++;
82
+
83
+ let depth = 0;
84
+ let inString = false;
85
+ let escaped = false;
86
+ let lastClose = -1;
87
+ for (; i < partialJson.length; i++) {
88
+ const ch = partialJson[i];
89
+ if (escaped) {
90
+ escaped = false;
91
+ continue;
92
+ }
93
+ if (ch === "\\") {
94
+ if (inString) escaped = true;
95
+ continue;
96
+ }
97
+ if (ch === '"') {
98
+ inString = !inString;
99
+ continue;
100
+ }
101
+ if (inString) continue;
102
+ if (ch === "{" || ch === "[") {
103
+ depth++;
104
+ } else if (ch === "}" || ch === "]") {
105
+ depth--;
106
+ if (ch === "}" && depth === 0) {
107
+ lastClose = i;
108
+ }
109
+ if (ch === "]" && depth === -1) {
110
+ // End of list reached.
111
+ break;
112
+ }
113
+ }
114
+ }
115
+
116
+ // If we're still inside the list and saw no closing `}` for the last entry,
117
+ // or there is trailing non-whitespace after the last `}` before the list
118
+ // ended (i.e. a new object has opened), drop the trailing entry.
119
+ const tail = lastClose === -1 ? partialJson.slice(i) : partialJson.slice(lastClose + 1);
120
+ const sawNewObjectAfterLastClose = /\{/.test(tail);
121
+ const listIsStillOpen = depth >= 0;
122
+
123
+ if (lastClose === -1 || (listIsStillOpen && sawNewObjectAfterLastClose)) {
124
+ return edits.slice(0, -1);
125
+ }
126
+ return [...edits];
127
+ }
128
+
129
+ // -----------------------------------------------------------------------------
130
+ // Strategies
131
+ // -----------------------------------------------------------------------------
132
+
133
+ interface ReplaceArgs {
134
+ path?: string;
135
+ edits?: ReplaceEditEntry[];
136
+ __partialJson?: string;
137
+ }
138
+
139
+ const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
140
+ extractCompleteEdits(args, partialJson) {
141
+ if (!args?.edits) return args;
142
+ return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
143
+ },
144
+ async computeDiffPreview(args, ctx) {
145
+ const first = args.edits?.[0];
146
+ if (!first) return null;
147
+ const path = first.path ?? args.path;
148
+ if (!path || first.old_text === undefined || first.new_text === undefined) return null;
149
+ ctx.signal.throwIfAborted();
150
+ const result = await computeEditDiff(
151
+ path,
152
+ first.old_text,
153
+ first.new_text,
154
+ ctx.cwd,
155
+ ctx.allowFuzzy ?? true,
156
+ first.all,
157
+ ctx.fuzzyThreshold,
158
+ );
159
+ ctx.signal.throwIfAborted();
160
+ return [toPerFilePreview(path, result)];
161
+ },
162
+ renderStreamingFallback() {
163
+ return "";
164
+ },
165
+ };
166
+
167
+ interface PatchArgs {
168
+ path?: string;
169
+ edits?: PatchEditEntry[];
170
+ __partialJson?: string;
171
+ }
172
+
173
+ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
174
+ extractCompleteEdits(args, partialJson) {
175
+ if (!args?.edits) return args;
176
+ return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
177
+ },
178
+ async computeDiffPreview(args, ctx) {
179
+ const first = args.edits?.[0];
180
+ const path = first?.path ?? args.path;
181
+ if (!path) return null;
182
+ ctx.signal.throwIfAborted();
183
+ const result = await computePatchDiff(
184
+ { path, op: first?.op ?? "update", rename: first?.rename, diff: first?.diff },
185
+ ctx.cwd,
186
+ { fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
187
+ );
188
+ ctx.signal.throwIfAborted();
189
+ return [toPerFilePreview(path, result)];
190
+ },
191
+ renderStreamingFallback() {
192
+ return "";
193
+ },
194
+ };
195
+
196
+ interface HashlineArgs {
197
+ path?: string;
198
+ edits?: HashlineToolEdit[];
199
+ __partialJson?: string;
200
+ }
201
+
202
+ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
203
+ extractCompleteEdits(args, partialJson) {
204
+ if (!args?.edits) return args;
205
+ return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
206
+ },
207
+ async computeDiffPreview(args, ctx) {
208
+ const first = args.edits?.[0] as (HashlineToolEdit & { path?: string }) | undefined;
209
+ const path = first?.path ?? args.path;
210
+ if (!path) return null;
211
+ const fileEdits = (args.edits ?? [])
212
+ .map(e => {
213
+ if (!e || typeof e !== "object") return undefined;
214
+ const entryPath = (e as { path?: string }).path ?? args.path;
215
+ if (!entryPath || entryPath !== path) return undefined;
216
+ return { ...(e as HashlineToolEdit), path } as HashlineToolEdit & { path: string };
217
+ })
218
+ .filter((e): e is HashlineToolEdit & { path: string } => e !== undefined);
219
+ ctx.signal.throwIfAborted();
220
+ const result = await computeHashlineDiff({ path, edits: fileEdits }, ctx.cwd);
221
+ ctx.signal.throwIfAborted();
222
+ return [toPerFilePreview(path, result)];
223
+ },
224
+ renderStreamingFallback() {
225
+ return "";
226
+ },
227
+ };
228
+
229
+ interface ChunkArgs {
230
+ path?: string;
231
+ edits?: ChunkToolEdit[];
232
+ __partialJson?: string;
233
+ }
234
+
235
+ const chunkStrategy: EditStreamingStrategy<ChunkArgs> = {
236
+ extractCompleteEdits(args, partialJson) {
237
+ if (!args?.edits) return args;
238
+ let edits = dropIncompleteLastEdit(args.edits, partialJson, "edits");
239
+ // Extra guard: if partial JSON still contains `":nu` / `":nul` (partial
240
+ // `null` literals), `partial-json` may have already surfaced the last
241
+ // entry with `write === null`. When that entry's `}` hasn't closed
242
+ // yet, it has already been dropped above. But if dropping was not
243
+ // triggered (e.g. list still open and no new `{` after), also drop the
244
+ // trailing null-write entry so the preview does not flicker with an
245
+ // error for an incomplete string/null literal.
246
+ if (partialJson && edits.length > 0) {
247
+ const last = edits[edits.length - 1] as Partial<ChunkToolEdit> | undefined;
248
+ const endsInPartialNull = /:\s*nu?l?\s*$/.test(partialJson.trimEnd());
249
+ if (last && endsInPartialNull && last.write === null) {
250
+ edits = edits.slice(0, -1);
251
+ }
252
+ }
253
+ return { ...args, edits };
254
+ },
255
+ async computeDiffPreview(args, ctx) {
256
+ const edits = args.edits ?? [];
257
+ if (edits.length === 0) return null;
258
+ // Group edits by file path
259
+ const groups = new Map<string, ChunkToolEdit[]>();
260
+ const fileOrder: string[] = [];
261
+ for (const edit of edits) {
262
+ if (!edit) continue;
263
+ const editPath = edit.path ?? args.path;
264
+ if (!editPath) continue;
265
+ const { filePath } = parseChunkEditPath(editPath);
266
+ if (!filePath) continue;
267
+ let bucket = groups.get(filePath);
268
+ if (!bucket) {
269
+ bucket = [];
270
+ groups.set(filePath, bucket);
271
+ fileOrder.push(filePath);
272
+ }
273
+ bucket.push({ ...edit, path: editPath });
274
+ }
275
+ if (fileOrder.length === 0) return null;
276
+
277
+ const MAX_FILES = 5;
278
+ const selected = fileOrder.slice(0, MAX_FILES);
279
+ const previews: PerFileDiffPreview[] = [];
280
+ for (const filePath of selected) {
281
+ ctx.signal.throwIfAborted();
282
+ const fileEdits = groups.get(filePath) ?? [];
283
+ const result = await computeChunkDiff({ path: filePath, edits: fileEdits }, ctx.cwd, { signal: ctx.signal });
284
+ previews.push(toPerFilePreview(filePath, result));
285
+ }
286
+ return previews;
287
+ },
288
+ renderStreamingFallback() {
289
+ return "";
290
+ },
291
+ };
292
+
293
+ interface ApplyPatchArgs {
294
+ input?: string;
295
+ }
296
+
297
+ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
298
+ extractCompleteEdits(args) {
299
+ // Apply_patch payload is plain text, not an edits array. Nothing to trim.
300
+ return args;
301
+ },
302
+ async computeDiffPreview(args, ctx) {
303
+ if (typeof args.input !== "string" || args.input.length === 0) return null;
304
+ let entries: PatchEditEntry[];
305
+ try {
306
+ entries = expandApplyPatchToEntries({ input: args.input });
307
+ } catch {
308
+ try {
309
+ entries = expandApplyPatchToPreviewEntries({ input: args.input });
310
+ } catch (err) {
311
+ return [{ path: "", error: err instanceof Error ? err.message : String(err) }];
312
+ }
313
+ }
314
+ const first = entries[0];
315
+ if (!first?.path) return null;
316
+ ctx.signal.throwIfAborted();
317
+ const result = await computePatchDiff(
318
+ { path: first.path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
319
+ ctx.cwd,
320
+ { fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
321
+ );
322
+ ctx.signal.throwIfAborted();
323
+ return [toPerFilePreview(first.path, result)];
324
+ },
325
+ renderStreamingFallback() {
326
+ return "";
327
+ },
328
+ };
329
+
330
+ // Vim streaming preview is handled by the existing vimToolRenderer inside
331
+ // edit/renderer.ts. The strategy here is a no-op so the registry is total.
332
+ const vimStrategy: EditStreamingStrategy<unknown> = {
333
+ extractCompleteEdits(args) {
334
+ return args;
335
+ },
336
+ async computeDiffPreview() {
337
+ return null;
338
+ },
339
+ renderStreamingFallback() {
340
+ return "";
341
+ },
342
+ };
343
+
344
+ interface AtomArgs {
345
+ path?: string;
346
+ edits?: unknown[];
347
+ }
348
+
349
+ const atomStrategy: EditStreamingStrategy<AtomArgs> = {
350
+ extractCompleteEdits(args, partialJson) {
351
+ if (!args.edits) return args;
352
+ return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
353
+ },
354
+ async computeDiffPreview() {
355
+ // Atom edits are line-anchored and validated against live file hashes; a
356
+ // streaming preview without that validation could mislead. Skip for now.
357
+ return null;
358
+ },
359
+ renderStreamingFallback() {
360
+ return "";
361
+ },
362
+ };
363
+
364
+ export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknown>> = {
365
+ replace: replaceStrategy as EditStreamingStrategy<unknown>,
366
+ patch: patchStrategy as EditStreamingStrategy<unknown>,
367
+ hashline: hashlineStrategy as EditStreamingStrategy<unknown>,
368
+ chunk: chunkStrategy as EditStreamingStrategy<unknown>,
369
+ apply_patch: applyPatchStrategy as EditStreamingStrategy<unknown>,
370
+ vim: vimStrategy,
371
+ atom: atomStrategy as EditStreamingStrategy<unknown>,
372
+ };
373
+
374
+ export { resolveEditMode };
375
+
376
+ // -----------------------------------------------------------------------------
377
+ // Helpers
378
+ // -----------------------------------------------------------------------------
379
+
380
+ function toPerFilePreview(path: string, result: DiffResult | DiffError): PerFileDiffPreview {
381
+ if ("error" in result) {
382
+ return { path, error: result.error };
383
+ }
384
+ return { path, diff: result.diff, firstChangedLine: result.firstChangedLine };
385
+ }
@@ -4,8 +4,8 @@
4
4
  * Uses brush-core via native bindings for shell execution.
5
5
  */
6
6
  import * as fs from "node:fs/promises";
7
- import { executeShell, Shell } from "@oh-my-pi/pi-natives";
8
- import { Settings } from "../config/settings";
7
+ import { executeShell, type MinimizerOptions, Shell } from "@oh-my-pi/pi-natives";
8
+ import { Settings, type ShellMinimizerSettings } from "../config/settings";
9
9
  import { OutputSink } from "../session/streaming-output";
10
10
  import { getOrCreateSnapshot } from "../utils/shell-snapshot";
11
11
  import { NON_INTERACTIVE_ENV } from "./non-interactive-env";
@@ -22,6 +22,17 @@ export interface BashExecutorOptions {
22
22
  /** Artifact path/id for full output storage */
23
23
  artifactPath?: string;
24
24
  artifactId?: string;
25
+ /**
26
+ * Invoked when the native minimizer rewrote the command's output, giving
27
+ * the caller a chance to persist the lossless original capture (typically
28
+ * via the session's `ArtifactManager`). The returned id is spliced into
29
+ * the sink output as `artifact://<id>` so the agent can retrieve the raw
30
+ * bytes. Return `undefined` to skip the footer.
31
+ */
32
+ onMinimizedSave?: (
33
+ originalText: string,
34
+ info: { filter: string; inputBytes: number; outputBytes: number },
35
+ ) => Promise<string | undefined>;
25
36
  }
26
37
 
27
38
  export interface BashResult {
@@ -53,10 +64,24 @@ async function resolveShellCwd(cwd: string | undefined): Promise<string | undefi
53
64
  }
54
65
  }
55
66
 
67
+ function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined {
68
+ if (!group.enabled) return undefined;
69
+ return {
70
+ enabled: true,
71
+ settingsPath: group.settingsPath || undefined,
72
+ only: group.only.length > 0 ? group.only : undefined,
73
+ except: group.except.length > 0 ? group.except : undefined,
74
+ maxCaptureBytes: group.maxCaptureBytes,
75
+ };
76
+ }
77
+
56
78
  export async function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
57
79
  const settings = await Settings.init();
58
80
  const { shell, env: shellEnv, prefix } = settings.getShellConfig();
59
81
  const snapshotPath = shell.includes("bash") ? await getOrCreateSnapshot(shell, shellEnv) : null;
82
+
83
+ const minimizer = buildMinimizerOptions(settings.getGroup("shellMinimizer"));
84
+
60
85
  const commandCwd = await resolveShellCwd(options?.cwd);
61
86
  const commandEnv = options?.env ? { ...NON_INTERACTIVE_ENV, ...options.env } : NON_INTERACTIVE_ENV;
62
87
 
@@ -89,7 +114,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
89
114
  };
90
115
  }
91
116
 
92
- const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey);
117
+ const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey, minimizer);
93
118
  const persistentSessionBroken = brokenShellSessions.has(sessionKey);
94
119
  if (persistentSessionBroken) {
95
120
  shellSessions.delete(sessionKey);
@@ -97,7 +122,11 @@ export async function executeBash(command: string, options?: BashExecutorOptions
97
122
 
98
123
  let shellSession = persistentSessionBroken ? undefined : shellSessions.get(sessionKey);
99
124
  if (!shellSession && !persistentSessionBroken) {
100
- shellSession = new Shell({ sessionEnv: shellEnv, snapshotPath: snapshotPath ?? undefined });
125
+ shellSession = new Shell({
126
+ sessionEnv: shellEnv,
127
+ snapshotPath: snapshotPath ?? undefined,
128
+ minimizer,
129
+ });
101
130
  shellSessions.set(sessionKey, shellSession);
102
131
  }
103
132
  const userSignal = options?.signal;
@@ -152,6 +181,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
152
181
  env: commandEnv,
153
182
  sessionEnv: shellEnv,
154
183
  snapshotPath: snapshotPath ?? undefined,
184
+ minimizer,
155
185
  timeoutMs: options?.timeout,
156
186
  signal: runAbortController.signal,
157
187
  },
@@ -204,6 +234,25 @@ export async function executeBash(command: string, options?: BashExecutorOptions
204
234
  };
205
235
  }
206
236
 
237
+ // When the native minimizer rewrote the output, swap the sink's accumulated
238
+ // raw stream for the minimized text, persist the original as a session
239
+ // artifact, and splice an `artifact://<id>` footer into the visible text so
240
+ // the agent can retrieve the raw bytes losslessly.
241
+ const minimized = winner.result.minimized;
242
+ if (minimized && minimized.text !== minimized.originalText) {
243
+ sink.replace(minimized.text);
244
+ if (options?.onMinimizedSave) {
245
+ const artifactId = await options.onMinimizedSave(minimized.originalText, {
246
+ filter: minimized.filter,
247
+ inputBytes: minimized.inputBytes,
248
+ outputBytes: minimized.outputBytes,
249
+ });
250
+ if (artifactId) {
251
+ sink.push(`\n[raw output: artifact://${artifactId}]\n`);
252
+ }
253
+ }
254
+ }
255
+
207
256
  // Normal completion
208
257
  return {
209
258
  exitCode: winner.result.exitCode,
@@ -232,9 +281,13 @@ function buildSessionKey(
232
281
  snapshotPath: string | null,
233
282
  env: Record<string, string>,
234
283
  agentSessionKey?: string,
284
+ minimizer?: MinimizerOptions,
235
285
  ): string {
236
286
  const entries = Object.entries(env);
237
287
  entries.sort(([a], [b]) => a.localeCompare(b));
238
288
  const envSerialized = entries.map(([key, value]) => `${key}=${value}`).join("\n");
239
- return [agentSessionKey ?? "", shell, prefix ?? "", snapshotPath ?? "", envSerialized].join("\n");
289
+ const minimizerSerialized = minimizer ? JSON.stringify(minimizer) : "";
290
+ return [agentSessionKey ?? "", shell, prefix ?? "", snapshotPath ?? "", envSerialized, minimizerSerialized].join(
291
+ "\n",
292
+ );
240
293
  }