@oh-my-pi/pi-coding-agent 3.6.1337 → 3.8.1337

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,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [3.8.1337] - 2026-01-04
6
+ ### Added
7
+
8
+ - Added automatic browser opening after exporting session to HTML
9
+ - Added automatic browser opening after sharing session as a Gist
10
+
11
+ ### Fixed
12
+
13
+ - Fixed session titles not persisting to file when set before first flush
14
+
15
+ ## [3.7.1337] - 2026-01-04
16
+ ### Added
17
+
18
+ - Added `EditMatchError` class for structured error handling in edit operations
19
+ - Added `utils` module export with `once` and `untilAborted` helper functions
20
+ - Added in-memory LSP content sync via `syncContent` and `notifySaved` client methods
21
+
22
+ ### Changed
23
+
24
+ - Refactored LSP integration to use writethrough callbacks for edit and write tools, improving performance by syncing content in-memory before disk writes
25
+ - Simplified FileDiagnosticsResult interface with renamed fields: `diagnostics` → `messages`, `hasErrors` → `errored`, `serverName` → `server`
26
+ - Session title generation now triggers before sending the first message rather than after agent work begins
27
+
28
+ ### Fixed
29
+
30
+ - Fixed potential text decoding issues in bash executor by using streaming TextDecoder instead of Buffer.toString()
31
+
5
32
  ## [3.6.1337] - 2026-01-03
6
33
 
7
34
  ## [3.5.1337] - 2026-01-03
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "3.6.1337",
3
+ "version": "3.8.1337",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,9 +39,9 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-agent-core": "3.6.1337",
43
- "@oh-my-pi/pi-ai": "3.6.1337",
44
- "@oh-my-pi/pi-tui": "3.6.1337",
42
+ "@oh-my-pi/pi-agent-core": "3.8.1337",
43
+ "@oh-my-pi/pi-ai": "3.8.1337",
44
+ "@oh-my-pi/pi-tui": "3.8.1337",
45
45
  "@sinclair/typebox": "^0.34.46",
46
46
  "ajv": "^8.17.1",
47
47
  "chalk": "^5.5.0",
@@ -14,6 +14,7 @@ import stripAnsi from "strip-ansi";
14
14
  import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell";
15
15
  import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot";
16
16
  import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate";
17
+ import { ScopeSignal } from "./utils";
17
18
 
18
19
  // ============================================================================
19
20
  // Types
@@ -47,6 +48,70 @@ export interface BashResult {
47
48
  // Implementation
48
49
  // ============================================================================
49
50
 
51
+ function createSanitizer(): TransformStream<Uint8Array, string> {
52
+ const decoder = new TextDecoder();
53
+ return new TransformStream({
54
+ transform(chunk, controller) {
55
+ const text = sanitizeBinaryOutput(stripAnsi(decoder.decode(chunk, { stream: true }))).replace(/\r/g, "");
56
+ controller.enqueue(text);
57
+ },
58
+ });
59
+ }
60
+
61
+ function createOutputSink(
62
+ spillThreshold: number,
63
+ maxBuffer: number,
64
+ onChunk?: (text: string) => void,
65
+ ): WritableStream<string> & {
66
+ dump: (annotation?: string) => { output: string; truncated: boolean; fullOutputPath?: string };
67
+ } {
68
+ const chunks: string[] = [];
69
+ let chunkBytes = 0;
70
+ let totalBytes = 0;
71
+ let fullOutputPath: string | undefined;
72
+ let fullOutputStream: WriteStream | undefined;
73
+
74
+ const sink = new WritableStream<string>({
75
+ write(text) {
76
+ totalBytes += text.length;
77
+
78
+ // Spill to temp file if needed
79
+ if (totalBytes > spillThreshold && !fullOutputPath) {
80
+ fullOutputPath = join(tmpdir(), `omp-${crypto.randomUUID()}.buffer`);
81
+ const ts = createWriteStream(fullOutputPath);
82
+ chunks.forEach((c) => {
83
+ ts.write(c);
84
+ });
85
+ fullOutputStream = ts;
86
+ }
87
+ fullOutputStream?.write(text);
88
+
89
+ // Rolling buffer
90
+ chunks.push(text);
91
+ chunkBytes += text.length;
92
+ while (chunkBytes > maxBuffer && chunks.length > 1) {
93
+ chunkBytes -= chunks.shift()!.length;
94
+ }
95
+
96
+ onChunk?.(text);
97
+ },
98
+ close() {
99
+ fullOutputStream?.end();
100
+ },
101
+ });
102
+
103
+ return Object.assign(sink, {
104
+ dump(annotation?: string) {
105
+ if (annotation) {
106
+ chunks.push(`\n\n${annotation}`);
107
+ }
108
+ const full = chunks.join("");
109
+ const { content, truncated } = truncateTail(full);
110
+ return { output: truncated ? content : full, truncated, fullOutputPath: fullOutputPath };
111
+ },
112
+ });
113
+ }
114
+
50
115
  /**
51
116
  * Execute a bash command with optional streaming and cancellation support.
52
117
  *
@@ -72,165 +137,61 @@ export async function executeBash(command: string, options?: BashExecutorOptions
72
137
  const prefixedCommand = prefix ? `${prefix} ${command}` : command;
73
138
  const finalCommand = `${snapshotPrefix}${prefixedCommand}`;
74
139
 
75
- return new Promise((resolve, reject) => {
76
- const child: Subprocess = Bun.spawn([shell, ...args, finalCommand], {
77
- cwd: options?.cwd,
78
- stdin: "ignore",
79
- stdout: "pipe",
80
- stderr: "pipe",
81
- env,
82
- });
83
-
84
- // Track sanitized output for truncation
85
- const outputChunks: string[] = [];
86
- let outputBytes = 0;
87
- const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
88
-
89
- // Temp file for large output
90
- let tempFilePath: string | undefined;
91
- let tempFileStream: WriteStream | undefined;
92
- let totalBytes = 0;
93
- let timedOut = false;
94
-
95
- // Handle abort signal and timeout
96
- const abortHandler = () => {
97
- killProcessTree(child.pid);
98
- };
99
-
100
- // Set up timeout if specified
101
- let timeoutHandle: Timer | undefined;
102
- if (options?.timeout && options.timeout > 0) {
103
- timeoutHandle = setTimeout(() => {
104
- timedOut = true;
105
- abortHandler();
106
- }, options.timeout);
107
- }
140
+ using signal = new ScopeSignal(options);
108
141
 
109
- if (options?.signal) {
110
- if (options.signal.aborted) {
111
- // Already aborted, don't even start
112
- child.kill();
113
- if (timeoutHandle) clearTimeout(timeoutHandle);
114
- resolve({
115
- output: "",
116
- exitCode: undefined,
117
- cancelled: true,
118
- truncated: false,
119
- });
120
- return;
121
- }
122
- options.signal.addEventListener("abort", abortHandler, { once: true });
123
- }
124
-
125
- const handleData = (data: Buffer) => {
126
- totalBytes += data.length;
127
-
128
- // Sanitize once at the source: strip ANSI, replace binary garbage, normalize newlines
129
- const text = sanitizeBinaryOutput(stripAnsi(data.toString())).replace(/\r/g, "");
130
-
131
- // Start writing to temp file if exceeds threshold
132
- if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
133
- const randomId = crypto.getRandomValues(new Uint8Array(8));
134
- const id = Array.from(randomId, (b) => b.toString(16).padStart(2, "0")).join("");
135
- tempFilePath = join(tmpdir(), `omp-bash-${id}.log`);
136
- tempFileStream = createWriteStream(tempFilePath);
137
- // Write already-buffered chunks to temp file
138
- for (const chunk of outputChunks) {
139
- tempFileStream.write(chunk);
140
- }
141
- }
142
+ const child: Subprocess = Bun.spawn([shell, ...args, finalCommand], {
143
+ cwd: options?.cwd,
144
+ stdin: "ignore",
145
+ stdout: "pipe",
146
+ stderr: "pipe",
147
+ env,
148
+ });
142
149
 
143
- if (tempFileStream) {
144
- tempFileStream.write(text);
145
- }
150
+ signal.catch(() => {
151
+ killProcessTree(child.pid);
152
+ });
146
153
 
147
- // Keep rolling buffer of sanitized text
148
- outputChunks.push(text);
149
- outputBytes += text.length;
150
- while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
151
- const removed = outputChunks.shift()!;
152
- outputBytes -= removed.length;
153
- }
154
+ const sink = createOutputSink(DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES * 2, options?.onChunk);
154
155
 
155
- // Stream to callback if provided
156
- if (options?.onChunk) {
157
- options.onChunk(text);
158
- }
159
- };
160
-
161
- // Read streams asynchronously
162
- (async () => {
156
+ const writer = sink.getWriter();
157
+ try {
158
+ async function pumpStream(readable: ReadableStream<Uint8Array>) {
159
+ const reader = readable.pipeThrough(createSanitizer()).getReader();
163
160
  try {
164
- const stdoutReader = (child.stdout as ReadableStream<Uint8Array>).getReader();
165
- const stderrReader = (child.stderr as ReadableStream<Uint8Array>).getReader();
166
-
167
- await Promise.all([
168
- (async () => {
169
- while (true) {
170
- const { done, value } = await stdoutReader.read();
171
- if (done) break;
172
- handleData(Buffer.from(value));
173
- }
174
- })(),
175
- (async () => {
176
- while (true) {
177
- const { done, value } = await stderrReader.read();
178
- if (done) break;
179
- handleData(Buffer.from(value));
180
- }
181
- })(),
182
- ]);
183
-
184
- const exitCode = await child.exited;
185
-
186
- // Clean up
187
- if (timeoutHandle) clearTimeout(timeoutHandle);
188
- if (options?.signal) {
189
- options.signal.removeEventListener("abort", abortHandler);
190
- }
191
- if (tempFileStream) {
192
- tempFileStream.end();
193
- }
194
-
195
- // Combine buffered chunks for truncation (already sanitized)
196
- const fullOutput = outputChunks.join("");
197
- const truncationResult = truncateTail(fullOutput);
198
-
199
- // Handle timeout
200
- if (timedOut) {
201
- const timeoutSecs = Math.round((options?.timeout || 0) / 1000);
202
- resolve({
203
- output: `${fullOutput}\n\nCommand timed out after ${timeoutSecs} seconds`,
204
- exitCode: undefined,
205
- cancelled: true,
206
- truncated: truncationResult.truncated,
207
- fullOutputPath: tempFilePath,
208
- });
209
- return;
161
+ while (true) {
162
+ const { done, value } = await reader.read();
163
+ if (done) break;
164
+ await writer.write(value);
210
165
  }
211
-
212
- // Non-zero exit codes or signal-killed processes are considered cancelled if killed via signal
213
- const cancelled = exitCode === null || (exitCode !== 0 && (options?.signal?.aborted ?? false));
214
-
215
- resolve({
216
- output: truncationResult.truncated ? truncationResult.content : fullOutput,
217
- exitCode: cancelled ? undefined : exitCode,
218
- cancelled,
219
- truncated: truncationResult.truncated,
220
- fullOutputPath: tempFilePath,
221
- });
222
- } catch (err) {
223
- // Clean up
224
- if (timeoutHandle) clearTimeout(timeoutHandle);
225
- if (options?.signal) {
226
- options.signal.removeEventListener("abort", abortHandler);
227
- }
228
- if (tempFileStream) {
229
- tempFileStream.end();
230
- }
231
-
232
- reject(err);
166
+ } finally {
167
+ reader.releaseLock();
233
168
  }
234
- })();
235
- });
169
+ }
170
+ await Promise.all([
171
+ pumpStream(child.stdout as ReadableStream<Uint8Array>),
172
+ pumpStream(child.stderr as ReadableStream<Uint8Array>),
173
+ ]);
174
+ } finally {
175
+ await writer.close();
176
+ }
177
+
178
+ // Non-zero exit codes or signal-killed processes are considered cancelled if killed via signal
179
+ const exitCode = await child.exited;
180
+
181
+ const cancelled = exitCode === null || (exitCode !== 0 && (options?.signal?.aborted ?? false));
182
+
183
+ if (signal.timedOut()) {
184
+ const secs = Math.round(options!.timeout! / 1000);
185
+ return {
186
+ exitCode: undefined,
187
+ cancelled: true,
188
+ ...sink.dump(`Command timed out after ${secs} seconds`),
189
+ };
190
+ }
191
+
192
+ return {
193
+ exitCode: cancelled ? undefined : exitCode,
194
+ cancelled,
195
+ ...sink.dump(),
196
+ };
236
197
  }
package/src/core/index.ts CHANGED
@@ -49,3 +49,5 @@ export {
49
49
  type MCPToolsLoadResult,
50
50
  type MCPTransport,
51
51
  } from "./mcp/index";
52
+
53
+ export * as utils from "./utils";
@@ -518,7 +518,10 @@ function getSortedSessions(sessionDir: string): RecentSessionInfo[] {
518
518
  // Find end of first JSON line
519
519
  const eol = sub.indexOf("}\n");
520
520
  if (eol <= 0) return null;
521
- return JSON.parse(sub.toString("utf8", 0, eol + 1));
521
+ const header = JSON.parse(sub.toString("utf8", 0, eol + 1));
522
+ // Validate session header
523
+ if (header.type !== "session" || typeof header.id !== "string") return null;
524
+ return header;
522
525
  };
523
526
 
524
527
  return readdirSync(sessionDir)
@@ -705,16 +708,23 @@ export class SessionManager {
705
708
 
706
709
  setSessionTitle(title: string): void {
707
710
  this.sessionTitle = title;
708
- // Update the session file header with the title
711
+
712
+ // Update the in-memory header (so first flush includes title)
713
+ const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
714
+ if (header) {
715
+ header.title = title;
716
+ }
717
+
718
+ // Update the session file header with the title (if already flushed)
709
719
  if (this.persist && this.sessionFile && existsSync(this.sessionFile)) {
710
720
  try {
711
721
  const content = readFileSync(this.sessionFile, "utf-8");
712
722
  const lines = content.split("\n");
713
723
  if (lines.length > 0) {
714
- const header = JSON.parse(lines[0]) as SessionHeader;
715
- if (header.type === "session") {
716
- header.title = title;
717
- lines[0] = JSON.stringify(header);
724
+ const fileHeader = JSON.parse(lines[0]) as SessionHeader;
725
+ if (fileHeader.type === "session") {
726
+ fileHeader.title = title;
727
+ lines[0] = JSON.stringify(fileHeader);
718
728
  writeFileSync(this.sessionFile, lines.join("\n"));
719
729
  }
720
730
  }
@@ -249,40 +249,52 @@ function findFirstDifferentLine(oldLines: string[], newLines: string[]): { oldLi
249
249
  return { oldLine: oldLines[0] ?? "", newLine: newLines[0] ?? "" };
250
250
  }
251
251
 
252
- export function formatEditMatchError(
253
- path: string,
254
- normalizedOldText: string,
255
- closest: EditMatch | undefined,
256
- options: { allowFuzzy: boolean; similarityThreshold: number; fuzzyMatches?: number },
257
- ): string {
258
- if (!closest) {
259
- return options.allowFuzzy
260
- ? `Could not find a close enough match in ${path}.`
261
- : `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`;
252
+ export class EditMatchError extends Error {
253
+ constructor(
254
+ public readonly path: string,
255
+ public readonly normalizedOldText: string,
256
+ public readonly closest: EditMatch | undefined,
257
+ public readonly options: { allowFuzzy: boolean; similarityThreshold: number; fuzzyMatches?: number },
258
+ ) {
259
+ super(EditMatchError.formatMessage(path, normalizedOldText, closest, options));
260
+ this.name = "EditMatchError";
262
261
  }
263
262
 
264
- const similarity = Math.round(closest.confidence * 100);
265
- const oldLines = normalizedOldText.split("\n");
266
- const actualLines = closest.actualText.split("\n");
267
- const { oldLine, newLine } = findFirstDifferentLine(oldLines, actualLines);
268
- const thresholdPercent = Math.round(options.similarityThreshold * 100);
269
-
270
- const hint = options.allowFuzzy
271
- ? options.fuzzyMatches && options.fuzzyMatches > 1
272
- ? `Found ${options.fuzzyMatches} high-confidence matches. Provide more context to make it unique.`
273
- : `Closest match was below the ${thresholdPercent}% similarity threshold.`
274
- : "Fuzzy matching is disabled. Enable 'Edit fuzzy match' in settings to accept high-confidence matches.";
275
-
276
- return [
277
- options.allowFuzzy
278
- ? `Could not find a close enough match in ${path}.`
279
- : `Could not find the exact text in ${path}.`,
280
- ``,
281
- `Closest match (${similarity}% similar) at line ${closest.startLine}:`,
282
- ` - ${oldLine}`,
283
- ` + ${newLine}`,
284
- hint,
285
- ].join("\n");
263
+ static formatMessage(
264
+ path: string,
265
+ normalizedOldText: string,
266
+ closest: EditMatch | undefined,
267
+ options: { allowFuzzy: boolean; similarityThreshold: number; fuzzyMatches?: number },
268
+ ): string {
269
+ if (!closest) {
270
+ return options.allowFuzzy
271
+ ? `Could not find a close enough match in ${path}.`
272
+ : `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`;
273
+ }
274
+
275
+ const similarity = Math.round(closest.confidence * 100);
276
+ const oldLines = normalizedOldText.split("\n");
277
+ const actualLines = closest.actualText.split("\n");
278
+ const { oldLine, newLine } = findFirstDifferentLine(oldLines, actualLines);
279
+ const thresholdPercent = Math.round(options.similarityThreshold * 100);
280
+
281
+ const hint = options.allowFuzzy
282
+ ? options.fuzzyMatches && options.fuzzyMatches > 1
283
+ ? `Found ${options.fuzzyMatches} high-confidence matches. Provide more context to make it unique.`
284
+ : `Closest match was below the ${thresholdPercent}% similarity threshold.`
285
+ : "Fuzzy matching is disabled. Enable 'Edit fuzzy match' in settings to accept high-confidence matches.";
286
+
287
+ return [
288
+ options.allowFuzzy
289
+ ? `Could not find a close enough match in ${path}.`
290
+ : `Could not find the exact text in ${path}.`,
291
+ ``,
292
+ `Closest match (${similarity}% similar) at line ${closest.startLine}:`,
293
+ ` - ${oldLine}`,
294
+ ` + ${newLine}`,
295
+ hint,
296
+ ].join("\n");
297
+ }
286
298
  }
287
299
 
288
300
  /**
@@ -444,7 +456,7 @@ export async function computeEditDiff(
444
456
 
445
457
  if (!matchOutcome.match) {
446
458
  return {
447
- error: formatEditMatchError(path, normalizedOldText, matchOutcome.closest, {
459
+ error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, {
448
460
  allowFuzzy: fuzzy,
449
461
  similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
450
462
  fuzzyMatches: matchOutcome.fuzzyMatches,