From 4f0e15560578e75a560e6c23479617a6c792a4bb Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 20 Mar 2026 19:37:08 -0700 Subject: [PATCH 1/4] Improve mobile dispatch and transcript logs --- README.md | 16 +- backend/src/app.test.ts | 2 + backend/src/sessions/service.ts | 104 ++- backend/src/sessions/startup-ready.test.ts | 21 + backend/src/sessions/startup-ready.ts | 21 + backend/src/terminal/mirror-routes.ts | 11 +- .../src/terminal/readable-transcript.test.ts | 29 + backend/src/terminal/readable-transcript.ts | 175 ++++ backend/src/terminal/routes.ts | 94 +- backend/src/terminal/semantic-processor.ts | 108 +-- backend/src/terminal/transcript-store.test.ts | 97 ++ backend/src/terminal/transcript-store.ts | 411 +++++--- backend/src/tmux/adapter.ts | 13 +- docs/remote-control.md | 6 + frontend/src/components/DispatchTask.tsx | 210 +++++ frontend/src/pages/Dashboard.tsx | 143 ++- frontend/src/pages/NewSession.tsx | 142 ++- frontend/src/pages/SessionDetail.tsx | 874 +++++++++++++++--- 18 files changed, 1945 insertions(+), 532 deletions(-) create mode 100644 backend/src/sessions/startup-ready.test.ts create mode 100644 backend/src/sessions/startup-ready.ts create mode 100644 backend/src/terminal/readable-transcript.test.ts create mode 100644 backend/src/terminal/readable-transcript.ts create mode 100644 backend/src/terminal/transcript-store.test.ts create mode 100644 frontend/src/components/DispatchTask.tsx diff --git a/README.md b/README.md index 1a4d0eb..b6b822a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ Start an agent on your laptop, walk away, and check in from your phone or tablet ## Why CloudCode? - **Agent agnostic:** Works with Claude Code, Gemini CLI, OpenAI Codex, GitHub Copilot CLI, or any CLI tool. -- **Readable logs:** Intercepts raw terminal output and renders it as formatted Markdown — easier to read on mobile. +- **Task launch:** Start tasks instantly from your mobile dashboard without opening a full terminal. CloudCode defaults to your recent projects and drops you into the live session view. +- **Transcript logs:** Shows the full session output in a scrollable, timestamped transcript view. - **QR code pairing:** Scan a QR code from your terminal to authenticate your phone. No passwords or SSH keys. - **Persistent sessions:** Sessions run inside `tmux`. Your agent keeps working if your laptop sleeps or your connection drops. Reconnect and pick up where you left off. - **Flexible networking:** Works on local Wi-Fi, over Tailscale (private network), or via Cloudflare Tunnels (no port-forwarding needed). @@ -114,6 +115,18 @@ cloudcode run claude-code --rc --tunnel cloudcode run custom --command "npx some-ai-tool" --rc ``` +### Send vs create + +Use the dashboard `Send` box when you want the fastest path: +- It uses your recent agent and workspace defaults. +- It creates a background session automatically. +- It opens the live terminal first. + +Use `Create` when you want full control: +- Pick the exact agent profile. +- Choose the workspace or worktree deliberately. +- Set a title and startup prompt before launch. + --- ## Architecture @@ -162,4 +175,3 @@ Built by Alex Chao (@alexchaomander). Find me on my socials! · [LinkedIn](https://www.linkedin.com/in/alexchao56/) · [YouTube](https://www.youtube.com/@alexchaomander) · [Substack](https://alexchao.substack.com/) - diff --git a/backend/src/app.test.ts b/backend/src/app.test.ts index 97eaa59..07fe5b8 100644 --- a/backend/src/app.test.ts +++ b/backend/src/app.test.ts @@ -20,6 +20,8 @@ vi.mock('./tmux/adapter.js', () => ({ hasSession: vi.fn().mockResolvedValue(true), listSessions: vi.fn().mockResolvedValue([]), capturePane: vi.fn().mockResolvedValue('test output'), + capturePaneHistory: vi.fn().mockResolvedValue('history output'), + setHistoryLimit: vi.fn().mockResolvedValue(undefined), resizeWindow: vi.fn().mockResolvedValue(undefined), })); diff --git a/backend/src/sessions/service.ts b/backend/src/sessions/service.ts index a3e1c2d..b00f016 100644 --- a/backend/src/sessions/service.ts +++ b/backend/src/sessions/service.ts @@ -6,12 +6,13 @@ import { execFileSync } from 'node:child_process'; import { db } from '../db/index.js'; import { logAudit } from '../audit/service.js'; import * as tmux from '../tmux/adapter.js'; -import { deleteTranscript, initTranscript } from '../terminal/transcript-store.js'; +import { sidecarManager, type SidecarStreamHandle } from '../terminal/sidecar-manager.js'; +import { appendTranscript, deleteTranscript, initTranscript } from '../terminal/transcript-store.js'; import { validateWorkdir } from '../utils/paths.js'; import type { AgentProfile, Session } from '../db/schema.js'; +import { hasPromptMarker } from './startup-ready.js'; const INITIAL_STARTUP_INPUT_DELAY_MS = parseInt(process.env.INITIAL_STARTUP_INPUT_DELAY_MS ?? '1600', 10); -const FOLLOWUP_STARTUP_INPUT_DELAY_MS = parseInt(process.env.FOLLOWUP_STARTUP_INPUT_DELAY_MS ?? '700', 10); const STARTUP_READY_TIMEOUT_MS = parseInt(process.env.STARTUP_READY_TIMEOUT_MS ?? '12000', 10); const STARTUP_READY_POLL_MS = parseInt(process.env.STARTUP_READY_POLL_MS ?? '250', 10); @@ -46,68 +47,62 @@ async function sendStartupLine(sessionName: string, text: string): Promise await tmux.sendEnter(sessionName); } -function getStartupReadyPatterns(profile: AgentProfile): string[] { - const slug = profile.slug.toLowerCase(); +const transcriptRecorders = new Map(); - if (slug === 'gemini-cli') { - return [ - 'type your message or @path/to/file', - '? for shortcuts', - '/model auto', - ]; - } +export function hasTranscriptRecorder(sessionId: string): boolean { + return transcriptRecorders.has(sessionId); +} - if (slug === 'claude-code') { - return [ - '? for shortcuts', - 'try "', - 'shift+tab to accept edits', - ]; - } +async function startTranscriptRecorder(sessionId: string, sessionName: string): Promise { + if (transcriptRecorders.has(sessionId)) return; - if (slug === 'openai-codex') { - return [ - '? for shortcuts', - 'type your message', - 'shift+tab to accept edits', - ]; - } + const recorder = await sidecarManager.openStream(sessionName, 160, 48, { + onOutput: ({ text }) => { + void appendTranscript(sessionId, text).catch(() => {}); + }, + onExit: () => { + transcriptRecorders.delete(sessionId); + }, + onError: () => { + transcriptRecorders.delete(sessionId); + }, + }); - if (slug === 'github-copilot-cli') { - return [ - 'type your message', - '? for shortcuts', - ]; - } + transcriptRecorders.set(sessionId, recorder); +} - return ['type your message', '? for shortcuts', '> ']; +async function stopTranscriptRecorder(sessionId: string): Promise { + const recorder = transcriptRecorders.get(sessionId); + if (!recorder) return; + transcriptRecorders.delete(sessionId); + await recorder.close().catch(() => {}); } -function hasPromptMarker(content: string, patterns: string[]): boolean { - const normalized = content.toLowerCase(); - if (patterns.some((pattern) => normalized.includes(pattern.toLowerCase()))) { - return true; +async function backfillTranscriptSnapshot(sessionId: string, sessionName: string): Promise { + if (typeof tmux.capturePaneHistory !== 'function' || typeof tmux.capturePane !== 'function') { + return; } - const lines = normalized - .split('\n') - .map((line) => line.trim()) - .filter(Boolean); + const [historyOutput, currentOutput] = await Promise.all([ + tmux.capturePaneHistory(sessionName), + tmux.capturePane(sessionName), + ]); + + const snapshot = [historyOutput, currentOutput].filter(Boolean).join('\n').trim(); + if (!snapshot) return; - const lastLine = lines.at(-1) ?? ''; - return ['>', '❯', '$', '#'].includes(lastLine); + await appendTranscript(sessionId, snapshot); } -async function waitForStartupReady(sessionName: string, profile: AgentProfile): Promise { +async function waitForStartupReady(sessionName: string, _profile: AgentProfile): Promise { const deadline = Date.now() + STARTUP_READY_TIMEOUT_MS; - const patterns = getStartupReadyPatterns(profile); let sawOutput = false; while (Date.now() < deadline) { const content = await tmux.capturePane(sessionName); if (content.trim()) { sawOutput = true; - if (hasPromptMarker(content, patterns)) { + if (hasPromptMarker(content)) { return; } } @@ -353,17 +348,23 @@ export async function createSession(params: CreateSessionParams): Promise 0 ? env : undefined ); + if (typeof tmux.setHistoryLimit === 'function') { + await tmux.setHistoryLimit(tmuxSessionName, 100000).catch(() => {}); + } + + await backfillTranscriptSnapshot(id, tmuxSessionName); + await startTranscriptRecorder(id, tmuxSessionName).catch((err) => { + // Transcript recording is best-effort; session creation should still succeed. + console.warn('Failed to start transcript recorder', err); + }); + if (profile.startup_template) { await waitForStartupReady(tmuxSessionName, profile); await sendStartupLine(tmuxSessionName, profile.startup_template); } if (startupPrompt) { - if (profile.startup_template) { - await sleep(FOLLOWUP_STARTUP_INPUT_DELAY_MS); - } else { - await waitForStartupReady(tmuxSessionName, profile); - } + await waitForStartupReady(tmuxSessionName, profile); await sendStartupLine(tmuxSessionName, startupPrompt); } @@ -423,6 +424,7 @@ export async function killSession(id: string, userId: string): Promise { if (!exited) { throw new Error(`Failed to terminate tmux session: ${session.tmux_session_name}`); } + await stopTranscriptRecorder(session.id); const now = new Date().toISOString(); db.prepare(`UPDATE sessions SET status = 'stopped', stopped_at = ?, updated_at = ? WHERE id = ?`).run(now, now, id); @@ -439,6 +441,8 @@ export async function deleteSession(id: string, userId: string): Promise { const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as (Session & { worktree_path: string | null }) | undefined; if (!session) throw new Error(`Session not found: ${id}`); + await stopTranscriptRecorder(session.id); + const exists = await tmux.hasSession(session.tmux_session_name); if (exists) { await tmux.killSession(session.tmux_session_name); diff --git a/backend/src/sessions/startup-ready.test.ts b/backend/src/sessions/startup-ready.test.ts new file mode 100644 index 0000000..2a13f68 --- /dev/null +++ b/backend/src/sessions/startup-ready.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { hasPromptMarker } from './startup-ready.js'; + +describe('hasPromptMarker', () => { + it('ignores help text that appears before the actual prompt', () => { + const content = [ + 'Welcome to Gemini CLI', + 'type your message or @path/to/file', + '? for shortcuts', + '', + ].join('\n'); + + expect(hasPromptMarker(content)).toBe(false); + }); + + it('detects a ready prompt on the last line', () => { + expect(hasPromptMarker('> ')).toBe(true); + expect(hasPromptMarker('❯ ')).toBe(true); + expect(hasPromptMarker('> Refactor the login flow')).toBe(true); + }); +}); diff --git a/backend/src/sessions/startup-ready.ts b/backend/src/sessions/startup-ready.ts new file mode 100644 index 0000000..0b4fbf0 --- /dev/null +++ b/backend/src/sessions/startup-ready.ts @@ -0,0 +1,21 @@ +export function hasPromptMarker(content: string): boolean { + const lines = content + .split('\n') + .map((line) => line.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '').trimEnd()) + .filter(Boolean); + + const lastLine = lines.at(-1)?.trim() ?? ''; + if (!lastLine) { + return false; + } + + if (/^[>❯$#]\s*$/.test(lastLine)) { + return true; + } + + if (/^[>❯$#]\s+\S+/.test(lastLine)) { + return true; + } + + return false; +} diff --git a/backend/src/terminal/mirror-routes.ts b/backend/src/terminal/mirror-routes.ts index 1032a20..8c9625e 100644 --- a/backend/src/terminal/mirror-routes.ts +++ b/backend/src/terminal/mirror-routes.ts @@ -1,15 +1,14 @@ import type { FastifyPluginAsync } from 'fastify'; -import { listSessions } from '../tmux/adapter.js'; import { requireAuth } from '../auth/middleware.js'; +import { listSessions } from '../sessions/service.js'; const mirrorRoutes: FastifyPluginAsync = async (fastify) => { - // GET /api/v1/terminal/tmux-sessions - list all active tmux sessions for mirroring + // GET /api/v1/terminal/tmux-sessions - list CloudCode-managed live sessions for mirroring fastify.get('/api/v1/terminal/tmux-sessions', { preHandler: requireAuth }, async (request, reply) => { try { - const sessions = await listSessions(); - // Filter out CloudCode-managed sessions if we want a clean "Mirror" list, - // but showing all is more powerful for "Remote Control" - return reply.send(sessions); + const sessions = listSessions(); + const filteredSessions = sessions.filter((session) => session.status === 'running' || session.status === 'starting'); + return reply.send(filteredSessions); } catch (err) { fastify.log.error(err); return reply.status(500).send({ error: 'Internal Server Error', message: 'Failed to list tmux sessions' }); diff --git a/backend/src/terminal/readable-transcript.test.ts b/backend/src/terminal/readable-transcript.test.ts new file mode 100644 index 0000000..122ca99 --- /dev/null +++ b/backend/src/terminal/readable-transcript.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { formatReadableTranscript } from './readable-transcript.js'; + +describe('formatReadableTranscript', () => { + it('preserves markdown structure and strips terminal chrome', () => { + const raw = [ + 'cc-12345/projects/demo', + 'IMPLEMENTATION PLAN', + '- Update the login flow', + '- Add tests', + '', + 'const answer = 42', + 'console.log(answer)', + '', + 'DONE', + ].join('\n'); + + const formatted = formatReadableTranscript(raw); + + expect(formatted).toContain('## IMPLEMENTATION PLAN'); + expect(formatted).toContain('- Update the login flow'); + expect(formatted).toContain('- Add tests'); + expect(formatted).toContain('```text'); + expect(formatted).toContain('const answer = 42'); + expect(formatted).toContain('console.log(answer)'); + expect(formatted).toContain('## DONE'); + expect(formatted).not.toContain('cc-12345'); + }); +}); diff --git a/backend/src/terminal/readable-transcript.ts b/backend/src/terminal/readable-transcript.ts new file mode 100644 index 0000000..b97d673 --- /dev/null +++ b/backend/src/terminal/readable-transcript.ts @@ -0,0 +1,175 @@ +const ANSI_ESCAPE_RE = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g +const LEADING_SIERRA_RE = /^\[?[0-9;]*[mK]/ +const BOX_DRAWING_RE = /[▄▀╭╮╰╯─│║|◇]/ + +function normalizeLine(line: string): string { + if (!line) return '' + + let clean = line.replace(ANSI_ESCAPE_RE, '') + + clean = clean + .replace(/\\\[[0-9;]*[mK]/g, '') + .replace(/\[[0-9;]*[mK]/g, '') + .replace(/\[?38;5;[0-9]+m/g, '') + .replace(/\[?39m/g, '') + .replace(/\(B/g, '') + .replace(LEADING_SIERRA_RE, '') + + clean = clean.replace(BOX_DRAWING_RE, ' ') + + return clean + .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '') + .replace(/\uFFFD+/g, '') + .replace(/[\u2800-\u28FF]/g, '') + .replace(/\r/g, '') +} + +function isUiChromeLine(line: string): boolean { + const trimmed = line.trim() + if (!trimmed) return false + + if (trimmed.includes('cc-') || trimmed.includes('projects/')) return true + if (trimmed.includes(';2c0;276;0c')) return true + if (trimmed.includes('[?12l') || trimmed.includes('[?25h') || trimmed.includes('[>c')) return true + if (trimmed.includes('to accept edits')) return true + + const lowered = trimmed.toLowerCase() + const chrome = [ + 'gemini cli', 'claude code', 'logged in', 'openai codex', 'copilot cli', + '/auth', '/upgrade', 'type your message', 'shift+tab', 'shortcuts', + 'analyzing', 'thinking', 'working', 'completed', 'no sandbox', '/model', + 'delegate to agent', 'subagent', 'termination reason', 'goal', 'result:' + ] + + if (chrome.some((needle) => lowered.includes(needle))) return true + + if (/^[0-9. ]+$/.test(trimmed) && (trimmed.includes('.') || trimmed.length < 5)) return true + if (trimmed.length < 3 && !/^[A-Z0-9]+$/.test(trimmed)) return true + + return false +} + +function isMarkdownStructuralLine(line: string): boolean { + return ( + /^#{1,6}\s+\S/.test(line) || + /^>\s?\S/.test(line) || + /^[-*+]\s+\S/.test(line) || + /^\d+\.\s+\S/.test(line) || + /^-\s+\[[ xX]\]\s+\S/.test(line) || + /^---+$/.test(line) || + /^\|.*\|$/.test(line) + ) +} + +function isLikelyHeading(line: string): boolean { + return ( + line.length > 3 && + line.length < 80 && + line === line.toUpperCase() && + /[A-Z]/.test(line) && + !/[`{}[\]()]/.test(line) + ) +} + +function isLikelyCodeLine(line: string): boolean { + return ( + /^\s{2,}\S/.test(line) || + /^[\t]+/.test(line) || + /^[{[]/.test(line) || + /^(const|let|var|function|class|return|if|for|while|import|export|async|await|try|catch|switch|case)\b/.test(line) || + /\bconsole\.(log|error|warn|info)\b/.test(line) || + /[;{}=<>()[\]]/.test(line) + ) +} + +function flushParagraph(paragraph: string[], out: string[]): void { + if (paragraph.length === 0) return + out.push(paragraph.join(' ')) + paragraph.length = 0 +} + +function flushInferredCodeBlock(codeBlock: string[], out: string[]): void { + if (codeBlock.length === 0) return + out.push('```text') + out.push(...codeBlock) + out.push('```') + codeBlock.length = 0 +} + +export function formatReadableTranscript(rawText: string): string { + const lines = rawText.split('\n') + const out: string[] = [] + const paragraph: string[] = [] + const inferredCodeBlock: string[] = [] + let inCodeFence = false + + for (const rawLine of lines) { + const line = normalizeLine(rawLine) + const trimmed = line.trimEnd() + + if (!trimmed.trim()) { + if (inCodeFence) { + out.push('') + continue + } + + flushParagraph(paragraph, out) + flushInferredCodeBlock(inferredCodeBlock, out) + if (out.length > 0 && out[out.length - 1] !== '') { + out.push('') + } + continue + } + + if (isUiChromeLine(trimmed)) continue + + if (/^```/.test(trimmed.trim())) { + flushParagraph(paragraph, out) + flushInferredCodeBlock(inferredCodeBlock, out) + out.push(trimmed.trim()) + inCodeFence = !inCodeFence + continue + } + + if (inCodeFence) { + out.push(trimmed) + continue + } + + const compact = trimmed.trim() + + if (isMarkdownStructuralLine(compact)) { + flushParagraph(paragraph, out) + flushInferredCodeBlock(inferredCodeBlock, out) + out.push(compact) + continue + } + + if (isLikelyHeading(compact)) { + flushParagraph(paragraph, out) + flushInferredCodeBlock(inferredCodeBlock, out) + out.push(`## ${compact}`) + continue + } + + if (isLikelyCodeLine(compact)) { + flushParagraph(paragraph, out) + inferredCodeBlock.push(compact) + continue + } + + flushInferredCodeBlock(inferredCodeBlock, out) + paragraph.push(compact) + } + + flushParagraph(paragraph, out) + flushInferredCodeBlock(inferredCodeBlock, out) + + const collapsed: string[] = [] + for (const line of out) { + if (line === '' && collapsed[collapsed.length - 1] === '') continue + collapsed.push(line) + } + + return collapsed.join('\n').trim() +} diff --git a/backend/src/terminal/routes.ts b/backend/src/terminal/routes.ts index 8eeff79..82c7175 100644 --- a/backend/src/terminal/routes.ts +++ b/backend/src/terminal/routes.ts @@ -1,5 +1,5 @@ import type { FastifyPluginAsync } from 'fastify'; -import { getSession, getSessionByPublicId } from '../sessions/service.js'; +import { getSession, getSessionByPublicId, hasTranscriptRecorder } from '../sessions/service.js'; import { validateSession } from '../auth/service.js'; import { sidecarManager, type SidecarStreamHandle } from './sidecar-manager.js'; import * as tmux from '../tmux/adapter.js'; @@ -9,10 +9,24 @@ import { formatReadableTerminalText, hasReadableTranscriptArtifacts, readTranscript, + readTranscriptPage, } from './transcript-store.js'; +async function resolveTerminalTarget(id: string) { + const session = getSession(id) ?? getSessionByPublicId(id); + if (session) { + return { session, tmuxSessionName: session.tmuxSessionName, isMirrorOnly: false }; + } + + if (await tmux.hasSession(id)) { + return { session: null, tmuxSessionName: id, isMirrorOnly: true }; + } + + return null; +} + const terminalRoutes: FastifyPluginAsync = async (fastify) => { - fastify.get('/api/v1/sessions/:id/terminal/bootstrap', async (request, reply) => { + fastify.get('/api/v1/sessions/:id/transcript', async (request, reply) => { const cookieToken = request.cookies?.['session']; const queryToken = (request.query as Record)['token']; const token = cookieToken ?? queryToken; @@ -27,35 +41,80 @@ const terminalRoutes: FastifyPluginAsync = async (fastify) => { return reply.status(404).send({ error: 'Not Found', message: 'Session not found' }); } - const isTerminalAvailable = session.status === 'running' || session.status === 'stopped' || session.status === 'error'; + const query = request.query as Record; + const parseNumber = (value?: string): number | undefined => { + if (value === undefined || value === '') return undefined; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; + }; + + const page = await readTranscriptPage(session.id, { + limit: parseNumber(query.limit), + before: parseNumber(query.before), + after: parseNumber(query.after), + }); + + return reply.send(page); + }); + + fastify.get('/api/v1/sessions/:id/terminal/bootstrap', async (request, reply) => { + const cookieToken = request.cookies?.['session']; + const queryToken = (request.query as Record)['token']; + const token = cookieToken ?? queryToken; + + if (!token || !validateSession(token)) { + return reply.status(401).send({ error: 'Unauthorized', message: 'Authentication required' }); + } + + const { id } = request.params as { id: string }; + const target = await resolveTerminalTarget(id); + if (!target) { + return reply.status(404).send({ error: 'Not Found', message: 'Session not found' }); + } + + const { session, tmuxSessionName, isMirrorOnly } = target; + const isTerminalAvailable = isMirrorOnly + ? true + : session.status === 'running' || session.status === 'stopped' || session.status === 'error'; const [dimensions, historyOutput, currentOutput] = await Promise.all([ isTerminalAvailable - ? tmux.getPaneDimensions(session.tmuxSessionName) + ? tmux.getPaneDimensions(tmuxSessionName) : Promise.resolve({ cols: 160, rows: 48 }), isTerminalAvailable - ? tmux.capturePaneHistory(session.tmuxSessionName) + ? tmux.capturePaneHistory(tmuxSessionName) : Promise.resolve(''), isTerminalAvailable - ? tmux.capturePane(session.tmuxSessionName) + ? tmux.capturePane(tmuxSessionName) : Promise.resolve(''), ]); try { - const transcriptOutput = await readTranscript(session.id, { ...dimensions, asMarkdown: true }); const paneOutput = [historyOutput, currentOutput].filter(Boolean).join('\n').trim(); + const scrollbackOutput = isMirrorOnly + ? paneOutput + : (await readTranscript(session.id, dimensions)) || paneOutput; + const timelineOutput = isMirrorOnly + ? scrollbackOutput + : (await readTranscript(session.id, { ...dimensions, asTimeline: true })) || scrollbackOutput; + const transcriptOutput = isMirrorOnly + ? formatReadableTerminalText(paneOutput) + : await readTranscript(session.id, { ...dimensions, asMarkdown: true }); const readablePaneOutput = formatReadableTerminalText(paneOutput); // We prioritize the semantic markdown transcript output if it exists and has content const readableOutput = transcriptOutput ? transcriptOutput - : (readablePaneOutput || await readTranscript(session.id, dimensions)); + : (readablePaneOutput || (session ? await readTranscript(session.id, dimensions) : '')); return reply.send({ + scrollbackOutput, + timelineOutput, readableOutput, transcriptOutput, readablePaneOutput, historyOutput, currentOutput, - fullOutput: readableOutput || (paneOutput || currentOutput), + transcriptPaginationSupported: true, + fullOutput: scrollbackOutput || readableOutput || paneOutput || currentOutput, }); } catch (e: any) { request.log.error({ err: e }, "Error in bootstrap"); @@ -98,8 +157,8 @@ const terminalRoutes: FastifyPluginAsync = async (fastify) => { } const attachSession = async (requestedSessionId: string): Promise => { - const session = getSession(requestedSessionId) ?? getSessionByPublicId(requestedSessionId); - if (!session) { + const target = await resolveTerminalTarget(requestedSessionId); + if (!target) { ws.send(JSON.stringify({ type: 'session.error', message: `Session not found: ${requestedSessionId}`, @@ -107,11 +166,14 @@ const terminalRoutes: FastifyPluginAsync = async (fastify) => { return; } + const { session, tmuxSessionName, isMirrorOnly } = target; await ptySession?.close().catch(() => {}); attachedSession = session; - ptySession = await sidecarManager.openStream(session.tmuxSessionName, lastSize.cols, lastSize.rows, { + ptySession = await sidecarManager.openStream(tmuxSessionName, lastSize.cols, lastSize.rows, { onOutput: ({ text, dataBase64 }) => { - void appendTranscript(session.id, text).catch(() => {}); + if (session && !isMirrorOnly && !hasTranscriptRecorder(session.id)) { + void appendTranscript(session.id, text).catch(() => {}); + } if (ws.readyState !== 1) return; ws.send(JSON.stringify({ type: 'terminal.output', dataBase64 })); }, @@ -127,9 +189,11 @@ const terminalRoutes: FastifyPluginAsync = async (fastify) => { ws.send(JSON.stringify({ type: 'session.status', - status: session.status, + status: session?.status ?? 'running', })); - void appendTranscriptResize(session.id, lastSize.cols, lastSize.rows).catch(() => {}); + if (session && !isMirrorOnly) { + void appendTranscriptResize(session.id, lastSize.cols, lastSize.rows).catch(() => {}); + } }; const attachAndTrack = (requestedSessionId: string): Promise => { diff --git a/backend/src/terminal/semantic-processor.ts b/backend/src/terminal/semantic-processor.ts index e173025..7565211 100644 --- a/backend/src/terminal/semantic-processor.ts +++ b/backend/src/terminal/semantic-processor.ts @@ -1,12 +1,11 @@ import xtermHeadless from '@xterm/headless'; +import { formatReadableTranscript } from './readable-transcript.js'; // Handle ESM / CJS interop for @xterm/headless const Terminal = (xtermHeadless as any).Terminal || (xtermHeadless as any).default?.Terminal; export class SemanticProcessor { private term: any; - private lastExtractedIndex: number = 0; - private committedBlocks: string[] = []; constructor() { // Use an ultra-wide terminal so words are NEVER wrapped by the grid. @@ -53,110 +52,7 @@ export class SemanticProcessor { return this.finalizeReadableTranscript(lines.join('\n')); } - private isUiChromeLine(line: string): boolean { - const trimmed = line.trim(); - if (!trimmed) return false; - - // Catch CloudCode and terminal state dumps - if (trimmed.includes('cc-') || trimmed.includes('projects/')) return true; - if (trimmed.includes(';2c0;276;0c')) return true; - if (trimmed.includes('to accept edits')) return true; - if (trimmed.includes('no sandbox')) return true; - - const lowered = trimmed.toLowerCase(); - const chrome = [ - 'gemini cli', 'claude code', 'logged in', 'openai codex', 'copilot cli', - '/auth', '/upgrade', 'type your message', 'shift+tab', 'shortcuts', - 'analyzing', 'thinking', 'working', 'completed', '/model', - 'delegate to agent', 'subagent', 'termination reason', 'goal', 'result:' - ]; - - if (chrome.some(c => lowered.includes(c))) return true; - - // Catch lone numbers, versions, or dots (e.g. "v0.33.1") - if (/^[0-9. v]+$/.test(trimmed) && (trimmed.includes('.') || trimmed.length < 8)) return true; - - // Catch decorative lines (boxes, separators) - const nonWhitespace = Array.from(trimmed); - const decorativeChars = nonWhitespace.filter((char) => /[\u2500-\u257F\u2580-\u259F│║|+◇]/.test(char)).length; - if (nonWhitespace.length >= 5 && decorativeChars / nonWhitespace.length > 0.5) return true; - - return false; - } - private finalizeReadableTranscript(rawText: string): string { - const lines = rawText.split('\n'); - const blocks: string[] = []; - let currentBlock = ''; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (this.isUiChromeLine(line)) continue; - - const trimmed = line.trim(); - if (!trimmed) { - if (currentBlock) { - blocks.push(currentBlock); - currentBlock = ''; - } - continue; - } - - // Semantic Paragraph Grouping - const isNewThought = - (/^[A-Z]/.test(trimmed) && currentBlock.length > 0 && /[.!?:;]\s*$/.test(currentBlock)) || - /^[*\-•+]|\d+\./.test(trimmed) || - (trimmed === trimmed.toUpperCase() && trimmed.length > 3 && trimmed.length < 40) || - trimmed.startsWith('{') || trimmed.startsWith('}') || trimmed.startsWith('[') || trimmed.startsWith(']'); - - if (isNewThought && currentBlock) { - blocks.push(currentBlock); - currentBlock = ''; - } - - // Add to current block - currentBlock += (currentBlock ? ' ' : '') + trimmed; - } - - if (currentBlock) blocks.push(currentBlock); - - // Final cleanup: filter garbage blocks and format - const finalBlocks: string[] = []; - - blocks.forEach(b => { - // Collapse multiple spaces - let text = b.replace(/\s{2,}/g, ' ').trim(); - - // EXTREME WORD HEALING: The raw stream sometimes outputs "w o r d". - // We will look for 1-3 letter lowercase fragments surrounded by spaces and fuse them - // if they don't look like real small words. - text = text.replace(/\b([a-zA-Z]{1,3})\s+([a-zA-Z]{2,})\b/g, (match, p1, p2) => { - const validSmallWords = ['a', 'an', 'as', 'at', 'be', 'by', 'do', 'go', 'he', 'hi', 'if', 'in', 'is', 'it', 'me', 'my', 'no', 'of', 'on', 'or', 'so', 'to', 'up', 'us', 'we', 'and', 'are', 'but', 'for', 'had', 'has', 'her', 'him', 'his', 'how', 'not', 'our', 'out', 'she', 'the', 'too', 'use', 'was', 'who', 'you']; - if (validSmallWords.includes(p1.toLowerCase())) return match; - return p1 + p2; - }); - - // Heal hyphenated line breaks - text = text.replace(/-\s+/g, '-'); - - // Must have some actual letters, not just symbols/JSON syntax - if (!/[a-zA-Z]{3,}/.test(text)) return; - - // Filter out weird fragment blocks - if (text.length < 5 && !/^[A-Z0-9]/.test(text)) return; - - // Filter out trailing JSON/terminal garbage that isn't a real sentence - if (text.startsWith('"FilePath":') || text.startsWith('"Reasoning":') || text.startsWith('"KeySymbols":')) return; - - // Duplicate removal (heuristic to catch AI reprinting the same block after a progress spinner) - if (finalBlocks.length > 0) { - const prev = finalBlocks[finalBlocks.length - 1]; - if (text.includes(prev.slice(0, Math.floor(prev.length * 0.8)))) return; - } - - finalBlocks.push(text); - }); - - return finalBlocks.join('\n\n'); + return formatReadableTranscript(rawText); } } diff --git a/backend/src/terminal/transcript-store.test.ts b/backend/src/terminal/transcript-store.test.ts new file mode 100644 index 0000000..5f8dc6c --- /dev/null +++ b/backend/src/terminal/transcript-store.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { buildTranscriptPage, formatTimelineTranscript } from './transcript-store.js'; + +describe('formatTimelineTranscript', () => { + it('adds timestamp separators between output chunks', () => { + const timeline = formatTimelineTranscript([ + { type: 'output', data: 'First chunk\n- item one', at: '2026-03-19T20:11:12.000Z' }, + { type: 'output', data: 'Second chunk', at: '2026-03-19T20:11:18.000Z' }, + ]); + + expect(timeline).toContain('── 20:11:12 ──'); + expect(timeline).toContain('First chunk'); + expect(timeline).toContain('- item one'); + expect(timeline).toContain('── 20:11:18 ──'); + expect(timeline).toContain('Second chunk'); + }); +}); + +describe('buildTranscriptPage', () => { + const events = [ + { type: 'output' as const, data: 'Chunk 1', at: '2026-03-19T20:11:12.000Z' }, + { type: 'resize' as const, cols: 140, rows: 40, at: '2026-03-19T20:11:13.000Z' }, + { type: 'output' as const, data: 'Chunk 2', at: '2026-03-19T20:11:14.000Z' }, + { type: 'output' as const, data: 'Chunk 3', at: '2026-03-19T20:11:15.000Z' }, + { type: 'output' as const, data: 'Chunk 4', at: '2026-03-19T20:11:16.000Z' }, + ]; + + it('returns the newest output chunks by default', () => { + const page = buildTranscriptPage(events, { limit: 2 }); + + expect(page.totalItems).toBe(4); + expect(page.items.map((item) => item.index)).toEqual([3, 4]); + expect(page.items.map((item) => item.text)).toEqual(['Chunk 3', 'Chunk 4']); + expect(page.hasMoreBefore).toBe(true); + expect(page.hasMoreAfter).toBe(false); + }); + + it('pages older output when before is supplied', () => { + const page = buildTranscriptPage(events, { before: 3, limit: 2 }); + + expect(page.items.map((item) => item.index)).toEqual([0, 2]); + expect(page.items.map((item) => item.text)).toEqual(['Chunk 1', 'Chunk 2']); + expect(page.hasMoreBefore).toBe(false); + expect(page.hasMoreAfter).toBe(true); + }); + + it('pages newer output when after is supplied', () => { + const page = buildTranscriptPage(events, { after: 0, limit: 2 }); + + expect(page.items.map((item) => item.index)).toEqual([2, 3]); + expect(page.items.map((item) => item.text)).toEqual(['Chunk 2', 'Chunk 3']); + expect(page.hasMoreBefore).toBe(true); + expect(page.hasMoreAfter).toBe(true); + }); + + it('dedupes repeated output chunks', () => { + const repeated = [ + { type: 'output' as const, data: 'Hello world', at: '2026-03-19T20:11:12.000Z' }, + { type: 'output' as const, data: 'Hello world', at: '2026-03-19T20:11:13.000Z' }, + { type: 'output' as const, data: 'Hello world\n', at: '2026-03-19T20:11:14.000Z' }, + { type: 'output' as const, data: 'Next thing', at: '2026-03-19T20:11:15.000Z' }, + ]; + + const page = buildTranscriptPage(repeated); + + expect(page.totalItems).toBe(2); + expect(page.items.map((item) => item.text)).toEqual(['Hello world', 'Next thing']); + }); + + it('dedupes near-identical redraws that only differ by ui chrome', () => { + const repeated = [ + { + type: 'output' as const, + data: '\u001b[?25lStatus: running\nWorking on task\n[?12l', + at: '2026-03-19T20:11:12.000Z', + }, + { + type: 'output' as const, + data: 'Status: running\nWorking on task\n\u001b[?25h', + at: '2026-03-19T20:11:13.000Z', + }, + { + type: 'output' as const, + data: 'Status: done\nWorking on task\n\u001b[?25h', + at: '2026-03-19T20:11:14.000Z', + }, + ]; + + const page = buildTranscriptPage(repeated); + + expect(page.totalItems).toBe(2); + expect(page.items.map((item) => item.text)).toEqual([ + 'Status: running', + 'Status: done', + ]); + }); +}); diff --git a/backend/src/terminal/transcript-store.ts b/backend/src/terminal/transcript-store.ts index 8fc70d0..6481a27 100644 --- a/backend/src/terminal/transcript-store.ts +++ b/backend/src/terminal/transcript-store.ts @@ -1,6 +1,7 @@ import { appendFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { formatReadableTranscript } from './readable-transcript.js'; const DATA_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'data', 'transcripts'); const EVENT_FILE_SUFFIX = '.events.jsonl'; @@ -10,11 +11,25 @@ const LEGACY_FILE_SUFFIX = '.log'; const MAX_TRANSCRIPT_SCROLLBACK = 20000; const DEFAULT_COLS = 160; const DEFAULT_ROWS = 48; -const BOX_DRAWING_ONLY_RE = /^[\s\u2500-\u257F\u2580-\u259F\u2800-\u28FF]+$/u; - type TranscriptEvent = - | { type: 'output'; data: string } - | { type: 'resize'; cols: number; rows: number }; + | { type: 'output'; data: string; at: string } + | { type: 'resize'; cols: number; rows: number; at: string }; + +export interface TranscriptPageItem { + index: number; + at: string; + text: string; +} + +export interface TranscriptPage { + items: TranscriptPageItem[]; + hasMoreBefore: boolean; + hasMoreAfter: boolean; + firstIndex: number | null; + lastIndex: number | null; + totalItems: number; + source: 'events' | 'legacy'; +} let HeadlessTerminalCtorPromise: Promise | null = null; @@ -68,158 +83,204 @@ async function getHeadlessTerminalCtor(): Promise { return HeadlessTerminalCtorPromise; } -function normalizeLine(line: string): string { - if (!line) return ''; - - // 1. Strip all ANSI escape sequences completely using a comprehensive regex - // This catches colors, cursor movements, erase in line/display, etc. - let clean = line.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''); - - // 2. Strip leftover artifacts that escape the primary regex - clean = clean - .replace(/\\\[[0-9;]*[mK]/g, '') - .replace(/\[[0-9;]*[mK]/g, '') - .replace(/\[?38;5;[0-9]+m/g, '') - .replace(/\[?39m/g, '') - .replace(/\(B/g, ''); - - // 3. Strip box drawing, UI borders, and status bar junk - clean = clean.replace(/[▄▀╭╮╰╯─│║|◇]/g, ' '); - - // 4. Strip control characters and unprintables - clean = clean - .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '') - .replace(/\uFFFD+/g, '') - .replace(/[\u2800-\u28FF]/g, '') - .replace(/\r/g, ''); +function parseEvents(raw: string): TranscriptEvent[] { + const events: TranscriptEvent[] = []; + + for (const line of raw.split('\n')) { + if (!line.trim()) continue; + + try { + const event = JSON.parse(line) as Partial; + if (event.type === 'output' && typeof event.data === 'string') { + events.push({ + type: 'output', + data: event.data, + at: typeof (event as any).at === 'string' ? (event as any).at : new Date().toISOString(), + }); + continue; + } + if ( + event.type === 'resize' && + typeof event.cols === 'number' && + typeof event.rows === 'number' + ) { + events.push({ + type: 'resize', + cols: event.cols, + rows: event.rows, + at: typeof (event as any).at === 'string' ? (event as any).at : new Date().toISOString(), + }); + } + } catch { + return []; + } + } + + return events; +} + +function clampLimit(limit: number | undefined): number { + if (!limit || Number.isNaN(limit)) return 80; + return Math.min(Math.max(Math.floor(limit), 1), 500); +} + +const lastTranscriptFingerprintBySession = new Map(); - return clean; +function normalizeTranscriptLine(line: string): string { + return line.replace(/\s+/g, ' ').trim(); +} + +function isMostlySymbolLine(line: string): boolean { + return /^[\W_]+$/.test(line) && !/[A-Za-z0-9]/.test(line); } function isUiChromeLine(line: string): boolean { const trimmed = line.trim(); if (!trimmed) return false; - - // Catch CloudCode and terminal state dumps + if (trimmed.includes('cc-') || trimmed.includes('projects/')) return true; if (trimmed.includes(';2c0;276;0c')) return true; if (trimmed.includes('[?12l') || trimmed.includes('[?25h') || trimmed.includes('[>c')) return true; if (trimmed.includes('to accept edits')) return true; - + const lowered = trimmed.toLowerCase(); const chrome = [ 'gemini cli', 'claude code', 'logged in', 'openai codex', 'copilot cli', '/auth', '/upgrade', 'type your message', 'shift+tab', 'shortcuts', 'analyzing', 'thinking', 'working', 'completed', 'no sandbox', '/model', - 'delegate to agent', 'subagent', 'termination reason', 'goal', 'result:' + 'delegate to agent', 'subagent', 'termination reason', 'goal', 'result:', ]; - - if (chrome.some(c => lowered.includes(c))) return true; - - // Catch lone numbers, versions, or dots + + if (chrome.some((needle) => lowered.includes(needle))) return true; + if (/^[0-9. ]+$/.test(trimmed) && (trimmed.includes('.') || trimmed.length < 5)) return true; if (trimmed.length < 3 && !/^[A-Z0-9]+$/.test(trimmed)) return true; - + return false; } -function finalizeReadableTranscript(rawText: string): string { - const lines = rawText.split('\n'); - const processed: string[] = []; - let currentBlock = ''; - - for (let i = 0; i < lines.length; i++) { - const cleanLine = normalizeLine(lines[i]); - if (isUiChromeLine(cleanLine)) continue; +function buildTranscriptFingerprint(text: string): string { + const readable = formatReadableTranscript(text); + const lines = readable + .split('\n') + .map(normalizeTranscriptLine) + .filter(Boolean) + .filter((line) => !isUiChromeLine(line)) + .filter((line) => !isMostlySymbolLine(line)); - const trimmed = cleanLine.trim(); - if (!trimmed) { - if (currentBlock) { - processed.push(currentBlock); - currentBlock = ''; - } - continue; - } + return lines.join('\n').trim(); +} - // Advanced Joining Logic: - // If the line is short or doesn't end in punctuation, it's likely a wrap. - const isNewThought = - /^[A-Z]/.test(trimmed) && currentBlock.length > 0 && /[.!?:;]\s*$/.test(currentBlock) || - /^[*\-•+]|\d+\./.test(trimmed) || - (trimmed === trimmed.toUpperCase() && trimmed.length > 4 && trimmed.length < 40); +function isNearDuplicateFingerprint(current: string, previous: string): boolean { + if (!current || !previous) return false; + if (current === previous) return true; - if (isNewThought && currentBlock) { - processed.push(currentBlock); - currentBlock = ''; - } + const currentLines = current.split('\n').filter(Boolean); + const previousLines = previous.split('\n').filter(Boolean); + if (currentLines.length < 3 || previousLines.length < 3) return false; - currentBlock += (currentBlock ? ' ' : '') + trimmed; + const previousSet = new Set(previousLines); + let shared = 0; + for (const line of currentLines) { + if (previousSet.has(line)) shared += 1; } - if (currentBlock) processed.push(currentBlock); - // Word Healing & Deduplication Pass - const finalBlocks: string[] = []; - const dictionary = ['description', 'descriptions', 'execution', 'architecture', 'technical', 'intelligence', 'strategy', 'documents', 'projects', 'context', 'protocol', 'linear', 'platform', 'management', 'components', 'connectors', 'interoperable', 'implementation', 'investigation', 'repository', 'structure', 'migrations']; + const overlap = shared / Math.max(currentLines.length, previousLines.length); + const lengthDelta = Math.abs(currentLines.length - previousLines.length); + return overlap >= 0.9 && lengthDelta <= 2; +} - processed.forEach(b => { - let text = b.replace(/\s{2,}/g, ' ').trim(); - - // 1. Heal broken common words: "descr iptio ns" -> "descriptions" - dictionary.forEach(word => { - // Create a fuzzy regex for the word with optional internal spaces - const fuzzy = word.split('').join('\\s?'); - const regex = new RegExp(`\\b${fuzzy}\\b`, 'gi'); - text = text.replace(regex, word); - }); +function dedupeTranscriptEvents(events: TranscriptEvent[]): TranscriptEvent[] { + const deduped: TranscriptEvent[] = []; + const recentFingerprints: string[] = []; - // 2. Generic single-letter heal: "p roject" -> "project" - text = text.replace(/\b([a-zA-Z])\s([a-zA-Z]{3,})\b/g, (match, p1, p2) => { - const valid = ['a', 'A', 'i', 'I']; - return valid.includes(p1) ? match : p1 + p2; - }); + for (const event of events) { + if (event.type !== 'output') { + deduped.push(event); + continue; + } - // 3. Duplicate removal: Don't add if the block is very similar to the previous one - if (finalBlocks.length > 0) { - const prev = finalBlocks[finalBlocks.length - 1]; - // Simple similarity check: if one contains 80% of the other - if (text.includes(prev.slice(0, Math.floor(prev.length * 0.8)))) return; + const fingerprint = buildTranscriptFingerprint(event.data); + if (!fingerprint) { + continue; } - if (text.length > 5) finalBlocks.push(text); - }); + const previousFingerprint = recentFingerprints[recentFingerprints.length - 1]; + const duplicate = recentFingerprints.some((prior) => isNearDuplicateFingerprint(fingerprint, prior)); + if (duplicate) { + continue; + } - return finalBlocks.join('\n\n'); -} + recentFingerprints.push(fingerprint); + if (recentFingerprints.length > 5) { + recentFingerprints.shift(); + } + deduped.push(event); + } -function parseEvents(raw: string): TranscriptEvent[] { - const events: TranscriptEvent[] = []; + return deduped; +} - for (const line of raw.split('\n')) { - if (!line.trim()) continue; +function buildTranscriptPageFromEvents( + events: TranscriptEvent[], + options?: { limit?: number; before?: number; after?: number }, +): TranscriptPage { + const limit = clampLimit(options?.limit); + const outputEvents = events + .map((event, index) => ({ event, index })) + .filter(({ event }) => event.type === 'output') as Array<{ event: Extract; index: number }>; + + if (outputEvents.length === 0) { + return { + items: [], + hasMoreBefore: false, + hasMoreAfter: false, + firstIndex: null, + lastIndex: null, + totalItems: 0, + source: 'events', + }; + } - try { - const event = JSON.parse(line) as Partial; - if (event.type === 'output' && typeof event.data === 'string') { - events.push({ type: 'output', data: event.data }); - continue; - } - if ( - event.type === 'resize' && - typeof event.cols === 'number' && - typeof event.rows === 'number' - ) { - events.push({ type: 'resize', cols: event.cols, rows: event.rows }); - } - } catch { - return []; - } + let selected = outputEvents; + if (typeof options?.after === 'number' && Number.isFinite(options.after)) { + selected = outputEvents.filter(({ index }) => index > options.after).slice(0, limit); + } else if (typeof options?.before === 'number' && Number.isFinite(options.before)) { + const older = outputEvents.filter(({ index }) => index < options.before); + selected = older.slice(Math.max(0, older.length - limit)); + } else if (outputEvents.length > limit) { + selected = outputEvents.slice(outputEvents.length - limit); } - return events; + const firstIndex = selected[0]?.index ?? null; + const lastIndex = selected[selected.length - 1]?.index ?? null; + + return { + items: selected.map(({ event, index }) => ({ + index, + at: event.at, + text: formatReadableTranscript(event.data), + })), + hasMoreBefore: firstIndex !== null ? outputEvents.some(({ index }) => index < firstIndex) : false, + hasMoreAfter: lastIndex !== null ? outputEvents.some(({ index }) => index > lastIndex) : false, + firstIndex, + lastIndex, + totalItems: outputEvents.length, + source: 'events', + }; +} + +export function buildTranscriptPage( + events: TranscriptEvent[], + options?: { limit?: number; before?: number; after?: number }, +): TranscriptPage { + return buildTranscriptPageFromEvents(dedupeTranscriptEvents(events), options); } async function writeToTerminal(term: HeadlessTerminalInstance, data: string): Promise { - term.write(data); + await new Promise((resolve) => { + term.write(data, () => resolve()); + }); } async function renderEventsToTranscript( @@ -251,7 +312,7 @@ async function renderEventsToTranscript( .join(''); if (combinedOutput) { - term.write(combinedOutput); + await writeToTerminal(term, combinedOutput); } const lines: string[] = []; @@ -263,15 +324,40 @@ async function renderEventsToTranscript( lines.push(line.translateToString(false)); } - return finalizeReadableTranscript(lines.join('\n')); + return formatReadableTranscript(lines.join('\n')); } function renderLegacyTranscript(raw: string): string { - return finalizeReadableTranscript(raw); + return formatReadableTranscript(raw); +} + +function formatTimelineStamp(at: string): string { + const date = new Date(at); + if (Number.isNaN(date.getTime())) return 'unknown time'; + return date.toISOString().slice(11, 19); +} + +export function formatTimelineTranscript(events: TranscriptEvent[]): string { + if (events.length === 0) return ''; + + const sections: string[] = []; + + for (const event of events) { + if (event.type !== 'output') continue; + + const body = formatReadableTranscript(event.data).trim(); + if (!body) continue; + + sections.push(`── ${formatTimelineStamp(event.at)} ──`); + sections.push(body); + sections.push(''); + } + + return sections.join('\n').trim(); } export function formatReadableTerminalText(raw: string): string { - return finalizeReadableTranscript(raw); + return formatReadableTranscript(raw); } export function hasReadableTranscriptArtifacts(text: string): boolean { @@ -305,6 +391,7 @@ export function hasReadableTranscriptArtifacts(text: string): boolean { export async function initTranscript(sessionId: string): Promise { await mkdir(DATA_DIR, { recursive: true }); await writeFile(eventPath(sessionId), '', 'utf8'); + lastTranscriptFingerprintBySession.delete(sessionId); } import { SemanticProcessor } from './semantic-processor.js'; @@ -314,9 +401,17 @@ const semanticProcessors = new Map(); export async function appendTranscript(sessionId: string, chunk: string): Promise { if (!chunk) return; await mkdir(DATA_DIR, { recursive: true }); + const at = new Date().toISOString(); + const fingerprint = buildTranscriptFingerprint(chunk); + if (!fingerprint) return; + + if (lastTranscriptFingerprintBySession.get(sessionId) === fingerprint) { + return; + } + lastTranscriptFingerprintBySession.set(sessionId, fingerprint); // 1. Append to raw event log - await appendFile(eventPath(sessionId), `${JSON.stringify({ type: 'output', data: chunk } satisfies TranscriptEvent)}\n`, 'utf8'); + await appendFile(eventPath(sessionId), `${JSON.stringify({ type: 'output', data: chunk, at } satisfies TranscriptEvent)}\n`, 'utf8'); // 2. Process semantically for Markdown let processor = semanticProcessors.get(sessionId); @@ -337,17 +432,30 @@ export async function appendTranscript(sessionId: string, chunk: string): Promis export async function appendTranscriptResize(sessionId: string, cols: number, rows: number): Promise { await mkdir(DATA_DIR, { recursive: true }); + const at = new Date().toISOString(); await appendFile( eventPath(sessionId), - `${JSON.stringify({ type: 'resize', cols, rows } satisfies TranscriptEvent)}\n`, + `${JSON.stringify({ type: 'resize', cols, rows, at } satisfies TranscriptEvent)}\n`, 'utf8', ); } export async function readTranscript( sessionId: string, - options?: { cols?: number; rows?: number; asMarkdown?: boolean }, + options?: { cols?: number; rows?: number; asMarkdown?: boolean; asTimeline?: boolean }, ): Promise { + if (options?.asTimeline) { + try { + const rawEvents = await readFile(eventPath(sessionId), 'utf8'); + const events = dedupeTranscriptEvents(parseEvents(rawEvents)); + if (events.length > 0) { + return formatTimelineTranscript(events); + } + } catch { + // Fall back to live pane rendering below. + } + } + if (options?.asMarkdown) { try { return await readFile(markdownPath(sessionId), 'utf8'); @@ -367,7 +475,7 @@ export async function readTranscript( try { const rawEvents = await readFile(eventPath(sessionId), 'utf8'); - const events = parseEvents(rawEvents); + const events = dedupeTranscriptEvents(parseEvents(rawEvents)); if (events.length > 0) { return await renderEventsToTranscript(events, options?.cols, options?.rows); } @@ -383,8 +491,65 @@ export async function readTranscript( } } +export async function readTranscriptPage( + sessionId: string, + options?: { limit?: number; before?: number; after?: number }, +): Promise { + try { + const rawEvents = await readFile(eventPath(sessionId), 'utf8'); + const events = dedupeTranscriptEvents(parseEvents(rawEvents)); + const page = buildTranscriptPageFromEvents(events, options); + if (page.totalItems > 0) { + return page; + } + } catch { + // Fall back below. + } + + try { + const legacy = await readFile(legacyPath(sessionId), 'utf8'); + const text = renderLegacyTranscript(legacy).trim(); + if (!text) { + return { + items: [], + hasMoreBefore: false, + hasMoreAfter: false, + firstIndex: null, + lastIndex: null, + totalItems: 0, + source: 'legacy', + }; + } + + return { + items: [{ + index: 0, + at: new Date().toISOString(), + text, + }], + hasMoreBefore: false, + hasMoreAfter: false, + firstIndex: 0, + lastIndex: 0, + totalItems: 1, + source: 'legacy', + }; + } catch { + return { + items: [], + hasMoreBefore: false, + hasMoreAfter: false, + firstIndex: null, + lastIndex: null, + totalItems: 0, + source: 'legacy', + }; + } +} + export async function deleteTranscript(sessionId: string): Promise { semanticProcessors.delete(sessionId); + lastTranscriptFingerprintBySession.delete(sessionId); await Promise.all([ rm(eventPath(sessionId), { force: true }).catch(() => {}), rm(cachePath(sessionId), { force: true }).catch(() => {}), diff --git a/backend/src/tmux/adapter.ts b/backend/src/tmux/adapter.ts index c6fd12c..894f484 100644 --- a/backend/src/tmux/adapter.ts +++ b/backend/src/tmux/adapter.ts @@ -52,6 +52,14 @@ export async function createSession( await run(tmuxArgs); } +export async function setHistoryLimit(sessionName: string, limit: number): Promise { + try { + await run(['set-window-option', '-t', `${sessionName}:0`, 'history-limit', limit.toString()]); + } catch { + // Best-effort scrollback tuning. + } +} + export interface TmuxSessionInfo { name: string; windows: number; @@ -113,11 +121,6 @@ export async function capturePane(sessionName: string): Promise { export async function capturePaneHistory(sessionName: string): Promise { try { - const state = await getPaneState(sessionName); - if (state.alternateOn) { - return ''; - } - const [history, dimensions] = await Promise.all([ run(['capture-pane', '-t', sessionName, '-p', '-e', '-N', '-S', '-']), getPaneDimensions(sessionName), diff --git a/docs/remote-control.md b/docs/remote-control.md index 14a6430..1fa3294 100644 --- a/docs/remote-control.md +++ b/docs/remote-control.md @@ -11,6 +11,12 @@ This is the equivalent of `claude --rc`. It starts the specified agent in a loca ### 2. Server Mode (`cloudcode start --rc`) This is the equivalent of `claude remote-control`. It starts the CloudCode background server and displays a pairing QR code. It doesn't start a specific agent locally but makes your entire environment available for remote management. - **Best for:** Leaving your laptop at home/office and accessing it throughout the day. +- **Bonus - Task Sending:** When CloudCode is running in Server Mode, you can use the mobile dashboard's **Send** action to launch a task instantly on your machine. This is the fast path: it uses recent defaults, starts a new background session, and takes you straight into the live terminal first. The **Logs** tab gives you the full transcript later if you want to review the session from the top. + +#### Send vs Create +- **Send:** Best when speed matters and the recent agent/workspace defaults are good enough. +- **Create:** Best when you want to choose the agent, workspace, title, or worktree before launch. +- **Rule of thumb:** Send for “do this now,” create for “set this up carefully.” ### 3. Mid-Session Handoff (`cloudcode share`) This is the equivalent of the `/rc` command. Run this command inside any existing `tmux` session on your machine. It will communicate with the CloudCode backend and generate a pairing QR code for that specific session. diff --git a/frontend/src/components/DispatchTask.tsx b/frontend/src/components/DispatchTask.tsx new file mode 100644 index 0000000..c30a66c --- /dev/null +++ b/frontend/src/components/DispatchTask.tsx @@ -0,0 +1,210 @@ +import { useState, FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { apiFetch } from '../hooks/useApi' +import { AgentProfile, Session } from '../types' + +interface DispatchTaskProps { + profiles: AgentProfile[] + recentWorkdir?: string + recentProfileId?: string + recentWorkdirs?: string[] + onDispatch?: (session: Session) => void +} + +export function DispatchTask({ profiles, recentWorkdir, recentProfileId, recentWorkdirs = [] }: DispatchTaskProps) { + const [task, setTask] = useState('') + const [showAdvanced, setShowAdvanced] = useState(false) + const [selectedProfileId, setSelectedProfileId] = useState(recentProfileId || (profiles.length > 0 ? profiles[0].id : '')) + const [selectedWorkdir, setSelectedWorkdir] = useState(recentWorkdir || '') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const navigate = useNavigate() + + const workspaceOptions = Array.from(new Set([ + selectedWorkdir, + recentWorkdir, + ...recentWorkdirs, + ].filter((workdir): workdir is string => Boolean(workdir)))) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + if (!task.trim() || !selectedProfileId) return + + setSubmitting(true) + setError(null) + + try { + // Use the first 40 chars of the task as the title + const title = task.length > 40 ? task.substring(0, 37) + '...' : task + + const res = await apiFetch<{ session: Session }>('/api/v1/sessions', { + method: 'POST', + body: JSON.stringify({ + title: `Task: ${title}`, + agentProfileId: selectedProfileId, + workdir: selectedWorkdir || null, + startupPrompt: task, + }), + }) + + // Open the live terminal first; readable logs remain available in the session tabs + navigate(`/sessions/${res.session.publicId}?tab=terminal`, { state: { activeTab: 'terminal' } }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Send failed') + setSubmitting(false) + } + } + + return ( +
+
+
+