fix(cli): replace all tmux attach output with dashboard URLs (#947)

Replace raw `tmux attach -t` commands in CLI output with web dashboard
URLs (`http://localhost:{port}/sessions/{sessionId}`) across all four
CLI commands: spawn, session restore, open, and start.

Add `stripHashPrefix` helper in session-utils to extract AO session IDs
from hash-prefixed tmux session names. Remove unused `tmuxTarget`
variable from start command.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adil 2026-04-08 02:52:37 +05:30
parent 5322220f6e
commit 24ecfc2926
9 changed files with 55 additions and 25 deletions

View File

@ -167,7 +167,7 @@ describe("open command", () => {
await program.parseAsync(["node", "test", "open", "app-1"]);
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("tmux attach");
expect(output).toContain("http://localhost:3000/sessions/app-1");
});
it("shows 'No sessions to open' when none exist", async () => {

View File

@ -286,7 +286,7 @@ describe("spawn command", () => {
});
});
it("shows ao session attach command instead of raw tmux attach", async () => {
it("shows dashboard URL instead of raw tmux attach", async () => {
const fakeSession: Session = {
id: "app-7",
projectId: "my-app",
@ -307,10 +307,10 @@ describe("spawn command", () => {
await program.parseAsync(["node", "test", "spawn"]);
const succeedMsg = String(mockSpinner.succeed.mock.calls[0]?.[0] ?? "");
expect(succeedMsg).toContain("ao session attach app-7");
expect(succeedMsg).not.toContain("tmux attach");
expect(succeedMsg).not.toContain("8474d6f29887-app-7");
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("http://localhost:3000/sessions/app-7");
expect(output).not.toContain("tmux attach");
expect(output).not.toContain("8474d6f29887-app-7");
});
it("passes --agent flag to sessionManager.spawn()", async () => {
@ -439,7 +439,8 @@ describe("spawn command", () => {
const succeedMsg = String(mockSpinner.succeed.mock.calls[0]?.[0] ?? "");
expect(succeedMsg).toContain("https://github.com/org/repo/pull/123");
expect(succeedMsg).toContain("ao session attach app-1");
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("http://localhost:3000/sessions/app-1");
});
it("passes GitHub assignment flag through to claimPR", async () => {

View File

@ -853,12 +853,12 @@ describe("start command — orchestrator session strategy display", () => {
await program.parseAsync(["node", "test", "start", "--no-dashboard"]);
const output = getLoggedOutput();
expect(output).toContain("tmux attach -t tmux-session-1");
expect(output).toContain("http://localhost:3000/sessions/app-orchestrator");
expect(output).not.toContain("reused existing session");
});
it.each(["delete", "ignore", "delete-new", "ignore-new", "kill-previous"] as const)(
"uses attach messaging when strategy is %s",
"uses session URL messaging when strategy is %s",
async (orchestratorSessionStrategy) => {
mockConfigRef.current = makeConfig({
"my-app": makeProject({ orchestratorSessionStrategy }),
@ -877,7 +877,7 @@ describe("start command — orchestrator session strategy display", () => {
await program.parseAsync(["node", "test", "start", "--no-dashboard"]);
const output = getLoggedOutput();
expect(output).toContain("tmux attach -t tmux-session-1");
expect(output).toContain("http://localhost:3000/sessions/app-orchestrator");
expect(output).not.toContain("reused existing session");
},
);
@ -900,8 +900,8 @@ describe("start command — orchestrator session strategy display", () => {
const output = getLoggedOutput();
// When --no-dashboard is used, auto-selects the most recent orchestrator
// and shows the tmux attach command (not the dashboard selection message)
expect(output).toContain("tmux attach -t tmux-session-existing");
// and shows the session URL (not the dashboard selection message)
expect(output).toContain("http://localhost:3000/sessions/app-orchestrator");
expect(output).not.toContain("existing sessions found — select one in the dashboard");
// Should NOT spawn a new orchestrator when existing ones exist

View File

@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
escapeRegex,
matchesPrefix,
stripHashPrefix,
findProjectForSession,
isOrchestratorSessionName,
} from "../../src/lib/session-utils.js";
@ -29,6 +30,24 @@ describe("escapeRegex", () => {
});
});
describe("stripHashPrefix", () => {
it("strips 12-char hex hash prefix", () => {
expect(stripHashPrefix("1686e4aaaeaa-ao-145")).toBe("ao-145");
});
it("returns plain session ID unchanged", () => {
expect(stripHashPrefix("ao-145")).toBe("ao-145");
});
it("returns orchestrator session name unchanged", () => {
expect(stripHashPrefix("app-orchestrator")).toBe("app-orchestrator");
});
it("handles hash prefix with orchestrator name", () => {
expect(stripHashPrefix("abcdef012345-app-orchestrator")).toBe("app-orchestrator");
});
});
describe("matchesPrefix", () => {
it("matches prefix-1", () => {
expect(matchesPrefix("app-1", "app")).toBe(true);

View File

@ -2,7 +2,7 @@ import chalk from "chalk";
import type { Command } from "commander";
import { loadConfig } from "@composio/ao-core";
import { exec, getTmuxSessions } from "../lib/shell.js";
import { matchesPrefix } from "../lib/session-utils.js";
import { matchesPrefix, stripHashPrefix } from "../lib/session-utils.js";
async function openInTerminal(sessionName: string, newWindow?: boolean): Promise<boolean> {
try {
@ -60,13 +60,15 @@ export function registerOpen(program: Command): void {
),
);
const port = config.port ?? 3000;
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);
console.log(
` ${chalk.yellow(session)}attach with: ${chalk.dim(`tmux attach -t ${session}`)}`,
` ${chalk.yellow(session)}view at: ${chalk.dim(`http://localhost:${port}/sessions/${sessionId}`)}`,
);
}
}

View File

@ -301,8 +301,8 @@ export function registerSession(program: Command): void {
if (restored.branch) {
console.log(chalk.dim(` Branch: ${restored.branch}`));
}
const tmuxTarget = restored.runtimeHandle?.id ?? sessionName;
console.log(chalk.dim(` Attach: tmux attach -t ${tmuxTarget}`));
const port = config.port ?? 3000;
console.log(chalk.dim(` View: http://localhost:${port}/sessions/${sessionName}`));
} catch (err) {
if (err instanceof SessionNotRestorableError) {
console.error(chalk.red(`Cannot restore: ${err.reason}`));

View File

@ -121,10 +121,11 @@ async function spawnSession(
const issueLabel = issueId ? ` for issue #${issueId}` : "";
const claimLabel = claimedPrUrl ? ` (claimed ${claimedPrUrl})` : "";
const port = config.port ?? 3000;
spinner.succeed(
`Session ${chalk.green(session.id)} spawned${issueLabel}${claimLabel}. ` +
`View in the dashboard (if running) or attach with: ${chalk.cyan(`ao session attach ${session.id}`)}`,
`Session ${chalk.green(session.id)} spawned${issueLabel}${claimLabel}`,
);
console.log(` View: ${chalk.dim(`http://localhost:${port}/sessions/${session.id}`)}`);
// Warn if prompt delivery failed (for post-launch agents like Claude Code)
const promptDelivered = session.metadata?.promptDelivered;

View File

@ -1009,7 +1009,6 @@ async function runStartup(
}
// Create orchestrator session (unless --no-orchestrator or existing orchestrators found)
let tmuxTarget = sessionId;
let hasExistingOrchestrators = false;
let selectedOrchestratorId: string | null = null;
@ -1050,8 +1049,6 @@ async function runStartup(
);
const selected = sortedOrchestrators[0];
selectedOrchestratorId = selected.id;
// Use runtimeHandle.id if available, otherwise fall back to the session ID
tmuxTarget = selected.runtimeHandle?.id ?? selected.id;
if (opts?.dashboard !== false && existingOrchestrators.length > 1) {
hasExistingOrchestrators = true;
}
@ -1067,9 +1064,6 @@ async function runStartup(
const systemPrompt = generateOrchestratorPrompt({ config, projectId, project });
const session = await sm.spawnOrchestrator({ projectId, systemPrompt });
selectedOrchestratorId = session.id;
if (session.runtimeHandle?.id) {
tmuxTarget = session.runtimeHandle.id;
}
reused =
orchestratorSessionStrategy === "reuse" &&
session.metadata?.["orchestratorSessionReused"] === "true";
@ -1115,7 +1109,10 @@ async function runStartup(
`http://localhost:${port}/sessions/${orchSessionId}`,
);
} else {
console.log(chalk.cyan("Orchestrator:"), `tmux attach -t ${tmuxTarget}`);
console.log(
chalk.cyan("Orchestrator:"),
`http://localhost:${port}/sessions/${orchSessionId}`,
);
}
} else if (reused) {
console.log(chalk.cyan("Orchestrator:"), `reused existing session (${sessionId})`);

View File

@ -4,6 +4,16 @@ export function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Strip optional 12-char hex hash prefix from a tmux session name.
* "1686e4aaaeaa-ao-145" "ao-145"
* "ao-145" "ao-145" (no-op if no hash prefix)
*/
export function stripHashPrefix(name: string): string {
const match = name.match(/^[a-f0-9]{12}-(.+)$/);
return match ? match[1] : name;
}
/** Check whether a session name matches a project prefix (strict: prefix-\d+ only). */
export function matchesPrefix(sessionName: string, prefix: string): boolean {
return new RegExp(`^${escapeRegex(prefix)}-\\d+$`).test(sessionName);