Files
flynn/docs/plans/2026-02-09-pairing-tui-persistence.md
T

19 KiB

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:

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):

export interface PairingStore {
  loadApproved(): ApprovedSender[];
  saveApproved(sender: ApprovedSender): void;
  removeApproved(channel: string, senderId: string): void;
}
  1. Update the PairingManager constructor to accept an optional PairingStore:
export class PairingManager {
  private config: PairingConfig;
  private pendingCodes: Map<string, PendingCode> = new Map();
  private approvedSenders: Map<string, ApprovedSender> = 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);
      }
    }
  }
  1. In validateCode(), after this.approvedSenders.set(key, ...) (around line 84), add:
    this.store?.saveApproved(this.approvedSenders.get(key)!);
  1. In revokeApproval(), after this.approvedSenders.delete(key) (around line 100), change to:
  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;
  }
  1. Update src/channels/index.ts to re-export:
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

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:

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:
import type { PairingStore, ApprovedSender } from '../channels/pairing.js';
  1. In init(), add the new table after the existing CREATE TABLE block:
  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)
      );
    `);
  }
  1. Add getPairingStore() method to SessionStore:
  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

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:

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:

  const pairingManager = initPairingManager(config);

to:

  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

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:

import type { PairingManager } from '../../channels/pairing.js';

Add to MinimalTuiConfig interface (around line 42):

  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:

      case 'pair':
        this.handlePairCommand(command.action, command.args);
        break;

Add the handler method to the MinimalTui class:

  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 <channel> <senderId>${colors.reset}\n`);
          return;
        }
        const parts = args.split(/\s+/);
        if (parts.length < 2) {
          console.log(`${colors.gray}Usage: /pair revoke <channel> <senderId>${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:

      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):

        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

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:

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

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

git add docs/plans/state.json
git commit -m "docs: update state.json with pairing persistence and TUI execution"