From 99b7e743f433c853f4283b52072f481f63d68e96 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 9 Feb 2026 22:05:21 -0800 Subject: [PATCH] docs: update state.json with pairing persistence and TUI wiring --- .../2026-02-09-pairing-tui-persistence.md | 600 ++++++++++++++++++ docs/plans/state.json | 13 +- 2 files changed, 609 insertions(+), 4 deletions(-) create mode 100644 docs/plans/2026-02-09-pairing-tui-persistence.md diff --git a/docs/plans/2026-02-09-pairing-tui-persistence.md b/docs/plans/2026-02-09-pairing-tui-persistence.md new file mode 100644 index 0000000..a26b8c7 --- /dev/null +++ b/docs/plans/2026-02-09-pairing-tui-persistence.md @@ -0,0 +1,600 @@ +# 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" +``` diff --git a/docs/plans/state.json b/docs/plans/state.json index f03bb95..96c1912 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -867,7 +867,7 @@ "dm_pairing_codes": { "priority": "Tier4", "status": "completed", - "description": "PairingManager with TTL codes, channel adapter integration (Telegram, Discord, Slack, WhatsApp), gateway pairing handlers (generate/list/revoke), TUI /pair command, daemon wiring.", + "description": "PairingManager with TTL codes, channel adapter integration (Telegram, Discord, Slack, WhatsApp), gateway pairing handlers (generate/list/revoke), TUI /pair command execution, daemon wiring, SQLite persistence via PairingStore interface.", "files_created": [ "src/channels/pairing.ts", "src/channels/pairing.test.ts", @@ -884,9 +884,14 @@ "src/gateway/handlers/handlers.test.ts", "src/gateway/server.ts", "src/daemon/index.ts", - "src/frontends/tui/commands.ts" + "src/daemon/services.ts", + "src/session/store.ts", + "src/frontends/tui/commands.ts", + "src/frontends/tui/commands.test.ts", + "src/frontends/tui/minimal.ts", + "src/cli/tui.ts" ], - "test_status": "22/22 passing (16 pairing + 6 handlers)" + "test_status": "35/35 passing (20 pairing + 6 handlers + 4 store + 5 commands)" } } }, @@ -928,7 +933,7 @@ }, "overall_progress": { - "total_test_count": 1107, + "total_test_count": 1120, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)",