@oh-my-pi/pi-coding-agent 15.10.8 → 15.10.9

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.
@@ -5,6 +5,9 @@
5
5
  * Messages are newline-delimited JSON.
6
6
  */
7
7
 
8
+ import * as fs from "node:fs/promises";
9
+ import * as path from "node:path";
10
+
8
11
  import { getProjectDir, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
9
12
  import { type Subprocess, spawn } from "bun";
10
13
  import type {
@@ -19,6 +22,134 @@ import type {
19
22
  import { toJsonRpcError } from "../../mcp/types";
20
23
  import { isMCPTimeoutEnabled, resolveMCPTimeoutMs } from "../timeout";
21
24
 
25
+ /** Subprocess argv for launching an MCP stdio server. */
26
+ export interface StdioSpawnCommand {
27
+ cmd: string[];
28
+ }
29
+
30
+ /** Inputs used to resolve platform-specific stdio spawn behavior. */
31
+ export interface ResolveStdioSpawnOptions {
32
+ cwd: string;
33
+ env: Record<string, string | undefined>;
34
+ platform?: NodeJS.Platform;
35
+ }
36
+
37
+ const DEFAULT_WINDOWS_PATHEXT = [".COM", ".EXE", ".BAT", ".CMD"];
38
+ const WINDOWS_BATCH_EXTENSIONS = new Set([".bat", ".cmd"]);
39
+
40
+ function getCaseInsensitiveEnv(env: Record<string, string | undefined>, name: string): string | undefined {
41
+ const direct = env[name];
42
+ if (direct !== undefined) return direct;
43
+ const normalized = name.toLowerCase();
44
+ for (const [key, value] of Object.entries(env)) {
45
+ if (key.toLowerCase() === normalized) return value;
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ function getWindowsPathExt(env: Record<string, string | undefined>): string[] {
51
+ const raw = getCaseInsensitiveEnv(env, "PATHEXT");
52
+ if (!raw) return DEFAULT_WINDOWS_PATHEXT;
53
+ const extensions: string[] = [];
54
+ for (const part of raw.split(";")) {
55
+ const trimmed = part.trim();
56
+ if (!trimmed) continue;
57
+ extensions.push(trimmed.startsWith(".") ? trimmed : `.${trimmed}`);
58
+ }
59
+ return extensions.length > 0 ? extensions : DEFAULT_WINDOWS_PATHEXT;
60
+ }
61
+
62
+ async function fileExists(filePath: string): Promise<boolean> {
63
+ try {
64
+ await fs.access(filePath);
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ function hasPathSegment(command: string): boolean {
72
+ return command.includes("/") || command.includes("\\") || path.isAbsolute(command);
73
+ }
74
+
75
+ function hasExecutableExtension(command: string, extensions: string[]): boolean {
76
+ const ext = path.extname(command).toLowerCase();
77
+ if (!ext) return false;
78
+ return extensions.some(candidate => candidate.toLowerCase() === ext);
79
+ }
80
+
81
+ async function resolveWindowsCommandPath(
82
+ command: string,
83
+ cwd: string,
84
+ env: Record<string, string | undefined>,
85
+ ): Promise<string | null> {
86
+ const extensions = getWindowsPathExt(env);
87
+ if (hasExecutableExtension(command, extensions)) return command;
88
+
89
+ const candidates = extensions.map(ext => `${command}${ext}`);
90
+ if (hasPathSegment(command)) {
91
+ for (const candidate of candidates) {
92
+ const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
93
+ if (await fileExists(resolved)) return resolved;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ const pathValue = getCaseInsensitiveEnv(env, "PATH");
99
+ if (!pathValue) return null;
100
+ for (const dir of pathValue.split(";")) {
101
+ if (!dir) continue;
102
+ for (const candidate of candidates) {
103
+ const resolved = path.join(dir, candidate);
104
+ if (await fileExists(resolved)) return resolved;
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+
110
+ function quoteCmdArg(value: string): string {
111
+ if (value.length === 0) return '""';
112
+ let result = '"';
113
+ for (const char of value) {
114
+ if (char === '"') {
115
+ result += '^"';
116
+ } else if (char === "^") {
117
+ result += "^^";
118
+ } else if (char === "%") {
119
+ result += "^%";
120
+ } else {
121
+ result += char;
122
+ }
123
+ }
124
+ return `${result}"`;
125
+ }
126
+
127
+ function isWindowsBatchCommand(command: string): boolean {
128
+ return WINDOWS_BATCH_EXTENSIONS.has(path.extname(command).toLowerCase());
129
+ }
130
+
131
+ function resolveComSpec(env: Record<string, string | undefined>): string {
132
+ const comspec = getCaseInsensitiveEnv(env, "COMSPEC");
133
+ return comspec && comspec.length > 0 ? comspec : "cmd.exe";
134
+ }
135
+
136
+ /** Resolve the subprocess argv used to launch an MCP stdio server. */
137
+ export async function resolveStdioSpawnCommand(
138
+ config: MCPStdioServerConfig,
139
+ options: ResolveStdioSpawnOptions,
140
+ ): Promise<StdioSpawnCommand> {
141
+ const args = config.args ?? [];
142
+ if (options.platform !== "win32") return { cmd: [config.command, ...args] };
143
+
144
+ const resolvedCommand =
145
+ (await resolveWindowsCommandPath(config.command, options.cwd, options.env)) ?? config.command;
146
+ if (!isWindowsBatchCommand(resolvedCommand)) return { cmd: [resolvedCommand, ...args] };
147
+
148
+ return {
149
+ cmd: [resolveComSpec(options.env), "/d", "/s", "/c", [resolvedCommand, ...args].map(quoteCmdArg).join(" ")],
150
+ };
151
+ }
152
+
22
153
  /** Minimal write surface of `Subprocess.stdin` we need for framed sends. */
23
154
  interface FrameSink {
24
155
  write(chunk: string): unknown;
@@ -100,15 +231,20 @@ export class StdioTransport implements MCPTransport {
100
231
  async connect(): Promise<void> {
101
232
  if (this.#connected) return;
102
233
 
103
- const args = this.config.args ?? [];
104
234
  const env = {
105
235
  ...Bun.env,
106
236
  ...this.config.env,
107
237
  };
238
+ const cwd = this.config.cwd ?? getProjectDir();
239
+ const spawnCommand = await resolveStdioSpawnCommand(this.config, {
240
+ cwd,
241
+ env,
242
+ platform: process.platform,
243
+ });
108
244
 
109
245
  this.#process = spawn({
110
- cmd: [this.config.command, ...args],
111
- cwd: this.config.cwd ?? getProjectDir(),
246
+ cmd: spawnCommand.cmd,
247
+ cwd,
112
248
  env,
113
249
  stdin: "pipe",
114
250
  stdout: "pipe",
@@ -58,16 +58,72 @@ function buildMatchKeys(keys: readonly KeyId[]): Set<string> {
58
58
  const BRACKETED_PASTE_START = "\x1b[200~";
59
59
  const BRACKETED_PASTE_END = "\x1b[201~";
60
60
  const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
61
+ const BRACKETED_IMAGE_PATH_BOUNDARY_REGEX = /\.(?:png|jpe?g|gif|webp)(?=$|["']?\s)/gi;
62
+ const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
61
63
 
62
- export function extractBracketedImagePastePath(data: string): string | undefined {
64
+ function isPastedPathSeparator(char: string | undefined): boolean {
65
+ return char === undefined || char === " " || char === "\t" || char === "\r" || char === "\n";
66
+ }
67
+
68
+ function imagePathBoundaryEnd(payload: string, segmentStart: number, extensionEnd: number): number | undefined {
69
+ const quote = payload[segmentStart];
70
+ const afterExtension = payload[extensionEnd];
71
+ if (quote === '"' || quote === "'") {
72
+ return afterExtension === quote && isPastedPathSeparator(payload[extensionEnd + 1])
73
+ ? extensionEnd + 1
74
+ : undefined;
75
+ }
76
+ if (isPastedPathSeparator(afterExtension)) return extensionEnd;
77
+ return undefined;
78
+ }
79
+
80
+ function normalizePastedImagePath(path: string): string {
81
+ const trimmed = path.trim();
82
+ const first = trimmed[0];
83
+ const last = trimmed[trimmed.length - 1];
84
+ const unquoted =
85
+ trimmed.length > 1 && (first === '"' || first === "'") && last === first ? trimmed.slice(1, -1) : trimmed;
86
+ return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1");
87
+ }
88
+
89
+ export function extractBracketedImagePastePaths(data: string): string[] | undefined {
63
90
  if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
64
91
  const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
65
92
  if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
66
93
 
67
94
  const pasted = data.slice(BRACKETED_PASTE_START.length, endIndex).trim();
68
- if (!pasted || /[\r\n]/.test(pasted)) return undefined;
69
- if (!BRACKETED_IMAGE_PATH_REGEX.test(pasted)) return undefined;
70
- return pasted;
95
+ if (!pasted) return undefined;
96
+
97
+ const paths: string[] = [];
98
+ let segmentStart = 0;
99
+ BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.lastIndex = 0;
100
+ for (
101
+ let match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted);
102
+ match;
103
+ match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted)
104
+ ) {
105
+ const extensionEnd = match.index + match[0].length;
106
+ const boundaryEnd = imagePathBoundaryEnd(pasted, segmentStart, extensionEnd);
107
+ if (boundaryEnd === undefined) continue;
108
+
109
+ const path = normalizePastedImagePath(pasted.slice(segmentStart, boundaryEnd));
110
+ if (!path || !BRACKETED_IMAGE_PATH_REGEX.test(path)) return undefined;
111
+ paths.push(path);
112
+
113
+ segmentStart = boundaryEnd;
114
+ while (segmentStart < pasted.length && isPastedPathSeparator(pasted[segmentStart])) {
115
+ segmentStart++;
116
+ }
117
+ BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.lastIndex = segmentStart;
118
+ }
119
+
120
+ if (paths.length === 0 || segmentStart !== pasted.length) return undefined;
121
+ return paths;
122
+ }
123
+
124
+ export function extractBracketedImagePastePath(data: string): string | undefined {
125
+ const paths = extractBracketedImagePastePaths(data);
126
+ return paths?.length === 1 ? paths[0] : undefined;
71
127
  }
72
128
 
73
129
  /**
@@ -111,8 +167,8 @@ export class CustomEditor extends Editor {
111
167
  onCopyPrompt?: () => void;
112
168
  /** Called when the configured image-paste shortcut is pressed. */
113
169
  onPasteImage?: () => Promise<boolean>;
114
- /** Called when a bracketed paste contains exactly one image-file path. */
115
- onPasteImagePath?: (path: string) => void;
170
+ /** Called when a bracketed paste contains one or more image-file paths. */
171
+ onPasteImagePath?: (path: string) => void | Promise<void>;
116
172
  /** Called when the configured raw text-paste shortcut is pressed. */
117
173
  onPasteTextRaw?: () => void;
118
174
  /** Called when the configured dequeue shortcut is pressed. */
@@ -188,9 +244,13 @@ export class CustomEditor extends Editor {
188
244
  return;
189
245
  }
190
246
 
191
- const pastedImagePath = extractBracketedImagePastePath(data);
192
- if (pastedImagePath && this.onPasteImagePath) {
193
- this.onPasteImagePath(pastedImagePath);
247
+ const pastedImagePaths = extractBracketedImagePastePaths(data);
248
+ if (pastedImagePaths && this.onPasteImagePath) {
249
+ void (async () => {
250
+ for (const path of pastedImagePaths) {
251
+ await this.onPasteImagePath?.(path);
252
+ }
253
+ })();
194
254
  return;
195
255
  }
196
256
 
@@ -7,7 +7,11 @@ interface FrozenRender {
7
7
  lines: string[];
8
8
  generation: number;
9
9
  appendOnly: boolean;
10
- volatile: boolean;
10
+ /**
11
+ * Frames remaining until a block that rewrote an interior row may re-earn
12
+ * append-only status. `0` means the block is not under rewrite suspicion.
13
+ */
14
+ volatileCooldown: number;
11
15
  }
12
16
 
13
17
  interface SnapshotCarrier {
@@ -51,10 +55,41 @@ function stripPlainBlankEdges(lines: string[]): string[] {
51
55
 
52
56
  interface LiveCommitState {
53
57
  appendOnly: boolean;
54
- volatile: boolean;
58
+ volatileCooldown: number;
55
59
  safeLength: number;
56
60
  }
57
61
 
62
+ /**
63
+ * Render frames a block must stay clean (static or append-shaped) after an
64
+ * interior rewrite before its rows become committable again. A one-off
65
+ * re-layout (a codespan finalizing across a wrap boundary, a paragraph
66
+ * re-parsed as a heading) only suspends commits briefly — the pinned emitter
67
+ * appends from the stalled high-water mark, so the gap backfills contiguously
68
+ * once the block re-earns append-only. Periodic animations (a spinner rewrites
69
+ * its row every few frames) keep resetting the countdown and never re-earn it,
70
+ * so genuinely volatile blocks stay deferred. Frames arrive at most at the
71
+ * TUI's 30 Hz render cadence, so 30 frames ≈ 1s of clean streaming.
72
+ */
73
+ const VOLATILE_REARM_FRAMES = 30;
74
+
75
+ /**
76
+ * Visible-content form of a row: SGR/OSC bytes and trailing pad spaces are
77
+ * write framing, not content. A styled line's closing escape moves when the
78
+ * line stops being the last of its span (a wrapped thinking paragraph growing
79
+ * by one row), and width-padded rows shift their trailing spaces as text
80
+ * grows; both leave the on-screen cells identical and must not count as a
81
+ * rewrite of a committed-candidate row. Committed scrollback rows are written
82
+ * with a full SGR/OSC reset terminator, so escape-placement drift between
83
+ * visually identical renders cannot bleed styles across rows.
84
+ */
85
+ function normalizeRow(line: string): string {
86
+ return Bun.stripANSI(line).trimEnd();
87
+ }
88
+
89
+ function rowsVisiblyEqual(prev: string, cur: string): boolean {
90
+ return prev === cur || normalizeRow(prev) === normalizeRow(cur);
91
+ }
92
+
58
93
  function hasValidSnapshot(
59
94
  snapshot: FrozenRender | undefined,
60
95
  width: number,
@@ -66,14 +101,14 @@ function hasValidSnapshot(
66
101
  function commonPrefixLength(prev: string[], cur: string[]): number {
67
102
  const limit = Math.min(prev.length, cur.length);
68
103
  let i = 0;
69
- while (i < limit && prev[i] === cur[i]) i++;
104
+ while (i < limit && rowsVisiblyEqual(prev[i]!, cur[i]!)) i++;
70
105
  return i;
71
106
  }
72
107
 
73
108
  function commonSuffixLength(prev: string[], cur: string[], prefixLength: number): number {
74
109
  const limit = Math.min(prev.length - prefixLength, cur.length - prefixLength);
75
110
  let i = 0;
76
- while (i < limit && prev[prev.length - 1 - i] === cur[cur.length - 1 - i]) i++;
111
+ while (i < limit && rowsVisiblyEqual(prev[prev.length - 1 - i]!, cur[cur.length - 1 - i]!)) i++;
77
112
  return i;
78
113
  }
79
114
 
@@ -84,42 +119,56 @@ function deriveLiveCommitState(
84
119
  generation: number,
85
120
  ): LiveCommitState {
86
121
  let appendOnly = false;
87
- let volatile = false;
122
+ let volatileCooldown = 0;
88
123
  if (hasValidSnapshot(previous, width, generation)) {
89
124
  appendOnly = previous.appendOnly;
90
- volatile = previous.volatile;
125
+ volatileCooldown = previous.volatileCooldown;
91
126
 
92
127
  const prefixLength = commonPrefixLength(previous.lines, current);
93
128
  const staticRender = prefixLength === previous.lines.length && prefixLength === current.length;
129
+ let cleanFrame = true;
94
130
  if (!staticRender) {
95
131
  const suffixLength = commonSuffixLength(previous.lines, current, prefixLength);
96
132
  // Append-only growth never rewrites a row that may already have scrolled
97
- // into native scrollback; it only grows the block at/near its tail. Three
133
+ // into native scrollback; it only grows the block at/near its tail. Four
98
134
  // shapes qualify: a pure bottom append, an insertion above stable trailing
99
- // chrome (a streaming tool's footer/border), and an in-place extension of
100
- // the current line by one streamed token (line count unchanged). The first
101
- // two preserve every previous row across a matching prefix + suffix; the
102
- // last leaves a single divergent previous row that the current row merely
103
- // lengthens. A divergent interior row that is genuinely rewritten means the
104
- // block re-laid-out committed contentvolatile, and never committed.
135
+ // chrome (a streaming tool's footer/border), an in-place extension of the
136
+ // current line by one streamed token (line count unchanged), and a
137
+ // wrap-shrink of the current line where its last word grew past the wrap
138
+ // column and moved down onto an appended row. The first two preserve every
139
+ // previous row across a matching prefix + suffix; the last two leave a
140
+ // single divergent previous rowthe block's in-flight bottom line, which
141
+ // cannot have been committed (commits stop at the viewport top and the
142
+ // bottom line is by definition on screen). Any other divergent interior
143
+ // row means the block re-laid-out committed-candidate content — a rewrite,
144
+ // which suspends commits until the block re-earns append-only.
105
145
  const preservedEveryRow = prefixLength + suffixLength >= previous.lines.length;
106
- const tailExtendedInPlace =
146
+ let tailExtendedInPlace = false;
147
+ if (
148
+ !preservedEveryRow &&
107
149
  prefixLength + suffixLength === previous.lines.length - 1 &&
108
- prefixLength < current.length &&
109
- current[prefixLength]!.startsWith(previous.lines[prefixLength]!);
110
- if ((preservedEveryRow || tailExtendedInPlace) && current.length >= previous.lines.length && !volatile) {
111
- appendOnly = true;
112
- } else if (!preservedEveryRow && !tailExtendedInPlace) {
113
- volatile = true;
150
+ prefixLength < current.length
151
+ ) {
152
+ const prevTail = normalizeRow(previous.lines[prefixLength]!);
153
+ const curTail = normalizeRow(current[prefixLength]!);
154
+ tailExtendedInPlace =
155
+ curTail.startsWith(prevTail) || (current.length > previous.lines.length && prevTail.startsWith(curTail));
156
+ }
157
+ if ((preservedEveryRow || tailExtendedInPlace) && current.length >= previous.lines.length) {
158
+ if (volatileCooldown === 0) appendOnly = true;
159
+ } else {
160
+ cleanFrame = false;
114
161
  appendOnly = false;
162
+ volatileCooldown = VOLATILE_REARM_FRAMES;
115
163
  }
116
164
  }
165
+ if (cleanFrame && volatileCooldown > 0) volatileCooldown--;
117
166
  }
118
167
 
119
168
  return {
120
169
  appendOnly,
121
- volatile,
122
- safeLength: volatile ? 0 : appendOnly ? current.length : 0,
170
+ volatileCooldown,
171
+ safeLength: appendOnly ? current.length : 0,
123
172
  };
124
173
  }
125
174
 
@@ -163,8 +212,11 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
163
212
  #nativeScrollbackLiveRegionStart: number | undefined;
164
213
  // Local line index up to which the leading run of live blocks is safe to
165
214
  // commit. Finalized blocks contribute their full frozen body; still-live
166
- // blocks contribute only after their stripped render has been observed
167
- // growing without changing a previously rendered interior row.
215
+ // blocks contribute only while their render has been observed growing
216
+ // without visibly rewriting a previously rendered interior row (escape
217
+ // placement and pad drift are ignored). A rewrite suspends the block's
218
+ // contribution until it re-earns append-only via VOLATILE_REARM_FRAMES
219
+ // clean frames; the pinned emitter then backfills the stalled gap.
168
220
  #nativeScrollbackCommitSafeEnd: number | undefined;
169
221
 
170
222
  override invalidate(): void {
@@ -265,7 +317,7 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
265
317
  lines: contribution,
266
318
  generation: this.#generation,
267
319
  appendOnly: liveCommitState?.appendOnly ?? false,
268
- volatile: liveCommitState?.volatile ?? false,
320
+ volatileCooldown: liveCommitState?.volatileCooldown ?? 0,
269
321
  };
270
322
  }
271
323
  }
@@ -191,7 +191,7 @@ export class InputController {
191
191
  this.ctx.keybindings.getKeys("app.clipboard.pasteImage"),
192
192
  );
193
193
  this.ctx.editor.onPasteImage = () => this.handleImagePaste();
194
- this.ctx.editor.onPasteImagePath = path => void this.handleImagePathPaste(path);
194
+ this.ctx.editor.onPasteImagePath = path => this.handleImagePathPaste(path);
195
195
  this.ctx.editor.setActionKeys(
196
196
  "app.clipboard.pasteTextRaw",
197
197
  this.ctx.keybindings.getKeys("app.clipboard.pasteTextRaw"),
@@ -36,7 +36,7 @@ import {
36
36
  import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
37
37
  import type { OAuthCredential } from "../../session/auth-storage";
38
38
  import { shortenPath } from "../../tools/render-utils";
39
- import { urlHyperlink } from "../../tui";
39
+ import { urlHyperlinkAlways } from "../../tui";
40
40
  import { openPath } from "../../utils/open";
41
41
  import { ChatBlock } from "../components/chat-block";
42
42
  import { MCPAddWizard } from "../components/mcp-add-wizard";
@@ -63,7 +63,7 @@ export class MCPAuthorizationLinkPrompt implements Component {
63
63
  invalidate(): void {}
64
64
 
65
65
  render(_width: number): string[] {
66
- const link = urlHyperlink(this.#url, "Click here to authorize");
66
+ const link = urlHyperlinkAlways(this.#url, "Click here to authorize");
67
67
  return [
68
68
  ` ${theme.fg("success", "Open authorization URL:")}`,
69
69
  ` ${theme.fg("accent", link)}`,