@oh-my-pi/pi-coding-agent 6.8.3 → 6.8.4

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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [6.8.4] - 2026-01-21
6
+ ### Changed
7
+
8
+ - Updated output sink to properly handle large outputs
9
+ - Improved error message formatting in SSH executor
10
+ - Updated web fetch timeout bounds and conversion
11
+
12
+ ### Fixed
13
+
14
+ - Fixed output truncation handling in streaming output
15
+ - Fixed timeout handling in web fetch tool
16
+ - Fixed async stream dumping in executors
17
+
5
18
  ## [6.8.3] - 2026-01-21
6
19
 
7
20
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "6.8.3",
3
+ "version": "6.8.4",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -40,11 +40,11 @@
40
40
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
41
41
  },
42
42
  "dependencies": {
43
- "@oh-my-pi/pi-agent-core": "6.8.3",
44
- "@oh-my-pi/pi-ai": "6.8.3",
45
- "@oh-my-pi/pi-git-tool": "6.8.3",
46
- "@oh-my-pi/pi-tui": "6.8.3",
47
- "@oh-my-pi/pi-utils": "6.8.3",
43
+ "@oh-my-pi/pi-agent-core": "6.8.4",
44
+ "@oh-my-pi/pi-ai": "6.8.4",
45
+ "@oh-my-pi/pi-git-tool": "6.8.4",
46
+ "@oh-my-pi/pi-tui": "6.8.4",
47
+ "@oh-my-pi/pi-utils": "6.8.4",
48
48
  "@openai/agents": "^0.3.7",
49
49
  "@sinclair/typebox": "^0.34.46",
50
50
  "ajv": "^8.17.1",
@@ -22,7 +22,7 @@ import type { Rule } from "../capability/rule";
22
22
  import { getAgentDbPath } from "../config";
23
23
  import { theme } from "../modes/interactive/theme/theme";
24
24
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
25
- import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor";
25
+ import { type BashResult, executeBash as executeBashCommand } from "./bash-executor";
26
26
  import {
27
27
  type CompactionResult,
28
28
  calculateContextTokens,
@@ -60,7 +60,6 @@ import type { Skill, SkillWarning } from "./skills";
60
60
  import { expandSlashCommand, type FileSlashCommand } from "./slash-commands";
61
61
  import { closeAllConnections } from "./ssh/connection-manager";
62
62
  import { unmountAll } from "./ssh/sshfs-mount";
63
- import type { BashOperations } from "./tools/bash";
64
63
  import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "./tools/patch";
65
64
  import { resolveToCwd } from "./tools/path-utils";
66
65
  import type { TodoItem } from "./tools/todo-write";
@@ -2546,25 +2545,19 @@ export class AgentSession {
2546
2545
  * @param command The bash command to execute
2547
2546
  * @param onChunk Optional streaming callback for output
2548
2547
  * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
2549
- * @param options.operations Custom BashOperations for remote execution
2550
2548
  */
2551
2549
  async executeBash(
2552
2550
  command: string,
2553
2551
  onChunk?: (chunk: string) => void,
2554
- options?: { excludeFromContext?: boolean; operations?: BashOperations },
2552
+ options?: { excludeFromContext?: boolean },
2555
2553
  ): Promise<BashResult> {
2556
2554
  this._bashAbortController = new AbortController();
2557
2555
 
2558
2556
  try {
2559
- const result = options?.operations
2560
- ? await executeBashWithOperations(command, process.cwd(), options.operations, {
2561
- onChunk,
2562
- signal: this._bashAbortController.signal,
2563
- })
2564
- : await executeBashCommand(command, {
2565
- onChunk,
2566
- signal: this._bashAbortController.signal,
2567
- });
2557
+ const result = await executeBashCommand(command, {
2558
+ onChunk,
2559
+ signal: this._bashAbortController.signal,
2560
+ });
2568
2561
 
2569
2562
  this.recordBashResult(command, result, options);
2570
2563
  return result;
@@ -8,7 +8,6 @@ import { cspawn, Exception, ptree } from "@oh-my-pi/pi-utils";
8
8
  import { getShellConfig } from "../utils/shell";
9
9
  import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot";
10
10
  import { OutputSink } from "./streaming-output";
11
- import type { BashOperations } from "./tools/bash";
12
11
 
13
12
  export interface BashExecutorOptions {
14
13
  cwd?: string;
@@ -34,7 +33,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
34
33
  const prefixedCommand = prefix ? `${prefix} ${command}` : command;
35
34
  const finalCommand = `${snapshotPrefix}${prefixedCommand}`;
36
35
 
37
- const stream = new OutputSink({ onChunk: options?.onChunk });
36
+ const sink = new OutputSink({ onChunk: options?.onChunk });
38
37
 
39
38
  const child = cspawn([shell, ...args, finalCommand], {
40
39
  cwd: options?.cwd,
@@ -45,12 +44,9 @@ export async function executeBash(command: string, options?: BashExecutorOptions
45
44
 
46
45
  // Pump streams - errors during abort/timeout are expected
47
46
  // Use preventClose to avoid closing the shared sink when either stream finishes
48
- await Promise.allSettled([
49
- child.stdout.pipeTo(stream.createWritable()),
50
- child.stderr.pipeTo(stream.createWritable()),
51
- ])
52
- .then(() => stream.close())
53
- .catch(() => {});
47
+ await Promise.allSettled([child.stdout.pipeTo(sink.createInput()), child.stderr.pipeTo(sink.createInput())]).catch(
48
+ () => {},
49
+ );
54
50
 
55
51
  // Wait for process exit
56
52
  try {
@@ -58,7 +54,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
58
54
  return {
59
55
  exitCode: child.exitCode ?? 0,
60
56
  cancelled: false,
61
- ...stream.dump(),
57
+ ...(await sink.dump()),
62
58
  };
63
59
  } catch (err) {
64
60
  // Exception covers NonZeroExitError, AbortError, TimeoutError
@@ -71,7 +67,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
71
67
  return {
72
68
  exitCode: undefined,
73
69
  cancelled: true,
74
- ...stream.dump(annotation),
70
+ ...(await sink.dump(annotation)),
75
71
  };
76
72
  }
77
73
 
@@ -79,60 +75,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
79
75
  return {
80
76
  exitCode: err.exitCode,
81
77
  cancelled: false,
82
- ...stream.dump(),
83
- };
84
- }
85
-
86
- throw err;
87
- }
88
- }
89
-
90
- export async function executeBashWithOperations(
91
- command: string,
92
- cwd: string,
93
- operations: BashOperations,
94
- options?: BashExecutorOptions,
95
- ): Promise<BashResult> {
96
- const stream = new OutputSink({ onChunk: options?.onChunk });
97
- const writable = stream.createWritable();
98
- const writer = writable.getWriter();
99
-
100
- const closeStreams = async () => {
101
- try {
102
- await writer.close();
103
- } catch {}
104
- try {
105
- await writable.close();
106
- } catch {}
107
- try {
108
- await stream.close();
109
- } catch {}
110
- };
111
-
112
- try {
113
- const result = await operations.exec(command, cwd, {
114
- onData: (data) => writer.write(data),
115
- signal: options?.signal,
116
- timeout: options?.timeout,
117
- });
118
-
119
- await closeStreams();
120
-
121
- const cancelled = options?.signal?.aborted ?? false;
122
-
123
- return {
124
- exitCode: cancelled ? undefined : (result.exitCode ?? undefined),
125
- cancelled,
126
- ...stream.dump(),
127
- };
128
- } catch (err) {
129
- await closeStreams();
130
-
131
- if (options?.signal?.aborted) {
132
- return {
133
- exitCode: undefined,
134
- cancelled: true,
135
- ...stream.dump(),
78
+ ...(await sink.dump()),
136
79
  };
137
80
  }
138
81
 
@@ -29,7 +29,6 @@ import type {
29
29
  SessionManager,
30
30
  } from "../session-manager";
31
31
  import type { BashToolDetails, FindToolDetails, GrepToolDetails, LsToolDetails, ReadToolDetails } from "../tools";
32
- import type { BashOperations } from "../tools/bash";
33
32
  import type { EditToolDetails } from "../tools/patch";
34
33
 
35
34
  export type { ExecOptions, ExecResult } from "../exec";
@@ -551,8 +550,6 @@ export interface InputEventResult {
551
550
 
552
551
  /** Result from user_bash event handler */
553
552
  export interface UserBashEventResult {
554
- /** Custom operations to use for execution */
555
- operations?: BashOperations;
556
553
  /** Full replacement: extension handled execution, use this result */
557
554
  result?: BashResult;
558
555
  }
package/src/core/index.ts CHANGED
@@ -11,7 +11,7 @@ export {
11
11
  type PromptOptions,
12
12
  type SessionStats,
13
13
  } from "./agent-session";
14
- export { type BashExecutorOptions, type BashResult, executeBash, executeBashWithOperations } from "./bash-executor";
14
+ export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor";
15
15
  export type { CompactionResult } from "./compaction/index";
16
16
  export {
17
17
  discoverAndLoadExtensions,
@@ -1,4 +1,4 @@
1
- import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
1
+ import { logger } from "@oh-my-pi/pi-utils";
2
2
  import {
3
3
  checkPythonKernelAvailability,
4
4
  type KernelDisplayOutput,
@@ -210,30 +210,20 @@ async function executeWithKernel(
210
210
  code: string,
211
211
  options: PythonExecutorOptions | undefined,
212
212
  ): Promise<PythonResult> {
213
- const sink = new OutputSink({ onLine: options?.onChunk });
213
+ const sink = new OutputSink({ onChunk: options?.onChunk });
214
214
  const displayOutputs: KernelDisplayOutput[] = [];
215
215
 
216
216
  try {
217
- const writable = sink.createStringWritable();
218
- const writer = writable.getWriter();
219
- let result: KernelExecuteResult;
220
- try {
221
- result = await kernel.execute(code, {
222
- signal: options?.signal,
223
- timeoutMs: options?.timeout,
224
- onChunk: (text) => {
225
- writer.write(sanitizeText(text));
226
- },
227
- onDisplay: (output) => {
228
- displayOutputs.push(output);
229
- },
230
- });
231
- } catch (err) {
232
- await writer.abort(err);
233
- throw err;
234
- } finally {
235
- await writer.close().catch(() => {});
236
- }
217
+ const result = await kernel.execute(code, {
218
+ signal: options?.signal,
219
+ timeoutMs: options?.timeout,
220
+ onChunk: (text) => {
221
+ sink.push(text);
222
+ },
223
+ onDisplay: (output) => {
224
+ displayOutputs.push(output);
225
+ },
226
+ });
237
227
 
238
228
  if (result.cancelled) {
239
229
  const secs = options?.timeout ? Math.round(options.timeout / 1000) : undefined;
@@ -244,7 +234,7 @@ async function executeWithKernel(
244
234
  cancelled: true,
245
235
  displayOutputs,
246
236
  stdinRequested: result.stdinRequested,
247
- ...sink.dump(annotation),
237
+ ...(await sink.dump(annotation)),
248
238
  };
249
239
  }
250
240
 
@@ -254,7 +244,7 @@ async function executeWithKernel(
254
244
  cancelled: false,
255
245
  displayOutputs,
256
246
  stdinRequested: true,
257
- ...sink.dump("Kernel requested stdin; interactive input is not supported."),
247
+ ...(await sink.dump("Kernel requested stdin; interactive input is not supported.")),
258
248
  };
259
249
  }
260
250
 
@@ -264,7 +254,7 @@ async function executeWithKernel(
264
254
  cancelled: false,
265
255
  displayOutputs,
266
256
  stdinRequested: false,
267
- ...sink.dump(),
257
+ ...(await sink.dump()),
268
258
  };
269
259
  } catch (err) {
270
260
  const error = err instanceof Error ? err : new Error(String(err));
@@ -70,16 +70,11 @@ export async function executeSSH(
70
70
  timeout: options?.timeout,
71
71
  });
72
72
 
73
- const sink = new OutputSink({ onLine: options?.onChunk });
73
+ const sink = new OutputSink({ onChunk: options?.onChunk });
74
74
 
75
- try {
76
- await Promise.allSettled([
77
- child.stdout.pipeTo(sink.createWritable()),
78
- child.stderr.pipeTo(sink.createWritable()),
79
- ]);
80
- } finally {
81
- await sink.close();
82
- }
75
+ await Promise.allSettled([child.stdout.pipeTo(sink.createInput()), child.stderr.pipeTo(sink.createInput())]).catch(
76
+ () => {},
77
+ );
83
78
 
84
79
  try {
85
80
  await child.exited;
@@ -87,7 +82,7 @@ export async function executeSSH(
87
82
  return {
88
83
  exitCode,
89
84
  cancelled: false,
90
- ...sink.dump(),
85
+ ...(await sink.dump()),
91
86
  };
92
87
  } catch (err) {
93
88
  if (err instanceof ptree.Exception) {
@@ -95,20 +90,20 @@ export async function executeSSH(
95
90
  return {
96
91
  exitCode: undefined,
97
92
  cancelled: true,
98
- ...sink.dump(`SSH: ${err.message}`),
93
+ ...(await sink.dump(`SSH: ${err.message}`)),
99
94
  };
100
95
  }
101
96
  if (err.aborted) {
102
97
  return {
103
98
  exitCode: undefined,
104
99
  cancelled: true,
105
- ...sink.dump(`SSH command aborted: ${err.message}`),
100
+ ...(await sink.dump(`Command aborted: ${err.message}`)),
106
101
  };
107
102
  }
108
103
  return {
109
104
  exitCode: err.exitCode,
110
105
  cancelled: false,
111
- ...sink.dump(`Unexpected error: ${err.message}`),
106
+ ...(await sink.dump(`Unexpected error: ${err.message}`)),
112
107
  };
113
108
  }
114
109
  throw err;
@@ -2,7 +2,7 @@ import { tmpdir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { sanitizeText } from "@oh-my-pi/pi-utils";
4
4
  import { nanoid } from "nanoid";
5
- import { DEFAULT_MAX_BYTES, DEFAULT_MAX_COLUMN } from "./tools/truncate";
5
+ import { DEFAULT_MAX_BYTES } from "./tools/truncate";
6
6
 
7
7
  export interface OutputResult {
8
8
  output: string;
@@ -14,7 +14,6 @@ export interface OutputSinkOptions {
14
14
  allocateFilePath?: () => string;
15
15
  spillThreshold?: number;
16
16
  maxColumn?: number;
17
- onLine?: (line: string) => void;
18
17
  onChunk?: (chunk: string) => void;
19
18
  }
20
19
 
@@ -29,182 +28,97 @@ function defaultFilePathAllocator(): string {
29
28
  * When memory limit exceeded, spills ~half to file in one batch operation.
30
29
  */
31
30
  export class OutputSink {
32
- private buffer = "";
33
- private lineEnds: number[] = []; // String index after each \n
34
-
35
- private fileSink?: Bun.FileSink;
36
- private filePath?: string;
37
-
38
- private readonly allocateFilePath: () => string;
39
- private readonly spillThreshold: number;
40
- private readonly maxColumn: number;
41
- private readonly onLine?: (line: string) => void;
42
- private readonly onChunk?: (chunk: string) => void;
31
+ #buffer = "";
32
+ #file?: {
33
+ path: string;
34
+ sink: Bun.FileSink;
35
+ };
36
+ #bytesWritten: number = 0;
37
+ #pending: Promise<void> = Promise.resolve();
38
+
39
+ readonly #allocateFilePath: () => string;
40
+ readonly #spillThreshold: number;
41
+ readonly #onChunk?: (chunk: string) => void;
43
42
 
44
43
  constructor(options?: OutputSinkOptions) {
45
44
  const {
46
45
  allocateFilePath = defaultFilePathAllocator,
47
46
  spillThreshold = DEFAULT_MAX_BYTES,
48
- maxColumn = DEFAULT_MAX_COLUMN,
49
- onLine,
50
47
  onChunk,
51
48
  } = options ?? {};
52
49
 
53
- this.allocateFilePath = allocateFilePath;
54
- this.spillThreshold = spillThreshold;
55
- this.maxColumn = maxColumn;
56
- this.onLine = onLine;
57
- this.onChunk = onChunk;
50
+ this.#allocateFilePath = allocateFilePath;
51
+ this.#spillThreshold = spillThreshold;
52
+ this.#onChunk = onChunk;
58
53
  }
59
54
 
60
- private pushLine(line: string, term?: string): void {
61
- while (line.length > this.maxColumn) {
62
- this.pushLine(line.slice(0, this.maxColumn), "--\n");
63
- line = line.slice(this.maxColumn);
64
- }
55
+ async #pushSanitized(data: string): Promise<void> {
56
+ this.#onChunk?.(data);
57
+ const dataBytes = Buffer.byteLength(data);
58
+ const overflow = dataBytes + this.#bytesWritten > this.#spillThreshold || this.#file != null;
65
59
 
66
- this.buffer += line;
67
- if (term) {
68
- this.buffer += term;
69
- }
60
+ const sink = overflow ? await this.#fileSink() : null;
70
61
 
71
- this.lineEnds.push(this.buffer.length);
72
- this.onLine?.(line);
62
+ this.#buffer += data;
63
+ await sink?.write(data);
73
64
 
74
- if (this.buffer.length > this.spillThreshold) {
75
- this.spillHalf();
65
+ if (this.#buffer.length > this.#spillThreshold) {
66
+ this.#buffer = this.#buffer.slice(-this.#spillThreshold);
76
67
  }
77
68
  }
78
69
 
79
- private pushChunk(line: string): void {
80
- this.onChunk?.(line);
81
- this.pushLine(line);
82
- }
83
-
84
- private getFileSink(): Bun.FileSink {
85
- if (!this.fileSink) {
86
- const filePath = this.allocateFilePath();
87
- this.filePath = filePath;
88
- this.fileSink = Bun.file(filePath).writer();
70
+ async #fileSink(): Promise<Bun.FileSink> {
71
+ if (!this.#file) {
72
+ const filePath = this.#allocateFilePath();
73
+ this.#file = {
74
+ path: filePath,
75
+ sink: Bun.file(filePath).writer(),
76
+ };
77
+ await this.#file.sink.write(this.#buffer);
89
78
  }
90
- return this.fileSink;
79
+ return this.#file.sink;
91
80
  }
92
81
 
93
- private spillHalf(): void {
94
- const target = this.buffer.length >>> 1;
95
-
96
- // Binary search: first line ending >= target
97
- let lo = 0;
98
- let hi = this.lineEnds.length;
99
- while (lo < hi) {
100
- const mid = (lo + hi) >>> 1;
101
- if (this.lineEnds[mid] < target) {
102
- lo = mid + 1;
103
- } else {
104
- hi = mid;
105
- }
106
- }
107
-
108
- // Clamp: evict at least 1 line, keep at least 1 line
109
- const splitIdx = Math.max(1, Math.min(lo, this.lineEnds.length - 1));
110
- const splitPos = this.lineEnds[splitIdx - 1];
111
-
112
- // Write evicted portion to file
113
- this.getFileSink().write(this.buffer.slice(0, splitPos));
114
-
115
- // Truncate buffer, shift line positions
116
- this.buffer = this.buffer.slice(splitPos);
117
- const remaining = this.lineEnds.length - splitIdx;
118
- for (let i = 0; i < remaining; i++) {
119
- this.lineEnds[i] = this.lineEnds[i + splitIdx] - splitPos;
120
- }
121
- this.lineEnds.length = remaining;
82
+ async push(chunk: string): Promise<void> {
83
+ chunk = sanitizeText(chunk);
84
+ const op = this.#pending.then(() => this.#pushSanitized(chunk));
85
+ this.#pending = op.catch(() => {});
86
+ await op;
122
87
  }
123
88
 
124
- createWritable(): WritableStream<Uint8Array> {
125
- const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
126
- let buf = "";
127
-
128
- const flushLines = () => {
129
- let start = 0;
130
- while (true) {
131
- const nl = buf.indexOf("\n", start);
132
- if (nl === -1) break;
133
- this.pushChunk(buf.slice(start, nl + 1));
134
- start = nl + 1;
135
- }
136
- buf = buf.slice(start);
137
- };
138
-
139
- const finalize = () => {
140
- buf += sanitizeText(decoder.decode());
141
- flushLines();
142
- buf = buf.trimEnd();
143
- if (buf) {
144
- this.pushChunk(`${buf}\n`);
145
- }
146
- };
147
-
148
- return new WritableStream<Uint8Array>({
149
- write: (chunk) => {
150
- buf += sanitizeText(decoder.decode(chunk, { stream: true }));
151
- flushLines();
89
+ createInput(): WritableStream<Uint8Array | string> {
90
+ let decoder: TextDecoder | undefined;
91
+ let finalize = async () => {};
92
+
93
+ return new WritableStream<Uint8Array | string>({
94
+ write: async (chunk) => {
95
+ if (typeof chunk === "string") {
96
+ await this.push(chunk);
97
+ } else {
98
+ if (!decoder) {
99
+ const dec = new TextDecoder("utf-8", { ignoreBOM: true });
100
+ decoder = dec;
101
+ finalize = async () => {
102
+ await this.push(dec.decode());
103
+ };
104
+ }
105
+ await this.push(decoder.decode(chunk, { stream: true }));
106
+ }
152
107
  },
153
108
  close: finalize,
154
109
  abort: finalize,
155
110
  });
156
111
  }
157
112
 
158
- createStringWritable(): WritableStream<string> {
159
- let buf = "";
160
-
161
- const flushLines = () => {
162
- let start = 0;
163
- while (true) {
164
- const nl = buf.indexOf("\n", start);
165
- if (nl === -1) break;
166
- this.pushChunk(buf.slice(start, nl + 1));
167
- start = nl + 1;
168
- }
169
- buf = buf.slice(start);
170
- };
171
-
172
- const finalize = () => {
173
- flushLines();
174
- buf = buf.trimEnd();
175
- if (buf) {
176
- this.pushChunk(`${buf}\n`);
177
- }
178
- };
179
-
180
- return new WritableStream<string>({
181
- write: (chunk) => {
182
- buf += sanitizeText(chunk);
183
- flushLines();
184
- },
185
- close: finalize,
186
- abort: finalize,
187
- });
188
- }
189
-
190
- async close(): Promise<void> {
191
- await this.fileSink?.end();
192
- }
113
+ async dump(notice?: string): Promise<OutputResult> {
114
+ await this.#pending;
115
+ const noticeLine = notice ? `[${notice}]\n` : "";
193
116
 
194
- dump(annotation?: string): OutputResult {
195
- let output = this.buffer;
196
- if (annotation) {
197
- output += `\n${annotation}\n`;
198
- }
199
- if (!this.filePath) {
200
- return { output, truncated: false };
117
+ if (this.#file) {
118
+ await this.#file.sink.end();
119
+ return { output: `${noticeLine}...${this.#buffer}`, truncated: true, fullOutputPath: this.#file.path };
120
+ } else {
121
+ return { output: `${noticeLine}${this.#buffer}`, truncated: false };
201
122
  }
202
- this.fileSink!.write(this.buffer);
203
- this.fileSink!.flush();
204
- return {
205
- output,
206
- truncated: true,
207
- fullOutputPath: this.filePath,
208
- };
209
123
  }
210
124
  }
@@ -6,14 +6,14 @@ import { Type } from "@sinclair/typebox";
6
6
  import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate";
7
7
  import type { Theme } from "../../modes/interactive/theme/theme";
8
8
  import bashDescription from "../../prompts/tools/bash.md" with { type: "text" };
9
- import { type BashExecutorOptions, executeBash, executeBashWithOperations } from "../bash-executor";
9
+ import { type BashExecutorOptions, executeBash } from "../bash-executor";
10
10
  import type { RenderResultOptions } from "../custom-tools/types";
11
11
  import { renderPromptTemplate } from "../prompt-templates";
12
12
  import { checkBashInterception, checkSimpleLsInterception } from "./bash-interceptor";
13
13
  import type { ToolSession } from "./index";
14
14
  import { resolveToCwd } from "./path-utils";
15
15
  import { ToolUIKit } from "./render-utils";
16
- import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate";
16
+ import { formatTailTruncationNotice, type TruncationResult, truncateTail } from "./truncate";
17
17
 
18
18
  export const BASH_DEFAULT_PREVIEW_LINES = 10;
19
19
 
@@ -31,32 +31,12 @@ export interface BashToolDetails {
31
31
  fullOutput?: string;
32
32
  }
33
33
 
34
- /**
35
- * Pluggable operations for bash execution.
36
- * Override to delegate command execution to remote systems.
37
- */
38
- export interface BashOperations {
39
- exec: (
40
- command: string,
41
- cwd: string,
42
- options: {
43
- onData: (data: Buffer) => void;
44
- signal?: AbortSignal;
45
- timeout?: number;
46
- },
47
- ) => Promise<{ exitCode: number | null }>;
48
- }
49
-
50
- export interface BashToolOptions {
51
- /** Custom operations for command execution. Default: local shell */
52
- operations?: BashOperations;
53
- }
34
+ export interface BashToolOptions {}
54
35
 
55
36
  /**
56
37
  * Bash tool implementation.
57
38
  *
58
39
  * Executes bash commands with optional timeout and working directory.
59
- * Supports custom operations for remote execution.
60
40
  */
61
41
  export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
62
42
  public readonly name = "bash";
@@ -65,11 +45,9 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
65
45
  public readonly parameters = bashSchema;
66
46
 
67
47
  private readonly session: ToolSession;
68
- private readonly options?: BashToolOptions;
69
48
 
70
- constructor(session: ToolSession, options?: BashToolOptions) {
49
+ constructor(session: ToolSession) {
71
50
  this.session = session;
72
- this.options = options;
73
51
  this.description = renderPromptTemplate(bashDescription);
74
52
  }
75
53
 
@@ -125,12 +103,8 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
125
103
  },
126
104
  };
127
105
 
128
- // Use custom operations if provided, otherwise use default local executor
129
- const result = this.options?.operations
130
- ? await executeBashWithOperations(command, commandCwd, this.options.operations, executorOptions)
131
- : await executeBash(command, executorOptions);
132
-
133
106
  // Handle errors
107
+ const result = await executeBash(command, executorOptions);
134
108
  if (result.cancelled) {
135
109
  throw new Error(result.output || "Command aborted");
136
110
  }
@@ -147,18 +121,10 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
147
121
  fullOutputPath: result.fullOutputPath,
148
122
  fullOutput: currentOutput,
149
123
  };
150
-
151
- const startLine = truncation.totalLines - truncation.outputLines + 1;
152
- const endLine = truncation.totalLines;
153
-
154
- if (truncation.lastLinePartial) {
155
- const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8"));
156
- outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${result.fullOutputPath}]`;
157
- } else if (truncation.truncatedBy === "lines") {
158
- outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${result.fullOutputPath}]`;
159
- } else {
160
- outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${result.fullOutputPath}]`;
161
- }
124
+ outputText += formatTailTruncationNotice(truncation, {
125
+ fullOutputPath: result.fullOutputPath,
126
+ originalContent: result.output,
127
+ });
162
128
  }
163
129
 
164
130
  if (result.exitCode !== 0 && result.exitCode !== undefined) {
@@ -263,7 +229,7 @@ export const bashToolRenderer = {
263
229
  warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
264
230
  } else {
265
231
  warnings.push(
266
- `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
232
+ `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes)} limit)`,
267
233
  );
268
234
  }
269
235
  }
@@ -1,5 +1,5 @@
1
1
  export { AskTool, type AskToolDetails } from "./ask";
2
- export { type BashOperations, BashTool, type BashToolDetails, type BashToolOptions } from "./bash";
2
+ export { BashTool, type BashToolDetails, type BashToolOptions } from "./bash";
3
3
  export { CalculatorTool, type CalculatorToolDetails } from "./calculator";
4
4
  export { CompleteTool } from "./complete";
5
5
  // Exa MCP tools (22 tools)
@@ -14,7 +14,7 @@ import type { PreludeHelper, PythonStatusEvent } from "../python-kernel";
14
14
  import type { ToolSession } from "./index";
15
15
  import { resolveToCwd } from "./path-utils";
16
16
  import { getTreeBranch, getTreeContinuePrefix, shortenPath, ToolUIKit, truncate } from "./render-utils";
17
- import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate";
17
+ import { DEFAULT_MAX_BYTES, formatTailTruncationNotice, type TruncationResult, truncateTail } from "./truncate";
18
18
 
19
19
  export const PYTHON_DEFAULT_PREVIEW_LINES = 10;
20
20
 
@@ -234,7 +234,6 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
234
234
  let details: PythonToolDetails | undefined;
235
235
 
236
236
  if (truncation.truncated) {
237
- const fullOutputSuffix = result.fullOutputPath ? ` Full output: ${result.fullOutputPath}` : "";
238
237
  details = {
239
238
  truncation,
240
239
  fullOutputPath: result.fullOutputPath,
@@ -242,18 +241,10 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
242
241
  images,
243
242
  statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
244
243
  };
245
-
246
- const startLine = truncation.totalLines - truncation.outputLines + 1;
247
- const endLine = truncation.totalLines;
248
-
249
- if (truncation.lastLinePartial) {
250
- const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8"));
251
- outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize})${fullOutputSuffix}]`;
252
- } else if (truncation.truncatedBy === "lines") {
253
- outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}${fullOutputSuffix}]`;
254
- } else {
255
- outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit)${fullOutputSuffix}]`;
256
- }
244
+ outputText += formatTailTruncationNotice(truncation, {
245
+ fullOutputPath: result.fullOutputPath,
246
+ originalContent: result.output,
247
+ });
257
248
  }
258
249
 
259
250
  if (!details && (jsonOutputs.length > 0 || images.length > 0 || statusEvents.length > 0)) {
@@ -685,7 +676,7 @@ export const pythonToolRenderer = {
685
676
  warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
686
677
  } else {
687
678
  warnings.push(
688
- `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
679
+ `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes)} limit)`,
689
680
  );
690
681
  }
691
682
  }
@@ -14,7 +14,7 @@ import { ensureHostInfo, getHostInfoForHost } from "../ssh/connection-manager";
14
14
  import { executeSSH } from "../ssh/ssh-executor";
15
15
  import type { ToolSession } from "./index";
16
16
  import { ToolUIKit } from "./render-utils";
17
- import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate";
17
+ import { formatTailTruncationNotice, type TruncationResult, truncateTail } from "./truncate";
18
18
 
19
19
  const sshSchema = Type.Object({
20
20
  host: Type.String({ description: "Host name from ssh.json or .ssh.json" }),
@@ -193,18 +193,10 @@ export class SshTool implements AgentTool<typeof sshSchema, SSHToolDetails> {
193
193
  truncation,
194
194
  fullOutputPath: result.fullOutputPath,
195
195
  };
196
-
197
- const startLine = truncation.totalLines - truncation.outputLines + 1;
198
- const endLine = truncation.totalLines;
199
-
200
- if (truncation.lastLinePartial) {
201
- const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8"));
202
- outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${result.fullOutputPath}]`;
203
- } else if (truncation.truncatedBy === "lines") {
204
- outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${result.fullOutputPath}]`;
205
- } else {
206
- outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${result.fullOutputPath}]`;
207
- }
196
+ outputText += formatTailTruncationNotice(truncation, {
197
+ fullOutputPath: result.fullOutputPath,
198
+ originalContent: result.output,
199
+ });
208
200
  }
209
201
 
210
202
  if (result.exitCode !== 0 && result.exitCode !== undefined) {
@@ -311,7 +303,7 @@ export const sshToolRenderer = {
311
303
  warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
312
304
  } else {
313
305
  warnings.push(
314
- `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
306
+ `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes)} limit)`,
315
307
  );
316
308
  }
317
309
  }
@@ -289,3 +289,95 @@ export function truncateLine(
289
289
  }
290
290
  return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true };
291
291
  }
292
+
293
+ // =============================================================================
294
+ // Truncation notice formatting
295
+ // =============================================================================
296
+
297
+ export interface TailTruncationNoticeOptions {
298
+ /** Path to full output file (e.g., from bash/python executor) */
299
+ fullOutputPath?: string;
300
+ /** Original content for computing last line size when lastLinePartial */
301
+ originalContent?: string;
302
+ /** Additional suffix to append inside the brackets */
303
+ suffix?: string;
304
+ }
305
+
306
+ /**
307
+ * Format a truncation notice for tail-truncated output (bash, python, ssh).
308
+ * Returns empty string if not truncated.
309
+ *
310
+ * Examples:
311
+ * - "[Showing last 50KB of line 1000 (line is 2.1MB). Full output: /tmp/out.txt]"
312
+ * - "[Showing lines 500-1000 of 1000. Full output: /tmp/out.txt]"
313
+ * - "[Showing lines 500-1000 of 1000 (50KB limit). Full output: /tmp/out.txt]"
314
+ */
315
+ export function formatTailTruncationNotice(
316
+ truncation: TruncationResult,
317
+ options: TailTruncationNoticeOptions = {},
318
+ ): string {
319
+ if (!truncation.truncated) {
320
+ return "";
321
+ }
322
+
323
+ const { fullOutputPath, originalContent, suffix = "" } = options;
324
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
325
+ const endLine = truncation.totalLines;
326
+ const fullOutputPart = fullOutputPath ? `. Full output: ${fullOutputPath}` : "";
327
+
328
+ let notice: string;
329
+
330
+ if (truncation.lastLinePartial) {
331
+ let lastLineSizePart = "";
332
+ if (originalContent) {
333
+ const lastLine = originalContent.split("\n").pop() || "";
334
+ lastLineSizePart = ` (line is ${formatSize(Buffer.byteLength(lastLine, "utf-8"))})`;
335
+ }
336
+ notice = `[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine}${lastLineSizePart}${fullOutputPart}${suffix}]`;
337
+ } else if (truncation.truncatedBy === "lines") {
338
+ notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}${fullOutputPart}${suffix}]`;
339
+ } else {
340
+ notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit)${fullOutputPart}${suffix}]`;
341
+ }
342
+
343
+ return `\n\n${notice}`;
344
+ }
345
+
346
+ export interface HeadTruncationNoticeOptions {
347
+ /** 1-indexed start line number (default: 1) */
348
+ startLine?: number;
349
+ /** Total lines in the original file (for "of N" display) */
350
+ totalFileLines?: number;
351
+ }
352
+
353
+ /**
354
+ * Format a truncation notice for head-truncated output (read tool).
355
+ * Returns empty string if not truncated.
356
+ *
357
+ * Examples:
358
+ * - "[Showing lines 1-2000 of 5000. Use offset=2001 to continue]"
359
+ * - "[Showing lines 100-2099 of 5000 (50KB limit). Use offset=2100 to continue]"
360
+ */
361
+ export function formatHeadTruncationNotice(
362
+ truncation: TruncationResult,
363
+ options: HeadTruncationNoticeOptions = {},
364
+ ): string {
365
+ if (!truncation.truncated) {
366
+ return "";
367
+ }
368
+
369
+ const startLineDisplay = options.startLine ?? 1;
370
+ const totalFileLines = options.totalFileLines ?? truncation.totalLines;
371
+ const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
372
+ const nextOffset = endLineDisplay + 1;
373
+
374
+ let notice: string;
375
+
376
+ if (truncation.truncatedBy === "lines") {
377
+ notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
378
+ } else {
379
+ notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(truncation.maxBytes)} limit). Use offset=${nextOffset} to continue]`;
380
+ }
381
+
382
+ return `\n\n${notice}`;
383
+ }
@@ -24,7 +24,9 @@ import { convertWithMarkitdown, fetchBinary } from "./web-scrapers/utils";
24
24
  // Types and Constants
25
25
  // =============================================================================
26
26
 
27
- const DEFAULT_TIMEOUT = 20;
27
+ const MIN_TIMEOUT = 1_000;
28
+ const DEFAULT_TIMEOUT = 20_000;
29
+ const MAX_TIMEOUT = 45_000;
28
30
 
29
31
  // Convertible document types (markitdown supported)
30
32
  const CONVERTIBLE_MIMES = new Set([
@@ -109,7 +111,7 @@ async function exec(
109
111
  ): Promise<{ stdout: string; stderr: string; ok: boolean }> {
110
112
  const proc = ptree.cspawn([cmd, ...args], {
111
113
  stdin: options?.input ? "pipe" : null,
112
- timeout: options?.timeout,
114
+ timeout: options?.timeout ? options.timeout * 1000 : undefined,
113
115
  });
114
116
 
115
117
  if (options?.input) {
@@ -244,7 +246,7 @@ async function tryMdSuffix(url: string, timeout: number, signal?: AbortSignal):
244
246
  if (signal?.aborted) {
245
247
  return null;
246
248
  }
247
- const result = await loadPage(candidate, { timeout: Math.min(timeout, 5), signal });
249
+ const result = await loadPage(candidate, { timeout: Math.min(timeout, MAX_TIMEOUT), signal });
248
250
  if (result.ok && result.content.trim().length > 100 && !looksLikeHtml(result.content)) {
249
251
  return result.content;
250
252
  }
@@ -910,7 +912,7 @@ export class WebFetchTool implements AgentTool<typeof webFetchSchema, WebFetchTo
910
912
  }
911
913
 
912
914
  // Clamp timeout
913
- const effectiveTimeout = Math.min(Math.max(timeout, 1), 120);
915
+ const effectiveTimeout = Math.min(Math.max(timeout, MIN_TIMEOUT), MAX_TIMEOUT);
914
916
 
915
917
  const result = await renderUrl(url, effectiveTimeout, raw, signal);
916
918
 
@@ -1,36 +1,13 @@
1
1
  import { rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import * as path from "node:path";
4
- import { $ } from "bun";
4
+ import { ptree } from "@oh-my-pi/pi-utils";
5
5
  import { nanoid } from "nanoid";
6
6
  import { ensureTool } from "../../../utils/tools-manager";
7
7
  import { createRequestSignal } from "./types";
8
8
 
9
9
  const MAX_BYTES = 50 * 1024 * 1024; // 50MB for binary files
10
10
 
11
- interface ExecResult {
12
- stdout: string;
13
- stderr: string;
14
- ok: boolean;
15
- exitCode: number;
16
- }
17
-
18
- async function exec(
19
- cmd: string,
20
- args: string[],
21
- options?: { timeout?: number; input?: string | Buffer },
22
- ): Promise<ExecResult> {
23
- void options;
24
- const result = await $`${cmd} ${args}`.quiet().nothrow();
25
- const decoder = new TextDecoder();
26
- return {
27
- stdout: result.stdout ? decoder.decode(result.stdout) : "",
28
- stderr: result.stderr ? decoder.decode(result.stderr) : "",
29
- ok: result.exitCode === 0,
30
- exitCode: result.exitCode ?? -1,
31
- };
32
- }
33
-
34
11
  export interface ConvertResult {
35
12
  content: string;
36
13
  ok: boolean;
@@ -72,16 +49,16 @@ export async function convertWithMarkitdown(
72
49
 
73
50
  try {
74
51
  await Bun.write(tmpFile, content);
75
- const result = await exec(markitdown, [tmpFile], { timeout });
76
- if (!result.ok) {
77
- const stderr = result.stderr.trim();
52
+ const result = await ptree.cspawn([markitdown, tmpFile], { timeout });
53
+ const [stdout, stderr, exitCode] = await Promise.all([result.stdout.text(), result.stderr.text(), result.exited]);
54
+ if (exitCode !== 0) {
78
55
  return {
79
- content: result.stdout,
56
+ content: stdout,
80
57
  ok: false,
81
- error: stderr.length > 0 ? stderr : `markitdown failed (exit ${result.exitCode})`,
58
+ error: stderr.length > 0 ? stderr : `markitdown failed (exit ${exitCode})`,
82
59
  };
83
60
  }
84
- return { content: result.stdout, ok: true };
61
+ return { content: stdout, ok: true };
85
62
  } finally {
86
63
  try {
87
64
  await rm(tmpFile, { force: true });
@@ -64,7 +64,6 @@ function buildSystemBlocks(
64
64
 
65
65
  return buildAnthropicSystemBlocks(systemPrompt, {
66
66
  includeClaudeCodeInstruction: includeClaudeCode,
67
- includeCacheControl: auth.isOAuth,
68
67
  extraInstructions,
69
68
  });
70
69
  }
package/src/index.ts CHANGED
@@ -194,7 +194,6 @@ export {
194
194
  export { type FileSlashCommand, loadSlashCommands as discoverSlashCommands } from "./core/slash-commands";
195
195
  // Tools (detail types and utilities)
196
196
  export {
197
- type BashOperations,
198
197
  type BashToolDetails,
199
198
  DEFAULT_MAX_BYTES,
200
199
  DEFAULT_MAX_LINES,