From 24ecfc2926bce69f73fc0254e31d42a3ded3c88d Mon Sep 17 00:00:00 2001 From: adil Date: Wed, 8 Apr 2026 02:52:37 +0530 Subject: [PATCH] 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) --- packages/cli/__tests__/commands/open.test.ts | 2 +- packages/cli/__tests__/commands/spawn.test.ts | 13 +++++++------ packages/cli/__tests__/commands/start.test.ts | 10 +++++----- .../cli/__tests__/lib/session-utils.test.ts | 19 +++++++++++++++++++ packages/cli/src/commands/open.ts | 6 ++++-- packages/cli/src/commands/session.ts | 4 ++-- packages/cli/src/commands/spawn.ts | 5 +++-- packages/cli/src/commands/start.ts | 11 ++++------- packages/cli/src/lib/session-utils.ts | 10 ++++++++++ 9 files changed, 55 insertions(+), 25 deletions(-) diff --git a/packages/cli/__tests__/commands/open.test.ts b/packages/cli/__tests__/commands/open.test.ts index e05481233..efd8298e3 100644 --- a/packages/cli/__tests__/commands/open.test.ts +++ b/packages/cli/__tests__/commands/open.test.ts @@ -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 () => { diff --git a/packages/cli/__tests__/commands/spawn.test.ts b/packages/cli/__tests__/commands/spawn.test.ts index 57413df19..5e511a57f 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -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 () => { diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 7872d3fdd..c4a5cd03a 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -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 diff --git a/packages/cli/__tests__/lib/session-utils.test.ts b/packages/cli/__tests__/lib/session-utils.test.ts index a2d072736..1765b859f 100644 --- a/packages/cli/__tests__/lib/session-utils.test.ts +++ b/packages/cli/__tests__/lib/session-utils.test.ts @@ -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); diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts index f66a2f1a8..e2fc46552 100644 --- a/packages/cli/src/commands/open.ts +++ b/packages/cli/src/commands/open.ts @@ -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 { 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}`)}`, ); } } diff --git a/packages/cli/src/commands/session.ts b/packages/cli/src/commands/session.ts index ca1df02e8..af8461fe7 100644 --- a/packages/cli/src/commands/session.ts +++ b/packages/cli/src/commands/session.ts @@ -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}`)); diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index 17f9f3f41..7027b59e0 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -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; diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 7854fa0cc..fd9afd9ea 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -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})`); diff --git a/packages/cli/src/lib/session-utils.ts b/packages/cli/src/lib/session-utils.ts index 7f8e0c4b8..f623c448d 100644 --- a/packages/cli/src/lib/session-utils.ts +++ b/packages/cli/src/lib/session-utils.ts @@ -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);