diff --git a/packages/cli/__tests__/commands/open.test.ts b/packages/cli/__tests__/commands/open.test.ts index 83bef6e18..817bff448 100644 --- a/packages/cli/__tests__/commands/open.test.ts +++ b/packages/cli/__tests__/commands/open.test.ts @@ -1,35 +1,108 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -const { mockExec, mockConfigRef, mockTmux } = vi.hoisted(() => ({ +const { + mockExec, + mockSpawn, + mockConfigRef, + mockListRef, + mockOpenUrl, + mockIsMacRef, + mockIsWindowsRef, + mockRunningRef, +} = vi.hoisted(() => ({ mockExec: vi.fn(), - mockTmux: vi.fn(), + mockSpawn: vi.fn(), mockConfigRef: { current: null as Record | null }, + mockListRef: { current: [] as Array<{ id: string; projectId: string; lifecycle: { session: { state: string } } }> }, + mockOpenUrl: vi.fn(), + mockIsMacRef: { current: true }, + mockIsWindowsRef: { current: false }, + mockRunningRef: { current: { pid: 1, port: 3000, projects: [] } as { pid: number; port: number; projects: string[] } | null }, })); +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawn: mockSpawn }; +}); + vi.mock("../../src/lib/shell.js", () => ({ exec: mockExec, execSilent: vi.fn(), - tmux: mockTmux, + tmux: vi.fn(), git: vi.fn(), gh: vi.fn(), - getTmuxSessions: async () => { - const output = await mockTmux("list-sessions", "-F", "#{session_name}"); - if (!output) return []; - return output.split("\n").filter(Boolean); - }, + getTmuxSessions: vi.fn(), getTmuxActivity: vi.fn().mockResolvedValue(null), })); +vi.mock("../../src/lib/create-session-manager.js", () => ({ + getSessionManager: async () => ({ + list: async () => mockListRef.current, + }), +})); + +vi.mock("../../src/lib/web-dir.js", () => ({ + openUrl: mockOpenUrl, +})); + +vi.mock("../../src/lib/running-state.js", () => ({ + getRunning: async () => mockRunningRef.current, +})); + vi.mock("@aoagents/ao-core", () => ({ loadConfig: () => mockConfigRef.current, + isMac: () => mockIsMacRef.current, + isWindows: () => mockIsWindowsRef.current, + isTerminalSession: (s: { lifecycle?: { session?: { state?: string } } }) => + s.lifecycle?.session?.state === "terminated" || s.lifecycle?.session?.state === "done", })); import { Command } from "commander"; import { registerOpen } from "../../src/commands/open.js"; +// Fictional fixture path used only inside the in-memory mock config below. +// Not anyone's real filesystem path — assertions reference this constant so +// the test verifies "config.projects[id].path flows through to wt's -d flag", +// independent of the literal value. +const TEST_REPO_PATH = "/fixtures/test-repo"; + let program: Command; let consoleSpy: ReturnType; +function makeSession(id: string, projectId: string, state = "working") { + const sessionState = + state === "terminated" + ? { + state, + reason: "runtime_lost", + terminatedAt: "2026-05-04T19:51:10.488Z", + } + : { state, reason: "task_in_progress", terminatedAt: null }; + const runtimeState = + state === "terminated" + ? { state: "missing", reason: "process_missing" } + : { state: "alive", reason: "process_running" }; + return { + id, + projectId, + lifecycle: { + session: sessionState, + runtime: runtimeState, + }, + }; +} + +function makeSpawnChild() { + const handlers: Record void> = {}; + return { + on: vi.fn((event: string, cb: () => void) => { + handlers[event] = cb; + return undefined; + }), + unref: vi.fn(), + }; +} + beforeEach(() => { mockConfigRef.current = { dataDir: "/tmp/ao", @@ -55,6 +128,12 @@ beforeEach(() => { path: "/home/user/backend", defaultBranch: "main", }, + "test-repo": { + name: "Test Repo", + repo: "org/test-repo", + path: TEST_REPO_PATH, + defaultBranch: "main", + }, }, notifiers: {}, notificationRouting: {}, @@ -71,20 +150,27 @@ beforeEach(() => { }); mockExec.mockReset(); - mockTmux.mockReset(); + mockSpawn.mockReset(); + mockOpenUrl.mockReset(); + mockListRef.current = []; + mockIsMacRef.current = true; + mockIsWindowsRef.current = false; + mockRunningRef.current = { pid: 1, port: 3000, projects: [] }; mockExec.mockResolvedValue({ stdout: "", stderr: "" }); + mockSpawn.mockReturnValue(makeSpawnChild()); }); afterEach(() => { vi.restoreAllMocks(); }); -describe("open command", () => { +describe("open command (macOS)", () => { it("opens all sessions when target is 'all'", async () => { - mockTmux.mockImplementation(async (...args: string[]) => { - if (args[0] === "list-sessions") return "app-1\napp-2\nbackend-1"; - return null; - }); + mockListRef.current = [ + makeSession("app-1", "my-app"), + makeSession("app-2", "my-app"), + makeSession("backend-1", "backend"), + ]; await program.parseAsync(["node", "test", "open", "all"]); @@ -96,10 +182,7 @@ describe("open command", () => { }); it("opens all sessions when no target given", async () => { - mockTmux.mockImplementation(async (...args: string[]) => { - if (args[0] === "list-sessions") return "app-1"; - return null; - }); + mockListRef.current = [makeSession("app-1", "my-app")]; await program.parseAsync(["node", "test", "open"]); @@ -108,10 +191,11 @@ describe("open command", () => { }); it("opens sessions for a specific project", async () => { - mockTmux.mockImplementation(async (...args: string[]) => { - if (args[0] === "list-sessions") return "app-1\napp-2\nbackend-1"; - return null; - }); + mockListRef.current = [ + makeSession("app-1", "my-app"), + makeSession("app-2", "my-app"), + makeSession("backend-1", "backend"), + ]; await program.parseAsync(["node", "test", "open", "my-app"]); @@ -122,25 +206,8 @@ describe("open command", () => { expect(output).not.toContain("backend-1"); }); - it("matches hashed tmux worker session names", async () => { - mockTmux.mockImplementation(async (...args: string[]) => { - if (args[0] === "list-sessions") return "1686e4aaaeaa-app-1\nbackend-1"; - return null; - }); - - await program.parseAsync(["node", "test", "open", "my-app"]); - - const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n"); - expect(output).toContain("Opening 1 session"); - expect(output).toContain("1686e4aaaeaa-app-1"); - expect(output).not.toContain("backend-1"); - }); - it("opens a single session by name", async () => { - mockTmux.mockImplementation(async (...args: string[]) => { - if (args[0] === "list-sessions") return "app-1\napp-2"; - return null; - }); + mockListRef.current = [makeSession("app-1", "my-app"), makeSession("app-2", "my-app")]; await program.parseAsync(["node", "test", "open", "app-1"]); @@ -150,10 +217,7 @@ describe("open command", () => { }); it("rejects unknown target", async () => { - mockTmux.mockImplementation(async (...args: string[]) => { - if (args[0] === "list-sessions") return "app-1"; - return null; - }); + mockListRef.current = [makeSession("app-1", "my-app")]; await expect(program.parseAsync(["node", "test", "open", "nonexistent"])).rejects.toThrow( "process.exit(1)", @@ -161,10 +225,7 @@ describe("open command", () => { }); it("passes --new-window flag to open-iterm-tab", async () => { - mockTmux.mockImplementation(async (...args: string[]) => { - if (args[0] === "list-sessions") return "app-1"; - return null; - }); + mockListRef.current = [makeSession("app-1", "my-app")]; await program.parseAsync(["node", "test", "open", "-w", "app-1"]); @@ -172,33 +233,82 @@ describe("open command", () => { }); it("falls back gracefully when open-iterm-tab fails", async () => { - mockTmux.mockImplementation(async (...args: string[]) => { - if (args[0] === "list-sessions") return "app-1"; - return null; - }); + mockListRef.current = [makeSession("app-1", "my-app")]; mockExec.mockRejectedValue(new Error("command not found")); await program.parseAsync(["node", "test", "open", "app-1"]); - const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n"); - expect(output).toContain("http://localhost:3000/projects/my-app/sessions/app-1"); + expect(mockOpenUrl).toHaveBeenCalledWith( + "http://localhost:3000/projects/my-app/sessions/app-1", + ); }); - it("falls back to the owning project for orchestrator sessions", async () => { - mockTmux.mockImplementation(async (...args: string[]) => { - if (args[0] === "list-sessions") return "app-orchestrator"; - return null; - }); - mockExec.mockRejectedValue(new Error("command not found")); + it("excludes terminated sessions from aggregate targets", async () => { + mockListRef.current = [ + makeSession("app-1", "my-app"), + makeSession("app-dead", "my-app", "terminated"), + ]; - await program.parseAsync(["node", "test", "open", "app-orchestrator"]); + await program.parseAsync(["node", "test", "open", "all"]); const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n"); - expect(output).toContain("http://localhost:3000/projects/my-app/sessions/app-orchestrator"); + expect(output).toContain("Opening 1 session"); + expect(output).toContain("app-1"); + expect(output).not.toContain("app-dead"); + }); + + it("includes a terminated session when looked up by name (opens dashboard with death reason)", async () => { + mockListRef.current = [makeSession("app-dead", "my-app", "terminated")]; + + await program.parseAsync(["node", "test", "open", "app-dead"]); + + expect(mockExec).not.toHaveBeenCalled(); + expect(mockOpenUrl).toHaveBeenCalledWith( + "http://localhost:3000/projects/my-app/sessions/app-dead", + ); + const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(output).toContain("(terminated)"); + expect(output).toContain("session=runtime_lost"); + expect(output).toContain("runtime=process_missing"); + expect(output).toContain("ao session restore app-dead"); + }); + + it("--browser forces dashboard URL even on macOS", async () => { + mockListRef.current = [makeSession("app-1", "my-app")]; + + await program.parseAsync(["node", "test", "open", "-b", "app-1"]); + + expect(mockExec).not.toHaveBeenCalled(); + expect(mockOpenUrl).toHaveBeenCalledWith( + "http://localhost:3000/projects/my-app/sessions/app-1", + ); + }); + + it("uses the live daemon's port from running-state, not config", async () => { + mockListRef.current = [makeSession("app-1", "my-app")]; + mockExec.mockRejectedValue(new Error("no iterm")); + mockRunningRef.current = { pid: 42, port: 4173, projects: ["my-app"] }; + + await program.parseAsync(["node", "test", "open", "app-1"]); + + expect(mockOpenUrl).toHaveBeenCalledWith( + "http://localhost:4173/projects/my-app/sessions/app-1", + ); + }); + + it("warns when daemon is not running (URL fallback may not load)", async () => { + mockListRef.current = [makeSession("app-1", "my-app")]; + mockExec.mockRejectedValue(new Error("no iterm")); + mockRunningRef.current = null; + + await program.parseAsync(["node", "test", "open", "app-1"]); + + const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(output).toContain("daemon does not appear to be running"); }); it("shows 'No sessions to open' when none exist", async () => { - mockTmux.mockResolvedValue(null); + mockListRef.current = []; await program.parseAsync(["node", "test", "open", "my-app"]); @@ -206,3 +316,101 @@ describe("open command", () => { expect(output).toContain("No sessions to open"); }); }); + +describe("open command (Windows)", () => { + beforeEach(() => { + mockIsMacRef.current = false; + mockIsWindowsRef.current = true; + }); + + it("spawns Windows Terminal running `ao session attach `", async () => { + mockListRef.current = [makeSession("tr-orchestrator", "test-repo")]; + + await program.parseAsync(["node", "test", "open", "tr-orchestrator"]); + + expect(mockSpawn).toHaveBeenCalledTimes(1); + const [cmd, args] = mockSpawn.mock.calls[0]; + expect(cmd).toBe("wt.exe"); + expect(args).toEqual([ + "-w", "0", "new-tab", + "--title", "ao:tr-orchestrator", + "-d", TEST_REPO_PATH, + "cmd.exe", "/k", "ao", "session", "attach", "tr-orchestrator", + ]); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it("falls back to `cmd /k` when wt.exe is unavailable", async () => { + mockListRef.current = [makeSession("tr-orchestrator", "test-repo")]; + mockSpawn.mockImplementationOnce(() => { + throw new Error("ENOENT: wt.exe not found"); + }); + mockSpawn.mockImplementationOnce(() => makeSpawnChild()); + + await program.parseAsync(["node", "test", "open", "tr-orchestrator"]); + + expect(mockSpawn).toHaveBeenCalledTimes(2); + expect(mockSpawn.mock.calls[1][0]).toBe("cmd.exe"); + expect(mockSpawn.mock.calls[1][1]).toEqual([ + "/c", "start", "ao:tr-orchestrator", + "/d", TEST_REPO_PATH, + "cmd.exe", "/k", "ao", "session", "attach", "tr-orchestrator", + ]); + }); + + it("falls back to dashboard URL when both terminal launchers fail", async () => { + mockListRef.current = [makeSession("tr-orchestrator", "test-repo")]; + mockSpawn.mockImplementation(() => { + throw new Error("ENOENT"); + }); + + await program.parseAsync(["node", "test", "open", "tr-orchestrator"]); + + expect(mockOpenUrl).toHaveBeenCalledWith( + "http://localhost:3000/projects/test-repo/sessions/tr-orchestrator", + ); + }); + + it("--browser skips terminal spawn and opens URL directly", async () => { + mockListRef.current = [makeSession("tr-orchestrator", "test-repo")]; + + await program.parseAsync(["node", "test", "open", "-b", "tr-orchestrator"]); + + expect(mockSpawn).not.toHaveBeenCalled(); + expect(mockOpenUrl).toHaveBeenCalledWith( + "http://localhost:3000/projects/test-repo/sessions/tr-orchestrator", + ); + }); + + it("opens dashboard URL for terminated sessions instead of attempting attach", async () => { + mockListRef.current = [makeSession("tr-orchestrator", "test-repo", "terminated")]; + + await program.parseAsync(["node", "test", "open", "tr-orchestrator"]); + + expect(mockSpawn).not.toHaveBeenCalled(); + expect(mockOpenUrl).toHaveBeenCalledWith( + "http://localhost:3000/projects/test-repo/sessions/tr-orchestrator", + ); + const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(output).toContain("(terminated)"); + }); +}); + +describe("open command (Linux)", () => { + beforeEach(() => { + mockIsMacRef.current = false; + mockIsWindowsRef.current = false; + }); + + it("opens the dashboard URL (no terminal-spawn helper exists)", async () => { + mockListRef.current = [makeSession("app-1", "my-app")]; + + await program.parseAsync(["node", "test", "open", "app-1"]); + + expect(mockSpawn).not.toHaveBeenCalled(); + expect(mockExec).not.toHaveBeenCalled(); + expect(mockOpenUrl).toHaveBeenCalledWith( + "http://localhost:3000/projects/my-app/sessions/app-1", + ); + }); +}); diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts index 9a92ff145..61f4c13b4 100644 --- a/packages/cli/src/commands/open.ts +++ b/packages/cli/src/commands/open.ts @@ -1,54 +1,115 @@ +import { spawn } from "node:child_process"; import chalk from "chalk"; import type { Command } from "commander"; -import { loadConfig } from "@aoagents/ao-core"; -import { exec, getTmuxSessions } from "../lib/shell.js"; -import { findProjectForSession, matchesPrefix, stripHashPrefix } from "../lib/session-utils.js"; +import { + isMac, + isTerminalSession, + isWindows, + loadConfig, + type Session, +} from "@aoagents/ao-core"; +import { exec } from "../lib/shell.js"; +import { getSessionManager } from "../lib/create-session-manager.js"; +import { findProjectForSession, matchesPrefix } from "../lib/session-utils.js"; import { DEFAULT_PORT } from "../lib/constants.js"; import { projectSessionUrl } from "../lib/routes.js"; +import { openUrl } from "../lib/web-dir.js"; +import { getRunning } from "../lib/running-state.js"; -async function openInTerminal(sessionName: string, newWindow?: boolean): Promise { +async function openInIterm(sessionName: string, newWindow?: boolean): Promise { try { const args = newWindow ? ["--new-window", sessionName] : [sessionName]; await exec("open-iterm-tab", args); return true; } catch { - // Fall back to tmux attach hint return false; } } +/** + * Spawn a detached child and treat any synchronous spawn error as failure. + * Returns true if the child was launched (its own exit code is not awaited — + * a new console window has its own lifecycle). + */ +function spawnDetached(cmd: string, args: string[]): boolean { + try { + const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: false }); + child.on("error", () => { /* swallow — caller already returned */ }); + child.unref(); + return true; + } catch { + return false; + } +} + +/** + * Open a new attached console window running `ao session attach `. + * Tries Windows Terminal first (matches the iTerm-tab feel on Mac), falls + * back to a plain `cmd /k` window which works on every Windows install. + * + * Both paths route through `cmd /k` rather than invoking `ao` directly: + * `wt new-tab` and `start` both call CreateProcess on the first token, and + * CreateProcess does not honor PATHEXT — so `ao` (really `ao.cmd`, an npm + * shim) is reported as ERROR_FILE_NOT_FOUND (0x80070002). Letting cmd.exe + * be the first token lets it do the .cmd resolution. + * + * `cwd` should be the project directory (where agent-orchestrator.yaml lives) + * so the spawned `ao session attach` can resolve config via loadConfig's + * upward search. Without it the new console inherits the user's homedir and + * attach fails with "No agent-orchestrator.yaml found". + */ +function openWindowsConsole(sessionId: string, cwd: string | undefined): boolean { + const title = `ao:${sessionId}`; + const inner = ["ao", "session", "attach", sessionId]; + + const wtArgs = ["-w", "0", "new-tab", "--title", title]; + if (cwd) wtArgs.push("-d", cwd); + wtArgs.push("cmd.exe", "/k", ...inner); + if (spawnDetached("wt.exe", wtArgs)) return true; + + // `start` syntax: start "title" [/d ] [args...] + const startArgs = ["/c", "start", title]; + if (cwd) startArgs.push("/d", cwd); + startArgs.push("cmd.exe", "/k", ...inner); + return spawnDetached("cmd.exe", startArgs); +} + export function registerOpen(program: Command): void { program .command("open") - .description("Open session(s) in terminal tabs") + .description("Open session(s) in an attached terminal (or the dashboard URL with --browser)") .argument("[target]", 'Session name, project ID, or "all" to open everything') - .option("-w, --new-window", "Open in a new terminal window") - .action(async (target: string | undefined, opts: { newWindow?: boolean }) => { + .option("-w, --new-window", "Open in a new terminal window (macOS)") + .option("-b, --browser", "Open the dashboard URL in a browser instead of a terminal") + .action(async (target: string | undefined, opts: { newWindow?: boolean; browser?: boolean }) => { const config = loadConfig(); - const allTmux = await getTmuxSessions(); + const sm = await getSessionManager(config); + const all = await sm.list(); - let sessionsToOpen: string[] = []; + // For aggregate targets ("all" / project) we hide terminated sessions — + // mirrors Mac's old tmux-list-sessions behavior, which only ever showed + // live sessions. For a named lookup the user is asking about a specific + // session, so we keep terminated ones in scope and open the dashboard + // so they can read the transcript even if the agent has died. + let sessionsToOpen: Session[] = []; if (!target || target === "all") { - // Open all sessions across all projects - for (const [projectId, project] of Object.entries(config.projects)) { - const prefix = project.sessionPrefix || projectId; - const matching = allTmux.filter((s) => matchesPrefix(s, prefix)); - sessionsToOpen.push(...matching); - } + sessionsToOpen = all.filter((s) => !isTerminalSession(s)); } else if (config.projects[target]) { - // Open all sessions for a specific project const project = config.projects[target]; const prefix = project.sessionPrefix || target; - sessionsToOpen = allTmux.filter((s) => matchesPrefix(s, prefix)); - } else if (allTmux.includes(target)) { - // Open a specific session - sessionsToOpen = [target]; + sessionsToOpen = all + .filter((s) => !isTerminalSession(s)) + .filter((s) => s.projectId === target || matchesPrefix(s.id, prefix)); } else { - console.error( - chalk.red(`Unknown target: ${target}\nSpecify a session name, project ID, or "all".`), - ); - process.exit(1); + const match = all.find((s) => s.id === target); + if (!match) { + console.error( + chalk.red(`Unknown target: ${target}\nSpecify a session name, project ID, or "all".`), + ); + process.exit(1); + } + sessionsToOpen = [match]; } if (sessionsToOpen.length === 0) { @@ -56,24 +117,79 @@ export function registerOpen(program: Command): void { return; } + // Prefer the live daemon's port over the config default — they can + // diverge if the dashboard auto-picked a free port at startup. + const running = await getRunning(); + const port = running?.port ?? config.port ?? DEFAULT_PORT; + if (!running && !opts.browser) { + console.log( + chalk.dim( + "Note: AO daemon does not appear to be running — dashboard URL fallback may not load.", + ), + ); + } + console.log( chalk.bold( `Opening ${sessionsToOpen.length} session${sessionsToOpen.length > 1 ? "s" : ""}...\n`, ), ); - const port = config.port ?? DEFAULT_PORT; - for (const session of sessionsToOpen.sort()) { - const opened = await openInTerminal(session, opts.newWindow); - if (opened) { - console.log(chalk.green(` Opened: ${session}`)); - } else { - const sessionId = stripHashPrefix(session); - const matchedProjectId = findProjectForSession(config, session) ?? target ?? sessionId; - console.log( - ` ${chalk.yellow(session)} — view at: ${chalk.dim(projectSessionUrl(port, matchedProjectId, sessionId))}`, - ); + const sorted = [...sessionsToOpen].sort((a, b) => a.id.localeCompare(b.id)); + + for (const session of sorted) { + const projectId = session.projectId ?? findProjectForSession(config, session.id) ?? target; + const url = projectSessionUrl(port, projectId ?? session.id, session.id); + const dead = isTerminalSession(session); + + // --browser, or terminated sessions (no live PTY to attach to), or + // named-lookup of a dead session: open the dashboard URL. + if (opts.browser || dead) { + openUrl(url); + if (dead) { + const sr = session.lifecycle.session.reason; + const rr = session.lifecycle.runtime.reason; + const at = session.lifecycle.session.terminatedAt; + const when = at ? new Date(at).toLocaleString() : "unknown time"; + console.log( + ` ${chalk.yellow(session.id)} ${chalk.dim("(terminated)")} — opened ${chalk.dim(url)}`, + ); + console.log( + chalk.dim(` died at ${when}: session=${sr}, runtime=${rr}`), + ); + console.log( + chalk.dim(` restart with: ao session restore ${session.id}`), + ); + } else { + console.log(` ${chalk.green(session.id)} — opened ${chalk.dim(url)}`); + } + continue; } + + if (isMac()) { + const opened = await openInIterm(session.id, opts.newWindow); + if (opened) { + console.log(chalk.green(` Opened: ${session.id}`)); + continue; + } + } else if (isWindows()) { + // The spawned `ao session attach` does loadConfig() which searches + // upward from cwd for agent-orchestrator.yaml. Anchor the new + // console at the project's path (where the yaml lives); if that + // isn't in config, fall back to the worktree (yaml may live in a + // parent directory of it). + const projectPath = projectId ? config.projects[projectId]?.path : undefined; + const cwd = projectPath ?? session.workspacePath ?? undefined; + if (openWindowsConsole(session.id, cwd)) { + console.log(chalk.green(` Opened: ${session.id} (new console)`)); + continue; + } + } + + // Final fallback (Linux always lands here, plus any platform whose + // terminal-spawn helper failed): open the dashboard URL. + openUrl(url); + console.log(` ${chalk.yellow(session.id)} — opened ${chalk.dim(url)}`); } console.log(); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 971858acb..68206f761 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -273,6 +273,7 @@ export { // Platform adapter — centralized cross-platform branching export { isWindows, + isMac, getDefaultRuntime, getShell, killProcessTree, diff --git a/packages/core/src/platform.ts b/packages/core/src/platform.ts index 8d1009fef..763a953f0 100644 --- a/packages/core/src/platform.ts +++ b/packages/core/src/platform.ts @@ -16,6 +16,10 @@ export function isWindows(): boolean { return process.platform === "win32"; } +export function isMac(): boolean { + return process.platform === "darwin"; +} + export function getDefaultRuntime(): "tmux" | "process" { return isWindows() ? "process" : "tmux"; }