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(addPairingStoreinterface, exportApprovedSender, accept optional store) - Modify:
src/channels/index.ts(re-exportPairingStoreandApprovedSender) - 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:
-
Export the
ApprovedSenderinterface (currently private — just addexport). -
Add
PairingStoreinterface after the existing interfaces (around line 23):
export interface PairingStore {
loadApproved(): ApprovedSender[];
saveApproved(sender: ApprovedSender): void;
removeApproved(channel: string, senderId: string): void;
}
- Update the
PairingManagerconstructor to accept an optionalPairingStore:
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);
}
}
}
- In
validateCode(), afterthis.approvedSenders.set(key, ...)(around line 84), add:
this.store?.saveApproved(this.approvedSenders.get(key)!);
- In
revokeApproval(), afterthis.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;
}
- Update
src/channels/index.tsto 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(addpairing_approvedtable, add methods, addgetPairingStore()) - Modify:
src/session/index.ts(re-exportPairingStoretype 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:
- Import the
PairingStoreandApprovedSendertypes at the top:
import type { PairingStore, ApprovedSender } from '../channels/pairing.js';
- In
init(), add the new table after the existingCREATE TABLEblock:
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)
);
`);
}
- Add
getPairingStore()method toSessionStore:
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(updateinitPairingManagerto 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(updatedm_pairing_codesentry)
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
descriptionto mention SQLite persistence and TUI execution - Add
src/session/store.tstofiles_modified - Add
src/daemon/services.tstofiles_modified(if not already) - Add
src/cli/tui.tstofiles_modified - Add
src/frontends/tui/minimal.tstofiles_modified - Update
test_statuscount 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"