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
110 changes: 107 additions & 3 deletions src/commands/session.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Command } from 'commander'
import { existsSync, readFileSync } from 'node:fs'
import { extname } from 'node:path'
import { gzipSync } from 'node:zlib'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { extname, join } from 'node:path'
import { gunzipSync, gzipSync } from 'node:zlib'
import { parse as parseYaml } from 'yaml'
import { ApiClient } from '../lib/api'
import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config'
Expand Down Expand Up @@ -39,6 +39,7 @@ import type {
ReplayAgentSessionRequest,
SessionSearchResult,
SessionSearchScope,
SessionTranscript,
StartAgentSessionRequest,
SyncAgentSessionRequest,
} from '../lib/types'
Expand Down Expand Up @@ -332,6 +333,76 @@ export function registerSession(program: Command): void {
})
})

session
.command('transcript <sessionId>')
.description(
"Download a session's raw transcript files (GET /v1/sessions/{id}/transcripts)",
)
.option('-o, --output <path>', 'write to a file instead of stdout')
.option('--process <processId>', 'pick a specific process (retries have several)')
.option('--all', "download every process's transcript")
.option('-d, --dir <dir>', 'directory for --all downloads', '.')
.option('--json', 'print the metadata response (incl. download URLs), download nothing')
.option('--gzip', 'keep the .jsonl.gz bytes as-is (skip gunzip)')
.action(
async (
sessionId: string,
opts: {
output?: string
process?: string
all?: boolean
dir: string
json?: boolean
gzip?: boolean
},
) => {
await runAction(async () => {
if (opts.all && (opts.process || opts.output)) {
throw new Error('--all cannot be combined with --process or -o')
}
const res = await new ApiClient().getSessionTranscripts(sessionId)
if (opts.json) {
printJson(res)
return
}
if (res.transcripts.length === 0) {
console.log('No transcripts stored for this session.')
return
}
const ext = opts.gzip ? 'jsonl.gz' : 'jsonl'
if (opts.all) {
mkdirSync(opts.dir, { recursive: true })
for (const t of res.transcripts) {
const path = join(opts.dir, `${t.process_id}.${ext}`)
writeFileSync(path, await fetchTranscript(t, { gzip: opts.gzip }))
console.log(path)
}
return
}
// Default: the latest process (the list is in process-creation
// order, so retries supersede the attempts before them).
let transcript = res.transcripts[res.transcripts.length - 1]!
if (opts.process) {
const picked = res.transcripts.find((t) => t.process_id === opts.process)
if (!picked) {
throw new Error(
`no transcript for process '${opts.process}' — available: ` +
res.transcripts.map((t) => t.process_id).join(', '),
)
}
transcript = picked
}
const data = await fetchTranscript(transcript, { gzip: opts.gzip })
if (opts.output) {
writeFileSync(opts.output, data)
console.log(opts.output)
} else {
process.stdout.write(data)
}
})
},
)

session
.command('get <sessionId>')
.description('Get a single agent session (GET /v1/sessions/{id})')
Expand Down Expand Up @@ -809,6 +880,39 @@ export function formatSearchResult(
// connect`); re-exported here for existing importers and tests.
export { formatStepLine, stepText }

// Pull a transcript from its presigned S3 URL (bare fetch — the signature in
// the URL is the credential) and gunzip unless the caller wants the raw
// .jsonl.gz bytes. A warning for a failed final write goes to stderr so the
// default stdout stream stays pure JSONL.
export async function fetchTranscript(
transcript: SessionTranscript,
opts: { gzip?: boolean },
): Promise<Buffer> {
if (transcript.write_status === 'failed') {
console.error(
`warning: ${transcript.process_id}'s final transcript write failed — ` +
'the tail past the last periodic flush may be missing',
)
}
const res = await fetch(transcript.download_url)
if (!res.ok) {
if (res.status === 404) {
throw new Error(
`transcript for ${transcript.process_id} is gone from storage — ` +
'it was likely deleted by your log retention setting',
)
}
throw new Error(
`download failed: ${res.status} ${res.statusText}` +
(res.status === 403
? ' (the presigned URL likely expired — re-run the command for a fresh one)'
: ''),
)
}
const raw = Buffer.from(await res.arrayBuffer())
return opts.gzip ? raw : gunzipSync(raw)
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
Expand Down
11 changes: 11 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
ListLinearTeamsResponse,
ListSentryOrganizationsResponse,
ListSessionStepsResponse,
ListSessionTranscriptsResponse,
ListSlackChannelsResponse,
ListSlackMembersResponse,
ReplayAgentSessionRequest,
Expand Down Expand Up @@ -203,6 +204,16 @@ export class ApiClient {
return res.steps
}

// The session's raw transcripts — one .jsonl.gz per process — each with a
// short-lived presigned download URL. Fetch the URL immediately; the JSON
// API never carries the bytes.
getSessionTranscripts(sessionId: string): Promise<ListSessionTranscriptsResponse> {
return this.request(
'GET',
`/v1/sessions/${encodeURIComponent(sessionId)}/transcripts`,
)
}

syncAgentSession(req: SyncAgentSessionRequest): Promise<SyncAgentSessionResponse> {
return this.request('POST', '/v1/sessions/sync', req)
}
Expand Down
22 changes: 22 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,28 @@ export interface ListSessionStepsResponse {
steps: AgentStep[]
}

// One process's raw transcript from GET /v1/sessions/{id}/transcripts: the
// pointer metadata plus a short-lived presigned S3 GET for the .jsonl.gz
// object itself (expires after expires_in seconds — fetch it immediately).
export interface SessionTranscript {
process_id: string
// "claude_stream_json" (cloud) or "claude_transcript" (synced laptop).
format: string
event_count: number | null
bytes: number | null
written_at: string | null
// null = only periodic flushes so far (session still running); "failed" =
// the final write failed, so the tail past the last flush may be missing.
write_status: 'ok' | 'failed' | null
download_url: string
expires_in: number
}

export interface ListSessionTranscriptsResponse {
session_id: string
transcripts: SessionTranscript[]
}

// GET /v1/sessions/{id}/ide (`agent session ide`): the live sandbox's
// code-server tunnel URL. Unguessable, customer-scoped at discovery, and dead
// once the sandbox is torn down — fetch it fresh on every open, never store it.
Expand Down
14 changes: 14 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,20 @@ describe('ApiClient sandbox variables', () => {
})
})

describe('getSessionTranscripts', () => {
afterEach(() => vi.unstubAllGlobals())

it('hits the transcripts path and returns the response', async () => {
const body = { session_id: 'session_1', transcripts: [] }
const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)

const out = await new ApiClient('http://api.test', 't').getSessionTranscripts('session_1')
expect(out).toEqual(body)
expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/v1/sessions/session_1/transcripts')
})
})

describe('replayAgentSession', () => {
afterEach(() => vi.unstubAllGlobals())

Expand Down
62 changes: 60 additions & 2 deletions test/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { gzipSync } from 'node:zlib'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { applyConfigOverride, readConfigFile, watchSession } from '../src/commands/session'
import {
applyConfigOverride,
fetchTranscript,
readConfigFile,
watchSession,
} from '../src/commands/session'
import type { ApiClient } from '../src/lib/api'
import type { AgentSession, AgentSessionStatus } from '../src/lib/types'
import type { AgentSession, AgentSessionStatus, SessionTranscript } from '../src/lib/types'

function session(status: AgentSessionStatus): AgentSession {
return {
Expand Down Expand Up @@ -159,3 +165,55 @@ describe('applyConfigOverride', () => {
)
})
})

describe('fetchTranscript', () => {
const transcript = (overrides: Partial<SessionTranscript> = {}): SessionTranscript => ({
process_id: 'proc_1',
format: 'claude_stream_json',
event_count: 2,
bytes: 100,
written_at: '2026-07-07T00:00:00+00:00',
write_status: 'ok',
download_url: 'https://s3.example.com/signed',
expires_in: 60,
...overrides,
})
const gzipped = gzipSync(Buffer.from('{"a":1}\n{"b":2}\n'))

afterEach(() => vi.unstubAllGlobals())

it('gunzips by default', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(new Uint8Array(gzipped), { status: 200 })),
)
const out = await fetchTranscript(transcript(), {})
expect(out.toString()).toBe('{"a":1}\n{"b":2}\n')
})

it('keeps the raw bytes with gzip: true', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(new Uint8Array(gzipped), { status: 200 })),
)
const out = await fetchTranscript(transcript(), { gzip: true })
expect(Buffer.compare(out, gzipped)).toBe(0)
})

it('maps a storage 404 to the retention explanation', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 404 })))
await expect(fetchTranscript(transcript(), {})).rejects.toThrow(/log retention/)
})

it('warns on stderr for a failed final write but still downloads', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(new Uint8Array(gzipped), { status: 200 })),
)
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const out = await fetchTranscript(transcript({ write_status: 'failed' }), {})
expect(out.toString()).toContain('{"a":1}')
expect(errSpy.mock.calls[0][0]).toMatch(/final transcript write failed/)
errSpy.mockRestore()
})
})
Loading