diff --git a/src/cli.tsx b/src/cli.tsx index c7f9403..69e987d 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -4,6 +4,7 @@ import { registerMe } from './commands/me' import { registerSession } from './commands/session' import { registerConfig } from './commands/config' import { registerSandbox } from './commands/sandbox' +import { registerHooks } from './commands/hooks' import { registerTemplate } from './commands/template' import { registerUsage } from './commands/usage' import { registerPing } from './commands/ping' @@ -21,6 +22,7 @@ registerMe(program) registerSession(program) registerConfig(program) registerSandbox(program) +registerHooks(program) registerTemplate(program) registerUsage(program) registerPing(program) diff --git a/src/commands/hooks.ts b/src/commands/hooks.ts new file mode 100644 index 0000000..0856a0a --- /dev/null +++ b/src/commands/hooks.ts @@ -0,0 +1,118 @@ +import type { Command } from 'commander' +import { printJson, printTable, runAction } from '../lib/output' +import { + claudeSettingsPath, + enrollRepo, + enrolledRepos, + hooksInstalled, + installHooks, + repoFromCwd, + unenrollRepo, + uninstallHooks, +} from '../lib/laptop' + +// `agent hooks …` — manage the Claude Code hooks + 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 hooks 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 registerHooks(program: Command): void { + const hooks = program + .command('hooks') + .description('Manage Claude Code hooks + repo enrollment for transcript sync') + + hooks + .command('install') + .description('Install the Stop + 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 — nothing will sync. Run `agent hooks enroll` inside a repo to opt it in.', + ) + } + }), + ) + + hooks + .command('uninstall') + .description('Remove the `agent session sync` hooks (enrollment is kept)') + .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 hook installation + enrolled repositories') + .option('--json', 'print JSON instead of text') + .action(async (opts: { json?: boolean }) => + runAction(async () => { + const installed = hooksInstalled() + const enrolled = enrolledRepos() + if (opts.json) { + printJson({ settings: claudeSettingsPath(), hooks: installed, enrolled_repos: enrolled }) + 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])) + }), + ) + + hooks + .command('enroll [repo]') + .description('Opt a repository (default: the cwd\'s origin) into transcript sync') + .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 hooks 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}.`) + }), + ) +} diff --git a/src/commands/session.tsx b/src/commands/session.tsx index 2c74a95..da44ab0 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -1,6 +1,7 @@ import type { Command } from 'commander' -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { extname } from 'node:path' +import { gzipSync } from 'node:zlib' import { parse as parseYaml } from 'yaml' import { ApiClient } from '../lib/api' import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config' @@ -20,7 +21,21 @@ import type { AgentSessionStatus, ReplayAgentSessionRequest, StartAgentSessionRequest, + SyncAgentSessionRequest, } from '../lib/types' +import { + branchFromCwd, + createWipCommit, + dropSpooledSync, + enrolledRepos, + listSpooledSyncs, + pushHandoffRef, + redactLine, + repoFromCwd, + spoolSync, +} from '../lib/laptop' +import { ApiError } from '../lib/api' +import { resolveToken } from '../lib/config' // Poll cadence for the `--watch` REST fallback (used only when live WebSocket // streaming is unavailable). Not user-configurable — the fallback is rare. @@ -267,6 +282,85 @@ 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 the instructions as its prompt — never a + // literal `claude --resume` of the local session. + session + .command('handoff ') + .description('Hand the current repo + a synced session off to a cloud agent') + .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 ( + instructions: string, + opts: { parent: string; cwd?: string; json?: boolean }, + ) => { + await runAction(async () => { + 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 api = new ApiClient() + const session = await api.startAgentSession({ + handoff: { parent_session_id: opts.parent, repo, sha, ref }, + prompt: instructions, + }) + if (opts.json) { + printJson(session) + return + } + console.log(`✓ started handoff session ${session.id} (${session.status})`) + await printSessionUrl(api, 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 hooks 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. + session + .command('sync') + .description('Sync a Claude Code transcript to Ellipsis (invoked by CC hooks)') + .option('--transcript ', 'transcript JSONL path (default: from hook stdin)') + .option('--session-id ', 'Claude Code session id (default: from hook stdin)') + .option('--reason ', 'stop | 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) + }) + }, + ) + session .command('stop ') .description('Stop an in-flight session (POST /v1/sessions/{id}/stop)') @@ -484,3 +578,135 @@ 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 quit = (message: string): void => { + if (!hookMode) throw new Error(message) + } + + 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' + if (!ccSessionId || !transcriptPath) { + return quit('need --session-id and --transcript (or hook JSON on stdin)') + } + + // Consent gate: per-repo opt-in, silently skipped otherwise. + const repo = repoFromCwd(cwd) + if (!repo || !enrolledRepos().includes(repo.toLowerCase())) { + return quit(`repository ${repo ?? `at ${cwd}`} is not enrolled (agent hooks enroll)`) + } + if (!resolveToken()) { + return quit('not logged in. Run `agent login` first, or set ELLIPSIS_API_TOKEN.') + } + if (!existsSync(transcriptPath)) { + return quit(`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(`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 api = new ApiClient() + try { + const res = await api.syncAgentSession(req) + 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) + if (!hookMode) throw err + return + } + // Permanent rejection (auth, validation, payload too large): never spool. + if (!hookMode) throw err + 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 api.syncAgentSession(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/api.ts b/src/lib/api.ts index 6497a7a..27f63a0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -16,6 +16,8 @@ import type { ReplayAgentSessionRequest, SandboxVariableInput, SandboxVariableSummary, + SyncAgentSessionRequest, + SyncAgentSessionResponse, SavedAgentConfig, StartAgentSessionRequest, UsageDashboard, @@ -117,6 +119,10 @@ export class ApiClient { return this.request('GET', `/v1/sessions/${encodeURIComponent(sessionId)}`) } + syncAgentSession(req: SyncAgentSessionRequest): Promise { + return this.request('POST', '/v1/sessions/sync', req) + } + replayAgentSession(sessionId: string, req: ReplayAgentSessionRequest): Promise { return this.request( 'POST', diff --git a/src/lib/config.ts b/src/lib/config.ts index 076eadf..1c23eef 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -17,6 +17,10 @@ function configFile(): string { export interface CliConfig { token?: string apiBase?: string + // Repositories ("owner/name") enrolled for laptop transcript sync — the + // per-repo opt-in consent gate for `agent session sync`. A sync whose cwd + // resolves to a repo outside this set is a silent no-op. + enrolledRepos?: string[] } export function loadConfig(): CliConfig { diff --git a/src/lib/laptop.ts b/src/lib/laptop.ts new file mode 100644 index 0000000..7578864 --- /dev/null +++ b/src/lib/laptop.ts @@ -0,0 +1,296 @@ +// Laptop transcript sync plumbing (`agent hooks …` + `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 /v1/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, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { loadConfig, saveConfig } 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 (loadConfig().enrolledRepos ?? []).map((r) => r.toLowerCase()) +} + +export function enrollRepo(repo: string): void { + const config = loadConfig() + const set = new Set((config.enrolledRepos ?? []).map((r) => r.toLowerCase())) + set.add(repo.toLowerCase()) + saveConfig({ ...config, enrolledRepos: [...set].sort() }) +} + +export function unenrollRepo(repo: string): void { + const config = loadConfig() + const next = (config.enrolledRepos ?? []).filter( + (r) => r.toLowerCase() !== repo.toLowerCase(), + ) + saveConfig({ ...config, enrolledRepos: next }) +} + +// --------------------------------------------------------------------------- +// 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( + process.env.ELLIPSIS_CONFIG_DIR ?? join(homedir(), '.config', 'ellipsis'), + '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. + } +} + +// --------------------------------------------------------------------------- +// 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/types.ts b/src/lib/types.ts index 6a56862..e328ee0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -76,7 +76,15 @@ export interface UsageDashboard { // ----------------------------- agent sessions ---------------------------- -export type AgentSessionSource = 'react' | 'manual' | 'api' | 'cli' | 'mention' | 'cron' +export type AgentSessionSource = + | 'react' + | 'manual' + | 'api' + | 'cli' + | 'mention' + | 'cron' + // A Claude Code session ingested from a developer laptop via `agent session sync`. + | 'laptop' export type AgentSessionStatus = | 'scheduled' @@ -128,10 +136,23 @@ export type AgentConfig = Record // --------------------------- request / response ------------------------- +// Laptop -> cloud handoff params: start a fresh session on the built-in +// handoff config, chained to the handed-off session (parent_kind=handoff). +// Mutually exclusive with config_id / config / template_id. +export interface HandoffAgentSessionParams { + parent_session_id: string + repo: string + // The WIP commit pushed to refs/ellipsis/handoff/ — the sandbox + // checkout target. + sha: string + ref?: string +} + export interface StartAgentSessionRequest { config_id?: string config?: AgentConfig template_id?: string + handoff?: HandoffAgentSessionParams // No `source`: the server derives a session's provenance from the credential // (a user token => `cli`), so it can't be spoofed by the request body. metadata?: Record @@ -159,6 +180,30 @@ export interface ReplayAgentSessionRequest { prompt?: string } +// One hook-driven transcript sync from this laptop (POST /v1/sessions/sync). +// The transcript is redacted client-side, gzipped, then base64-encoded. +export interface SyncAgentSessionRequest { + cc_session_id: string + transcript_gzip_b64: string + // Which Claude Code hook fired the sync: Stop (mid-session, once per turn) + // or SessionEnd (the process terminated). + reason: 'stop' | 'session_end' + // The enrolled repository ("owner/name", from the cwd's git remote), the + // cwd, and the checked-out branch — laptop-side context for the session row. + repo?: string + cwd?: string + git_branch?: string +} + +export interface SyncAgentSessionResponse { + session_id: string + process_id: string + event_count: number + // False when the server already stored a snapshot at least this long + // (longest-snapshot-wins) — acknowledged, nothing written. Still success. + accepted: boolean +} + export interface ListAgentSessionsResponse { sessions: AgentSession[] }