diff --git a/src/dev-dashboard/config.ts b/src/dev-dashboard/config.ts index 4d9e405b3..3105af06c 100644 --- a/src/dev-dashboard/config.ts +++ b/src/dev-dashboard/config.ts @@ -41,6 +41,8 @@ const TtydSessionSchema = z.object({ pid: z.number().int(), startedAt: z.string(), tmuxSessionName: z.string().optional(), + /** User-set display name; also mirrors the tmux session name after a unified rename. */ + name: z.string().optional(), }); const WeatherCoordsSchema = z.object({ diff --git a/src/dev-dashboard/contract/dto.ts b/src/dev-dashboard/contract/dto.ts index 62b7a9e47..55dfca150 100644 --- a/src/dev-dashboard/contract/dto.ts +++ b/src/dev-dashboard/contract/dto.ts @@ -113,7 +113,23 @@ export interface TmuxHubSession { name: string; attached: number; windows: number; + /** Active-pane facts from tmux itself — the only meta available for a session with no ttyd. */ + command?: string; + cwd?: string; + /** Raw `#{pane_title}`: Claude's ` `, a shell's own title, or absent. */ + title?: string; + /** Unix seconds. */ + created?: number; + lastActivity?: number; ttydTabIds: string[]; + ttydTabs: Array<{ + id: string; + port: number; + label: string; + cwd?: string; + lastCommand?: string; + title?: string; + }>; canAttachInTtyd: boolean; cmuxSurfaces: Array<{ workspaceId: string; surfaceId: string; title: string }>; inCmux: boolean; diff --git a/src/dev-dashboard/lib/tmux/claude-pane-title.test.ts b/src/dev-dashboard/lib/tmux/claude-pane-title.test.ts new file mode 100644 index 000000000..b89eca0cc --- /dev/null +++ b/src/dev-dashboard/lib/tmux/claude-pane-title.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { isClaudeForegroundCommand, parseClaudePaneTitle } from "@app/dev-dashboard/lib/tmux/claude-pane-title"; + +describe("parseClaudePaneTitle", () => { + test("strips working and idle markers", () => { + expect(parseClaudePaneTitle("✳ testt")).toBe("testt"); + expect(parseClaudePaneTitle("⠐ templates-todo")).toBe("templates-todo"); + expect(parseClaudePaneTitle("* testt")).toBe("testt"); + }); + + test("accepts every animated braille spinner frame, not just U+2810", () => { + // Observed live: a running session's title read `⠂ ttyd-naming` (U+2802) and parsed as null, + // so the sync silently succeeded or failed depending on poll timing. + for (const marker of ["⠂", "⠈", "⠠", "⣾", "⠋", "⠿", "⠀"]) { + expect(parseClaudePaneTitle(`${marker} ttyd-naming`)).toBe("ttyd-naming"); + } + }); + + test("keeps multi-word titles and sanitizes colons", () => { + expect(parseClaudePaneTitle("✳ Debug formatting: spacing")).toBe("Debug formatting- spacing"); + }); + + test("sanitizes dots — tmux target-syntax separators that 3.6a silently munges to _ on rename", () => { + expect(parseClaudePaneTitle("✳ Fix v1.2 bug")).toBe("Fix v1-2 bug"); + // Unicode ellipsis is NOT a dot — Claude's truncated auto-topic titles keep it. + expect(parseClaudePaneTitle("✳ Analyze slow HAR file load…")).toBe("Analyze slow HAR file load…"); + }); + + test("rejects non-Claude titles and the stock default", () => { + expect(parseClaudePaneTitle("zsh")).toBeNull(); + expect(parseClaudePaneTitle("/Users/me/proj")).toBeNull(); + expect(parseClaudePaneTitle("…/Projects/Foo")).toBeNull(); + expect(parseClaudePaneTitle("")).toBeNull(); + expect(parseClaudePaneTitle(undefined)).toBeNull(); + expect(parseClaudePaneTitle("✳ Claude Code")).toBeNull(); + expect(parseClaudePaneTitle("⠐ Claude Code")).toBeNull(); + }); +}); + +describe("isClaudeForegroundCommand", () => { + test("matches claude binaries only", () => { + expect(isClaudeForegroundCommand("claude")).toBe(true); + expect(isClaudeForegroundCommand("/opt/homebrew/bin/claude")).toBe(true); + expect(isClaudeForegroundCommand("claude-code")).toBe(true); + expect(isClaudeForegroundCommand("bash")).toBe(false); + expect(isClaudeForegroundCommand("cursor")).toBe(false); + expect(isClaudeForegroundCommand(undefined)).toBe(false); + }); +}); diff --git a/src/dev-dashboard/lib/tmux/claude-pane-title.ts b/src/dev-dashboard/lib/tmux/claude-pane-title.ts new file mode 100644 index 000000000..e957e251a --- /dev/null +++ b/src/dev-dashboard/lib/tmux/claude-pane-title.ts @@ -0,0 +1,53 @@ +/** + * Claude Code sets the tmux pane title via OSC to ` `. Strip the marker so we can + * mirror the name onto the tmux session + ttyd tab. + * + * The marker is `✳` while working, and otherwise an ANIMATED BRAILLE SPINNER FRAME — not a single + * fixed glyph. Matching only `⠐` (U+2810) made the sync succeed or fail depending on which frame + * happened to be current at poll time: a live session observed here read `⠂ ttyd-naming` (U+2802) + * and parsed as null. Accept the whole Braille Patterns block (U+2800–U+28FF). + * + * @see src/cmux/docs/Cmux.md — same title convention on cmux surfaces. + */ +const CLAUDE_PANE_TITLE_RE = /^[✳*⠀-⣿]\s*(.+)$/u; + +/** Stock title before the user runs `/rename` — never promote this to a session name. */ +const CLAUDE_DEFAULT_TITLES = new Set(["claude code", "claude"]); + +export function parseClaudePaneTitle(title: string | undefined | null): string | null { + if (!title) { + return null; + } + + const trimmed = title.trim(); + + if (!trimmed) { + return null; + } + + const match = trimmed.match(CLAUDE_PANE_TITLE_RE); + + if (!match?.[1]) { + return null; + } + + // tmux session names cannot contain `:` or `.` — both are target-syntax separators. + // tmux 3.6a even silently munges a renamed session's dots to `_`, which would desync + // the stored binding name from the real session. Collapse whitespace; keep the rest. + const name = match[1].replace(/[:.]/g, "-").replace(/\s+/g, " ").trim(); + + if (!name || CLAUDE_DEFAULT_TITLES.has(name.toLowerCase())) { + return null; + } + + return name; +} + +export function isClaudeForegroundCommand(command: string | undefined): boolean { + if (!command) { + return false; + } + + const base = command.trim().split("/").pop()?.toLowerCase() ?? ""; + return base === "claude" || base === "claude-code"; +} diff --git a/src/dev-dashboard/lib/tmux/create-session.ts b/src/dev-dashboard/lib/tmux/create-session.ts index 55a33b232..39e1fd3c3 100644 --- a/src/dev-dashboard/lib/tmux/create-session.ts +++ b/src/dev-dashboard/lib/tmux/create-session.ts @@ -2,20 +2,22 @@ import { env } from "@genesiscz/utils/env"; import { makeStandaloneTmuxSessionName } from "@genesiscz/utils/tmux/naming"; import { createTmuxSession, sessionExists } from "@genesiscz/utils/tmux/sessions"; -export function createStandaloneTmuxSession(opts: { name?: string; cwd?: string; command?: string } = {}): { +export async function createStandaloneTmuxSession( + opts: { name?: string; cwd?: string; command?: string } = {} +): Promise<{ sessionName: string; cwd: string; command: string; -} { +}> { const sessionName = opts.name?.trim() || makeStandaloneTmuxSessionName(); const cwd = opts.cwd ?? process.cwd(); const command = opts.command ?? env.paths.getShell("/bin/zsh"); - if (sessionExists(sessionName)) { + if (await sessionExists(sessionName)) { throw new Error(`tmux session ${sessionName} already exists`); } - createTmuxSession(sessionName, cwd, command); + await createTmuxSession(sessionName, cwd, command); return { sessionName, cwd, command }; } diff --git a/src/dev-dashboard/lib/tmux/hub.test.ts b/src/dev-dashboard/lib/tmux/hub.test.ts index d3c4b3307..c128d2483 100644 --- a/src/dev-dashboard/lib/tmux/hub.test.ts +++ b/src/dev-dashboard/lib/tmux/hub.test.ts @@ -8,13 +8,48 @@ describe("tmux hub enrichment", () => { { name: "free-session", attached: 0, windows: 1 }, { name: "busy-session", attached: 1, windows: 1 }, ], - [{ id: "tab-1", tmuxSessionName: "busy-session" }] + [{ id: "tab-1", port: 60586, command: "/bin/zsh", cwd: "/work", tmuxSessionName: "busy-session" }] ); expect(enriched[0]?.canAttachInTtyd).toBe(true); expect(enriched[0]?.ttydTabIds).toEqual([]); + expect(enriched[0]?.ttydTabs).toEqual([]); expect(enriched[1]?.canAttachInTtyd).toBe(false); expect(enriched[1]?.ttydTabIds).toEqual(["tab-1"]); + expect(enriched[1]?.ttydTabs).toEqual([ + { + id: "tab-1", + port: 60586, + label: "busy-session", + cwd: "/work", + lastCommand: undefined, + }, + ]); + }); + + test("surfaces ttyd display name / lastCommand on hub tabs", () => { + const enriched = enrichSessionsForHub( + [{ name: "bridge", attached: 1, windows: 1 }], + [ + { + id: "t1", + port: 50100, + command: "/bin/zsh", + cwd: "/Users/me/proj", + tmuxSessionName: "bridge", + name: "My Bridge", + lastCommand: "claude", + }, + ] + ); + + expect(enriched[0]?.ttydTabs[0]).toEqual({ + id: "t1", + port: 50100, + label: "My Bridge", + cwd: "/Users/me/proj", + lastCommand: "claude", + }); }); test("marks sessions attached in cmux", () => { @@ -25,5 +60,6 @@ describe("tmux hub enrichment", () => { expect(enriched[0]?.inCmux).toBe(true); expect(enriched[0]?.cmuxSurfaces).toHaveLength(1); + expect(enriched[0]?.ttydTabs).toEqual([]); }); }); diff --git a/src/dev-dashboard/lib/tmux/hub.ts b/src/dev-dashboard/lib/tmux/hub.ts index ada473c46..5fb206057 100644 --- a/src/dev-dashboard/lib/tmux/hub.ts +++ b/src/dev-dashboard/lib/tmux/hub.ts @@ -1,8 +1,21 @@ +import { ttydLabel } from "@app/dev-dashboard/lib/ttyd/label"; import type { CmuxTmuxSurfaceRef } from "@genesiscz/utils/cmux/tmux-bindings"; import type { TmuxSessionInfo } from "@genesiscz/utils/tmux/types"; +export interface TtydHubTab { + id: string; + port: number; + /** Identity. Never Claude's topic — see the name-vs-title note in ttyd/manager.ts. */ + label: string; + cwd?: string; + lastCommand?: string; + /** Claude's live topic, shown alongside the label rather than replacing it. */ + title?: string; +} + export interface TmuxHubSession extends TmuxSessionInfo { ttydTabIds: string[]; + ttydTabs: TtydHubTab[]; canAttachInTtyd: boolean; cmuxSurfaces: CmuxTmuxSurfaceRef[]; inCmux: boolean; @@ -10,7 +23,13 @@ export interface TmuxHubSession extends TmuxSessionInfo { interface TtydBinding { id: string; + port: number; + command: string; + cwd: string; tmuxSessionName?: string; + name?: string; + lastCommand?: string; + title?: string; } export function enrichSessionsForHub( @@ -18,26 +37,35 @@ export function enrichSessionsForHub( ttydSessions: TtydBinding[], cmuxBySession: Map = new Map() ): TmuxHubSession[] { - const ttydByTmux = new Map(); + const ttydByTmux = new Map(); for (const ttyd of ttydSessions) { if (!ttyd.tmuxSessionName) { continue; } + const tab: TtydHubTab = { + id: ttyd.id, + port: ttyd.port, + label: ttydLabel(ttyd), + cwd: ttyd.cwd, + lastCommand: ttyd.lastCommand, + title: ttyd.title, + }; const existing = ttydByTmux.get(ttyd.tmuxSessionName) ?? []; - existing.push(ttyd.id); + existing.push(tab); ttydByTmux.set(ttyd.tmuxSessionName, existing); } return sessions.map((session) => { - const ttydTabIds = ttydByTmux.get(session.name) ?? []; + const ttydTabs = ttydByTmux.get(session.name) ?? []; const cmuxSurfaces = cmuxBySession.get(session.name) ?? []; return { ...session, - ttydTabIds, - canAttachInTtyd: ttydTabIds.length === 0, + ttydTabIds: ttydTabs.map((tab) => tab.id), + ttydTabs, + canAttachInTtyd: ttydTabs.length === 0, cmuxSurfaces, inCmux: cmuxSurfaces.length > 0, }; diff --git a/src/dev-dashboard/lib/tmux/naming.test.ts b/src/dev-dashboard/lib/tmux/naming.test.ts new file mode 100644 index 000000000..1b8a8581c --- /dev/null +++ b/src/dev-dashboard/lib/tmux/naming.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test"; +import { makeCmuxTmuxSessionName, makeTtydTmuxSessionName } from "@app/dev-dashboard/lib/tmux/naming"; + +describe("tmux session naming", () => { + test("ttyd defaults to dd-<8hex>", () => { + expect(makeTtydTmuxSessionName("58bcf039-aaaa-bbbb-cccc-dddddddddddd")).toBe("dd-58bcf039"); + }); + + test("cmux defaults to dd-cmux-<8hex>", () => { + expect(makeCmuxTmuxSessionName()).toMatch(/^dd-cmux-[0-9a-f]{8}$/); + }); +}); diff --git a/src/dev-dashboard/lib/tmux/naming.ts b/src/dev-dashboard/lib/tmux/naming.ts index 7c861858b..47d7846ad 100644 --- a/src/dev-dashboard/lib/tmux/naming.ts +++ b/src/dev-dashboard/lib/tmux/naming.ts @@ -2,10 +2,11 @@ import { makeStandaloneTmuxSessionName } from "@genesiscz/utils/tmux/naming"; export { DEV_DASHBOARD_WORKSPACE } from "@app/dev-dashboard/lib/tmux/constants"; +/** Short default for ttyd-spawned sessions — was `dev-dashboard-<8hex>`, now `dd-<8hex>`. */ export function makeTtydTmuxSessionName(id: string): string { - return `dev-dashboard-${id.slice(0, 8)}`; + return `dd-${id.slice(0, 8)}`; } export function makeCmuxTmuxSessionName(): string { - return makeStandaloneTmuxSessionName("dev-dashboard-cmux"); + return makeStandaloneTmuxSessionName("dd-cmux"); } diff --git a/src/dev-dashboard/lib/tmux/presets.ts b/src/dev-dashboard/lib/tmux/presets.ts index 6fe0f1455..01679e5e8 100644 --- a/src/dev-dashboard/lib/tmux/presets.ts +++ b/src/dev-dashboard/lib/tmux/presets.ts @@ -71,7 +71,7 @@ export function savePreset(input: SavePresetInput, store?: TmuxPresetStore): Tmu return s.summarize(preset); } -export function restorePreset(name: string, store?: TmuxPresetStore): RestorePresetResult { +export async function restorePreset(name: string, store?: TmuxPresetStore): Promise { const s = resolveStore(store); const preset = s.read(name); @@ -82,7 +82,7 @@ export function restorePreset(name: string, store?: TmuxPresetStore): RestorePre for (const session of preset.sessions) { try { - const outcome = restoreTmuxSession(session); + const outcome = await restoreTmuxSession(session); outcomes.push(outcome); if (outcome.created) { diff --git a/src/dev-dashboard/lib/tmux/rename.ts b/src/dev-dashboard/lib/tmux/rename.ts index e9f1132ae..964b47bf0 100644 --- a/src/dev-dashboard/lib/tmux/rename.ts +++ b/src/dev-dashboard/lib/tmux/rename.ts @@ -1,6 +1,11 @@ -import { retargetTtydTmuxBindings } from "@app/dev-dashboard/lib/ttyd/manager"; +import { retargetTtydTmuxBindings, syncTtydDisplayNamesForTmux } from "@app/dev-dashboard/lib/ttyd/manager"; import { renameTmuxSession } from "@genesiscz/utils/tmux/sessions"; +/** + * Rename a live tmux session and keep every dashboard surface in sync: + * relaunch bound ttyd processes so `attach-session -t` tracks the new name, and + * mirror the name onto ttyd display labels so tabs match Session Hub. + */ export async function renameTmuxSessionInHub(fromName: string, toName: string): Promise { const trimmed = toName.trim(); @@ -8,8 +13,9 @@ export async function renameTmuxSessionInHub(fromName: string, toName: string): throw new Error("Destination tmux session name cannot be empty."); } - renameTmuxSession(fromName, trimmed); + await renameTmuxSession(fromName, trimmed); await retargetTtydTmuxBindings(fromName, trimmed); + await syncTtydDisplayNamesForTmux(trimmed, trimmed); return trimmed; } diff --git a/src/dev-dashboard/lib/ttyd/label.ts b/src/dev-dashboard/lib/ttyd/label.ts index 962acd488..a11c84056 100644 --- a/src/dev-dashboard/lib/ttyd/label.ts +++ b/src/dev-dashboard/lib/ttyd/label.ts @@ -1,16 +1,14 @@ -import type { TtydSession } from "./types"; +import { deriveTtydDisplayName, type TtydNameSource } from "@app/dev-dashboard/lib/ttyd/naming"; /** * Display label for a ttyd session. Pure — lives in its own module (not * `manager.ts`) so the browser can import it without dragging the server-only * manager (node:child_process, config → auth → node:crypto) into the client * bundle. + * + * Delegates to {@link deriveTtydDisplayName} so tab chrome and Session Hub share + * the same identity (tmux session name), not `zsh :port`. */ -export function ttydLabel(session: TtydSession): string { - const name = session.name?.trim(); - if (name) { - return name; - } - - return `${session.command.split("/").pop()} :${session.port}`; +export function ttydLabel(session: TtydNameSource): string { + return deriveTtydDisplayName(session); } diff --git a/src/dev-dashboard/lib/ttyd/manager.test.ts b/src/dev-dashboard/lib/ttyd/manager.test.ts index a522e8ed3..267dabdc2 100644 --- a/src/dev-dashboard/lib/ttyd/manager.test.ts +++ b/src/dev-dashboard/lib/ttyd/manager.test.ts @@ -90,7 +90,7 @@ describe.skipIf(!hasTtydDeps)("ttyd manager", () => { expect(fromName).toBeTruthy(); const toName = `retarget-test-${session.id.slice(0, 8)}`; - renameTmuxSession(fromName!, toName); + await renameTmuxSession(fromName!, toName); await retargetTtydTmuxBindings(fromName!, toName); @@ -98,8 +98,8 @@ describe.skipIf(!hasTtydDeps)("ttyd manager", () => { expect(listed?.tmuxSessionName).toBe(toName); expect(listed?.port).toBe(session.port); expect(listed?.id).toBe(session.id); - expect(sessionExists(toName)).toBe(true); - expect(sessionExists(fromName!)).toBe(false); + expect(await sessionExists(toName)).toBe(true); + expect(await sessionExists(fromName!)).toBe(false); // Live process must attach to the NEW name (this is the bug: config-only retarget). const ps = Bun.spawnSync(["/bin/ps", "-p", String(listed!.pid), "-o", "command="], { @@ -110,6 +110,23 @@ describe.skipIf(!hasTtydDeps)("ttyd manager", () => { expect(cmd).toContain(`-t ${toName}`); expect(cmd).not.toContain(`-t ${fromName}`); }); + + test("renameTtyd renames the bound tmux session and mirrors the display name", async () => { + const { renameTtyd } = await import("./manager"); + const session = await spawnTtyd({ command: "/bin/sh", cwd: process.cwd() }); + const fromName = session.tmuxSessionName; + expect(fromName).toBeTruthy(); + + const toName = `tab-rename-${session.id.slice(0, 8)}`; + const ok = await renameTtyd(session.id, toName); + expect(ok).toBe(true); + + const listed = (await listTtyd()).find((s) => s.id === session.id); + expect(listed?.name).toBe(toName); + expect(listed?.tmuxSessionName).toBe(toName); + expect(await sessionExists(toName)).toBe(true); + expect(await sessionExists(fromName!)).toBe(false); + }); }); describe.skipIf(!hasTtydDeps)("spawnTtyd persist-failure cleanup", () => { @@ -162,16 +179,22 @@ const labelBase: TtydSession = { }; describe("ttydLabel", () => { - test("falls back to ' :' when no name", () => { + test("falls back to tmux session name when bound", () => { + expect(ttydLabel({ ...labelBase, tmuxSessionName: "dev-dashboard-abc12345" })).toBe("dev-dashboard-abc12345"); + }); + + test("falls back to ' :' when unbound", () => { expect(ttydLabel(labelBase)).toBe("zsh :50245"); }); test("uses the custom name when set", () => { - expect(ttydLabel({ ...labelBase, name: "deploy-watch" })).toBe("deploy-watch"); + expect(ttydLabel({ ...labelBase, name: "deploy-watch", tmuxSessionName: "dev-dashboard-x" })).toBe( + "deploy-watch" + ); }); - test("blank name falls back", () => { - expect(ttydLabel({ ...labelBase, name: " " })).toBe("zsh :50245"); + test("blank name falls back to tmux when bound", () => { + expect(ttydLabel({ ...labelBase, name: " ", tmuxSessionName: "bridge" })).toBe("bridge"); }); }); diff --git a/src/dev-dashboard/lib/ttyd/manager.ts b/src/dev-dashboard/lib/ttyd/manager.ts index 4cd27db7d..46bf8f71a 100644 --- a/src/dev-dashboard/lib/ttyd/manager.ts +++ b/src/dev-dashboard/lib/ttyd/manager.ts @@ -1,12 +1,14 @@ import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { getConfig, saveTtydSessions } from "@app/dev-dashboard/config"; +import { isClaudeForegroundCommand, parseClaudePaneTitle } from "@app/dev-dashboard/lib/tmux/claude-pane-title"; import { makeTtydTmuxSessionName } from "@app/dev-dashboard/lib/tmux/naming"; import type { TtydSession } from "@app/dev-dashboard/lib/ttyd/types"; import { env } from "@genesiscz/utils/env"; import { logger } from "@genesiscz/utils/logger"; import { findFreePort } from "@genesiscz/utils/net/free-port"; import { killWithEscalation } from "@genesiscz/utils/process/killWithEscalation"; +import { profiler } from "@genesiscz/utils/profile"; import { buildTerminalSpawnEnv } from "@genesiscz/utils/terminal/locale"; import { resolveTmuxBin } from "@genesiscz/utils/tmux/bin"; import { @@ -14,8 +16,10 @@ import { ensureTmuxServerPersists, ensureTmuxSessionEnvironment, killTmuxSession, - listTmuxSessionCommands, + listTmuxSessionActivePanes, + renameTmuxSession, sessionExists, + type TmuxActivePaneInfo, } from "@genesiscz/utils/tmux/sessions"; import type { Subprocess } from "bun"; @@ -42,6 +46,12 @@ const registry = new Map(); const TTYD_BIN = "/opt/homebrew/bin/ttyd"; let hydrated = false; +// `listTtyd` is polled by several routes every few seconds and `spawnTtyd` sits on the +// interactive "new terminal" path, so both are worth breaking into phases: a slow request +// here is always one phase, never the whole function. +// PROFILE=ttyd,tmux tools dev-dashboard … +const prof = profiler.scope("ttyd"); + function tmuxAlreadyOpenInTtyd(tmuxSessionName: string): boolean { for (const tracked of registry.values()) { if (tracked.session.tmuxSessionName === tmuxSessionName) { @@ -57,20 +67,6 @@ function tmuxAlreadyOpenInTtyd(tmuxSessionName: string): boolean { * unrelated process that reused the PID. ttyd is spawned with a unique * `-b /ttyd/` base path, so its argv carries the session id as a marker. */ -function processMatchesSession(session: TtydSession): boolean { - const result = Bun.spawnSync(["/bin/ps", "-p", String(session.pid), "-o", "command="], { - stdio: ["ignore", "pipe", "ignore"], - }); - - if (result.exitCode !== 0) { - return false; - } - - const cmd = result.stdout.toString().trim(); - - return cmd.includes("ttyd") && cmd.includes(`/ttyd/${session.id}`); -} - async function processMatchesSessionAsync(session: TtydSession): Promise { const proc = Bun.spawn(["/bin/ps", "-p", String(session.pid), "-o", "command="], { stdio: ["ignore", "pipe", "ignore"], @@ -126,9 +122,11 @@ async function persistRegistry(): Promise { } const all = Array.from(registry.values()).map((tracked) => tracked.session); - const alive = await isSessionAliveBatch(all); + const alive = await prof.measureAsync("persist.aliveBatch", () => isSessionAliveBatch(all)); const sessions = all.filter((session) => alive.get(session.id) === true); - await saveTtydSessions(sessions); + // Re-reads the whole config off disk before writing it back; called once per spawn/kill + // AND once per renamed session inside the list hot path. + await prof.measureAsync("persist.saveConfig", () => saveTtydSessions(sessions)); } async function hydrateRegistry(): Promise { @@ -195,7 +193,7 @@ async function stopTtydProcess(tracked: Tracked, id: string): Promise { return; } - if (!processMatchesSession(tracked.session)) { + if (!(await processMatchesSessionAsync(tracked.session))) { logger.debug({ id, pid: tracked.session.pid }, "ttyd pid no longer ours; skipping kill"); return; } @@ -212,31 +210,31 @@ async function stopTtydProcess(tracked: Tracked, id: string): Promise { return; } - const poll = (): void => { + const poll = async (): Promise => { try { process.kill(pid, 0); // A live PID isn't necessarily still ttyd — the OS can reuse a PID // within the escalation grace window. Confirm ownership before // continuing to treat it as the process we're waiting to exit. - if (!processMatchesSession(tracked.session)) { + if (!(await processMatchesSessionAsync(tracked.session))) { listener(); return; } - setTimeout(poll, 200); + setTimeout(() => void poll(), 200); } catch (err) { // ESRCH means the process is actually gone; anything else (e.g. EPERM, // pid reused by a process we can't signal) means it's still alive. if (err && typeof err === "object" && "code" in err && err.code === "ESRCH") { listener(); } else { - setTimeout(poll, 200); + setTimeout(() => void poll(), 200); } } }; - poll(); + void poll(); }, }); } catch (err) { @@ -244,28 +242,6 @@ async function stopTtydProcess(tracked: Tracked, id: string): Promise { } } -/** - * ttyd's attach target is baked into argv at spawn (`tmux attach-session -t NAME`). - * Renaming the tmux session (or only updating config.tmuxSessionName) leaves a live - * ttyd forever trying the old name → "can't find session" + "Reconnecting…". - * True when the live process cmdline still contains the expected session name. - */ -function ttydProcessTargetsTmux(session: TtydSession, tmuxSessionName: string): boolean { - if (session.pid <= 0) { - return false; - } - - const result = Bun.spawnSync(["/bin/ps", "-p", String(session.pid), "-o", "command="], { - stdio: ["ignore", "pipe", "ignore"], - }); - - if (result.exitCode !== 0) { - return false; - } - - return argvTargetsTmux(result.stdout.toString(), tmuxSessionName); -} - /** * Match the attach target specifically — the base path also contains the ttyd * uuid which can look like a session fragment. @@ -294,10 +270,10 @@ export function argvTargetsTmux(cmd: string, tmuxSessionName: string): boolean { } /** - * Async sibling of ttydProcessTargetsTmux — the heal sweep runs one of these per - * live session on `listTtyd()`, and listTtyd is polled by several routes. Serial - * `Bun.spawnSync("/bin/ps")` there re-introduced exactly the blocking hot path - * `isSessionAliveBatch` was written to remove (10.4ms serial vs 2.6ms parallel, n=11). + * ttyd's attach target is baked into argv at spawn (`tmux attach-session -t NAME`). + * Renaming the tmux session (or only updating config.tmuxSessionName) leaves a live + * ttyd forever trying the old name → "can't find session" + "Reconnecting…". + * True when the live process cmdline still contains the expected session name. */ async function ttydProcessTargetsTmuxAsync(session: TtydSession, tmuxSessionName: string): Promise { if (session.pid <= 0) { @@ -455,27 +431,31 @@ async function waitForTtydListening(child: TtydChild, port: number): Promise { + // Split lock-wait from the work: queueing behind another caller's kill+relaunch is + // indistinguishable from a slow relaunch when only the route is timed. + const endLockWait = prof.start("relaunch.lockWait"); + return withTtydLifecycleLock(async () => { + endLockWait(); const prev = tracked.session; - if (!sessionExists(tmuxSessionName)) { + if (!(await sessionExists(tmuxSessionName))) { throw new Error(`tmux session ${tmuxSessionName} does not exist`); } // A concurrent caller (heal + retarget, or two polling routes) may have // relaunched this session at the same target while we waited for the lock. // Replacing a healthy child would drop live websockets for nothing. - if (ttydProcessTargetsTmux(prev, tmuxSessionName)) { + if (await ttydProcessTargetsTmuxAsync(prev, tmuxSessionName)) { tracked.session = { ...prev, tmuxSessionName }; registry.set(prev.id, tracked); logger.debug({ id: prev.id, tmuxSessionName }, "ttyd already targets this tmux session; skipping relaunch"); return; } - ensureTmuxSessionEnvironment(tmuxSessionName); - ensureTmuxServerPersists(); + await Promise.all([ensureTmuxSessionEnvironment(tmuxSessionName), ensureTmuxServerPersists()]); - await stopTtydProcess(tracked, prev.id); + await prof.measureAsync("relaunch.stopProcess", () => stopTtydProcess(tracked, prev.id)); // Port may still be draining for a tick after kill; retry bind briefly. let lastErr: unknown; @@ -497,7 +477,11 @@ async function relaunchTtydAtTmux(tracked: Tracked, tmuxSessionName: string): Pr // that failure surfaces asynchronously, so without this probe the // retry loop never retries and callers see a "successful" retarget // pointing at a dead endpoint. - await waitForTtydListening(launched.child, prev.port); + // + // Worst case here is 8 attempts × TTYD_LISTEN_TIMEOUT_MS plus backoff, so a + // per-attempt timer is the difference between "ttyd is slow" and "attempt 6". + const spawnedChild = launched.child; + await prof.measureAsync("relaunch.waitListening", () => waitForTtydListening(spawnedChild, prev.port)); tracked.session = launched.session; tracked.child = launched.child; @@ -521,7 +505,7 @@ async function relaunchTtydAtTmux(tracked: Tracked, tmuxSessionName: string): Pr } export async function spawnTtyd(opts: SpawnOptions = {}): Promise { - await hydrateRegistry(); + await prof.measureAsync("spawn.hydrate", () => hydrateRegistry()); if (!existsSync(TTYD_BIN)) { throw new Error(`ttyd not found at ${TTYD_BIN}`); @@ -530,13 +514,13 @@ export async function spawnTtyd(opts: SpawnOptions = {}): Promise { const rawCommand = opts.command ?? env.paths.getShell("/bin/zsh"); const command = rawCommand.trim().length > 0 && !rawCommand.includes("=") ? rawCommand.trim() : "/bin/zsh"; const cwd = opts.cwd ?? process.cwd(); - const port = await findFreePort(); + const port = await prof.measureAsync("spawn.freePort", () => findFreePort()); const id = randomUUID(); let tmuxSessionName: string; if (opts.attachTmuxSession) { - if (!sessionExists(opts.attachTmuxSession)) { + if (!(await sessionExists(opts.attachTmuxSession))) { throw new Error(`tmux session ${opts.attachTmuxSession} does not exist`); } @@ -546,21 +530,27 @@ export async function spawnTtyd(opts: SpawnOptions = {}): Promise { throw err; } - tmuxSessionName = opts.attachTmuxSession; - ensureTmuxSessionEnvironment(tmuxSessionName); - // Re-pin the server even when attaching to a pre-existing session — it may - // have been bootstrapped (by an older dashboard) with exit-empty on. - ensureTmuxServerPersists(); + const attachTo = opts.attachTmuxSession; + tmuxSessionName = attachTo; + await prof.measureAsync("spawn.tmuxAttachSetup", () => + Promise.all([ + ensureTmuxSessionEnvironment(attachTo), + // Re-pin the server even when attaching to a pre-existing session — it may + // have been bootstrapped (by an older dashboard) with exit-empty on. + ensureTmuxServerPersists(), + ]) + ); } else { - tmuxSessionName = makeTtydTmuxSessionName(id); - createTmuxSession(tmuxSessionName, cwd, command); + const created = makeTtydTmuxSessionName(id); + tmuxSessionName = created; + await prof.measureAsync("spawn.tmuxCreateSession", () => createTmuxSession(created, cwd, command)); } const { session, child } = launchTtydChild({ id, port, cwd, command, tmuxSessionName }); try { registry.set(id, { session, child }); - await persistRegistry(); + await prof.measureAsync("spawn.persist", () => persistRegistry()); } catch (err) { registry.delete(id); await killWithEscalation(child); @@ -575,14 +565,14 @@ export async function spawnTtyd(opts: SpawnOptions = {}): Promise { /** * Config/registry can say `tmuxSessionName: "bridge"` while the live ttyd still - * has `attach-session -t dev-dashboard-OLD` in argv (rename retarget used to only + * has `attach-session -t dd-OLD` in argv (rename retarget used to only * rewrite JSON). Heal by relaunching misaligned processes so list/UI recover without * a manual kill. */ const HEAL_TTL_MS = 3000; let lastHealAt = 0; -async function healStaleTtydTmuxTargets(): Promise { +async function healStaleTtydTmuxTargets(liveTmuxSessions: ReadonlySet): Promise { // Same reasoning as pruneDeadSessions' TTL: listTtyd is polled from several // routes every few seconds, and a stale attach target only appears on rename. if (Date.now() - lastHealAt < HEAL_TTL_MS) { @@ -592,11 +582,14 @@ async function healStaleTtydTmuxTargets(): Promise { lastHealAt = Date.now(); // One `ps` per session, all in flight at once — never a serial blocking sweep. + // Existence checks reuse the caller's single list-sessions result: a per-binding + // `sessionExists` here was an N+1 subprocess storm (10 bindings = 10 extra + // full `tmux list-sessions` calls per poll). const candidates = await Promise.all( Array.from(registry.values()).map(async (tracked): Promise => { const expected = tracked.session.tmuxSessionName; - if (!expected || !sessionExists(expected)) { + if (!expected || !liveTmuxSessions.has(expected)) { return null; } @@ -651,22 +644,53 @@ async function healStaleTtydTmuxTargets(): Promise { } export async function listTtyd(): Promise { - await hydrateRegistry(); - await pruneDeadSessions(); - await healStaleTtydTmuxTargets(); + await prof.measureAsync("list.hydrate", () => hydrateRegistry()); - // Refresh each session's live `lastCommand` from its bound tmux session (one list-sessions call, - // shared across all sessions). Drives the auto-name; a manual `name` still wins downstream. - const commandByTmux = listTmuxSessionCommands(); + // prune touches only ps + config; the tmux list touches only tmux — run them + // concurrently. (hydrate stays sequential: prune reads the hydrated registry.) + const [, panesByTmux] = await Promise.all([ + prof.measureAsync("list.prune", () => pruneDeadSessions()), + // One list-sessions call serving THREE consumers: heal's existence set, + // syncNames' pane titles, and the lastCommand enrichment below. + prof.measureAsync("list.activePanes", () => listTmuxSessionActivePanes()), + ]); + + await prof.measureAsync("list.heal", () => healStaleTtydTmuxTargets(new Set(panesByTmux.keys()))); return Array.from(registry.values()).map((tracked) => { const { session } = tracked; - const lastCommand = session.tmuxSessionName ? commandByTmux.get(session.tmuxSessionName) : undefined; + const pane = session.tmuxSessionName ? panesByTmux.get(session.tmuxSessionName) : undefined; - return { ...session, lastCommand }; + return { + ...session, + lastCommand: pane?.command || undefined, + title: claudeTopicForPane(pane) ?? undefined, + }; }); } +/** + * NAME vs TITLE. These are two different things and neither may overwrite the other: + * + * - `name` is IDENTITY. It comes from the tmux session name or an explicit rename (tab pencil, + * hub rename, cmux rename) and is what every surface labels the session with. + * - `title` is Claude Code's live topic, read off `#{pane_title}`. It is a derived, ephemeral + * fact — Claude re-summarizes mid-session and animates a spinner marker in front of it. + * + * This used to promote the title INTO the name (renaming the real tmux session and rewriting + * `session.name`), which silently destroyed manual names: a tab renamed "aaa" came back as + * "testt" the moment Claude re-emitted its topic. Auto-topics and `/rename` ride the exact same + * OSC escape, so there is no way to honour only the deliberate ones. Surface the topic alongside + * the name instead and let the name win wherever a single label is shown. + */ +function claudeTopicForPane(pane: TmuxActivePaneInfo | undefined): string | null { + if (!pane || !isClaudeForegroundCommand(pane.command)) { + return null; + } + + return parseClaudePaneTitle(pane.title); +} + /** * Resolve a session's port. The front-proxy (a *separate* process from the * vite-middleware that runs `spawnTtyd`) hits this for every /ttyd//* @@ -719,13 +743,69 @@ export async function renameTtyd(id: string, name: string): Promise { } const trimmed = name.trim(); - tracked.session.name = trimmed.length > 0 ? trimmed : undefined; + + // Clearing the display name only — keep the tmux session (identity still derives from + // tmuxSessionName via deriveTtydDisplayName). + if (!trimmed) { + tracked.session.name = undefined; + await persistRegistry(); + logger.info({ id, name: undefined }, "ttyd display name cleared"); + + return true; + } + + const fromTmux = tracked.session.tmuxSessionName; + + if (fromTmux && fromTmux !== trimmed) { + // One identity: tab rename = tmux rename. Retarget relaunches ttyd so attach argv tracks. + await renameTmuxSession(fromTmux, trimmed); + await retargetTtydTmuxBindings(fromTmux, trimmed); + } + + const after = registry.get(id); + + if (!after) { + return false; + } + + after.session.name = trimmed; await persistRegistry(); - logger.info({ id, name: tracked.session.name }, "ttyd renamed"); + logger.info( + { id, name: trimmed, tmuxSessionName: after.session.tmuxSessionName }, + "ttyd renamed (synced to tmux when bound)" + ); return true; } +/** After a hub-side tmux rename, mirror the new name onto every bound ttyd display label. */ +export async function syncTtydDisplayNamesForTmux(tmuxSessionName: string, displayName: string): Promise { + await hydrateRegistry(); + + const trimmed = displayName.trim(); + let changed = false; + + for (const tracked of registry.values()) { + if (tracked.session.tmuxSessionName !== tmuxSessionName) { + continue; + } + + const next = trimmed.length > 0 ? trimmed : undefined; + + if (tracked.session.name === next) { + continue; + } + + tracked.session.name = next; + changed = true; + } + + if (changed) { + await persistRegistry(); + logger.info({ tmuxSessionName, displayName: trimmed }, "synced ttyd display names after tmux rename"); + } +} + export async function retargetTtydTmuxBindings(fromName: string, toName: string): Promise { await hydrateRegistry(); @@ -776,7 +856,7 @@ export async function killTtyd(id: string, opts: KillTtydOptions = {}): Promise< await stopTtydProcess(tracked, id); if (opts.killTmux && tracked.session.tmuxSessionName) { - killTmuxSession(tracked.session.tmuxSessionName); + await killTmuxSession(tracked.session.tmuxSessionName); } registry.delete(id); diff --git a/src/dev-dashboard/lib/ttyd/naming.test.ts b/src/dev-dashboard/lib/ttyd/naming.test.ts index eeb929fc7..a53c28b88 100644 --- a/src/dev-dashboard/lib/ttyd/naming.test.ts +++ b/src/dev-dashboard/lib/ttyd/naming.test.ts @@ -28,15 +28,15 @@ describe("isMeaningfulCommand", () => { }); }); -describe("deriveTtydDisplayName precedence (manual wins — auto never overwrites)", () => { - it("a manual name beats a meaningful lastCommand", () => { +describe("deriveTtydDisplayName precedence (tmux identity over live command)", () => { + it("a manual name beats tmux session name and lastCommand", () => { const s = session({ name: "My Server", lastCommand: "vim", tmuxSessionName: "dev-dashboard-abc12345" }); expect(deriveTtydDisplayName(s)).toBe("My Server"); }); - it("auto-names from lastCommand when no manual name", () => { + it("prefers tmux session name over a meaningful lastCommand (shared hub identity)", () => { const s = session({ lastCommand: "claude", tmuxSessionName: "dev-dashboard-abc12345" }); - expect(deriveTtydDisplayName(s)).toBe("claude"); + expect(deriveTtydDisplayName(s)).toBe("dev-dashboard-abc12345"); }); it("falls back to tmux session name when lastCommand is just a shell", () => { @@ -44,13 +44,18 @@ describe("deriveTtydDisplayName precedence (manual wins — auto never overwrite expect(deriveTtydDisplayName(s)).toBe("dev-dashboard-abc12345"); }); - it("falls back to command when there is no tmux binding and no meaningful command", () => { + it("uses meaningful lastCommand when unbound", () => { + const s = session({ lastCommand: "node", command: "/bin/zsh" }); + expect(deriveTtydDisplayName(s)).toBe("node"); + }); + + it("falls back to command:port when unbound and no meaningful command", () => { const s = session({ command: "/bin/zsh", lastCommand: "zsh" }); - expect(deriveTtydDisplayName(s)).toBe("/bin/zsh"); + expect(deriveTtydDisplayName(s)).toBe("zsh :4001"); }); it("a whitespace-only manual name is treated as unset", () => { const s = session({ name: " ", lastCommand: "node", tmuxSessionName: "t" }); - expect(deriveTtydDisplayName(s)).toBe("node"); + expect(deriveTtydDisplayName(s)).toBe("t"); }); }); diff --git a/src/dev-dashboard/lib/ttyd/naming.ts b/src/dev-dashboard/lib/ttyd/naming.ts index c412bf013..71783e638 100644 --- a/src/dev-dashboard/lib/ttyd/naming.ts +++ b/src/dev-dashboard/lib/ttyd/naming.ts @@ -1,9 +1,16 @@ import type { TtydSession } from "@app/dev-dashboard/lib/ttyd/types"; +/** + * The subset of {@link TtydSession} the display-name derivation actually reads. Structural on + * purpose: hub enrichment passes contract-shaped bindings that lack `pid`/`startedAt`. + */ +export type TtydNameSource = Pick; + /** * Commands that are not meaningful as an auto-name — a session sitting at a shell prompt should fall * back to its tmux name / spawn command, not be labeled "zsh". Long-running foreground processes - * (claude, vim, node, …) ARE meaningful, so anything not in this set becomes the auto-name. + * (claude, vim, node, …) ARE meaningful, so anything not in this set becomes the auto-name when + * there is no tmux binding. */ const UNINTERESTING_COMMANDS = new Set(["zsh", "bash", "sh", "fish", "-zsh", "-bash", "login", "tmux"]); @@ -17,25 +24,36 @@ export function isMeaningfulCommand(command: string | undefined): command is str return trimmed.length > 0 && !UNINTERESTING_COMMANDS.has(trimmed); } +function portFallback(session: TtydNameSource): string { + return `${session.command.split("/").pop()} :${session.port}`; +} + /** - * The display name for a ttyd session, honoring the precedence the user asked for — a MANUAL name - * always wins, so the automatic command-derived name never overwrites a hand-set one: + * The display name for a ttyd session. Manual rename always wins; otherwise the **tmux session + * name** is the shared identity with Session Hub (so tabs like `zsh :60586` never diverge from the + * hub row). Meaningful foreground commands only auto-name when there is no tmux binding. * - * 1. `name` — explicit rename (the in-terminal pencil or a cmux rename). Sticky. - * 2. `lastCommand` — the live foreground command, when it is meaningful (auto-name). - * 3. `tmuxSessionName` — the bound tmux session. - * 4. `command` — the spawn command (always present). + * 1. `name` — explicit rename (tab pencil or hub rename). Sticky. + * 2. `tmuxSessionName` — the bound tmux session (shared with Session Hub). + * 3. `lastCommand` — live foreground command, when meaningful and unbound. + * 4. `command`:`port` — spawn fallback. */ -export function deriveTtydDisplayName(session: TtydSession): string { +export function deriveTtydDisplayName(session: TtydNameSource): string { const manual = session.name?.trim(); if (manual) { return manual; } + const tmux = session.tmuxSessionName?.trim(); + + if (tmux) { + return tmux; + } + if (isMeaningfulCommand(session.lastCommand)) { return session.lastCommand.trim(); } - return session.tmuxSessionName ?? session.command; + return portFallback(session); } diff --git a/src/dev-dashboard/lib/ttyd/types.ts b/src/dev-dashboard/lib/ttyd/types.ts index 3ce953c49..196b93224 100644 --- a/src/dev-dashboard/lib/ttyd/types.ts +++ b/src/dev-dashboard/lib/ttyd/types.ts @@ -6,14 +6,23 @@ export interface TtydSession { pid: number; startedAt: string; tmuxSessionName?: string; - /** User-set display name; falls back to " :" when unset. */ + /** + * User-set display name. When set (including after a unified rename), tabs and Session Hub share + * this identity with `tmuxSessionName`. Falls back via {@link deriveTtydDisplayName}. + */ name?: string; /** * Live command in the bound tmux session's active pane (`#{pane_current_command}`), refreshed on - * every `listTtyd()`. Drives an auto-name when the user has not set `name`. NOT persisted — it is - * a derived live fact, recomputed each read. Absent when the session has no tmux binding. + * every `listTtyd()`. Surfaced as secondary meta in the hub / CLI — not the primary tab label when + * a tmux binding exists. NOT persisted — derived live fact, recomputed each read. */ lastCommand?: string; + /** + * Claude Code's live topic from `#{pane_title}`, marker stripped. Informational ONLY: it is + * never promoted into `name`, because auto-topics and `/rename` are indistinguishable on the + * wire and promoting them destroyed manual names. NOT persisted — recomputed each read. + */ + title?: string; } export type SplitNode = diff --git a/src/dev-dashboard/server/adapters/bun-serve.ts b/src/dev-dashboard/server/adapters/bun-serve.ts index 44dd18cfc..6d9d736d4 100644 --- a/src/dev-dashboard/server/adapters/bun-serve.ts +++ b/src/dev-dashboard/server/adapters/bun-serve.ts @@ -2,6 +2,11 @@ import type { Router } from "@app/dev-dashboard/server/router"; import type { RouteContext, RouteResult, RouteServices, SseEmitter } from "@app/dev-dashboard/server/types"; import { SafeJSON } from "@genesiscz/utils/json"; import { logger } from "@genesiscz/utils/logger"; +import { profiler } from "@genesiscz/utils/profile"; + +// Per-endpoint handler time, keyed by route pattern so `:id` routes aggregate. +// PROFILE=route,ttyd,tmux tools dev-dashboard … +const prof = profiler.scope("route"); export function toResponse(result: RouteResult): Response { if (result.kind === "json") { @@ -119,5 +124,7 @@ export async function routerToResponse( services: opts.services, }; - return toResponse(await matched.def.handler(ctx)); + const label = `${matched.def.method} ${matched.def.pattern}`; + + return toResponse(await prof.measureAsync(label, async () => matched.def.handler(ctx))); } diff --git a/src/dev-dashboard/server/adapters/node-connect.ts b/src/dev-dashboard/server/adapters/node-connect.ts index 936a7ad57..fb5c34fb1 100644 --- a/src/dev-dashboard/server/adapters/node-connect.ts +++ b/src/dev-dashboard/server/adapters/node-connect.ts @@ -2,6 +2,11 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { RouteMatch, Router } from "@app/dev-dashboard/server/router"; import type { RouteContext, RouteResult, RouteServices, SseEmitter } from "@app/dev-dashboard/server/types"; import { SafeJSON } from "@genesiscz/utils/json"; +import { profiler } from "@genesiscz/utils/profile"; + +// Per-endpoint handler time, keyed by route pattern so `:id` routes aggregate. +// PROFILE=route,ttyd,tmux tools dev-dashboard … +const prof = profiler.scope("route"); async function readRawBytes(req: IncomingMessage): Promise { const chunks: Buffer[] = []; @@ -118,7 +123,8 @@ export async function handleWithRouter( services: opts.services, }; - const result = await matched.def.handler(ctx); + const label = `${matched.def.method} ${matched.def.pattern}`; + const result = await prof.measureAsync(label, async () => matched.def.handler(ctx)); writeResult(res, result); return true; diff --git a/src/dev-dashboard/server/routes/cmux.ts b/src/dev-dashboard/server/routes/cmux.ts index 53b151696..69902edbf 100644 --- a/src/dev-dashboard/server/routes/cmux.ts +++ b/src/dev-dashboard/server/routes/cmux.ts @@ -131,13 +131,13 @@ export function cmuxRoutes(): RouteDef[] { title: body.title, }); - // A cmux rename is an explicit user action, so it sets the ttyd MANUAL name - // (same tier as the in-terminal pencil) — best-effort, never fails the rename. + // One identity: cmux rename → ttyd display name + bound tmux session. + // Best-effort — never fails the cmux rename itself. if (ttydId) { try { await renameTtyd(ttydId, body.title); } catch (err) { - logger.debug({ err, ttydId }, "cmux rename: ttyd display-name propagation failed"); + logger.debug({ err, ttydId }, "cmux rename: ttyd/tmux sync failed"); } } } else { diff --git a/src/dev-dashboard/server/routes/tmux-presets.ts b/src/dev-dashboard/server/routes/tmux-presets.ts index 1592349cf..d4c137d70 100644 --- a/src/dev-dashboard/server/routes/tmux-presets.ts +++ b/src/dev-dashboard/server/routes/tmux-presets.ts @@ -48,7 +48,7 @@ export function tmuxPresetsRoutes(): RouteDef[] { return { kind: "json", status: 400, body: { error: "name is required" } }; } - return { kind: "json", status: 200, body: { result: restorePreset(body.name) } }; + return { kind: "json", status: 200, body: { result: await restorePreset(body.name) } }; } catch (err) { logger.warn({ err, route: "POST /api/tmux/presets/restore" }, "tmux presets: restore failed"); diff --git a/src/dev-dashboard/server/routes/tmux.ts b/src/dev-dashboard/server/routes/tmux.ts index f03e7b0fd..d101af226 100644 --- a/src/dev-dashboard/server/routes/tmux.ts +++ b/src/dev-dashboard/server/routes/tmux.ts @@ -8,8 +8,13 @@ import { fetchCmuxFullLayout } from "@genesiscz/utils/cmux/layout"; import type { CmuxTmuxSurfaceRef } from "@genesiscz/utils/cmux/tmux-bindings"; import { indexCmuxSurfacesByTmuxSession } from "@genesiscz/utils/cmux/tmux-bindings"; import { logger } from "@genesiscz/utils/logger"; +import { profiler } from "@genesiscz/utils/profile"; import { listTmuxSessions } from "@genesiscz/utils/tmux/sessions"; +// The route-level timer (server/adapters/*) gives the total; this splits it into the two +// halves that actually move — the optional cmux layout RPC and the ttyd list sweep. +const prof = profiler.scope("route"); + export function tmuxRoutes(): RouteDef[] { return [ { @@ -35,7 +40,9 @@ export function tmuxRoutes(): RouteDef[] { // workspace/surface ids+titles, never `preview`. Capturing the visible // screen of each selected surface (the default) added ~600ms to this // endpoint on a 12-workspace machine. - const layout = await fetchCmuxFullLayout({ includePreviews: false }); + const layout = await prof.measureAsync("tmux.cmuxLayout", () => + fetchCmuxFullLayout({ includePreviews: false }) + ); if (layout.available) { cmuxBySession = indexCmuxSurfacesByTmuxSession(layout); @@ -45,7 +52,11 @@ export function tmuxRoutes(): RouteDef[] { } } - const sessions = enrichSessionsForHub(listTmuxSessions(), await listTtyd(), cmuxBySession); + // Argument order preserved: `listTtyd()` can rename tmux sessions mid-call, and + // this list is deliberately the pre-rename snapshot. + const tmuxSessions = await prof.measureAsync("tmux.listSessions", () => listTmuxSessions()); + const ttydSessions = await prof.measureAsync("tmux.listTtyd", () => listTtyd()); + const sessions = enrichSessionsForHub(tmuxSessions, ttydSessions, cmuxBySession); return { kind: "json", status: 200, body: { sessions } }; }, @@ -57,7 +68,7 @@ export function tmuxRoutes(): RouteDef[] { try { const body = await ctx.readJson<{ name?: string; cwd?: string; command?: string }>(); - return { kind: "json", status: 200, body: createStandaloneTmuxSession(body) }; + return { kind: "json", status: 200, body: await createStandaloneTmuxSession(body) }; } catch (err) { logger.warn({ err, route: "POST /api/tmux/create" }, "tmux hub: create session failed"); diff --git a/src/dev-dashboard/ui/src/components/TmuxSessionName.tsx b/src/dev-dashboard/ui/src/components/TmuxSessionName.tsx index 24f9317da..d39b5457e 100644 --- a/src/dev-dashboard/ui/src/components/TmuxSessionName.tsx +++ b/src/dev-dashboard/ui/src/components/TmuxSessionName.tsx @@ -131,7 +131,8 @@ export function TmuxSessionName({ name, editable = true, size = "sm", className setEditing(true); }} className="shrink-0 rounded-md p-1 text-[var(--dd-text-muted)] transition-colors hover:bg-white/5 hover:text-[var(--dd-accent-from)]" - aria-label="Rename tmux session" + aria-label="Rename session (tmux + ttyd)" + title="Renames the tmux session and the ttyd tab together" > diff --git a/src/dev-dashboard/ui/src/components/TmuxSessionsPanel.tsx b/src/dev-dashboard/ui/src/components/TmuxSessionsPanel.tsx index 39e93fa2d..03609ece8 100644 --- a/src/dev-dashboard/ui/src/components/TmuxSessionsPanel.tsx +++ b/src/dev-dashboard/ui/src/components/TmuxSessionsPanel.tsx @@ -89,7 +89,8 @@ export function TmuxSessionsPanel({ open, onOpenChange, onFocusTtydTab }: Props) Session hub Tmux sessions - Attach in ttyd or send to cmux — shared tmux I/O across surfaces. + Same names as the terminal tabs — rename once, tmux + ttyd both update. Attach in ttyd + or send to cmux. @@ -181,7 +182,17 @@ function SessionRow({ onRemove?: () => void; onRenamed: (nextName: string) => void; }) { - const alreadyInTtyd = session.ttydTabIds.length > 0; + const alreadyInTtyd = (session.ttydTabs?.length ?? session.ttydTabIds.length) > 0; + const primaryTab = session.ttydTabs?.[0]; + // Fall back to tmux's own active-pane facts. Reading these only off the ttyd binding left every + // unbound session (a plain `tools tmux create`) rendering as a bare name with no meta at all. + const cwd = primaryTab?.cwd ?? session.cwd; + const lastCommand = primaryTab?.lastCommand ?? session.command; + const shortCwd = cwd ? shortenPath(cwd) : null; + // Prefer the server-parsed topic from the bound ttyd; fall back to parsing tmux's raw + // pane title so unbound sessions (a plain `tools tmux create`) show it too. + const topic = primaryTab?.title ?? claudeTopicFromTitle(session.title); + const idleFor = session.lastActivity ? formatSinceSeconds(session.lastActivity) : null; return (

{session.windows} window(s) · {session.attached} attached - {alreadyInTtyd ? ` · ttyd ×${session.ttydTabIds.length}` : ""} + {alreadyInTtyd + ? ` · ttyd ${ + (session.ttydTabs ?? []).map((t) => `:${t.port}`).join(" ") || + `×${session.ttydTabIds.length}` + }` + : ""} {session.inCmux && session.cmuxSurfaces.length > 0 ? ` · cmux ×${session.cmuxSurfaces.length}` : ""} + {idleFor ? ` · active ${idleFor} ago` : ""}

+ {topic ? ( +

+ {topic} +

+ ) : null} + {shortCwd || lastCommand ? ( +

+ {lastCommand ? {lastCommand} : null} + {lastCommand && shortCwd ? · : null} + {shortCwd ? {shortCwd} : null} +

+ ) : null}