From b5c8182e673bb7fa4d2fdcf43f849061056dea6d Mon Sep 17 00:00:00 2001 From: rivshughes <146305463+rivshughes@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:57:21 -0400 Subject: [PATCH] =?UTF-8?q?A=20worker's=20conversation=20as=20a=20chat:=20?= =?UTF-8?q?a=20=F0=9F=92=AC=20Chat=20tab=20beside=20its=20terminal,=20the?= =?UTF-8?q?=20one=20a=20phone=20opens=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Claude Code worker's terminal is a poor way to read what it's been doing on a phone: a screen-sized window onto a TUI, with the history behind it. The chat tab shows the conversation itself instead: what you told it, what it said back (as markdown) and each tool it used, as a row that opens to show what ran and what came back, in a page that scrolls like any chat. It's read off the transcript Claude Code keeps for the session (the file its hooks name, see providers/claude.ts), tailed while anyone has the tab open: the server parses the user and assistant records into chat items, sends the latest 80 on attach, each new one as it's written, and a tool's result as a patch of the row it belongs to. Earlier… pages further back. A /clear names a new transcript, which starts the chat over. The box at the bottom sends a prompt the way the terminal's does (worker.prompt, with the 🎀), ⏹ interrupts, and when the worker wants permission its choices show above the box as buttons (Yes, Always, No), the same keys the keypad sends. A question with choices of its own still wants the terminal, which is the next tab. On the 2D view the chat is the tab that opens; on a computer the terminal is, as before. Other providers' workers keep a terminal only. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- docs/controls.md | 2 +- docs/features.md | 3 +- src/client/ui/terminal.ts | 22 ++- src/client/ui/termtabs.ts | 27 ++-- src/client/ui/transcript.css | 33 +++++ src/client/ui/transcript.ts | 211 +++++++++++++++++++++++++++ src/server/transcript.ts | 157 ++++++++++++++++++++ src/server/workers/manager.ts | 13 +- src/server/ws/handlers/index.ts | 4 +- src/server/ws/handlers/transcript.ts | 121 +++++++++++++++ src/shared/protocol.ts | 4 + src/shared/protocol/transcript.ts | 40 +++++ tests/transcript.test.ts | 97 ++++++++++++ 14 files changed, 713 insertions(+), 23 deletions(-) create mode 100644 src/client/ui/transcript.css create mode 100644 src/client/ui/transcript.ts create mode 100644 src/server/transcript.ts create mode 100644 src/server/ws/handlers/transcript.ts create mode 100644 src/shared/protocol/transcript.ts create mode 100644 tests/transcript.test.ts diff --git a/README.md b/README.md index fac9441a5..85055d03c 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i - **Workers at desks.** Walk up to an empty desk, press **E**, and pick Claude Code, Codex, OpenCode, Grok, Muse, DeepSeek Harness, Pi or Cursor, each with its model and reasoning effort. The agent's live terminal shows on its laptop, and anyone can open it and type. - **Talk instead of typing.** Hold **Ctrl+Space** (or the **🎀**) in a worker's terminal or a prompt box and say what you want: it's typed in for you to send. Your browser does the listening, so there's nothing to install. - **You can't miss who needs you.** A worker that stops to ask you something lights a red beacon over its desk, puts a banner on your screen saying who and what for, and sounds an alarm. One that has finished jumps up and down and dings. Press **N** to go straight to whoever is waiting. -- **From your phone, too.** `/lite` is the office in 2D: every worker and what it's waiting on, its terminal with the keys a phone keyboard lacks, and the boards. The 3D office offers it on a phone or a slow computer. +- **From your phone, too.** `/lite` is the office in 2D: every worker and what it's waiting on, its conversation as a chat (with its terminal a tab away, with the keys a phone keyboard lacks), and the boards. The 3D office offers it on a phone or a slow computer. - **GitHub on the walls.** Issues and pull requests hang on cork boards. Hand an issue to a worker, queue tasks, give a worker its own git worktree and open its PR with one key (if one gets deleted behind the office's back, the worker waits at its desk until you rebuild it). One task can span several projects: the worker gets a worktree of each, and a PR in each that links the others. - **Agents that manage agents.** Every worker can list, hire, message and send home the others, through an `agent-office` MCP server (Claude Code, Codex, OpenCode) or the `office-workers` command. Ask one to "send everyone whose PR merged home" and it does, deleting their worktrees and branches unless they hold unpushed work. A worker that opens its pull request itself (`gh pr create`) shows it at its desk, and one the office missed can be told which is its own (`office-workers pr`). - **Together.** Voice, chat, screen sharing on the lounge TV and a shared whiteboard. diff --git a/docs/controls.md b/docs/controls.md index 4a863358d..1e2ec002f 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -33,7 +33,7 @@ Back to the [README](../README.md). You can also click a nearby desk to interact with it, or click a worker in the Workers panel (**πŸ€– Workers**, top right) to open its terminal. -On a phone, use the 2D view at `/lite` instead: a terminal there has a row of keys under it (**1** **2** **3**, the arrows, Enter, Tab, Esc, Ctrl+C) and a box to send a prompt. See [Features](features.md). +On a phone, use the 2D view at `/lite` instead: a worker opens on its conversation as a chat (**πŸ’¬ Chat**), with a box to send a prompt and buttons to answer a permission prompt, and its terminal on the next tab, with a row of keys under it (**1** **2** **3**, the arrows, Enter, Tab, Esc, Ctrl+C). See [Features](features.md). ## In a terminal diff --git a/docs/features.md b/docs/features.md index f320d9b99..d62c0a4fd 100644 --- a/docs/features.md +++ b/docs/features.md @@ -38,7 +38,8 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Workers act out what they're doing.** A working worker acts out its latest tool call, so you can tell what it's doing from across the room without reading its card. Reading files, it holds up a stack of papers and flips through them. Editing, it hunches over the keys and types flat out. Running tests or a build, it leans back with its hands behind its head. Searching or fetching the web, a globe spins beside its laptop. When its tests or build fail twice in a row, it puts its head in its hands. Waiting on you, it jumps for a couple of seconds, then crosses its arms and taps its foot. When it finishes, it does a little spin and a puff of confetti goes up. Claude Code hooks tell a shell command that runs tests from one that only reads, and they report failures. Codex and OpenCode report only the tool's name, so their shell commands are acted out as plain typing. - **Next worker that needs you.** Press **N** to go straight to the first worker waiting on someone (it needs input, or it's done and nobody has looked), standing behind it and looking at its laptop. Press **N** again for the next one, and after the last you start over: the ones that need input come first, then the ones that are done, and whoever has waited longest is first among each. A pin at the edge of the screen points the way to each waiting worker you can't see, red for *needs input* and green for *done*, and the top bar counts them (`πŸ™‹ 2 need you Β· βœ… 1 done`, in red and pulsing while anyone needs you; click it to do the same as **N**). With nobody waiting on your floor, **N** tells you which floor has someone. - **Notifications.** You don't have to be in the office tab. The tab title counts the workers waiting on someone, like `(2) my-project Β· Agent Office`. Allow desktop notifications (the office asks when you first hire a worker, or turn them on under **βš™οΈ**) and a worker that needs you or finishes while you're in another tab or app pops one up (*πŸ™‹ Byte needs you*, with what it's asking; that one stays up until it's dealt with). Click it to go straight to that worker: the office comes to the front with you at its desk and its terminal open. For the whole team, paste a Slack or Discord incoming webhook under **βš™οΈ** β†’ *Team notifications* (or start with `--webhook `), and the office posts to that channel when a worker has needed input or been done for a few seconds with nobody at its terminal. -- **The 2D view, for your phone.** Open `/lite` (or **πŸ“± 2D view** in the **☰** menu) for the office without the 3D. It lists every worker on the floor, the ones waiting on someone first, each with what it's asking (*πŸ™‹ Wants permission: Bash: npm test*) or doing, its branch and PR, and who typed to it last. Tap one for its terminal, with a row of the keys a phone keyboard hasn't got (**1** **2** **3** to answer a question or a permission prompt, the arrows, Enter, Tab, Esc and Ctrl+C) and a box to send it a prompt. **✍️** on a card sends one without opening the terminal. **πŸ“Œ Issues**, **πŸ”€ PRs**, **πŸ“‹ Queue** and **✨ New task** open the same windows as in the office, and the floor at the top switches projects, with a button straight to any floor where someone's waiting. The tab title counts who's waiting, and once you allow notifications (**πŸ””**) a worker that needs you pops one up and buzzes the phone. The 3D office offers the 2D view as it loads on a phone, and on a computer where it draws fewer than 20 frames a second for ten seconds. **Stay in 3D** stops it asking in that browser. A browser that can't draw the 3D office at all (no WebGL) goes straight to the 2D view, and signing in from `/lite` brings you back there. Teammates see you under *In the office* as *πŸ“± on the 2D view*, not standing anywhere in it. +- **The 2D view, for your phone.** Open `/lite` (or **πŸ“± 2D view** in the **☰** menu) for the office without the 3D. It lists every worker on the floor, the ones waiting on someone first, each with what it's asking (*πŸ™‹ Wants permission: Bash: npm test*) or doing, its branch and PR, and who typed to it last. Tap one for its conversation as a chat (see below), with its terminal on the **πŸ’» Terminal** tab: a row of the keys a phone keyboard hasn't got (**1** **2** **3** to answer a question or a permission prompt, the arrows, Enter, Tab, Esc and Ctrl+C) and a box to send it a prompt. **✍️** on a card sends one without opening the terminal. **πŸ“Œ Issues**, **πŸ”€ PRs**, **πŸ“‹ Queue** and **✨ New task** open the same windows as in the office, and the floor at the top switches projects, with a button straight to any floor where someone's waiting. The tab title counts who's waiting, and once you allow notifications (**πŸ””**) a worker that needs you pops one up and buzzes the phone. The 3D office offers the 2D view as it loads on a phone, and on a computer where it draws fewer than 20 frames a second for ten seconds. **Stay in 3D** stops it asking in that browser. A browser that can't draw the 3D office at all (no WebGL) goes straight to the 2D view, and signing in from `/lite` brings you back there. Teammates see you under *In the office* as *πŸ“± on the 2D view*, not standing anywhere in it. +- **A worker's conversation as a chat.** A Claude Code worker's terminal has a **πŸ’¬ Chat** tab: what you told it, what it said back (as it wrote it, headings, lists and code included) and each tool it used, as a row that opens to show what ran and what came back, in a page that scrolls like any chat. It's read off the session transcript Claude Code keeps, so it's the conversation itself rather than a picture of the terminal, and it shows a worker's whole history across office restarts; **Earlier…** at the top pages further back. The box at the bottom sends a prompt (🎀 dictates one), **⏹** interrupts, and when the worker wants permission the choices show above the box as buttons (**Yes**, **Always**, **No**), the same keys the terminal takes. A question with its own choices, or a menu, still wants the terminal: the tab is a tap away. On a phone the chat is the tab that opens; on a computer the terminal is, with the chat beside it. - **Task cards.** A card over each worker's head names what it's on ("Fix Login Redirect") and says in one line what it's doing right now. For Claude workers, Claude Haiku writes it from prompts and recent tools through the `claude` CLI. OpenCode, Codex, Grok, Muse, DeepSeek Harness and Cursor use local task summaries and never invoke Claude to name their tasks. - **Survives restarts.** Workers are saved to disk with their agent choice and session ID, and their terminals run in a small process of their own. When the server restarts (a dev-server reload, a `kill` of its process), the agents keep working through it, and the new server picks every terminal back up, scrollback and all. Open terminals reconnect by themselves. Ctrl+C stops the workers; the next start wakes them back up in their sessions, and **R** resumes the Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi or Cursor session of one that exited. DeepSeek Harness workers are the exception to keeping working through a restart: their ACP session is a child of the office process, so it stops with the office and comes back *offline*, resumable with **R**. When a restart does take a worker's terminal with it (the machine rebooted, the terminal process died or was replaced by a newer one), a worker that was in the middle of something is resumed with *continue*, so it carries on by itself. The chat and each worker's last 3,000 lines of terminal are kept too, so after a restart (or a crash) you can still scroll back through what the workers printed and what everyone said. - **Search.** Press **/** (or **πŸ”Ž Search** in the **☰** menu) to search the chat and every worker's terminal at once, including what they showed before the office restarted. Click a terminal line to open that terminal scrolled to it, with the line lit up. diff --git a/src/client/ui/terminal.ts b/src/client/ui/terminal.ts index b7124ec77..30866367d 100644 --- a/src/client/ui/terminal.ts +++ b/src/client/ui/terminal.ts @@ -15,6 +15,7 @@ import { engineLabel, providerUsageNote, providerUsageState, providerWaitingLabe import { naturalKey } from './termkeys'; import { termTabs } from './termtabs'; import { dictateField, dictation } from './dictate'; +import { transcriptPane } from './transcript'; /** A line to scroll to once the terminal has loaded: a search hit (see search.ts). */ export interface TerminalFind { @@ -134,7 +135,19 @@ export function openTerminal(net: Net, workerId: string, onChanges?: () => void, const sayBtn = h('button.btn.primary', { type: 'submit' }, 'Send'); const sayForm = h('form.term-say', {}, dictateField(say), sayBtn); const keypad = opts.keypad ? h('div.term-keypad', {}, keys, sayForm) : null; - const tabs = termTabs(workerId, { host, keypad, focusTerm: () => term.focus() }); + // A Claude Code worker's conversation as a chat, a tab beside the terminal: the one a phone opens on. + const chat = + info.kind === 'agent' && resolvedProvider(info.provider, store.project) === 'claude' + ? transcriptPane(net, workerId, { + key: (data) => { + sendSize(true); + sayTyping(); + term.input(data); + }, + toTerminal: () => tabs.show('main'), + }) + : null; + const tabs = termTabs(workerId, { host, keypad, focusTerm: () => term.focus(), chat: chat ? { el: chat.el, onShow: chat.show, onHide: chat.hide } : undefined, start: chat && opts.keypad ? 'chat' : 'main' }); // What you say is typed in at the terminal's cursor, as a paste, for you to read over and send (see dictate.ts). const mic = dictation( { @@ -149,7 +162,7 @@ export function openTerminal(net: Net, workerId: string, onChanges?: () => void, ); host.append(mic.live); // The keypad has an Esc of its own, and a 🎀 on its prompt box. - const el = h('div.modal.term', { role: 'dialog', 'aria-label': `${info.name} terminal` }, h('header', {}, dot, title, pill, cost, viewers, typed, modelsBtn, keypad ? null : mic.button, keypad ? null : escBtn, onChanges ? changesBtn : null, hideBtn, closeBtn), tabs.bar, host, tabs.pages, keypad); + const el = h('div.modal.term', { role: 'dialog', 'aria-label': `${info.name} terminal` }, h('header', {}, dot, title, pill, cost, viewers, typed, modelsBtn, keypad ? null : mic.button, keypad ? null : escBtn, onChanges ? changesBtn : null, hideBtn, closeBtn), tabs.bar, host, chat?.el ?? null, tabs.pages, keypad); const term = new Terminal({ fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', @@ -262,6 +275,7 @@ export function openTerminal(net: Net, workerId: string, onChanges?: () => void, cost.textContent = w.kind !== 'agent' ? '' : usageState === 'tracked' && w.usage ? usageLabel(w.usage, workerProvider) : waiting ? waiting : usageState === 'untracked' ? 'usage untracked' : ''; cost.title = w.kind === 'agent' && w.usage ? usageTitle(w.usage, workerProvider) : w.kind === 'agent' ? providerUsageNote(workerProvider!) : ''; renderPresence(w); + chat?.refresh(w); const openCode = w.kind === 'agent' && resolvedProvider(w.provider, store.project) === 'opencode'; modelsBtn.classList.toggle('hidden', !openCode); modelsBtn.toggleAttribute('disabled', !openCode || !ready || isAsleep(w.status)); @@ -307,7 +321,8 @@ export function openTerminal(net: Net, workerId: string, onChanges?: () => void, let pendingFind = find; const onMsg = (msg: ServerMsg) => { - if (msg.t === 'term.data' && msg.workerId === workerId) term.write(msg.data); + if (msg.t === 'transcript.items' || msg.t === 'transcript.none') chat?.onMsg(msg); + else if (msg.t === 'term.data' && msg.workerId === workerId) term.write(msg.data); else if (msg.t === 'term.typing' && msg.workerId === workerId) { typing.set(msg.id, Date.now() + TYPING_SHOWS_MS); const w = store.workers.get(workerId); @@ -346,6 +361,7 @@ export function openTerminal(net: Net, workerId: string, onChanges?: () => void, clearInterval(typingTimer); ro.disconnect(); mic.drop(); + chat?.dispose(); net.send({ t: 'worker.detach', workerId }); term.dispose(); if (current?.modal === modal) current = null; diff --git a/src/client/ui/termtabs.ts b/src/client/ui/termtabs.ts index ef6c2400b..64a759637 100644 --- a/src/client/ui/termtabs.ts +++ b/src/client/ui/termtabs.ts @@ -26,10 +26,14 @@ export interface TermTabsOptions { keypad: HTMLElement | null; /** Back on the terminal's tab. */ focusTerm(): void; + /** The worker's chat (ui/transcript.ts), a tab of its own beside the terminal, for a worker that has one. */ + chat?: { el: HTMLElement; onShow(): void; onHide(): void }; + /** The tab open at first (the terminal, unless said otherwise). */ + start?: 'main' | 'chat'; } -/** The tab bar (above the terminal) and where the pages show (beside it). */ -export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLElement; pages: HTMLElement } { +/** The tab bar (above the terminal) and where the pages show (beside it); `show('main')` is back to the terminal. */ +export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLElement; pages: HTMLElement; show(id: 'main' | 'chat'): void } { const tabsBar = h('div.term-tabs', { role: 'tablist', 'aria-label': 'Tabs' }); const tabName = h('input', { type: 'text', placeholder: 'Tab name (e.g. ChatGPT)', 'aria-label': 'Tab name', maxlength: '40', autocomplete: 'off' }) as HTMLInputElement; const tabUrl = h('input', { type: 'url', placeholder: 'https://…', 'aria-label': 'Web page address', autocomplete: 'off' }) as HTMLInputElement; @@ -44,12 +48,13 @@ export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLEl const tabs = tabsByWorker.get(workerId) ?? []; tabsByWorker.set(workerId, tabs); const frames = new Map(); - let active = 'main'; + let active = opts.chat && opts.start === 'chat' ? 'chat' : 'main'; const render = () => { const mainTab = h('div.term-tab', { class: active === 'main' ? 'on' : '' }, h('button.term-tab-label', { type: 'button', role: 'tab', 'aria-selected': String(active === 'main'), onclick: () => show('main') }, 'πŸ’» Terminal')); + const chatTab = opts.chat ? h('div.term-tab', { class: active === 'chat' ? 'on' : '' }, h('button.term-tab-label', { type: 'button', role: 'tab', 'aria-selected': String(active === 'chat'), onclick: () => show('chat') }, 'πŸ’¬ Chat')) : null; tabsBar.replaceChildren( - mainTab, + ...(chatTab ? [chatTab, mainTab] : [mainTab]), ...tabs.map((t) => h( 'div.term-tab', @@ -74,15 +79,19 @@ export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLEl openOut.classList.toggle('hidden', !page); if (page) openOut.href = page.url; }; - const show = (id: string) => { + const show = (id: string, focus = true) => { + const wasChat = active === 'chat'; active = id; const onMain = id === 'main'; + const onChat = id === 'chat'; opts.host.classList.toggle('hidden', !onMain); - pages.classList.toggle('hidden', onMain); + pages.classList.toggle('hidden', onMain || onChat); opts.keypad?.classList.toggle('hidden', !onMain); for (const [tid, frame] of frames) frame.classList.toggle('hidden', tid !== id); render(); - if (onMain) opts.focusTerm(); + if (onChat) opts.chat?.onShow(); + else if (wasChat) opts.chat?.onHide(); + if (onMain && focus) opts.focusTerm(); }; const close = (id: string) => { const i = tabs.findIndex((t) => t.id === id); @@ -99,7 +108,7 @@ export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLEl pages.append(frame); }; for (const t of tabs) frameFor(t); - render(); + show(active, false); addBtn.addEventListener('click', () => { form.classList.remove('hidden'); @@ -129,5 +138,5 @@ export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLEl closeForm(); show(tab.id); }); - return { bar, pages }; + return { bar, pages, show }; } diff --git a/src/client/ui/transcript.css b/src/client/ui/transcript.css new file mode 100644 index 000000000..671cdea83 --- /dev/null +++ b/src/client/ui/transcript.css @@ -0,0 +1,33 @@ +/* A worker's chat (the πŸ’¬ Chat tab on its terminal, see transcript.ts). */ +.modal.term .chat-pane { flex: 1; min-height: 0; display: flex; flex-direction: column; background: var(--paper); } +.chat-log { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; padding: 12px; display: flex; flex-direction: column; gap: 10px; } +.chat-older { align-self: center; flex: none; } +.chat-empty { align-self: center; margin: auto; color: var(--muted); font-weight: 800; text-align: center; } +.chat-msg { max-width: 88%; padding: 9px 13px; font-size: 15px; line-height: 1.4; overflow-wrap: anywhere; } +.chat-msg.you { align-self: flex-end; background: var(--accent); color: var(--accent-ink); border: 3px solid var(--ink); border-radius: 18px 18px 6px 18px; white-space: pre-wrap; } +.chat-msg.agent { align-self: flex-start; background: #fff; border: 3px solid var(--ink); border-radius: 18px 18px 18px 6px; box-shadow: 0 3px 0 var(--ink); } +.chat-msg.agent .md > :first-child { margin-top: 0; } +.chat-msg.agent .md > :last-child { margin-bottom: 0; } +.chat-msg.agent .md pre { max-width: 100%; overflow-x: auto; } +.chat-note { align-self: center; color: var(--muted); font-size: 12px; font-weight: 800; } +.chat-tool { align-self: stretch; max-width: 100%; border: 2px solid var(--ink); border-radius: 12px; background: #fff; font-size: 13px; } +.chat-tool summary { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 6px 10px; cursor: pointer; list-style: none; font-weight: 800; } +.chat-tool summary::-webkit-details-marker { display: none; } +.chat-tool-name { flex: 0 1 auto; min-width: 0; max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chat-tool-gist { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-weight: 600; } +.chat-tool-mark { flex: none; font-weight: 900; } +.chat-tool-mark.ok { color: #2e9e5b; } +.chat-tool-mark.failed { color: #c3423f; } +.chat-tool-body { border-top: 2px solid var(--ink); padding: 8px 10px; display: flex; flex-direction: column; gap: 8px; } +.chat-tool-body pre { margin: 0; max-height: 260px; overflow: auto; padding: 8px; border-radius: 8px; background: #1e1f2e; color: #e8e6f0; font-size: 12px; line-height: 1.35; white-space: pre-wrap; overflow-wrap: anywhere; } +.chat-tool-body pre.failed { background: #3a1f22; } +.chat-busy { flex: none; padding: 6px 14px; font-size: 13px; font-weight: 800; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; animation: chat-pulse 1.6s ease-in-out infinite; } +@keyframes chat-pulse { 50% { opacity: .45; } } +@media (prefers-reduced-motion: reduce) { .chat-busy { animation: none; } } +.chat-ask { flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 12px; border-top: 3px solid var(--ink); background: #fff3c4; } +.chat-ask-text { flex: 1 1 100%; font-size: 14px; font-weight: 800; overflow-wrap: anywhere; } +.chat-ask-keys { display: flex; gap: 6px; flex: 1; } +.chat-ask-keys .btn { flex: 1; justify-content: center; height: 40px; } +.chat-compose { flex: none; display: flex; gap: 8px; align-items: center; padding: 8px 10px; padding-bottom: max(8px, env(safe-area-inset-bottom)); border-top: 3px solid var(--ink); background: var(--paper); } +.chat-compose .dictate-field { flex: 1; min-width: 0; } +.modal .chat-compose input[type=text] { flex: 1; min-width: 0; padding: 8px 10px; } diff --git a/src/client/ui/transcript.ts b/src/client/ui/transcript.ts new file mode 100644 index 000000000..c8662a104 --- /dev/null +++ b/src/client/ui/transcript.ts @@ -0,0 +1,211 @@ +// A worker's conversation as a chat, beside its terminal (the πŸ’¬ Chat tab, see termtabs.ts): your +// prompts, what it said back and what it did, from its transcript (shared/protocol/transcript.ts), +// in a page that scrolls like any chat does. Prompts go in as from the terminal's prompt box, and a +// permission prompt is answered with the same keys the keypad sends. +import './transcript.css'; +import type { Net } from '../net'; +import { store } from '../state'; +import { h, timeAgo } from './dom'; +import { markdown } from './markdown'; +import { dictateField } from './dictate'; +import type { ChatItem, ServerMsg, WorkerInfo } from '../../shared/protocol'; +import { isAsleep } from '../../shared/status'; + +/** Within this many pixels of the bottom counts as reading the latest, so new items keep the view there. */ +const STUCK_PX = 48; + +const TOOL_ICON: Record = { Bash: 'πŸ’»', Read: 'πŸ“–', Edit: '✏️', MultiEdit: '✏️', Write: '✏️', NotebookEdit: '✏️', Grep: 'πŸ”', Glob: 'πŸ”', WebFetch: '🌐', WebSearch: '🌐', Agent: 'πŸ€–', Task: 'πŸ€–', AskUserQuestion: '❓', TodoWrite: 'πŸ“', Skill: '🧩' }; + +export interface TranscriptPaneOptions { + /** Types keys into the worker's terminal (1 2 3 for a prompt, Esc to interrupt). */ + key(keys: string): void; + /** Shows the terminal tab, for what only it can answer. */ + toTerminal(): void; +} + +export interface TranscriptPane { + el: HTMLElement; + onMsg(msg: ServerMsg): void; + refresh(w: WorkerInfo): void; + /** The tab's showing: follow the chat. */ + show(): void; + /** Hidden behind another tab: stop following. */ + hide(): void; + dispose(): void; +} + +/** A tool's name as a person would say it: an MCP tool is "Server Β· tool", not mcp__server__tool. */ +function toolLabel(name: string): string { + const m = /^mcp__(.+?)__(.+)$/.exec(name); + if (!m) return name; + const server = m[1].replace(/^claude_ai_/, '').replace(/_/g, ' '); + return `${server} Β· ${m[2]}`; +} + +function firstLine(s: string): string { + const line = s.split('\n')[0]; + return line.length > 90 ? `${line.slice(0, 90)}…` : line; +} + +export function transcriptPane(net: Net, workerId: string, opts: TranscriptPaneOptions): TranscriptPane { + const older = h('button.btn.chat-older.hidden', { type: 'button' }, 'Earlier…'); + const empty = h('p.chat-empty', {}, 'Loading the conversation…'); + const log = h('div.chat-log', { role: 'log', 'aria-live': 'polite' }, older, empty); + const busy = h('div.chat-busy.hidden'); + const askText = h('span.chat-ask-text'); + const askKeys = h('div.chat-ask-keys'); + const toTerm = h('button.btn', { type: 'button', title: 'The terminal, where the choices show in full' }, 'πŸ’» Terminal'); + const ask = h('div.chat-ask.hidden', { role: 'status' }, askText, askKeys, toTerm); + const say = h('input', { type: 'text', placeholder: 'Reply, or tell it what to do next…', 'aria-label': 'Prompt', enterkeyhint: 'send', autocomplete: 'off' }) as HTMLInputElement; + const stop = h('button.btn', { type: 'button', title: 'Interrupt the worker (sends Esc)', 'aria-label': 'Interrupt' }, '⏹'); + const sendBtn = h('button.btn.primary', { type: 'submit' }, 'Send'); + const compose = h('form.chat-compose', {}, stop, dictateField(say), sendBtn); + const el = h('div.chat-pane.hidden', {}, log, busy, ask, compose); + + const rows = new Map(); + let first: ChatItem | undefined; + let more = false; + let shown = false; + let loading = false; + + const stuck = () => log.scrollHeight - log.scrollTop - log.clientHeight < STUCK_PX; + const toBottom = () => (log.scrollTop = log.scrollHeight); + + /** The row for an item: a bubble, a tool call that opens up, or a note between them. */ + const render = (item: ChatItem): HTMLElement => { + const when = { title: new Date(item.at).toLocaleString() }; + if (item.role === 'tool' && item.tool) { + const t = item.tool; + const mark = t.result === undefined ? h('span.chat-tool-mark.running', { title: 'Running' }, '⏳') : t.error ? h('span.chat-tool-mark.failed', { title: 'Failed' }, 'βœ—') : h('span.chat-tool-mark.ok', { title: 'Done' }, 'βœ“'); + const summary = h('summary', {}, h('span.chat-tool-icon', {}, TOOL_ICON[t.name] ?? 'πŸ”§'), h('span.chat-tool-name', { title: t.name }, toolLabel(t.name)), h('span.chat-tool-gist', {}, firstLine(t.input)), mark); + const body = h('div.chat-tool-body', {}, h('pre', {}, t.input), t.result !== undefined ? h('pre.chat-tool-result', { class: t.error ? 'failed' : '' }, t.result || '(no output)') : null); + return h('details.chat-tool', when, summary, body); + } + if (item.role === 'agent') return h('div.chat-msg.agent', when, markdown(item.text ?? '')); + if (item.role === 'note') return h('div.chat-note', when, item.text ?? ''); + return h('div.chat-msg.you', when, item.text ?? ''); + }; + + const add = (items: ChatItem[], where: 'end' | 'start') => { + const frag = document.createDocumentFragment(); + for (const item of items) { + const row = render(item); + rows.set(item.id, row); + frag.append(row); + } + if (where === 'end') log.append(frag); + else older.after(frag); + }; + + const onMsg = (msg: ServerMsg) => { + if (msg.t === 'transcript.none') { + if (msg.workerId !== workerId) return; + empty.textContent = msg.why; + empty.classList.remove('hidden'); + return; + } + if (msg.t !== 'transcript.items' || msg.workerId !== workerId) return; + const { items, mode } = msg; + if (mode === 'reset') { + for (const row of rows.values()) row.remove(); + rows.clear(); + more = !!msg.more; + first = items[0]; + empty.classList.toggle('hidden', items.length > 0); + if (!items.length) empty.textContent = 'Nothing said yet'; + add(items, 'end'); + older.classList.toggle('hidden', !more); + requestAnimationFrame(toBottom); + } else if (mode === 'append') { + const follow = stuck(); + empty.classList.add('hidden'); + add(items, 'end'); + first ??= items[0]; + if (follow) requestAnimationFrame(toBottom); + } else if (mode === 'prepend') { + loading = false; + older.toggleAttribute('disabled', false); + const before = log.scrollHeight; + add(items, 'start'); + if (items.length) first = items[0]; + more = !!msg.more; + older.classList.toggle('hidden', !more); + // Stay on the line you were reading: the new rows push it down by their height. + log.scrollTop += log.scrollHeight - before; + } else if (mode === 'patch') { + const follow = stuck(); + for (const item of items) { + const old = rows.get(item.id); + if (!old) continue; + const row = render(item); + if (old instanceof HTMLDetailsElement && old.open) (row as HTMLDetailsElement).open = true; + old.replaceWith(row); + rows.set(item.id, row); + } + if (follow) requestAnimationFrame(toBottom); + } + }; + + const refresh = (w: WorkerInfo) => { + const asleep = isAsleep(w.status); + sendBtn.toggleAttribute('disabled', asleep); + stop.toggleAttribute('disabled', asleep); + busy.classList.toggle('hidden', w.status !== 'working'); + if (w.status === 'working') busy.textContent = `✍️ ${w.activity || 'Working…'}`; + const asking = w.status === 'needs_input' && !asleep; + ask.classList.toggle('hidden', !asking); + if (asking) { + const permission = /^wants permission/i.test(w.activity ?? ''); + askText.textContent = w.activity || 'Waiting for your answer'; + const labels = permission ? ['1 Β· Yes', '2 Β· Always', '3 Β· No'] : ['1', '2', '3']; + askKeys.replaceChildren( + ...labels.map((label, i) => + h('button.btn', { type: 'button', title: `Answer ${i + 1}`, onclick: () => opts.key(String(i + 1)) }, label), + ), + ); + toTerm.classList.toggle('hidden', permission); + } + if (w.lastInput) say.title = `${w.lastInput.by} typed here last, ${timeAgo(w.lastInput.at)}`; + }; + + older.addEventListener('click', () => { + if (loading || !first) return; + loading = true; + older.toggleAttribute('disabled', true); + net.send({ t: 'transcript.more', workerId, before: first.id }); + }); + toTerm.addEventListener('click', () => opts.toTerminal()); + stop.addEventListener('click', () => { + if (!stop.hasAttribute('disabled')) opts.key('\x1b'); + }); + compose.addEventListener('submit', (e) => { + e.preventDefault(); + const prompt = say.value.trim(); + if (!prompt || sendBtn.hasAttribute('disabled')) return; + net.send({ t: 'worker.prompt', workerId, prompt }); + say.value = ''; + }); + + return { + el, + onMsg, + refresh, + show() { + el.classList.remove('hidden'); + if (!shown) net.send({ t: 'transcript.attach', workerId }); + shown = true; + const w = store.workers.get(workerId); + if (w) refresh(w); + requestAnimationFrame(toBottom); + }, + hide() { + el.classList.add('hidden'); + if (shown) net.send({ t: 'transcript.detach', workerId }); + shown = false; + }, + dispose() { + if (shown) net.send({ t: 'transcript.detach', workerId }); + shown = false; + }, + }; +} diff --git a/src/server/transcript.ts b/src/server/transcript.ts new file mode 100644 index 000000000..7d75834e3 --- /dev/null +++ b/src/server/transcript.ts @@ -0,0 +1,157 @@ +// A worker's chat, read off Claude Code's session transcript: the JSONL file its hooks name (see +// providers/claude.ts), tailed as it grows. Each `user` and `assistant` record becomes chat items: +// a prompt, what the agent said, and each tool it used, with the tool's result filled in once the +// `tool_result` for it is written. The file's format belongs to Claude Code, so everything here is +// parsed defensively and anything unrecognised is skipped. +import { closeSync, fstatSync, openSync, readSync } from 'node:fs'; +import type { ChatItem } from '../shared/protocol.js'; + +/** The most items kept per worker; older ones are forgotten (the terminal's scrollback has them). */ +const MAX_ITEMS = 3000; +/** How much of a tool's input and result go to the browser. */ +const INPUT_MAX = 600; +const RESULT_MAX = 2000; +const CHUNK = 4 * 1024 * 1024; + +/** Cut to `max` characters, with a mark where it was cut. */ +function cut(s: string, max: number): string { + return s.length > max ? `${s.slice(0, max)}\n… (${s.length - max} more characters)` : s; +} + +/** The text of a content block, or of a tool result's content, which can be text blocks or a string. */ +function textOf(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .map((b) => (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text' && typeof (b as { text?: unknown }).text === 'string' ? (b as { text: string }).text : b && (b as { type?: unknown }).type === 'image' ? '[image]' : '')) + .filter(Boolean) + .join('\n'); +} + +/** + * What a person typed, as they typed it: Claude Code wraps slash commands and what the office or a + * hook adds in tags of its own, which aren't the conversation. + */ +export function cleanPrompt(text: string): string { + const command = /([^<]*)<\/command-name>/.exec(text)?.[1]?.trim(); + const args = /([^<]*)<\/command-args>/.exec(text)?.[1]?.trim(); + if (command) return args ? `${command} ${args}` : command; + return text + .replace(/[\s\S]*?<\/system-reminder>/g, '') + .replace(/[\s\S]*?<\/local-command-stdout>/g, '') + .replace(/<\/?[a-z_-]+>/g, '') + .trim(); +} + +/** The gist of a tool's input: the one field that says what it did, or else the whole thing. */ +export function toolGist(name: string, input: unknown): string { + const i = (input && typeof input === 'object' ? input : {}) as Record; + const first = (...keys: string[]) => keys.map((k) => i[k]).find((v) => typeof v === 'string' && v.trim()) as string | undefined; + const gist = first('command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'description', 'prompt', 'skill', 'path') ?? (Object.keys(i).length ? JSON.stringify(i) : ''); + return cut(gist, INPUT_MAX); +} + +/** + * Turns a worker's transcript into chat items as the file grows. `read()` takes in what's new and + * says which items were added and which already-sent ones changed (a tool's result came back). + */ +export class TranscriptLog { + readonly items: ChatItem[] = []; + private offset = 0; + private rest = ''; + /** Each tool call still waiting for its result, by the tool_use id its result will name. */ + private pending = new Map(); + + constructor(readonly path: string) {} + + /** Reads what's been written since last time. Nothing happens if the file can't be read yet. */ + read(): { added: ChatItem[]; patched: ChatItem[] } { + const added: ChatItem[] = []; + const patched: ChatItem[] = []; + for (const line of this.newLines()) this.take(line, added, patched); + if (this.items.length > MAX_ITEMS) this.items.splice(0, this.items.length - MAX_ITEMS); + return { added, patched }; + } + + /** One record of the transcript. */ + take(line: string, added: ChatItem[], patched: ChatItem[]) { + let r: any; + try { + r = JSON.parse(line); + } catch { + return; + } + if (!r || typeof r !== 'object' || r.isSidechain || r.isMeta || r.isCompactSummary) return; + const at = Date.parse(r.timestamp) || Date.now(); + const id = typeof r.uuid === 'string' ? r.uuid : `${at}-${this.items.length}`; + const content = r.message?.content; + if (r.type === 'user') { + if (typeof content === 'string') return this.push(added, { id, at, role: 'you', text: cleanPrompt(content) }); + if (!Array.isArray(content)) return; + let n = 0; + for (const b of content) { + if (!b || typeof b !== 'object') continue; + if (b.type === 'tool_result') { + const item = typeof b.tool_use_id === 'string' ? this.pending.get(b.tool_use_id) : undefined; + if (!item?.tool) continue; + this.pending.delete(b.tool_use_id); + item.tool.result = cut(textOf(b.content), RESULT_MAX); + if (b.is_error) item.tool.error = true; + // Already in `added` when the call and its result come in the same read: don't send it twice. + if (!added.includes(item)) patched.push(item); + } else if (b.type === 'text' && typeof b.text === 'string') { + const text = cleanPrompt(b.text); + if (!text) continue; + const interrupted = /^\[Request interrupted by user/i.test(text); + this.push(added, { id: `${id}-${n++}`, at, role: interrupted ? 'note' : 'you', text: interrupted ? 'Interrupted' : text }); + } + } + } else if (r.type === 'assistant' && Array.isArray(content)) { + let n = 0; + for (const b of content) { + if (!b || typeof b !== 'object') continue; + if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) this.push(added, { id: `${id}-${n++}`, at, role: 'agent', text: b.text }); + else if (b.type === 'tool_use' && typeof b.name === 'string') { + const item: ChatItem = { id: typeof b.id === 'string' ? b.id : `${id}-${n++}`, at, role: 'tool', tool: { name: b.name, input: toolGist(b.name, b.input) } }; + if (typeof b.id === 'string') this.pending.set(b.id, item); + this.push(added, item); + } + } + } + } + + private push(added: ChatItem[], item: ChatItem) { + this.items.push(item); + added.push(item); + } + + /** Whole lines written since the last read; a half-written last line waits for the rest. */ + private *newLines(): Generator { + let fd: number; + try { + fd = openSync(this.path, 'r'); + } catch { + return; + } + try { + const size = fstatSync(fd).size; + // Started over (a shorter file): read it from the top. + if (size < this.offset) { + this.offset = 0; + this.rest = ''; + } + const buf = Buffer.allocUnsafe(CHUNK); + while (this.offset < size) { + const n = readSync(fd, buf, 0, Math.min(CHUNK, size - this.offset), this.offset); + if (n <= 0) break; + this.offset += n; + const text = this.rest + buf.toString('utf8', 0, n); + const lines = text.split('\n'); + this.rest = lines.pop() ?? ''; + for (const l of lines) if (l.trim()) yield l; + } + } finally { + closeSync(fd); + } + } +} diff --git a/src/server/workers/manager.ts b/src/server/workers/manager.ts index b19b2b3cd..5b05b5616 100644 --- a/src/server/workers/manager.ts +++ b/src/server/workers/manager.ts @@ -190,15 +190,14 @@ export class WorkerManager { return this.workers.get(id)?.owner; } + /** Its agent's transcript (Claude Code's session log), once a hook has named it; a /clear names a new one. */ + transcriptOf(id: string): string | undefined { + return this.workers.get(id)?.tracker.transcript; + } + /** Each worker's terminal process and directory, to tell whose servers are whose. */ owners(): ServiceOwner[] { - return [...this.workers.values()].map((w) => ({ - workerId: w.info.id, - pid: w.pty?.pid, - agent: w.info.kind === 'agent', - cwd: this.cwd(w.info), - root: this.dir, - })); + return [...this.workers.values()].map((w) => ({ workerId: w.info.id, pid: w.pty?.pid, agent: w.info.kind === 'agent', cwd: this.cwd(w.info), root: this.dir })); } /** diff --git a/src/server/ws/handlers/index.ts b/src/server/ws/handlers/index.ts index dd119c2ea..902cc7ce2 100644 --- a/src/server/ws/handlers/index.ts +++ b/src/server/ws/handlers/index.ts @@ -19,6 +19,7 @@ import { rooftopHandlers } from './rooftop.js'; import { servicesView, settingsHandlers } from './settings.js'; import { signinsHandlers } from './signins.js'; import { teamHandlers } from './team.js'; +import { transcriptHandlers, transcriptHooks } from './transcript.js'; import { usageHandlers } from './usage.js'; import { whiteboardHandlers, whiteboardHooks, whiteboardView } from './whiteboard.js'; import { jailView, workerHandlers, workerHooks, workersView } from './workers.js'; @@ -44,6 +45,7 @@ export const handlers: HandlerMap = { ...settingsHandlers, ...signinsHandlers, ...teamHandlers, + ...transcriptHandlers, ...usageHandlers, ...whiteboardHandlers, ...workerHandlers, @@ -53,7 +55,7 @@ export const handlers: HandlerMap = { * The features that keep something per person on a floor, in the order they let go of it when * someone leaves the floor or the office (see FeatureHooks): the order the office has always done it in. */ -export const features: readonly FeatureHooks[] = [workerHooks, changesHooks, whiteboardHooks, ballHooks, carHooks, cabinetHooks]; +export const features: readonly FeatureHooks[] = [workerHooks, changesHooks, whiteboardHooks, ballHooks, carHooks, cabinetHooks, transcriptHooks]; /** What someone arriving on a floor is sent (see office/views.ts): a piece from each feature, in the order it has always gone out. */ export const views: ViewPieces = { diff --git a/src/server/ws/handlers/transcript.ts b/src/server/ws/handlers/transcript.ts new file mode 100644 index 000000000..e64a0499c --- /dev/null +++ b/src/server/ws/handlers/transcript.ts @@ -0,0 +1,121 @@ +// A worker's chat (see shared/protocol/transcript.ts): browsers following it, and the transcript +// polled for them while any does. +import { statSync } from 'node:fs'; +import type { ChatItem, TranscriptClientMsg } from '../../../shared/protocol.js'; +import type { Ctx } from '../../office/context.js'; +import type { Client } from '../../office/client.js'; +import { TranscriptLog } from '../../transcript.js'; +import { workerOf } from './common.js'; +import type { FeatureHooks, HandlerMap } from './types.js'; + +/** How many items a browser gets at first, and per page of older ones. */ +const PAGE = 80; +const POLL_MS = 1000; + +interface Followed { + log: TranscriptLog; + viewers: Set; + size: number; +} + +const followed = new Map(); +let timer: NodeJS.Timeout | undefined; + +function page(items: ChatItem[], end: number): { items: ChatItem[]; more: boolean } { + const start = Math.max(0, end - PAGE); + return { items: items.slice(start, end), more: start > 0 }; +} + +/** The worker's transcript file, or why there's no chat for it. */ +function transcriptOf(ctx: Ctx, workerId: string): { path: string } | { why: string } { + const w = workerOf(ctx, workerId); + if (!w) return { why: 'No such worker' }; + if (w.info.kind !== 'agent' || (w.info.provider ?? w.floor.workers.officeDefault.provider) !== 'claude') return { why: 'The chat view follows Claude Code workers; this one has a terminal only' }; + const path = w.floor.workers.transcriptOf(w.wid); + return path ? { path } : { why: `${w.info.name} hasn't said anything yet` }; +} + +/** Sends everyone following `workerId` what's new in its transcript, and starts over if it's a new file (a /clear). */ +function poll(ctx: Ctx) { + for (const [workerId, f] of followed) { + if (!f.viewers.size) continue; + const t = transcriptOf(ctx, workerId); + if ('why' in t) continue; + if (t.path !== f.log.path) { + f.log = new TranscriptLog(t.path); + f.size = 0; + f.log.read(); + const p = page(f.log.items, f.log.items.length); + for (const c of f.viewers) ctx.sendTo(c, { t: 'transcript.items', workerId, mode: 'reset', ...p }); + continue; + } + let size: number; + try { + size = statSync(t.path).size; + } catch { + continue; + } + if (size === f.size) continue; + f.size = size; + const { added, patched } = f.log.read(); + for (const c of f.viewers) { + if (added.length) ctx.sendTo(c, { t: 'transcript.items', workerId, mode: 'append', items: added }); + if (patched.length) ctx.sendTo(c, { t: 'transcript.items', workerId, mode: 'patch', items: patched }); + } + } +} + +function unfollow(c: Client, workerId?: string) { + for (const [id, f] of followed) { + if (workerId && id !== workerId) continue; + f.viewers.delete(c); + if (!f.viewers.size) followed.delete(id); + } + if (!followed.size && timer) { + clearInterval(timer); + timer = undefined; + } +} + +export const transcriptHandlers = { + 'transcript.attach'(ctx, c, msg) { + const w = workerOf(ctx, msg.workerId); + if (!w) return; + const t = transcriptOf(ctx, w.wid); + if ('why' in t) return ctx.sendTo(c, { t: 'transcript.none', workerId: w.wid, why: t.why }); + let f = followed.get(w.wid); + if (!f || f.log.path !== t.path) { + f = { log: new TranscriptLog(t.path), viewers: new Set(), size: 0 }; + followed.set(w.wid, f); + } + f.viewers.add(c); + if (!f.size) { + f.log.read(); + try { + f.size = statSync(t.path).size; + } catch { + // read again at the next poll + } + } + ctx.sendTo(c, { t: 'transcript.items', workerId: w.wid, mode: 'reset', ...page(f.log.items, f.log.items.length) }); + timer ??= setInterval(() => poll(ctx), POLL_MS); + }, + 'transcript.detach'(_ctx, c, msg) { + const w = typeof msg.workerId === 'string' ? msg.workerId.slice(0, 32) : ''; + if (w) unfollow(c, w); + }, + 'transcript.more'(ctx, c, msg) { + const w = workerOf(ctx, msg.workerId); + const f = w && followed.get(w.wid); + if (!w || !f || !f.viewers.has(c)) return; + const before = typeof msg.before === 'string' ? msg.before : ''; + const end = f.log.items.findIndex((i) => i.id === before); + if (end <= 0) return; + ctx.sendTo(c, { t: 'transcript.items', workerId: w.wid, mode: 'prepend', ...page(f.log.items, end) }); + }, +} satisfies HandlerMap; + +export const transcriptHooks: FeatureHooks = { + leaving: (_ctx, c) => unfollow(c), + closed: (_ctx, c) => unfollow(c), +}; diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index ee0d258ef..155abdac4 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -14,6 +14,7 @@ import type { QueueClientMsg, QueueServerMsg } from './protocol/queue.js'; import type { RooftopClientMsg, RooftopServerMsg } from './protocol/rooftop.js'; import type { SettingsClientMsg, SettingsServerMsg } from './protocol/settings.js'; import type { BallClientMsg, CabinetClientMsg, CarClientMsg, DecorClientMsg, DogClientMsg, JukeboxClientMsg, ToysServerMsg, WhiteboardClientMsg } from './protocol/toys.js'; +import type { TranscriptClientMsg, TranscriptServerMsg } from './protocol/transcript.js'; import type { UsageClientMsg, UsageServerMsg } from './protocol/usage.js'; import type { WorkerClientMsg, WorkerServerMsg } from './protocol/workers.js'; @@ -28,6 +29,7 @@ export * from './protocol/queue.js'; export * from './protocol/rooftop.js'; export * from './protocol/settings.js'; export * from './protocol/toys.js'; +export * from './protocol/transcript.js'; export * from './protocol/usage.js'; export * from './protocol/workers.js'; @@ -35,6 +37,7 @@ export type ClientMsg = | PresenceClientMsg | RooftopClientMsg | WorkerClientMsg + | TranscriptClientMsg | GitHubClientMsg | QueueClientMsg | MeetingClientMsg @@ -58,6 +61,7 @@ export type ServerMsg = | PresenceServerMsg | RooftopServerMsg | WorkerServerMsg + | TranscriptServerMsg | GitHubServerMsg | QueueServerMsg | MeetingServerMsg diff --git a/src/shared/protocol/transcript.ts b/src/shared/protocol/transcript.ts new file mode 100644 index 000000000..7e3f52340 --- /dev/null +++ b/src/shared/protocol/transcript.ts @@ -0,0 +1,40 @@ +// A worker's conversation as a chat: what was said to it, what it said back and what it did, read +// off its agent's transcript (Claude Code's session log) rather than its terminal. + +/** One entry of a worker's chat. */ +export interface ChatItem { + id: string; + /** When it was written, ms since the epoch. */ + at: number; + /** `you`: a prompt from a person; `agent`: what it said; `tool`: something it did; `note`: the session itself (an interruption, a /clear). */ + role: 'you' | 'agent' | 'tool' | 'note'; + text?: string; + tool?: ChatTool; +} + +/** A tool the agent used: what it ran, and what came back once it had. */ +export interface ChatTool { + name: string; + /** The gist of its input: the command, the file, the pattern, the question. */ + input: string; + /** What it got back, cut short; unset until it has. */ + result?: string; + error?: boolean; +} + +export type TranscriptClientMsg = + /** Follow this worker's chat: answered with its latest items (`reset`), then each new one as it's written. */ + | { t: 'transcript.attach'; workerId: string } + | { t: 'transcript.detach'; workerId: string } + /** The items before `before` (an item id), oldest kept first. */ + | { t: 'transcript.more'; workerId: string; before: string }; + +export type TranscriptServerMsg = + /** + * `reset`: the latest items, replacing what the browser has; `append`: new ones; `prepend`: older + * ones asked for; `patch`: items the browser has, changed (a tool's result came back). `more`: there + * are older items than the first one here. + */ + | { t: 'transcript.items'; workerId: string; mode: 'reset' | 'append' | 'prepend' | 'patch'; items: ChatItem[]; more?: boolean } + /** No chat for this worker, and why (not a Claude Code worker, or nothing written yet). */ + | { t: 'transcript.none'; workerId: string; why: string }; diff --git a/tests/transcript.test.ts b/tests/transcript.test.ts new file mode 100644 index 000000000..11dadd433 --- /dev/null +++ b/tests/transcript.test.ts @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { appendFileSync, mkdtempSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { TranscriptLog, cleanPrompt, toolGist } from '../src/server/transcript.ts'; + +const line = (r: unknown) => `${JSON.stringify(r)}\n`; +const user = (content: unknown, extra: Record = {}) => ({ type: 'user', uuid: `u-${Math.random()}`, timestamp: '2026-10-21T10:00:00Z', message: { role: 'user', content }, ...extra }); +const assistant = (content: unknown, extra: Record = {}) => ({ type: 'assistant', uuid: `a-${Math.random()}`, timestamp: '2026-10-21T10:00:01Z', message: { id: 'msg', role: 'assistant', content }, ...extra }); + +function tempLog(...records: unknown[]): TranscriptLog { + const file = path.join(mkdtempSync(path.join(os.tmpdir(), 'ao-transcript-')), 'session.jsonl'); + writeFileSync(file, records.map(line).join('')); + return new TranscriptLog(file); +} + +test('prompts, replies and tool calls become chat items, and a tool result fills its call in', () => { + const log = tempLog( + user('fix the login bug'), + assistant([{ type: 'thinking', thinking: 'hmm' }, { type: 'text', text: 'Looking at it.' }, { type: 'tool_use', id: 'tu-1', name: 'Bash', input: { command: 'npm test', description: 'Run tests' } }]), + user([{ type: 'tool_result', tool_use_id: 'tu-1', content: [{ type: 'text', text: '12 passing' }] }], { toolUseResult: {} }), + assistant([{ type: 'text', text: 'All green.' }]), + ); + const { added, patched } = log.read(); + assert.deepEqual( + added.map((i) => [i.role, i.text ?? i.tool?.name]), + [ + ['you', 'fix the login bug'], + ['agent', 'Looking at it.'], + ['tool', 'Bash'], + ['agent', 'All green.'], + ], + ); + // The call and its result came in the same read: the item is sent once, filled in. + assert.equal(patched.length, 0); + const tool = added[2].tool!; + assert.equal(tool.input, 'npm test'); + assert.equal(tool.result, '12 passing'); + assert.equal(tool.error, undefined); + assert.equal(added[2].id, 'tu-1'); +}); + +test('a result that arrives in a later read is a patch of the item already sent, and reads are incremental', () => { + const log = tempLog(user('run it'), assistant([{ type: 'tool_use', id: 'tu-2', name: 'Bash', input: { command: 'npm run build' } }])); + const first = log.read(); + assert.equal(first.added.length, 2); + assert.equal(first.added[1].tool?.result, undefined); + appendFileSync(log.path, line(user([{ type: 'tool_result', tool_use_id: 'tu-2', content: 'boom', is_error: true }]))); + appendFileSync(log.path, line(assistant([{ type: 'text', text: 'That failed.' }]))); + const second = log.read(); + assert.deepEqual( + second.added.map((i) => i.text), + ['That failed.'], + ); + assert.equal(second.patched.length, 1); + assert.equal(second.patched[0].id, 'tu-2'); + assert.equal(second.patched[0].tool?.result, 'boom'); + assert.equal(second.patched[0].tool?.error, true); + assert.equal(log.items.length, 3); + // Nothing new: nothing added. + assert.deepEqual(log.read(), { added: [], patched: [] }); +}); + +test("what isn't the conversation is left out: subagents, meta records, bookkeeping and empty text", () => { + const log = tempLog( + { type: 'permission-mode', mode: 'default' }, + user('hidden', { isMeta: true }), + user('side', { isSidechain: true }), + assistant([{ type: 'text', text: ' ' }]), + user([{ type: 'text', text: '[Request interrupted by user for tool use]' }]), + { type: 'summary', summary: 'x' }, + 'not json', + ); + const { added } = log.read(); + assert.deepEqual( + added.map((i) => [i.role, i.text]), + [['note', 'Interrupted']], + ); +}); + +test('a prompt reads as it was typed: slash commands by name, and the tags around it gone', () => { + assert.equal(cleanPrompt('/clear\nclear'), '/clear'); + assert.equal(cleanPrompt('/modelopus'), '/model opus'); + assert.equal(cleanPrompt('do it\n\nsecret context\n'), 'do it'); + assert.equal(cleanPrompt('noise'), ''); +}); + +test("a tool's gist is the field that says what it did", () => { + assert.equal(toolGist('Bash', { command: 'ls -la', description: 'List' }), 'ls -la'); + assert.equal(toolGist('Read', { file_path: '/a/b.ts' }), '/a/b.ts'); + assert.equal(toolGist('Grep', { pattern: 'TODO', path: '.' }), 'TODO'); + assert.equal(toolGist('Agent', { description: 'Survey', prompt: 'long…' }), 'Survey'); + assert.equal(toolGist('Odd', { a: 1 }), '{"a":1}'); + assert.equal(toolGist('Odd', undefined), ''); + assert.match(toolGist('Bash', { command: 'x'.repeat(700) }), /more characters\)$/); +});