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
20 KiB
Unified Session Creation Path 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 Cross-AI Review child session creation to use the same WS QUERY path as normal sessions, eliminating the HTTP-creates-session / WS-reconnects split.
Spec: docs/superpowers/specs/2026-03-25-unified-session-path-design.md
Architecture: Merge sendMessage and pasteToSession in BOTH adapters (Codex + Claude) so QUERY handles any content size. Move session creation from POST /api/reviews to FloatingReviewPanel's useChat QUERY. POST /api/reviews becomes a registration-only endpoint called after the session exists.
Edge Cases & Scenarios
Before reading the tasks, understand all scenarios this plan must handle:
| # | Scenario | Path | Notes |
|---|---|---|---|
| A | Normal Codex session from WebUI | QUERY → handleQuery → startSession → registerClient → sendMessage | ✅ Already works |
| B | Cross-AI Review child (same device) | QUERY → handleQuery (same as A) → then POST /api/reviews/register | ✅ New unified path |
| C | Multi-device: other device connects to parent with active review | RECONNECT → handleReconnect loads active reviews → REVIEW_STARTED → FloatingReviewPanel mounts → RECONNECT to child | ⚠️ RECONNECT path must be preserved |
| D | Page refresh: reconnect to parent + active review | Same as C | ⚠️ RECONNECT path must be preserved |
| E | registerReview POST fails after session created | Session exists but no DB record → retry or show error | ⚠️ Error handling needed |
| F | User clicks End before registerReview completes | reviewId is empty → must not call endReview('') | ⚠️ Guard needed |
| G | Send-back to Claude parent | Claude sendMessage must handle large multiline text | ⚠️ Claude merge needed |
| H | Send-back to Codex parent | Codex sendMessage already handles (Task 1) | ✅ |
| I | CODETAP_REF marker injection | handleQuery injects for non-Claude → sendMessage auto-splits | ✅ |
Key constraint: RECONNECT path must be preserved for scenarios C and D. FloatingReviewPanel must support BOTH:
- New path:
initialPromptprovided, nochildSessionId→ useChat QUERY (creates session) - Reconnect path:
childSessionIdprovided, noinitialPrompt→ useChat RECONNECT (joins existing session)
Task 1: Merge sendMessage and pasteToSession in BOTH adapters
Files:
server/adapters/codex/codex-tmux-adapter.tsserver/adapters/codex/index.tsserver/adapters/claude/tmux-adapter.tsserver/adapters/claude/index.ts
This task is standalone — makes sendMessage handle all content sizes in both adapters without breaking anything.
- Step 1: Rewrite Codex
sendMessage()(lines 204-221)
Merge the logic from pasteToSession() (lines 223-258) into sendMessage():
async sendMessage(sessionId: string, text: string, options: QueryOptions = {}): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) throw new Error(`Session ${sessionId} not found`);
session._promptSenderClientId = options.clientId || null;
session.isProcessing = true;
// Restart pane monitor if it was stopped
if (!session.monitor) {
this._startMonitor(sessionId, session.windowId);
}
// Large or multiline content: use pasteBuffer (fast, handles newlines)
if (text.length > 500 || text.includes('\n')) {
const singleLine = text.replace(/\n/g, '\\n');
// Fresh Codex sessions have TUI placeholder text. If content starts with
// CODETAP_REF marker, send marker via sendKeys first (clears placeholder),
// then pasteBuffer the rest.
const markerMatch = singleLine.match(/^\[CODETAP_REF:[^\]]+\]/);
if (markerMatch) {
const marker = markerMatch[0];
const rest = singleLine.substring(marker.length);
await tmuxManager.sendKeys(session.windowId, marker, false);
await new Promise<void>(r => setTimeout(r, 200));
if (rest) {
await tmuxManager.pasteBuffer(session.windowId, rest, false);
}
} else {
await tmuxManager.pasteBuffer(session.windowId, singleLine, false);
}
await new Promise<void>(r => setTimeout(r, 300));
await tmuxManager.sendControl(session.windowId, 'Enter');
} else {
// Short text: sendKeys (character-by-character)
await tmuxManager.sendKeys(session.windowId, text, false);
await new Promise<void>(r => setTimeout(r, 200));
await tmuxManager.sendControl(session.windowId, 'Enter');
}
// If there are pending hook bodies waiting for marker matching, try now
if (this._pendingHookBodies.size > 0 && session._watcherPending) {
this._tryMatchPending(sessionId);
}
}
- Step 2: Remove Codex
pasteToSession()method (lines 223-258)
Delete the entire method from CodexTmuxAdapter.
- Step 3: Update
CodexAdapter.pasteToSessioninserver/adapters/codex/index.ts
Delegate to sendMessage (keeps public API working until Task 3 removes callers):
async pasteToSession(sid: string, content: string): Promise<void> {
return this._tmux.sendMessage(sid, content);
}
- Step 4: Update Claude
sendMessage()inserver/adapters/claude/tmux-adapter.ts
Currently Claude's sendMessage always uses sendKeys(text, true). Add large content handling:
async sendMessage(sessionId: string, text: string, options: QueryOptions = {}): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) throw new Error(`Session ${sessionId} not found`);
session._promptSenderClientId = options.clientId || null;
if (!session.monitor) {
this._startMonitor(sessionId, session.windowId);
}
// Large or multiline content: use pasteBuffer (fast)
if (text.length > 500 || text.includes('\n')) {
await tmuxManager.pasteBuffer(session.windowId, text);
} else {
await tmuxManager.sendKeys(session.windowId, text, true);
}
}
Note: Claude's pasteBuffer already handles Enter (sendEnter defaults to true in tmux-manager). Claude doesn't need \n → \\n replacement or CODETAP_REF marker splitting (Claude generates its own UUID upfront, no placeholder issue).
- Step 5: Update
ClaudeAdapter.pasteToSessioninserver/adapters/claude/index.ts
Delegate to sendMessage:
async pasteToSession(sid: string, content: string): Promise<void> {
return this._tmux.sendMessage(sid, content);
}
- Step 6: Verify TypeScript compilation
npx tsc --noEmit
- Step 7: Commit
git add server/adapters/codex/codex-tmux-adapter.ts server/adapters/codex/index.ts server/adapters/claude/tmux-adapter.ts server/adapters/claude/index.ts
git commit -m "refactor: merge sendMessage and pasteToSession in both adapters — auto-detect large content"
Task 2: Add registerReview API endpoint + update frontend
Files:
server/index.ts— add POST /api/reviews/registersrc/lib/api.ts— addregisterReview()functionsrc/components/ChatView.tsx— handleReviewSelect uses local state, calls registerReview after session createdsrc/components/FloatingReviewPanel.tsx— acceptinitialPrompt, auto-send via QUERY, support RECONNECT for multi-devicesrc/hooks/useChat.ts— supportinitialPromptfor auto-sending first message
All files change together to maintain compilation.
- Step 1: Add
registerReviewtoapi.ts
registerReview: (parentCliSessionId: string, childSessionId: string, targetAdapter: string, anchorMessageId: string, prompt: string, title: string) =>
request<{ reviewId: string }>('/api/reviews/register', {
method: 'POST',
body: JSON.stringify({ parentCliSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title }),
}),
- Step 2: Add POST /api/reviews/register endpoint in
server/index.ts
app.post('/api/reviews/register', authMiddleware, async (req: Request, res: Response) => {
try {
const { parentCliSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title } = req.body;
if (!parentCliSessionId || !childSessionId) {
return res.status(400).json({ error: 'parentCliSessionId and childSessionId required' });
}
const parentAdapterName = sessionAdapterMap.get(parentCliSessionId) || DEFAULT_ADAPTER;
const reviewId = crypto.randomUUID();
sessionReviews.create(reviewId, parentCliSessionId, childSessionId, targetAdapter, parentAdapterName, anchorMessageId, prompt, title);
if (!sessionAdapterMap.has(childSessionId)) {
sessionAdapterMap.set(childSessionId, targetAdapter);
}
broadcastReviewStarted(parentCliSessionId, {
reviewId, childSessionId, childCliSessionId: childSessionId,
childAdapter: targetAdapter, anchorMessageId, reviewTitle: title,
});
res.json({ reviewId });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
- Step 3: Update FloatingReviewPanel — dual-path support
File: src/components/FloatingReviewPanel.tsx
Update interface to support both paths:
interface FloatingReviewPanelProps {
reviewId?: string; // empty until registerReview completes (new path)
childSessionId?: string; // empty for new session (QUERY), set for reconnect (RECONNECT)
childAdapter: string;
reviewTitle?: string;
panelState: 'expanded' | 'minimized' | 'hidden';
onPanelStateChange: (state: 'expanded' | 'minimized' | 'hidden') => void;
onEnd: () => void;
// New path only:
initialPrompt?: string; // review context to auto-send as first QUERY
cwd?: string;
onSessionCreated?: (childSessionId: string) => void;
}
useChat call:
const {
messages, streaming, liveStatus, toolStatuses,
sendMessage: chatSendMessage, abort, sessionId: chatSessionId,
} = useChat(
childSessionId || undefined, // undefined → new session (QUERY); set → reconnect
initialPrompt, // auto-send as first message (new path only)
childAdapter,
cwd,
);
// Notify parent when session is created via QUERY (new path)
const notifiedRef = useRef(false);
useEffect(() => {
if (chatSessionId && !childSessionId && onSessionCreated && !notifiedRef.current) {
notifiedRef.current = true;
onSessionCreated(chatSessionId);
}
}, [chatSessionId, childSessionId, onSessionCreated]);
- Step 4: Update useChat — support
initialPromptparameter
File: src/hooks/useChat.ts
Update signature:
export function useChat(
existingSessionId?: string,
initialPrompt?: string,
adapterOverride?: string,
cwdOverride?: string,
) {
Add ref and auto-send in WS onopen:
const initialPromptSent = useRef(false);
// In the WS onopen handler, after connection established:
if (initialPrompt && !existingSessionId && !initialPromptSent.current) {
initialPromptSent.current = true;
actualSend(initialPrompt);
}
Important: actualSend must pass adapter: adapterOverride and cwd: cwdOverride in the QUERY options so handleQuery uses the correct adapter and directory.
- Step 5: Update ChatView
handleReviewSelect— local mount + registerReview
File: src/components/ChatView.tsx
Add state:
const [reviewInitialPrompt, setReviewInitialPrompt] = useState<string | null>(null);
const [reviewCwd, setReviewCwd] = useState<string | null>(null);
Replace api.createReview() call in handleReviewSelect:
// Instead of api.createReview, set local state to mount panel
setActiveReview({
reviewId: '',
childSessionId: '',
childCliSessionId: '',
childAdapter: targetAdapter,
anchorMessageId: anchorMsgId,
reviewTitle: title,
});
setReviewInitialPrompt(cappedContext);
setReviewCwd(/* parent session's cwd from adapterConfig or session state */);
setReviewPanelState('expanded');
Update FloatingReviewPanel props:
<FloatingReviewPanel
reviewId={activeReview.reviewId || undefined}
childSessionId={activeReview.childSessionId || undefined}
childAdapter={activeReview.childAdapter}
reviewTitle={activeReview.reviewTitle}
panelState={reviewPanelState}
onPanelStateChange={setReviewPanelState}
onEnd={async () => {
// Guard: only call endReview if reviewId exists (edge case F)
if (activeReview.reviewId) {
try { await api.endReview(activeReview.reviewId); } catch {}
}
// Always destroy child session if it exists
if (activeReview.childSessionId) {
// session cleanup happens server-side when session ends
}
setActiveReview(null);
setReviewPanelState('hidden');
setReviewInitialPrompt(null);
}}
initialPrompt={reviewInitialPrompt || undefined}
cwd={reviewCwd || undefined}
onSessionCreated={async (childSid) => {
try {
const result = await api.registerReview(
sessionId, childSid, activeReview.childAdapter,
activeReview.anchorMessageId, activeReview.reviewTitle || '', ''
);
setActiveReview(prev => prev ? {
...prev,
reviewId: result.reviewId,
childSessionId: childSid,
childCliSessionId: childSid,
} : null);
} catch (err) {
// Edge case E: registerReview failed
console.error('Failed to register review:', err);
// Session exists but no DB record — user can still chat, just won't persist
}
setReviewInitialPrompt(null);
}}
/>
- Step 6: Verify RECONNECT path still works (scenarios C/D)
The RECONNECT path is preserved because:
- When
childSessionIdis provided (from REVIEW_STARTED broadcast on reconnect), useChat sends RECONNECT - When
initialPromptis NOT provided, no auto-send happens - FloatingReviewPanel renders ChatBody normally with messages from HISTORY_LOAD
Verify by checking: handleReconnect in session-manager.ts sends active reviews → useChat REVIEW_STARTED handler sets activeReview with childSessionId → FloatingReviewPanel mounts with childSessionId → useChat RECONNECT.
- Step 7: Verify TypeScript compilation
npx tsc --noEmit
- Step 8: Commit
git add server/index.ts src/lib/api.ts src/components/ChatView.tsx src/components/FloatingReviewPanel.tsx src/hooks/useChat.ts
git commit -m "feat: unified session path — review child uses QUERY, registerReview after session created"
Task 3: Clean up — remove old review session creation + pasteToSession
Files:
-
server/index.ts -
src/lib/api.ts -
server/adapters/interface.ts -
server/adapters/codex/index.ts -
server/adapters/claude/index.ts -
server/adapters/claude/tmux-adapter.ts -
Step 1: Remove old POST /api/reviews session creation logic
In server/index.ts POST /api/reviews handler (lines 249-319):
- Remove
adapter.startSession()call - Remove
adapter.pasteToSession()call - Remove marker injection logic
- Keep only: DB record creation + broadcast (same as /api/reviews/register)
- Or remove the entire endpoint and redirect to /api/reviews/register
Check frontend callers:
grep -rn "createReview\|/api/reviews'" src/ --include="*.ts" --include="*.tsx"
Remove createReview from api.ts if no longer called.
- Step 2: Update send-back to use sendMessage
In POST /api/reviews/:id/send-back (server/index.ts lines 369-371):
// OLD:
await parentAdapter.pasteToSession(parentSessionId, formatted);
// NEW:
await parentAdapter.sendMessage(parentSessionId, formatted);
Both Claude and Codex sendMessage now handle large content (Task 1).
- Step 3: Remove
pasteToSessionfrom adapter interface
Check remaining callers:
grep -rn "pasteToSession" server/ --include="*.ts"
If no remaining callers after Steps 1-2, remove from:
-
server/adapters/interface.ts— base class method -
server/adapters/codex/index.ts— delegation -
server/adapters/codex/codex-tmux-adapter.ts— if any leftover -
server/adapters/claude/index.ts— delegation -
server/adapters/claude/tmux-adapter.ts— implementation -
Step 4: Verify TypeScript compilation
npx tsc --noEmit
- Step 5: Commit
git add server/ src/lib/api.ts
git commit -m "refactor: remove old review session creation and pasteToSession from adapter interface"
Task 4: E2E Verification
- Step 1: Start server
CLAUDE_UI_PASSWORD=TEST npm run dev
-
Step 2: Test normal Codex session (scenario A) New Project → code-tap → Codex → send message → verify response + icon buttons.
-
Step 3: Test normal Claude session New Project → code-tap → Claude → send message → verify response.
-
Step 4: Test Cross-AI Review unified path (scenario B)
- Claude session → send message → get response
- Click send icon → select "Direct send"
- Verify FloatingReviewPanel opens
- Verify panel shows Codex response (via QUERY, same as normal)
- Verify session ID updates to real UUID
-
Step 5: Test send-back (scenario H) In review panel, click send-back icon → verify message appears in parent chat.
-
Step 6: Test end review Click "End" → verify panel closes, markers appear.
-
Step 7: Test end review before registerReview (scenario F) Quick-click End immediately after review starts (before Codex responds) → verify no crash.
-
Step 8: Test page refresh reconnect (scenario D)
- Start a review
- Refresh page
- Reconnect to parent session
- Verify FloatingReviewPanel re-appears with child session (RECONNECT path)
Self-Review Checklist
Flow comparison after all tasks
Normal session (Codex or Claude):
useChat.actualSend("Hi") → WS QUERY → handleQuery → startSession → registerClient → sendMessage
Review child (same device, scenario B):
useChat.actualSend(reviewContext) → WS QUERY → handleQuery → startSession → registerClient → sendMessage
→ SESSION_CREATED → POST /api/reviews/register → DB record + broadcast
Review child (other device/reconnect, scenarios C/D):
REVIEW_STARTED from server → FloatingReviewPanel mounts with childSessionId
→ useChat RECONNECT → handleReconnect → registerClient → HISTORY_LOAD
All three paths work. Scenarios B and normal use IDENTICAL QUERY flow.
Adapter sendMessage unification
| Adapter | Short text | Long/multiline text |
|---|---|---|
| Codex | sendKeys | \n→\\n + pasteBuffer (with CODETAP_REF marker split) |
| Claude | sendKeys | pasteBuffer (no \n replacement needed, no marker split) |
Error handling
- registerReview failure → catch, log, session continues (no DB record but chat works) ✅
- End with empty reviewId → guard, skip endReview API call ✅
- initialPrompt double-send → ref guard prevents ✅
Files changed
| File | Change |
|---|---|
server/adapters/codex/codex-tmux-adapter.ts |
Merge sendMessage + pasteToSession |
server/adapters/codex/index.ts |
pasteToSession delegates to sendMessage |
server/adapters/claude/tmux-adapter.ts |
sendMessage handles large content |
server/adapters/claude/index.ts |
pasteToSession delegates to sendMessage |
server/adapters/interface.ts |
Remove pasteToSession (Task 3) |
server/index.ts |
Add /api/reviews/register, remove old POST /api/reviews session creation |
src/lib/api.ts |
Add registerReview(), remove createReview() |
src/components/ChatView.tsx |
handleReviewSelect → local state + registerReview callback |
src/components/FloatingReviewPanel.tsx |
Dual-path: initialPrompt (QUERY) or childSessionId (RECONNECT) |
src/hooks/useChat.ts |
Support initialPrompt auto-send |