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
18 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: Unify session ID management across all adapters — single storage (SQLite), adapter-prefixed internal IDs, CLI UUID in chat header, and real-time session discovery via API-based SessionStart hook.
Architecture: Bottom-up: DB schema migration → server adapter changes (Claude, Codex) → session-manager protocol update → client UI → CLI script → E2E spec updates. Each task produces a committable, non-breaking state.
Tech Stack: TypeScript, SQLite (better-sqlite3), React, Bash (CLI), Gherkin (E2E specs)
Spec: docs/superpowers/specs/2026-03-23-session-id-unification-design.md
File Structure
| File | Action | Responsibility |
|---|---|---|
server/db.ts |
Modify | Schema migration, rename columns, add clearAll(), remove session-map migration |
server/config.ts |
Modify | Remove sessionMap path |
server/index.ts |
Modify | Call clearAll() on shutdown |
server/adapters/interface.ts |
Modify | Add adapter, rename claudeSessionId → cliSessionId in ActiveSessionInfo |
server/adapters/claude/hook-config.ts |
Modify | SessionStart → fireAndForget |
server/adapters/claude/index.ts |
Modify | Add session-start hook route |
server/adapters/claude/tmux-adapter.ts |
Modify | claude- prefix, remove desktop-, add handleSessionStart, update resolveSessionId recovery |
server/adapters/codex/codex-tmux-adapter.ts |
Modify | codex- prefix, remove desktop-, align with Claude pattern |
server/adapters/codex/index.ts |
Verify | Ensure session-start hook route exists |
server/session-manager.ts |
Modify | SESSION_CREATED includes cliSessionId |
src/hooks/useChat.ts |
Modify | Store cliSessionId from SESSION_CREATED |
src/components/ChatView.tsx |
Modify | Header shows CLI UUID (primary) + internal ID (secondary) |
bin/codetap |
Modify | --adapter flag, window naming, resume/continue logic, -a/-A display |
bin/codetap-hook |
Delete | Replaced by API POST |
tests/e2e-spec.feature |
Modify | 9 scenario updates for new session ID architecture |
Task 1: DB Schema Migration
Files:
-
Modify:
server/db.ts:19-29(CREATE TABLE),server/db.ts:105-130(prepared statements),server/db.ts:252-287(operations) -
Step 1: Update CREATE TABLE for fresh installs (line 19-29)
Change claude_session → cli_session, add adapter, remove is_active:
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
cli_session TEXT NOT NULL,
adapter TEXT DEFAULT 'claude',
cwd TEXT NOT NULL,
window_id TEXT,
permission_mode TEXT DEFAULT 'default',
created_at TEXT DEFAULT (datetime('now')),
last_activity TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_sessions_cli ON sessions(cli_session);
CREATE INDEX IF NOT EXISTS idx_sessions_adapter ON sessions(adapter);
- Step 2: Add migration logic after CREATE TABLE block
After line 59, add migration for existing databases:
try {
const tableInfo = d.prepare("PRAGMA table_info('sessions')").all() as { name: string }[];
const hasOldColumn = tableInfo.some(c => c.name === 'claude_session');
const hasNewColumn = tableInfo.some(c => c.name === 'cli_session');
const hasAdapter = tableInfo.some(c => c.name === 'adapter');
if (hasOldColumn && !hasNewColumn) {
d.exec(`ALTER TABLE sessions RENAME COLUMN claude_session TO cli_session`);
console.log('[db] Migrated: claude_session → cli_session');
}
if (!hasAdapter) {
d.exec(`ALTER TABLE sessions ADD COLUMN adapter TEXT DEFAULT 'claude'`);
d.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_adapter ON sessions(adapter)`);
console.log('[db] Migrated: added adapter column');
}
} catch (e) {
console.warn('[db] Migration check:', (e as Error).message);
}
- Step 3: Update
SessionRowinterface (line 252-261)
export interface SessionRow {
id: string;
cli_session: string;
adapter: string;
cwd: string;
window_id: string | null;
permission_mode: string;
created_at: string;
last_activity: string;
}
- Step 4: Update prepared statements (lines 105-130)
All SQL: claude_session → cli_session, remove is_active references. Add adapter to upsert. Rename sessionsFindByClaudeSession → sessionsFindByCliSession. Change sessionsRemove from UPDATE SET is_active=0 to DELETE. Remove sessionsCleanupStale.
- Step 5: Update
sessionsoperations object (line 263-287)
export const sessions = {
upsert(id: string, cliSession: string, cwd: string, windowId?: string, adapter?: string): void {
stmts().sessionsUpsert.run(id, cliSession, cwd, windowId ?? null, adapter ?? 'claude');
},
findByCliSession(cliSession: string): SessionRow | undefined {
return stmts().sessionsFindByCliSession.get(cliSession) 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'); },
};
- Step 6: Remove session-map.json migration (lines 196-219)
Delete the session-map section of migrateJsonToSqlite. Keep the push-subscriptions migration. Remove SessionMapJsonEntry interface.
- Step 7: Commit
git add server/db.ts
git commit -m "refactor: migrate session DB schema — cli_session, adapter column, remove is_active"
Task 2: Remove sessionMap from config + add clearAll() to shutdown
Files:
-
Modify:
server/config.ts:18,58 -
Modify:
server/index.ts:245-253 -
Step 1: Remove
sessionMapfrom config
In AppConfig.paths (line 18), remove sessionMap: string;.
In loadConfig() (line 58), remove sessionMap: path.join(CODETAP_DIR, 'session-map.json'),.
- Step 2: Add
sessions.clearAll()to shutdown
In shutdown() (line 245-253), before closeDB():
import { sessions as dbSessions } from './db.js';
// ...
dbSessions.clearAll();
closeDB();
- Step 3: Commit
git add server/config.ts server/index.ts
git commit -m "refactor: remove sessionMap config, clear sessions on shutdown"
Task 3: Update ActiveSessionInfo — rename claudeSessionId → cliSessionId
Files:
-
Modify:
server/adapters/interface.ts:18-28 -
Modify: all files referencing
claudeSessionId -
Step 1: Update interface
In ActiveSessionInfo (line 18-28), rename claudeSessionId → cliSessionId, add adapter:
export interface ActiveSessionInfo {
sessionId: string;
cwd: string;
cliSessionId: string;
adapter: string;
permissionMode: string;
lastActivity: number | null;
hasClients: boolean;
hasDesktop: boolean;
isNonInteractive: boolean;
firstPrompt: string | null;
}
- Step 2: Find and fix all
claudeSessionIdreferences
grep -rn 'claudeSessionId' server/ src/ --include='*.ts' --include='*.tsx'
Replace claudeSessionId → cliSessionId in ALL files:
server/index.ts(active-sessions endpoint)server/session-manager.ts(push notifications, pending sessions)server/adapters/claude/tmux-adapter.ts(SessionStateinterface field,getActiveSessions,_createSession, all usages)server/adapters/codex/codex-tmux-adapter.ts(same:SessionStatefield → rename tocliSessionId)src/hooks/useSessions.ts(activeSessionIds set)src/components/SessionsView.tsx(pending badge)
Note: The SessionState interfaces in both adapter files have a claudeSessionId / codexSessionId field that stores the CLI UUID. Rename both to cliSessionId for consistency across adapters.
- Step 3: Commit
git add -A
git commit -m "refactor: rename claudeSessionId → cliSessionId across codebase"
Task 4: Claude adapter — SessionStart hook + internal ID format
Files:
-
Modify:
server/adapters/claude/hook-config.ts:174,197 -
Modify:
server/adapters/claude/index.ts:113-147 -
Modify:
server/adapters/claude/tmux-adapter.ts:130,858 -
Step 1: SessionStart hook →
fireAndForget(hook-config.ts)
Line 197: change hookPath to fireAndForget('session-start').
Remove hookPath from _hookIdentifiers() (line 174). Update _isOurHookEntry to only check portTag.
- Step 2: Add
session-startroute (index.ts)
After line 147, add:
hookRoute(`${prefix}/session-start`, (body) => {
this._tmux.handleSessionStart(body);
});
- Step 3: Add
handleSessionStartmethod (tmux-adapter.ts)
New method. Algorithm:
async handleSessionStart(body: HookBody): Promise<void> {
const cliUuid = body.session_id;
if (!cliUuid) return;
// 1. Already known? (idempotent — safe if hook fires twice)
const cached = this.claudeToSessionId.get(cliUuid);
if (cached && this.sessions.has(cached)) {
this.sessions.get(cached)!.lastActivity = Date.now();
return;
}
const windows = await tmuxManager.listWindows();
const cwd = body.cwd || process.cwd();
// 2. Recovery: check DB for original internal ID (non-graceful restart)
const dbRow = dbSessions.findByCliSession(cliUuid);
if (dbRow?.window_id && windows.some(w => w.id === dbRow.window_id)) {
const sessionId = dbRow.id; // Restore ORIGINAL internal ID
if (!this.sessions.has(sessionId)) {
this.sessions.set(sessionId, this._createSession(dbRow.window_id, cwd, cliUuid, dbRow.permission_mode || 'default'));
this._startMonitor(sessionId, dbRow.window_id);
this._ensureWatcher(sessionId);
}
this.claudeToSessionId.set(cliUuid, sessionId);
return;
}
// 3. New session: find unmanaged tmux window with claude- prefix
// The hook body doesn't contain the window name, but the tmux window
// was created by bin/codetap with name "claude-{timestamp}".
// We find the first claude-* window that isn't already managed.
for (const w of windows) {
if (w.name.startsWith('claude-') && !this.sessions.has(w.name)) {
const alreadyManaged = [...this.sessions.values()].some(s => s.windowId === w.id);
if (!alreadyManaged) {
const sessionId = w.name;
this.sessions.set(sessionId, this._createSession(w.id, cwd, cliUuid, 'default'));
this.claudeToSessionId.set(cliUuid, sessionId);
dbSessions.upsert(sessionId, cliUuid, cwd, w.id, 'claude');
this._startMonitor(sessionId, w.id);
this._ensureWatcher(sessionId);
this.emit('session-discovered', sessionId);
return;
}
}
}
}
- Step 4: Change
startSessionID format (tmux-adapter.ts:130)
const windowName = `claude-${Date.now()}`;
Update dbSessions.upsert to pass 'claude' as adapter.
- Step 5: Update
resolveSessionId— removedesktop-prefix entirely (tmux-adapter.ts:858)
The desktop- prefix logic is no longer needed. Change line 858 from:
const sessionId = `desktop-${claudeSessionId.slice(0, 8)}`;
to:
const sessionId = dbRow.id; // Restore original internal ID from DB (e.g., claude-1774210269126)
The DB row's id field will now always be in {adapter}-{timestamp} format. No new ID is generated — we reuse what was stored.
-
Step 6: Rename
findByClaudeSession→findByCliSessionin all calls -
Step 7: Update
getActiveSessions— addadapter: 'claude', rename field -
Step 8: Commit
git add server/adapters/claude/
git commit -m "feat: Claude adapter — session-start API hook, claude- prefix, remove desktop-"
Task 5: Codex adapter — align with unified schema
Files:
-
Modify:
server/adapters/codex/codex-tmux-adapter.ts:121,242 -
Modify:
server/adapters/codex/index.ts -
Step 1: Change
startSessionID tocodex-prefix (line 121) -
Step 2: Remove
desktop-inhandleSessionStart(line 242) — use DB original ID -
Step 3: Update
getActiveSessions— addadapter: 'codex', rename field -
Step 4: Rename
findByClaudeSession→findByCliSessionin all calls -
Step 5: Commit
git add server/adapters/codex/
git commit -m "feat: Codex adapter — codex- prefix, remove desktop-, align with unified schema"
Task 6: SESSION_CREATED includes cliSessionId
Files:
-
Modify:
server/session-manager.ts:198,266 -
Step 1: Update
handleQuerySESSION_CREATED (line 198)
// After Task 3 rename, SessionState.claudeSessionId → cliSessionId
const sessionObj = adapter.getSession(handle.sessionId) as { cliSessionId?: string } | null;
send(conn, {
type: WS.SESSION_CREATED,
sessionId: handle.sessionId,
cliSessionId: sessionObj?.cliSessionId || handle.sessionId,
});
- Step 2: Update
handleReconnectSESSION_CREATED (line 266)
Same pattern — cast getSession() result and read cliSessionId.
- Step 3: Commit
git add server/session-manager.ts
git commit -m "feat: SESSION_CREATED includes cliSessionId for chat header"
Task 7: Client — store cliSessionId + update chat header
Files:
-
Modify:
src/hooks/useChat.ts:95,143 -
Modify:
src/components/ChatView.tsx:54-88,230 -
Step 1: Add
cliSessionIdstate in useChat (line 95)
const [cliSessionId, setCliSessionId] = useState<string | null>(null);
Update SESSION_CREATED handler (line 143):
case WS.SESSION_CREATED:
setSessionId(msg.sessionId);
if (msg.cliSessionId) setCliSessionId(msg.cliSessionId);
break;
Add cliSessionId to the returned object.
- Step 2: Update ChatHeader component (ChatView.tsx:54-88)
Accept cliSessionId prop. Display CLI UUID as primary (truncated, with copy), internal ID as secondary line below.
- Step 3: Update ChatHeader usage (ChatView.tsx:230)
<ChatHeader sessionId={sessionId || initialSessionId} cliSessionId={cliSessionId} cwd={cwd} />
- Step 4: Commit
git add src/hooks/useChat.ts src/components/ChatView.tsx
git commit -m "feat: chat header shows CLI UUID (primary) + internal ID (secondary)"
Task 8: CLI — --adapter flag + window naming + enhanced display
Files:
-
Modify:
bin/codetap -
Delete:
bin/codetap-hook -
Step 1: Add
--adapterflag parsing
Insert before the resume mode section (around line 304). This parses --adapter from anywhere in the args:
# --- Parse --adapter flag ---
ADAPTER="claude"
ADAPTER_CMD="claude"
prev_arg=""
for arg in "$@"; do
if [ "$prev_arg" = "--adapter" ]; then
case "$arg" in
claude) ADAPTER="claude"; ADAPTER_CMD="claude" ;;
codex) ADAPTER="codex"; ADAPTER_CMD="codex" ;;
*) echo "Unknown adapter: $arg"; exit 1 ;;
esac
fi
prev_arg="$arg"
done
# Strip --adapter and its value from positional args
CLEANED_ARGS=()
skip_next=false
for arg in "$@"; do
if $skip_next; then skip_next=false; continue; fi
if [ "$arg" = "--adapter" ]; then skip_next=true; continue; fi
CLEANED_ARGS+=("$arg")
done
set -- "${CLEANED_ARGS[@]}"
- Step 2: Update window naming and commands
Replace the resume/continue/new block (lines 305-316):
if [ "$1" = "--resume" ] && [ -n "$2" ]; then
WINDOW_NAME="$2"
COMMAND="$ADAPTER_CMD $YOLO --resume $2"
shift 2
elif [ "$1" = "--continue" ]; then
WINDOW_NAME="${ADAPTER}-$(date +%s)"
case "$ADAPTER" in
claude) COMMAND="$ADAPTER_CMD $YOLO --continue" ;;
codex) COMMAND="$ADAPTER_CMD resume --last" ;;
*) COMMAND="$ADAPTER_CMD --continue" ;;
esac
shift
else
WINDOW_NAME="${ADAPTER}-$(date +%s)"
COMMAND="$ADAPTER_CMD $YOLO $*"
fi
- Step 3: Enhance
-a/-Adisplay
Update the session listing loop to query the server API for UUID:
# Fetch session details from running server
SESSION_DATA=$(curl -sf $CURL_OPTS \
"$PROTOCOL://127.0.0.1:$PORT/api/active-sessions" 2>/dev/null)
# In the listing loop, extract UUID per window name:
UUID=$(echo "$SESSION_DATA" | python3 -c "
import json, sys
try:
for s in json.load(sys.stdin):
if s.get('sessionId') == '$NAME':
print(s.get('cliSessionId', '')); break
except: pass
" 2>/dev/null)
echo " $i) $NAME"
[ -n "$UUID" ] && echo " UUID: $UUID"
-
Step 4: Delete
bin/codetap-hook -
Step 5: Commit
git add bin/codetap
git rm bin/codetap-hook
git commit -m "feat: CLI — --adapter flag, adapter-prefixed windows, enhanced display"
Task 9: Update E2E Specs
Files:
-
Modify:
tests/e2e-spec.feature -
Step 1: Chat header display (L247) — CLI UUID primary + internal ID secondary
-
Step 2: CLI
--adapterscenarios (after L1238) —codetap new --adapter codex -
Step 3:
-a/-Adisplay format (L1212) — UUID + internal ID -
Step 4: Remove session-map.json refs (L1308) — DB-based recovery
-
Step 5: Session Dedup regression (L1829) — updated root cause
-
Step 6: SessionStart hook scenario — API POST flow
-
Step 7: tmux window naming (L1176) —
{adapter}-{timestamp}format -
Step 8: Non-graceful restart recovery (after L1325) — restore from DB
-
Step 9: Active session card UUID (L1548) — clarify display locations
-
Step 10: Commit
git add tests/e2e-spec.feature
git commit -m "test: update E2E specs for session ID unification"
Task 10: End-to-End Verification
- Step 1: Build and start server
npm run build && CLAUDE_UI_PASSWORD=test npx tsx server/index.ts
Verify: No migration errors in console.
- Step 2: Web UI — new session
Open CodeTap → New → send message.
Verify: Header shows CLI UUID (primary) + claude-{timestamp} (secondary).
- Step 3: Active tab — no duplicates
Verify: Only 1 session, no duplicates. Connect button works.
- Step 4: CLI — codetap new
Verify: tmux window named claude-{timestamp}, session appears immediately in Active tab.
- Step 5: Server shutdown
Verify: codetap stop clears sessions table and kills tmux windows.