@oh-my-pi/pi-coding-agent 3.6.1337 → 3.9.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,45 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [3.9.1337] - 2026-01-04
6
+
7
+ ### Changed
8
+
9
+ - Changed default for `lsp.formatOnWrite` setting from `true` to `false`
10
+ - Updated status line thinking level display to use emoji icons instead of abbreviated text
11
+ - Changed auto-compact indicator from "(auto)" text to icon
12
+
13
+ ### Fixed
14
+
15
+ - Fixed stale diagnostics persisting after file content changes in LSP client
16
+
17
+ ## [3.8.1337] - 2026-01-04
18
+ ### Added
19
+
20
+ - Added automatic browser opening after exporting session to HTML
21
+ - Added automatic browser opening after sharing session as a Gist
22
+
23
+ ### Fixed
24
+
25
+ - Fixed session titles not persisting to file when set before first flush
26
+
27
+ ## [3.7.1337] - 2026-01-04
28
+ ### Added
29
+
30
+ - Added `EditMatchError` class for structured error handling in edit operations
31
+ - Added `utils` module export with `once` and `untilAborted` helper functions
32
+ - Added in-memory LSP content sync via `syncContent` and `notifySaved` client methods
33
+
34
+ ### Changed
35
+
36
+ - Refactored LSP integration to use writethrough callbacks for edit and write tools, improving performance by syncing content in-memory before disk writes
37
+ - Simplified FileDiagnosticsResult interface with renamed fields: `diagnostics` → `messages`, `hasErrors` → `errored`, `serverName` → `server`
38
+ - Session title generation now triggers before sending the first message rather than after agent work begins
39
+
40
+ ### Fixed
41
+
42
+ - Fixed potential text decoding issues in bash executor by using streaming TextDecoder instead of Buffer.toString()
43
+
5
44
  ## [3.6.1337] - 2026-01-03
6
45
 
7
46
  ## [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.9.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.9.1337",
43
+ "@oh-my-pi/pi-ai": "3.9.1337",
44
+ "@oh-my-pi/pi-tui": "3.9.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
  }
@@ -59,7 +59,7 @@ export interface MCPSettings {
59
59
  }
60
60
 
61
61
  export interface LspSettings {
62
- formatOnWrite?: boolean; // default: true (format files using LSP after write tool writes code files)
62
+ formatOnWrite?: boolean; // default: false (format files using LSP after write tool writes code files)
63
63
  diagnosticsOnWrite?: boolean; // default: true (return LSP diagnostics after write tool writes code files)
64
64
  diagnosticsOnEdit?: boolean; // default: false (return LSP diagnostics after edit tool edits code files)
65
65
  }
@@ -539,7 +539,7 @@ export class SettingsManager {
539
539
  }
540
540
 
541
541
  getLspFormatOnWrite(): boolean {
542
- return this.settings.lsp?.formatOnWrite ?? true;
542
+ return this.settings.lsp?.formatOnWrite ?? false;
543
543
  }
544
544
 
545
545
  setLspFormatOnWrite(enabled: boolean): void {
@@ -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,