Skip to content
Draft
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
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,21 @@ openclaw onboard

Arc has one runtime with two surfaces:

| Surface | Role |
| --- | --- |
| Surface | Role |
| ------------------- | ------------------------------------------------------ |
| **Swift macOS app** | Flagship review workstation — diffs, queues, decisions |
| **VPS TUI** | Fast remote operator console — queue, inspect, unblock |
| **VPS TUI** | Fast remote operator console — queue, inspect, unblock |

### The Layer Model

Arc only makes sense if the layers stay clean:

| Layer | Role |
| --- | --- |
| **Arc** | product, workflow, workstation, project cockpit |
| **OpenClaw** | runtime, gateway, worktrees, worker lifecycle, durable state |
| **Claude + Codex** | worker engines that do the coding work |
| **Obsidian** | planning, notes, specs, architecture, project memory |
| Layer | Role |
| ------------------ | ------------------------------------------------------------ |
| **Arc** | product, workflow, workstation, project cockpit |
| **OpenClaw** | runtime, gateway, worktrees, worker lifecycle, durable state |
| **Claude + Codex** | worker engines that do the coding work |
| **Obsidian** | planning, notes, specs, architecture, project memory |

Obsidian should hold thinking. Arc should hold execution.

Expand Down
2 changes: 1 addition & 1 deletion docs/cockpit/FAST-TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Arc becomes the default daily surface when these are all done:

- [ ] broad product polish for strangers
- [ ] hosted-first architecture
- [ ] generalized platform abstractions
- [x] generalized platform abstractions
- [ ] multi-user shared cockpit state
- [ ] advanced memory / retrieval work
- [ ] full-editor ambitions before the review workstation is strong
7 changes: 4 additions & 3 deletions src/agents/shell-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { isWindows } from "../infra/platform.js";

export function resolvePowerShellPath(): string {
// Prefer PowerShell 7 when available; PS 5.1 lacks "&&" support.
Expand Down Expand Up @@ -40,7 +41,7 @@ export function resolvePowerShellPath(): string {
}

export function getShellConfig(): { shell: string; args: string[] } {
if (process.platform === "win32") {
if (isWindows) {
// Use PowerShell instead of cmd.exe on Windows.
// Problem: Many Windows system utilities (ipconfig, systeminfo, etc.) write
// directly to the console via WriteConsole API, bypassing stdout pipes.
Expand Down Expand Up @@ -107,7 +108,7 @@ export function detectRuntimeShell(): string | undefined {
}
}

if (process.platform === "win32") {
if (isWindows) {
if (process.env.POWERSHELL_DISTRIBUTION_CHANNEL) {
return "pwsh";
}
Expand Down Expand Up @@ -168,7 +169,7 @@ export function sanitizeBinaryOutput(text: string): string {
}

export function killProcessTree(pid: number): void {
if (process.platform === "win32") {
if (isWindows) {
try {
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
Expand Down
2 changes: 1 addition & 1 deletion src/daemon/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { resolveHomeDir } from "./paths.js";
import { execSchtasks } from "./schtasks-exec.js";

export type ExtraGatewayService = {
platform: "darwin" | "linux" | "win32";
platform: import("../infra/platform.js").SupportedPlatform;
label: string;
detail: string;
scope: "user" | "system";
Expand Down
8 changes: 5 additions & 3 deletions src/daemon/service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { PlatformRegistry, SupportedPlatform } from "../infra/platform.js";
import { isSupportedPlatform } from "../infra/platform.js";
import {
installLaunchAgent,
isLaunchAgentLoaded,
Expand Down Expand Up @@ -91,9 +93,9 @@ export function describeGatewayServiceRestart(
};
}

type SupportedGatewayServicePlatform = "darwin" | "linux" | "win32";
type SupportedGatewayServicePlatform = SupportedPlatform;

const GATEWAY_SERVICE_REGISTRY: Record<SupportedGatewayServicePlatform, GatewayService> = {
const GATEWAY_SERVICE_REGISTRY: PlatformRegistry<GatewayService> = {
darwin: {
label: "LaunchAgent",
loadedText: "loaded",
Expand Down Expand Up @@ -135,7 +137,7 @@ const GATEWAY_SERVICE_REGISTRY: Record<SupportedGatewayServicePlatform, GatewayS
function isSupportedGatewayServicePlatform(
platform: NodeJS.Platform,
): platform is SupportedGatewayServicePlatform {
return Object.hasOwn(GATEWAY_SERVICE_REGISTRY, platform);
return isSupportedPlatform(platform);
}

export function resolveGatewayService(): GatewayService {
Expand Down
14 changes: 5 additions & 9 deletions src/infra/fs-safe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
isPathInside,
isSymlinkOpenError,
} from "./path-guards.js";
import { fdLinkPaths, isWindows, supportsNoFollow } from "./platform.js";

export type SafeOpenErrorCode =
| "invalid-path"
Expand Down Expand Up @@ -48,7 +49,7 @@ export type SafeLocalReadResult = {
stat: Stats;
};

const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
const SUPPORTS_NOFOLLOW = supportsNoFollow && "O_NOFOLLOW" in fsConstants;
const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
const OPEN_WRITE_EXISTING_FLAGS =
fsConstants.O_WRONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
Expand Down Expand Up @@ -361,12 +362,7 @@ export async function resolveOpenedFileRealPathForHandle(
}
}

const fdCandidates =
process.platform === "linux"
? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`]
: process.platform === "win32"
? []
: [`/dev/fd/${handle.fd}`];
const fdCandidates = fdLinkPaths(handle.fd);
for (const fdPath of fdCandidates) {
try {
return await fs.realpath(fdPath);
Expand Down Expand Up @@ -551,7 +547,7 @@ export async function writeFileWithinRoot(params: {
encoding?: BufferEncoding;
mkdir?: boolean;
}): Promise<void> {
if (process.platform === "win32") {
if (isWindows) {
await writeFileWithinRootLegacy(params);
return;
}
Expand Down Expand Up @@ -608,7 +604,7 @@ export async function copyFileWithinRoot(params: {
}

try {
if (process.platform === "win32") {
if (isWindows) {
await copyFileWithinRootLegacy(params, source);
return;
}
Expand Down
6 changes: 4 additions & 2 deletions src/infra/gateway-processes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
import fsSync from "node:fs";
import { parseCmdScriptCommandLine } from "../daemon/cmd-argv.js";
import { isGatewayArgv, parseProcCmdline } from "./gateway-process-argv.js";
import { procCmdlinePath } from "./platform.js";
import { findGatewayPidsOnPortSync as findUnixGatewayPidsOnPortSync } from "./restart-stale-pids.js";

const WINDOWS_GATEWAY_DISCOVERY_TIMEOUT_MS = 5_000;
Expand Down Expand Up @@ -111,9 +112,10 @@ function readWindowsListeningPidsOnPortSync(port: number): number[] {
}

export function readGatewayProcessArgsSync(pid: number): string[] | null {
if (process.platform === "linux") {
const cmdlinePath = procCmdlinePath(pid);
if (cmdlinePath) {
try {
return parseProcCmdline(fsSync.readFileSync(`/proc/${pid}/cmdline`, "utf8"));
return parseProcCmdline(fsSync.readFileSync(cmdlinePath, "utf8"));
} catch {
return null;
}
Expand Down
167 changes: 167 additions & 0 deletions src/infra/platform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import {
currentPlatform,
fdLinkPaths,
isArmHost,
isHeadless,
isMacOS,
isLinux,
isWindows,
isSupportedPlatform,
platformLabel,
procCmdlinePath,
resolvePlatformEntry,
supportsNoFollow,
type PlatformRegistry,
type SupportedPlatform,
} from "./platform.js";

describe("isSupportedPlatform", () => {
it("accepts darwin, linux, and win32", () => {
expect(isSupportedPlatform("darwin")).toBe(true);
expect(isSupportedPlatform("linux")).toBe(true);
expect(isSupportedPlatform("win32")).toBe(true);
});

it("rejects other platforms", () => {
expect(isSupportedPlatform("freebsd")).toBe(false);
expect(isSupportedPlatform("sunos")).toBe(false);
expect(isSupportedPlatform("aix")).toBe(false);
expect(isSupportedPlatform("android")).toBe(false);
});
});

describe("currentPlatform", () => {
it("returns current process platform when supported", () => {
const platform = currentPlatform();
expect(["darwin", "linux", "win32"]).toContain(platform);
expect(platform).toBe(process.platform);
});
});

describe("boolean helpers", () => {
it("exactly one of isMacOS, isLinux, isWindows is true", () => {
const trueCount = [isMacOS, isLinux, isWindows].filter(Boolean).length;
expect(trueCount).toBe(1);
});

it("matches process.platform", () => {
if (process.platform === "darwin") {
expect(isMacOS).toBe(true);
}
if (process.platform === "linux") {
expect(isLinux).toBe(true);
}
if (process.platform === "win32") {
expect(isWindows).toBe(true);
}
});
});

describe("procCmdlinePath", () => {
it("returns /proc path on linux", () => {
if (process.platform === "linux") {
expect(procCmdlinePath(123)).toBe("/proc/123/cmdline");
}
});

it("returns null on non-linux platforms", () => {
if (process.platform !== "linux") {
expect(procCmdlinePath(123)).toBeNull();
}
});
});

describe("fdLinkPaths", () => {
it("returns at least one path on unix platforms", () => {
if (process.platform !== "win32") {
const paths = fdLinkPaths(5);
expect(paths.length).toBeGreaterThan(0);
expect(paths.every((p) => p.includes("5"))).toBe(true);
}
});

it("returns linux-specific paths on linux", () => {
if (process.platform === "linux") {
const paths = fdLinkPaths(7);
expect(paths).toEqual(["/proc/self/fd/7", "/dev/fd/7"]);
}
});

it("returns darwin-specific path on darwin", () => {
if (process.platform === "darwin") {
expect(fdLinkPaths(7)).toEqual(["/dev/fd/7"]);
}
});

it("returns empty on windows", () => {
if (process.platform === "win32") {
expect(fdLinkPaths(7)).toEqual([]);
}
});
});

describe("supportsNoFollow", () => {
it("is false only on windows", () => {
expect(supportsNoFollow).toBe(process.platform !== "win32");
});
});

describe("platformLabel", () => {
it("returns human-friendly names", () => {
expect(platformLabel("darwin")).toBe("macOS");
expect(platformLabel("linux")).toBe("Linux");
expect(platformLabel("win32")).toBe("Windows");
});
});

describe("PlatformRegistry / resolvePlatformEntry", () => {
it("resolves the current platform entry", () => {
const registry: PlatformRegistry<string> = {
darwin: "mac-value",
linux: "linux-value",
win32: "win-value",
};
const result = resolvePlatformEntry(registry);
const expected = registry[process.platform as SupportedPlatform];
expect(result).toBe(expected);
});
});

describe("isHeadless", () => {
it("returns false for darwin regardless of env", () => {
expect(isHeadless({}, "darwin")).toBe(false);
});

it("returns false for win32 regardless of env", () => {
expect(isHeadless({}, "win32")).toBe(false);
});

it("returns false on linux with DISPLAY set", () => {
expect(isHeadless({ DISPLAY: ":0" }, "linux")).toBe(false);
});

it("returns false on linux with WAYLAND_DISPLAY set", () => {
expect(isHeadless({ WAYLAND_DISPLAY: "wayland-0" }, "linux")).toBe(false);
});

it("returns true on linux SSH without display", () => {
expect(isHeadless({ SSH_CLIENT: "1.2.3.4 12345 22" }, "linux")).toBe(true);
});

it("returns true on linux with no display or SSH", () => {
expect(isHeadless({}, "linux")).toBe(true);
});
});

describe("isArmHost", () => {
it("detects arm architectures", () => {
expect(isArmHost("arm")).toBe(true);
expect(isArmHost("arm64")).toBe(true);
});

it("rejects non-arm architectures", () => {
expect(isArmHost("x64")).toBe(false);
expect(isArmHost("ia32")).toBe(false);
});
});
Loading
Loading