fix(cli): make ao open work cross-platform

Mac-only assumptions broke `ao open` on Windows and Linux:

- source of truth was `tmux list-sessions`, which is empty without tmux
- the open action shelled out to `open-iterm-tab`, a macOS helper

Switch the source of truth to `sm.list()` (works on every platform — also
handles `runtime-process` sessions on Windows) and branch the open action:

- macOS: `open-iterm-tab` (unchanged), tmux attach inside iTerm
- Windows: `wt new-tab cmd /k ao session attach <id>` for live sessions,
  with `cmd /c start cmd /k ...` as the no-`wt` fallback. Both paths route
  through `cmd /k` because `wt` and `start` call CreateProcess directly,
  which doesn't honor PATHEXT and reports 0x80070002 for `ao` (really
  `ao.cmd`). New tab anchors at `config.projects[id].path` so the spawned
  attach can resolve `agent-orchestrator.yaml` via loadConfig's upward
  search; without this attach fails with "No agent-orchestrator.yaml found"
  when the user's homedir is the inherited cwd.
- Linux: dashboard URL via `openUrl()`. No consistent terminal-spawn API
  across DEs, so we don't try.

Other behavior changes:

- read the live daemon's port from `running.json` so URLs stay correct
  when the dashboard auto-picked a non-default port
- warn when the daemon is not running (URL fallback won't load)
- aggregate targets (`all`, `<project>`) hide terminated sessions; named
  lookup keeps them in scope and opens the dashboard with the death
  reason inline (`died at <ts>: session=<reason>, runtime=<reason>`) plus
  a `ao session restore <id>` hint
- new `--browser` flag forces the URL path on any platform

Add `isMac()` to `platform.ts` (per the project rule that platform checks
live in one place rather than spread as ad-hoc `process.platform === ...`
guards) and re-export from core.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Priyanshu Choudhary 2026-05-05 02:36:54 +05:30
parent 13cdbf2246
commit d04fad33ab
4 changed files with 428 additions and 99 deletions

View File

@ -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<string, unknown> | 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<typeof import("node:child_process")>();
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<typeof vi.spyOn>;
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<string, () => 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 <id>`", 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",
);
});
});

View File

@ -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<boolean> {
async function openInIterm(sessionName: string, newWindow?: boolean): Promise<boolean> {
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 <id>`.
* 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 <dir>] <command> [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();
});

View File

@ -273,6 +273,7 @@ export {
// Platform adapter — centralized cross-platform branching
export {
isWindows,
isMac,
getDefaultRuntime,
getShell,
killProcessTree,

View File

@ -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";
}