diff --git a/README.md b/README.md index b2b317c..ec04c0c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # rses -**Cross-resume between Claude Code, Codex CLI, and OpenCode.** -Pick up where one AI coding agent left off — in another. +**Cross-resume between Claude Code, Codex CLI, OpenCode, and Droid.** +Pick up where one AI coding agent left off - in another. ``` rses claude with codex --last @@ -9,7 +9,7 @@ rses claude with codex --last That's it. Claude launches with full context from your last Codex session: the original task, git diff, conversation history, and a pointer to the session file for deep-dive. -Works in all 6 directions between Claude Code, Codex CLI, and OpenCode. +Works in all 12 directions between Claude Code, Codex CLI, OpenCode, and Droid. ## Install @@ -31,6 +31,10 @@ rses codex with claude --last # OpenCode works too — any combination. rses opencode with codex --last rses claude with opencode --last + +# Droid sessions are supported too. +rses codex with droid --last +rses droid with claude --last ``` ## What it does @@ -53,6 +57,7 @@ rses with [session-id] [flags] rses claude with codex --last # most recent Codex session rses codex with claude ses_46f04b499ffe... # specific Claude session rses opencode with codex # interactive picker +rses droid with codex --last # most recent Droid session rses claude with codex --last --dry-run # print handoff, don't launch ``` @@ -82,6 +87,7 @@ Power-user shorthand — type less, ship faster: | `cc`, `cl`, `c` | `claude` | | `cdx`, `cx`, `x` | `codex` | | `oc`, `o` | `opencode` | +| `d`, `dr` | `droid` | | `w` | `with` | ```bash @@ -114,13 +120,14 @@ rses opencode with claude --last --provider anthropic | Claude Code | `~/.claude/transcripts/ses_*.jsonl` | Reads only `user`/`assistant` types | | Codex CLI | `~/.codex/state_*.sqlite` (auto-discovers version) + JSONL fallback | Handles both 2025 and 2026 schemas | | OpenCode | `~/.local/share/opencode/opencode.db` | Single JOIN query, reads stable columns only | +| Droid | `~/.factory/sessions/**/*.jsonl` | Reads session metadata plus user/assistant turns, strips system reminders | All parsers are read-only and wrapped in try/catch — if a tool changes its format, rses degrades gracefully instead of crashing. ## Requirements - **Node.js 22+** (for built-in `node:sqlite`) -- At least one of: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [OpenCode](https://github.com/opencode-ai/opencode) +- At least one of: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [OpenCode](https://github.com/opencode-ai/opencode), [Droid](https://docs.factory.ai/cli/getting-started/overview) - macOS or Linux (Windows support planned) ## License diff --git a/bin/rses.js b/bin/rses.js index cf0497c..700338f 100755 --- a/bin/rses.js +++ b/bin/rses.js @@ -18,16 +18,21 @@ import { parseOpenCodeSession, findOpenCodeSessionById, getLastOpenCodeSession, queryOpenCodeSessions } from '../src/parse-opencode.js' +import { + parseDroidSession, findDroidSessionById, getLastDroidSession, + queryDroidSessions +} from '../src/parse-droid.js' import { buildHandoff } from '../src/build-handoff.js' import { launchWithHandoff } from '../src/launch.js' import { lsSessions } from '../src/ls.js' import { pick } from '../src/picker.js' -const VALID_TOOLS = new Set(['claude', 'codex', 'opencode']) +const VALID_TOOLS = new Set(['claude', 'codex', 'opencode', 'droid']) const ALIASES = { cc: 'claude', cl: 'claude', c: 'claude', cdx: 'codex', cx: 'codex', x: 'codex', oc: 'opencode', o: 'opencode', + d: 'droid', dr: 'droid', w: 'with', } function resolve_alias(s) { return ALIASES[s] || s } @@ -35,12 +40,13 @@ const RESUME_HINTS = { claude: ' claude --resume', codex: ' codex resume', opencode: ' opencode (select session from built-in picker)', + droid: ' droid --resume', } program .name('rses') .version('0.1.0') - .description('Cross-resume between Claude Code, Codex CLI, and OpenCode sessions') + .description('Cross-resume between Claude Code, Codex CLI, OpenCode, and Droid sessions') program .command('export ') @@ -49,7 +55,7 @@ program .action((rawSource, id, opts) => { const source = resolve_alias(rawSource) if (!VALID_TOOLS.has(source)) { - console.error(`Unknown source: ${source}. Use 'claude', 'codex', or 'opencode'.`) + console.error(`Unknown source: ${source}. Use 'claude', 'codex', 'opencode', or 'droid'.`) process.exit(1) } const handoff = resolveAndBuild(source, id, { ...opts, last: false }) @@ -58,14 +64,14 @@ program program .command('ls [tool]') - .description('List recent sessions (claude, codex, opencode, or all)') + .description('List recent sessions (claude, codex, opencode, droid, or all)') .option('--dir ', 'Filter by working directory') .action((rawTool, opts) => { const tool = rawTool ? resolve_alias(rawTool) : null - const tools = tool ? [tool] : ['codex', 'claude', 'opencode'] + const tools = tool ? [tool] : ['codex', 'claude', 'opencode', 'droid'] for (const t of tools) { if (!VALID_TOOLS.has(t)) { - console.error(`Unknown tool: ${t}. Use 'claude', 'codex', or 'opencode'.`) + console.error(`Unknown tool: ${t}. Use 'claude', 'codex', 'opencode', or 'droid'.`) process.exit(1) } lsSessions(t, opts.dir ? expandPath(opts.dir) : null) @@ -181,6 +187,21 @@ async function pickSession(source, filterDir) { return pick(items, 'Select an OpenCode session:') + } else if (source === 'droid') { + const rows = queryDroidSessions({ limit: 30, filterDir }) || [] + if (!rows.length) return null + + const items = rows.map(r => { + const date = new Date(r.updatedAt).toISOString().slice(0, 16).replace('T', ' ') + const cwd = shorten(r.cwd) + return { + display: makeDisplay(date, cwd, r.title || basename(r.path, '.jsonl')), + value: r.path, + } + }) + + return pick(items, 'Select a Droid session:') + } else { const files = findClaudeSessions(filterDir) if (!files.length) return null @@ -285,8 +306,32 @@ async function resolveAndBuildAsync(source, id, opts) { process.exit(1) } + } else if (source === 'droid') { + if (opts.last) { + filePath = getLastDroidSession(filterDir) + if (!filePath) { + console.error('No Droid sessions found' + (filterDir ? ` in ${filterDir}` : '') + '.') + process.exit(1) + } + } else if (id) { + filePath = findDroidSessionById(id) + if (!filePath) { + console.error(`Droid session not found: ${id}`) + console.error('Tip: run `rses ls droid` to list sessions.') + process.exit(1) + } + } else { + filePath = await pickSession('droid', filterDir) + if (!filePath) { console.error('Cancelled.'); process.exit(0) } + } + + try { parsed = parseDroidSession(filePath); parsed.filePath = filePath } catch (e) { + console.error(`Failed to parse Droid session: ${e.message}`) + process.exit(1) + } + } else { - console.error(`Unknown source tool: ${source}. Use 'claude', 'codex', or 'opencode'.`) + console.error(`Unknown source tool: ${source}. Use 'claude', 'codex', 'opencode', or 'droid'.`) process.exit(1) } @@ -315,8 +360,13 @@ function resolveAndBuild(source, id, opts) { const sessionId = opts.last ? getLastOpenCodeSession(filterDir) : findOpenCodeSessionById(id) if (!sessionId) { console.error(`OpenCode session not found: ${id}`); process.exit(1) } parsed = parseOpenCodeSession(sessionId) + } else if (source === 'droid') { + if (!id && !opts.last) { console.error('export requires a session ID.'); process.exit(1) } + filePath = opts.last ? getLastDroidSession(filterDir) : findDroidSessionById(id) + if (!filePath) { console.error(`Droid session not found: ${id}`); process.exit(1) } + parsed = parseDroidSession(filePath); parsed.filePath = filePath } else { - console.error(`Unknown source: ${source}. Use 'claude', 'codex', or 'opencode'.`) + console.error(`Unknown source: ${source}. Use 'claude', 'codex', 'opencode', or 'droid'.`) process.exit(1) } @@ -326,10 +376,10 @@ function resolveAndBuild(source, id, opts) { async function runHandoff(target, source, id, opts) { if (!VALID_TOOLS.has(target)) { - console.error(`Unknown target: ${target}. Use 'claude', 'codex', or 'opencode'.`); process.exit(1) + console.error(`Unknown target: ${target}. Use 'claude', 'codex', 'opencode', or 'droid'.`); process.exit(1) } if (!VALID_TOOLS.has(source)) { - console.error(`Unknown source: ${source}. Use 'claude', 'codex', or 'opencode'.`); process.exit(1) + console.error(`Unknown source: ${source}. Use 'claude', 'codex', 'opencode', or 'droid'.`); process.exit(1) } if (target === source) { console.error(`Same source and target (${target}). Use the native resume:`) diff --git a/package.json b/package.json index 906c34e..728232f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "rses-cli", "version": "0.1.0", - "description": "Cross-resume between Claude Code, Codex CLI, and OpenCode sessions", + "description": "Cross-resume between Claude Code, Codex CLI, OpenCode, and Droid sessions", "bin": { "rses": "./bin/rses.js" }, @@ -23,6 +23,6 @@ "engines": { "node": ">=22" }, - "keywords": ["claude", "codex", "opencode", "ai", "cli", "resume", "session", "handoff"], + "keywords": ["claude", "codex", "opencode", "droid", "ai", "cli", "resume", "session", "handoff"], "license": "MIT" } diff --git a/src/build-handoff.js b/src/build-handoff.js index 0a3120b..1b24ad0 100644 --- a/src/build-handoff.js +++ b/src/build-handoff.js @@ -14,7 +14,7 @@ function trunc(str, max) { export function buildHandoff(source, parsed) { const { cwd, uuid, sessionId, startCommit, branch: parsedBranch, task, turns, filePath } = parsed const id = uuid || sessionId || 'unknown' - const TOOL_NAMES = { codex: 'Codex', claude: 'Claude', opencode: 'OpenCode' } + const TOOL_NAMES = { codex: 'Codex', claude: 'Claude', opencode: 'OpenCode', droid: 'Droid' } const toolName = TOOL_NAMES[source] || source const git = cwd ? getGitContext(cwd, startCommit) : null diff --git a/src/launch.js b/src/launch.js index af790a8..b2158da 100644 --- a/src/launch.js +++ b/src/launch.js @@ -1,7 +1,7 @@ import { spawn } from 'child_process' export function launchWithHandoff(tool, handoff, cwd, passthroughArgs = []) { - // opencode uses `opencode run `, claude/codex accept prompt as bare arg + // opencode uses `opencode run `, claude/codex/droid accept prompt as bare arg const args = tool === 'opencode' ? ['run', ...passthroughArgs, handoff] : [...passthroughArgs, handoff] @@ -20,6 +20,7 @@ export function launchWithHandoff(tool, handoff, cwd, passthroughArgs = []) { claude: ' Install: npm i -g @anthropic-ai/claude-code', codex: ' Install: npm i -g @openai/codex', opencode: ' Install: see https://github.com/opencode-ai/opencode', + droid: ' Install: see https://docs.factory.ai/cli/getting-started/overview', } console.error(`\nError: '${tool}' not found on PATH. Is it installed?`) console.error(installHints[tool] || ` Install ${tool} and ensure it's on your PATH.`) diff --git a/src/ls.js b/src/ls.js index f30c9e7..f3543e1 100644 --- a/src/ls.js +++ b/src/ls.js @@ -1,6 +1,7 @@ import { findCodexSessions, queryCodexSessions } from './parse-codex.js' import { findClaudeSessions } from './parse-claude.js' import { queryOpenCodeSessions } from './parse-opencode.js' +import { queryDroidSessions } from './parse-droid.js' import { readFileSync } from 'fs' import { basename } from 'path' @@ -106,9 +107,21 @@ export function lsSessions(tool, filterDir = null) { } } + if (tool === 'droid') { + const dbRows = queryDroidSessions({ limit: 20, filterDir }) + if (dbRows?.length) { + rows = dbRows.map(r => ({ + id: r.id, + date: formatDate(r.updatedAt), + cwd: r.cwd || '—', + task: (r.title || '(no title)').slice(0, 70), + })) + } + } + if (!rows) { - if (tool === 'opencode') { - console.log('No opencode sessions found.') + if (tool === 'opencode' || tool === 'droid') { + console.log(`No ${tool} sessions found.`) return } diff --git a/src/parse-droid.js b/src/parse-droid.js new file mode 100644 index 0000000..65ebd14 --- /dev/null +++ b/src/parse-droid.js @@ -0,0 +1,145 @@ +import { readFileSync, readdirSync, statSync } from 'fs' +import { join, basename } from 'path' +import { homedir } from 'os' + +const SESSIONS_DIR = join(homedir(), '.factory', 'sessions') + +function walkDroidSessions(dir) { + const results = [] + let entries + try { entries = readdirSync(dir) } catch { return results } + + for (const entry of entries) { + const full = join(dir, entry) + let stat + try { stat = statSync(full) } catch { continue } + + if (stat.isDirectory()) { + results.push(...walkDroidSessions(full)) + } else if (entry.endsWith('.jsonl')) { + results.push({ path: full, mtime: stat.mtimeMs }) + } + } + + return results +} + +function readSessionStart(filePath) { + try { + const firstLine = readFileSync(filePath, 'utf8') + .split('\n') + .find(l => l.trim()) + if (!firstLine) return null + const obj = JSON.parse(firstLine) + if (obj.type !== 'session_start') return null + return obj + } catch { + return null + } +} + +function isInDir(cwd, filterDir) { + return cwd === filterDir || cwd.startsWith(filterDir + '/') +} + +function stripSystemReminders(text) { + return text + .replace(/[\s\S]*?<\/system-reminder>/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +function extractText(content, stripReminders = false) { + if (typeof content === 'string') { + return stripReminders ? stripSystemReminders(content) : content.trim() + } + if (!Array.isArray(content)) return '' + + const text = content + .filter(c => c?.type === 'text') + .map(c => c.text || '') + .join('\n') + .trim() + + return stripReminders ? stripSystemReminders(text) : text +} + +export function queryDroidSessions({ limit = 30, filterDir = null } = {}) { + const all = walkDroidSessions(SESSIONS_DIR).sort((a, b) => b.mtime - a.mtime) + const rows = [] + + for (const { path, mtime } of all) { + const meta = readSessionStart(path) + const cwd = meta?.cwd || null + if (filterDir && (!cwd || !isInDir(cwd, filterDir))) continue + + rows.push({ + id: meta?.id || basename(path, '.jsonl'), + title: (meta?.sessionTitle || meta?.title || '').trim(), + cwd, + updatedAt: mtime, + path, + }) + + if (rows.length >= limit) break + } + + return rows +} + +export function findDroidSessionById(id) { + const all = walkDroidSessions(SESSIONS_DIR) + + const normalized = id.endsWith('.jsonl') ? id.slice(0, -6) : id + const exact = all.find(({ path }) => basename(path, '.jsonl') === normalized) + if (exact) return exact.path + + return all.find(({ path }) => basename(path).includes(normalized))?.path || null +} + +export function getLastDroidSession(filterDir = null) { + const rows = queryDroidSessions({ limit: 1, filterDir }) + return rows[0]?.path || null +} + +export function parseDroidSession(filePath) { + const raw = readFileSync(filePath, 'utf8') + const lines = raw.split('\n').filter(l => l.trim()) + + if (!lines.length) throw new Error('Empty session file') + + let sessionId = basename(filePath, '.jsonl') + let cwd = null + let title = '' + const turns = [] + + for (const line of lines) { + let obj + try { obj = JSON.parse(line) } catch { continue } + + if (obj.type === 'session_start') { + sessionId = obj.id || sessionId + cwd = obj.cwd || cwd + title = obj.sessionTitle || obj.title || title + continue + } + + if (obj.type !== 'message' || !obj.message) continue + + const role = obj.message.role + if (role !== 'user' && role !== 'assistant') continue + + const text = extractText(obj.message.content, role === 'user') + if (text) turns.push({ role, text }) + } + + const taskTurn = turns.find(t => t.role === 'user') + + return { + sessionId, + cwd, + startCommit: null, + task: taskTurn?.text || title || '', + turns, + } +}