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,477 @@
# Codex UUID Discovery Fix + Session Architecture Cleanup — 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:** Fix Codex startSession deadlock, replace guess-based matching with JSONL marker matching, kill tmux windows on shutdown, remove DB sessions table.
**Architecture:** 6 tasks: (1) remove _waitForCliUUID + add marker matching in Codex adapter, (2) inject marker in session-manager + index.ts, (3) filter marker in frontend, (4) shutdown kills tmux + remove _findAndAttachWindow, (5) remove DB sessions table, (6) simplify handleReconnect + review endpoints.
**Spec:** `docs/superpowers/specs/2026-03-24-codex-uuid-discovery-fix.md`
---
### Task 1: Codex adapter — remove deadlock, add marker matching
**Files:**
- Modify: `server/adapters/codex/codex-tmux-adapter.ts`
- [ ] **Step 1: Delete `_waitForCliUUID` method**
Remove the entire method. Also remove the call to it in `startSession()` and the `renameWindow` call after it. `startSession` now returns temp key immediately:
```typescript
return { sessionId: tempName };
```
- [ ] **Step 2: Delete `_rekeySession` method (if still exists from earlier)**
This was added in a previous fix. Will be replaced by `_rekeyAndRename`.
- [ ] **Step 3: Add `_rekeyAndRename` method**
```typescript
private async _rekeyAndRename(tempKey: string, cliUuid: string): Promise<void> {
const session = this.sessions.get(tempKey);
if (!session) return;
session.cliSessionId = cliUuid;
session._watcherPending = false;
this.sessions.delete(tempKey);
this.sessions.set(cliUuid, session);
await tmuxManager.renameWindow(session.windowId, cliUuid);
if (session.monitor) {
(session.monitor as any).sessionId = cliUuid;
}
}
```
Note: NO `dbSessions` calls here — DB sessions table will be removed in Task 5.
- [ ] **Step 4: Add `_matchByTranscriptMarker` method**
Reads JSONL at given path, finds `[CODETAP_REF:xxx]` in first user message, returns `xxx` if it's a key in `this.sessions`:
```typescript
private _matchByTranscriptMarker(transcriptPath: string): string | null {
try {
const content = readFileSync(transcriptPath, 'utf8');
const lines = content.split('\n').filter(Boolean);
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Check for user message content containing marker
// NOTE: Read codex transcript-parser.ts to get correct field names
const text = this._extractTextFromEntry(entry);
if (text) {
const match = text.match(/\[CODETAP_REF:([^\]]+)\]/);
if (match && this.sessions.has(match[1])) return match[1];
}
} catch {}
}
} catch {}
return null;
}
```
Add a helper `_extractTextFromEntry` that handles the Codex JSONL format (check `transcript-parser.ts` for the actual field structure).
- [ ] **Step 5: Rewrite `handleSessionStart` matching**
Replace `pendingSessions.length === 1` logic:
```
1. Direct lookup: this.sessions.has(codexUuid) → already managed → update state, return
2. Marker matching: _matchByTranscriptMarker(body.transcript_path) → found tempKey → _rekeyAndRename(tempKey, codexUuid), start watcher, return
3. Fallback pending scan: pendingSessions.length === 1 → legacy (kept for sessions without marker)
4. No match: create session entry for this UUID (desktop/unknown origin)
```
In step 4, do NOT call `_findAndAttachWindow` (will be deleted in Task 4). Instead, try matching by tmux window name:
```typescript
const windows = await tmuxManager.listWindows();
const match = windows.find(w => w.name === codexUuid);
if (match) {
session.windowId = match.id;
this._startMonitor(codexUuid, match.id);
}
```
- [ ] **Step 6: Update `_watchForTranscript` with marker verification**
In `scanOnce`, after finding a JSONL file candidate, verify it belongs to this session:
```typescript
const firstChunk = readFileSync(fullPath, 'utf8').slice(0, 2000);
if (!firstChunk.includes(`CODETAP_REF:${sessionId}`)) continue;
```
When match confirmed, call `_rekeyAndRename(sessionId, uuid)`.
- [ ] **Step 7: Commit**
```bash
git add server/adapters/codex/codex-tmux-adapter.ts
git commit -m "fix: remove _waitForCliUUID deadlock, add marker-based matching"
```
---
### Task 2: Inject CODETAP_REF marker in session-manager + index.ts
**Files:**
- Modify: `server/session-manager.ts`
- Modify: `server/index.ts`
- [ ] **Step 1: Inject marker in handleQuery for new sessions**
In `handleQuery()`, after `startSession` returns and before `sendMessage`:
```typescript
let messageText = prompt;
if (!sessionId) {
// New session — prepend marker for Codex UUID matching
messageText = `[CODETAP_REF:${handle.sessionId}]\n${prompt}`;
}
await adapter.sendMessage(handle.sessionId, messageText, { clientId: conn.clientId });
```
For Claude, `handle.sessionId` is already a UUID — marker is harmless, just filtered out in ChatView.
- [ ] **Step 2: Inject marker in POST /api/reviews context**
```typescript
if (context) {
const markerContext = `[CODETAP_REF:${handle.sessionId}]\n${context}`;
await adapter.pasteToSession(handle.sessionId, markerContext);
}
```
- [ ] **Step 3: Commit**
```bash
git add server/session-manager.ts server/index.ts
git commit -m "feat: inject CODETAP_REF marker in first message for UUID matching"
```
---
### Task 3: Filter marker in frontend
**Files:**
- Modify: `src/lib/content-utils.ts`
- Modify: `src/hooks/useChat.ts`
- [ ] **Step 1: Add stripMarker to content-utils.ts**
```typescript
const CODETAP_REF_REGEX = /^\[CODETAP_REF:[^\]]+\]\n?/;
export function stripMarker(text: string): string {
return text.replace(CODETAP_REF_REGEX, '');
}
```
- [ ] **Step 2: Strip marker in convertMessages (useChat.ts)**
Import `stripMarker` from `@/lib/content-utils`. In `convertMessages()`, when processing user messages, strip marker from text content blocks:
```typescript
if (msg.role === 'user') {
const content = typeof msg.content === 'string'
? [{ type: 'text', text: stripMarker(msg.content) }]
: (msg.content || []).map((b: any) =>
b.type === 'text' ? { ...b, text: stripMarker(b.text || '') } : b
);
// ... rest of processing
}
```
- [ ] **Step 3: Commit**
```bash
git add src/lib/content-utils.ts src/hooks/useChat.ts
git commit -m "feat: strip CODETAP_REF marker from user messages in ChatView"
```
---
### Task 4: Shutdown kills tmux + remove _findAndAttachWindow
**Files:**
- Modify: `server/adapters/claude/tmux-adapter.ts`
- Modify: `server/adapters/codex/codex-tmux-adapter.ts`
- [ ] **Step 1: Codex destroy() kills tmux session**
Claude's `destroy()` already has `tmuxManager.killSession()` (added in earlier task). Add the same to Codex's `destroy()`:
```typescript
await tmuxManager.killSession();
```
Note: Both adapters share the same tmuxManager. Calling `killSession()` twice is harmless (catch block swallows error).
- [ ] **Step 3: Delete _findAndAttachWindow from Codex adapter**
Remove the entire `_findAndAttachWindow` method. Remove all call sites (in `handleSessionStart` fallback path).
- [ ] **Step 4: Commit**
```bash
git add server/adapters/claude/tmux-adapter.ts server/adapters/codex/codex-tmux-adapter.ts
git commit -m "feat: shutdown kills tmux windows, remove _findAndAttachWindow"
```
---
### Task 5: Remove DB sessions table
**Files:**
- Modify: `server/db.ts`
- Modify: `server/adapters/claude/tmux-adapter.ts`
- Modify: `server/adapters/codex/codex-tmux-adapter.ts`
- Modify: `server/session-manager.ts`
- Modify: `server/index.ts`
- Modify: `bin/codetap`
- [ ] **Step 1: Add parent_adapter to session_reviews table**
In `server/db.ts`, add `parent_adapter TEXT NOT NULL DEFAULT 'claude'` to the `session_reviews` CREATE TABLE.
Add migration: if `parent_adapter` column doesn't exist, add it:
```sql
ALTER TABLE session_reviews ADD COLUMN parent_adapter TEXT NOT NULL DEFAULT 'claude';
```
Update `SessionReviewRow` interface to include `parent_adapter: string`.
Update `sessionReviews.create()` to accept and store `parentAdapter`.
Update `POST /api/reviews` in `server/index.ts` to pass the parent's adapter name when creating a review.
- [ ] **Step 2: Remove sessions table from db.ts**
Delete:
- `CREATE TABLE IF NOT EXISTS sessions` from initDB
- Old schema detection/drop logic (`PRAGMA table_info`, `hasOldColumns`)
- `sessionsUpsert`, `sessionsGet`, `sessionsFindByWindowId`, `sessionsRemove`, `sessionsGetAll` prepared statements from `PreparedStatements` interface and `stmts()` function
- `SessionRow` interface
- `sessions` export object
- `CREATE INDEX idx_sessions_window`
Keep: everything related to `session_reviews`.
- [ ] **Step 2: Remove all dbSessions calls from Claude adapter**
Grep for `dbSessions` in `tmux-adapter.ts`. Remove every call (9 sites):
- `dbSessions.upsert(...)` in startSession (line 131), attachSession (line 181), resumeSession (line 224), handleSessionStart (line 547)
- `dbSessions.remove(...)` in handleSessionEnd (line 500), cleanup loop (line 707)
- `dbSessions.get(...)` in handleSessionStart (line 532), _findWindowForSession (line 875)
Also remove the `import { sessions as dbSessions } from '../../db.js'` line.
NOTE: `_findWindowForSession` currently does `dbSessions.get(sessionId)` first, then falls back to `windows.find(w => w.name === sessionId)`. After removing DB, keep ONLY the window name matching fallback.
- [ ] **Step 3: Remove all dbSessions calls from Codex adapter**
Same pattern. Grep and remove all `dbSessions.*` calls. Remove import.
- [ ] **Step 4: Remove dbSessions from session-manager.ts**
Remove:
- `import { sessions as dbSessions } from './db.js'` (keep `sessionReviews` import)
- `dbSessions.get(sessionId)` in handleReconnect (cwd lookup — no longer needed)
- `dbSessions.get(review.parent_cli_session_id)` in review restoration (Task 6 changes this)
- [ ] **Step 5: Remove dbSessions from index.ts**
Remove:
- `dbSessions.clearAll()` from shutdown handler
- `dbSessions.get(parentCliSessionId)` from review endpoints (Task 6 changes these)
- Import of `sessions as dbSessions`
- [ ] **Step 6: Update bin/codetap**
Remove all SQL queries that reference the `sessions` table:
- `get_project_sessions()` function — queries sessions by cwd
- `-a` listing block — queries sessions by window name
- `--resume` block — queries sessions by id
These CLI features will stop working without the DB. Options:
a) Remove these features from bin/codetap (they depend on DB)
b) Use tmux list-windows directly instead of DB queries
Recommended: option (b) — replace DB queries with tmux-based queries:
`get_project_sessions()`:
```bash
tmux list-windows -t codetap -F '#{window_name}\t#{pane_current_path}' 2>/dev/null | \
awk -F'\t' -v cwd="$CWD" '$2 == cwd { print $1 }'
```
`-a` listing:
```bash
tmux list-windows -t codetap -F '#{window_name}\t#{pane_current_command}\t#{pane_current_path}' 2>/dev/null
# window_name = UUID, pane_current_command = "claude" or "codex" (adapter detection)
```
`--resume`:
```bash
# Check if UUID exists as a tmux window name
tmux list-windows -t codetap -F '#{window_name}' 2>/dev/null | grep -q "^${RESUME_ID}$"
# Detect adapter from pane command
ADAPTER=$(tmux display -t "codetap:${RESUME_ID}" -p '#{pane_current_command}' 2>/dev/null)
```
- [ ] **Step 7: TypeScript compilation check**
`npx tsc --noEmit` — zero errors.
- [ ] **Step 8: Commit**
```bash
git add server/db.ts server/adapters/claude/tmux-adapter.ts server/adapters/codex/codex-tmux-adapter.ts server/session-manager.ts server/index.ts bin/codetap
git commit -m "refactor: remove DB sessions table — in-memory Map is sole source of truth"
```
---
### Task 6: Simplify handleReconnect + review endpoints use Map for cwd
**Files:**
- Modify: `server/session-manager.ts`
- Modify: `server/index.ts`
- [ ] **Step 1: Simplify handleReconnect**
Remove the entire `hasActiveWindow` + `resumeSession` block:
```typescript
// BEFORE:
if (!adapter.getSession(sessionId)) {
const hasWindow = await adapter.hasActiveWindow(sessionId);
if (hasWindow) {
const dbRow = dbSessions.get(sessionId);
try { await adapter.resumeSession(sessionId, dbRow?.cwd || ''); } catch {}
}
}
// AFTER:
// (deleted — handleReconnect only loads history, handleQuery builds windows)
```
- [ ] **Step 2: Review endpoints use adapter.getSession for cwd**
In POST /api/reviews:
```typescript
// BEFORE:
const parentRow = dbSessions.get(parentCliSessionId);
const cwd = parentRow?.cwd || process.cwd();
// AFTER:
const parentSession = adapter.getSession(parentCliSessionId) as { cwd?: string } | null;
const cwd = parentSession?.cwd || process.cwd();
```
In POST /api/reviews/:id/send-back and DELETE /api/reviews/:id:
```typescript
// BEFORE:
const parentRow = dbSessions.get(review.parent_cli_session_id);
const parentAdapter = getAdapter(parentRow?.adapter || DEFAULT_ADAPTER);
// AFTER: parent_adapter is now stored in session_reviews
const parentAdapter = getAdapter(review.parent_adapter);
```
No need to iterate adapters — `parent_adapter` is directly in the review row.
In handleReconnect review restoration:
```typescript
// BEFORE:
const parentRow = dbSessions.get(review.parent_cli_session_id);
const cwd = parentRow?.cwd || '';
await childAdapterObj.resumeSession(review.child_cli_session_id, cwd);
// AFTER: don't call resumeSession — if server didn't restart, child is still in Map.
// If server restarted, windows are dead, review should be marked ended.
if (!childAdapterObj.getSession(review.child_cli_session_id)) {
// Child session gone (server restarted + windows killed) → mark review ended
sessionReviews.endReview(review.id);
continue;
}
// Child still alive → just send REVIEW_STARTED event, child's useChat reconnects itself
```
- [ ] **Step 3: Commit**
```bash
git add server/session-manager.ts server/index.ts
git commit -m "refactor: handleReconnect simplified, review endpoints use in-memory Map"
```
---
## Self-Review Checklist
### Compilation Safety
- Task 1 removes `_waitForCliUUID` calls → `startSession` returns temp key → compiles ✅
- Task 5 removes dbSessions → all callers must be updated in same task → grep to verify zero remaining references ✅
- Task 6 depends on Task 5 (dbSessions already removed) → correct ordering ✅
### Codex UUID Discovery Flow (after fix)
```
1. startSession → return tempKey immediately
2. handleQuery/reviews → paste [CODETAP_REF:tempKey] + prompt
3. Codex processes → SessionStart hook fires (or JSONL appears)
4. handleSessionStart → read JSONL → find CODETAP_REF:tempKey → match
5. _rekeyAndRename(tempKey, uuid) → Map re-key + tmux rename
6. From now on: session ID = UUID = tmux window name
```
### handleReconnect Flow (after fix)
```
User clicks session → RECONNECT
1. registerClient(conn, sessionId)
2. load JSONL history → HISTORY_LOAD
3. replay pending state
4. NO resumeSession, NO cwd lookup, NO DB query
5. If user sends message → handleQuery → resumeSession → builds tmux window
```
### Review Endpoints (after fix)
- POST /api/reviews: `cwd` from `adapter.getSession(parentId).cwd` (parent is active, must be in Map)
- send-back: find parent adapter by iterating `getAllAdapters()`, check `adapter.getSession(reviewParentId)`
- delete: same pattern
### bin/codetap (after fix)
- `-a` listing: `tmux list-windows` directly instead of DB query
- `--resume`: `tmux list-windows` to find window by name (= UUID)
- `get_project_sessions`: `tmux list-windows` + filter by `pane_current_path`
### Things NOT changed
- `session_reviews` DB table — stays (Cross-AI Review needs it)
- In-memory `sessions` Map — stays (runtime state store)
- JSONL files — untouched (historical record)
- Push notifications — untouched
- Permission manager — untouched
### Issues Found in Self-Review (all addressed in plan)
- Claude's `destroy()` already has `killSession()` — only Codex needs it added (Task 4 updated)
- Claude's `_findWindowForSession` has DB-first check — keep only name-matching fallback (Task 5 Step 2 noted)
- Review restoration in handleReconnect: don't call `resumeSession`, just check if child exists or mark ended (Task 6 Step 2 updated)
- `send-back`/`delete` review endpoints: added `parent_adapter` column to `session_reviews` — direct lookup, no iteration (Task 5 Step 1)
- `bin/codetap --resume` detects adapter from `pane_current_command` (Task 5 Step 6 updated)
- dbSessions has 26 call sites across 4 files — all enumerated in Task 5
## Verification
1. Server starts without sessions table in DB
2. New Claude session from Web UI → works (marker injected, UUID known immediately)
3. New Codex session from Web UI → works (marker injected, UUID discovered via hook, tmux renamed)
4. Cross-AI Review → works (marker in context, child session matched, floating panel opens)
5. Server shutdown → all tmux windows killed
6. Server restart → clean state, no stale windows
7. `bin/codetap -a` → lists sessions from tmux directly
8. `bin/codetap --resume <UUID>` → works
9. Historical session click → loads history (no 30s wait, no resumeSession)
10. ChatView shows no `[CODETAP_REF:...]` markers