From 829bf87c770a1eacb52db0a6778a3ee6c7bc32e0 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Fri, 21 Aug 2026 17:24:19 -0400 Subject: [PATCH] feat!: drop laptop transcript sync, move to SDK 0.13.0 The /sessions/sync and /sessions/laptop endpoints are gone server-side, so shipped CLIs 404 on every background sync. Removes the client half whole: the hook install/enroll commands, the spool-and-retry plumbing, the local redaction pass, and laptop to cloud handoff. The git helpers the rest of the CLI still needs move to src/lib/git.ts. SDK 0.13.0 also restructured sessions: the config identity moved under session.agent, live_summary became summary.description, the start response no longer resolves a config name, and the defaults ladder is a map instead of a row list. Migrated each call site. "config default list" joins the account's configs to keep showing names, since the ladder carries ids only. Also pads the chat header off the line above it. --- README.md | 2 +- bun.lock | 4 +- package.json | 2 +- skills/ellipsis/SKILL.md | 18 - src/cli.tsx | 2 - src/commands/config.ts | 67 ++-- src/commands/connect.ts | 10 +- src/commands/help.ts | 2 +- src/commands/hooks.ts | 236 ----------- src/commands/review.ts | 2 +- src/commands/session.tsx | 285 +------------ src/lib/args.ts | 1 - src/lib/config.ts | 20 +- src/lib/git.ts | 26 ++ src/lib/help.ts | 2 +- src/lib/laptop.ts | 445 --------------------- src/lib/sessions.ts | 15 +- src/lib/types.ts | 8 +- src/ui/ConnectApp.tsx | 1 + src/ui/SessionsApp.tsx | 14 +- src/ui/launch.tsx | 2 +- test/__snapshots__/screenshot.test.ts.snap | 3 +- test/args.test.ts | 4 +- test/config.test.ts | 16 - test/laptop.test.ts | 124 ------ test/screenshot.test.ts | 1 + test/search.test.ts | 4 +- test/sessions.test.ts | 25 +- 28 files changed, 126 insertions(+), 1215 deletions(-) delete mode 100644 src/commands/hooks.ts create mode 100644 src/lib/git.ts delete mode 100644 src/lib/laptop.ts delete mode 100644 test/laptop.test.ts diff --git a/README.md b/README.md index 0f3ca56..c74b498 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ out for all of them. Every field is optional and the defaults above are what you get with no `sessionBar` at all. Two caveats on `repo`: a shell outside a repository lists every repository rather than nothing, and sessions that name their repository -only inside their agent config — dashboard starts, cron runs, handoffs — do not +only inside their agent config — dashboard starts, cron runs — do not match a repo filter, so `"repo": "any"` is the way to see those alongside the rest. diff --git a/bun.lock b/bun.lock index 2936b84..eb4ab9c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@ellipsis/cli", "dependencies": { - "@ellipsis-dev/sdk": "^0.12.0", + "@ellipsis-dev/sdk": "^0.13.0", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^12.1.0", @@ -37,7 +37,7 @@ "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], - "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.12.0", "", {}, "sha512-TUNHs3NqcyrWMyegI6cMkyiO+139MDA77aZqAghDEoMeIMMNvDU6lHnfPQVUNiOP4xHR0ImPBnEFxdixIDyAcw=="], + "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.13.0", "", {}, "sha512-0bc6c5N4dDrx2GLobgkz8M8r8raOIJRs5jkDoAhUOrJ016TmT/rvJwRqNA2jsicF6NbZS0Qfi4KZEKOI8V7L1w=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/package.json b/package.json index 3e582a3..d227def 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ellipsis-dev/sdk": "^0.12.0", + "@ellipsis-dev/sdk": "^0.13.0", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^12.1.0", diff --git a/skills/ellipsis/SKILL.md b/skills/ellipsis/SKILL.md index 9c4ca88..4adcddb 100644 --- a/skills/ellipsis/SKILL.md +++ b/skills/ellipsis/SKILL.md @@ -69,8 +69,6 @@ fee. There are no seats. The built-in responder needs no configuration and answers in the thread. - **Catching bugs before merge**: turn code review on and every pull request is reviewed, or commit a pipeline file to scope and customize it. -- **A task that should not block the laptop**: `agent session handoff` pushes a - snapshot of the working tree and continues the work in a cloud session. - **Delegation from scripts or CI**: `agent session start` or `POST /sessions`. With `--watch` it streams into the log and exits nonzero unless the session completes, so it works as a gate. @@ -474,22 +472,6 @@ agent github repos # also github members, slack channe agent file upload shot.png # store a PNG, print an org-gated link ``` -Sync local Claude Code sessions into the same searchable history, then hand work -off: - -```sh -agent hook install # Stop and SessionEnd hooks -agent hook enroll # opt this repository in; sync is per-repo -agent hook status # what is installed and enrolled -agent session handoff "finish the retry backoff and add tests" --parent -``` - -Sync is opt-in per repository: installing the hooks alone uploads nothing. -Transcripts are redacted locally before upload, so local work becomes auditable -without shipping credentials. `handoff` requires `--parent`, pushes the -working-tree snapshot to a hidden ref rather than a branch, and leaves your tree -undisturbed. - Most singular commands accept the plural spelling as a hidden alias, and `review` also answers to `cr`. `agent --help` and `agent --help` are authoritative for flags. diff --git a/src/cli.tsx b/src/cli.tsx index be79329..6eaf1f0 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -8,7 +8,6 @@ import { registerReview } from './commands/review' import { registerConfig } from './commands/config' import { registerVariable } from './commands/variable' import { registerFile } from './commands/file' -import { registerHook } from './commands/hooks' import { registerTemplate } from './commands/template' import { registerModel } from './commands/model' import { registerIntegration } from './commands/integrations' @@ -45,7 +44,6 @@ registerReview(program) registerConfig(program) registerVariable(program) registerFile(program) -registerHook(program) registerTemplate(program) registerModel(program) registerIntegration(program) diff --git a/src/commands/config.ts b/src/commands/config.ts index c534e31..05b483c 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -4,13 +4,13 @@ import { basename, dirname, extname } from 'node:path' import { api } from '../lib/api' import { resolveAppBase } from '../lib/config' import { alsoKnownAs, apiRoutes } from '../lib/help' -import { repoFromCwd } from '../lib/laptop' +import { repoFromCwd } from '../lib/git' import { formatTs, printJson, printTable, printYaml, runAction } from '../lib/output' import { configUrl } from '../lib/urls' import { readConfigFile } from './session' import type { AgentConfig, - AgentDefaultView, + AgentDefaults, CreateAgentConfigRequest, CreatedAgentConfig, SavedAgentConfig, @@ -262,15 +262,12 @@ export function registerConfig(program: Command): void { // (the same ladder session start resolves server-side). .action(async (opts: { json?: boolean }) => { await runAction(async () => { - const { defaults: rungs } = await api().agents.defaults.list() + const ladder = await api().agents.defaults.list() const repo = repoFromCwd(process.cwd()) - const repoRung = repo - ? rungs.find((d) => d.repository?.toLowerCase() === repo.toLowerCase()) - : undefined - const accountRung = rungs.find((d) => d.repository === null) - const effective = repoRung ?? accountRung + const repoRung = repo ? repoDefault(ladder, repo) : undefined + const effective = repoRung ?? ladder.account ?? null if (opts.json) { - printJson({ repository: repo ?? null, effective: effective ?? null }) + printJson({ repository: repo ?? null, effective }) return } if (!effective) { @@ -281,10 +278,8 @@ export function registerConfig(program: Command): void { ) return } - const rung = effective.repository - ? `repo default for ${effective.repository}` - : 'account default' - console.log(`using config "${defaultName(effective)}" (${rung})${brokenSuffix(effective)}`) + const rung = repoRung ? `repo default for ${repo}` : 'account default' + console.log(`using config "${effective}" (${rung})`) }) }) @@ -303,24 +298,28 @@ export function registerConfig(program: Command): void { // merged view, not just this command's own opts. .action(async (_opts: { json?: boolean }, cmd: Command) => { await runAction(async () => { - const { defaults: rungs } = await api().agents.defaults.list() + const client = api() + const ladder = await client.agents.defaults.list() if (cmd.optsWithGlobals().json) { - printJson(rungs) + printJson(ladder) return } + const rungs: [string, string][] = [ + ...(ladder.account ? ([['account', ladder.account]] as [string, string][]) : []), + ...Object.entries(ladder.repositories), + ] if (rungs.length === 0) { console.log('No defaults set. Sessions start on the bare config.') return } + // The ladder carries ids only, but a human reads this table — so join + // the account's configs to show each rung's name. + const names = new Map( + (await client.agents.configs.list()).configs.map((c) => [c.id, configName(c)]), + ) printTable( - ['RUNG', 'CONFIG', 'CONFIG ID', 'STATUS', 'UPDATED'], - rungs.map((d) => [ - d.repository ?? 'account', - d.config_name ?? '—', - d.config_id, - d.broken ? `broken: ${d.broken}` : 'ok', - formatTs(d.updated_at), - ]), + ['RUNG', 'CONFIG', 'CONFIG ID'], + rungs.map(([rung, id]) => [rung, names.get(id) ?? id, id]), ) }) }) @@ -340,16 +339,17 @@ export function registerConfig(program: Command): void { async (configId: string, opts: { repo?: string | boolean; json?: boolean }, cmd: Command) => { await runAction(async () => { const repository = resolveRepoFlag(opts.repo) - const { default: set } = await api().agents.defaults.set({ + const ladder = await api().agents.defaults.set({ config_id: configId, ...(repository ? { repository } : {}), }) if (cmd.optsWithGlobals().json) { - printJson(set) + printJson(ladder) return } - const rung = set.repository ? `default for ${set.repository}` : 'account default' - console.log(`✓ set ${rung} to "${defaultName(set)}" (${set.config_id})`) + const rung = repository ? `default for ${repository}` : 'account default' + const id = repository ? repoDefault(ladder, repository) : ladder.account + console.log(`✓ set ${rung} to ${id ?? configId}`) }) }, ) @@ -478,14 +478,11 @@ export function resolveRepoFlag(repo: string | boolean | undefined): string | un return repo } -function defaultName(d: AgentDefaultView): string { - return d.config_name ?? d.config_id -} - -// A set-but-broken rung fails session starts closed (never a silent -// fall-through), so surface it wherever the rung is shown. -function brokenSuffix(d: AgentDefaultView): string { - return d.broken ? ` (broken: ${d.broken})` : '' +// The repo rung's config id. Rungs are keyed "owner/name" as GitHub spells it, +// so match case-insensitively rather than indexing directly. +function repoDefault(ladder: AgentDefaults, repo: string): string | undefined { + const want = repo.toLowerCase() + return Object.entries(ladder.repositories).find(([r]) => r.toLowerCase() === want)?.[1] } // A minimal valid agent config. `claude.system` is the only required field; diff --git a/src/commands/connect.ts b/src/commands/connect.ts index b34dd57..8297f8f 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -42,7 +42,7 @@ export function resolveConnectSessionId( // Whether the composer can send to this session (and why not) — shared with // the multi-session UI; re-exported so existing imports keep working. -import { connectability } from '../lib/sessions' +import { connectability, sessionConfigName } from '../lib/sessions' export { connectability } export function registerConnect(session: Command): void { @@ -88,9 +88,9 @@ export async function runConnect( // An extra opening notice from the caller — shown in the app instead of // printed beforehand, which would land in scrollback behind the app. startupNotice?: string, - // The agent config name from the caller (e.g. `start --connect`, whose - // start response carries resolved_config_name); when absent it is derived - // from the fetched session. Shown in the footer meta line. + // The agent config name from the caller (e.g. `start --connect`); when + // absent it is derived from the fetched session. Shown in the footer meta + // line. configName?: string, ): Promise { const client = api() @@ -112,7 +112,7 @@ export async function runConnect( const url = sessionUrl(resolveAppBase(), me.customer_login, sessionId) // The config identity for the footer meta line: the caller's resolved name // first, then whatever the session itself carries. - const config = configName ?? session.config_id ?? null + const config = configName ?? sessionConfigName(session) // No scrollback preamble: the app owns the whole surface, Claude Code-style. // The footer carries the session identity/status; a watch-only reason diff --git a/src/commands/help.ts b/src/commands/help.ts index f2b3633..8707fd1 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -2,7 +2,7 @@ import type { Command } from 'commander' import { api, APIError } from '../lib/api' import { apiRoutes } from '../lib/help' import { runAction } from '../lib/output' -import { repoFromCwd } from '../lib/laptop' +import { repoFromCwd } from '../lib/git' import { startConnect } from './session' import type { StartAgentSessionRequest } from '../lib/types' diff --git a/src/commands/hooks.ts b/src/commands/hooks.ts deleted file mode 100644 index cc464b7..0000000 --- a/src/commands/hooks.ts +++ /dev/null @@ -1,236 +0,0 @@ -import type { Command } from 'commander' -import { alsoKnownAs } from '../lib/help' -import { formatTs, printJson, printTable, runAction } from '../lib/output' -import { toInt } from '../lib/args' -import { - claudeSettingsPath, - enrollRepo, - enrolledRepos, - hookStatsPath, - hooksInstalled, - installHooks, - readHookStats, - readSyncLog, - repoFromCwd, - spooledPendingCount, - syncLogPath, - unenrollRepo, - uninstallHooks, - type HookSyncStats, -} from '../lib/laptop' - -// `agent hook …` manages the Claude Code hooks and per-repo enrollment that -// drive laptop transcript sync (`agent session sync`). Installing the hooks -// alone syncs nothing: consent is per-repo opt-in, so a repo must also be -// enrolled (`agent hook enroll`, run inside the repo) before its sessions -// are captured. - -// Resolve the repo to enroll/unenroll: an explicit "owner/name" arg wins, -// else derive it from the cwd's git remote. -function resolveRepo(explicit: string | undefined): string { - if (explicit) { - if (!/^[^/\s]+\/[^/\s]+$/.test(explicit)) { - throw new Error(`"${explicit}" is not an owner/name repository.`) - } - return explicit - } - const repo = repoFromCwd(process.cwd()) - if (!repo) { - throw new Error( - 'Not inside a git repository with an origin remote. Run from the repo, or pass owner/name explicitly.', - ) - } - return repo -} - -export function registerHook(program: Command): void { - const hooks = alsoKnownAs( - program - .command('hook') - .description('Sync local Claude Code transcripts to Ellipsis, per enrolled repo'), - 'hooks', - ) - - hooks - .command('install') - .description('Install the Stop and SessionEnd hooks that run `agent session sync`') - .action(async () => - runAction(async () => { - const { path } = installHooks() - console.log(`Installed Stop + SessionEnd hooks in ${path}.`) - const enrolled = enrolledRepos() - if (enrolled.length === 0) { - console.log( - 'No repositories enrolled yet, so nothing will sync. Run `agent hook enroll` inside a repo to opt it in.', - ) - } - }), - ) - - hooks - .command('uninstall') - .description('Remove the sync hooks, keeping every repo enrollment') - .action(async () => - runAction(async () => { - const { path, changed } = uninstallHooks() - console.log( - changed ? `Removed sync hooks from ${path}.` : `No sync hooks found in ${path}.`, - ) - }), - ) - - hooks - .command('status') - .description('Show which hooks are installed and which repos are enrolled') - .option('--json', 'output raw JSON') - .action(async (opts: { json?: boolean }) => - runAction(async () => { - const installed = hooksInstalled() - const enrolled = enrolledRepos() - const stats = readHookStats() - if (opts.json) { - printJson({ - settings: claudeSettingsPath(), - hooks: installed, - enrolled_repos: enrolled, - sync_stats: stats ?? null, - }) - return - } - printTable( - ['HOOK', 'INSTALLED'], - Object.entries(installed).map(([event, ok]) => [event, ok ? 'yes' : 'no']), - ) - console.log('') - if (enrolled.length === 0) console.log('Enrolled repositories: none') - else printTable(['ENROLLED REPOSITORY'], enrolled.map((r) => [r])) - if (stats?.last_sync_at) { - console.log('') - console.log( - `Last sync ${ago(stats.last_sync_at)} (${stats.last_outcome}), ` + - `${stats.synced_24h} synced / ${stats.failed_24h} failed in 24h, ` + - `${spooledPendingCount()} spooled pending`, - ) - } - }), - ) - - alsoKnownAs( - hooks - .command('log') - .description('Show what the background syncs did, newest last (they are otherwise silent)'), - 'logs', - ) - .option('-n, --tail ', 'show the last N entries', toInt, 20) - .option('--failures', 'only show entries whose outcome is not "synced"') - .option('--json', 'output raw JSON (NDJSON, one entry per line)') - .action(async (opts: { tail: number; failures?: boolean; json?: boolean }) => - runAction(async () => { - let entries = readSyncLog() - if (opts.failures) entries = entries.filter((e) => e.outcome !== 'synced') - entries = entries.slice(-Math.max(0, opts.tail)) - if (opts.json) { - for (const e of entries) console.log(JSON.stringify(e)) - return - } - if (entries.length === 0) { - console.log( - opts.failures - ? 'No sync failures logged.' - : `No sync activity logged yet (${syncLogPath()}).`, - ) - return - } - printTable( - ['TIME', 'OUTCOME', 'REPO', 'REASON', 'DETAIL'], - entries.map((e) => [ - formatTs(e.ts), - e.outcome, - e.repo ?? '—', - e.reason ?? '—', - e.outcome === 'synced' - ? `${e.event_count ?? '?'} events → ${e.session_id ?? '?'}` - : e.error ?? '', - ]), - ) - }), - ) - - hooks - .command('stats') - .description('Show sync counts: last outcome, 24h synced/failed, spooled pending') - .option('--json', 'output raw JSON') - .action(async (opts: { json?: boolean }) => - runAction(async () => { - const stats = readHookStats() - if (!stats) { - if (opts.json) { - printJson(null) - return - } - console.log(`No sync stats yet (${hookStatsPath()}). Nothing has attempted to sync.`) - return - } - // spooled_pending in the file is a snapshot from the last sync; the - // spool dir is cheap to count, so show it live. - const live: HookSyncStats = { ...stats, spooled_pending: spooledPendingCount() } - if (opts.json) { - printJson(live) - return - } - console.log(`stats file: ${hookStatsPath()}`) - console.log( - `last sync: ${live.last_sync_at ? `${formatTs(live.last_sync_at)} (${ago(live.last_sync_at)})` : '—'}`, - ) - console.log(`last outcome: ${live.last_outcome ?? '—'}`) - if (live.last_error) console.log(`last error: ${live.last_error}`) - console.log(`synced (24h): ${live.synced_24h}`) - console.log(`failed (24h): ${live.failed_24h}`) - console.log(`spooled pending: ${live.spooled_pending}`) - console.log(`total synced: ${live.total_synced}`) - if (live.recent_session_ids.length) { - console.log('recent sessions:') - for (const id of live.recent_session_ids) console.log(` ${id}`) - } - }), - ) - - hooks - .command('enroll [repo]') - .description("Opt a repository into transcript sync (default: the cwd's origin)") - .action(async (repo: string | undefined) => - runAction(async () => { - const resolved = resolveRepo(repo) - enrollRepo(resolved) - console.log(`Enrolled ${resolved}. Claude Code sessions in this repo will sync.`) - const installed = hooksInstalled() - if (!installed.Stop || !installed.SessionEnd) { - console.log('Hooks are not installed. Run `agent hook install` to start syncing.') - } - }), - ) - - hooks - .command('unenroll [repo]') - .description('Opt a repository back out of transcript sync') - .action(async (repo: string | undefined) => - runAction(async () => { - const resolved = resolveRepo(repo) - unenrollRepo(resolved) - console.log(`Unenrolled ${resolved}.`) - }), - ) -} - -// Coarse relative time ("4m ago") for the status/stats one-liners. -export function ago(iso: string): string { - const ms = Date.now() - Date.parse(iso) - if (!Number.isFinite(ms) || ms < 0) return iso - const s = Math.floor(ms / 1000) - if (s < 60) return `${s}s ago` - const m = Math.floor(s / 60) - if (m < 60) return `${m}m ago` - const h = Math.floor(m / 60) - if (h < 24) return `${h}h ago` - return `${Math.floor(h / 24)}d ago` -} diff --git a/src/commands/review.ts b/src/commands/review.ts index 7646eef..b64bfe2 100644 --- a/src/commands/review.ts +++ b/src/commands/review.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { basename, dirname, extname } from 'node:path' import { api, APIError } from '../lib/api' import { alsoKnownAs, apiRoutes } from '../lib/help' -import { repoFromCwd } from '../lib/laptop' +import { repoFromCwd } from '../lib/git' import { formatTs, printJson, printTable, relativeAge, runAction, usdFromMillicents } from '../lib/output' import { watchSessionStreaming } from './session' import type { Ellipsis } from '@ellipsis-dev/sdk' diff --git a/src/commands/session.tsx b/src/commands/session.tsx index 58e6229..72f2435 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -1,7 +1,7 @@ import type { Command } from 'commander' -import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { readFileSync, writeFileSync } from 'node:fs' import { extname } from 'node:path' -import { gunzipSync, gzipSync } from 'node:zlib' +import { gunzipSync } from 'node:zlib' import { parse as parseYaml } from 'yaml' import { api } from '../lib/api' import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config' @@ -47,27 +47,13 @@ import type { SessionSearchResult, SessionSearchScope, StartAgentSessionRequest, - SyncAgentSessionRequest, } from '../lib/types' -import { - branchFromCwd, - createWipCommit, - dropSpooledSync, - enrolledRepos, - listSpooledSyncs, - pushHandoffRef, - recordSyncOutcome, - redactLine, - repoFromCwd, - spoolSync, - type SyncOutcome, -} from '../lib/laptop' +import { repoFromCwd } from '../lib/git' import { openBrowser } from '../lib/auth' import { registerConnect, runConnect } from './connect' import { canHostSessionsUi, defaultStartRequest, runSessionsUi } from '../ui/launch' import { formatStepLine, oneLine, recordText } from '../lib/steps' -import { APIError } from '../lib/api' -import { resolveToken } from '../lib/config' +import { sessionConfigName } from '../lib/sessions' // Poll cadence for the `--watch` REST fallback (used only when live WebSocket // streaming is unavailable). Not user-configurable — the fallback is rare. @@ -254,20 +240,20 @@ export function registerSession(program: Command): void { if (opts.connect && !promptText) req.idle_start = true const client = api() - const { session, resolved_config_name, resolution_source } = - await client.sessions.start(req) + const { session } = await client.sessions.start(req) // Say which agent the server picked when it came from the defaults // ladder, so a bare `agent` never silently runs an unexpected config. // The connect UI shows the config in its footer meta line (anything // printed before the app would land in scrollback); every other // mode prints this note. + const resolvedConfigName = sessionConfigName(session) let configNote: string | undefined - if (resolved_config_name) { - if (resolution_source === 'repo_default') { - configNote = `using config "${resolved_config_name}" (repo default)` - } else if (resolution_source === 'account_default') { - configNote = `using config "${resolved_config_name}" (account default)` + if (resolvedConfigName) { + if (session.agent.source === 'repo_default') { + configNote = `using config "${resolvedConfigName}" (repo default)` + } else if (session.agent.source === 'account_default') { + configNote = `using config "${resolvedConfigName}" (account default)` } } @@ -286,7 +272,7 @@ export function registerSession(program: Command): void { await watchSessionStreaming(client, session.id, FALLBACK_POLL_INTERVAL_SECONDS, false) return } - await startConnect(session, undefined, resolved_config_name ?? undefined) + await startConnect(session, undefined, resolvedConfigName ?? undefined) return } @@ -329,7 +315,7 @@ export function registerSession(program: Command): void { 'ls', ).addHelpText( 'after', - '\nSources: laptop, react, manual, api, cli, mention, cron. ' + + '\nSources: react, manual, api, cli, mention, cron. ' + '--since/--until accept ISO 8601 or "today", "yesterday", "N days ago".', ), 'GET /sessions', @@ -405,7 +391,7 @@ export function registerSession(program: Command): void { 'after', '\nA PR-shaped query ("#512", "acme/api#512", or a pull request URL) also finds the ' + 'session that created that exact pull request.\n' + - 'Sources: laptop, react, manual, api, cli, mention, cron. ' + + 'Sources: react, manual, api, cli, mention, cron. ' + '--since/--until accept ISO 8601 or "today", "yesterday", "N days ago".', ), 'GET /sessions/search', @@ -716,92 +702,6 @@ export function registerSession(program: Command): void { }, ) - // Laptop → cloud handoff (design: LOCAL_CLAUDE_CODE.md §7.2): snapshot the - // dirty working tree as a commit (without disturbing it), push it to - // refs/ellipsis/handoff/, and start a fresh cloud session on the - // built-in handoff config with that prompt as its query — never a - // literal `claude --resume` of the local session. - apiRoutes( - session - .command('handoff ') - .description('Hand this repo and a synced local session off to a cloud agent'), - 'POST /sessions', - ) - .requiredOption( - '-p, --parent ', - 'the synced laptop session to chain from (see `agent session list --source laptop`)', - ) - .option('--cwd ', 'repository to hand off (default: current directory)') - .option('--json', 'output raw JSON') - .action( - async ( - promptWords: string[], - opts: { parent: string; cwd?: string; json?: boolean }, - ) => { - await runAction(async () => { - const prompt = promptWords.join(' ') - const cwd = opts.cwd ?? process.cwd() - const repo = repoFromCwd(cwd) - if (!repo) { - throw new Error('not inside a git repository with an origin remote') - } - const { sha, dirty } = createWipCommit(cwd) - const ref = pushHandoffRef(cwd, sha) - if (!opts.json) { - console.log( - dirty - ? `✓ pushed working-tree snapshot ${sha.slice(0, 12)} to ${ref}` - : `✓ working tree clean, handing off HEAD ${sha.slice(0, 12)} via ${ref}`, - ) - } - const client = api() - const { session } = await client.sessions.start({ - handoff: { parent_session_id: opts.parent, repo, sha, ref }, - prompt, - }) - if (opts.json) { - printJson(session) - return - } - console.log(`✓ started handoff session ${session.id} (${session.status})`) - await printSessionUrl(client, session.id) - console.log(` follow with: agent session get ${session.id} --watch`) - }) - }, - ) - - // The laptop-transcript sync (design: LOCAL_CLAUDE_CODE.md §7.1). Normally - // invoked by the Claude Code Stop/SessionEnd hooks `agent hook install` - // writes, with the hook's JSON context on stdin; the flags exist for manual - // runs and testing. In hook mode every failure path is a QUIET no-op (exit - // 0): consent gaps (unenrolled repo), a logged-out CLI, and network errors - // must never surface into someone's Claude Code session. Network failures - // spool to disk and flush on the next successful sync. - apiRoutes( - session - .command('sync') - .description('Sync a local Claude Code transcript up, as the installed hooks do'), - 'POST /sessions/sync', - ) - .option('--transcript ', 'transcript JSONL path (default: from hook stdin)') - .option('--session-id ', 'Claude Code session id (default: from hook stdin)') - .option('--reason ', 'stop or session_end (default: from hook stdin)') - .option('--cwd ', 'session working directory (default: from hook stdin)') - .option('--json', 'output raw JSON') - .action( - async (opts: { - transcript?: string - sessionId?: string - reason?: string - cwd?: string - json?: boolean - }) => { - await runAction(async () => { - await syncTranscript(opts) - }) - }, - ) - apiRoutes( session.command('stop ').description('Stop an in-flight session'), 'POST /sessions/{id}/stop', @@ -832,7 +732,7 @@ export async function startConnect( // not carry; shown in the chat footer's meta line. resolvedConfigName?: string, ): Promise { - const configName = resolvedConfigName ?? session.config_id ?? undefined + const configName = resolvedConfigName ?? sessionConfigName(session) ?? undefined if (canHostSessionsUi()) { await runSessionsUi({ initialSessionId: session.id, @@ -985,7 +885,8 @@ function printSessionSummary(s: AgentSession): void { console.log(`id: ${s.id}`) console.log(`status: ${s.status}${s.status_reason ? ` (${s.status_reason})` : ''}`) if (s.source) console.log(`source: ${s.source}`) - if (s.config_id) console.log(`config: ${s.config_id}`) + const config = sessionConfigName(s) + if (config) console.log(`config: ${config}`) console.log(`created: ${s.created_at}`) console.log(`updated: ${s.updated_at}`) console.log(`tokens: ${(s.tokens?.total ?? 0).toLocaleString()}`) @@ -1208,155 +1109,3 @@ function sleep(ms: number): Promise { function nowClock(): string { return new Date().toTimeString().slice(0, 8) } - -// --------------------------------------------------------------------------- -// `agent session sync` implementation. -// --------------------------------------------------------------------------- - -// The JSON context Claude Code writes to a hook's stdin. Fields beyond these -// exist per event; we only need the session identity + transcript location. -interface HookStdin { - session_id?: string - transcript_path?: string - cwd?: string - hook_event_name?: string - reason?: string -} - -async function readHookStdin(): Promise { - if (process.stdin.isTTY) return undefined - let data = '' - for await (const chunk of process.stdin) data += chunk - data = data.trim() - if (!data) return undefined - try { - return JSON.parse(data) as HookStdin - } catch { - return undefined - } -} - -// A fetch() network failure (DNS, refused, offline) — retriable, so spool. -// ApiError >= 500 is treated the same; 4xx is permanent and never spooled. -function isRetriable(err: unknown): boolean { - if (err instanceof APIError) return err.status >= 500 - // Anything that never produced an HTTP response (DNS, refused, offline). - return true -} - -async function syncTranscript(opts: { - transcript?: string - sessionId?: string - reason?: string - cwd?: string - json?: boolean -}): Promise { - const hook = await readHookStdin() - // Hook mode = driven by CC (stdin context, no explicit flags): all failure - // paths are silent no-ops so they never surface into the session. - const hookMode = hook !== undefined && !opts.transcript && !opts.sessionId - - const ccSessionId = opts.sessionId ?? hook?.session_id - const transcriptPath = opts.transcript ?? hook?.transcript_path - const cwd = opts.cwd ?? hook?.cwd ?? process.cwd() - const reason: 'stop' | 'session_end' = - opts.reason === 'session_end' || opts.reason === 'stop' - ? opts.reason - : hook?.hook_event_name === 'SessionEnd' - ? 'session_end' - : 'stop' - const repo = repoFromCwd(cwd) - - // Hook mode is quiet on every failure path, so the local activity log - // (hooks/sync.log.jsonl + stats.json, surfaced by `agent hook log/stats`) - // is the only place a failed background sync is observable. Recording is - // best-effort and never throws, preserving the exit-0 guarantee. - const quit = (outcome: SyncOutcome, message: string): void => { - recordSyncOutcome({ outcome, cc_session_id: ccSessionId, repo, reason, error: message }) - if (!hookMode) throw new Error(message) - } - - if (!ccSessionId || !transcriptPath) { - return quit('rejected', 'need --session-id and --transcript (or hook JSON on stdin)') - } - - // Consent gate: per-repo opt-in, silently skipped otherwise. - if (!repo || !enrolledRepos().includes(repo.toLowerCase())) { - return quit( - 'skipped_unenrolled', - `repository ${repo ?? `at ${cwd}`} is not enrolled (agent hook enroll)`, - ) - } - if (!resolveToken()) { - return quit('not_logged_in', 'not logged in. Run `agent login` first, or set ELLIPSIS_API_TOKEN.') - } - if (!existsSync(transcriptPath)) { - return quit('no_transcript', `transcript not found: ${transcriptPath}`) - } - - // Redact line-by-line (secrets never leave the laptop unredacted), then - // gzip + base64 for the JSON body. - const lines = readFileSync(transcriptPath, 'utf8') - .split('\n') - .filter((l) => l.trim().length > 0) - .map(redactLine) - if (lines.length === 0) { - return quit('no_transcript', `transcript is empty: ${transcriptPath}`) - } - - const req: SyncAgentSessionRequest = { - cc_session_id: ccSessionId, - transcript_gzip_b64: gzipSync(lines.join('\n') + '\n').toString('base64'), - reason, - repo, - cwd, - git_branch: branchFromCwd(cwd), - } - - const client = api() - try { - const res = await client.sessions.sync(req) - recordSyncOutcome({ - outcome: 'synced', - cc_session_id: ccSessionId, - repo, - reason, - session_id: res.session_id, - event_count: res.event_count, - }) - if (opts.json) printJson(res) - else if (!hookMode) { - console.log( - `✓ synced ${res.event_count} events to session ${res.session_id}` + - (res.accepted ? '' : ' (server already had a newer snapshot)'), - ) - } - } catch (err) { - if (isRetriable(err)) { - // Spool (latest snapshot per session wins) and stay quiet in hook mode — - // the next sync flushes it. - spoolSync(req) - quit('spooled', (err as Error).message) - return - } - // Permanent rejection (auth, validation, payload too large): never spool. - quit('rejected', (err as Error).message) - return - } - - // The API is reachable — flush anything an earlier offline sync spooled. - for (const { file, req: spooled } of listSpooledSyncs()) { - if (spooled.cc_session_id === ccSessionId) { - // The snapshot we just synced supersedes it (snapshots only grow). - dropSpooledSync(file) - continue - } - try { - await client.sessions.sync(spooled) - dropSpooledSync(file) - } catch (err) { - if (isRetriable(err)) break // server unhealthy again; retry next time - dropSpooledSync(file) // permanent rejection: retrying can't succeed - } - } -} diff --git a/src/lib/args.ts b/src/lib/args.ts index 75cad51..82f2b9c 100644 --- a/src/lib/args.ts +++ b/src/lib/args.ts @@ -34,7 +34,6 @@ export function toNumber(value: string): number { // Values the server accepts for the session facets, mirrored here so a typo // fails fast with the full list instead of a server-side 422. export const SESSION_SOURCES = [ - 'laptop', 'react', 'manual', 'api', diff --git a/src/lib/config.ts b/src/lib/config.ts index bd7ce13..2ef1016 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -24,12 +24,11 @@ function configFile(): string { // `apiBase` by default (api. -> app.), but stored explicitly so a self-hosted // instance whose dashboard host isn't a mechanical swap can set it directly // (`agent host add … --app-base`). `token` is the credential minted against -// THIS instance; `enrolledRepos` is this instance's laptop-sync consent set. +// THIS instance. export interface Host { apiBase: string appBase?: string token?: string - enrolledRepos?: string[] } // How the interactive UI's session bar is scoped. Every field is optional; a @@ -68,7 +67,6 @@ export interface CliConfig { interface CliConfigV1 { token?: string apiBase?: string - enrolledRepos?: string[] } // Swap the `api` host label for `app` (api.ellipsis.dev -> app.ellipsis.dev, @@ -98,14 +96,13 @@ function migrate(raw: unknown): CliConfig { const v1 = obj as CliConfigV1 const hosts: Record = {} let activeHost: string | undefined - if (v1.token || v1.apiBase || (v1.enrolledRepos && v1.enrolledRepos.length > 0)) { + if (v1.token || v1.apiBase) { const apiBase = (v1.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, '') const name = hostNameForBase(apiBase) hosts[name] = { apiBase, appBase: deriveAppBase(apiBase), ...(v1.token ? { token: v1.token } : {}), - ...(v1.enrolledRepos ? { enrolledRepos: v1.enrolledRepos } : {}), } activeHost = name } @@ -249,7 +246,7 @@ export const SESSION_BAR_DEFAULTS: Required> & export type ResolvedSessionBar = typeof SESSION_BAR_DEFAULTS -const SESSION_SOURCES = ['react', 'manual', 'api', 'cli', 'mention', 'cron', 'laptop'] +const SESSION_SOURCES = ['react', 'manual', 'api', 'cli', 'mention', 'cron'] // The session bar's settings, defaults filled in — set them under // `"sessionBar"` in ~/.ellipsis/config.json. A value of the wrong type or @@ -278,17 +275,6 @@ export function sessionBar(): ResolvedSessionBar { } } -export function getEnrolledRepos(): string[] { - return (activeHost()?.enrolledRepos ?? []).map((r) => r.toLowerCase()) -} - -export function setEnrolledRepos(repos: string[]): void { - const name = ensureActiveHost() - const cfg = loadConfig() - cfg.hosts[name].enrolledRepos = repos - saveConfig(cfg) -} - // --- credential / URL resolution -------------------------------------------- // // Precedence (highest wins): explicit arg → environment → active host → default. diff --git a/src/lib/git.ts b/src/lib/git.ts new file mode 100644 index 0000000..5510864 --- /dev/null +++ b/src/lib/git.ts @@ -0,0 +1,26 @@ +// Reading the local checkout: which repo and branch the cwd is on, so +// commands can default their --repo to where they were run. + +import { execFileSync } from 'node:child_process' + +function git(cwd: string, ...args: string[]): string | undefined { + try { + return execFileSync('git', ['-C', cwd, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + } catch { + return undefined + } +} + +// "owner/name" from a git remote URL (ssh or https, with or without .git). +export function repoFromRemoteUrl(url: string): string | undefined { + const m = url.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/) + return m ? `${m[1]}/${m[2]}` : undefined +} + +export function repoFromCwd(cwd: string): string | undefined { + const url = git(cwd, 'remote', 'get-url', 'origin') + return url ? repoFromRemoteUrl(url) : undefined +} diff --git a/src/lib/help.ts b/src/lib/help.ts index 7c07203..9d0d457 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -20,7 +20,7 @@ function withoutAliases(term: string, cmd: Command): string { const TOP_LEVEL_GROUPS: ReadonlyArray<{ title: string; commands: readonly string[] }> = [ { title: 'Sessions', commands: ['session', 'review'] }, { title: 'Agents', commands: ['config', 'model', 'template'] }, - { title: 'Platform', commands: ['variable', 'file', 'hook'] }, + { title: 'Platform', commands: ['variable', 'file'] }, { title: 'Integrations', commands: ['integration', 'github', 'slack', 'linear', 'sentry'] }, { title: 'Spend', commands: ['budget', 'usage', 'analytics'] }, { title: 'Account', commands: ['install', 'login', 'logout', 'me', 'host', 'ping'] }, diff --git a/src/lib/laptop.ts b/src/lib/laptop.ts deleted file mode 100644 index 2e9a3d0..0000000 --- a/src/lib/laptop.ts +++ /dev/null @@ -1,445 +0,0 @@ -// Laptop transcript sync plumbing (`agent hook …` + `agent session sync`) — -// the client half of documents/eng/LOCAL_CLAUDE_CODE.md §7.1 in the monorepo. -// -// Claude Code fires `Stop` (once per turn) and `SessionEnd` hooks whose -// command is `agent session sync`; the hook's JSON context arrives on stdin -// with the session id and the live on-disk transcript path. The sync checks -// per-repo enrollment (cwd → git remote → enrolled set; silent no-op -// otherwise), redacts client-side (secrets never leave the laptop -// unredacted), gzips, and POSTs to /sessions/sync. Network failures spool -// to disk and are flushed on the next successful sync. - -import { execFileSync } from 'node:child_process' -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - renameSync, - unlinkSync, - writeFileSync, -} from 'node:fs' -import { homedir } from 'node:os' -import { dirname, join } from 'node:path' -import { configDir, getEnrolledRepos, setEnrolledRepos } from './config' -import type { SyncAgentSessionRequest } from './types' - -// --------------------------------------------------------------------------- -// Claude Code settings (~/.claude/settings.json): install/remove our hooks. -// --------------------------------------------------------------------------- - -// The events we capture. Stop is v0 of the laptop phase by design — the -// pipeline can't rely on session-end-only capture (a mid-session sync is what -// makes a live session visible and handoff-able). -export const HOOK_EVENTS = ['Stop', 'SessionEnd'] as const - -// How we recognize our own handler entries in settings.json, so install is -// idempotent and uninstall never touches hooks the user wrote themselves. -export const HOOK_COMMAND = 'agent session sync' - -interface HookHandler { - type: string - command?: string - async?: boolean - timeout?: number - [key: string]: unknown -} - -interface HookGroup { - matcher?: string - hooks: HookHandler[] - [key: string]: unknown -} - -export function claudeSettingsPath(): string { - return process.env.CLAUDE_SETTINGS_PATH ?? join(homedir(), '.claude', 'settings.json') -} - -function readSettings(path: string): Record { - if (!existsSync(path)) return {} - return JSON.parse(readFileSync(path, 'utf8')) as Record -} - -function isOurs(handler: HookHandler): boolean { - return handler.type === 'command' && (handler.command ?? '').startsWith(HOOK_COMMAND) -} - -// Install the Stop + SessionEnd handlers, preserving everything else in the -// file (other events, other matcher groups, other handlers in our groups). -// Idempotent: re-running replaces our entries rather than duplicating them. -export function installHooks(): { path: string; changed: boolean } { - const path = claudeSettingsPath() - const settings = readSettings(path) - const hooks = (settings.hooks ?? {}) as Record - let changed = false - for (const event of HOOK_EVENTS) { - const groups: HookGroup[] = hooks[event] ?? [] - for (const g of groups) g.hooks = (g.hooks ?? []).filter((h) => !isOurs(h)) - const kept = groups.filter((g) => (g.hooks ?? []).length > 0) - kept.push({ - hooks: [ - { - type: 'command', - command: HOOK_COMMAND, - // Background so a slow upload never blocks the turn; async hooks' - // exit codes are ignored, so a failed sync can't disturb the - // session either. - async: true, - timeout: 120, - }, - ], - }) - hooks[event] = kept - changed = true - } - settings.hooks = hooks - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, JSON.stringify(settings, null, 2) + '\n') - return { path, changed } -} - -export function uninstallHooks(): { path: string; changed: boolean } { - const path = claudeSettingsPath() - if (!existsSync(path)) return { path, changed: false } - const settings = readSettings(path) - const hooks = (settings.hooks ?? {}) as Record - let changed = false - for (const event of HOOK_EVENTS) { - const groups = hooks[event] - if (!groups) continue - const next = groups - .map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !isOurs(h)) })) - .filter((g) => g.hooks.length > 0) - if (next.length !== groups.length || JSON.stringify(next) !== JSON.stringify(groups)) { - changed = true - } - if (next.length === 0) delete hooks[event] - else hooks[event] = next - } - settings.hooks = hooks - if (Object.keys(hooks).length === 0) delete settings.hooks - writeFileSync(path, JSON.stringify(settings, null, 2) + '\n') - return { path, changed } -} - -export function hooksInstalled(): Record { - const settings = readSettings(claudeSettingsPath()) - const hooks = (settings.hooks ?? {}) as Record - const out: Record = {} - for (const event of HOOK_EVENTS) { - out[event] = (hooks[event] ?? []).some((g) => (g.hooks ?? []).some(isOurs)) - } - return out -} - -// --------------------------------------------------------------------------- -// Per-repo enrollment (consent is per-repo opt-in, never account-wide). -// Stored in the CLI config file as "owner/name" strings. -// --------------------------------------------------------------------------- - -function git(cwd: string, ...args: string[]): string | undefined { - try { - return execFileSync('git', ['-C', cwd, ...args], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim() - } catch { - return undefined - } -} - -// "owner/name" from a git remote URL (ssh or https, with or without .git). -export function repoFromRemoteUrl(url: string): string | undefined { - const m = url.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/) - return m ? `${m[1]}/${m[2]}` : undefined -} - -export function repoFromCwd(cwd: string): string | undefined { - const url = git(cwd, 'remote', 'get-url', 'origin') - return url ? repoFromRemoteUrl(url) : undefined -} - -export function branchFromCwd(cwd: string): string | undefined { - return git(cwd, 'rev-parse', '--abbrev-ref', 'HEAD') -} - -export function enrolledRepos(): string[] { - return getEnrolledRepos() -} - -export function enrollRepo(repo: string): void { - const set = new Set(getEnrolledRepos()) - set.add(repo.toLowerCase()) - setEnrolledRepos([...set].sort()) -} - -export function unenrollRepo(repo: string): void { - setEnrolledRepos(getEnrolledRepos().filter((r) => r !== repo.toLowerCase())) -} - -// --------------------------------------------------------------------------- -// Client-side redaction: secrets never leave the laptop unredacted. Pattern -// list is deliberately high-precision (recognizable token shapes), not a -// generic entropy scan — false positives corrupt tool results the transcript -// exists to preserve. -// --------------------------------------------------------------------------- - -const REDACTION_PATTERNS: RegExp[] = [ - // GitHub tokens (classic + fine-grained + app/oauth/refresh). - /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, - /\bgithub_pat_[A-Za-z0-9_]{30,}\b/g, - // AWS access key ids + the canonical secret-key assignment shape. - /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, - /\baws_secret_access_key\s*[=:]\s*[A-Za-z0-9/+=]{30,}/gi, - // Anthropic / OpenAI. - /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, - /\bsk-[A-Za-z0-9_-]{30,}\b/g, - // Slack tokens and webhook URLs. - /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, - /https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/]+/g, - // Stripe, npm, PyPI. - /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g, - /\bnpm_[A-Za-z0-9]{30,}\b/g, - /\bpypi-[A-Za-z0-9_-]{30,}\b/g, - // JWTs (three base64url segments, header starts with eyJ). - /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, - // Private key blocks (single-line JSON-escaped or raw). - /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, -] - -export function redactLine(line: string): string { - let out = line - for (const pattern of REDACTION_PATTERNS) out = out.replace(pattern, '[REDACTED]') - return out -} - -// --------------------------------------------------------------------------- -// Spool-and-retry: a sync that can't reach the API is written here (latest -// snapshot per CC session wins — snapshots only grow) and flushed by the next -// invocation. Bounded by one file per session; SessionEnd's final sync -// re-delivers everything a lost Stop sync carried. -// --------------------------------------------------------------------------- - -function spoolDir(): string { - return join(configDir(), 'spool') -} - -export function spoolSync(req: SyncAgentSessionRequest): string { - const dir = spoolDir() - mkdirSync(dir, { recursive: true }) - // One file per CC session: a newer snapshot supersedes the spooled one. - const file = join(dir, `${req.cc_session_id.replace(/[^A-Za-z0-9-]/g, '_')}.json`) - writeFileSync(file, JSON.stringify(req), { mode: 0o600 }) - return file -} - -export function listSpooledSyncs(): { file: string; req: SyncAgentSessionRequest }[] { - const dir = spoolDir() - if (!existsSync(dir)) return [] - const out: { file: string; req: SyncAgentSessionRequest }[] = [] - for (const name of readdirSync(dir)) { - if (!name.endsWith('.json')) continue - const file = join(dir, name) - try { - out.push({ file, req: JSON.parse(readFileSync(file, 'utf8')) }) - } catch { - // A torn write from a crashed hook; drop it — the next sync of that - // session carries a longer snapshot anyway. - unlinkSync(file) - } - } - return out -} - -export function dropSpooledSync(file: string): void { - try { - unlinkSync(file) - } catch { - // Already gone (a concurrent hook flushed it) — fine. - } -} - -// Cheap count of spooled (retriable, not-yet-delivered) syncs, without -// parsing them — used by the hook stats object. -export function spooledPendingCount(): number { - const dir = spoolDir() - if (!existsSync(dir)) return 0 - return readdirSync(dir).filter((n) => n.endsWith('.json')).length -} - -// --------------------------------------------------------------------------- -// Hook observability. In hook mode `agent session sync` is a quiet no-op on -// every failure path by design (async hooks' output is discarded and a broken -// sync must never disturb a Claude Code session), so nothing is visible in the -// session itself. Instead, every sync attempt appends one JSONL line to -// hooks/sync.log.jsonl (read by `agent hook log`) and atomically rewrites -// hooks/stats.json — a plain JSON object anything can read without invoking -// the CLI (read by `agent hook stats`). All of it is best-effort: a logging -// failure stays silent so hook mode keeps its exit-0 guarantee. -// --------------------------------------------------------------------------- - -export type SyncOutcome = - | 'synced' // delivered to POST /sessions/sync - | 'skipped_unenrolled' // consent gate: repo not enrolled (or no git remote) - | 'not_logged_in' // no token anywhere - | 'no_transcript' // transcript path missing/empty on disk - | 'spooled' // retriable failure (network / 5xx); queued for the next sync - | 'rejected' // permanent failure (4xx / bad invocation); never retried - -export interface SyncLogEntry { - ts: string // ISO-8601, when the attempt finished - outcome: SyncOutcome - cc_session_id?: string - repo?: string - reason?: string // stop | session_end - session_id?: string // public API session id (synced only) - event_count?: number // events the server acknowledged (synced only) - error?: string // failure detail (non-synced outcomes) -} - -// The stats object rewritten on every sync. 24h windows are derived from the -// activity log; total_synced is carried forward so the log cap can't shrink it. -export interface HookSyncStats { - last_sync_at?: string // ts of the most recent attempt (any outcome) - last_outcome?: SyncOutcome - last_error?: string // error of the most recent failed attempt - synced_24h: number - failed_24h: number // not_logged_in / no_transcript / spooled / rejected - spooled_pending: number - total_synced: number - recent_session_ids: string[] // distinct public API session ids, most recent first -} - -// The log is an audit trail for "did that background sync fail, and why", -// not unbounded history — keep the newest N lines. -const SYNC_LOG_MAX_LINES = 1000 -const RECENT_SESSION_IDS_MAX = 10 - -const FAILURE_OUTCOMES: ReadonlySet = new Set([ - 'not_logged_in', - 'no_transcript', - 'spooled', - 'rejected', -]) - -function hooksDir(): string { - return join(configDir(), 'hooks') -} - -export function syncLogPath(): string { - return join(hooksDir(), 'sync.log.jsonl') -} - -export function hookStatsPath(): string { - return join(hooksDir(), 'stats.json') -} - -// stats.json readers may race a hook's rewrite, so writes go through a -// same-directory temp file + rename (atomic on POSIX). -function writeFileAtomic(path: string, data: string): void { - const tmp = `${path}.${process.pid}.tmp` - writeFileSync(tmp, data, { mode: 0o600 }) - renameSync(tmp, path) -} - -export function readSyncLog(): SyncLogEntry[] { - const path = syncLogPath() - if (!existsSync(path)) return [] - const out: SyncLogEntry[] = [] - for (const line of readFileSync(path, 'utf8').split('\n')) { - if (!line.trim()) continue - try { - out.push(JSON.parse(line) as SyncLogEntry) - } catch { - // A torn line from a crashed hook — skip it, keep the rest. - } - } - return out -} - -export function readHookStats(): HookSyncStats | undefined { - try { - return JSON.parse(readFileSync(hookStatsPath(), 'utf8')) as HookSyncStats - } catch { - return undefined - } -} - -export function computeHookStats( - entries: SyncLogEntry[], - totalSynced: number, - now: Date = new Date(), -): HookSyncStats { - const dayAgo = now.getTime() - 24 * 60 * 60 * 1000 - const recent = entries.filter((e) => Date.parse(e.ts) >= dayAgo) - const last = entries[entries.length - 1] - const lastFailed = [...entries].reverse().find((e) => FAILURE_OUTCOMES.has(e.outcome)) - const recentIds: string[] = [] - for (const e of [...entries].reverse()) { - if (e.outcome !== 'synced' || !e.session_id) continue - if (recentIds.includes(e.session_id)) continue - recentIds.push(e.session_id) - if (recentIds.length >= RECENT_SESSION_IDS_MAX) break - } - return { - last_sync_at: last?.ts, - last_outcome: last?.outcome, - last_error: lastFailed?.error, - synced_24h: recent.filter((e) => e.outcome === 'synced').length, - failed_24h: recent.filter((e) => FAILURE_OUTCOMES.has(e.outcome)).length, - spooled_pending: spooledPendingCount(), - total_synced: totalSynced, - recent_session_ids: recentIds, - } -} - -// Append one attempt to the activity log (capped) and rewrite stats.json. -// Never throws: observability must not break the hook-mode exit-0 guarantee. -export function recordSyncOutcome(entry: Omit): void { - try { - mkdirSync(hooksDir(), { recursive: true }) - const full: SyncLogEntry = { ts: new Date().toISOString(), ...entry } - const entries = [...readSyncLog(), full].slice(-SYNC_LOG_MAX_LINES) - writeFileAtomic(syncLogPath(), entries.map((e) => JSON.stringify(e)).join('\n') + '\n') - // total_synced carries forward from the previous stats object (the log is - // capped, so recounting it would shrink the total); first write falls back - // to counting whatever history the log holds. - const prevTotal = - readHookStats()?.total_synced ?? - entries.filter((e) => e.outcome === 'synced' && e !== full).length - const total = prevTotal + (full.outcome === 'synced' ? 1 : 0) - writeFileAtomic(hookStatsPath(), JSON.stringify(computeHookStats(entries, total), null, 2) + '\n') - } catch { - // Best-effort by design. - } -} - -// --------------------------------------------------------------------------- -// Handoff (design §7.2): snapshot the dirty working tree WITHOUT disturbing it -// and push it somewhere the cloud sandbox can fetch. -// --------------------------------------------------------------------------- - -function gitOrThrow(cwd: string, ...args: string[]): string { - return execFileSync('git', ['-C', cwd, ...args], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }).trim() -} - -// The WIP commit: `git stash create` builds a commit of the dirty tree without -// touching the index or working copy; a clean tree hands off HEAD itself. -export function createWipCommit(cwd: string): { sha: string; dirty: boolean } { - const stashSha = gitOrThrow(cwd, 'stash', 'create', 'ellipsis handoff') - if (stashSha) return { sha: stashSha, dirty: true } - return { sha: gitOrThrow(cwd, 'rev-parse', 'HEAD'), dirty: false } -} - -// Push the WIP commit to a hidden ref (never a branch — it must not appear in -// branch UIs). Requires push permission on the repo; the caller surfaces the -// git error verbatim when it fails. -export function pushHandoffRef(cwd: string, sha: string): string { - const ref = `refs/ellipsis/handoff/${sha.slice(0, 12)}` - gitOrThrow(cwd, 'push', 'origin', `${sha}:${ref}`) - return ref -} - diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index a3ebc02..62abb9e 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -73,11 +73,11 @@ export function rowGlyph(word: string): { glyph: string; color?: string; dim: bo return { glyph: '●', color: theme.success, dim: true } } -// The row's one-line description: what the session is doing right now -// (live_summary), else what it was asked to do (prompt), else where it came +// The row's one-line description: what the session is doing right now (its +// live summary), else what it was asked to do (prompt), else where it came // from. Whitespace collapsed; the caller truncates to the column. export function rowDescription(session: AgentSession): string { - const summary = session.live_summary + const summary = session.summary?.description if (typeof summary === 'string' && summary.trim()) return oneLineText(summary) const prompt = session.prompt if (typeof prompt === 'string' && prompt.trim()) return oneLineText(prompt) @@ -143,10 +143,11 @@ export function rowMeta(session: AgentSession, now: Date = new Date()): string { return bits.join(' · ') } -// Where the session runs — laptop syncs are the only non-cloud kind the nav -// distinguishes. Every session-returning route carries `source` top-level. -export function sessionSource(session: AgentSession): string { - return session.source === 'laptop' ? 'laptop' : 'cloud' +// The agent identity to show for a session: the config's own name, else the id +// of the saved config its snapshot came from. Both are absent for inline, +// platform-default, and built-in configs, which have nothing to name. +export function sessionConfigName(session: AgentSession): string | null { + return session.agent.config.ellipsis.name ?? session.agent.config_id ?? null } // The sidebar's status bands, top to bottom: live conversations, then parked diff --git a/src/lib/types.ts b/src/lib/types.ts index 493310d..5562a2d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -34,12 +34,9 @@ export type SessionExecution = S['SessionExecution'] export type ListSessionRecordsResponse = S['SessionRecordsListResponse'] export type ListAgentSessionsResponse = S['SessionsListResponse'] export type StartAgentSessionRequest = NonNullable[0]> -export type StartAgentSessionResponse = S['StartAgentSessionResponse'] +export type SessionResponse = S['SessionResponse'] export type ReplayAgentSessionRequest = NonNullable[1]> export type SendSessionMessageRequest = S['SendSessionMessageRequest'] -export type HandoffAgentSessionParams = S['HandoffAgentSessionParams'] -export type SyncAgentSessionRequest = Parameters[0] -export type SyncAgentSessionResponse = S['SyncAgentSessionResponse'] export type SessionLogSegment = S['SessionLogSegment'] export type GetSessionLogResponse = S['GetSessionLogResponse'] @@ -60,8 +57,7 @@ export type CreateAgentConfigRequest = Parameters clearInterval(t) }, []) - // Cloud sessions only. A laptop session is a local `claude` run synced up for - // the record; opening one here has nothing to connect to, so it would be a - // dead row taking a slot from a session worth showing. Client-side because - // it holds whatever `sessionBar.sources` says. const rows = useMemo( - () => mergeSidebarSessions(sessions, localSessions).filter((s) => sessionSource(s) !== 'laptop'), + () => mergeSidebarSessions(sessions, localSessions), [localSessions, sessions], ) @@ -320,7 +316,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { canSend: c.canSend, notice: [notice, c.reason].filter(Boolean).join(' · ') || null, model: session.tokens?.model || null, - configName: configName ?? session.config_id ?? null, + configName: configName ?? sessionConfigName(session), url: sessionUrl(appBase, customerLogin, sessionId), } setEntries((prev) => new Map(prev).set(sessionId, entry)) @@ -391,14 +387,14 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { setStartError(null) try { const req = applyComposerChoices(props.buildStartRequest(prompt), choices) - const { session, resolved_config_name } = await api.sessions.start(req) + const { session } = await api.sessions.start(req) lastWords.current.set(session.id, rowStatusWord(session)) setLocalSessions((prev) => [session, ...prev]) setSelected(session.id) setMainPane({ type: 'chat', sessionId: session.id }) setFocus('chat') // Seed the entry from the start response's resolved config identity. - void loadEntry(session.id, resolved_config_name ?? session.config_id ?? undefined) + void loadEntry(session.id, sessionConfigName(session) ?? undefined) } catch (err) { setStartError(errorDetail(err)) } finally { diff --git a/src/ui/launch.tsx b/src/ui/launch.tsx index 2824ec8..6c3f3ca 100644 --- a/src/ui/launch.tsx +++ b/src/ui/launch.tsx @@ -2,7 +2,7 @@ import React from 'react' import { render } from 'ink' import { api } from '../lib/api' import { requireToken, resolveApiBase, resolveAppBase, sessionBar } from '../lib/config' -import { repoFromCwd } from '../lib/laptop' +import { repoFromCwd } from '../lib/git' import { makeOpenSocket, resolveWsBase } from '../lib/stream' import { applyDetectedThemeMode } from '../lib/terminalBackground' import type { StartAgentSessionRequest } from '../lib/types' diff --git a/test/__snapshots__/screenshot.test.ts.snap b/test/__snapshots__/screenshot.test.ts.snap index ecf11e4..0c109bb 100644 --- a/test/__snapshots__/screenshot.test.ts.snap +++ b/test/__snapshots__/screenshot.test.ts.snap @@ -24,7 +24,8 @@ exports[`interactive UI screenshots > a bare \`agent\` opens on the new-session `; exports[`interactive UI screenshots > enter on a picked session opens its chat 1`] = ` -" ✦ Cloud agent session on ellipsis.dev +" + ✦ Cloud agent session on ellipsis.dev ⎿ Sandbox started · 1 log line ● I found the login bug: the token refresh races the redirect. diff --git a/test/args.test.ts b/test/args.test.ts index 549fb13..6835d39 100644 --- a/test/args.test.ts +++ b/test/args.test.ts @@ -56,14 +56,14 @@ describe('collectKeyValue', () => { describe('collectSource / collectStatus / parseScope', () => { it('accumulate valid values like collect', () => { - expect(collectSource('cli', collectSource('laptop', []))).toEqual(['laptop', 'cli']) + expect(collectSource('cli', collectSource('cron', []))).toEqual(['cron', 'cli']) expect(collectStatus('completed', [])).toEqual(['completed']) expect(parseScope('recaps')).toBe('recaps') expect(parseScope('records')).toBe('records') }) it('reject unknown values listing the valid ones', () => { - expect(() => collectSource('slack', [])).toThrow(/source must be one of: laptop, react/) + expect(() => collectSource('slack', [])).toThrow(/source must be one of: react, manual/) expect(() => collectStatus('done', [])).toThrow(/status must be one of: scheduled/) expect(() => parseScope('all')).toThrow(/scope must be one of: records, recaps, both/) }) diff --git a/test/config.test.ts b/test/config.test.ts index 9a81c87..6601149 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -8,7 +8,6 @@ import { addHost, clearActiveHostToken, deleteHost, - getEnrolledRepos, listHosts, loadConfig, requireToken, @@ -18,7 +17,6 @@ import { SESSION_BAR_DEFAULTS, sessionBar, setActiveHostToken, - setEnrolledRepos, updateHost, useHost, } from '../src/lib/config' @@ -210,15 +208,6 @@ describe('host management', () => { expect(resolveToken()).toBeUndefined() expect(activeHostName()).toBe('beta') // entry survives }) - - it('enrolled repos are scoped to the active host', () => { - addHost('beta', 'https://beta-api.ellipsis.dev') - setEnrolledRepos(['acme/api']) - addHost('prod', 'https://api.ellipsis.dev') - expect(getEnrolledRepos()).toEqual([]) - useHost('beta') - expect(getEnrolledRepos()).toEqual(['acme/api']) - }) }) describe('sessionBar', () => { @@ -308,11 +297,6 @@ describe('v1 -> v2 config migration', () => { expect(loadConfig().activeHost).toBe('prod') }) - it('carries enrolled repos onto the migrated host', () => { - writeConfig({ apiBase: 'https://api.ellipsis.dev', enrolledRepos: ['acme/api'] }) - expect(getEnrolledRepos()).toEqual(['acme/api']) - }) - it('an empty v1 config migrates to no hosts', () => { writeConfig({}) const cfg = loadConfig() diff --git a/test/laptop.test.ts b/test/laptop.test.ts deleted file mode 100644 index f078eea..0000000 --- a/test/laptop.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - computeHookStats, - hookStatsPath, - readHookStats, - readSyncLog, - recordSyncOutcome, - spooledPendingCount, - syncLogPath, - type HookSyncStats, - type SyncLogEntry, -} from '../src/lib/laptop' - -// Each test gets a throwaway ELLIPSIS_CONFIG_DIR so the hook activity log and -// stats object are written to a known place, never the real ~/.config. -let dir: string - -beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'ellipsis-hooks-')) - process.env.ELLIPSIS_CONFIG_DIR = dir -}) - -afterEach(() => { - delete process.env.ELLIPSIS_CONFIG_DIR - rmSync(dir, { recursive: true, force: true }) -}) - -describe('recordSyncOutcome / readSyncLog', () => { - it('appends one JSONL entry per attempt with a timestamp', () => { - recordSyncOutcome({ outcome: 'synced', cc_session_id: 'cc1', repo: 'o/r', reason: 'stop', session_id: 's1', event_count: 3 }) - recordSyncOutcome({ outcome: 'spooled', cc_session_id: 'cc2', repo: 'o/r', reason: 'session_end', error: 'fetch failed' }) - const entries = readSyncLog() - expect(entries).toHaveLength(2) - expect(entries[0].outcome).toBe('synced') - expect(entries[0].session_id).toBe('s1') - expect(entries[1].outcome).toBe('spooled') - expect(entries[1].error).toBe('fetch failed') - expect(Date.parse(entries[0].ts)).not.toBeNaN() - }) - - it('skips torn lines instead of failing', () => { - recordSyncOutcome({ outcome: 'synced', session_id: 's1' }) - writeFileSync(syncLogPath(), readFileSync(syncLogPath(), 'utf8') + '{"truncat') - expect(readSyncLog()).toHaveLength(1) - }) - - it('returns [] when nothing has been logged', () => { - expect(readSyncLog()).toEqual([]) - expect(readHookStats()).toBeUndefined() - }) -}) - -describe('stats object', () => { - it('is rewritten on every attempt and readable as plain JSON', () => { - recordSyncOutcome({ outcome: 'synced', session_id: 's1', event_count: 2 }) - recordSyncOutcome({ outcome: 'rejected', error: 'boom' }) - expect(existsSync(hookStatsPath())).toBe(true) - const stats = JSON.parse(readFileSync(hookStatsPath(), 'utf8')) as HookSyncStats - expect(stats.last_outcome).toBe('rejected') - expect(stats.last_error).toBe('boom') - expect(stats.synced_24h).toBe(1) - expect(stats.failed_24h).toBe(1) - expect(stats.total_synced).toBe(1) - expect(stats.recent_session_ids).toEqual(['s1']) - expect(readHookStats()).toEqual(stats) - }) - - it('does not count skipped_unenrolled as a failure', () => { - recordSyncOutcome({ outcome: 'skipped_unenrolled', error: 'not enrolled' }) - const stats = readHookStats() - expect(stats?.failed_24h).toBe(0) - expect(stats?.synced_24h).toBe(0) - expect(stats?.last_outcome).toBe('skipped_unenrolled') - }) - - it('carries total_synced forward across writes', () => { - recordSyncOutcome({ outcome: 'synced', session_id: 's1' }) - recordSyncOutcome({ outcome: 'synced', session_id: 's2' }) - recordSyncOutcome({ outcome: 'spooled', error: 'offline' }) - expect(readHookStats()?.total_synced).toBe(2) - }) -}) - -describe('computeHookStats', () => { - const entry = (over: Partial): SyncLogEntry => ({ - ts: new Date().toISOString(), - outcome: 'synced', - ...over, - }) - - it('windows 24h counts and dedupes recent session ids (newest first)', () => { - const now = new Date('2026-07-05T12:00:00Z') - const old = '2026-07-01T12:00:00Z' - const fresh = '2026-07-05T11:00:00Z' - const stats = computeHookStats( - [ - entry({ ts: old, session_id: 'old' }), - entry({ ts: fresh, session_id: 'a' }), - entry({ ts: fresh, session_id: 'a' }), - entry({ ts: fresh, outcome: 'rejected', error: 'nope' }), - entry({ ts: fresh, session_id: 'b' }), - ], - 7, - now, - ) - expect(stats.synced_24h).toBe(3) - expect(stats.failed_24h).toBe(1) - expect(stats.total_synced).toBe(7) - expect(stats.recent_session_ids).toEqual(['b', 'a', 'old']) - expect(stats.last_outcome).toBe('synced') - expect(stats.last_error).toBe('nope') - }) - - it('counts pending spool files', () => { - mkdirSync(join(dir, 'spool'), { recursive: true }) - writeFileSync(join(dir, 'spool', 'cc1.json'), '{}') - writeFileSync(join(dir, 'spool', 'ignored.txt'), '') - expect(spooledPendingCount()).toBe(1) - expect(computeHookStats([], 0).spooled_pending).toBe(1) - }) -}) diff --git a/test/screenshot.test.ts b/test/screenshot.test.ts index 8b6be42..5bfd6ed 100644 --- a/test/screenshot.test.ts +++ b/test/screenshot.test.ts @@ -28,6 +28,7 @@ function stubSession(id: string, prompt: string, minutesAgo: number): AgentSessi updated_at: at, last_activity_at: at, prompting: { enabled: true }, + agent: { config: { ellipsis: { name: null } }, config_id: null, override: null, source: 'platform_default' }, tokens: { total: 12_400 }, cost: { total: 42_000 }, } as unknown as AgentSession diff --git a/test/search.test.ts b/test/search.test.ts index cb1ea67..d4419fa 100644 --- a/test/search.test.ts +++ b/test/search.test.ts @@ -46,7 +46,7 @@ describe('searchSessions', () => { q: 'shift trade webhook', scope: 'both', author_id: [5201153], - source: ['laptop', 'cli'], + source: ['cron', 'cli'], session_ids: ['session_1', 'session_2'], limit: 20, }) @@ -55,7 +55,7 @@ describe('searchSessions', () => { expect(url).toContain('q=shift+trade+webhook') expect(url).toContain('scope=both') expect(url).toContain('author_id=5201153') - expect(url).toContain('source=laptop&source=cli') + expect(url).toContain('source=cron&source=cli') expect(url).toContain('session_ids=session_1&session_ids=session_2') expect(url).toContain('limit=20') }) diff --git a/test/sessions.test.ts b/test/sessions.test.ts index 02a214d..fad5ce4 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -18,7 +18,6 @@ import { rowStatusWord, SESSION_BAR_FETCH, sessionBarQuery, - sessionSource, shortAge, sidebarSlice, sortSidebarSessions, @@ -26,7 +25,10 @@ import { mergeSidebarSessions, } from '../src/lib/sessions' import { theme } from '../src/lib/theme' -import type { AgentSession, SupportedModel } from '../src/lib/types' +import type { AgentConfig, AgentSession, SupportedModel } from '../src/lib/types' + +// The session fixtures only read the config's name, so the rest is a stub. +const BARE_CONFIG = { ellipsis: { name: null } } as unknown as AgentConfig function session(overrides: Partial): AgentSession { return { @@ -35,7 +37,7 @@ function session(overrides: Partial): AgentSession { updated_at: '2026-07-07T00:00:00Z', status: 'running', status_reason: null, - config_id: null, + agent: { config: BARE_CONFIG, config_id: null, override: null, source: 'platform_default' }, source: 'api', harness: 'claude_code', prompting: { enabled: true }, @@ -112,7 +114,10 @@ describe('rowStatusWord / rowGlyph', () => { describe('rowDescription', () => { it('prefers the live summary, collapsed to one line', () => { - const s = session({ live_summary: 'fixing the\n webhook tests', prompt: 'do a thing' }) + const s = session({ + summary: { description: 'fixing the\n webhook tests', created_at: null }, + prompt: 'do a thing', + }) expect(rowDescription(s)).toBe('fixing the webhook tests') }) @@ -124,7 +129,9 @@ describe('rowDescription', () => { }) it('ignores whitespace-only summaries', () => { - expect(rowDescription(session({ live_summary: ' \n ', prompt: 'p' }))).toBe('p') + expect( + rowDescription(session({ summary: { description: ' \n ', created_at: null }, prompt: 'p' })), + ).toBe('p') }) }) @@ -180,14 +187,6 @@ describe('rowMeta', () => { }) }) -describe('sessionSource', () => { - it('reads laptop off the session\'s top-level source', () => { - expect(sessionSource(session({ source: 'laptop' }))).toBe('laptop') - expect(sessionSource(session({ source: 'react' }))).toBe('cloud') - expect(sessionSource(session({}))).toBe('cloud') - }) -}) - describe('sessionBarQuery', () => { const bar = { days: 7,