# Pairing System: TUI Wiring + SQLite Persistence > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Make the DM pairing system fully functional by wiring up the TUI `/pair` command and persisting approved senders across daemon restarts. **Architecture:** Add a `PairingStore` interface to decouple `PairingManager` from storage. `SessionStore` implements it via a new `pairing_approved` SQLite table. The TUI receives `PairingManager` as an optional dependency and handles `/pair` subcommands directly. **Tech Stack:** TypeScript, better-sqlite3, Vitest --- ## Task 1: Add PairingStore Interface and Persistence to PairingManager **Files:** - Modify: `src/channels/pairing.ts` (add `PairingStore` interface, export `ApprovedSender`, accept optional store) - Modify: `src/channels/index.ts` (re-export `PairingStore` and `ApprovedSender`) - Test: `src/channels/pairing.test.ts` **Step 1: Write failing tests for persistence integration** Add these tests to `src/channels/pairing.test.ts` inside a new `describe('PairingManager with store')` block: ```typescript describe('PairingManager with store', () => { it('calls store.saveApproved when a code is validated', () => { const store: PairingStore = { loadApproved: () => [], saveApproved: vi.fn(), removeApproved: vi.fn(), }; const mgr = new PairingManager({ enabled: true, codeTtl: 300_000, codeLength: 6 }, store); const code = mgr.generateCode(); mgr.validateCode('telegram', '123', code); expect(store.saveApproved).toHaveBeenCalledWith(expect.objectContaining({ channel: 'telegram', senderId: '123', })); }); it('calls store.removeApproved when approval is revoked', () => { const store: PairingStore = { loadApproved: () => [], saveApproved: vi.fn(), removeApproved: vi.fn(), }; const mgr = new PairingManager({ enabled: true, codeTtl: 300_000, codeLength: 6 }, store); const code = mgr.generateCode(); mgr.validateCode('telegram', '123', code); mgr.revokeApproval('telegram', '123'); expect(store.removeApproved).toHaveBeenCalledWith('telegram', '123'); }); it('loads approved senders from store on construction', () => { const store: PairingStore = { loadApproved: () => [ { channel: 'telegram', senderId: '111', approvedAt: Date.now(), codeUsed: 'AAAAAA' }, { channel: 'discord', senderId: '222', approvedAt: Date.now(), codeUsed: 'BBBBBB' }, ], saveApproved: vi.fn(), removeApproved: vi.fn(), }; const mgr = new PairingManager({ enabled: true, codeTtl: 300_000, codeLength: 6 }, store); expect(mgr.isApproved('telegram', '111')).toBe(true); expect(mgr.isApproved('discord', '222')).toBe(true); expect(mgr.listApproved()).toHaveLength(2); }); it('works without a store (backward-compatible)', () => { const mgr = new PairingManager({ enabled: true, codeTtl: 300_000, codeLength: 6 }); const code = mgr.generateCode(); mgr.validateCode('telegram', '123', code); expect(mgr.isApproved('telegram', '123')).toBe(true); }); }); ``` **Step 2: Run tests to verify they fail** Run: `pnpm test:run src/channels/pairing.test.ts` Expected: FAIL — `PairingStore` is not exported, constructor doesn't accept second arg **Step 3: Implement PairingStore interface and update PairingManager** In `src/channels/pairing.ts`: 1. Export the `ApprovedSender` interface (currently private — just add `export`). 2. Add `PairingStore` interface after the existing interfaces (around line 23): ```typescript export interface PairingStore { loadApproved(): ApprovedSender[]; saveApproved(sender: ApprovedSender): void; removeApproved(channel: string, senderId: string): void; } ``` 3. Update the `PairingManager` constructor to accept an optional `PairingStore`: ```typescript export class PairingManager { private config: PairingConfig; private pendingCodes: Map = new Map(); private approvedSenders: Map = new Map(); private store?: PairingStore; constructor(config: PairingConfig, store?: PairingStore) { this.config = config; this.store = store; if (store) { for (const sender of store.loadApproved()) { const key = `${sender.channel}:${sender.senderId}`; this.approvedSenders.set(key, sender); } } } ``` 4. In `validateCode()`, after `this.approvedSenders.set(key, ...)` (around line 84), add: ```typescript this.store?.saveApproved(this.approvedSenders.get(key)!); ``` 5. In `revokeApproval()`, after `this.approvedSenders.delete(key)` (around line 100), change to: ```typescript revokeApproval(channel: string, senderId: string): boolean { const key = `${channel}:${senderId}`; const deleted = this.approvedSenders.delete(key); if (deleted) { this.store?.removeApproved(channel, senderId); } return deleted; } ``` 6. Update `src/channels/index.ts` to re-export: ```typescript export { PairingManager, type PairingConfig, type PairingStore, type ApprovedSender } from './pairing.js'; ``` **Step 4: Run tests to verify they pass** Run: `pnpm test:run src/channels/pairing.test.ts` Expected: ALL PASS (16 existing + 4 new = 20 tests) **Step 5: Commit** ```bash git add src/channels/pairing.ts src/channels/pairing.test.ts src/channels/index.ts git commit -m "feat(pairing): add PairingStore interface for persistence injection" ``` --- ## Task 2: Add SQLite Persistence to SessionStore **Files:** - Modify: `src/session/store.ts` (add `pairing_approved` table, add methods, add `getPairingStore()`) - Modify: `src/session/index.ts` (re-export `PairingStore` type if needed) - Test: `src/session/store.test.ts` (existing test file — add pairing persistence tests) **Step 1: Check existing store test file** Read `src/session/store.test.ts` to understand the test setup pattern (likely uses temp DB files or `:memory:`). **Step 2: Write failing tests for pairing persistence** Add a new `describe('pairing persistence')` block to `src/session/store.test.ts`: ```typescript describe('pairing persistence', () => { it('getPairingStore returns a PairingStore', () => { const store = new SessionStore(':memory:'); const pairingStore = store.getPairingStore(); expect(pairingStore).toBeDefined(); expect(pairingStore.loadApproved).toBeInstanceOf(Function); expect(pairingStore.saveApproved).toBeInstanceOf(Function); expect(pairingStore.removeApproved).toBeInstanceOf(Function); store.close(); }); it('saveApproved and loadApproved round-trip', () => { const store = new SessionStore(':memory:'); const ps = store.getPairingStore(); ps.saveApproved({ channel: 'telegram', senderId: '123', approvedAt: 1000, codeUsed: 'AABB11' }); ps.saveApproved({ channel: 'discord', senderId: '456', approvedAt: 2000, codeUsed: 'CC33DD' }); const loaded = ps.loadApproved(); expect(loaded).toHaveLength(2); expect(loaded).toEqual(expect.arrayContaining([ expect.objectContaining({ channel: 'telegram', senderId: '123', codeUsed: 'AABB11' }), expect.objectContaining({ channel: 'discord', senderId: '456', codeUsed: 'CC33DD' }), ])); store.close(); }); it('removeApproved deletes a sender', () => { const store = new SessionStore(':memory:'); const ps = store.getPairingStore(); ps.saveApproved({ channel: 'telegram', senderId: '123', approvedAt: 1000, codeUsed: 'AABB11' }); ps.removeApproved('telegram', '123'); expect(ps.loadApproved()).toHaveLength(0); store.close(); }); it('saveApproved upserts on duplicate channel+senderId', () => { const store = new SessionStore(':memory:'); const ps = store.getPairingStore(); ps.saveApproved({ channel: 'telegram', senderId: '123', approvedAt: 1000, codeUsed: 'FIRST1' }); ps.saveApproved({ channel: 'telegram', senderId: '123', approvedAt: 2000, codeUsed: 'SECOND' }); const loaded = ps.loadApproved(); expect(loaded).toHaveLength(1); expect(loaded[0].codeUsed).toBe('SECOND'); store.close(); }); }); ``` **Step 3: Run tests to verify they fail** Run: `pnpm test:run src/session/store.test.ts` Expected: FAIL — `getPairingStore` does not exist **Step 4: Implement pairing persistence in SessionStore** In `src/session/store.ts`: 1. Import the `PairingStore` and `ApprovedSender` types at the top: ```typescript import type { PairingStore, ApprovedSender } from '../channels/pairing.js'; ``` 2. In `init()`, add the new table after the existing `CREATE TABLE` block: ```typescript private init(): void { this.db.exec(` CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()) ); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id); CREATE TABLE IF NOT EXISTS pairing_approved ( channel TEXT NOT NULL, sender_id TEXT NOT NULL, approved_at INTEGER NOT NULL, code_used TEXT NOT NULL, PRIMARY KEY (channel, sender_id) ); `); } ``` 3. Add `getPairingStore()` method to `SessionStore`: ```typescript getPairingStore(): PairingStore { return { loadApproved: (): ApprovedSender[] => { const rows = this.db.prepare( 'SELECT channel, sender_id, approved_at, code_used FROM pairing_approved' ).all() as Array<{ channel: string; sender_id: string; approved_at: number; code_used: string }>; return rows.map(r => ({ channel: r.channel, senderId: r.sender_id, approvedAt: r.approved_at, codeUsed: r.code_used, })); }, saveApproved: (sender: ApprovedSender): void => { this.db.prepare(` INSERT OR REPLACE INTO pairing_approved (channel, sender_id, approved_at, code_used) VALUES (?, ?, ?, ?) `).run(sender.channel, sender.senderId, sender.approvedAt, sender.codeUsed); }, removeApproved: (channel: string, senderId: string): void => { this.db.prepare( 'DELETE FROM pairing_approved WHERE channel = ? AND sender_id = ?' ).run(channel, senderId); }, }; } ``` **Step 5: Run tests to verify they pass** Run: `pnpm test:run src/session/store.test.ts` Expected: ALL PASS **Step 6: Commit** ```bash git add src/session/store.ts src/session/store.test.ts git commit -m "feat(session): add pairing_approved table and getPairingStore()" ``` --- ## Task 3: Wire PairingStore Through Daemon Startup **Files:** - Modify: `src/daemon/services.ts:94-109` (update `initPairingManager` to accept store) - Modify: `src/daemon/index.ts:104` (create store, pass to pairing manager) **Step 1: Update initPairingManager to accept PairingStore** In `src/daemon/services.ts`, change the `initPairingManager` function signature: ```typescript import type { PairingStore } from '../channels/index.js'; export function initPairingManager(config: Config, store?: PairingStore): PairingManager | undefined { if (!config.pairing.enabled) return undefined; const ttlMatch = config.pairing.code_ttl.match(/^(\d+)(s|m|h)$/); const codeTtlMs = ttlMatch ? Number(ttlMatch[1]) * ({ s: 1000, m: 60_000, h: 3_600_000 }[ttlMatch[2] as 's' | 'm' | 'h']) : 5 * 60_000; const manager = new PairingManager({ enabled: true, codeTtl: codeTtlMs, codeLength: config.pairing.code_length, }, store); console.log(`Pairing codes enabled (TTL: ${config.pairing.code_ttl}, length: ${config.pairing.code_length})`); return manager; } ``` **Step 2: Wire PairingStore from SessionStore in daemon/index.ts** In `src/daemon/index.ts`, around line 104, change: ```typescript const pairingManager = initPairingManager(config); ``` to: ```typescript const pairingStore = config.pairing.enabled ? sessionStore.getPairingStore() : undefined; const pairingManager = initPairingManager(config, pairingStore); ``` **Step 3: Run typecheck** Run: `pnpm typecheck` Expected: No errors **Step 4: Run all tests to verify nothing broke** Run: `pnpm test:run` Expected: ALL PASS **Step 5: Commit** ```bash git add src/daemon/services.ts src/daemon/index.ts git commit -m "feat(daemon): wire PairingStore from SessionStore into PairingManager" ``` --- ## Task 4: Wire TUI /pair Command Execution **Files:** - Modify: `src/frontends/tui/minimal.ts:33-43,159-203` (add PairingManager to config, add case 'pair') - Modify: `src/cli/tui.ts:46-163` (create PairingManager, pass to MinimalTui) **Step 1: Add PairingManager to MinimalTuiConfig** In `src/frontends/tui/minimal.ts`, add import and config field: ```typescript import type { PairingManager } from '../../channels/pairing.js'; ``` Add to `MinimalTuiConfig` interface (around line 42): ```typescript pairingManager?: PairingManager; ``` **Step 2: Add case 'pair' to handleCommand** In `src/frontends/tui/minimal.ts`, add a new case in `handleCommand()` before the `case 'message':` line: ```typescript case 'pair': this.handlePairCommand(command.action, command.args); break; ``` Add the handler method to the `MinimalTui` class: ```typescript private handlePairCommand(action?: 'generate' | 'list' | 'revoke', args?: string): void { const pm = this.config.pairingManager; if (!pm) { console.log(`${colors.gray}Pairing not enabled. Set pairing.enabled: true in config.${colors.reset}\n`); return; } switch (action) { case 'generate': { const code = pm.generateCode(args); const pending = pm.listPendingCodes().find(p => p.code === code); const expiresIn = pending ? Math.round((pending.expiresAt - Date.now()) / 1000) : '?'; console.log(`${colors.bold}Pairing code: ${code}${colors.reset}`); console.log(`${colors.gray}Expires in ${expiresIn}s${args ? ` (label: ${args})` : ''}${colors.reset}\n`); break; } case 'revoke': { if (!args) { console.log(`${colors.gray}Usage: /pair revoke ${colors.reset}\n`); return; } const parts = args.split(/\s+/); if (parts.length < 2) { console.log(`${colors.gray}Usage: /pair revoke ${colors.reset}\n`); return; } const [channel, senderId] = parts; const revoked = pm.revokeApproval(channel, senderId); if (revoked) { console.log(`${colors.bold}Revoked approval for ${channel}:${senderId}${colors.reset}\n`); } else { console.log(`${colors.gray}No approval found for ${channel}:${senderId}${colors.reset}\n`); } break; } case 'list': default: { const pending = pm.listPendingCodes(); const approved = pm.listApproved(); if (pending.length === 0 && approved.length === 0) { console.log(`${colors.gray}No pending codes or approved senders.${colors.reset}\n`); return; } if (pending.length > 0) { console.log(`${colors.bold}Pending codes:${colors.reset}`); for (const p of pending) { const expiresIn = Math.round((p.expiresAt - Date.now()) / 1000); console.log(` ${p.code} expires in ${expiresIn}s${p.label ? ` (${p.label})` : ''}`); } } if (approved.length > 0) { console.log(`${colors.bold}Approved senders:${colors.reset}`); for (const a of approved) { const date = new Date(a.approvedAt).toISOString().slice(0, 16).replace('T', ' '); console.log(` ${a.channel}:${a.senderId} since ${date} (code: ${a.codeUsed})`); } } console.log(''); break; } } } ``` **Step 3: Wire PairingManager into cli/tui.ts** In `src/cli/tui.ts`, after the `modelRouter` creation (around line 63) and before the `MinimalTui` construction (line 142), add: ```typescript const { initPairingManager } = await import('../daemon/services.js'); const pairingStore = config.pairing.enabled ? sessionStore.getPairingStore() : undefined; const pairingManager = initPairingManager(config, pairingStore); ``` Then pass it to `MinimalTui` (add to the config object around line 148): ```typescript const tui = new MinimalTui({ session, modelClient: modelRouter, modelRouter, systemPrompt, agent, pairingManager, // <-- add this localProviders: config.models.local_providers, ... ``` **Step 4: Run typecheck** Run: `pnpm typecheck` Expected: No errors **Step 5: Run all tests** Run: `pnpm test:run` Expected: ALL PASS **Step 6: Commit** ```bash git add src/frontends/tui/minimal.ts src/cli/tui.ts git commit -m "feat(tui): wire /pair command execution with PairingManager" ``` --- ## Task 5: Add Missing Tests **Files:** - Modify: `src/frontends/tui/commands.test.ts` (add /pair parsing tests) **Step 1: Add /pair parsing tests** Add a new `describe('/pair command')` block to `src/frontends/tui/commands.test.ts`: ```typescript describe('/pair command', () => { it('parses /pair as list', () => { expect(parseCommand('/pair')).toEqual({ type: 'pair', action: 'list' }); }); it('parses /pair list', () => { expect(parseCommand('/pair list')).toEqual({ type: 'pair', action: 'list' }); }); it('parses /pair generate without label', () => { expect(parseCommand('/pair generate')).toEqual({ type: 'pair', action: 'generate', args: undefined }); }); it('parses /pair generate with label', () => { expect(parseCommand('/pair generate for alice')).toEqual({ type: 'pair', action: 'generate', args: 'for alice' }); }); it('parses /pair revoke with channel and sender', () => { expect(parseCommand('/pair revoke telegram 12345')).toEqual({ type: 'pair', action: 'revoke', args: 'telegram 12345' }); }); }); ``` **Step 2: Run tests to verify they pass** Run: `pnpm test:run src/frontends/tui/commands.test.ts` Expected: ALL PASS (parsing already works, we're just adding test coverage) **Step 3: Commit** ```bash git add src/frontends/tui/commands.test.ts git commit -m "test(tui): add /pair command parsing tests" ``` --- ## Task 6: Update state.json and Run Final Verification **Files:** - Modify: `docs/plans/state.json` (update `dm_pairing_codes` entry) **Step 1: Run full test suite** Run: `pnpm test:run` Expected: ALL PASS **Step 2: Run typecheck** Run: `pnpm typecheck` Expected: No errors **Step 3: Run build** Run: `pnpm build` Expected: Clean build **Step 4: Update state.json** Update the `dm_pairing_codes` entry in `docs/plans/state.json`: - Update `description` to mention SQLite persistence and TUI execution - Add `src/session/store.ts` to `files_modified` - Add `src/daemon/services.ts` to `files_modified` (if not already) - Add `src/cli/tui.ts` to `files_modified` - Add `src/frontends/tui/minimal.ts` to `files_modified` - Update `test_status` count to reflect new tests **Step 5: Commit** ```bash git add docs/plans/state.json git commit -m "docs: update state.json with pairing persistence and TUI execution" ```