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
This commit is contained in:
kuannnn
2026-03-18 10:24:45 +08:00
commit 42861ea7fa
151 changed files with 33897 additions and 0 deletions
@@ -0,0 +1,563 @@
# 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`:
```sql
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:
```typescript
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 `SessionRow` interface (line 252-261)**
```typescript
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 `sessions` operations object (line 263-287)**
```typescript
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**
```bash
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 `sessionMap` from 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()`:
```typescript
import { sessions as dbSessions } from './db.js';
// ...
dbSessions.clearAll();
closeDB();
```
- [ ] **Step 3: Commit**
```bash
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`:
```typescript
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 `claudeSessionId` references**
```bash
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` (`SessionState` interface field, `getActiveSessions`, `_createSession`, all usages)
- `server/adapters/codex/codex-tmux-adapter.ts` (same: `SessionState` field → rename to `cliSessionId`)
- `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**
```bash
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-start` route (index.ts)**
After line 147, add:
```typescript
hookRoute(`${prefix}/session-start`, (body) => {
this._tmux.handleSessionStart(body);
});
```
- [ ] **Step 3: Add `handleSessionStart` method (tmux-adapter.ts)**
New method. Algorithm:
```typescript
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 `startSession` ID format (tmux-adapter.ts:130)**
```typescript
const windowName = `claude-${Date.now()}`;
```
Update `dbSessions.upsert` to pass `'claude'` as adapter.
- [ ] **Step 5: Update `resolveSessionId` — remove `desktop-` prefix entirely (tmux-adapter.ts:858)**
The `desktop-` prefix logic is no longer needed. Change line 858 from:
```typescript
const sessionId = `desktop-${claudeSessionId.slice(0, 8)}`;
```
to:
```typescript
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` → `findByCliSession` in all calls**
- [ ] **Step 7: Update `getActiveSessions` — add `adapter: 'claude'`, rename field**
- [ ] **Step 8: Commit**
```bash
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 `startSession` ID to `codex-` prefix (line 121)**
- [ ] **Step 2: Remove `desktop-` in `handleSessionStart` (line 242) — use DB original ID**
- [ ] **Step 3: Update `getActiveSessions` — add `adapter: 'codex'`, rename field**
- [ ] **Step 4: Rename `findByClaudeSession` → `findByCliSession` in all calls**
- [ ] **Step 5: Commit**
```bash
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 `handleQuery` SESSION_CREATED (line 198)**
```typescript
// 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 `handleReconnect` SESSION_CREATED (line 266)**
Same pattern — cast `getSession()` result and read `cliSessionId`.
- [ ] **Step 3: Commit**
```bash
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 `cliSessionId` state in useChat (line 95)**
```typescript
const [cliSessionId, setCliSessionId] = useState<string | null>(null);
```
Update SESSION_CREATED handler (line 143):
```typescript
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)**
```tsx
<ChatHeader sessionId={sessionId || initialSessionId} cliSessionId={cliSessionId} cwd={cwd} />
```
- [ ] **Step 4: Commit**
```bash
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 `--adapter` flag parsing**
Insert before the resume mode section (around line 304). This parses `--adapter` from anywhere in the args:
```bash
# --- 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):
```bash
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`/`-A` display**
Update the session listing loop to query the server API for UUID:
```bash
# 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**
```bash
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 `--adapter` scenarios (after L1238)**`codetap new --adapter codex`
- [ ] **Step 3: `-a`/`-A` display 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**
```bash
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**
```bash
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.