@oh-my-pi/pi-coding-agent 6.2.0 → 6.7.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 (93) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/docs/sdk.md +1 -1
  3. package/package.json +5 -5
  4. package/scripts/generate-template.ts +6 -6
  5. package/src/cli/args.ts +3 -0
  6. package/src/core/agent-session.ts +39 -0
  7. package/src/core/bash-executor.ts +3 -3
  8. package/src/core/cursor/exec-bridge.ts +95 -88
  9. package/src/core/custom-commands/bundled/review/index.ts +142 -145
  10. package/src/core/custom-commands/bundled/wt/index.ts +68 -66
  11. package/src/core/custom-commands/loader.ts +4 -6
  12. package/src/core/custom-tools/index.ts +2 -2
  13. package/src/core/custom-tools/loader.ts +66 -61
  14. package/src/core/custom-tools/types.ts +4 -4
  15. package/src/core/custom-tools/wrapper.ts +61 -25
  16. package/src/core/event-bus.ts +19 -47
  17. package/src/core/extensions/index.ts +8 -4
  18. package/src/core/extensions/loader.ts +160 -120
  19. package/src/core/extensions/types.ts +4 -4
  20. package/src/core/extensions/wrapper.ts +149 -100
  21. package/src/core/hooks/index.ts +1 -1
  22. package/src/core/hooks/tool-wrapper.ts +96 -70
  23. package/src/core/hooks/types.ts +1 -2
  24. package/src/core/index.ts +1 -0
  25. package/src/core/mcp/index.ts +6 -2
  26. package/src/core/mcp/json-rpc.ts +88 -0
  27. package/src/core/mcp/loader.ts +22 -4
  28. package/src/core/mcp/manager.ts +202 -48
  29. package/src/core/mcp/tool-bridge.ts +143 -55
  30. package/src/core/mcp/tool-cache.ts +122 -0
  31. package/src/core/python-executor.ts +3 -9
  32. package/src/core/sdk.ts +33 -32
  33. package/src/core/session-manager.ts +30 -0
  34. package/src/core/settings-manager.ts +34 -1
  35. package/src/core/ssh/ssh-executor.ts +6 -84
  36. package/src/core/streaming-output.ts +107 -53
  37. package/src/core/tools/ask.ts +92 -93
  38. package/src/core/tools/bash.ts +103 -94
  39. package/src/core/tools/calculator.ts +41 -26
  40. package/src/core/tools/complete.ts +76 -66
  41. package/src/core/tools/context.ts +22 -24
  42. package/src/core/tools/exa/index.ts +1 -1
  43. package/src/core/tools/exa/mcp-client.ts +56 -101
  44. package/src/core/tools/find.ts +250 -253
  45. package/src/core/tools/git.ts +39 -33
  46. package/src/core/tools/grep.ts +440 -427
  47. package/src/core/tools/index.ts +62 -61
  48. package/src/core/tools/ls.ts +119 -114
  49. package/src/core/tools/lsp/clients/biome-client.ts +5 -7
  50. package/src/core/tools/lsp/clients/index.ts +4 -4
  51. package/src/core/tools/lsp/clients/lsp-linter-client.ts +5 -7
  52. package/src/core/tools/lsp/config.ts +2 -2
  53. package/src/core/tools/lsp/index.ts +604 -578
  54. package/src/core/tools/notebook.ts +121 -119
  55. package/src/core/tools/output.ts +163 -147
  56. package/src/core/tools/patch/applicator.ts +1100 -0
  57. package/src/core/tools/patch/diff.ts +362 -0
  58. package/src/core/tools/patch/fuzzy.ts +647 -0
  59. package/src/core/tools/patch/index.ts +430 -0
  60. package/src/core/tools/patch/normalize.ts +220 -0
  61. package/src/core/tools/patch/normative.ts +49 -0
  62. package/src/core/tools/patch/parser.ts +528 -0
  63. package/src/core/tools/patch/shared.ts +228 -0
  64. package/src/core/tools/patch/types.ts +244 -0
  65. package/src/core/tools/python.ts +139 -136
  66. package/src/core/tools/read.ts +237 -216
  67. package/src/core/tools/render-utils.ts +196 -77
  68. package/src/core/tools/renderers.ts +1 -1
  69. package/src/core/tools/ssh.ts +99 -80
  70. package/src/core/tools/task/executor.ts +11 -7
  71. package/src/core/tools/task/index.ts +352 -343
  72. package/src/core/tools/task/worker.ts +13 -23
  73. package/src/core/tools/todo-write.ts +74 -59
  74. package/src/core/tools/web-fetch.ts +54 -47
  75. package/src/core/tools/web-search/index.ts +27 -16
  76. package/src/core/tools/write.ts +73 -44
  77. package/src/core/ttsr.ts +106 -152
  78. package/src/core/voice.ts +49 -39
  79. package/src/index.ts +16 -12
  80. package/src/lib/worktree/index.ts +1 -9
  81. package/src/modes/interactive/components/diff.ts +15 -8
  82. package/src/modes/interactive/components/settings-defs.ts +24 -0
  83. package/src/modes/interactive/components/tool-execution.ts +34 -6
  84. package/src/modes/interactive/controllers/event-controller.ts +6 -19
  85. package/src/modes/interactive/controllers/input-controller.ts +1 -1
  86. package/src/modes/interactive/utils/ui-helpers.ts +5 -1
  87. package/src/modes/rpc/rpc-mode.ts +99 -81
  88. package/src/prompts/tools/patch.md +76 -0
  89. package/src/prompts/tools/read.md +1 -1
  90. package/src/prompts/tools/{edit.md → replace.md} +1 -0
  91. package/src/utils/shell.ts +0 -40
  92. package/src/core/tools/edit-diff.ts +0 -574
  93. package/src/core/tools/edit.ts +0 -345
@@ -1,5 +1,3 @@
1
- import stripAnsi from "strip-ansi";
2
- import { sanitizeBinaryOutput } from "../utils/shell";
3
1
  import { logger } from "./logger";
4
2
  import {
5
3
  checkPythonKernelAvailability,
@@ -9,7 +7,7 @@ import {
9
7
  type PreludeHelper,
10
8
  PythonKernel,
11
9
  } from "./python-kernel";
12
- import { createOutputSink } from "./streaming-output";
10
+ import { OutputSink, sanitizeText } from "./streaming-output";
13
11
  import { DEFAULT_MAX_BYTES } from "./tools/truncate";
14
12
 
15
13
  export type PythonKernelMode = "session" | "per-call";
@@ -72,10 +70,6 @@ export async function disposeAllKernelSessions(): Promise<void> {
72
70
  await Promise.allSettled(sessions.map((session) => disposeKernelSession(session)));
73
71
  }
74
72
 
75
- function sanitizeChunk(text: string): string {
76
- return sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, "");
77
- }
78
-
79
73
  async function ensureKernelAvailable(cwd: string): Promise<void> {
80
74
  const availability = await checkPythonKernelAvailability(cwd);
81
75
  if (!availability.ok) {
@@ -218,7 +212,7 @@ async function executeWithKernel(
218
212
  code: string,
219
213
  options: PythonExecutorOptions | undefined,
220
214
  ): Promise<PythonResult> {
221
- const sink = createOutputSink(DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES * 2, options?.onChunk);
215
+ const sink = new OutputSink(DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES * 2, options?.onChunk);
222
216
  const writer = sink.getWriter();
223
217
  const displayOutputs: KernelDisplayOutput[] = [];
224
218
 
@@ -227,7 +221,7 @@ async function executeWithKernel(
227
221
  signal: options?.signal,
228
222
  timeoutMs: options?.timeout,
229
223
  onChunk: async (text) => {
230
- await writer.write(sanitizeChunk(text));
224
+ await writer.write(sanitizeText(text));
231
225
  },
232
226
  onDisplay: async (output) => {
233
227
  displayOutputs.push(output);
package/src/core/sdk.ts CHANGED
@@ -40,13 +40,13 @@ import { initializeWithSettings } from "../discovery";
40
40
  import { registerAsyncCleanup } from "../modes/cleanup";
41
41
  import { AgentSession } from "./agent-session";
42
42
  import { AuthStorage } from "./auth-storage";
43
- import { createCursorExecHandlers } from "./cursor/exec-bridge";
43
+ import { CursorExecHandlers } from "./cursor/exec-bridge";
44
44
  import {
45
45
  type CustomCommandsLoadResult,
46
46
  loadCustomCommands as loadCustomCommandsInternal,
47
47
  } from "./custom-commands/index";
48
48
  import type { CustomTool, CustomToolContext, CustomToolSessionEvent } from "./custom-tools/types";
49
- import { createEventBus, type EventBus } from "./event-bus";
49
+ import { EventBus } from "./event-bus";
50
50
  import {
51
51
  discoverAndLoadExtensions,
52
52
  type ExtensionContext,
@@ -79,29 +79,29 @@ import {
79
79
  loadProjectContextFiles as loadContextFilesInternal,
80
80
  } from "./system-prompt";
81
81
  import { time } from "./timings";
82
- import { createToolContextStore } from "./tools/context";
82
+ import { ToolContextStore } from "./tools/context";
83
83
  import { getGeminiImageTools } from "./tools/gemini-image";
84
84
  import {
85
+ BashTool,
85
86
  BUILTIN_TOOLS,
86
- createBashTool,
87
- createEditTool,
88
- createFindTool,
89
- createGitTool,
90
- createGrepTool,
91
- createLsTool,
92
- createPythonTool,
93
- createReadTool,
94
- createSshTool,
95
87
  createTools,
96
- createWriteTool,
88
+ EditTool,
89
+ FindTool,
90
+ GitTool,
91
+ GrepTool,
97
92
  getWebSearchTools,
93
+ LsTool,
94
+ loadSshTool,
95
+ PythonTool,
96
+ ReadTool,
98
97
  setPreferredImageProvider,
99
98
  setPreferredWebSearchProvider,
100
99
  type Tool,
101
100
  type ToolSession,
101
+ WriteTool,
102
102
  warmupLspServers,
103
103
  } from "./tools/index";
104
- import { createTtsrManager } from "./ttsr";
104
+ import { TtsrManager } from "./ttsr";
105
105
 
106
106
  // Types
107
107
  export interface CreateAgentSessionOptions {
@@ -212,21 +212,21 @@ export type { FileSlashCommand } from "./slash-commands";
212
212
  export type { Tool } from "./tools/index";
213
213
 
214
214
  export {
215
- // Tool factories
215
+ // Tool classes and factories
216
216
  BUILTIN_TOOLS,
217
217
  createTools,
218
218
  type ToolSession,
219
- // Individual tool factories (for custom usage)
220
- createReadTool,
221
- createBashTool,
222
- createPythonTool,
223
- createSshTool,
224
- createEditTool,
225
- createWriteTool,
226
- createGrepTool,
227
- createFindTool,
228
- createGitTool,
229
- createLsTool,
219
+ // Individual tool classes (for custom usage)
220
+ BashTool,
221
+ EditTool,
222
+ FindTool,
223
+ GitTool,
224
+ GrepTool,
225
+ loadSshTool,
226
+ LsTool,
227
+ PythonTool,
228
+ ReadTool,
229
+ WriteTool,
230
230
  };
231
231
 
232
232
  // Helper Functions
@@ -551,7 +551,7 @@ function createCustomToolsExtension(tools: CustomTool[]): ExtensionFactory {
551
551
  export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
552
552
  const cwd = options.cwd ?? process.cwd();
553
553
  const agentDir = options.agentDir ?? getDefaultAgentDir();
554
- const eventBus = options.eventBus ?? createEventBus();
554
+ const eventBus = options.eventBus ?? new EventBus();
555
555
 
556
556
  registerSshCleanup();
557
557
  registerPythonCleanup();
@@ -662,7 +662,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
662
662
  time("discoverSkills");
663
663
 
664
664
  // Discover rules
665
- const ttsrManager = createTtsrManager(settingsManager.getTtsrSettings());
665
+ const ttsrManager = new TtsrManager(settingsManager.getTtsrSettings());
666
666
  const rulesResult = await loadCapability<Rule>(ruleCapability.id, { cwd });
667
667
  for (const rule of rulesResult.items) {
668
668
  if (rule.ttsrTrigger) {
@@ -728,6 +728,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
728
728
  enableProjectConfig: settingsManager.getMCPProjectConfigEnabled(),
729
729
  // Always filter Exa - we have native integration
730
730
  filterExa: true,
731
+ cacheStorage: settingsManager.getStorage(),
731
732
  });
732
733
  time("discoverAndLoadMCPTools");
733
734
  mcpManager = mcpResult.manager;
@@ -849,7 +850,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
849
850
  session.abort();
850
851
  },
851
852
  });
852
- const toolContextStore = createToolContextStore(getSessionContext);
853
+ const toolContextStore = new ToolContextStore(getSessionContext);
853
854
 
854
855
  const registeredTools = extensionRunner?.getAllRegisteredTools() ?? [];
855
856
  const allCustomTools = [
@@ -880,10 +881,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
880
881
  time("combineTools");
881
882
 
882
883
  let cursorEventEmitter: ((event: AgentEvent) => void) | undefined;
883
- const cursorExecHandlers = createCursorExecHandlers({
884
+ const cursorExecHandlers = new CursorExecHandlers({
884
885
  cwd,
885
886
  tools: toolRegistry,
886
- getToolContext: toolContextStore.getContext,
887
+ getToolContext: () => toolContextStore.getContext(),
887
888
  emitEvent: (event) => cursorEventEmitter?.(event),
888
889
  });
889
890
 
@@ -985,7 +986,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
985
986
  followUpMode: settingsManager.getFollowUpMode(),
986
987
  interruptMode: settingsManager.getInterruptMode(),
987
988
  thinkingBudgets: settingsManager.getThinkingBudgets(),
988
- getToolContext: toolContextStore.getContext,
989
+ getToolContext: (tc) => toolContextStore.getContext(tc),
989
990
  getApiKey: async () => {
990
991
  const currentModel = agent.state.model;
991
992
  if (!currentModel) {
@@ -1386,6 +1386,36 @@ export class SessionManager {
1386
1386
  return entry.id;
1387
1387
  }
1388
1388
 
1389
+ /**
1390
+ * Rewrite tool call arguments in the most recent assistant message containing the toolCallId.
1391
+ * Returns true if a tool call was updated.
1392
+ */
1393
+ async rewriteAssistantToolCallArgs(toolCallId: string, args: Record<string, unknown>): Promise<boolean> {
1394
+ let updated = false;
1395
+ for (let i = this.fileEntries.length - 1; i >= 0; i--) {
1396
+ const entry = this.fileEntries[i];
1397
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
1398
+ const message = entry.message as { content?: unknown };
1399
+ if (!Array.isArray(message.content)) continue;
1400
+ for (const block of message.content) {
1401
+ if (typeof block !== "object" || block === null) continue;
1402
+ if (!("type" in block) || (block as { type?: string }).type !== "toolCall") continue;
1403
+ const toolCall = block as { id?: string; arguments?: Record<string, unknown> };
1404
+ if (toolCall.id === toolCallId) {
1405
+ toolCall.arguments = args;
1406
+ updated = true;
1407
+ break;
1408
+ }
1409
+ }
1410
+ if (updated) break;
1411
+ }
1412
+
1413
+ if (updated && this.persist && this.sessionFile) {
1414
+ await this._rewriteFile();
1415
+ }
1416
+ return updated;
1417
+ }
1418
+
1389
1419
  /**
1390
1420
  * Append a custom message entry (for extensions) that participates in LLM context.
1391
1421
  * @param customType Hook identifier for filtering on reload
@@ -123,6 +123,8 @@ export interface PythonSettings {
123
123
 
124
124
  export interface EditSettings {
125
125
  fuzzyMatch?: boolean; // default: true (accept high-confidence fuzzy matches for whitespace/indentation)
126
+ fuzzyThreshold?: number; // default: 0.95 (similarity threshold for fuzzy matching)
127
+ patchMode?: boolean; // default: true (use codex-style apply-patch format instead of oldText/newText)
126
128
  }
127
129
 
128
130
  export type { SymbolPreset };
@@ -318,7 +320,7 @@ const DEFAULT_SETTINGS: Settings = {
318
320
  mcp: { enableProjectConfig: true },
319
321
  lsp: { formatOnWrite: false, diagnosticsOnWrite: true, diagnosticsOnEdit: false },
320
322
  python: { toolMode: "both", kernelMode: "session", sharedGateway: true },
321
- edit: { fuzzyMatch: true },
323
+ edit: { fuzzyMatch: true, fuzzyThreshold: 0.95 },
322
324
  ttsr: { enabled: true, contextMode: "discard", repeatMode: "once", repeatGap: 10 },
323
325
  voice: {
324
326
  enabled: false,
@@ -550,6 +552,13 @@ export class SettingsManager {
550
552
  return { ...this.settings };
551
553
  }
552
554
 
555
+ /**
556
+ * Access the underlying agent storage (null for in-memory settings).
557
+ */
558
+ getStorage(): AgentStorage | null {
559
+ return this.storage;
560
+ }
561
+
553
562
  /**
554
563
  * Load settings from SQLite storage, applying any schema migrations.
555
564
  * @param storage - AgentStorage instance, or null for in-memory mode
@@ -1266,6 +1275,30 @@ export class SettingsManager {
1266
1275
  await this.save();
1267
1276
  }
1268
1277
 
1278
+ getEditFuzzyThreshold(): number {
1279
+ return this.settings.edit?.fuzzyThreshold ?? 0.95;
1280
+ }
1281
+
1282
+ async setEditFuzzyThreshold(value: number): Promise<void> {
1283
+ if (!this.globalSettings.edit) {
1284
+ this.globalSettings.edit = {};
1285
+ }
1286
+ this.globalSettings.edit.fuzzyThreshold = value;
1287
+ await this.save();
1288
+ }
1289
+
1290
+ getEditPatchMode(): boolean {
1291
+ return this.settings.edit?.patchMode ?? true;
1292
+ }
1293
+
1294
+ async setEditPatchMode(enabled: boolean): Promise<void> {
1295
+ if (!this.globalSettings.edit) {
1296
+ this.globalSettings.edit = {};
1297
+ }
1298
+ this.globalSettings.edit.patchMode = enabled;
1299
+ await this.save();
1300
+ }
1301
+
1269
1302
  getDisabledProviders(): string[] {
1270
1303
  return [...(this.settings.disabledProviders ?? [])];
1271
1304
  }
@@ -1,12 +1,8 @@
1
- import { createWriteStream, type WriteStream } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
1
  import type { Subprocess } from "bun";
5
- import { nanoid } from "nanoid";
6
- import stripAnsi from "strip-ansi";
7
- import { killProcessTree, sanitizeBinaryOutput } from "../../utils/shell";
2
+ import { killProcessTree } from "../../utils/shell";
8
3
  import { logger } from "../logger";
9
- import { DEFAULT_MAX_BYTES, truncateTail } from "../tools/truncate";
4
+ import { OutputSink, pumpStream } from "../streaming-output";
5
+ import { DEFAULT_MAX_BYTES } from "../tools/truncate";
10
6
  import { ScopeSignal } from "../utils";
11
7
  import { buildRemoteCommand, ensureConnection, ensureHostInfo, type SSHConnectionTarget } from "./connection-manager";
12
8
  import { hasSshfs, mountRemote } from "./sshfs-mount";
@@ -37,68 +33,6 @@ export interface SSHResult {
37
33
  fullOutputPath?: string;
38
34
  }
39
35
 
40
- function createSanitizer(): TransformStream<Uint8Array, string> {
41
- const decoder = new TextDecoder();
42
- return new TransformStream({
43
- transform(chunk, controller) {
44
- const text = sanitizeBinaryOutput(stripAnsi(decoder.decode(chunk, { stream: true }))).replace(/\r/g, "");
45
- controller.enqueue(text);
46
- },
47
- });
48
- }
49
-
50
- function createOutputSink(
51
- spillThreshold: number,
52
- maxBuffer: number,
53
- onChunk?: (text: string) => void,
54
- ): WritableStream<string> & {
55
- dump: (annotation?: string) => { output: string; truncated: boolean; fullOutputPath?: string };
56
- } {
57
- const chunks: string[] = [];
58
- let chunkBytes = 0;
59
- let totalBytes = 0;
60
- let fullOutputPath: string | undefined;
61
- let fullOutputStream: WriteStream | undefined;
62
-
63
- const sink = new WritableStream<string>({
64
- write(text) {
65
- totalBytes += text.length;
66
-
67
- if (totalBytes > spillThreshold && !fullOutputPath) {
68
- fullOutputPath = join(tmpdir(), `omp-${nanoid()}.buffer`);
69
- const ts = createWriteStream(fullOutputPath);
70
- chunks.forEach((c) => {
71
- ts.write(c);
72
- });
73
- fullOutputStream = ts;
74
- }
75
- fullOutputStream?.write(text);
76
-
77
- chunks.push(text);
78
- chunkBytes += text.length;
79
- while (chunkBytes > maxBuffer && chunks.length > 1) {
80
- chunkBytes -= chunks.shift()!.length;
81
- }
82
-
83
- onChunk?.(text);
84
- },
85
- close() {
86
- fullOutputStream?.end();
87
- },
88
- });
89
-
90
- return Object.assign(sink, {
91
- dump(annotation?: string) {
92
- if (annotation) {
93
- chunks.push(`\n\n${annotation}`);
94
- }
95
- const full = chunks.join("");
96
- const { content, truncated } = truncateTail(full);
97
- return { output: truncated ? content : full, truncated, fullOutputPath };
98
- },
99
- });
100
- }
101
-
102
36
  function quoteForCompatShell(command: string): string {
103
37
  if (command.length === 0) {
104
38
  return "''";
@@ -146,25 +80,13 @@ export async function executeSSH(
146
80
  killProcessTree(child.pid);
147
81
  });
148
82
 
149
- const sink = createOutputSink(DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES * 2, options?.onChunk);
83
+ const sink = new OutputSink(DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES * 2, options?.onChunk);
150
84
 
151
85
  const writer = sink.getWriter();
152
86
  try {
153
- async function pumpStream(readable: ReadableStream<Uint8Array>) {
154
- const reader = readable.pipeThrough(createSanitizer()).getReader();
155
- try {
156
- while (true) {
157
- const { done, value } = await reader.read();
158
- if (done) break;
159
- await writer.write(value);
160
- }
161
- } finally {
162
- reader.releaseLock();
163
- }
164
- }
165
87
  await Promise.all([
166
- pumpStream(child.stdout as ReadableStream<Uint8Array>),
167
- pumpStream(child.stderr as ReadableStream<Uint8Array>),
88
+ pumpStream(child.stdout as ReadableStream<Uint8Array>, writer),
89
+ pumpStream(child.stderr as ReadableStream<Uint8Array>, writer),
168
90
  ]);
169
91
  } finally {
170
92
  await writer.close();
@@ -2,9 +2,55 @@ import { tmpdir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { nanoid } from "nanoid";
4
4
  import stripAnsi from "strip-ansi";
5
- import { sanitizeBinaryOutput } from "../utils/shell";
6
5
  import { truncateTail } from "./tools/truncate";
7
6
 
7
+ /**
8
+ * Sanitize binary output for display/storage.
9
+ * Removes characters that crash string-width or cause display issues:
10
+ * - Control characters (except tab, newline, carriage return)
11
+ * - Lone surrogates
12
+ * - Unicode Format characters (crash string-width due to a bug)
13
+ * - Characters with undefined code points
14
+ */
15
+ export function sanitizeBinaryOutput(str: string): string {
16
+ // Use Array.from to properly iterate over code points (not code units)
17
+ // This handles surrogate pairs correctly and catches edge cases where
18
+ // codePointAt() might return undefined
19
+ return Array.from(str)
20
+ .filter((char) => {
21
+ // Filter out characters that cause string-width to crash
22
+ // This includes:
23
+ // - Unicode format characters
24
+ // - Lone surrogates (already filtered by Array.from)
25
+ // - Control chars except \t \n \r
26
+ // - Characters with undefined code points
27
+
28
+ const code = char.codePointAt(0);
29
+
30
+ // Skip if code point is undefined (edge case with invalid strings)
31
+ if (code === undefined) return false;
32
+
33
+ // Allow tab, newline, carriage return
34
+ if (code === 0x09 || code === 0x0a || code === 0x0d) return true;
35
+
36
+ // Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d)
37
+ if (code <= 0x1f) return false;
38
+
39
+ // Filter out Unicode format characters
40
+ if (code >= 0xfff9 && code <= 0xfffb) return false;
41
+
42
+ return true;
43
+ })
44
+ .join("");
45
+ }
46
+
47
+ /**
48
+ * Sanitize text output: strip ANSI codes, remove binary garbage, normalize line endings.
49
+ */
50
+ export function sanitizeText(text: string): string {
51
+ return sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, "");
52
+ }
53
+
8
54
  interface OutputFileSink {
9
55
  write(data: string): number | Promise<number>;
10
56
  end(): void;
@@ -12,7 +58,6 @@ interface OutputFileSink {
12
58
 
13
59
  export function createSanitizer(): TransformStream<Uint8Array, string> {
14
60
  const decoder = new TextDecoder();
15
- const sanitizeText = (text: string) => sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, "");
16
61
  return new TransformStream({
17
62
  transform(chunk, controller) {
18
63
  const text = sanitizeText(decoder.decode(chunk, { stream: true }));
@@ -42,59 +87,68 @@ export async function pumpStream(readable: ReadableStream<Uint8Array>, writer: W
42
87
  }
43
88
  }
44
89
 
45
- export function createOutputSink(
46
- spillThreshold: number,
47
- maxBuffer: number,
48
- onChunk?: (text: string) => void,
49
- ): WritableStream<string> & {
50
- dump: (annotation?: string) => { output: string; truncated: boolean; fullOutputPath?: string };
51
- } {
52
- const chunks: Array<{ text: string; bytes: number }> = [];
53
- let chunkBytes = 0;
54
- let totalBytes = 0;
55
- let fullOutputPath: string | undefined;
56
- let fullOutputStream: OutputFileSink | undefined;
57
-
58
- const sink = new WritableStream<string>({
59
- write(text) {
60
- const bytes = Buffer.byteLength(text, "utf-8");
61
- totalBytes += bytes;
62
-
63
- if (totalBytes > spillThreshold && !fullOutputPath) {
64
- fullOutputPath = join(tmpdir(), `omp-${nanoid()}.buffer`);
65
- const stream = Bun.file(fullOutputPath).writer();
66
- for (const chunk of chunks) {
67
- stream.write(chunk.text);
90
+ export interface OutputSinkDump {
91
+ output: string;
92
+ truncated: boolean;
93
+ fullOutputPath?: string;
94
+ }
95
+
96
+ export class OutputSink {
97
+ private readonly stream: WritableStream<string>;
98
+ private readonly chunks: Array<{ text: string; bytes: number }> = [];
99
+ private chunkBytes = 0;
100
+ private totalBytes = 0;
101
+ private fullOutputPath: string | undefined;
102
+ private fullOutputStream: OutputFileSink | undefined;
103
+
104
+ constructor(
105
+ private readonly spillThreshold: number,
106
+ private readonly maxBuffer: number,
107
+ private readonly onChunk?: (text: string) => void,
108
+ ) {
109
+ this.stream = new WritableStream<string>({
110
+ write: (text) => {
111
+ const bytes = Buffer.byteLength(text, "utf-8");
112
+ this.totalBytes += bytes;
113
+
114
+ if (this.totalBytes > this.spillThreshold && !this.fullOutputPath) {
115
+ this.fullOutputPath = join(tmpdir(), `omp-${nanoid()}.buffer`);
116
+ const stream = Bun.file(this.fullOutputPath).writer();
117
+ for (const chunk of this.chunks) {
118
+ stream.write(chunk.text);
119
+ }
120
+ this.fullOutputStream = stream;
68
121
  }
69
- fullOutputStream = stream;
70
- }
71
- fullOutputStream?.write(text);
72
-
73
- chunks.push({ text, bytes });
74
- chunkBytes += bytes;
75
- while (chunkBytes > maxBuffer && chunks.length > 1) {
76
- const removed = chunks.shift();
77
- if (removed) {
78
- chunkBytes -= removed.bytes;
122
+ this.fullOutputStream?.write(text);
123
+
124
+ this.chunks.push({ text, bytes });
125
+ this.chunkBytes += bytes;
126
+ while (this.chunkBytes > this.maxBuffer && this.chunks.length > 1) {
127
+ const removed = this.chunks.shift();
128
+ if (removed) {
129
+ this.chunkBytes -= removed.bytes;
130
+ }
79
131
  }
80
- }
81
132
 
82
- onChunk?.(text);
83
- },
84
- close() {
85
- fullOutputStream?.end();
86
- },
87
- });
133
+ this.onChunk?.(text);
134
+ },
135
+ close: () => {
136
+ this.fullOutputStream?.end();
137
+ },
138
+ });
139
+ }
88
140
 
89
- return Object.assign(sink, {
90
- dump(annotation?: string) {
91
- if (annotation) {
92
- const text = `\n\n${annotation}`;
93
- chunks.push({ text, bytes: Buffer.byteLength(text, "utf-8") });
94
- }
95
- const full = chunks.map((chunk) => chunk.text).join("");
96
- const { content, truncated } = truncateTail(full);
97
- return { output: truncated ? content : full, truncated, fullOutputPath };
98
- },
99
- });
141
+ getWriter(): WritableStreamDefaultWriter<string> {
142
+ return this.stream.getWriter();
143
+ }
144
+
145
+ dump(annotation?: string): OutputSinkDump {
146
+ if (annotation) {
147
+ const text = `\n\n${annotation}`;
148
+ this.chunks.push({ text, bytes: Buffer.byteLength(text, "utf-8") });
149
+ }
150
+ const full = this.chunks.map((chunk) => chunk.text).join("");
151
+ const { content, truncated } = truncateTail(full);
152
+ return { output: truncated ? content : full, truncated, fullOutputPath: this.fullOutputPath };
153
+ }
100
154
  }