Add abode terminal hub: tmux-backed Claude Code sessions for the June 15 billing split#347
Add abode terminal hub: tmux-backed Claude Code sessions for the June 15 billing split#347brendanlong wants to merge 6 commits into
Conversation
Allows the upcoming abode CLI to reuse settings merging without importing the Agent SDK through claude-runner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pure helpers that turn DB-backed session settings into the interactive claude argv, MCP config file, env overlay, and tmux-safe shell quoting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dedicated tmux server on the 'abode' socket: detached session creation with cwd/env injection, exact-name matching, attach/kill helpers, and full-screen handover options (status off, F12 to detach, mouse on). Integration-tested against real tmux on a test socket. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- src/cli/sessions.ts: create/attach/stop/archive sessions backed by tmux-hosted interactive Claude Code, reusing worktree-manager and settings-merger against the same database - Move repo/branch/issue listing from the github router into the github service so the CLI can reuse it; router now maps GitHubApiError to TRPCError - Extract generateIssuePrompt into src/lib/issue-prompt.ts shared by the new-session page and the CLI Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A tmux-backed terminal UI for managing Claude Code sessions over SSH (e.g. Termux on a phone), sharing the web app's database, settings, and workspace layout. Sessions run the interactive Claude Code TUI, which stays on subscription billing after the June 15 Agent SDK billing split. - Hub: session list with tmux liveness, attach/detach (F12), stop, stop & archive, new-session flow with repo/branch/issue pickers - All config injected at launch from the DB (model, system prompt, env vars, MCP servers) — nothing written into repo clones - Settings editing via $EDITOR on zod-validated JSON docs with secret decrypt/re-encrypt round-trip - bin/abode wrapper + pnpm abode script Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a terminal-based CLI hub called 'abode' to manage interactive Claude Code sessions inside dedicated tmux instances, allowing users to list, attach, stop, and archive sessions, as well as edit global and per-repository settings using their local text editor. The feedback highlights several critical improvements: ensuring database consistency by wrapping repository settings upserts inside the transaction, preventing resume failures on errored sessions, avoiding shell-specific quoting issues in tmux by passing arguments directly, correcting session status tracking when archiving is canceled, and splitting the editor command string to handle arguments correctly.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const settings = await prisma.repoSettings.upsert({ | ||
| where: { repoFullName }, | ||
| create: { repoFullName, ...settingsData }, | ||
| update: settingsData, | ||
| }); | ||
|
|
||
| const envVarRows = doc.envVars.map((ev) => envVarDocToDb(ev, settings.id)); | ||
| const mcpServerRows = doc.mcpServers.map((server) => mcpServerDocToDb(server, settings.id)); | ||
|
|
||
| await prisma.$transaction([ | ||
| prisma.envVar.deleteMany({ where: { repoSettingsId: settings.id } }), | ||
| prisma.envVar.createMany({ data: envVarRows }), | ||
| prisma.mcpServer.deleteMany({ where: { repoSettingsId: settings.id } }), | ||
| prisma.mcpServer.createMany({ data: mcpServerRows }), | ||
| ]); |
There was a problem hiding this comment.
The upsert operation is executed outside of the database transaction. If the subsequent transaction (which deletes and recreates the environment variables and MCP servers) fails, the parent repoSettings record will still be updated or created, leaving the database in an inconsistent state.
Using Prisma's interactive transaction API ensures that all dependent operations are executed atomically and rolled back together if any part fails.
| const settings = await prisma.repoSettings.upsert({ | |
| where: { repoFullName }, | |
| create: { repoFullName, ...settingsData }, | |
| update: settingsData, | |
| }); | |
| const envVarRows = doc.envVars.map((ev) => envVarDocToDb(ev, settings.id)); | |
| const mcpServerRows = doc.mcpServers.map((server) => mcpServerDocToDb(server, settings.id)); | |
| await prisma.$transaction([ | |
| prisma.envVar.deleteMany({ where: { repoSettingsId: settings.id } }), | |
| prisma.envVar.createMany({ data: envVarRows }), | |
| prisma.mcpServer.deleteMany({ where: { repoSettingsId: settings.id } }), | |
| prisma.mcpServer.createMany({ data: mcpServerRows }), | |
| ]); | |
| await prisma.$transaction(async (tx) => { | |
| const settings = await tx.repoSettings.upsert({ | |
| where: { repoFullName }, | |
| create: { repoFullName, ...settingsData }, | |
| update: settingsData, | |
| }); | |
| const envVarRows = doc.envVars.map((ev) => envVarDocToDb(ev, settings.id)); | |
| const mcpServerRows = doc.mcpServers.map((server) => mcpServerDocToDb(server, settings.id)); | |
| await tx.envVar.deleteMany({ where: { repoSettingsId: settings.id } }); | |
| await tx.envVar.createMany({ data: envVarRows }); | |
| await tx.mcpServer.deleteMany({ where: { repoSettingsId: settings.id } }); | |
| await tx.mcpServer.createMany({ data: mcpServerRows }); | |
| }); |
| // Resume the existing Claude conversation unless the session never got | ||
| // past creation (no conversation to resume yet). | ||
| await launchClaudeInTmux(session, { resume: session.status !== 'creating' }); |
There was a problem hiding this comment.
If a session's status is 'error', it means the session failed during its initial creation (e.g., during repository cloning or workspace setup). In this scenario, no Claude session was ever successfully started, so there is no existing conversation to resume. Attempting to attach to an 'error' session will try to run claude --resume <sessionId>, which will fail.
We should only attempt to resume the session if the status is neither 'creating' nor 'error'.
| // Resume the existing Claude conversation unless the session never got | |
| // past creation (no conversation to resume yet). | |
| await launchClaudeInTmux(session, { resume: session.status !== 'creating' }); | |
| // Resume the existing Claude conversation unless the session never got | |
| // past creation (no conversation to resume yet). | |
| await launchClaudeInTmux(session, { resume: session.status !== 'creating' && session.status !== 'error' }); |
| args.push('-e', `${key}=${value}`); | ||
| } | ||
|
|
||
| args.push(shellQuote(options.command)); |
There was a problem hiding this comment.
Passing the command as a single shell-quoted string forces tmux to execute it via the user's default shell (e.g., /bin/sh -c "..."). If the user's default shell is non-Bourne compatible (such as fish or tcsh), the single-quote escaping syntax used in shellQuote will cause syntax errors and fail to execute.
Passing the command arguments directly to tmux new-session as multiple arguments allows tmux to execute them directly via execvp without invoking a shell, completely bypassing shell-specific quoting issues.
| args.push(shellQuote(options.command)); | |
| args.push('--', ...options.command); |
| if (action === 'archive') { | ||
| await archiveAfterConfirm(session); | ||
| } else { | ||
| await stopCliSession(session); | ||
| } |
There was a problem hiding this comment.
If Claude exited and the user selects "Archive it" but subsequently declines the confirmation prompt (i.e., archiveAfterConfirm returns false), the session is neither archived nor stopped. This leaves the session's database status as 'running' indefinitely, even though the backing tmux session is dead.
If the user declines the archive confirmation, we should fall back to stopping the session so its database status is correctly updated to 'stopped'.
| if (action === 'archive') { | |
| await archiveAfterConfirm(session); | |
| } else { | |
| await stopCliSession(session); | |
| } | |
| if (action === 'archive') { | |
| const archived = await archiveAfterConfirm(session); | |
| if (!archived) { | |
| await stopCliSession(session); | |
| } | |
| } else { | |
| await stopCliSession(session); | |
| } |
|
|
||
| try { | ||
| while (true) { | ||
| const result = spawnSync(editor, [filePath], { stdio: 'inherit' }); |
There was a problem hiding this comment.
If the user's $EDITOR or $VISUAL environment variable contains arguments (for example, EDITOR="code --wait" or EDITOR="nano -w"), passing the entire string directly to spawnSync will fail with ENOENT because Node.js will look for an executable named exactly "code --wait".
Splitting the editor string by whitespace allows us to separate the executable binary from its arguments, ensuring compatibility with common editor configurations.
const [editorBin, ...editorArgs] = editor.split(/\s+/);
const result = spawnSync(editorBin, [...editorArgs, filePath], { stdio: 'inherit' });- Pass the session command to tmux as direct argv (execvp, no shell) and delete the now-unused shellQuote; add an integration test covering argument-boundary preservation through tmux - Make post-create tmux server option setup best-effort so a command that exits immediately (CI test race) doesn't fail session creation - Wrap repo settings save in an interactive transaction so the parent upsert rolls back with the env var / MCP server replacement - Don't pass --resume for sessions that errored during creation - Mark a session stopped when the user declines the archive confirm after Claude exited - Support $EDITOR values with arguments (e.g. "code --wait") Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed all review feedback in 8ae0424:
The CI integration failure was a race in the new tmux test: a fast-exiting command shut down the tmux server (exit-empty) before the post-create — Claude |
Why
On June 15 the Agent SDK and
claude -pmove to a separate, API-rate credit pool; only the interactive Claude Code TUI stays on subscription limits. This PR adds a terminal-first way to run clawed-abode sessions that keeps usage in the interactive pool, designed for SSH/Termux use from a phone.What
pnpm abode(orbin/abode) starts a terminal hub that shares the existing database, settings, and workspace layout with the web app:generateIssuePrompt), prompt editing in$EDITOR$EDITORon a zod-validated JSON document — secrets decrypted for editing and re-encrypted on savetmux -e, generated--mcp-configin the workspace dir) — nothing is ever written into the repo cloneclaude --resume <sessionId>if Claude exited or the host rebooted (Session.iddoubles as the Claude session UUID)Refactors
buildSystemPrompt/DEFAULT_SYSTEM_PROMPTextracted to SDK-freesystem-prompt.tsGitHubApiError→TRPCError)NO_REPO_SENTINELcentralized insrc/lib/types.tsgenerateIssuePromptextracted from the new-session page intosrc/lib/issue-prompt.tsTesting
sh), settings-doc encrypt/decrypt round-trips, editor JSON parsingclaudebinary verified the full create→launch→archive flow: correct argv (session ID, model, layered system prompt incl.__no_repo__custom prompt, initial prompt), cwd, DB env vars in the tmux session, and archive killing tmux + removing the workspacepnpm test:run(411 passed),pnpm test:integration(145 passed), typecheck and lint clean (one pre-existing warning)Notes
-e PATH=...(login shell resets it), soABODE_CLAUDE_BINoverrides the claude binary path when neededMessagetable; history lives in Claude Code's transcript (used by--resume) and tmux scrollback🤖 Generated with Claude Code