@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.
- package/CHANGELOG.md +30 -1
- package/package.json +7 -7
- package/src/cli/args.ts +7 -0
- package/src/cli/stats-cli.ts +5 -0
- package/src/config/model-registry.ts +99 -9
- package/src/config/settings-schema.ts +22 -2
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/internal-urls/index.ts +2 -1
- package/src/internal-urls/mcp-protocol.ts +156 -0
- package/src/internal-urls/router.ts +1 -1
- package/src/internal-urls/types.ts +3 -3
- package/src/ipy/prelude.py +1 -0
- package/src/mcp/client.ts +235 -2
- package/src/mcp/index.ts +1 -1
- package/src/mcp/manager.ts +399 -5
- package/src/mcp/oauth-flow.ts +26 -1
- package/src/mcp/smithery-auth.ts +104 -0
- package/src/mcp/smithery-connect.ts +145 -0
- package/src/mcp/smithery-registry.ts +455 -0
- package/src/mcp/types.ts +140 -0
- package/src/modes/components/footer.ts +10 -4
- package/src/modes/components/settings-defs.ts +15 -1
- package/src/modes/components/status-line/git-utils.ts +42 -0
- package/src/modes/components/status-line/presets.ts +6 -6
- package/src/modes/components/status-line/segments.ts +27 -4
- package/src/modes/components/status-line/types.ts +2 -0
- package/src/modes/components/status-line-segment-editor.ts +1 -0
- package/src/modes/components/status-line.ts +109 -5
- package/src/modes/controllers/command-controller.ts +12 -2
- package/src/modes/controllers/extension-ui-controller.ts +12 -21
- package/src/modes/controllers/mcp-command-controller.ts +577 -14
- package/src/modes/controllers/selector-controller.ts +5 -0
- package/src/modes/theme/theme.ts +6 -0
- package/src/prompts/tools/hashline.md +4 -3
- package/src/sdk.ts +115 -3
- package/src/session/agent-session.ts +19 -4
- package/src/session/session-manager.ts +17 -5
- package/src/slash-commands/builtin-registry.ts +10 -0
- package/src/task/executor.ts +37 -3
- package/src/task/index.ts +37 -5
- package/src/task/isolation-backend.ts +72 -0
- package/src/task/render.ts +6 -1
- package/src/task/types.ts +1 -0
- package/src/task/worktree.ts +67 -5
- package/src/tools/index.ts +1 -1
- package/src/tools/path-utils.ts +2 -1
- package/src/tools/read.ts +3 -7
- 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
|
+
}
|
package/src/task/render.ts
CHANGED
|
@@ -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 */
|
package/src/task/worktree.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
// ═══════════════════════════════════════════════════════════════════════════
|
package/src/tools/index.ts
CHANGED
|
@@ -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
|
|
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;
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -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, {
|
|
16
|
+
Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
|
|
17
17
|
} catch {
|
|
18
18
|
// Best-effort: browser opening is non-critical
|
|
19
19
|
}
|