Multi-adapter mobile UI for AI coding assistants. Supports Claude Code, Codex CLI, and Gemini CLI through one interface. Features: - Real-time bidirectional sync via tmux + WebSocket - Cross-AI review (send one AI's output to another for review) - Multi-review tabs with minimize/expand - Push notifications (PWA) with smart session-aware filtering - Three-channel event system (hooks, file watcher, pane monitor) - Voice input, image paste, draft persistence - Terminal-native design (JetBrains Mono, dark theme, pixel art claw) - CLI with --adapter flag on every command - Zero-overhead fire-and-forget hooks
16 KiB
Session ID Unification — CLI UUID as Single Source of Truth
Problem
The codebase has two session ID systems that create bugs and complexity:
- Internal ID (format
claude-1774300056705/codex-1774300056705): tmux window name, in-memory Map key, DB primary key - CLI UUID (format
d6d56787-bfaf-4312-ae4d-99683ba45459): permanent ID from the CLI tool, JSONL filename
These require constant translation via resolveSessionId() and cliToSessionId Maps. When translation fails:
- Mobile can not receive desktop events (registered under CLI UUID, events broadcast under internal ID)
- handleReconnect creates unwanted tmux windows (lost hasActiveWindow guard)
- SessionsView passes different ID types (project list = CLI UUID, active list = internal ID)
- Latent bug:
_registerCliUUID()references renamed columnclaude_session(should becli_session)
Solution
Eliminate internal ID as a session identifier. Use CLI UUID everywhere. Internal ID becomes just a tmux window display name with no programmatic significance.
Design
Layer 1: Adapter Internals
Files: server/adapters/claude/tmux-adapter.ts, server/adapters/codex/codex-tmux-adapter.ts, server/adapters/codex/pane-monitor.ts, server/adapters/interface.ts
this.sessions Map key: internal ID changes to CLI UUID.
Eliminated entirely:
cliToSessionId: Map<string, string>-- no translation neededresolveSessionId()method -- no translation needed_registerCliUUID()-- no mapping to maintain (also fixesclaude_sessioncolumn bug)_remapCliSession()-- no remapping needed_removeCliMapping()-- no mapping to clean up
startSession() returns CLI UUID. The tmux window name remains {adapter}-{timestamp} for tmux display but is not used as an identifier.
resumeSession() / attachSession() take CLI UUID as parameter.
All event emits use CLI UUID as first argument.
getActiveSessions() returns CLI UUID as sessionId. The cliSessionId field kept for compatibility (same value).
_findWindowForSession() looks up window_id from DB by CLI UUID instead of matching tmux window names.
handleSessionStart() pattern matching: w.name.startsWith('claude-') still works because tmux window names retain the {adapter}-{timestamp} format.
Codex startSession() UUID timing: Codex CLI generates its own UUID (arrives via SessionStart hook or JSONL filename). Add _waitForCliUUID() that waits for session.cliSessionId to be populated (max 15 seconds). Session initially stored under a temporary key (the window name), re-keyed once UUID is known.
During the temp-key window:
- Hooks arriving from CLI use the
_watcherPendingscanning pattern (already exists) to find the session - Events are emitted under the temp key (few clients would be registered under it yet)
- Once UUID arrives: session is re-keyed in the Map, DB is upserted,
sessionAdapterMapupdated
If _waitForCliUUID times out:
- Kill the tmux window (
tmuxManager.killWindow) - Remove the temp session from the Map
- Throw error (propagates to client as WS error message)
Codex CodexPaneMonitor: stores sessionId for event emission -- changes from internal ID to CLI UUID.
Layer 2: DB Schema
Files: server/db.ts
sessions table migration:
Before:
id(PRIMARY KEY) = internal IDcli_session= CLI UUID
After:
id(PRIMARY KEY) = CLI UUIDcli_sessioncolumn removedwindow_namecolumn added (stores old internal ID for tmux display/debug)
SessionRow interface:
export interface SessionRow {
id: string; // CLI UUID (was internal ID)
cwd: string;
window_id: string | null; // tmux window ID (@N)
window_name: string | null; // tmux window name for debug
adapter: string;
permission_mode: string;
created_at: string;
last_activity: string;
}
Prepared statements:
sessionsUpsert(id=cliUUID, cwd, windowId, windowName, adapter)--idis CLI UUIDsessionsFindByCliSession-- REMOVED (use primary key lookup)sessionsFindByWindowId-- unchangedsessionsRemove(id)--idis now CLI UUID- Add
sessionsGet(id)-- simple primary key lookup
session_reviews table: No changes (already uses CLI UUIDs).
session_stats table: Verify session_id uses CLI UUID. Migrate if needed.
Migration SQL (SQLite table rebuild pattern):
-- Step 1: Create new table with new schema
CREATE TABLE IF NOT EXISTS sessions_new (
id TEXT PRIMARY KEY,
cwd TEXT NOT NULL,
window_id TEXT,
window_name TEXT,
adapter TEXT DEFAULT 'claude',
permission_mode TEXT DEFAULT 'default',
created_at TEXT DEFAULT (datetime('now')),
last_activity TEXT DEFAULT (datetime('now'))
);
-- Step 2: Copy data, swapping id and cli_session
-- Skip rows where cli_session is empty, equals the internal ID, or matches {adapter}-{timestamp} pattern
INSERT OR IGNORE INTO sessions_new (id, cwd, window_id, window_name, adapter, permission_mode, created_at, last_activity)
SELECT
CASE
WHEN cli_session IS NOT NULL AND cli_session != '' AND cli_session != id THEN cli_session
ELSE id
END,
cwd, window_id, id, adapter, permission_mode, created_at, last_activity
FROM sessions;
-- Step 3: Drop old table and rename
DROP TABLE sessions;
ALTER TABLE sessions_new RENAME TO sessions;
-- Step 4: Recreate indexes
CREATE INDEX IF NOT EXISTS idx_sessions_window ON sessions(window_id);
Migration is wrapped in a transaction and runs inside initDB() before any adapter initialization. Detection: check if the old cli_session column exists (PRAGMA table_info(sessions)).
Handling rows where cli_session = internal ID: Some Codex sessions store the internal ID as both id and cli_session (when UUID wasn't yet known). The migration uses CASE WHEN cli_session != id THEN cli_session ELSE id END — these rows keep their internal ID as id. They will be orphaned (no JSONL match) and cleaned up naturally by clearAll() on next shutdown.
session_stats table: This table exists in the schema but is never written to by any code in the codebase (no INSERT statements found). It can be left as-is or dropped. No migration needed.
Layer 3: Session Manager
Files: server/session-manager.ts
sessionClients Map key: CLI UUID (was internal ID).
sessionAdapterMap Map key: CLI UUID (was internal ID).
broadcast(sessionId, message): sessionId is CLI UUID. This is the core fix -- adapter events emitted with CLI UUID now match client registration keys.
sendSessionCreated(): Sends single sessionId (CLI UUID). Remove cliSessionId field.
handleQuery(): No resolveSessionId call. options.sessionId from client is CLI UUID directly.
handleReconnect(): Greatly simplified. No resolveSessionId needed. All 11 existing steps preserved:
Resolve internal ID via resolveSessionIdREMOVED (no translation needed)- Register client under CLI UUID
- Clear push pending notifications (keyed by CLI UUID)
- Send SESSION_CREATED (single ID)
- Send cached status
- Resume if not in memory — with
hasActiveWindowguard:if session not in memory: if tmux window exists: resumeSession (attach to monitor events) else: do nothing (just load history from JSONL) - Sync watcher position
- Load JSONL history (
adapter.getMessages(sessionId)— CLI UUID directly, no cliSessionId extraction needed) - Send streaming state (SESSION_STATE)
- Replay pending tools and permissions
- Restore active child reviews (
sessionReviews.getActiveForParent(sessionId)— CLI UUID directly)
Key simplification in step 11: no longer needs findByCliSession to look up parent CWD for child session resume — can use sessions.get(sessionId) primary key lookup directly (since id is now CLI UUID).
triggerPush(): Simplified — sessionId IS CLI UUID, so child review check uses sessionId directly (no need to look up sessionObj.cliSessionId). Single getSession() call replaces the current two calls with different casts. Uses CLI UUID for sessionClients lookup, push pending, and push payload.
session-ended handler: sessionId parameter IS CLI UUID. The entire convoluted lookup (findByWindowId fallback + getAll().find()) to extract cli_session is eliminated — sessionId is already the CLI UUID. Cascade cleanup calls sessionReviews.getActiveForParent(sessionId) directly. sessionClients.delete and sessionAdapterMap.delete remain synchronous (before any async cascade work).
server/index.ts active sessions endpoint: The dual lookup getClientCount(s.sessionId) || getClientCount(s.cliSessionId) simplifies to getClientCount(s.sessionId) since both are the same CLI UUID.
broadcastReviewStarted/Ended: parentSessionId is CLI UUID.
Layer 4: Frontend
Files: src/hooks/useChat.ts, src/lib/ws.ts, src/components/ChatView.tsx, src/components/SessionsView.tsx, src/components/FloatingReviewPanel.tsx, src/lib/api.ts
useChat.ts: Merge sessionId and cliSessionId states into single sessionId (always CLI UUID). SESSION_CREATED handler sets one state. All outgoing WS messages (QUERY, ABORT, RECONNECT, SET_MODEL, SET_PERMISSION_MODE, PLAN_RESPONSE) send this single sessionId.
ws.ts: activeSessionId stores CLI UUID from SESSION_CREATED.
ChatView.tsx: Header shows single sessionId (CLI UUID). Review API calls use sessionId directly. Remove cliSessionId from ChatHeader props.
SessionsView.tsx: Both session lists (project + active) now use CLI UUID for session.sessionId. onOpenChat(session.sessionId) is consistent. destroySession(session.sessionId) passes CLI UUID. Push pending lookup pending[session.sessionId] uses CLI UUID.
FloatingReviewPanel.tsx: childSessionId prop is CLI UUID.
ActiveSessionInfo interface: sessionId becomes CLI UUID. cliSessionId kept as deprecated alias (same value).
Layer 5: Push Notifications + Permissions
Files: server/push.ts, server/permission-manager.ts, src/sw.ts, src/App.tsx
push.ts: pendingSessions Map keyed by CLI UUID.
permission-manager.ts: PendingPermission.sessionId is CLI UUID. sessionPendingIds Map keyed by CLI UUID.
sw.ts: Push payload sessionId is CLI UUID. Notification click URL /?session=${cliUUID}.
App.tsx: URL parameter ?session= is CLI UUID. Service worker OPEN_SESSION message carries CLI UUID.
Layer 6: CLI (bin/codetap)
File: bin/codetap
DB queries updated to use new schema (no cli_session column, id is CLI UUID):
- Line 239
get_project_sessions(): ChangeSELECT id FROM sessionstoSELECT window_name FROM sessions— the function returns IDs to match against tmux window names, which are{adapter}-{timestamp}format (stored inwindow_name, notid) - Line 290: Change
SELECT id, adapter, cli_session, cwdtoSELECT id, adapter, window_name, cwd— display CLI UUID asid, usewindow_namefor tmux matching - Line 260: Match tmux window names against
window_namecolumn (notid) - Line 382
--resume: Simplified toWHERE id='${SAFE_ID}'sinceidis now CLI UUID - Window name generation unchanged (
{adapter}-{timestamp})
Critical: The -a listing mode joins tmux window names against DB session IDs. After migration, this join key changes from id to window_name. The IN (...) SQL clause must query window_name column.
Codex UUID Discovery Flow
1. handleQuery() -> adapter.startSession(cwd, options)
2. Codex startSession():
a. Generate window name "codex-{timestamp}"
b. Create tmux window
c. Wait for CLI ready (_waitForReady)
d. Start _watchForTranscript() (sets up FSWatcher)
e. NEW: _waitForCliUUID() -- wait for session.cliSessionId to be populated
- Source 1: handleSessionStart hook fires with session_id
- Source 2: _watchForTranscript detects JSONL file, extracts UUID from filename
f. Once UUID known: set as Map key, upsert DB
g. Return { sessionId: cliUUID }
3. handleQuery() continues with cliUUID
4. registerClient, sendSessionCreated, sendMessage -- all use cliUUID
Timeout: 15 seconds. If UUID not discovered, startSession fails with error.
Files Affected (Complete List)
| File | Change Type | Summary |
|---|---|---|
server/db.ts |
MODIFY | Schema migration, remove cli_session, add window_name, update SessionRow |
server/session-manager.ts |
MODIFY | All Map keys to CLI UUID, simplify handleReconnect, fix triggerPush |
server/adapters/claude/tmux-adapter.ts |
MODIFY | sessions Map key, eliminate cliToSessionId/resolveSessionId, events use CLI UUID |
server/adapters/codex/codex-tmux-adapter.ts |
MODIFY | Same as Claude adapter, add _waitForCliUUID |
server/adapters/codex/pane-monitor.ts |
MODIFY | sessionId is CLI UUID |
server/adapters/interface.ts |
MODIFY | Remove resolveSessionId, update ActiveSessionInfo |
server/adapters/claude/index.ts |
MODIFY | Remove resolveSessionId delegation |
server/adapters/codex/index.ts |
MODIFY | Remove resolveSessionId delegation |
server/push.ts |
MODIFY | pendingSessions keyed by CLI UUID |
server/permission-manager.ts |
MODIFY | All session indices use CLI UUID |
server/transport/client-connection.ts |
NO CHANGE | sessionId field semantics change (now CLI UUID) |
server/index.ts |
MODIFY | Remove dual-ID lookups in active-sessions, simplify review endpoints |
server/types/messages.ts |
MODIFY | QueryOptions.sessionId is CLI UUID |
server/types/adapter.ts |
MODIFY | SessionInfo.sessionId is CLI UUID (already was) |
server/ws-types.ts |
NO CHANGE | Just message type constants |
src/hooks/useChat.ts |
MODIFY | Merge sessionId + cliSessionId into one |
src/lib/ws.ts |
MODIFY | activeSessionId stores CLI UUID |
src/lib/api.ts |
MODIFY | destroySession passes CLI UUID |
src/components/ChatView.tsx |
MODIFY | Single ID in header, remove cliSessionId usage |
src/components/SessionsView.tsx |
MODIFY | Consistent CLI UUID for both lists |
src/components/FloatingReviewPanel.tsx |
MODIFY | childSessionId is CLI UUID |
src/sw.ts |
MODIFY | Push sessionId is CLI UUID |
src/App.tsx |
MODIFY | URL param and SW message use CLI UUID |
bin/codetap |
MODIFY | DB queries use new schema |
server/adapters/claude/jsonl-store.ts |
NO CHANGE | Already uses CLI UUID |
server/adapters/codex/jsonl-store.ts |
NO CHANGE | Already uses CLI UUID |
server/adapters/claude/pane-monitor.ts |
NO CHANGE | Uses windowId (tmux ID), not session ID |
server/adapters/claude/hook-config.ts |
NO CHANGE | No session IDs |
server/adapters/codex/hook-config.ts |
NO CHANGE | No session IDs |
server/adapters/registry.ts |
NO CHANGE | No session IDs |
server/config.ts |
NO CHANGE | No session IDs |
server/stores/jsonl-watcher.ts |
NO CHANGE | File path based |
src/hooks/useSessions.ts |
MODIFY | s.cliSessionId for green dots → s.sessionId (same value after unification) |
tests/e2e-spec.feature |
MODIFY | Remove references to resolveSessionId, cliSessionId dual-ID |
What This Fixes
- Mobile receives desktop events in real-time (same broadcast key)
- handleReconnect does not create unwanted tmux windows (hasActiveWindow guard)
- SessionsView uses consistent IDs (both lists pass CLI UUID)
- No more resolveSessionId translation failures
- Fixes _registerCliUUID bug (references renamed column)
- Eliminates ~200 lines of translation/mapping code
- Push notifications navigate to correct session
- Active session client count lookup simplified (no dual-ID check)
Scope Boundaries
NOT included in this refactor:
- Changing tmux window names (they remain
{adapter}-{timestamp}for display) - Changing JSONL file paths (already CLI UUID based)
- Changing the Cross-AI Review feature (already uses CLI UUIDs)
- Auto N-round debate or other deferred features