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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ellipsis/cli",
"version": "1.0.0",
"version": "1.1.0",
"description": "Ellipsis agent CLI: drive the Ellipsis cloud from your terminal",
"license": "MIT",
"type": "module",
Expand Down
21 changes: 18 additions & 3 deletions src/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export async function runConnect(
sessionId: string,
showRecords: boolean,
readOnly = false,
// An extra opening notice from the caller (e.g. `start --connect` reporting
// which config the defaults ladder picked) — shown in the app instead of
// printed beforehand, which would land in scrollback behind the app.
startupNotice?: string,
): Promise<void> {
const api = new ApiClient()
const token = requireToken()
Expand All @@ -101,6 +105,7 @@ 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 notice = [startupNotice, reason].filter(Boolean).join(' · ') || null
const url = sessionUrl(resolveAppBase(), me.customer_login, sessionId)

// No scrollback preamble: the app owns the whole surface, Claude Code-style.
Expand All @@ -122,6 +127,14 @@ export async function runConnect(
// Written by the app when it exits because the conversation closed (terminal;
// nothing left to reconnect to), so the detach sign-off below stays honest.
const exitState = { closed: false }
// Start the app at the top of a fresh window: newlines scroll whatever is on
// screen (the shell prompt, anything a caller printed) into scrollback, then
// the cursor homes to row 1. Without this the first paint begins mid-screen,
// overflows the window, and the app's opening lines (the sandbox startup
// notes) end up stranded above the fold.
if (process.stdout.isTTY) {
process.stdout.write('\n'.repeat(process.stdout.rows ?? 24) + '\x1b[H')
}
const app = render(
React.createElement(ConnectApp, {
api,
Expand All @@ -131,7 +144,7 @@ export async function runConnect(
canSend,
minRenderFeedSeq: showRecords ? 0 : store.cursor,
sessionUrl: url,
initialNotice: reason ?? null,
initialNotice: notice,
// The session's one model, fixed at creation (backend tokens_model).
model: typeof session.tokens_model === 'string' ? session.tokens_model : null,
exitState,
Expand All @@ -141,7 +154,9 @@ export async function runConnect(

if (canSend && !exitState.closed) {
// The session keeps running after a detach; hand back the exact command
// that re-opens this conversation.
console.log(`\nresume with: agent session connect ${sessionId}`)
// that re-opens this conversation. No leading newline: the single row this
// line scrolls is absorbed by the app's top padding (see ConnectApp), so
// the sign-off never scrolls the app's first content line out of the window.
console.log(`resume with: agent session connect ${sessionId}`)
}
}
26 changes: 20 additions & 6 deletions src/commands/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ export function registerSession(program: Command): void {
.option('--cpu <n>', 'sandbox vCPUs (e.g. 2 or 0.5)', toNumber)
.option('--memory <size>', 'sandbox memory (e.g. 8GB)')
.option('--timeout <duration>', 'sandbox timeout (e.g. 30m or 1h)')
.option(
'--rebuild',
'skip the sandbox image cache: fresh full build (image layers, clones, image.setup), whose snapshot refreshes the cache',
)
.option('--budget <usd>', 'per-run spend limit in USD for this session (limits.run)', toNumber)
.option(
'-p, --prompt <text>',
Expand Down Expand Up @@ -163,6 +167,7 @@ export function registerSession(program: Command): void {
cpu?: number
memory?: string
timeout?: string
rebuild?: boolean
budget?: number
prompt?: string
metadata: Record<string, string>
Expand Down Expand Up @@ -224,6 +229,9 @@ export function registerSession(program: Command): void {
// Appended to the initial user query at build time; gives this
// session instructions on top of the config's shared system prompt.
if (promptText) req.prompt = promptText
// Skip the image cache for the initial provision (wakes cache as
// usual); the fresh build's snapshot becomes the new cache entry.
if (opts.rebuild) req.force_rebuild = true
// A promptless connect (a bare `agent`) starts the session idle:
// no fabricated kickoff message, Claude Code waits at the prompt
// for whatever is typed into the composer, like a local `claude`.
Expand All @@ -236,11 +244,14 @@ export function registerSession(program: Command): void {

// Say which agent the server picked when it came from the defaults
// ladder, so a bare `agent` never silently runs an unexpected config.
if (!opts.json && session.resolved_config_name) {
// The connect UI shows it as its opening notice (anything printed
// before the app would land in scrollback); every other mode prints it.
let configNote: string | undefined
if (session.resolved_config_name) {
if (session.resolution_source === 'repo_default') {
console.log(`using config "${session.resolved_config_name}" (repo default)`)
configNote = `using config "${session.resolved_config_name}" (repo default)`
} else if (session.resolution_source === 'account_default') {
console.log(`using config "${session.resolved_config_name}" (account default)`)
configNote = `using config "${session.resolved_config_name}" (account default)`
}
}

Expand All @@ -250,6 +261,7 @@ export function registerSession(program: Command): void {
const ellipsisBlock = (session.agent_config as Record<string, unknown> | undefined)
?.ellipsis as { interactive?: boolean } | undefined
if (ellipsisBlock?.interactive === false) {
if (configNote) console.log(configNote)
console.log(
'this agent is not interactive; watching output instead of connecting',
)
Expand All @@ -258,10 +270,12 @@ export function registerSession(program: Command): void {
await watchSessionStreaming(api, session.id, FALLBACK_POLL_INTERVAL_SECONDS, false)
return
}
await startConnect(session)
await startConnect(session, configNote)
return
}

if (!opts.json && configNote) console.log(configNote)

if (opts.watch) {
if (!opts.json) {
console.log(`✓ started session ${session.id}`)
Expand Down Expand Up @@ -794,8 +808,8 @@ export function registerSession(program: Command): void {
// (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)
export async function startConnect(session: AgentSession, notice?: string): Promise<void> {
await runConnect(session.id, true, false, notice)
}

// `--watch` entry point: stream the session's output live over WebSocket, and
Expand Down
5 changes: 5 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ export interface StartAgentSessionRequest {
// bare `agent`). Mutually exclusive with prompt; the server ignores it when
// the resolved config is not interactive (that session runs its workflow).
idle_start?: boolean
// Skip the sandbox image cache for this session's initial provision: a
// fresh full build (image layers + clone + image.setup from scratch),
// whose snapshot then refreshes the cache for later runs. Wakes of a
// durable session provision through the cache as usual. The --rebuild flag.
force_rebuild?: boolean
}

// Replay payload for POST /v1/sessions/{id}/replay. Re-runs an existing
Expand Down
Loading