diff --git a/skills/ellipsis/SKILL.md b/skills/ellipsis/SKILL.md index 0cf7d25..3b8d1c8 100644 --- a/skills/ellipsis/SKILL.md +++ b/skills/ellipsis/SKILL.md @@ -154,6 +154,34 @@ a session-scoped token, so you can start child sessions, search the team's session history, and upload screenshots as org-gated links (`agent asset upload shot.png`) without any login. +## Releasing the CLI (maintainers) + +The CLI ships only as a Homebrew formula from the `ellipsis-dev/homebrew-cli` +tap. It is never published to npm: `package.json` is `private`, has no `bin`, +and there is no `publishConfig`. The single `npm` call in the release workflow +(`npm version --no-git-tag-version`) is just a local tool to rewrite the +version field, not a registry publish. + +Publishing is fully automated by `.github/workflows/release.yml`, triggered by +pushing a `vX.Y.Z` git tag (there is also a `workflow_dispatch` fallback that +takes a version input in the Actions UI). On a tag push it: + +1. Stamps the version into `package.json` (the single source of truth: + `src/lib/constants.ts` reads `pkg.version`, which bun inlines into the + binary, so `agent --version` never drifts). +2. Cross-compiles four binaries (`darwin-arm64`, `darwin-x64`, `linux-x64`, + `linux-arm64`) with `bun build --compile`, tars each, and computes SHA-256 + checksums. +3. Creates the GitHub release with the tarballs and `checksums.txt`. +4. Regenerates `Formula/agent.rb` in the tap repo from the template and pushes + it, so `brew install ellipsis-dev/cli/agent` picks up the new version. + +The only manual steps (Hunter cuts releases) are: ensure CI is green, bump the +`package.json` version, commit it as `chore(release): vX.Y.Z`, then create and +push the matching `vX.Y.Z` tag. The tag version must equal the `package.json` +version, because the formula's `test do` block asserts `agent --version` equals +the released version. + ## Pointers - Quickstart: https://www.ellipsis.dev/docs/get-started/quickstart diff --git a/src/commands/connect.ts b/src/commands/connect.ts index c05d134..fa1dca1 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -4,7 +4,7 @@ import { render } from 'ink' import { ApiClient } from '../lib/api' import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config' import { runAction } from '../lib/output' -import { eventToItems, foldCosts, type CCEvent } from '../lib/events' +import { foldCosts, recordToItems, type CCEvent } from '../lib/events' import { sessionUrl } from '../lib/urls' import { resolveWsBase } from '../lib/ws' import { ConnectApp } from '../ui/ConnectApp' @@ -114,19 +114,18 @@ export async function runConnect( 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), the + // Fetch the stored records 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 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) + // opening spend. Records are ordered by feed_seq (the shared transcript + + // lifecycle feed); a claude_code record's payload is the Claude Code event + // the UI renders live, a lifecycle record renders as a notice line. + const records = await api.getAgentSessionSteps(sessionId) + const ordered = [...records].sort((a, b) => a.feed_seq - b.feed_seq) + const initialMaxFeedSeq = ordered.reduce((m, s) => Math.max(m, s.feed_seq), 0) const initialItems = showSteps - ? ordered.flatMap((st) => eventToItems(st.data as CCEvent, `s${st.step_index}`)) + ? ordered.flatMap((st) => recordToItems(st, `s${st.feed_seq}`)) : [] - const initialCost = foldCosts(ordered.map((st) => st.data as CCEvent)) + const initialCost = foldCosts(ordered.map((st) => st.payload as CCEvent)) const app = render( React.createElement(ConnectApp, { @@ -136,9 +135,9 @@ export async function runConnect( wsBase, canSend, initialItems, - // Always advance the cursor past existing steps: --no-steps skips + // Always advance the cursor past existing records: --no-steps skips // *rendering* history, not re-streaming it live. - initialMaxStepIndex, + initialMaxFeedSeq, initialStatus: session.surface?.status ?? session.status, sessionUrl: url, initialCost, diff --git a/src/commands/session.tsx b/src/commands/session.tsx index ffb917a..cc0aeea 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -35,7 +35,6 @@ import type { AgentSession, AgentSessionSource, AgentSessionStatus, - AgentStep, GithubAccountSnippet, ReplayAgentSessionRequest, SessionSearchResult, @@ -59,7 +58,7 @@ import { } from '../lib/laptop' import { openBrowser } from '../lib/auth' import { registerConnect, runConnect } from './connect' -import { formatStepLine, oneLine, stepText } from '../lib/steps' +import { formatStepLine, oneLine, recordText } from '../lib/steps' import { ApiError } from '../lib/api' import { resolveToken } from '../lib/config' @@ -403,11 +402,10 @@ export function registerSession(program: Command): void { console.log('No steps recorded for this session.') return } - // Chronological, one line per step; --json has the full payloads. - const ordered = [...steps].sort( - (a, b) => a.created_at.localeCompare(b.created_at) || a.step_index - b.step_index, - ) - for (const step of ordered) console.log(formatStepLine(step)) + // Feed order (transcript + lifecycle merged), one line per record; + // --json has the full payloads. + const ordered = [...steps].sort((a, b) => a.feed_seq - b.feed_seq) + for (const record of ordered) console.log(formatStepLine(record)) }) }) @@ -1069,9 +1067,9 @@ export function formatSearchResult( return lines } -// formatStepLine / stepText moved to lib/steps.ts (shared with `session +// formatStepLine / recordText moved to lib/steps.ts (shared with `session // connect`); re-exported here for existing importers and tests. -export { formatStepLine, stepText } +export { formatStepLine, recordText } // 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 diff --git a/src/lib/api.ts b/src/lib/api.ts index ff8ec39..d433e6f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2,7 +2,6 @@ import { resolveApiBase, resolveToken } from './config' import { USER_AGENT } from './constants' import type { AgentSession, - AgentStep, AgentTemplate, AnalyticsMetricsQuery, AnalyticsPullRequestsQuery, @@ -43,6 +42,7 @@ import type { SandboxVariableSummary, SearchSessionsQuery, SearchSessionsResponse, + SessionRecord, SyncAgentSessionRequest, SyncAgentSessionResponse, SavedAgentConfig, @@ -195,13 +195,14 @@ export class ApiClient { ) } - // The session's full stored transcript, ordered by created_at then step_index. - async getAgentSessionSteps(sessionId: string): Promise { + // The session's full stored transcript as native session_records (transcript + // + lifecycle), ordered by feed_seq. + async getAgentSessionSteps(sessionId: string): Promise { const res = await this.request( 'GET', `/v1/sessions/${encodeURIComponent(sessionId)}/steps`, ) - return res.steps + return res.records } // The session's raw transcripts — one .jsonl.gz per process — each with a diff --git a/src/lib/events.ts b/src/lib/events.ts index 3cfd8de..1911369 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -10,7 +10,8 @@ // is pure (no ANSI, no Ink) so it can be unit-tested directly; colours and // layout live in the UI component. -import { oneLine } from './steps' +import { lifecycleText, oneLine } from './steps' +import type { SessionRecord } from './types' // A loosely-typed content block of a Claude Code message. We only read the // fields we display and never interpret the rest of the payload. @@ -242,6 +243,18 @@ export function eventToItems(event: CCEvent, keyBase: string): TranscriptItem[] return items } +// Render one native session_record into transcript items. A claude_code +// record's payload is a CCEvent (assistant/user/result — expanded by +// eventToItems); a lifecycle record becomes a single dim notice line (the +// spawn/respawn/idle notifications). `keyBase` makes React keys unique. +export function recordToItems(record: SessionRecord, keyBase: string): TranscriptItem[] { + if (record.source === 'lifecycle') { + const text = lifecycleText(record.record_type, record.payload) + return text ? [{ key: keyBase, kind: 'notice', text, spaceBefore: true }] : [] + } + return eventToItems(record.payload as CCEvent, keyBase) +} + // Clamp a multi-line body to `maxLines`, appending a dim "+N lines" marker when // truncated — used for tool-result bodies so a huge file read stays compact. export function clampLines(text: string, maxLines: number): { body: string; more: number } { diff --git a/src/lib/steps.ts b/src/lib/steps.ts index 981b357..f72e46d 100644 --- a/src/lib/steps.ts +++ b/src/lib/steps.ts @@ -1,10 +1,41 @@ import { formatTs } from './output' -import type { AgentStep } from './types' +import type { SessionRecord } from './types' // Step-rendering helpers shared by `session steps` and `session connect` // (moved out of commands/session.tsx so connect.ts can use them without an // import cycle; session.tsx re-exports them for compatibility). +// Human one-liner for a source==='lifecycle' record — the spawn/respawn/idle +// notifications the transcript itself doesn't carry. null for a type we don't +// surface (falls back to the raw record_type). +export function lifecycleText( + recordType: string, + payload: Record, +): string | null { + switch (recordType) { + case 'sandbox_starting': + return 'Starting sandbox…' + case 'sandbox_ready': { + const repos = Array.isArray(payload.repositories) + ? (payload.repositories as unknown[]).filter((r): r is string => typeof r === 'string') + : [] + return repos.length ? `Sandbox ready · ${repos.join(', ')}` : 'Sandbox ready' + } + case 'session_resumed': + return 'Resumed the conversation' + case 'session_paused': + return 'Sleeping — your next message wakes it' + case 'session_closed': + return 'Conversation closed' + case 'session_cancelled': { + const reason = typeof payload.reason === 'string' ? payload.reason : null + return reason ? `Session cancelled · ${reason}` : 'Session cancelled' + } + default: + return null + } +} + // A content block of a Claude Code stream event, typed loosely: the CLI only // extracts display text and names, never interprets the payload. interface StepContentBlock { @@ -16,24 +47,30 @@ interface StepContentBlock { content?: unknown } -// One transcript step as a single display line: index, timestamp, step type, +// One session_record as a single display line: index, timestamp, record type, // and the first ~120 characters of its text content. Exported for tests. -export function formatStepLine(step: AgentStep): string { - const type = step.step_subtype ? `${step.step_type}/${step.step_subtype}` : step.step_type +export function formatStepLine(record: SessionRecord): string { + const subtype = + typeof record.payload.subtype === 'string' ? record.payload.subtype : null + const type = subtype ? `${record.record_type}/${subtype}` : record.record_type return [ - String(step.step_index).padStart(4), - formatTs(step.created_at), + String(record.stream_seq).padStart(4), + formatTs(record.created_at), type.padEnd(16), - oneLine(stepText(step), 120), + oneLine(recordText(record), 120), ].join(' ') } -// Best-effort display text for a stored step. `data` is the raw Claude Code -// stream event: a result step carries `result`, assistant/user steps carry an +// Best-effort display text for a stored record. A lifecycle record shows its +// notification line; a claude_code record's `payload` is the raw Claude Code +// stream event — a result step carries `result`, assistant/user steps carry an // API message whose content is a string or a list of blocks (text, thinking, // tool_use, tool_result). Anything unrecognized falls back to its JSON. -export function stepText(step: AgentStep): string { - const data = step.data ?? {} +export function recordText(record: SessionRecord): string { + if (record.source === 'lifecycle') { + return lifecycleText(record.record_type, record.payload) ?? record.record_type + } + const data = record.payload ?? {} if (typeof data.result === 'string') return data.result const message = data.message as { content?: unknown } | undefined const text = contentText(message?.content) diff --git a/src/lib/types.ts b/src/lib/types.ts index b910aa7..757f32c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -287,20 +287,42 @@ export interface ListAgentSessionsQuery { // ------------------------------ session steps ---------------------------- // One stored transcript event, from GET /v1/sessions/{id}/steps. Loosely -// typed: `data` is the raw Claude Code stream event (assistant turn, tool -// call, tool result, final result); the CLI only extracts display text. -export interface AgentStep { +// The render switch on a session_record (session_records.source). The CLI +// renders claude_code records natively and lifecycle records as system lines. +export type RecordSource = 'claude_code' | 'lifecycle' + +// session_records.record_type for source==='lifecycle' rows: the +// spawn/respawn/idle notifications the transcript itself does not carry. +export type LifecycleRecordType = + | 'sandbox_starting' + | 'sandbox_ready' + | 'session_resumed' + | 'session_paused' + | 'session_closed' + | 'session_cancelled' + +// One native session_record from GET /v1/sessions/{id}/steps. `payload` is the +// harness's verbatim line — for claude_code, a Claude Code stream event +// (assistant turn, tool call/result, result); for lifecycle, a small blob. The +// CLI switches on `source` and only extracts display text. `feed_seq` is the +// shared per-session order (transcript + lifecycle merged); `stream_seq` is the +// per-execution native index (NEGATIVE for lifecycle records). +export interface SessionRecord { id: string + agent_session_id: string + session_execution_id: string created_at: string - step_index: number - step_type: string - step_subtype: string | null - data: Record + feed_seq: number + stream_seq: number + source: RecordSource + record_type: string + record_format: string + payload: Record [key: string]: unknown } export interface ListSessionStepsResponse { - steps: AgentStep[] + records: SessionRecord[] } // One process's raw transcript from GET /v1/sessions/{id}/transcripts: the diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 2e4a22c..5ff3b7e 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -5,8 +5,8 @@ import { ApiClient, ApiError } from '../lib/api' import { streamSession, StreamUnavailableError, type StreamFrame } from '../lib/ws' import { clampLines, - eventToItems, foldCosts, + recordToItems, resultCostUsd, statusSystemLine, type CCEvent, @@ -39,9 +39,10 @@ export interface ConnectAppProps { // closed / --no-input sessions follow read-only and exit when the stream ends. canSend: boolean initialItems: TranscriptItem[] - // The highest step_index already rendered into initialItems, so live refreshes - // only append steps newer than what's on screen. - initialMaxStepIndex: number + // The highest feed_seq already rendered into initialItems, so live refreshes + // only append records newer than what's on screen (feed_seq is the shared + // per-session order across transcript + lifecycle records). + initialMaxFeedSeq: number initialStatus: string // The clickable dashboard link for this session (app.ellipsis.dev/…/sessions/{id}), // shown in the footer status line. @@ -93,9 +94,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const abort = useRef(new AbortController()) const lastStatus = useRef(props.initialStatus) const keyCounter = useRef(0) - // The highest step_index committed to the transcript, and the refresh + // The highest feed_seq committed to the transcript, and the refresh // in-flight / re-run guards so overlapping wakes don't double-append. - const maxStep = useRef(props.initialMaxStepIndex) + const maxFeed = useRef(props.initialMaxFeedSeq) // The cumulative cost last committed, so a new result's delta = the turn cost. const costTotal = useRef(props.initialCost.total) // Whether the sandbox ever reached `running`, so a terminal status *before* @@ -169,17 +170,15 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } refreshing.current = true try { - const steps = await api.getAgentSessionSteps(sessionId) - const ordered = [...steps].sort( - (a, b) => a.created_at.localeCompare(b.created_at) || a.step_index - b.step_index, - ) - const fresh = ordered.filter((st) => st.step_index > maxStep.current) + const records = await api.getAgentSessionSteps(sessionId) + const ordered = [...records].sort((a, b) => a.feed_seq - b.feed_seq) + const fresh = ordered.filter((st) => st.feed_seq > maxFeed.current) if (fresh.length) { for (const st of fresh) { - maxStep.current = Math.max(maxStep.current, st.step_index) - applyCost(st.data as CCEvent) + maxFeed.current = Math.max(maxFeed.current, st.feed_seq) + applyCost(st.payload as CCEvent) } - append(fresh.flatMap((st) => eventToItems(st.data as CCEvent, `s${st.step_index}`))) + append(fresh.flatMap((st) => recordToItems(st, `s${st.feed_seq}`))) // A committed step landed — the streamed overlay is now part of the // transcript (or the turn advanced), so drop the live overlay. setLiveText('') diff --git a/test/search.test.ts b/test/search.test.ts index 0cc38d3..46f1e0d 100644 --- a/test/search.test.ts +++ b/test/search.test.ts @@ -3,13 +3,13 @@ import { ApiClient } from '../src/lib/api' import { formatSearchResult, formatStepLine, + recordText, resolveAuthorId, - stepText, } from '../src/commands/session' import type { AgentSession, - AgentStep, GithubAccountSnippet, + SessionRecord, SessionSearchResult, } from '../src/lib/types' @@ -64,14 +64,14 @@ describe('searchSessions', () => { describe('getAgentSessionSteps', () => { afterEach(() => vi.unstubAllGlobals()) - it('unwraps the steps array from the session-scoped path (encoded)', async () => { + it('unwraps the records array from the session-scoped path (encoded)', async () => { const fetchMock = vi.fn( - async () => new Response(JSON.stringify({ steps: [{ id: 'step_1' }] }), { status: 200 }), + async () => new Response(JSON.stringify({ records: [{ id: 'rec_1' }] }), { status: 200 }), ) vi.stubGlobal('fetch', fetchMock) const out = await new ApiClient('http://api.test', 't').getAgentSessionSteps('session/1') - expect(out.map((s) => s.id)).toEqual(['step_1']) + expect(out.map((s) => s.id)).toEqual(['rec_1']) expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/v1/sessions/session%2F1/steps') }) }) @@ -176,23 +176,32 @@ describe('formatSearchResult', () => { }) }) -describe('stepText / formatStepLine', () => { - const step = (data: Record, overrides: Partial = {}): AgentStep => ({ - id: 'step_1', +describe('recordText / formatStepLine', () => { + const record = ( + payload: Record, + overrides: Partial = {}, + ): SessionRecord => ({ + id: 'rec_1', + agent_session_id: 'session_1', + session_execution_id: 'exec_1', created_at: '2026-07-03T12:00:00+00:00', - step_index: 3, - step_type: 'assistant', - step_subtype: null, - data, + feed_seq: 3, + stream_seq: 3, + source: 'claude_code', + record_type: (payload.type as string) ?? 'assistant', + record_format: 'claude_stream_json@2.0', + payload, ...overrides, }) - it('reads a result step', () => { - expect(stepText(step({ type: 'result', result: 'All tests pass.' }))).toBe('All tests pass.') + it('reads a result record', () => { + expect(recordText(record({ type: 'result', result: 'All tests pass.' }))).toBe( + 'All tests pass.', + ) }) it('reads string message content', () => { - expect(stepText(step({ message: { content: 'plain text' } }))).toBe('plain text') + expect(recordText(record({ message: { content: 'plain text' } }))).toBe('plain text') }) it('joins text/thinking blocks and summarizes tool calls', () => { @@ -205,7 +214,7 @@ describe('stepText / formatStepLine', () => { ], }, } - expect(stepText(step(data))).toBe( + expect(recordText(record(data))).toBe( 'check the auth flow Reading the file. [tool: Read] {"file_path":"src/auth.ts"}', ) }) @@ -216,25 +225,33 @@ describe('stepText / formatStepLine', () => { content: [{ type: 'tool_result', content: [{ type: 'text', text: 'file contents' }] }], }, } - expect(stepText(step(data))).toBe('file contents') + expect(recordText(record(data))).toBe('file contents') }) it('falls back to the raw JSON for unknown payloads', () => { - expect(stepText(step({ subtype: 'init' }))).toBe('{"subtype":"init"}') + expect(recordText(record({ subtype: 'init' }))).toBe('{"subtype":"init"}') }) it('formats one line with index, timestamp, type, and truncated text', () => { + // record_type + payload.subtype drive the type column; stream_seq the index. const line = formatStepLine( - step( - { message: { content: 'line one\nline two' } }, - { step_type: 'system', step_subtype: 'init' }, + record( + { subtype: 'init', message: { content: 'line one\nline two' } }, + { record_type: 'system' }, ), ) expect(line).toBe(' 3 2026-07-03 12:00 system/init line one line two') }) + it('renders a lifecycle record as its notification line', () => { + const line = formatStepLine( + record({}, { source: 'lifecycle', record_type: 'sandbox_ready', stream_seq: -2 }), + ) + expect(line).toBe(' -2 2026-07-03 12:00 sandbox_ready Sandbox ready') + }) + it('truncates long text to about 120 characters', () => { - const line = formatStepLine(step({ message: { content: 'x'.repeat(500) } })) + const line = formatStepLine(record({ message: { content: 'x'.repeat(500) } })) expect(line.endsWith('...')).toBe(true) // 4 (index) + 16 (timestamp) + 16 (type) + separators + 120 of text. expect(line.length).toBe(42 + 120)