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 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Eliminate the dual session ID system (internal ID + CLI UUID) and unify on CLI UUID as the single source of truth across the entire codebase.
Architecture: 5 phases -- (1) DB schema migration, (2) adapter internals (both Claude + Codex), (3) session manager + permissions + push, (4) server endpoints + frontend, (5) CLI script + cleanup. Each phase builds on the previous.
Tech Stack: TypeScript, SQLite (better-sqlite3), tmux, React, WebSocket, Shell (bin/codetap)
Spec: docs/superpowers/specs/2026-03-24-session-id-unification-design.md
Phase 1: DB Schema
Task 1: Migrate sessions table -- CLI UUID as primary key
Files:
-
Modify:
server/db.ts -
Step 1: Update SessionRow interface (line 284)
Remove cli_session field, add window_name:
export interface SessionRow {
id: string; // CLI UUID (was internal ID)
cwd: string;
window_id: string | null;
window_name: string | null; // tmux window name for debug
adapter: string;
permission_mode: string;
created_at: string;
last_activity: string;
}
- Step 2: Add schema migration in initDB() (after line 85)
Detect old cli_session column and rebuild table:
const hasCliSession = tableInfo.some((c: any) => c.name === 'cli_session');
const hasWindowName = tableInfo.some((c: any) => c.name === 'window_name');
if (hasCliSession && !hasWindowName) {
d.exec(`
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'))
);
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;
DROP TABLE sessions;
ALTER TABLE sessions_new RENAME TO sessions;
CREATE INDEX IF NOT EXISTS idx_sessions_window ON sessions(window_id);
`);
console.log('[db] Migrated sessions table: CLI UUID as primary key');
}
- Step 3: Update CREATE TABLE for fresh installs (line 20)
CREATE TABLE IF NOT EXISTS sessions (
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 4: Update prepared statements
Replace sessionsUpsert SQL:
INSERT INTO sessions (id, cwd, window_id, window_name, adapter)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
cwd = excluded.cwd,
window_id = excluded.window_id,
window_name = excluded.window_name,
last_activity = datetime('now')
Replace sessionsFindByCliSession with sessionsGet:
sessionsGet: d.prepare('SELECT * FROM sessions WHERE id = ?'),
- Step 5: Update sessions operations export (line 308)
export const sessions = {
upsert(id: string, cwd: string, windowId?: string, windowName?: string, adapter?: string): void {
stmts().sessionsUpsert.run(id, cwd, windowId || null, windowName || null, adapter || 'claude');
},
get(id: string): SessionRow | undefined {
return stmts().sessionsGet.get(id) as SessionRow | undefined;
},
findByWindowId(windowId: string): SessionRow | undefined {
return stmts().sessionsFindByWindowId.get(windowId) as SessionRow | undefined;
},
remove(id: string): void { stmts().sessionsRemove.run(id); },
getAll(): SessionRow[] { return stmts().sessionsGetAll.all() as SessionRow[]; },
clearAll(): void { getDB().exec('DELETE FROM sessions'); },
};
Key: upsert signature changes from (id, cliSession, cwd, windowId, adapter) to (id, cwd, windowId, windowName, adapter). findByCliSession replaced by get (PK lookup).
IMPORTANT — Backward compatibility: To allow each task to compile independently, KEEP the old methods as deprecated aliases alongside the new ones:
/** @deprecated Use get() instead */
findByCliSession(cliSession: string): SessionRow | undefined {
return this.get(cliSession); // After migration, cli_session IS the id
},
/** @deprecated Use upsert(id, cwd, windowId, windowName, adapter) instead */
upsertLegacy(id: string, cliSession: string, cwd: string, windowId?: string, adapter?: string): void {
// During transition: use cliSession as new id if it looks like a UUID, else use id
const effectiveId = (cliSession && cliSession !== id && cliSession.includes('-')) ? cliSession : id;
this.upsert(effectiveId, cwd, windowId, id, adapter);
},
These aliases are removed in Task 8 (cleanup). This allows Tasks 2-5 to compile at each intermediate step.
- Step 6: Wrap migration in transaction
Ensure the migration SQL in Step 2 is wrapped in a transaction:
d.transaction(() => {
d.exec(` ... migration SQL ... `);
})();
- Step 7: Remove old index creation
At lines 94-98 of db.ts, the CREATE INDEX idx_sessions_cli ON sessions(cli_session) must be removed or guarded (column no longer exists after migration).
- Step 8: Commit
git add server/db.ts
git commit -m "refactor: migrate sessions table -- CLI UUID as primary key"
Phase 2: Adapter Internals
Task 2: Unify Claude adapter to CLI UUID
Files:
-
Modify:
server/adapters/claude/tmux-adapter.ts -
Modify:
server/adapters/claude/index.ts -
Modify:
server/adapters/interface.ts -
Step 1: Remove translation infrastructure
In tmux-adapter.ts:
- Remove
cliToSessionId: Map<string, string>field (line 88) - Remove ALL
this.cliToSessionId.set(...)calls - Remove
resolveSessionId()method (lines 912-970) - Remove
_registerCliUUID()(lines 797-805) -- also fixesclaude_sessionbug - Remove
_remapCliSession()(lines 779-785) - Remove
_removeCliMapping()(lines 792-793)
In interface.ts: remove resolveSessionId method from IAdapter base class.
In claude/index.ts: remove resolveSessionId delegation (line 219).
- Step 2: Change sessions Map key to CLI UUID
In startSession():
const sessionId = cliSessionId(waswindowName)this.sessions.set(sessionId, ...)keyed by CLI UUIDdbSessions.upsert(sessionId, cwd, windowId, windowName, 'claude')-- new signature- Return
{ sessionId }-- now CLI UUID
In resumeSession():
const newSessionId = cliUuid(was'claude-${Date.now()}')- Keep
const windowName = 'claude-${Date.now()}'for tmux display this.sessions.set(newSessionId, ...)keyed by CLI UUIDdbSessions.upsert(newSessionId, cwd, windowId, windowName, 'claude')- Return
{ sessionId: newSessionId }
In attachSession(): same pattern -- use CLI UUID as Map key.
In handleSessionStart(): use CLI UUID from hook body directly as Map key.
- Step 3: Update _findWindowForSession (line 986)
Replace window-name matching AND the findByCliSession fallback (line 994) with DB PK lookup:
const dbRow = dbSessions.get(sessionId);
if (dbRow?.window_id) return dbRow.window_id;
Remove windows.find(w => w.name === sessionId) -- no longer matching by window name.
- Step 4: Update all dbSessions.upsert calls to new signature
Enumerate ALL call sites in this file and update each from (id, cliSession, cwd, windowId, adapter) to (id, cwd, windowId, windowName, adapter):
-
Line 136 (startSession)
-
Line 192 (attachSession)
-
Line 239 (resumeSession)
-
Line 572 (handleSessionStart)
-
Line 946 (inside resolveSessionId -- goes away when method is deleted)
-
Step 5: Commit
git add server/adapters/claude/tmux-adapter.ts server/adapters/claude/index.ts server/adapters/interface.ts
git commit -m "refactor: Claude adapter uses CLI UUID as session key"
Task 3: Unify Codex adapter to CLI UUID + _waitForCliUUID
Files:
-
Modify:
server/adapters/codex/codex-tmux-adapter.ts -
Modify:
server/adapters/codex/index.ts -
Modify:
server/adapters/codex/pane-monitor.ts -
Step 1: Remove translation infrastructure
Same as Claude: remove cliToSessionId Map, resolveSessionId(), _removeCliMapping().
In codex/index.ts: remove resolveSessionId delegation.
- Step 2: Add _waitForCliUUID method
New method that polls session.cliSessionId every 500ms (max 15s). When UUID discovered: re-key session in Map, upsert DB, remove temp key. On timeout: kill tmux window, remove temp session, throw error.
- Step 3: Update startSession
Store session under temp windowName key initially. After _waitForReady, call await this._waitForCliUUID(windowName) which returns CLI UUID. Return { sessionId: cliUUID }.
- Step 4: Update resumeSession + handleSessionStart + _watchForTranscript
All use CLI UUID as Map key directly. handleSessionStart and _watchForTranscript set session.cliSessionId (which _waitForCliUUID polls for).
- Step 5: Update all dbSessions.upsert calls to new signature
Enumerate ALL call sites in this file:
-
Line 135 (startSession)
-
Line 189 (resumeSession)
-
Line 337 (handleSessionStart)
-
Line 753 (_watchForTranscript scanOnce lambda)
-
Line 861 (_findAndAttachWindow)
-
Step 6: Commit
git add server/adapters/codex/codex-tmux-adapter.ts server/adapters/codex/index.ts server/adapters/codex/pane-monitor.ts
git commit -m "refactor: Codex adapter uses CLI UUID, add _waitForCliUUID"
Phase 3: Session Manager
Task 4: Unify session-manager to CLI UUID
Files:
-
Modify:
server/session-manager.ts -
Step 1: Remove all resolveSessionId calls
In handleQuery: remove resolution block. sessionId from client is CLI UUID.
In handleReconnect: remove resolution block. Use sessionId directly as effectiveId.
Remove all (adapter as ...).resolveSessionId?.(...) casts.
- Step 2: Simplify sendSessionCreated
Send single sessionId (CLI UUID). Remove cliSessionId field.
- Step 3: Simplify handleReconnect
Preserve all 11 steps. Key changes:
-
Step 6: add
hasActiveWindowguard (prevent creating unwanted tmux windows) -
Step 8: use
sessionIddirectly forgetMessages()(CLI UUID = JSONL key) -
Step 11: use
sessionIddirectly forgetActiveForParent() -
Replace
dbSessions.findByCliSessionwithdbSessions.get -
Remove dynamic
import('./db.js')-- use static import -
Step 4: Simplify triggerPush
Single getSession() call. Use sessionId directly for child review check.
- Step 5: Simplify session-ended handler
sessionId IS CLI UUID. Remove convoluted DB lookup for endedCliId. Use directly for review cascade.
- Step 6: Update dbSessions calls
Replace all dbSessions.findByCliSession(...) with dbSessions.get(...).
- Step 7: Commit
git add server/session-manager.ts
git commit -m "refactor: session-manager uses CLI UUID for broadcast and registration"
Phase 4: Server Endpoints + Frontend
Task 5: Update server/index.ts
Files:
-
Modify:
server/index.ts -
Step 1: Replace dbSessions.findByCliSession with dbSessions.get
All dbSessions.findByCliSession(...) calls become dbSessions.get(...).
- Step 2: Simplify active-sessions client count
Replace getClientCount(s.sessionId) || getClientCount(s.cliSessionId) with getClientCount(s.sessionId).
-
Step 3: Update review endpoints to use dbSessions.get
-
Step 4: Commit
git add server/index.ts
git commit -m "refactor: server endpoints use CLI UUID, remove dual-ID lookups"
Task 6: Unify frontend
Files:
-
Modify:
src/hooks/useChat.ts -
Modify:
src/components/ChatView.tsx -
Modify:
src/hooks/useSessions.ts -
Modify:
src/components/SessionsView.tsx -
Step 1: Merge sessionId + cliSessionId in useChat
Remove cliSessionId state. Keep only sessionId (CLI UUID). Remove setCliSessionId(msg.cliSessionId) from SESSION_CREATED handler. Remove cliSessionId from return.
- Step 2: Update ChatView
Remove cliSessionId from useChat destructuring. Use sessionId for ChatHeader display and review API calls.
- Step 3: Update useSessions
Change s.cliSessionId to s.sessionId in activeSessionIds builder.
- Step 4: Update SessionsView
Change pending[session.cliSessionId] to pending[session.sessionId] for notification badges.
- Step 5: Commit
git add src/hooks/useChat.ts src/components/ChatView.tsx src/hooks/useSessions.ts src/components/SessionsView.tsx
git commit -m "refactor: frontend uses single sessionId (CLI UUID)"
Phase 5: CLI + Cleanup
Task 7: Update bin/codetap
Files:
-
Modify:
bin/codetap -
Step 1: Update get_project_sessions() SQL (line 239)
Change SELECT id FROM sessions to SELECT window_name FROM sessions -- returns tmux window names for matching.
- Step 2: Update -a listing SQL (line 290)
Change to SELECT id, adapter, window_name, cwd FROM sessions WHERE window_name IN (...).
- Step 3: Update --resume SQL (line 382)
Change to WHERE id='${SAFE_ID}' OR window_name='${SAFE_ID}' -- accepts both CLI UUID and window name (backwards compatible for users who may pass old-style IDs).
- Step 4: Commit
git add bin/codetap
git commit -m "refactor: bin/codetap uses new DB schema"
Task 8: Final cleanup -- remove deprecated aliases, verify all files
Files:
-
Modify:
server/db.ts -
Modify:
server/adapters/interface.ts -
Modify:
tests/e2e-spec.feature -
Step 1: Remove deprecated DB method aliases
In server/db.ts, remove findByCliSession and upsertLegacy deprecated aliases added in Task 1. Grep to verify zero remaining callers.
- Step 2: Deprecate ActiveSessionInfo.cliSessionId
In server/adapters/interface.ts, add /** @deprecated Use sessionId instead */ comment.
- Step 3: Verify no-op files from spec
These files are listed in the spec as MODIFY but require no code changes (already use generic string params). Verify each with grep:
-
server/push.ts-- callers now pass CLI UUID. NO CHANGE needed. -
server/permission-manager.ts-- callers now pass CLI UUID. NO CHANGE needed. -
server/types/messages.ts--QueryOptions.sessionIdis generic. NO CHANGE. -
server/types/adapter.ts--SessionInfo.sessionIdalready CLI UUID. NO CHANGE. -
src/lib/ws.ts,src/lib/api.ts,src/sw.ts,src/App.tsx,src/components/FloatingReviewPanel.tsx-- NO CHANGE. -
Step 4: Verify session_stats table
Confirm session_stats table is never written to (no INSERT statements). No migration needed.
- Step 5: Update e2e specs
Remove references to dual-ID system, resolveSessionId, cliSessionId as separate concept.
- Step 6: TypeScript compilation check
npx tsc --noEmit -- zero errors.
- Step 7: Commit
git add -A
git commit -m "refactor: cleanup -- remove deprecated aliases, verify all files, update e2e specs"
Verification
After all tasks:
- Server starts, migration runs without errors
- Open historical session from project list -- history loads immediately (no 30s wait)
- Open session from active list -- connects correctly
- Send message -- goes to correct tmux window
- Desktop opens same session -- mobile receives streaming events in real-time
- Push notification click -- navigates to correct session
- Cross-AI Review -- create, chat, send-back, end -- all work
bin/codetap -a-- lists sessions correctlybin/codetap --resume <UUID>-- resumes correctly- Server restart -- sessions re-discovered, reviews survive