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
4 changes: 2 additions & 2 deletions src/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export async function runConnect(

// 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(`${session.id} · ${session.surface?.status ?? session.status}`)
console.log(url)
if (session.agent_config_id) console.log(`config ${session.agent_config_id}`)
if (reason) console.log(reason)
Expand Down Expand Up @@ -139,7 +139,7 @@ export async function runConnect(
// Always advance the cursor past existing steps: --no-steps skips
// *rendering* history, not re-streaming it live.
initialMaxStepIndex,
initialStatus: session.status,
initialStatus: session.surface?.status ?? session.status,
sessionUrl: url,
initialCost,
}),
Expand Down
25 changes: 16 additions & 9 deletions src/lib/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,20 +281,27 @@ export function foldCosts(events: CCEvent[]): {
// 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.
// Maps the server's derived `status` word (session_surface) to a transcript
// system line. The words are the customer-facing surface, not the raw
// per-execution status.
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'
return 'queued · waiting for a worker'
case 'starting':
return 'starting sandbox'
case 'working':
return 'agent working'
case 'waiting':
return 'waiting for your reply'
case 'sleeping':
return 'sleeping · your next message wakes it'
case 'retrying':
return 'retrying after a transient error'
case 'completed':
return 'session complete'
case 'error':
return 'session errored'
case 'closed':
return 'conversation closed'
case 'failed':
return 'session failed'
case 'cancelled':
return 'session cancelled'
case 'stopped':
Expand Down
9 changes: 9 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ export interface AgentSession {
// the cloud session loop and accepts /messages; null = single-shot.
session_key?: string | null
session_state?: 'idle' | 'running' | 'closed' | null
// Customer-facing status surface (session_surface.py). `status` is the derived
// single word to display (working/waiting/sleeping/starting/…); `session` +
// `run` are the two raw axes. null for un-keyed (laptop) sessions and on list
// rows that don't populate it. Prefer surface.status over the raw `status`.
surface?: {
session: 'alive' | 'sleeping' | 'closed' | null
run: string | null
status: string | null
} | null
cost_tokens: number
cost_sandbox_cpu: number
cost_sandbox_memory: number
Expand Down
10 changes: 8 additions & 2 deletions src/lib/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import { DEFAULT_WS_BASE, USER_AGENT } from './constants'

// The frame protocol spoken over the session WebSocket (server -> client). One
// JSON object per message. Mirrors session_stream.py in the backend.
// status: { type, status, ts }
// status: { type, status, session, run, ts }
// stdout/stderr: { type, data, seq, ts }
// delta: { type, text?, output_tokens? }
// done: { type, status, exit_status }
// done: { type, status, session, run, exit_status }
// error: { type, message }
// `status` is the derived single word (working/waiting/sleeping/starting/…);
// `session` (alive/sleeping/closed) and `run` are the two raw axes. All three
// are null for an un-keyed (laptop) session. See session_surface.py.
// `seq` is a monotonic per-session cursor; the client resumes from the last seq
// it saw via `?after_seq=` so a dropped socket loses nothing. `delta` is the
// EPHEMERAL live-streaming frame (partial assistant text + running token count);
Expand All @@ -18,6 +21,9 @@ export interface StreamFrame {
type: 'stdout' | 'stderr' | 'status' | 'delta' | 'done' | 'error'
data?: string
status?: string
// status/done frames: the two raw session-surface axes (null for laptop).
session?: string | null
run?: string | null
seq?: number
ts?: string
message?: string
Expand Down
36 changes: 22 additions & 14 deletions src/ui/ConnectApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,12 @@ export interface ConnectAppProps {
initialCost: { total: number | null; lastStep: number | null }
}

// Statuses in which the agent is actively producing output — drives the spinner.
// Surface statuses in which something is actively happening — drives the
// spinner. `waiting` (turn done, warm, your move) and `sleeping` (parked) are
// deliberately NOT here: the spinner stops and the footer reads the calm state
// instead of a misleading "running".
function isWorkingStatus(status: string): boolean {
return ['scheduled', 'creating_sandbox', 'running', 'retrying'].includes(status)
return ['scheduled', 'starting', 'working', 'retrying'].includes(status)
}

export function ConnectApp(props: ConnectAppProps): React.ReactElement {
Expand Down Expand Up @@ -211,7 +214,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
lastStatus.current = frame.status
setStatus(frame.status)
setWorking(isWorkingStatus(frame.status))
if (frame.status === 'running') everRunning.current = true
// Box-up states (working/waiting) mean the session became connectable;
// used to tell a preflight failure from a mid-conversation one.
if (['working', 'waiting'].includes(frame.status)) everRunning.current = true
emitStatusLine(frame.status)
}
if (frame.type === 'error') {
Expand Down Expand Up @@ -258,7 +263,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
if (
outcome.type === 'done' &&
!everRunning.current &&
['error', 'cancelled', 'stopped'].includes(outcome.status)
['failed', 'cancelled', 'stopped'].includes(outcome.status)
) {
setNotice(`session ${outcome.status} before it became connectable`)
process.exitCode = 1
Expand Down Expand Up @@ -287,19 +292,22 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
})
}, [canSend, exit, handleFrame, refreshSteps, sessionId, token, wsBase])

// A status backstop that doesn't depend on the socket: the lifecycle frames
// (creating_sandbox → running) may arrive before the socket attaches, so poll
// the session too and drive the same transition handling. `lastStatus`
// dedupes against socket frames so a transition is only announced once.
// A status backstop that doesn't depend on the socket: the lifecycle
// transitions may arrive before the socket attaches, so poll the session too
// and drive the same transition handling. Use the derived surface word so this
// matches the stream's `frame.status` exactly (the raw `status` is a different
// vocabulary); fall back to raw for un-keyed sessions. `lastStatus` dedupes
// against socket frames so a transition is only announced once.
const pollStatus = useCallback(async (): Promise<void> => {
try {
const s = await api.getAgentSession(sessionId)
if (s.status !== lastStatus.current) {
lastStatus.current = s.status
setStatus(s.status)
setWorking(isWorkingStatus(s.status))
if (s.status === 'running') everRunning.current = true
emitStatusLine(s.status)
const word = s.surface?.status ?? s.status
if (word !== lastStatus.current) {
lastStatus.current = word
setStatus(word)
setWorking(isWorkingStatus(word))
if (['working', 'waiting'].includes(word)) everRunning.current = true
emitStatusLine(word)
}
} catch {
// Transient fetch failure — the next tick retries.
Expand Down
11 changes: 7 additions & 4 deletions test/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,13 @@ describe('foldCosts', () => {
})

describe('statusSystemLine', () => {
it('maps lifecycle statuses to Claude-Code-style lines', () => {
expect(statusSystemLine('creating_sandbox')).toBe('creating sandbox')
expect(statusSystemLine('running')).toBe('sandbox ready · spawning agent process')
expect(statusSystemLine('completed')).toBe('session complete')
it('maps surface statuses to Claude-Code-style lines', () => {
expect(statusSystemLine('starting')).toBe('starting sandbox')
expect(statusSystemLine('working')).toBe('agent working')
expect(statusSystemLine('waiting')).toBe('waiting for your reply')
expect(statusSystemLine('sleeping')).toBe('sleeping · your next message wakes it')
expect(statusSystemLine('closed')).toBe('conversation closed')
expect(statusSystemLine('failed')).toBe('session failed')
})

it('is null for an unknown status', () => {
Expand Down
Loading