@oh-my-pi/pi-coding-agent 13.5.7 → 13.6.0

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 (49) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/package.json +7 -7
  3. package/src/cli/args.ts +7 -0
  4. package/src/cli/stats-cli.ts +5 -0
  5. package/src/config/model-registry.ts +99 -9
  6. package/src/config/settings-schema.ts +22 -2
  7. package/src/extensibility/extensions/types.ts +2 -0
  8. package/src/internal-urls/docs-index.generated.ts +2 -2
  9. package/src/internal-urls/index.ts +2 -1
  10. package/src/internal-urls/mcp-protocol.ts +156 -0
  11. package/src/internal-urls/router.ts +1 -1
  12. package/src/internal-urls/types.ts +3 -3
  13. package/src/ipy/prelude.py +1 -0
  14. package/src/mcp/client.ts +235 -2
  15. package/src/mcp/index.ts +1 -1
  16. package/src/mcp/manager.ts +399 -5
  17. package/src/mcp/oauth-flow.ts +26 -1
  18. package/src/mcp/smithery-auth.ts +104 -0
  19. package/src/mcp/smithery-connect.ts +145 -0
  20. package/src/mcp/smithery-registry.ts +455 -0
  21. package/src/mcp/types.ts +140 -0
  22. package/src/modes/components/footer.ts +10 -4
  23. package/src/modes/components/settings-defs.ts +15 -1
  24. package/src/modes/components/status-line/git-utils.ts +42 -0
  25. package/src/modes/components/status-line/presets.ts +6 -6
  26. package/src/modes/components/status-line/segments.ts +27 -4
  27. package/src/modes/components/status-line/types.ts +2 -0
  28. package/src/modes/components/status-line-segment-editor.ts +1 -0
  29. package/src/modes/components/status-line.ts +109 -5
  30. package/src/modes/controllers/command-controller.ts +12 -2
  31. package/src/modes/controllers/extension-ui-controller.ts +12 -21
  32. package/src/modes/controllers/mcp-command-controller.ts +577 -14
  33. package/src/modes/controllers/selector-controller.ts +5 -0
  34. package/src/modes/theme/theme.ts +6 -0
  35. package/src/prompts/tools/hashline.md +4 -3
  36. package/src/sdk.ts +115 -3
  37. package/src/session/agent-session.ts +19 -4
  38. package/src/session/session-manager.ts +17 -5
  39. package/src/slash-commands/builtin-registry.ts +10 -0
  40. package/src/task/executor.ts +37 -3
  41. package/src/task/index.ts +37 -5
  42. package/src/task/isolation-backend.ts +72 -0
  43. package/src/task/render.ts +6 -1
  44. package/src/task/types.ts +1 -0
  45. package/src/task/worktree.ts +67 -5
  46. package/src/tools/index.ts +1 -1
  47. package/src/tools/path-utils.ts +2 -1
  48. package/src/tools/read.ts +3 -7
  49. package/src/utils/open.ts +1 -1
@@ -0,0 +1,72 @@
1
+ import { projfsOverlayProbe } from "@oh-my-pi/pi-natives";
2
+ import { Snowflake } from "@oh-my-pi/pi-utils";
3
+ import { cleanupProjfsOverlay, ensureProjfsOverlay, isProjfsUnavailableError } from "./worktree";
4
+
5
+ export type TaskIsolationMode = "none" | "worktree" | "fuse-overlay" | "fuse-projfs";
6
+
7
+ export interface IsolationBackendResolution {
8
+ effectiveIsolationMode: TaskIsolationMode;
9
+ warning: string;
10
+ }
11
+
12
+ export async function resolveIsolationBackendForTaskExecution(
13
+ requestedMode: TaskIsolationMode,
14
+ isIsolated: boolean,
15
+ repoRoot: string | null,
16
+ platform: NodeJS.Platform = process.platform,
17
+ ): Promise<IsolationBackendResolution> {
18
+ let effectiveIsolationMode = requestedMode;
19
+ let warning = "";
20
+ if (!(isIsolated && repoRoot)) {
21
+ return { effectiveIsolationMode, warning };
22
+ }
23
+
24
+ if (requestedMode === "fuse-overlay" && platform === "win32") {
25
+ effectiveIsolationMode = "worktree";
26
+ warning =
27
+ '<system-notification>fuse-overlay isolation is unavailable on Windows. Use task.isolation.mode = "fuse-projfs" for ProjFS. Falling back to worktree isolation.</system-notification>';
28
+ return { effectiveIsolationMode, warning };
29
+ }
30
+
31
+ if (requestedMode === "fuse-projfs" && platform !== "win32") {
32
+ effectiveIsolationMode = "worktree";
33
+ warning =
34
+ "<system-notification>fuse-projfs isolation is only available on Windows. Falling back to worktree isolation.</system-notification>";
35
+ return { effectiveIsolationMode, warning };
36
+ }
37
+
38
+ if (!(requestedMode === "fuse-projfs" && platform === "win32")) {
39
+ return { effectiveIsolationMode, warning };
40
+ }
41
+
42
+ const probe = projfsOverlayProbe();
43
+ if (!probe.available) {
44
+ effectiveIsolationMode = "worktree";
45
+ const reason = probe.reason ? ` Reason: ${probe.reason}` : "";
46
+ warning = `<system-notification>ProjFS is unavailable on this host. Falling back to worktree isolation.${reason}</system-notification>`;
47
+ return { effectiveIsolationMode, warning };
48
+ }
49
+
50
+ const probeIsolationId = `probe-${Snowflake.next()}`;
51
+ let probeIsolationDir: string | null = null;
52
+ try {
53
+ probeIsolationDir = await ensureProjfsOverlay(repoRoot, probeIsolationId);
54
+ } catch (err) {
55
+ if (isProjfsUnavailableError(err)) {
56
+ effectiveIsolationMode = "worktree";
57
+ const raw = err instanceof Error ? err.message : String(err);
58
+ const reason = raw.replace(/^PROJFS_UNAVAILABLE:\s*/, "");
59
+ const detail = reason ? ` Reason: ${reason}` : "";
60
+ warning = `<system-notification>ProjFS prerequisites are unavailable for this repository. Falling back to worktree isolation.${detail}</system-notification>`;
61
+ } else {
62
+ const message = err instanceof Error ? err.message : String(err);
63
+ throw new Error(`ProjFS isolation initialization failed. ${message}`);
64
+ }
65
+ } finally {
66
+ if (probeIsolationDir) {
67
+ await cleanupProjfsOverlay(probeIsolationDir);
68
+ }
69
+ }
70
+
71
+ return { effectiveIsolationMode, warning };
72
+ }
@@ -783,6 +783,11 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
783
783
 
784
784
  lines.push(...renderTaskSection(result.task, continuePrefix, expanded, theme));
785
785
 
786
+ if (aborted && result.abortReason) {
787
+ lines.push(
788
+ `${continuePrefix}${theme.fg("error", theme.status.aborted)} ${theme.fg("dim", truncateToWidth(replaceTabs(result.abortReason), 80))}`,
789
+ );
790
+ }
786
791
  // Check for review result (submit_result with review schema + report_finding)
787
792
  const completeData = result.extractedToolData?.submit_result as Array<{ data: unknown }> | undefined;
788
793
  const reportFindingData = normalizeReportFindings(result.extractedToolData?.report_finding);
@@ -873,7 +878,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
873
878
  }
874
879
 
875
880
  // Error message
876
- if (result.error && (!success || mergeFailed)) {
881
+ if (result.error && (!success || mergeFailed) && (!aborted || result.error !== result.abortReason)) {
877
882
  lines.push(`${continuePrefix}${theme.fg(mergeFailed ? "warning" : "error", truncateToWidth(result.error, 70))}`);
878
883
  }
879
884
 
package/src/task/types.ts CHANGED
@@ -169,6 +169,7 @@ export interface SingleResult {
169
169
  modelOverride?: string | string[];
170
170
  error?: string;
171
171
  aborted?: boolean;
172
+ abortReason?: string;
172
173
  /** Aggregated usage from the subprocess, accumulated incrementally from message_end events. */
173
174
  usage?: Usage;
174
175
  /** Output path for the task result */
@@ -2,6 +2,7 @@ import type { Dirent } from "node:fs";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import path from "node:path";
5
+ import { projfsOverlayStart, projfsOverlayStop } from "@oh-my-pi/pi-natives";
5
6
  import { getWorktreeDir, isEnoent, logger, Snowflake } from "@oh-my-pi/pi-utils";
6
7
  import { $ } from "bun";
7
8
 
@@ -37,6 +38,17 @@ export async function getRepoRoot(cwd: string): Promise<string> {
37
38
  return repoRoot;
38
39
  }
39
40
 
41
+ const PROJFS_UNAVAILABLE_PREFIX = "PROJFS_UNAVAILABLE:";
42
+ const GIT_NO_INDEX_NULL_PATH = process.platform === "win32" ? "NUL" : "/dev/null";
43
+
44
+ export function isProjfsUnavailableError(err: unknown): boolean {
45
+ return err instanceof Error && err.message.includes(PROJFS_UNAVAILABLE_PREFIX);
46
+ }
47
+
48
+ export function getGitNoIndexNullPath(): string {
49
+ return GIT_NO_INDEX_NULL_PATH;
50
+ }
51
+
40
52
  export async function ensureWorktree(baseCwd: string, id: string): Promise<string> {
41
53
  const repoRoot = await getRepoRoot(baseCwd);
42
54
  const encodedProject = getEncodedProjectName(repoRoot);
@@ -248,9 +260,10 @@ async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise
248
260
  const baselineUntracked = new Set(rb.untracked);
249
261
  const newUntracked = currentUntracked.filter(entry => !baselineUntracked.has(entry));
250
262
  if (newUntracked.length > 0) {
263
+ const nullPath = getGitNoIndexNullPath();
251
264
  const untrackedDiffs = await Promise.all(
252
265
  newUntracked.map(entry =>
253
- $`git diff --binary --no-index /dev/null ${entry}`.cwd(repoDir).quiet().nothrow().text(),
266
+ $`git diff --binary --no-index ${nullPath} ${entry}`.cwd(repoDir).quiet().nothrow().text(),
254
267
  ),
255
268
  );
256
269
  parts.push(...untrackedDiffs.filter(d => d.trim()));
@@ -273,9 +286,10 @@ async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise
273
286
 
274
287
  if (newUntracked.length === 0) return diff;
275
288
 
289
+ const nullPath = getGitNoIndexNullPath();
276
290
  const untrackedDiffs = await Promise.all(
277
291
  newUntracked.map(entry =>
278
- $`git diff --binary --no-index /dev/null ${entry}`.cwd(repoDir).quiet().nothrow().text(),
292
+ $`git diff --binary --no-index ${nullPath} ${entry}`.cwd(repoDir).quiet().nothrow().text(),
279
293
  ),
280
294
  );
281
295
  return `${diff}${diff && !diff.endsWith("\n") ? "\n" : ""}${untrackedDiffs.join("\n")}`;
@@ -371,10 +385,14 @@ export async function cleanupWorktree(dir: string): Promise<void> {
371
385
  }
372
386
 
373
387
  // ═══════════════════════════════════════════════════════════════════════════
374
- // Fuse-overlay isolation
388
+ // Fuse-overlay isolation (Unix)
375
389
  // ═══════════════════════════════════════════════════════════════════════════
376
390
 
377
391
  export async function ensureFuseOverlay(baseCwd: string, id: string): Promise<string> {
392
+ if (process.platform === "win32") {
393
+ throw new Error('fuse-overlay isolation is unsupported on Windows. Use task.isolation.mode = "fuse-projfs".');
394
+ }
395
+
378
396
  const repoRoot = await getRepoRoot(baseCwd);
379
397
  const encodedProject = getEncodedProjectName(repoRoot);
380
398
  const baseDir = getWorktreeDir(encodedProject, id);
@@ -382,13 +400,13 @@ export async function ensureFuseOverlay(baseCwd: string, id: string): Promise<st
382
400
  const workDir = path.join(baseDir, "work");
383
401
  const mergedDir = path.join(baseDir, "merged");
384
402
 
385
- // Clean up any stale mount at this path
403
+ // Clean up any stale mount at this path (linux only)
386
404
  const fusermount = Bun.which("fusermount3") ?? Bun.which("fusermount");
387
405
  if (fusermount) {
388
406
  await $`${fusermount} -u ${mergedDir}`.quiet().nothrow();
389
407
  }
390
- await fs.rm(baseDir, { recursive: true, force: true });
391
408
 
409
+ await fs.rm(baseDir, { recursive: true, force: true });
392
410
  await fs.mkdir(upperDir, { recursive: true });
393
411
  await fs.mkdir(workDir, { recursive: true });
394
412
  await fs.mkdir(mergedDir, { recursive: true });
@@ -426,6 +444,50 @@ export async function cleanupFuseOverlay(mergedDir: string): Promise<void> {
426
444
  }
427
445
  }
428
446
 
447
+ // ═══════════════════════════════════════════════════════════════════════════
448
+ // ProjFS isolation (Windows)
449
+ // ═══════════════════════════════════════════════════════════════════════════
450
+
451
+ export async function ensureProjfsOverlay(baseCwd: string, id: string): Promise<string> {
452
+ if (process.platform !== "win32") {
453
+ throw new Error("fuse-projfs isolation is only available on Windows.");
454
+ }
455
+
456
+ const repoRoot = await getRepoRoot(baseCwd);
457
+ const encodedProject = getEncodedProjectName(repoRoot);
458
+ const baseDir = getWorktreeDir(encodedProject, id);
459
+ const mergedDir = path.join(baseDir, "merged");
460
+
461
+ await fs.rm(baseDir, { recursive: true, force: true });
462
+ await fs.mkdir(mergedDir, { recursive: true });
463
+ try {
464
+ projfsOverlayStart(repoRoot, mergedDir);
465
+ return mergedDir;
466
+ } catch (err) {
467
+ await fs.rm(baseDir, { recursive: true, force: true });
468
+ throw err;
469
+ }
470
+ }
471
+
472
+ export async function cleanupProjfsOverlay(mergedDir: string): Promise<void> {
473
+ try {
474
+ if (process.platform === "win32") {
475
+ try {
476
+ projfsOverlayStop(mergedDir);
477
+ } catch (err) {
478
+ logger.warn("ProjFS overlay stop failed during cleanup", {
479
+ mergedDir,
480
+ error: err instanceof Error ? err.message : String(err),
481
+ });
482
+ }
483
+ }
484
+ } finally {
485
+ // baseDir is the parent of the merged directory
486
+ const baseDir = path.dirname(mergedDir);
487
+ await fs.rm(baseDir, { recursive: true, force: true });
488
+ }
489
+ }
490
+
429
491
  // ═══════════════════════════════════════════════════════════════════════════
430
492
  // Branch-mode isolation
431
493
  // ═══════════════════════════════════════════════════════════════════════════
@@ -129,7 +129,7 @@ export interface ToolSession {
129
129
  modelRegistry?: import("../config/model-registry").ModelRegistry;
130
130
  /** MCP manager for proxying MCP calls through parent */
131
131
  mcpManager?: import("../mcp/manager").MCPManager;
132
- /** Internal URL router for agent:// and skill:// URLs */
132
+ /** Internal URL router for protocols like agent://, skill://, and mcp:// */
133
133
  internalRouter?: InternalUrlRouter;
134
134
  /** Agent output manager for unique agent:// IDs across task invocations */
135
135
  agentOutputManager?: AgentOutputManager;
@@ -56,7 +56,8 @@ function normalizeAtPrefix(filePath: string): string {
56
56
  withoutAt.startsWith("artifact://") ||
57
57
  withoutAt.startsWith("skill://") ||
58
58
  withoutAt.startsWith("rule://") ||
59
- withoutAt.startsWith("local://")
59
+ withoutAt.startsWith("local://") ||
60
+ withoutAt.startsWith("mcp://")
60
61
  ) {
61
62
  return withoutAt;
62
63
  }
package/src/tools/read.ts CHANGED
@@ -582,7 +582,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
582
582
 
583
583
  const displayMode = resolveFileDisplayMode(this.session);
584
584
 
585
- // Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://)
585
+ // Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://)
586
586
  const internalRouter = this.session.internalRouter;
587
587
  if (internalRouter?.canHandle(readPath)) {
588
588
  return this.#handleInternalUrl(readPath, offset, limit);
@@ -841,7 +841,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
841
841
  }
842
842
 
843
843
  /**
844
- * Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://).
844
+ * Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://).
845
845
  * Supports pagination via offset/limit but rejects them when query extraction is used.
846
846
  */
847
847
  async #handleInternalUrl(url: string, offset?: number, limit?: number): Promise<AgentToolResult<ReadToolDetails>> {
@@ -859,13 +859,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
859
859
  const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
860
860
  const hasPathExtraction = parsed.pathname && parsed.pathname !== "/" && parsed.pathname !== "";
861
861
  const queryParam = parsed.searchParams.get("q");
862
- const hasQueryExtraction = queryParam !== null && queryParam !== "";
862
+ const hasQueryExtraction = scheme === "agent" && queryParam !== null && queryParam !== "";
863
863
  const hasExtraction = scheme === "agent" && (hasPathExtraction || hasQueryExtraction);
864
864
 
865
- if (scheme !== "agent" && hasQueryExtraction) {
866
- throw new ToolError("Only agent:// URLs support ?q= query extraction");
867
- }
868
-
869
865
  // Reject offset/limit with query extraction
870
866
  if (hasExtraction && (offset !== undefined || limit !== undefined)) {
871
867
  throw new ToolError("Cannot combine query extraction with offset/limit");
package/src/utils/open.ts CHANGED
@@ -13,7 +13,7 @@ export function openPath(urlOrPath: string): void {
13
13
  break;
14
14
  }
15
15
  try {
16
- Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore", windowsHide: true });
16
+ Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
17
17
  } catch {
18
18
  // Best-effort: browser opening is non-critical
19
19
  }