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,