Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ The built-in edit/write definitions capture their working directory, so SoL-Pi c

Action Fusion decodes `file://` targets with Node's `fileURLToPath()` before resolving the queue and hash-check path. This keeps file URLs, including percent-encoded filenames and Pi's optional `@` prefix, aligned with the file handled by the built-in mutation tool.

On Windows it applies the same drive-path conversion Pi's own resolver applies, so Git Bash, MSYS, Cygwin, and WSL targets such as `/c/src/app.ts` and home-relative `~\` paths resolve to the file the built-in mutation tool wrote. On other platforms those inputs keep their POSIX meaning.

The queue covers only fused operations registered by this SoL-Pi instance. External processes, direct built-in-tool calls outside the replacement, and unrelated extensions are not globally locked. SoL-Pi hashes the target immediately before launching `then_run` and skips the command if it observes an intervening content change.

## ObservationPack
Expand Down
21 changes: 19 additions & 2 deletions src/sol-pi/extensions/action-fusion/file-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,34 @@ import { basename, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const queueTails = new Map<string, Promise<void>>();
const WINDOWS_SHELL_DRIVE = /^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i;

function stripToolPathPrefix(filePath: string): string {
return filePath.startsWith("@") ? filePath.slice(1) : filePath;
}

/**
* Git Bash, MSYS, Cygwin, and WSL hand Pi paths like `/c/src/app.ts`. On
* Windows, Pi's built-in mutation tools convert those to a native drive path
* before touching the filesystem, so the queue and hash guard must convert them
* the same way or they address a file the mutation never wrote.
*/
export function normalizeWindowsShellPath(filePath: string): string {
if (process.platform !== "win32") return filePath;
if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\")) return filePath;
const match = WINDOWS_SHELL_DRIVE.exec(filePath);
if (!match?.[1]) return filePath;
return `${match[1].toUpperCase()}:\\${match[2]?.replaceAll("/", "\\") ?? ""}`;
}

export function resolveToolPath(cwd: string, filePath: string): string {
const stripped = stripToolPathPrefix(filePath);
const stripped = normalizeWindowsShellPath(stripToolPathPrefix(filePath));
// Pi accepts file URLs; the queue and hash guard must use the same target.
const expanded = stripped.startsWith("file://") ? fileURLToPath(stripped) : stripped;
if (expanded === "~") return homedir();
if (expanded.startsWith("~/")) return resolve(homedir(), expanded.slice(2));
if (expanded.startsWith("~/") || (process.platform === "win32" && expanded.startsWith("~\\"))) {
return resolve(homedir(), expanded.slice(2));
}
return resolve(cwd, expanded);
}

Expand Down
45 changes: 44 additions & 1 deletion tests/action-fusion-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, it } from "vitest";
import { resolveToolPath } from "../src/sol-pi/extensions/action-fusion/file-queue.ts";
import { homedir } from "node:os";
import { normalizeWindowsShellPath, resolveToolPath } from "../src/sol-pi/extensions/action-fusion/file-queue.ts";
import { createActionFusionExtension, type ActionFusionOptions } from "../src/sol-pi/extensions/action-fusion/index.ts";

const tempDirs: string[] = [];
Expand All @@ -29,6 +30,18 @@ function loadTools(options: ActionFusionOptions = {}): Map<string, ToolDefinitio
return tools;
}

/** Exercise the platform-dependent branches without a Windows runner. */
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
const original = Object.getOwnPropertyDescriptor(process, "platform");
if (!original) throw new Error("process.platform is not configurable");
Object.defineProperty(process, "platform", { ...original, value: platform });
try {
run();
} finally {
Object.defineProperty(process, "platform", original);
}
}

function context(cwd: string): ExtensionContext {
return {
cwd,
Expand All @@ -49,6 +62,36 @@ describe("Action Fusion file URL paths", () => {
expect(resolveToolPath(cwd, `@${url}`)).toBe(target);
});

it("converts Git Bash, MSYS, Cygwin, and WSL drive paths on Windows", () => {
withPlatform("win32", () => {
expect(normalizeWindowsShellPath("/c/src/app.ts")).toBe("C:\\src\\app.ts");
expect(normalizeWindowsShellPath("/mnt/d/work/notes.md")).toBe("D:\\work\\notes.md");
expect(normalizeWindowsShellPath("/cygdrive/e/x/y")).toBe("E:\\x\\y");
expect(normalizeWindowsShellPath("/c")).toBe("C:\\");
// Not a drive path: leave it alone.
expect(normalizeWindowsShellPath("/usr/local/bin/pi")).toBe("/usr/local/bin/pi");
expect(normalizeWindowsShellPath("//server/share/file.txt")).toBe("//server/share/file.txt");
expect(normalizeWindowsShellPath("C:\\already\\native.ts")).toBe("C:\\already\\native.ts");
});
});

it("leaves a POSIX path alone off Windows", () => {
withPlatform("linux", () => {
expect(normalizeWindowsShellPath("/c/src/app.ts")).toBe("/c/src/app.ts");
expect(resolveToolPath("/work", "/c/src/app.ts")).toBe("/c/src/app.ts");
});
});

it("expands a Windows home-relative path", () => {
withPlatform("win32", () => {
expect(resolveToolPath("/work", "~\\notes.txt")).toBe(resolve(homedir(), "notes.txt"));
});
withPlatform("linux", () => {
// A backslash is an ordinary filename character here.
expect(resolveToolPath("/work", "~\\notes.txt")).toBe(resolve("/work", "~\\notes.txt"));
});
});

it("preserves ordinary relative and absolute path semantics", () => {
const cwd = join(tmpdir(), "action-fusion-cwd");
const target = join(cwd, "target.txt");
Expand Down