Files
clawtap/docs/superpowers/specs/2026-03-24-session-id-unification-design.md
T
kuannnn 42861ea7fa feat: ClawTap v0.1.0 — initial release
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
2026-03-26 10:40:26 +08:00

304 lines
16 KiB
Markdown

# Session ID Unification — CLI UUID as Single Source of Truth
## Problem
The codebase has two session ID systems that create bugs and complexity:
1. **Internal ID** (format `claude-1774300056705` / `codex-1774300056705`): tmux window name, in-memory Map key, DB primary key
2. **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 column `claude_session` (should be `cli_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 needed
- `resolveSessionId()` method -- no translation needed
- `_registerCliUUID()` -- no mapping to maintain (also fixes `claude_session` column 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 `_watcherPending` scanning 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, `sessionAdapterMap` updated
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 ID
- `cli_session` = CLI UUID
After:
- `id` (PRIMARY KEY) = CLI UUID
- `cli_session` column removed
- `window_name` column added (stores old internal ID for tmux display/debug)
**`SessionRow` interface:**
```typescript
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)` -- `id` is CLI UUID
- `sessionsFindByCliSession` -- REMOVED (use primary key lookup)
- `sessionsFindByWindowId` -- unchanged
- `sessionsRemove(id)` -- `id` is 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):
```sql
-- 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:
1. ~~Resolve internal ID via resolveSessionId~~ REMOVED (no translation needed)
2. Register client under CLI UUID
3. Clear push pending notifications (keyed by CLI UUID)
4. Send SESSION_CREATED (single ID)
5. Send cached status
6. Resume if not in memory — **with `hasActiveWindow` guard**:
```
if session not in memory:
if tmux window exists: resumeSession (attach to monitor events)
else: do nothing (just load history from JSONL)
```
7. Sync watcher position
8. Load JSONL history (`adapter.getMessages(sessionId)` — CLI UUID directly, no cliSessionId extraction needed)
9. Send streaming state (SESSION_STATE)
10. Replay pending tools and permissions
11. 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()`: Change `SELECT id FROM sessions` to `SELECT window_name FROM sessions` — the function returns IDs to match against tmux window names, which are `{adapter}-{timestamp}` format (stored in `window_name`, not `id`)
- Line 290: Change `SELECT id, adapter, cli_session, cwd` to `SELECT id, adapter, window_name, cwd` — display CLI UUID as `id`, use `window_name` for tmux matching
- Line 260: Match tmux window names against `window_name` column (not `id`)
- Line 382 `--resume`: Simplified to `WHERE id='${SAFE_ID}'` since `id` is 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
1. Mobile receives desktop events in real-time (same broadcast key)
2. handleReconnect does not create unwanted tmux windows (hasActiveWindow guard)
3. SessionsView uses consistent IDs (both lists pass CLI UUID)
4. No more resolveSessionId translation failures
5. Fixes _registerCliUUID bug (references renamed column)
6. Eliminates ~200 lines of translation/mapping code
7. Push notifications navigate to correct session
8. 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