Skip to content

Add abode terminal hub: tmux-backed Claude Code sessions for the June 15 billing split#347

Open
brendanlong wants to merge 6 commits into
mainfrom
claude/fac14010-7ead-4c6c-90fd-90fbb34d18dd
Open

Add abode terminal hub: tmux-backed Claude Code sessions for the June 15 billing split#347
brendanlong wants to merge 6 commits into
mainfrom
claude/fac14010-7ead-4c6c-90fd-90fbb34d18dd

Conversation

@brendanlong

Copy link
Copy Markdown
Owner

Why

On June 15 the Agent SDK and claude -p move 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 (or bin/abode) starts a terminal hub that shares the existing database, settings, and workspace layout with the web app:

  • Session list with tmux liveness (●/○), attach, stop, stop & archive (with confirm), archived list
  • Attach = full-screen handover to the interactive Claude TUI in tmux (status bar off, mouse on). F12 or Ctrl-b d detaches back to the hub, which asks: keep running in background / reattach / stop / stop & archive
  • New session flow: searchable repo picker (favorites first, No Repository supported), branch picker, optional issue picker that prefills name + initial prompt (shared generateIssuePrompt), prompt editing in $EDITOR
  • Settings editing for global + per-repo scopes via $EDITOR on a zod-validated JSON document — secrets decrypted for editing and re-encrypted on save
  • Everything injected at launch from the DB (model resolution, layered system prompt, decrypted env vars via tmux -e, generated --mcp-config in the workspace dir) — nothing is ever written into the repo clone
  • Restart-safe: sessions relaunch with claude --resume <sessionId> if Claude exited or the host rebooted (Session.id doubles as the Claude session UUID)

Refactors

  • buildSystemPrompt/DEFAULT_SYSTEM_PROMPT extracted to SDK-free system-prompt.ts
  • GitHub repo/branch/issue listing moved from the tRPC router into the github service (router now maps GitHubApiErrorTRPCError)
  • NO_REPO_SENTINEL centralized in src/lib/types.ts
  • generateIssuePrompt extracted from the new-session page into src/lib/issue-prompt.ts

Testing

  • Unit tests: claude argv/MCP-config/env builders, shell quoting (round-tripped through a real sh), settings-doc encrypt/decrypt round-trips, editor JSON parsing
  • Integration tests: real tmux on a dedicated test socket (create/list/exact-match/kill, cwd + env injection)
  • Manual E2E with a stub claude binary 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 workspace
  • pnpm test:run (411 passed), pnpm test:integration (145 passed), typecheck and lint clean (one pre-existing warning)

Notes

  • tmux ignores -e PATH=... (login shell resets it), so ABODE_CLAUDE_BIN overrides the claude binary path when needed
  • TUI messages aren't persisted to the Message table; history lives in Claude Code's transcript (used by --resume) and tmux scrollback
  • The web app is untouched and still works for settings or metered sessions

🤖 Generated with Claude Code

claude added 5 commits June 11, 2026 12:10
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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/cli/settings.ts Outdated
Comment on lines +101 to +115
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 }),
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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 });
});

Comment thread src/cli/sessions.ts Outdated
Comment on lines +177 to +179
// Resume the existing Claude conversation unless the session never got
// past creation (no conversation to resume yet).
await launchClaudeInTmux(session, { resume: session.status !== 'creating' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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'.

Suggested change
// 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' });

Comment thread src/cli/tmux.ts Outdated
args.push('-e', `${key}=${value}`);
}

args.push(shellQuote(options.command));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
args.push(shellQuote(options.command));
args.push('--', ...options.command);

Comment thread src/cli/index.ts
Comment on lines +82 to +86
if (action === 'archive') {
await archiveAfterConfirm(session);
} else {
await stopCliSession(session);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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'.

Suggested change
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);
}

Comment thread src/cli/editor.ts Outdated

try {
while (true) {
const result = spawnSync(editor, [filePath], { stdio: 'inherit' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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>
@brendanlong

Copy link
Copy Markdown
Owner Author

Addressed all review feedback in 8ae0424:

  • tmux argv (verified empirically on tmux 3.6): multi-argument commands are exec'd directly without a shell, so the command is now passed as -- ...argv and shellQuote is deleted entirely. Added an integration test that round-trips tricky arguments (quotes, $HOME, backticks, spaces) through a real tmux session.
  • Repo settings save now uses an interactive transaction so the parent upsert rolls back together with the env var / MCP server replacement.
  • error-status sessions no longer launch with --resume (nothing to resume).
  • Declining the archive confirm after Claude exited now marks the session stopped instead of leaving it running.
  • $EDITOR with arguments (code --wait, nano -w) is now split into binary + args.

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 set-option calls ran. Server option setup is now best-effort — for real sessions Claude keeps the server alive, and if the command already exited there's nothing left to configure. Ran the integration suite 5x locally to confirm it's stable.

— Claude

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants