diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift index 12a5e864c25b..5cf9694838c5 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift @@ -40,6 +40,10 @@ extension ControlCommandCoordinator { return await context?.controlMobileTaskModelsList( params: request.params ) + case "mobile.chat.send": + return await context?.controlMobileChatSend(params: request.params) + case "mobile.chat.interrupt": + return await context?.controlMobileChatInterrupt(params: request.params) default: return nil } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift index e2c845c91c88..3906eab8e4d8 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift @@ -136,6 +136,23 @@ public protocol ControlMobileHostContext: AnyObject { params: [String: JSONValue] ) async -> ControlCallResult + /// `mobile.chat.send` — types a prompt into an agent session's terminal, + /// the same delivery the iOS chat uses. + /// + /// - Parameter params: The decoded request params (`session_id`, `text`). + /// - Returns: The fully-built command result. + nonisolated func controlMobileChatSend( + params: [String: JSONValue] + ) async -> ControlCallResult + + /// `mobile.chat.interrupt` — interrupts an agent session's running turn. + /// + /// - Parameter params: The decoded request params (`session_id`, `hard`). + /// - Returns: The fully-built command result. + nonisolated func controlMobileChatInterrupt( + params: [String: JSONValue] + ) async -> ControlCallResult + /// `chat.sessions.dump` (local debug socket) — the full chat-session registry /// dump, for diagnosing inconsistent phone-side chat state. /// @@ -154,4 +171,18 @@ public extension ControlMobileHostContext { data: nil ) } + + /// Default for test and partial contexts without agent chat delivery. + nonisolated func controlMobileChatSend( + params: [String: JSONValue] + ) async -> ControlCallResult { + .err(code: "method_not_found", message: "Agent chat is unavailable", data: nil) + } + + /// Default for test and partial contexts without agent chat delivery. + nonisolated func controlMobileChatInterrupt( + params: [String: JSONValue] + ) async -> ControlCallResult { + .err(code: "method_not_found", message: "Agent chat is unavailable", data: nil) + } } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index 1047c0bd46b5..4e73bdd3fefe 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -107,9 +107,9 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "browser.profiles.delete", "browser.import.cookies", "mobile.attach_ticket.create", - // Provider discovery may read configuration or run `opencode models`; - // it must never hold the main actor while waiting for process I/O. - "mobile.task.models.list", + // Provider discovery reads config or runs `opencode models`; chat + // send/interrupt await terminal input. Neither may hold the main actor. + "mobile.task.models.list", "mobile.chat.send", "mobile.chat.interrupt", // `mobile.terminal.set_font` only validates params and emits a push // event via thread-safe MobileHostService statics, so it runs on the worker // like the other mobile data-plane verbs. Without this entry the policy diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorMobileHostTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorMobileHostTests.swift index 6989e69173d6..beebb1461d2b 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorMobileHostTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorMobileHostTests.swift @@ -69,6 +69,18 @@ private final class FakeMobileHostControlCommandContext: ControlCommandContext { } } + nonisolated func controlMobileChatSend( + params: [String: JSONValue] + ) async -> ControlCallResult { + await MainActor.run { record("chat.send", params) } + } + + nonisolated func controlMobileChatInterrupt( + params: [String: JSONValue] + ) async -> ControlCallResult { + await MainActor.run { record("chat.interrupt", params) } + } + func controlMobileChatSessionsDump() -> ControlCallResult { record("chat.sessions.dump", [:]) } @@ -127,6 +139,18 @@ struct ControlCommandCoordinatorMobileHostTests { ) } + @Test func workerSurfaceRoutesChatSendAndInterruptThroughAsyncSeam() async { + let (coordinator, context) = makeCoordinator() + let params: [String: JSONValue] = ["session_id": .string("abc"), "text": .string("hi")] + #expect(await coordinator.handleMobileHostAsync(request("mobile.chat.send", params), context: context) != nil) + #expect(context.lastMarker == "chat.send") + #expect(context.lastParams == params) + #expect(await coordinator.handleMobileHostAsync(request("mobile.chat.interrupt"), context: context) != nil) + #expect(context.lastMarker == "chat.interrupt") + #expect(ControlCommandExecutionPolicy(forMethod: "mobile.chat.send") == .socketWorker(mainThreadCallable: false)) + #expect(ControlCommandExecutionPolicy(forMethod: "mobile.chat.interrupt") == .socketWorker(mainThreadCallable: false)) + } + @Test func v2SurfaceUsesPrivateHostStatusVariant() { let (coordinator, context) = makeCoordinator() #expect(coordinator.handle(request("mobile.host.status")) != nil) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 3e3eef1ce9b1..1f1ce0cb41cb 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -143921,6 +143921,65 @@ } } }, + "command.openTerminalChatView.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open terminal as chat" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルをチャットで表示" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "以对话形式查看终端" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal als Chat öffnen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le terminal en discussion" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الطرفية كدردشة" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir la terminal como chat" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "以對話形式檢視終端機" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널을 채팅으로 열기" + } + } + } + }, "command.openDiffViewer.title": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate+AgentChat.swift b/Sources/AppDelegate+AgentChat.swift index 6de9213ebdf9..97fdd563d696 100644 --- a/Sources/AppDelegate+AgentChat.swift +++ b/Sources/AppDelegate+AgentChat.swift @@ -1,5 +1,6 @@ import AppKit import CMUXAgentLaunch +import CmuxSettings import Foundation import os import Security @@ -164,6 +165,25 @@ extension AppDelegate { return true } + /// Starts or reuses the agent-chat sidecar and returns its page base URL, + /// or nil when Agent Chat is off or the sidecar is unreachable. + func agentChatBrowserBaseURL(tabManager: TabManager, preferredWindow: NSWindow?) async -> URL? { + guard CmuxFeatureFlags.shared.isAgentChatUIEnabled, + BrowserAvailabilitySettings.isEnabled(), + AgentChatActionInFlightGate.begin() else { return nil } + defer { AgentChatActionInFlightGate.end() } + let store = mainWindowContext(for: tabManager)?.cmuxConfigStore + let agentChat = store?.agentChat ?? .default + AgentChatThemeSync.start() + let availability = await ensureAgentChatServerAvailable( + agentChat, + globalConfigPath: store?.globalConfigPath, + preferredWindow: preferredWindow + ) + AgentChatThemeSync.syncNow(agentChat: agentChat) + return availability.isReachable ? availability.browserURL : nil + } + @discardableResult private func openAgentChatWorkspace( tabManager: TabManager, @@ -320,6 +340,9 @@ extension AppDelegate { "CMUX_AGENT_CHAT_PORT": "0", "CMUX_AGENT_CHAT_STATE_FILE": stateFileURL.path, "CMUX_AGENT_CHAT_LAUNCH_ID": launchId, + // Terminal chat views deliver prompts over this app's socket. + "CMUX_SOCKET_PATH": TerminalController.shared.activeSocketPath(preferredPath: SocketControlSettings.socketPath()), + "CMUX_BUNDLED_CLI_PATH": CLIForwardingLaunchRouter.bundledCLIURL()?.path ?? "", ] ) else { return AgentChatServerAvailability(isReachable: false, browserURL: agentChat.url) diff --git a/Sources/ContentView+AgentChatCommandPalette.swift b/Sources/ContentView+AgentChatCommandPalette.swift index 9ab90cf3c846..bfd90bec56a6 100644 --- a/Sources/ContentView+AgentChatCommandPalette.swift +++ b/Sources/ContentView+AgentChatCommandPalette.swift @@ -33,6 +33,15 @@ extension ContentView { subtitle: { _ in String(localized: "command.newAgentChat.subtitle", defaultValue: "Agent Chat") }, keywords: ["create", "new", "agent", "chat", "browser", "codex", "claude"], when: { !$0.bool(CommandPaletteContextKeys.browserDisabled) } + ), CommandPaletteCommandContribution( + commandId: "palette.openTerminalChatView", + title: { _ in String(localized: "command.openTerminalChatView.title", defaultValue: "Open terminal as chat") }, + subtitle: { _ in String(localized: "command.newAgentChat.subtitle", defaultValue: "Agent Chat") }, + keywords: ["terminal", "chat", "view", "transcript", "gui", "agent", "claude", "codex"], + when: { + $0.bool(CommandPaletteContextKeys.panelIsTerminal) + && !$0.bool(CommandPaletteContextKeys.browserDisabled) + } )] } @@ -55,6 +64,21 @@ extension ContentView { NSSound.beep() } } + registerTerminalChatViewCommandPaletteHandler(®istry) + } + + /// Registers the read-only chat view for the focused terminal's agent. + func registerTerminalChatViewCommandPaletteHandler(_ registry: inout CommandPaletteHandlerRegistry) { + registry.register(commandId: "palette.openTerminalChatView") { + guard let context = focusedPanelContext, context.panel.panelType == .terminal else { + NSSound.beep() + return + } + context.workspace.openTerminalChatView( + terminalPanelId: context.panelId, + presentingWindow: AppDelegate.shared?.mainWindow(for: windowId) + ) + } } /// Palette context key indicating that the selected workspace is remotely managed. diff --git a/Sources/TerminalController+Capabilities.swift b/Sources/TerminalController+Capabilities.swift index e49a038c2480..8845d116da0f 100644 --- a/Sources/TerminalController+Capabilities.swift +++ b/Sources/TerminalController+Capabilities.swift @@ -57,6 +57,8 @@ extension TerminalController { "mobile.compatible_tags.set", "mobile.task.attachment.upload", "mobile.task.models.list", + "mobile.chat.send", + "mobile.chat.interrupt", // Socket-reachable panel artifact reads (worker lane); fetch stays // behind the authenticated mobile execution context. "mobile.panel.artifact.stat", diff --git a/Sources/TerminalController+ControlMobileHostContext.swift b/Sources/TerminalController+ControlMobileHostContext.swift index 299f74563eb9..7fb2e2dccfeb 100644 --- a/Sources/TerminalController+ControlMobileHostContext.swift +++ b/Sources/TerminalController+ControlMobileHostContext.swift @@ -77,6 +77,20 @@ extension TerminalController: ControlMobileHostContext { ) } + nonisolated func controlMobileChatSend( + params: [String: JSONValue] + ) async -> ControlCallResult { + let params = foundationParams(params) + return bridgeMobileResult(await v2MobileChatSend(params: params)) + } + + nonisolated func controlMobileChatInterrupt( + params: [String: JSONValue] + ) async -> ControlCallResult { + let params = foundationParams(params) + return bridgeMobileResult(await v2MobileChatInterrupt(params: params)) + } + func controlMobileChatSessionsDump() -> ControlCallResult { bridgeMobileResult(v2ChatSessionsDump()) } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 260c8dfbaee0..dd26fdf3a48d 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1308,7 +1308,7 @@ class TerminalController { await self.v2SurfaceReadSelection(params: parsedRequest.params) } } - if request.method == "mobile.task.models.list" { + if ["mobile.task.models.list", "mobile.chat.send", "mobile.chat.interrupt"].contains(request.method) { return v2AsyncResultCall( id: request.id, timeoutSeconds: 7 diff --git a/Sources/Workspace+AgentChat.swift b/Sources/Workspace+AgentChat.swift index ac4dc7ca9419..ca3c1a8568f8 100644 --- a/Sources/Workspace+AgentChat.swift +++ b/Sources/Workspace+AgentChat.swift @@ -9,4 +9,43 @@ extension Workspace { preferredWindow: presentingWindow ) } + + /// Opens a read-only chat view of the agent running in a terminal panel. + /// The agent-chat sidecar renders the agent's own transcript, so the + /// terminal process stays the only agent. + func openTerminalChatView(terminalPanelId: UUID, presentingWindow: NSWindow?) { + guard let owningTabManager, let appDelegate = AppDelegate.shared else { + NSSound.beep() + return + } + Task { @MainActor [weak self, weak owningTabManager] in + guard let owningTabManager, + let base = await appDelegate.agentChatBrowserBaseURL( + tabManager: owningTabManager, + preferredWindow: presentingWindow + ), + let self, + let url = Self.terminalChatViewURL(base: base, terminalPanelId: terminalPanelId), + self.newBrowserSplit( + from: terminalPanelId, + orientation: .horizontal, + url: url, + transparentBackground: true + ) != nil else { + NSSound.beep() + return + } + } + } + + /// `/terminal/?transparent=1`; the sidecar maps the + /// panel to its agent session through the hook session stores. + static func terminalChatViewURL(base: URL, terminalPanelId: UUID) -> URL? { + let path = base + .appendingPathComponent("terminal", isDirectory: true) + .appendingPathComponent(terminalPanelId.uuidString) + var components = URLComponents(url: path, resolvingAgainstBaseURL: false) + components?.queryItems = [URLQueryItem(name: "transparent", value: "1")] + return components?.url + } } diff --git a/agent-chat/README.md b/agent-chat/README.md index 7a756fa94e80..31413b30cb76 100644 --- a/agent-chat/README.md +++ b/agent-chat/README.md @@ -12,6 +12,12 @@ Three entrypoints, all landing on the same server: One page = one session: `/` is the composer, `/s/` a chat. When `CMUX_AGENT_CHAT_TOKEN` or `--token` is configured, every HTTP route, static asset, API route, and WebSocket upgrade except `/healthz` must be under `//...`; missing or wrong tokens return 404. There is deliberately no in-page session list or header; each chat is its own cmux workspace tab (page title = first prompt), so cmux's sidebar is the session list. +## Terminal chat view + +`/terminal/` shows a Claude Code or Codex session that is already running in a cmux terminal as a chat, without starting another agent. The sidecar looks the surface up in the hook session stores (`~/.cmuxterm/claude-hook-sessions.json`, `~/.cmuxterm/codex-hook-sessions.json`), tails the agent's own transcript (Claude `projects//.jsonl`, Codex `sessions/YYYY/MM/DD/rollout-*-.jsonl`), and normalizes it into `AgentEvent`s with `adapters/transcript.ts`, so the regular chat renderer draws it. The page lives at `/s/t-`, which re-resolves after a sidecar restart; a view with no open page stops tailing after five minutes. + +The terminal stays the source of truth. The composer types the prompt into the terminal's agent with `mobile.chat.send` (bracketed paste and submit, the delivery the iOS chat uses), and Stop or Esc sends `mobile.chat.interrupt`; both go over the cmux control socket through `cmux rpc` (`cmux-rpc.ts`, using the `CMUX_BUNDLED_CLI_PATH` and `CMUX_SOCKET_PATH` the app passes to the sidecar). The prompt shows immediately and is replaced by the transcript's own copy when it lands. Permission prompts, questions, and pickers stay in the terminal: when the hook store says the agent needs input, the view shows the request with an "Answer in terminal" button that focuses the terminal pane. Open the view with "Open terminal as chat" in the command palette (Agent Chat UI flag) or `cmux-chat --terminal --surface ` from a shell. + ## Model catalog The sidecar fetches the model catalog from `https://cmux.dev/api/agent-models` (`CMUX_AGENT_MODELS_URL` overrides it for development), revalidates it with ETags after a one-hour TTL, and caches the last-good response at `~/.cache/cmux-agent-chat/models.json` for offline startup. Refreshes happen in the background; changed catalogs are pushed to open pages so model pickers update without reloading. diff --git a/agent-chat/adapters/transcript.ts b/agent-chat/adapters/transcript.ts new file mode 100644 index 000000000000..25fde57cb302 --- /dev/null +++ b/agent-chat/adapters/transcript.ts @@ -0,0 +1,541 @@ +// Terminal chat view: renders the JSONL transcript a terminal agent (Claude +// Code, Codex) writes as it runs, so a cmux terminal can be shown as a chat +// without starting a second agent process. The terminal stays the source of +// truth: this adapter tails the file and normalizes it into AgentEvent, and +// prompts and interrupts go to the terminal through the cmux control socket. +import { open, stat } from "node:fs/promises"; +import type { Adapter, AgentEvent, OptionValue, SessionCtx } from "../types"; +import { cmuxRpc, type CmuxRpcResult } from "../cmux-rpc"; +import { tryParse, truncate } from "./lines"; + +export type TranscriptAgent = "claude" | "codex"; + +/** The terminal agent session a transcript view drives. */ +export interface TranscriptTarget { + agentSessionId: string; + surfaceId?: string; +} + +type Rpc = (method: string, params: Record) => Promise; +let rpc: Rpc = cmuxRpc; + +export function setTranscriptRpcForTest(next: Rpc | null) { + rpc = next ?? cmuxRpc; +} + +export function transcriptTarget(sess: SessionCtx): TranscriptTarget | undefined { + return sess.internal.transcriptTarget as TranscriptTarget | undefined; +} + +/** Focuses the terminal pane that runs the agent (for prompts the view cannot answer). */ +export async function focusTranscriptTerminal(sess: SessionCtx): Promise { + const surfaceId = transcriptTarget(sess)?.surfaceId; + if (!surfaceId) return { ok: false, error: "The terminal for this session is unknown." }; + return rpc("surface.focus", { surface_id: surfaceId }); +} + +export interface TranscriptParser { + readonly agent: TranscriptAgent; + /** Latest session title the transcript reported (Claude `ai-title`, Codex thread name). */ + title?: string; + /** Working directory the transcript reported, when any. */ + cwd?: string; + parse(line: string): AgentEvent[]; +} + +export function transcriptParser(agent: TranscriptAgent): TranscriptParser { + return agent === "codex" ? new CodexTranscriptParser() : new ClaudeTranscriptParser(); +} + +/** Parses a whole transcript; used by tests and the initial load. */ +export function parseTranscriptText(agent: TranscriptAgent, text: string): { events: AgentEvent[]; title?: string; cwd?: string } { + const parser = transcriptParser(agent); + const events: AgentEvent[] = []; + for (const line of text.split("\n")) { + if (line.trim()) events.push(...parser.parse(line)); + } + return { events, title: parser.title, cwd: parser.cwd }; +} + +// Tool inputs worth showing as the one-line activity detail, in priority order. +const TOOL_DETAIL_KEYS = ["command", "cmd", "file_path", "pattern", "path", "url", "query", "description", "prompt", "skill"]; + +export function toolDetail(input: unknown): string { + if (input == null) return ""; + if (typeof input === "string") { + const parsed = tryParse(input); + if (parsed && typeof parsed === "object") return toolDetail(parsed); + return truncate(input); + } + if (typeof input !== "object") return truncate(String(input)); + const record = input as Record; + for (const key of TOOL_DETAIL_KEYS) { + const value = record[key]; + if (typeof value === "string" && value.trim()) return truncate(value); + if (Array.isArray(value) && value.every((v) => typeof v === "string") && value.length) return truncate(value.join(" ")); + } + return truncate(JSON.stringify(record)); +} + +function textOf(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((c: any) => (typeof c === "string" ? c : typeof c?.text === "string" ? c.text : c?.type === "image" ? "[image]" : "")) + .filter(Boolean) + .join("\n"); +} + +function tagValue(text: string, tag: string): string | undefined { + const match = text.match(new RegExp(`<${tag}>([\\s\\S]*?)`)); + return match?.[1]?.trim(); +} + +// Claude Code wraps harness bookkeeping in pseudo-XML user messages. These are +// never something the person typed, so they are hidden from the transcript. +const CLAUDE_HIDDEN_USER_TAGS = /^<(system-reminder|local-command-caveat|user-prompt-submit-hook|agent-message)>/; + +class ClaudeTranscriptParser implements TranscriptParser { + readonly agent = "claude" as const; + title?: string; + cwd?: string; + private seen = new Set(); + private turnOpen = false; + private endedMessageIds = new Set(); + + parse(line: string): AgentEvent[] { + const events = this.parseLine(line); + for (const evt of events) { + if (evt.kind === "done") this.turnOpen = false; + else if (evt.kind !== "status") this.turnOpen = true; + } + return events; + } + + /** Appends a turn end unless the turn already ended. */ + private endTurn(events: AgentEvent[], stats?: string): AgentEvent[] { + const open = this.turnOpen || events.some((e) => e.kind !== "status"); + return open ? [...events, stats ? { kind: "done", stats } : { kind: "done" }] : events; + } + + private parseLine(line: string): AgentEvent[] { + const ev = tryParse(line); + if (!ev || typeof ev !== "object") return []; + if (ev.isSidechain) return []; + if (typeof ev.cwd === "string" && ev.cwd) this.cwd = ev.cwd; + // Claude rewrites nothing, but resumed sessions can replay an entry; a + // uuid seen before is ignored so the view does not duplicate turns. + if (typeof ev.uuid === "string") { + if (this.seen.has(ev.uuid)) return []; + this.seen.add(ev.uuid); + } + switch (ev.type) { + case "ai-title": + if (typeof ev.aiTitle === "string" && ev.aiTitle.trim()) this.title = ev.aiTitle.trim(); + return []; + case "summary": + if (typeof ev.summary === "string" && ev.summary.trim() && !this.title) this.title = ev.summary.trim(); + return []; + case "user": + return this.user(ev); + case "assistant": + return this.assistant(ev); + case "system": + return this.system(ev); + default: + return []; + } + } + + private user(ev: any): AgentEvent[] { + if (ev.isMeta || ev.isCompactSummary || ev.isVisibleInTranscriptOnly) return []; + const originKind = ev.origin?.kind; + const content = ev.message?.content; + if (originKind === "task-notification") { + const text = typeof content === "string" ? content : textOf(content); + const summary = tagValue(text, "summary") ?? tagValue(text, "status"); + return [{ kind: "status", text: summary ? `Background task: ${truncate(summary, 160)}` : "Background task update" }]; + } + if (originKind && originKind !== "human") return []; + const out: AgentEvent[] = []; + if (Array.isArray(content)) { + for (const block of content) { + if (block?.type !== "tool_result") continue; + const result = textOf(block.content); + out.push({ kind: "tool-end", toolId: String(block.tool_use_id ?? ""), ok: !block.is_error, detail: truncate(result, 400) }); + } + // Claude often stores a typed prompt after injected reminder blocks in + // the same message, so hidden blocks are dropped one by one. + const prompt = content.filter((b: any) => + b?.type === "image" || (b?.type === "text" && typeof b.text === "string" && !CLAUDE_HIDDEN_USER_TAGS.test(b.text.trim()))); + if (prompt.length) out.push(...this.prompt(textOf(prompt))); + return out; + } + if (typeof content === "string") return this.prompt(content); + return out; + } + + private prompt(raw: string): AgentEvent[] { + const text = raw.trim(); + if (!text) return []; + if (text.startsWith("[Request interrupted by user")) return this.endTurn([{ kind: "status", text: "Interrupted" }]); + if (CLAUDE_HIDDEN_USER_TAGS.test(text)) return []; + const command = tagValue(text, "command-name"); + if (command) { + const args = tagValue(text, "command-args"); + return [{ kind: "user", text: args ? `${command} ${args}` : command }]; + } + const stdout = tagValue(text, "local-command-stdout"); + if (stdout !== undefined) return stdout ? [{ kind: "status", text: truncate(stdout, 200) }] : []; + const bash = tagValue(text, "bash-input"); + if (bash !== undefined) return [{ kind: "user", text: `! ${bash}` }]; + if (text.startsWith("") || text.startsWith("")) { + const output = [tagValue(text, "bash-stdout"), tagValue(text, "bash-stderr")].filter(Boolean).join("\n"); + return output ? [{ kind: "status", text: truncate(output, 200) }] : []; + } + return [{ kind: "user", text }]; + } + + private assistant(ev: any): AgentEvent[] { + const out: AgentEvent[] = []; + const content = ev.message?.content; + if (ev.isApiErrorMessage) { + const message = textOf(content); + return message ? [{ kind: "error", message: truncate(message, 400) }] : []; + } + if (typeof content === "string") { + if (content.trim()) out.push({ kind: "assistant", text: content }); + } else if (Array.isArray(content)) { + out.push(...this.assistantBlocks(content)); + } + // `turn_duration` is only written for some turns; the final assistant + // message's stop reason marks the end of every turn. Each content block + // is its own line carrying the message's stop reason, and thinking comes + // before the text, so the turn ends at the message's first text line. + const stop = ev.message?.stop_reason; + const messageId = String(ev.message?.id ?? ev.uuid ?? ""); + const hasText = out.some((e) => e.kind === "assistant"); + if ((stop === "end_turn" || stop === "stop_sequence") && hasText && !this.endedMessageIds.has(messageId)) { + this.endedMessageIds.add(messageId); + return this.endTurn(out); + } + return out; + } + + private assistantBlocks(content: any[]): AgentEvent[] { + const out: AgentEvent[] = []; + for (const block of content) { + if (block?.type === "text" && typeof block.text === "string" && block.text.trim()) { + out.push({ kind: "assistant", text: block.text }); + } else if (block?.type === "thinking" && typeof block.thinking === "string" && block.thinking.trim()) { + out.push({ kind: "thinking", text: block.thinking }); + } else if (block?.type === "tool_use" || block?.type === "server_tool_use") { + out.push({ kind: "tool-start", toolId: String(block.id ?? ""), name: String(block.name ?? "tool"), detail: toolDetail(block.input) }); + } + } + return out; + } + + private system(ev: any): AgentEvent[] { + switch (ev.subtype) { + case "turn_duration": { + const ms = Number(ev.durationMs); + return this.endTurn([], Number.isFinite(ms) && ms > 0 ? `${(ms / 1000).toFixed(1)}s` : undefined); + } + case "compact_boundary": + return [{ kind: "status", text: "Conversation compacted" }]; + case "api_error": + return [{ kind: "error", message: truncate(String(ev.content ?? ev.error?.message ?? "API error"), 400) }]; + default: + return []; + } + } +} + +// Codex rollout files mix three generations of records: `event_msg` user and +// agent messages, `response_item` model items, and newer `item_completed` +// events. Model items carry tools and reasoning; user prompts come from the +// event records because response_item user messages include injected context. +class CodexTranscriptParser implements TranscriptParser { + readonly agent = "codex" as const; + title?: string; + cwd?: string; + private lastUser?: string; + private sinceUser = 0; + + parse(line: string): AgentEvent[] { + const ev = tryParse(line); + if (!ev || typeof ev !== "object") return []; + const payload = ev.payload ?? {}; + switch (ev.type) { + case "session_meta": + if (typeof payload.cwd === "string") this.cwd = payload.cwd; + return []; + case "turn_context": + if (typeof payload.cwd === "string") this.cwd = payload.cwd; + return []; + case "compacted": + return this.track([{ kind: "status", text: "Conversation compacted" }]); + case "event_msg": + return this.track(this.eventMsg(payload)); + case "response_item": + return this.track(this.responseItem(payload)); + default: + return []; + } + } + + private track(events: AgentEvent[]): AgentEvent[] { + for (const evt of events) { + if (evt.kind === "user") { + this.lastUser = evt.text; + this.sinceUser = 0; + } else { + this.sinceUser++; + } + } + return events; + } + + private user(text: unknown): AgentEvent[] { + const value = typeof text === "string" ? text.trim() : ""; + if (!value) return []; + // The same prompt is recorded by both `user_message` and `item_completed`. + if (value === this.lastUser && this.sinceUser === 0) return []; + return [{ kind: "user", text: value }]; + } + + private eventMsg(payload: any): AgentEvent[] { + switch (payload.type) { + case "user_message": + return this.user(payload.message); + case "item_completed": + if (payload.item?.type === "UserMessage") return this.user(textOf(payload.item.content)); + return []; + case "task_complete": + return [{ kind: "done" }]; + case "turn_aborted": + return [{ kind: "status", text: "Interrupted" }, { kind: "done" }]; + case "error": + return [{ kind: "error", message: truncate(String(payload.message ?? "error"), 400) }]; + case "thread_name_updated": + if (typeof payload.thread_name === "string" && payload.thread_name.trim()) this.title = payload.thread_name.trim(); + return []; + default: + return []; + } + } + + private responseItem(payload: any): AgentEvent[] { + switch (payload.type) { + case "message": { + if (payload.role !== "assistant") return []; + const text = textOf(payload.content).trim(); + return text ? [{ kind: "assistant", text }] : []; + } + case "reasoning": { + const text = Array.isArray(payload.summary) ? textOf(payload.summary).trim() : ""; + return text ? [{ kind: "thinking", text }] : []; + } + case "function_call": + case "custom_tool_call": + case "local_shell_call": { + const input = payload.arguments ?? payload.input ?? payload.action; + return [{ kind: "tool-start", toolId: String(payload.call_id ?? payload.id ?? ""), name: String(payload.name ?? "shell"), detail: toolDetail(input) }]; + } + case "function_call_output": + case "custom_tool_call_output": { + const output = typeof payload.output === "string" ? payload.output : textOf(payload.output?.content ?? payload.output); + return [{ kind: "tool-end", toolId: String(payload.call_id ?? ""), detail: truncate(output, 400) }]; + } + default: + return []; + } + } +} + +// Initial loads read at most this much from the end of a transcript; long +// sessions start at a line boundary inside the window. +export const TRANSCRIPT_INITIAL_WINDOW_BYTES = 8 * 1024 * 1024; +const TRANSCRIPT_POLL_MS = 500; +const TRANSCRIPT_READ_CHUNK = 1024 * 1024; + +/** Follows an append-only JSONL file by offset, delivering complete lines. */ +export class TranscriptTail { + private offset = -1; + private pending = ""; + private timer: ReturnType | null = null; + private inflight: Promise | null = null; + private decoder = new TextDecoder(); + + constructor( + readonly path: string, + private readonly onLines: (lines: string[], mtimeMs: number) => void, + private readonly opts: { pollMs?: number; initialWindowBytes?: number } = {}, + ) {} + + start() { + if (this.timer) return; + // A transcript that stops being readable between stat and open (deleted, + // or a root-owned file) must not reject out of the timer: an unhandled + // rejection ends the whole sidecar. The next poll tries again. + const tick = () => void this.poll().catch(() => {}); + tick(); + this.timer = setInterval(tick, this.opts.pollMs ?? TRANSCRIPT_POLL_MS); + } + + stop() { + if (this.timer) clearInterval(this.timer); + this.timer = null; + } + + /** Reads everything appended since the last poll; joins a read in flight. */ + poll(): Promise { + if (!this.inflight) { + this.inflight = this.read().finally(() => { + this.inflight = null; + }); + } + return this.inflight; + } + + private async read(): Promise { + const info = await stat(this.path).catch(() => null); + if (!info) return; + let skipPartialFirstLine = false; + if (this.offset < 0) { + const window = this.opts.initialWindowBytes ?? TRANSCRIPT_INITIAL_WINDOW_BYTES; + this.offset = Math.max(0, info.size - window); + skipPartialFirstLine = this.offset > 0; + } else if (info.size < this.offset) { + // Truncated or replaced: follow the new file from its start. + this.offset = 0; + this.pending = ""; + this.decoder = new TextDecoder(); + } + if (info.size === this.offset) return; + const handle = await open(this.path, "r"); + try { + const buf = new Uint8Array(TRANSCRIPT_READ_CHUNK); + while (this.offset < info.size) { + const { bytesRead } = await handle.read(buf, 0, Math.min(buf.length, info.size - this.offset), this.offset); + if (bytesRead <= 0) break; + this.offset += bytesRead; + this.pending += this.decoder.decode(buf.subarray(0, bytesRead), { stream: true }); + if (skipPartialFirstLine) { + const nl = this.pending.indexOf("\n"); + if (nl < 0) continue; + this.pending = this.pending.slice(nl + 1); + skipPartialFirstLine = false; + } + const lastNl = this.pending.lastIndexOf("\n"); + if (lastNl < 0) continue; + const lines = this.pending.slice(0, lastNl).split("\n").filter((l) => l.trim()); + this.pending = this.pending.slice(lastNl + 1); + if (lines.length) this.onLines(lines, info.mtimeMs); + } + } finally { + await handle.close(); + } + } +} + +// A turn counts as running while its last event is not a turn end and the +// transcript was written recently; Claude and Codex both flush per item. +export const TRANSCRIPT_ACTIVE_WINDOW_MS = 30_000; + +export function transcriptLooksRunning(events: AgentEvent[], lastWriteMs: number, now = Date.now()): boolean { + if (now - lastWriteMs > TRANSCRIPT_ACTIVE_WINDOW_MS) return false; + for (let i = events.length - 1; i >= 0; i--) { + const kind = events[i].kind; + if (kind === "done") return false; + if (kind === "user" || kind === "tool-start" || kind === "tool-end" || kind === "thinking" || kind === "assistant" || kind === "status") return true; + } + return false; +} + +interface TranscriptState { + tail: TranscriptTail; + parser: TranscriptParser; + lastWriteMs: number; + statusTimer: ReturnType; +} + +function transcriptState(sess: SessionCtx): TranscriptState | undefined { + return sess.internal.transcript as TranscriptState | undefined; +} + +/** + * Starts tailing `path` into `sess`. `onTitle` fires when the transcript + * reports a new title so the server can refresh the page title. + */ +export function attachTranscript( + sess: SessionCtx, + agent: TranscriptAgent, + path: string, + onTitle?: (title: string) => void, + opts: { pollMs?: number; initialWindowBytes?: number; onTick?: () => void } = {}, +): TranscriptTail { + const parser = transcriptParser(agent); + const refreshStatus = () => { + const st = transcriptState(sess); + if (!st) return; + sess.setStatus(transcriptLooksRunning(sess.events, st.lastWriteMs) ? "running" : "idle"); + opts.onTick?.(); + }; + const tail = new TranscriptTail(path, (lines, mtimeMs) => { + const st = transcriptState(sess); + // Activity comes from the file's own write time, so a transcript that + // went idle long ago does not look busy when its history first loads. + if (st) st.lastWriteMs = mtimeMs; + const title = parser.title; + for (const line of lines) { + for (const evt of parser.parse(line)) sess.emit(evt); + } + if (parser.title && parser.title !== title) onTitle?.(parser.title); + refreshStatus(); + }, opts); + const state: TranscriptState = { + tail, + parser, + lastWriteMs: 0, + statusTimer: setInterval(refreshStatus, 2_000), + }; + sess.internal.transcript = state; + sess.internal.transcriptPath = path; + tail.start(); + return tail; +} + +export const transcriptAdapter: Adapter = { + // The prompt is typed into the terminal's agent (bracketed paste + submit, + // the delivery the iOS chat uses). The transcript then records it, which is + // what renders the user message, so nothing is emitted here on success. + async send(sess: SessionCtx, prompt: string) { + const target = transcriptTarget(sess); + if (!target) { + sess.emit({ kind: "error", message: "This view is not attached to a terminal session.", prompt }); + return; + } + const res = await rpc("mobile.chat.send", { session_id: target.agentSessionId, text: prompt }); + if (!res.ok) sess.emit({ kind: "error", message: `Couldn't send to the terminal: ${res.error}`, prompt }); + }, + stop(sess: SessionCtx) { + const target = transcriptTarget(sess); + if (!target) return; + void rpc("mobile.chat.interrupt", { session_id: target.agentSessionId }).then((res) => { + if (!res.ok) sess.emit({ kind: "error", message: `Couldn't interrupt the terminal: ${res.error}` }); + }); + }, + dispose(sess: SessionCtx) { + const st = transcriptState(sess); + if (!st) return; + st.tail.stop(); + clearInterval(st.statusTimer); + delete sess.internal.transcript; + }, + async setOption(_sess: SessionCtx, _id: string, _value: OptionValue) { + throw new Error("operation is not supported in a transcript view"); + }, + capabilities: { options: [], triggers: [] }, +}; diff --git a/agent-chat/cmux-chat b/agent-chat/cmux-chat index 7cb212a3e809..10edb23cfbdb 100755 --- a/agent-chat/cmux-chat +++ b/agent-chat/cmux-chat @@ -6,6 +6,8 @@ # cmux-chat -p codex refactor foo pick provider (claude/codex/opencode/pi/gemini) # cmux-chat --split open as a split in the current workspace instead # cmux-chat --no-open ... print the URL only +# cmux-chat --terminal [--surface ID] chat view of the agent running in a +# terminal ($CMUX_SURFACE_ID), as a split # # Starts the sidecar server (bun server.ts) if it isn't running. set -euo pipefail @@ -23,6 +25,7 @@ if [ -n "${CMUX_AGENT_CHAT_STATE_FILE:-}" ]; then fi provider="claude" cwd="" open_mode="tab" auto_approve=true no_open=false +terminal=false surface="${CMUX_SURFACE_ID:-}" prompt=() while [ $# -gt 0 ]; do case "$1" in @@ -31,8 +34,10 @@ while [ $# -gt 0 ]; do --split) open_mode="split"; shift ;; --tab) open_mode="tab"; shift ;; --no-open) no_open=true; shift ;; + --terminal) terminal=true; open_mode="split"; shift ;; + --surface) surface="$2"; shift 2 ;; --no-auto-approve) auto_approve=false; shift ;; - -h|--help) sed -n '2,10p' "$SELF" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '2,12p' "$SELF" | sed 's/^# \{0,1\}//'; exit 0 ;; *) prompt+=("$1"); shift ;; esac done @@ -49,7 +54,10 @@ if ! curl -fsS --max-time 1 "$BASE/healthz" >/dev/null 2>&1; then fi url="$BASE/" -if [ ${#prompt[@]} -gt 0 ]; then +if $terminal; then + [ -n "$surface" ] || { echo "--terminal needs --surface or CMUX_SURFACE_ID" >&2; exit 1; } + url="$BASE/terminal/$surface" +elif [ ${#prompt[@]} -gt 0 ]; then body=$(printf '%s' "${prompt[*]}" | \ CC_PROVIDER="$provider" CC_CWD="${cwd:-$PWD}" CC_AUTO="$auto_approve" python3 -c ' import json, sys, os diff --git a/agent-chat/cmux-rpc.ts b/agent-chat/cmux-rpc.ts new file mode 100644 index 000000000000..b3d94af89e73 --- /dev/null +++ b/agent-chat/cmux-rpc.ts @@ -0,0 +1,38 @@ +// Calls the cmux app's control socket through the bundled CLI (`cmux rpc`). +// The app passes CMUX_BUNDLED_CLI_PATH and CMUX_SOCKET_PATH to the sidecar it +// launches; a manually started sidecar falls back to `cmux` on PATH. + +export interface CmuxRpcResult { ok: boolean; result?: unknown; error?: string } + +const RPC_TIMEOUT_MS = 10_000; + +export async function cmuxRpc(method: string, params: Record): Promise { + const cli = process.env.CMUX_BUNDLED_CLI_PATH?.trim() || "cmux"; + let proc: ReturnType; + try { + proc = Bun.spawn([cli, "rpc", method, JSON.stringify(params)], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: process.env, + }); + } catch (err) { + return { ok: false, error: `cmux CLI unavailable: ${err instanceof Error ? err.message : String(err)}` }; + } + const timer = setTimeout(() => proc.kill(), RPC_TIMEOUT_MS); + try { + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout as ReadableStream).text(), + new Response(proc.stderr as ReadableStream).text(), + proc.exited, + ]); + if (code !== 0) return { ok: false, error: (stderr.trim() || stdout.trim() || `cmux rpc exited ${code}`).slice(0, 300) }; + try { + return { ok: true, result: JSON.parse(stdout) }; + } catch { + return { ok: true, result: stdout.trim() }; + } + } finally { + clearTimeout(timer); + } +} diff --git a/agent-chat/public/app.css b/agent-chat/public/app.css index da0ef07ff13a..1a80e824dcc5 100644 --- a/agent-chat/public/app.css +++ b/agent-chat/public/app.css @@ -610,6 +610,10 @@ details.thinking .t-body { white-space: pre-wrap; font-size: calc(var(--font-siz white-space: pre-wrap; color: var(--text-faint); font: calc(var(--font-size-base) - 2px)/1.5 var(--font-sans); padding: 4px 6px 6px; } +/* A running turn renders its activity directly in the turn, outside the + collapsed summary; keep thinking and interim text in the content column. */ +.turn-live-activity { max-width: 760px; margin: 0 auto 10px; padding: 0 24px; } +.turn-live-activity .turn-thinking-detail { padding-left: 0; padding-right: 0; } .turn-actions { max-width: 760px; margin: -8px auto 18px; padding: 0 24px; display: flex; align-items: center; gap: 6px; @@ -670,6 +674,29 @@ details.thinking .t-body { white-space: pre-wrap; font-size: calc(var(--font-siz background: color-mix(in srgb, var(--accent) 10%, var(--bg-input)); color: var(--text-dim); font: 400 calc(var(--font-size-base) - 2px) var(--font-sans); } +.transcript-composer-row { + display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 32px; padding: 0 0 1px 8px; +} +.transcript-hint { + display: inline-flex; align-items: center; gap: 8px; min-width: 0; + color: var(--text-faint); font: 400 calc(var(--font-size-base) - 2px) var(--font-sans); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.transcript-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--text-faint); } +.transcript-dot.running { background: var(--accent); animation: transcript-pulse 1.4s ease-in-out infinite; } +@keyframes transcript-pulse { 50% { opacity: 0.35; } } +@media (prefers-reduced-motion: reduce) { .transcript-dot.running { animation: none; } } +.terminal-attention { + display: flex; align-items: center; gap: 10px; margin: 0 0 7px; padding: 8px 8px 8px 12px; border-radius: 10px; + background: color-mix(in srgb, var(--accent) 12%, var(--bg-input)); + color: var(--text); font: 400 calc(var(--font-size-base) - 1.5px) var(--font-sans); +} +.terminal-attention-text { flex: 1; min-width: 0; overflow-wrap: anywhere; } +.terminal-attention-btn { + flex: none; height: 28px; padding: 0 11px; border: 0; border-radius: 8px; cursor: default; + background: var(--accent); color: #fff; font: 500 calc(var(--font-size-base) - 2px) var(--font-sans); +} +.terminal-attention-btn:hover { filter: brightness(1.08); } #chat-card { display: flex; flex-direction: column; gap: 6px; padding: 6px; background: var(--bg-input); border: 0; border-radius: 14px; diff --git a/agent-chat/server.ts b/agent-chat/server.ts index a86b88690ece..462af4ee68c3 100644 --- a/agent-chat/server.ts +++ b/agent-chat/server.ts @@ -16,6 +16,8 @@ import { claudeAdapter } from "./adapters/claude"; import { codexAdapter } from "./adapters/codex"; import { piAdapter } from "./adapters/pi"; import { makeAcpAdapter } from "./adapters/acp"; +import { attachTranscript, focusTranscriptTerminal, transcriptAdapter, type TranscriptAgent } from "./adapters/transcript"; +import { resolveSessionTranscript, resolveSurfaceTranscript, transcriptAttention, type TranscriptSource } from "./transcript-sources"; import { pickAccentColor, resolveGhosttyTheme, resolveGhosttyThemeAsync, type GhosttyTheme } from "./theme"; import { agentModelCatalog, type AgentModelProviderCatalog } from "./catalog"; import { discoverHarnesses } from "./harnesses"; @@ -171,6 +173,14 @@ interface Session extends SessionCtx { adapter: Adapter; sockets: Set>; createdAt: number; + /** Set for chat views of an agent running in a cmux terminal. */ + transcript?: { + agent: TranscriptAgent; + path: string; + disposeTimer?: ReturnType; + /** What the agent is waiting on in the terminal (permission, question), if anything. */ + attention?: string | null; + }; } interface WsData { subscribed: string | null; @@ -237,7 +247,8 @@ function sessionSummary(s: Session) { parentSessionId: s.parentSessionId, parentConversationId: s.parentConversationId, startRequestId: s.startRequestId, - capabilities: capabilitiesFor(s.provider), + capabilities: s.transcript ? s.adapter.capabilities : capabilitiesFor(s.provider), + ...(s.transcript ? { mode: "transcript" as const, attention: s.transcript.attention ?? null } : {}), }; } @@ -346,10 +357,11 @@ function createSession( parentConversationId?: string; startRequestId?: string; } = {}, + override: { id?: string; adapter?: Adapter } = {}, ): Session { - const adapter = adapters.get(provider); + const adapter = override.adapter ?? adapters.get(provider); if (!adapter) throw new Error(`unknown provider: ${provider}`); - const id = crypto.randomUUID().slice(0, 8); + const id = override.id ?? crypto.randomUUID().slice(0, 8); const sess: Session = { id, provider, @@ -563,6 +575,11 @@ function emitDoneAfterFiles(sess: Session, evt: InternalDoneEvent) { } function sendPrompt(sess: Session, prompt: string, requestId = crypto.randomUUID()) { + if (sess.transcript) { + // Typed into the terminal's agent; the transcript records the prompt. + void sess.adapter.send(sess, prompt); + return; + } emitRouting(sess, { phase: "started", requestId, attempt: 1, provider: sess.provider }); const activeGeneration = activeAttributionGeneration(sess); if (adapterAttributionMode(sess) === "current-turn" && activeGeneration) { @@ -602,6 +619,99 @@ function sendPrompt(sess: Session, prompt: string, requestId = crypto.randomUUID }); } +// Transcript views are keyed by the agent's own session id so a reload after +// a sidecar restart can re-resolve the same view from the hook stores. +const TRANSCRIPT_SESSION_PREFIX = "t-"; +const TRANSCRIPT_IDLE_DISPOSE_MS = 5 * 60_000; + +function transcriptSessionId(source: TranscriptSource): string { + return `${TRANSCRIPT_SESSION_PREFIX}${source.sessionId}`; +} + +function transcriptTitle(source: TranscriptSource): string { + const label = source.agent === "codex" ? "Codex" : "Claude Code"; + return source.cwd ? `${label} · ${pathBasename(source.cwd)}` : label; +} + +function ensureTranscriptSession(source: TranscriptSource): Session { + const id = transcriptSessionId(source); + const existing = sessions.get(id); + if (existing?.transcript?.path === source.path) return existing; + if (existing?.transcript) { + // The agent's transcript moved (for example a resolved fallback path): + // re-point the same session so open pages stay subscribed. + existing.adapter.dispose(existing); + existing.events.length = 0; + delete existing.internal.eventGenerations; + existing.transcript.path = source.path; + existing.internal.transcriptTarget = { agentSessionId: source.sessionId, surfaceId: source.surfaceId }; + startTranscriptTail(existing, source); + broadcastSessionHistory(existing); + return existing; + } + const sess = createSession(source.agent, source.cwd ?? DEFAULT_CWD, false, transcriptTitle(source), {}, {}, { + id, + adapter: transcriptAdapter, + }); + sess.transcript = { agent: source.agent, path: source.path }; + sess.internal.transcriptTarget = { agentSessionId: source.sessionId, surfaceId: source.surfaceId }; + startTranscriptTail(sess, source); + return sess; +} + +function startTranscriptTail(sess: Session, source: TranscriptSource) { + attachTranscript(sess, source.agent, source.path, (title) => { + if (sess.title === title) return; + sess.title = title; + broadcastSessions(); + const payload = JSON.stringify({ kind: "session-title", sessionId: sess.id, title }); + for (const ws of sess.sockets) ws.send(payload); + }, { onTick: () => refreshTranscriptAttention(sess, source) }); +} + +// Permission prompts, questions, and pickers live in the terminal and are not +// in the transcript until answered; the hook store says when the agent waits. +function refreshTranscriptAttention(sess: Session, source: TranscriptSource) { + if (!sess.transcript || !sess.sockets.size) return; + const attention = transcriptAttention(source.agent, source.sessionId); + if ((sess.transcript.attention ?? null) === attention) return; + sess.transcript.attention = attention; + const payload = JSON.stringify({ kind: "session-attention", sessionId: sess.id, attention }); + for (const ws of sess.sockets) ws.send(payload); +} + +function resolveTranscriptSessionById(id: string): Session | undefined { + if (!id.startsWith(TRANSCRIPT_SESSION_PREFIX)) return undefined; + const source = resolveSessionTranscript(id.slice(TRANSCRIPT_SESSION_PREFIX.length)); + return source ? ensureTranscriptSession(source) : undefined; +} + +function cancelTranscriptDispose(sess: Session) { + if (sess.transcript?.disposeTimer) clearTimeout(sess.transcript.disposeTimer); + if (sess.transcript) sess.transcript.disposeTimer = undefined; +} + +// A transcript view with no open page stops tailing after a grace period; the +// next visit re-resolves it from the hook stores. +function scheduleTranscriptDispose(sess: Session) { + if (!sess.transcript || sess.sockets.size) return; + cancelTranscriptDispose(sess); + sess.transcript.disposeTimer = setTimeout(() => { + if (sess.sockets.size || sessions.get(sess.id) !== sess) return; + sess.adapter.dispose(sess); + sessions.delete(sess.id); + broadcastSessions(); + }, TRANSCRIPT_IDLE_DISPOSE_MS); +} + +function transcriptNotFoundPage(): Response { + const body = `No agent session + +

No Claude Code or Codex session is recorded for this terminal yet.

+

Start the agent in the terminal (cmux hooks must be enabled), then reopen the chat view.

`; + return new Response(body, { status: 404, headers: { "content-type": "text/html; charset=utf-8" } }); +} + function refreshSession(sess: Session) { Promise.resolve(sess.adapter.refreshOptions?.(sess)).catch((err) => { console.error("[agent-chat] refresh-options failed", err); @@ -1916,6 +2026,14 @@ function startServer() { if (prompt) sendPrompt(sess, prompt); return Response.json({ id: sess.id, url: `http://127.0.0.1:${server.port}${prefixedPath(`/s/${sess.id}`)}` }); } + const terminalMatch = url.pathname.match(/^\/terminal\/([0-9A-Fa-f-]{8,64})\/?$/); + if (terminalMatch && req.method === "GET") { + const source = resolveSurfaceTranscript(terminalMatch[1]); + if (!source) return transcriptNotFoundPage(); + const sess = ensureTranscriptSession(source); + scheduleTranscriptDispose(sess); + return new Response(null, { status: 302, headers: { location: `${prefixedPath(`/s/${sess.id}`)}${url.search}` } }); + } if (url.pathname === "/api/sessions" && req.method === "GET") { return Response.json([...sessions.values()].sort((a, b) => b.createdAt - a.createdAt).map(sessionSummary)); } @@ -1944,7 +2062,11 @@ function startServer() { close(ws) { allSockets.delete(ws); const sid = ws.data.subscribed; - if (sid) sessions.get(sid)?.sockets.delete(ws); + const sess = sid ? sessions.get(sid) : undefined; + if (sess) { + sess.sockets.delete(ws); + scheduleTranscriptDispose(sess); + } }, message(ws, raw) { let msg: any; @@ -2101,7 +2223,8 @@ function handleMessage(ws: Bun.ServerWebSocket, msg: any) { break; } case "subscribe": { - const sess = sessions.get(String(msg.sessionId)); + const sessionId = String(msg.sessionId); + const sess = sessions.get(sessionId) ?? resolveTranscriptSessionById(sessionId); if (!sess) { ws.send(JSON.stringify({ kind: "no-session", sessionId: msg.sessionId })); return; @@ -2116,6 +2239,14 @@ function handleMessage(ws: Bun.ServerWebSocket, msg: any) { refreshSession(sess); break; } + case "focus-terminal": { + const sess = sessions.get(String(msg.sessionId)); + if (!sess?.transcript) return; + Promise.resolve(focusTranscriptTerminal(sess)).then((res) => { + if (!res.ok) sess.emit({ kind: "error", message: `Couldn't focus the terminal: ${res.error}` }); + }); + break; + } case "stop": { const sess = sessions.get(String(msg.sessionId)); sess?.adapter.stop(sess); @@ -2233,9 +2364,14 @@ function handleMessage(ws: Bun.ServerWebSocket, msg: any) { function subscribe(ws: Bun.ServerWebSocket, sess: Session) { const prev = ws.data.subscribed; - if (prev) sessions.get(prev)?.sockets.delete(ws); + const prevSess = prev ? sessions.get(prev) : undefined; + if (prevSess && prevSess !== sess) { + prevSess.sockets.delete(ws); + scheduleTranscriptDispose(prevSess); + } ws.data.subscribed = sess.id; sess.sockets.add(ws); + cancelTranscriptDispose(sess); } process.on("SIGINT", () => { diff --git a/agent-chat/src/components/Chat.tsx b/agent-chat/src/components/Chat.tsx index f81e28078ee9..bd609edba2a9 100644 --- a/agent-chat/src/components/Chat.tsx +++ b/agent-chat/src/components/Chat.tsx @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } import { useCtx } from "../context"; import { agentChatText } from "../i18n"; import { readStoredProviderOptions, persistOptionsSnapshot, updateStoredProviderOption } from "../options-store"; -import type { OptionValue, SessionOption } from "../session"; +import { routedToTranscript, type OptionValue, type SessionOption } from "../session"; import { ArrowUp } from "./icons"; import { isCtrlJ, insertNewlineAtCaret, useCommandMenu } from "./CommandMenu"; import { optionAcceptsValue, optionsForSelectedModel } from "./options"; @@ -55,7 +55,7 @@ function useStickToBottom(scrollRef: RefObject, stickRef: } export function Chat() { - const { ready, connectionEpoch, providers, capabilities, providerOptions, session, routing, blocks, options, actions, commands, filesByCwd, fileDiffs, ctrlJ, forkPending, handoffPending, reply, stop, setOption, fork, handoff, compose, requestProviderOptions, requestProviderCommands, requestFiles, requestFileDiff } = useCtx(); + const { ready, connectionEpoch, providers, capabilities, providerOptions, session, routing, blocks, options, actions, commands, filesByCwd, fileDiffs, ctrlJ, forkPending, handoffPending, reply, stop, focusTerminal, setOption, fork, handoff, compose, requestProviderOptions, requestProviderCommands, requestFiles, requestFileDiff } = useCtx(); const [text, setText] = useState(""); const [openOptionId, setOpenOptionId] = useState(null); const [helpOpen, setHelpOpen] = useState(false); @@ -72,12 +72,15 @@ export function Chat() { [providerOptions, providers], ); const running = session?.status === "running"; + // Transcript views mirror a terminal agent: no composer, catalogs, or files. + const transcriptView = session ? session.mode === "transcript" : routedToTranscript; + const catalogCwd = transcriptView ? "" : cwd; const resolvedOptions = useMemo(() => optionsForSelectedModel(options), [options]); useRestoreModelScopedOptions({ provider: session?.provider, options: resolvedOptions, setOption, pendingModelRestoreRef }); usePersistSessionOptions(session?.provider, resolvedOptions, pendingModelRestoreRef.current !== null); - useProviderCatalogs(ready, connectionEpoch, providers, session?.provider ?? "", cwd, requestProviderOptions, requestProviderCommands); - useFileCatalog(ready, connectionEpoch, cwd, requestFiles); + useProviderCatalogs(ready, connectionEpoch, providers, session?.provider ?? "", catalogCwd, requestProviderOptions, requestProviderCommands); + useFileCatalog(ready, connectionEpoch, catalogCwd, requestFiles); useStickToBottom(scrollRef, stickRef, blocks, running); useKeymap({ options: resolvedOptions, @@ -125,6 +128,15 @@ export function Chat() { compose(); }; + const chatActions = ( +
+ {running ? : null} + +
+ ); + return (
@@ -146,6 +158,12 @@ export function Chat() { ) : routing?.phase === "rerouted" ? (
{agentChatText("movedServingRoute")}
) : null} + {transcriptView && session?.attention ? ( +
+ {session.attention} + +
+ ) : null}