@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.
- package/CHANGELOG.md +46 -0
- package/docs/sdk.md +1 -1
- package/package.json +5 -5
- package/scripts/generate-template.ts +6 -6
- package/src/cli/args.ts +3 -0
- package/src/core/agent-session.ts +39 -0
- package/src/core/bash-executor.ts +3 -3
- package/src/core/cursor/exec-bridge.ts +95 -88
- package/src/core/custom-commands/bundled/review/index.ts +142 -145
- package/src/core/custom-commands/bundled/wt/index.ts +68 -66
- package/src/core/custom-commands/loader.ts +4 -6
- package/src/core/custom-tools/index.ts +2 -2
- package/src/core/custom-tools/loader.ts +66 -61
- package/src/core/custom-tools/types.ts +4 -4
- package/src/core/custom-tools/wrapper.ts +61 -25
- package/src/core/event-bus.ts +19 -47
- package/src/core/extensions/index.ts +8 -4
- package/src/core/extensions/loader.ts +160 -120
- package/src/core/extensions/types.ts +4 -4
- package/src/core/extensions/wrapper.ts +149 -100
- package/src/core/hooks/index.ts +1 -1
- package/src/core/hooks/tool-wrapper.ts +96 -70
- package/src/core/hooks/types.ts +1 -2
- package/src/core/index.ts +1 -0
- package/src/core/mcp/index.ts +6 -2
- package/src/core/mcp/json-rpc.ts +88 -0
- package/src/core/mcp/loader.ts +22 -4
- package/src/core/mcp/manager.ts +202 -48
- package/src/core/mcp/tool-bridge.ts +143 -55
- package/src/core/mcp/tool-cache.ts +122 -0
- package/src/core/python-executor.ts +3 -9
- package/src/core/sdk.ts +33 -32
- package/src/core/session-manager.ts +30 -0
- package/src/core/settings-manager.ts +34 -1
- package/src/core/ssh/ssh-executor.ts +6 -84
- package/src/core/streaming-output.ts +107 -53
- package/src/core/tools/ask.ts +92 -93
- package/src/core/tools/bash.ts +103 -94
- package/src/core/tools/calculator.ts +41 -26
- package/src/core/tools/complete.ts +76 -66
- package/src/core/tools/context.ts +22 -24
- package/src/core/tools/exa/index.ts +1 -1
- package/src/core/tools/exa/mcp-client.ts +56 -101
- package/src/core/tools/find.ts +250 -253
- package/src/core/tools/git.ts +39 -33
- package/src/core/tools/grep.ts +440 -427
- package/src/core/tools/index.ts +62 -61
- package/src/core/tools/ls.ts +119 -114
- package/src/core/tools/lsp/clients/biome-client.ts +5 -7
- package/src/core/tools/lsp/clients/index.ts +4 -4
- package/src/core/tools/lsp/clients/lsp-linter-client.ts +5 -7
- package/src/core/tools/lsp/config.ts +2 -2
- package/src/core/tools/lsp/index.ts +604 -578
- package/src/core/tools/notebook.ts +121 -119
- package/src/core/tools/output.ts +163 -147
- package/src/core/tools/patch/applicator.ts +1100 -0
- package/src/core/tools/patch/diff.ts +362 -0
- package/src/core/tools/patch/fuzzy.ts +647 -0
- package/src/core/tools/patch/index.ts +430 -0
- package/src/core/tools/patch/normalize.ts +220 -0
- package/src/core/tools/patch/normative.ts +49 -0
- package/src/core/tools/patch/parser.ts +528 -0
- package/src/core/tools/patch/shared.ts +228 -0
- package/src/core/tools/patch/types.ts +244 -0
- package/src/core/tools/python.ts +139 -136
- package/src/core/tools/read.ts +237 -216
- package/src/core/tools/render-utils.ts +196 -77
- package/src/core/tools/renderers.ts +1 -1
- package/src/core/tools/ssh.ts +99 -80
- package/src/core/tools/task/executor.ts +11 -7
- package/src/core/tools/task/index.ts +352 -343
- package/src/core/tools/task/worker.ts +13 -23
- package/src/core/tools/todo-write.ts +74 -59
- package/src/core/tools/web-fetch.ts +54 -47
- package/src/core/tools/web-search/index.ts +27 -16
- package/src/core/tools/write.ts +73 -44
- package/src/core/ttsr.ts +106 -152
- package/src/core/voice.ts +49 -39
- package/src/index.ts +16 -12
- package/src/lib/worktree/index.ts +1 -9
- package/src/modes/interactive/components/diff.ts +15 -8
- package/src/modes/interactive/components/settings-defs.ts +24 -0
- package/src/modes/interactive/components/tool-execution.ts +34 -6
- package/src/modes/interactive/controllers/event-controller.ts +6 -19
- package/src/modes/interactive/controllers/input-controller.ts +1 -1
- package/src/modes/interactive/utils/ui-helpers.ts +5 -1
- package/src/modes/rpc/rpc-mode.ts +99 -81
- package/src/prompts/tools/patch.md +76 -0
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/{edit.md → replace.md} +1 -0
- package/src/utils/shell.ts +0 -40
- package/src/core/tools/edit-diff.ts +0 -574
- 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 {
|
|
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 =
|
|
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(
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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 ??
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
133
|
+
this.onChunk?.(text);
|
|
134
|
+
},
|
|
135
|
+
close: () => {
|
|
136
|
+
this.fullOutputStream?.end();
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
88
140
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
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
|
}
|