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
28 changes: 28 additions & 0 deletions skills/ellipsis/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 12 additions & 13 deletions src/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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, {
Expand All @@ -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,
Expand Down
16 changes: 7 additions & 9 deletions src/commands/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import type {
AgentSession,
AgentSessionSource,
AgentSessionStatus,
AgentStep,
GithubAccountSnippet,
ReplayAgentSessionRequest,
SessionSearchResult,
Expand All @@ -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'

Expand Down Expand Up @@ -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))
})
})

Expand Down Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { resolveApiBase, resolveToken } from './config'
import { USER_AGENT } from './constants'
import type {
AgentSession,
AgentStep,
AgentTemplate,
AnalyticsMetricsQuery,
AnalyticsPullRequestsQuery,
Expand Down Expand Up @@ -43,6 +42,7 @@ import type {
SandboxVariableSummary,
SearchSessionsQuery,
SearchSessionsResponse,
SessionRecord,
SyncAgentSessionRequest,
SyncAgentSessionResponse,
SavedAgentConfig,
Expand Down Expand Up @@ -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<AgentStep[]> {
// The session's full stored transcript as native session_records (transcript
// + lifecycle), ordered by feed_seq.
async getAgentSessionSteps(sessionId: string): Promise<SessionRecord[]> {
const res = await this.request<ListSessionStepsResponse>(
'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
Expand Down
15 changes: 14 additions & 1 deletion src/lib/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 } {
Expand Down
59 changes: 48 additions & 11 deletions src/lib/steps.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>,
): 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 {
Expand All @@ -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)
Expand Down
38 changes: 30 additions & 8 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
feed_seq: number
stream_seq: number
source: RecordSource
record_type: string
record_format: string
payload: Record<string, unknown>
[key: string]: unknown
}

export interface ListSessionStepsResponse {
steps: AgentStep[]
records: SessionRecord[]
}

// One process's raw transcript from GET /v1/sessions/{id}/transcripts: the
Expand Down
27 changes: 13 additions & 14 deletions src/ui/ConnectApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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*
Expand Down Expand Up @@ -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('')
Expand Down
Loading
Loading