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:
parent
13cdbf2246
commit
d04fad33ab
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -273,6 +273,7 @@ export {
|
|||
// Platform adapter — centralized cross-platform branching
|
||||
export {
|
||||
isWindows,
|
||||
isMac,
|
||||
getDefaultRuntime,
|
||||
getShell,
|
||||
killProcessTree,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue