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

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/dist/types/config/model-registry.d.ts +13 -0
  3. package/dist/types/config/settings-schema.d.ts +0 -9
  4. package/dist/types/debug/terminal-info.d.ts +0 -1
  5. package/dist/types/extensibility/custom-tools/loader.d.ts +22 -3
  6. package/dist/types/extensibility/extensions/index.d.ts +1 -1
  7. package/dist/types/extensibility/extensions/loader.d.ts +17 -1
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
  9. package/dist/types/mcp/transports/stdio.d.ts +12 -0
  10. package/dist/types/modes/components/custom-editor.d.ts +3 -2
  11. package/dist/types/modes/components/transcript-container.d.ts +12 -26
  12. package/dist/types/sdk.d.ts +42 -2
  13. package/dist/types/task/discovery.d.ts +1 -2
  14. package/dist/types/task/executor.d.ts +16 -0
  15. package/dist/types/tiny/title-client.d.ts +1 -1
  16. package/dist/types/tools/index.d.ts +17 -0
  17. package/dist/types/tools/todo.d.ts +2 -0
  18. package/dist/types/tui/hyperlink.d.ts +8 -0
  19. package/package.json +9 -9
  20. package/src/cli/list-models.ts +5 -11
  21. package/src/config/model-registry.ts +91 -20
  22. package/src/config/settings-schema.ts +0 -10
  23. package/src/debug/terminal-info.ts +0 -3
  24. package/src/edit/diff.ts +48 -15
  25. package/src/eval/js/shared/rewrite-imports.ts +9 -1
  26. package/src/extensibility/custom-tools/loader.ts +43 -19
  27. package/src/extensibility/extensions/index.ts +1 -0
  28. package/src/extensibility/extensions/loader.ts +29 -6
  29. package/src/extensibility/plugins/legacy-pi-compat.ts +30 -6
  30. package/src/internal-urls/docs-index.generated.ts +4 -4
  31. package/src/mcp/transports/stdio.ts +139 -3
  32. package/src/modes/components/custom-editor.ts +69 -9
  33. package/src/modes/components/model-selector.ts +62 -52
  34. package/src/modes/components/transcript-container.ts +204 -125
  35. package/src/modes/controllers/event-controller.ts +0 -45
  36. package/src/modes/controllers/input-controller.ts +5 -5
  37. package/src/modes/controllers/mcp-command-controller.ts +2 -2
  38. package/src/modes/controllers/selector-controller.ts +0 -4
  39. package/src/modes/interactive-mode.ts +2 -10
  40. package/src/prompts/system/system-prompt.md +3 -3
  41. package/src/prompts/tools/bash.md +3 -3
  42. package/src/prompts/tools/todo.md +5 -1
  43. package/src/sdk.ts +138 -56
  44. package/src/ssh/ssh-executor.ts +60 -4
  45. package/src/task/discovery.ts +17 -24
  46. package/src/task/executor.ts +19 -0
  47. package/src/task/index.ts +4 -0
  48. package/src/tiny/title-client.ts +6 -3
  49. package/src/tools/index.ts +17 -0
  50. package/src/tools/todo.ts +16 -7
  51. package/src/tui/hyperlink.ts +27 -3
  52. package/src/web/search/providers/anthropic.ts +8 -2
@@ -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
 
@@ -263,21 +263,26 @@ export class ModelSelectorComponent extends Container {
263
263
  // Add bottom border
264
264
  this.addChild(new DynamicBorder());
265
265
 
266
- // Load models and do initial render
267
- this.#loadModels().then(() => {
268
- this.#buildProviderTabs();
269
- this.#updateTabBar();
270
- // Always apply the current search query — the user may have typed
271
- // while models were loading asynchronously.
272
- const currentQuery = this.#searchInput.getValue();
273
- if (currentQuery) {
274
- this.#filterModels(currentQuery);
275
- } else {
276
- this.#updateList();
277
- }
278
- // Request re-render after models are loaded
279
- this.#tui.requestRender();
280
- });
266
+ // Hydrate synchronously from the current registry snapshot so the first
267
+ // Enter after opening the selector acts on cached models instead of being
268
+ // dropped while the offline refresh promise is still pending. This stays
269
+ // on the open path, so it must remain cheap — heavy lifting lives in the
270
+ // registry's one-pass getCanonicalModelSelections.
271
+ this.#syncFromRegistryState();
272
+
273
+ // Reconcile with cached discovery state in the background. A --models
274
+ // scope is registry-independent, so the offline reload would only repeat
275
+ // the synchronous hydration above.
276
+ if (this.#scopedModels.length === 0) {
277
+ this.#modelRegistry
278
+ .refresh("offline")
279
+ .then(() => this.#syncFromRegistryState())
280
+ .catch(error => {
281
+ this.#errorMessage = error instanceof Error ? error.message : String(error);
282
+ this.#updateList();
283
+ })
284
+ .finally(() => this.#tui.requestRender());
285
+ }
281
286
  }
282
287
 
283
288
  #buildMenuRoleActions(): void {
@@ -477,37 +482,30 @@ export class ModelSelectorComponent extends Container {
477
482
 
478
483
  const candidates = models.map(item => item.model);
479
484
  this.#loadRoleModels(candidates);
480
- const canonicalRecords = this.#modelRegistry.getCanonicalModels({
485
+ const canonicalSelections = this.#modelRegistry.getCanonicalModelSelections({
481
486
  availableOnly: this.#scopedModels.length === 0,
482
487
  candidates,
483
488
  });
484
- const canonicalModels = canonicalRecords
485
- .map(record => {
486
- const selectedModel = this.#modelRegistry.resolveCanonicalModel(record.id, {
487
- availableOnly: this.#scopedModels.length === 0,
488
- candidates,
489
- });
490
- if (!selectedModel) return undefined;
491
- const searchText = [
492
- record.id,
493
- record.name,
494
- selectedModel.provider,
495
- selectedModel.id,
496
- selectedModel.name,
497
- ...record.variants.flatMap(variant => [variant.selector, variant.model.name]),
498
- ].join(" ");
499
- return {
500
- kind: "canonical" as const,
501
- id: record.id,
502
- model: selectedModel,
503
- selector: record.id,
504
- variantCount: record.variants.length,
505
- searchText,
506
- normalizedSearchText: normalizeSearchText(searchText),
507
- compactSearchText: compactSearchText(searchText),
508
- };
509
- })
510
- .filter((item): item is CanonicalModelItem => item !== undefined);
489
+ const canonicalModels = canonicalSelections.map(({ record, model: selectedModel }): CanonicalModelItem => {
490
+ const searchText = [
491
+ record.id,
492
+ record.name,
493
+ selectedModel.provider,
494
+ selectedModel.id,
495
+ selectedModel.name,
496
+ ...record.variants.flatMap(variant => [variant.selector, variant.model.name]),
497
+ ].join(" ");
498
+ return {
499
+ kind: "canonical",
500
+ id: record.id,
501
+ model: selectedModel,
502
+ selector: record.id,
503
+ variantCount: record.variants.length,
504
+ searchText,
505
+ normalizedSearchText: normalizeSearchText(searchText),
506
+ compactSearchText: compactSearchText(searchText),
507
+ };
508
+ });
511
509
 
512
510
  this.#sortModels(models);
513
511
  this.#sortCanonicalModels(canonicalModels);
@@ -523,12 +521,27 @@ export class ModelSelectorComponent extends Container {
523
521
  );
524
522
  }
525
523
 
526
- async #loadModels(): Promise<void> {
527
- if (this.#scopedModels.length === 0) {
528
- // Reload config and cached discovery state without blocking on live provider refresh
529
- await this.#modelRegistry.refresh("offline");
530
- }
524
+ /**
525
+ * Rebuild the visible model lists from the registry's in-memory state.
526
+ * Re-entrant: runs once synchronously at construction and again whenever a
527
+ * background refresh lands, so it re-applies the live search query and pins
528
+ * the highlighted item by selector — a refresh that reorders or inserts
529
+ * models must not yank the user's selection out from under a pending Enter.
530
+ */
531
+ #syncFromRegistryState(): void {
532
+ const selectedKey = this.#getSelectedItem()?.selector;
531
533
  this.#loadModelsFromCurrentRegistryState();
534
+ this.#buildProviderTabs();
535
+ this.#updateTabBar();
536
+ this.#applyTabFilter();
537
+ if (selectedKey) {
538
+ const visibleItems = this.#getVisibleItems();
539
+ const restoredIndex = visibleItems.findIndex(item => item.selector === selectedKey);
540
+ if (restoredIndex >= 0 && restoredIndex !== this.#selectedIndex) {
541
+ this.#selectedIndex = this.#coerceSelectedIndex(restoredIndex, visibleItems);
542
+ this.#updateList();
543
+ }
544
+ }
532
545
  }
533
546
 
534
547
  #buildProviderTabs(): void {
@@ -631,10 +644,7 @@ export class ModelSelectorComponent extends Container {
631
644
  // here must stay purely in-memory — do not call modelRegistry.refresh()
632
645
  // again or tab switches will pay an extra whole-registry reload after the
633
646
  // network round-trip completes.
634
- this.#loadModelsFromCurrentRegistryState();
635
- this.#buildProviderTabs();
636
- this.#updateTabBar();
637
- this.#applyTabFilter();
647
+ this.#syncFromRegistryState();
638
648
  } catch (error) {
639
649
  this.#errorMessage = error instanceof Error ? error.message : String(error);
640
650
  this.#updateList();