Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 20 additions & 22 deletions src/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { render } from 'ink'
import { ApiClient } from '../lib/api'
import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config'
import { runAction } from '../lib/output'
import { eventToItems, type CCEvent, type TranscriptItem } from '../lib/events'
import { eventToItems, foldCosts, type CCEvent } from '../lib/events'
import { sessionUrl } from '../lib/urls'
import { resolveWsBase } from '../lib/ws'
import { ConnectApp } from '../ui/ConnectApp'
import type { AgentSession, AgentStep } from '../lib/types'
import type { AgentSession } from '../lib/types'

// `agent session connect [sessionId]` — the terminal window into a cloud
// session (documents/eng/SESSION_IDE.md §2.6, in the ellipsis monorepo).
Expand Down Expand Up @@ -102,22 +102,31 @@ export async function runConnect(
const canSend = readOnly ? false : c.canSend
const reason =
readOnly && c.canSend ? 'read-only (--no-input) — following without the composer' : c.reason
const url = sessionUrl(resolveAppBase(), me.customer_login, sessionId)

// A compact header printed once above the live transcript.
// A compact header printed once above the live transcript. The live footer
// keeps the id/url, status, and spend on screen; this is the scrollback record.
console.log(`${session.id} · ${session.status}`)
console.log(sessionUrl(resolveAppBase(), me.customer_login, sessionId))
console.log(url)
if (session.agent_config_id) console.log(`config ${session.agent_config_id}`)
if (reason) console.log(reason)
if (canSend) {
console.log('type to send · /stop ends the turn · /exit or Ctrl+C detaches')
}

// Fetch the stored steps to seed the transcript (unless --no-steps) and to
// set the live-refresh cursor, so the live updates only append what's new.
// The step `data` is the same Claude Code event shape the UI renders live.
// Fetch the stored steps to seed the transcript (unless --no-steps), the
// live-refresh cursor (so live updates only append what's new), and the
// opening spend. The step `data` is the same Claude Code event shape the UI
// renders live.
const steps = await api.getAgentSessionSteps(sessionId)
const initialMaxStepIndex = steps.reduce((m, s) => Math.max(m, s.step_index), -1)
const initialItems = showSteps ? stepsToItems(steps) : []
const ordered = [...steps].sort(
(a, b) => a.created_at.localeCompare(b.created_at) || a.step_index - b.step_index,
)
const initialMaxStepIndex = ordered.reduce((m, s) => Math.max(m, s.step_index), -1)
const initialItems = showSteps
? ordered.flatMap((st) => eventToItems(st.data as CCEvent, `s${st.step_index}`))
: []
const initialCost = foldCosts(ordered.map((st) => st.data as CCEvent))

const app = render(
React.createElement(ConnectApp, {
Expand All @@ -131,6 +140,8 @@ export async function runConnect(
// *rendering* history, not re-streaming it live.
initialMaxStepIndex,
initialStatus: session.status,
sessionUrl: url,
initialCost,
}),
)
await app.waitUntilExit()
Expand All @@ -139,16 +150,3 @@ export async function runConnect(
console.log('\ndetached — the session keeps running (reconnect with the same command)')
}
}

// Flatten stored steps into transcript items, in chronological order — the
// stored `data` is the same Claude Code event shape as the live stream.
function stepsToItems(steps: AgentStep[]): TranscriptItem[] {
const ordered = [...steps].sort(
(a, b) => a.created_at.localeCompare(b.created_at) || a.step_index - b.step_index,
)
const items: TranscriptItem[] = []
for (const step of ordered) {
items.push(...eventToItems(step.data as CCEvent, `s${step.step_index}`))
}
return items
}
71 changes: 9 additions & 62 deletions src/commands/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ export function registerSession(program: Command): void {
const session = await api.startAgentSession(req)

if (opts.connect) {
await startConnect(api, session)
await startConnect(session)
return
}

Expand Down Expand Up @@ -745,67 +745,14 @@ export function registerSession(program: Command): void {
)
}

// `start --connect`: wait for the session's sandbox to come live, then drop into
// the semantic connect (render the conversation, follow it live, send messages
// through the inbox — the same as `session connect`). A terminal status before
// RUNNING means the session never got a sandbox (e.g. a preflight/budget gate),
// so there is nothing to connect to.
const CONNECT_READY_TIMEOUT_SECONDS = 120

export async function startConnect(api: ApiClient, session: AgentSession): Promise<void> {
const sessionId = session.id
// The sandbox may already be live for a warm session; otherwise show an
// animated wait (the transient line clears before the connect UI takes over,
// so the session id/url aren't printed twice). A terminal status before
// RUNNING means the session never got a sandbox (preflight/budget gate).
if (session.status !== 'running') {
const stop = startWaitSpinner(`starting ${sessionId} — waiting for the sandbox`)
const deadline = Date.now() + CONNECT_READY_TIMEOUT_SECONDS * 1000
for (;;) {
const s = await api.getAgentSession(sessionId)
if (s.status === 'running') break
if (TERMINAL_STATUSES.has(s.status)) {
const reason = s.status_reason ? ` — ${s.status_reason}` : ''
stop(`session ${s.status} before it became connectable${reason}`)
process.exitCode = 1
return
}
if (Date.now() > deadline) {
stop(
`timed out waiting for the sandbox; once it is running, connect with:\n` +
` agent session connect ${sessionId}`,
)
process.exitCode = 1
return
}
await sleep(1000)
}
stop()
}
await runConnect(sessionId, true)
}

// A single-line braille spinner for a pre-connect wait. `stop(final?)` clears
// the animated line and optionally prints a final message in its place. On a
// non-TTY (piped/CI) it prints one static line instead of animating.
function startWaitSpinner(label: string): (final?: string) => void {
if (!process.stdout.isTTY) {
console.log(`${label}…`)
return (final) => {
if (final) console.log(final)
}
}
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
let i = 0
process.stdout.write('\x1b[?25l') // hide cursor
const draw = () => process.stdout.write(`\r\x1b[K${frames[i++ % frames.length]} ${label}…`)
draw()
const timer = setInterval(draw, 80)
return (final) => {
clearInterval(timer)
process.stdout.write('\r\x1b[K\x1b[?25h') // clear the line, restore cursor
if (final) console.log(final)
}
// `start --connect`: drop straight into the semantic connect (render the
// conversation, follow it live, send messages through the inbox — the same as
// `session connect`). The connect UI itself renders the sandbox lifecycle
// (creating sandbox → spawning agent process) as it happens and reports a
// terminal status reached before the sandbox ever ran (a preflight/budget gate),
// so there is nothing to wait for out here.
export async function startConnect(session: AgentSession): Promise<void> {
await runConnect(session.id, true)
}

// `--watch` entry point: stream the session's output live over WebSocket, and
Expand Down
54 changes: 54 additions & 0 deletions src/lib/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,57 @@ export function clampLines(text: string, maxLines: number): { body: string; more
if (lines.length <= maxLines) return { body: text, more: 0 }
return { body: lines.slice(0, maxLines).join('\n'), more: lines.length - maxLines }
}

// The cumulative session cost (USD) a Claude Code `result` event carries: each
// result caps a turn and reports the running total for the whole session (not
// the turn alone). null for non-result events or a result without a cost.
export function resultCostUsd(event: CCEvent): number | null {
if (event.type !== 'result') return null
return typeof event.total_cost_usd === 'number' ? event.total_cost_usd : null
}

// Fold a chronological event list into the spend the footer shows: `total` is
// the latest result's cumulative cost; `lastStep` is the delta from the result
// before it — i.e. the cost of the most recent completed turn. Both null until
// the first result lands; for the first result, lastStep equals total.
export function foldCosts(events: CCEvent[]): {
total: number | null
lastStep: number | null
} {
let prev: number | null = null
let total: number | null = null
for (const event of events) {
const cost = resultCostUsd(event)
if (cost == null) continue
prev = total
total = cost
}
const lastStep = total != null && prev != null ? Math.max(0, total - prev) : total
return { total, lastStep }
}

// A dim, Claude-Code-style one-liner for a session lifecycle status, shown in
// the transcript when the status changes ("creating sandbox", "spawning agent
// process", …). null for statuses that don't warrant their own line.
export function statusSystemLine(status: string): string | null {
switch (status) {
case 'scheduled':
return 'scheduled · waiting for a worker'
case 'creating_sandbox':
return 'creating sandbox'
case 'running':
return 'sandbox ready · spawning agent process'
case 'retrying':
return 'retrying after a transient error'
case 'completed':
return 'session complete'
case 'error':
return 'session errored'
case 'cancelled':
return 'session cancelled'
case 'stopped':
return 'session stopped'
default:
return null
}
}
10 changes: 10 additions & 0 deletions src/lib/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,13 @@ export function configUrl(appBase: string, accountLogin: string, configId: strin
export function cliAuthUrl(appBase: string, userCode: string): string {
return `${appBase}/cli-auth?code=${encodeURIComponent(userCode)}`
}

// Wrap `label` in an OSC 8 hyperlink so it opens `url` on click in terminals
// that support it (iTerm2, VS Code, WezTerm, kitty, GNOME); degrades to plain
// text elsewhere. Only emitted to a TTY — piped output stays free of escapes.
export function hyperlink(url: string, label: string, isTty = process.stdout.isTTY): string {
if (!isTty) return label
const OSC = ']8;;'
const ST = '\\'
return `${OSC}${url}${ST}${label}${OSC}${ST}`
}
Loading
Loading