# Plan: Model Persistence with Per-Session Overrides

Status: implemented (2026-02-13)

## Summary

This plan fixes model tier persistence so it works correctly across TUI, WebChat, and Telegram channels, and adds per-session model overrides that survive daemon restarts. Currently the model tier is a single global preference stored in `preferences.json` and propagated via `ModelRouter.setTier()`. This creates three problems:

1. **Global coupling**: Switching models in TUI changes WebChat sessions too (via `SessionBridge.onTierChanged`)
2. **No per-session persistence**: Channel adapter sessions (Telegram, Discord, etc.) can't have independent model overrides
3. **WebChat uses raw NativeAgent**: The `SessionBridge` creates `NativeAgent` instances directly instead of `AgentOrchestrator`, losing delegation, compaction, memory, and proper usage tracking

The solution introduces a `session_config` SQLite table for per-session key-value settings, wires it through `SessionManager`, and changes the model resolution chain to: **session override → agent config → global default**.

## Context

- **Request**: Fix model persistence across all channels with per-session overrides
- **Codebase**: Flynn multi-channel AI assistant daemon with TUI, WebChat (gateway), Telegram, Discord, Slack adapters
- **Constraints**: Must be backwards-compatible; existing `preferences.json` global default must still work; no breaking changes to gateway protocol

## Current Architecture (Problems)

### Problem 1: Global Model Tier Coupling

In `src/daemon/index.ts` (lines 104-110):
```ts
modelRouter.setOnTierChange((tier) => savePreference(dataDir, 'modelTier', tier));
```

In `src/gateway/session-bridge.ts` (lines 38-51):
```ts
// SessionBridge subscribes to tier changes and updates ALL WebChat agents
router.addOnTierChange((tier: ModelTier) => this.onTierChanged(tier));
// ...
private onTierChanged(tier: ModelTier): void {
  this.currentTier = tier;
  for (const agent of this.agents.values()) {
    agent.setModelTier(tier);
  }
}
```

This means `/model fast` in TUI immediately switches all WebChat sessions to `fast` too.

### Problem 2: No Per-Session Config Persistence

The `SessionStore` only stores messages. There's no mechanism to persist per-session settings like model tier overrides. When the daemon restarts, all sessions lose their model tier.

### Problem 3: WebChat Uses NativeAgent Instead of AgentOrchestrator

`SessionBridge.getOrCreateAgent()` (line 186) creates raw `NativeAgent` instances:
```ts
agent = new NativeAgent({
  modelClient: this.config.modelClient,
  systemPrompt: this.config.systemPrompt,
  session,
  toolRegistry: this.config.toolRegistry,
  toolExecutor: this.config.toolExecutor,
});
```

This means WebChat sessions lack: delegation to sub-agents, automatic compaction, memory injection, proper cost tracking, and tool policy context.

### Problem 4: Telegram Has No `/model` Command in Channel Adapter

The `TelegramAdapter` (channel-based) has `/reset` and `/start` commands but no `/model` command. The old `src/frontends/telegram/bot.ts` has `/local`, `/cloud`, `/model` commands but those operate on a shared `NativeAgent`, not per-session.

## Design Decisions

### Decision 1: SQLite `session_config` Table for Per-Session Settings
- **Choice**: Add a `session_config` table with `(session_id, key, value)` columns
- **Rationale**: Sessions already persist in SQLite; config is a natural extension. Key-value is flexible enough for future settings (not just model tier).
- **Alternatives considered**: JSON file per session (too many files), adding columns to messages table (wrong abstraction), in-memory only (lost on restart)

### Decision 2: Three-Level Model Resolution Chain
- **Choice**: Session override → Agent config → Global default
- **Rationale**: Allows users to override per-session while falling back to agent routing config and then global preferences. This matches the existing pattern in `routing.ts` line 63.
- **Alternatives considered**: Two-level (session → global) loses agent config routing; single global (current) is too coarse

### Decision 3: WebChat Upgrade to AgentOrchestrator
- **Choice**: Replace `NativeAgent` with `AgentOrchestrator` in `SessionBridge`
- **Rationale**: WebChat sessions should have feature parity with channel adapter sessions (delegation, compaction, memory). This is a prerequisite for per-session model persistence to work properly.
- **Alternatives considered**: Keep NativeAgent but add a tier override field (doesn't fix delegation/compaction gap)

### Decision 4: Model Commands via Channel Message Metadata
- **Choice**: Handle `/model` in Telegram adapter by sending metadata through the message handler, and in WebChat via the agent.send handler
- **Rationale**: Consistent with existing `/reset` pattern — commands are dispatched as `InboundMessage` with `metadata.isCommand = true`
- **Alternatives considered**: Separate RPC endpoint for model switching (adds protocol complexity)

## Implementation Steps

### Step 1: Database Schema — `session_config` Table

**Files**: `src/session/store.ts`

**Changes**:
- [ ] Add `session_config` table creation in `init()` method
- [ ] Add CRUD methods for session config

**Details**:

In `init()` (line 22), add to the `this.db.exec()` call:

```sql
CREATE TABLE IF NOT EXISTS session_config (
  session_id TEXT NOT NULL,
  key TEXT NOT NULL,
  value TEXT NOT NULL,
  PRIMARY KEY (session_id, key)
);
```

Add these new methods to `SessionStore`:

```ts
/** Get a single config value for a session. */
getSessionConfig(sessionId: string, key: string): string | undefined {
  const stmt = this.db.prepare(
    'SELECT value FROM session_config WHERE session_id = ? AND key = ?',
  );
  const row = stmt.get(sessionId, key) as { value: string } | undefined;
  return row?.value;
}

/** Get all config values for a session. */
getAllSessionConfig(sessionId: string): Record<string, string> {
  const stmt = this.db.prepare(
    'SELECT key, value FROM session_config WHERE session_id = ?',
  );
  const rows = stmt.all(sessionId) as Array<{ key: string; value: string }>;
  const result: Record<string, string> = {};
  for (const row of rows) {
    result[row.key] = row.value;
  }
  return result;
}

/** Set a config value for a session. Upserts (INSERT OR REPLACE). */
setSessionConfig(sessionId: string, key: string, value: string): void {
  this.db.prepare(
    'INSERT OR REPLACE INTO session_config (session_id, key, value) VALUES (?, ?, ?)',
  ).run(sessionId, key, value);
}

/** Delete a config value for a session. */
deleteSessionConfig(sessionId: string, key: string): void {
  this.db.prepare(
    'DELETE FROM session_config WHERE session_id = ? AND key = ?',
  ).run(sessionId, key);
}

/** Delete all config for a session (used when session is cleared/pruned). */
clearSessionConfig(sessionId: string): void {
  this.db.prepare(
    'DELETE FROM session_config WHERE session_id = ?',
  ).run(sessionId);
}
```

Also update `clearSession()` (line 80) to also clear config:

```ts
clearSession(sessionId: string): void {
  const transaction = this.db.transaction(() => {
    this.db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
    this.db.prepare('DELETE FROM session_config WHERE session_id = ?').run(sessionId);
  });
  transaction();
}
```

And update `pruneStale()` (line 92) to also clean up config for pruned sessions:

```ts
pruneStale(beforeTimestamp: number): string[] {
  // ... existing stale detection ...
  const deleteMessages = this.db.prepare('DELETE FROM messages WHERE session_id = ?');
  const deleteConfig = this.db.prepare('DELETE FROM session_config WHERE session_id = ?');
  const transaction = this.db.transaction(() => {
    for (const { session_id } of stale) {
      deleteMessages.run(session_id);
      deleteConfig.run(session_id);
    }
  });
  transaction();
  return stale.map(r => r.session_id);
}
```

---

### Step 2: SessionManager Config Methods

**Files**: `src/session/manager.ts`

**Changes**:
- [ ] Add `getSessionConfig()`, `setSessionConfig()`, `deleteSessionConfig()` methods to `SessionManager`
- [ ] Add config methods to `ManagedSession`
- [ ] Update `Session` interface

**Details**:

Update the `Session` interface (line 5):

```ts
export interface Session {
  id: string;
  addMessage(message: Message): void;
  getHistory(): Message[];
  clear(): void;
  replaceHistory(messages: Message[]): void;
  getConfig(key: string): string | undefined;
  setConfig(key: string, value: string): void;
  deleteConfig(key: string): void;
}
```

Add methods to `ManagedSession` (after line 66):

```ts
getConfig(key: string): string | undefined {
  return this.store.getSessionConfig(this.id, key);
}

setConfig(key: string, value: string): void {
  this.store.setSessionConfig(this.id, key, value);
}

deleteConfig(key: string): void {
  this.store.deleteSessionConfig(this.id, key);
}
```

Add convenience methods to `SessionManager` (after line 131):

```ts
/** Get a session config value. */
getSessionConfig(frontend: string, userId: string, key: string): string | undefined {
  const session = this.getSession(frontend, userId);
  return session.getConfig(key);
}

/** Set a session config value. */
setSessionConfig(frontend: string, userId: string, key: string, value: string): void {
  const session = this.getSession(frontend, userId);
  session.setConfig(key, value);
}

/** Delete a session config value. */
deleteSessionConfig(frontend: string, userId: string, key: string): void {
  const session = this.getSession(frontend, userId);
  session.deleteConfig(key);
}
```

Update `src/session/index.ts` to export the new `Session` interface properly (already done, `Session` is re-exported).

---

### Step 3: Model Resolution in `routing.ts`

**Files**: `src/daemon/routing.ts`

**Changes**:
- [ ] Read per-session model tier override when creating/reusing an agent
- [ ] Persist model tier to session config when it changes
- [ ] Add `/model` command handling in the message handler

**Details**:

In `getOrCreateAgent()` (line 43), after getting the session, read the session config:

```ts
const session = deps.sessionManager.getSession(channel, senderId);

// Read per-session model tier override (persisted in SQLite)
const sessionTierOverride = session.getConfig('modelTier') as ModelTier | undefined;

// Resolution chain: metadata (cron) → session override → agent config → global default
const effectiveTier = tierFromMetadata
  ?? sessionTierOverride
  ?? agentConfig?.modelTier
  ?? deps.config.agents.primary_tier
  ?? 'default';
```

This replaces line 63 which currently doesn't check session config.

Add a `/model` command handler in the `handler` function (after the existing `/usage` handler, around line 212):

```ts
if (msg.metadata.command === 'model') {
  const modelArg = msg.metadata.commandArgs as string | undefined;
  const { orchestrator: agent } = getOrCreateAgent(msg.channel, msg.senderId, msg.metadata);
  const session = deps.sessionManager.getSession(msg.channel, msg.senderId);

  if (!modelArg) {
    // Show current model tier
    const currentTier = agent.getModelTier();
    const sessionOverride = session.getConfig('modelTier');
    const available = deps.modelRouter.getAvailableTiers();
    const labels = deps.modelRouter.getAllLabels();
    const lines = [`Active tier: ${currentTier}${sessionOverride ? ' (session override)' : ''}`];
    for (const tier of available) {
      const label = labels[tier] ?? 'unknown';
      const marker = tier === currentTier ? ' ←' : '';
      lines.push(`  ${tier}: ${label}${marker}`);
    }
    await reply({ text: lines.join('\n'), replyTo: msg.id });
    return;
  }

  // Resolve alias and switch tier
  const tier = resolveModelAlias(modelArg);
  if (!deps.modelRouter.getAvailableTiers().includes(tier)) {
    await reply({ text: `Model tier not available: ${modelArg}`, replyTo: msg.id });
    return;
  }

  // Persist to session config
  session.setConfig('modelTier', tier);

  // Update the orchestrator's agent tier
  agent.setModelTier(tier);

  const label = deps.modelRouter.getLabel(tier);
  await reply({ text: `Switched to model: ${tier} (${label})`, replyTo: msg.id });
  return;
}
```

Also add the `resolveModelAlias` import at the top:

```ts
import { resolveModelAlias } from '../frontends/tui/commands.js';
```

And update the audio tier resolution section (lines 217-228) to also check session override:

```ts
let effectiveTier: string = deps.config.agents.primary_tier ?? 'default';
const session = deps.sessionManager.getSession(msg.channel, msg.senderId);
const sessionTierOverride = session.getConfig('modelTier');

if (msg.metadata?.modelTier) {
  effectiveTier = msg.metadata.modelTier as string;
} else if (sessionTierOverride) {
  effectiveTier = sessionTierOverride;
} else if (deps.agentRouter && deps.agentConfigRegistry) {
  // ... existing agent config lookup ...
}
```

**Important**: The agent cache in `routing.ts` keys on session ID + agent config name. When the model tier changes per-session, we need to invalidate the cached agent or update it in place. Since `AgentOrchestrator.setModelTier()` already exists and updates the underlying `NativeAgent`, updating in place is sufficient — no cache invalidation needed.

However, note that `tierFromMetadata` (for cron jobs) currently affects the cache key (line 55). Per-session overrides should NOT affect the cache key because the agent is already created and we update it in place.

---

### Step 4: Reset Command — Clear Session Overrides

**Files**: `src/daemon/routing.ts`

**Changes**:
- [ ] When `/reset` is handled, also clear session config

**Details**:

In the reset handler (line 167):

```ts
if (msg.metadata.command === 'reset') {
  agent.reset();
  // Clear per-session config overrides
  const session = deps.sessionManager.getSession(msg.channel, msg.senderId);
  session.deleteConfig('modelTier');
  return;
}
```

---

### Step 5: WebChat SessionBridge — Upgrade to AgentOrchestrator

**Files**: `src/gateway/session-bridge.ts`

**Changes**:
- [ ] Replace `NativeAgent` with `AgentOrchestrator`
- [ ] Accept additional config for orchestrator creation (delegation, compaction, memory, etc.)
- [ ] Remove global tier sync (each session manages its own tier)
- [ ] Load per-session tier from session config on creation

**Details**:

Update the interface and imports:

```ts
import { AgentOrchestrator, type DelegationConfig } from '../backends/native/orchestrator.js';
import type { MemoryStore } from '../memory/store.js';
import type { Config } from '../config/index.js';
// Remove NativeAgent import (no longer directly used)
```

Update `SessionBridgeConfig`:

```ts
export interface SessionBridgeConfig {
  sessionManager: SessionManager;
  modelClient: ModelClient | ModelRouter;
  systemPrompt: string;
  toolRegistry: ToolRegistry;
  toolExecutor: ToolExecutor;
  config?: Config;
  memoryStore?: MemoryStore;
}
```

Replace `NativeAgent` references with `AgentOrchestrator`:

```ts
interface ClientEntry {
  connectionId: string;
  sessionId: string;
  agent: AgentOrchestrator;
  busy: boolean;
}

export class SessionBridge {
  private clients: Map<string, ClientEntry> = new Map();
  private agents: Map<string, AgentOrchestrator> = new Map();
  // ...
```

**Remove** the `onTierChanged` method and the `addOnTierChange` subscription in the constructor. WebChat sessions should NOT follow TUI tier changes — they manage their own tier via session config.

Update `getOrCreateAgent()`:

```ts
private getOrCreateAgent(sessionId: string): AgentOrchestrator {
  let agent = this.agents.get(sessionId);
  if (!agent) {
    const session = this.config.sessionManager.getSession('ws', sessionId);
    const config = this.config.config;

    // Read per-session tier override from session config
    const sessionTier = session.getConfig?.('modelTier') as ModelTier | undefined;
    const primaryTier = sessionTier ?? config?.agents.primary_tier ?? 'default';

    const delegationConfig: DelegationConfig = {
      compaction: config?.agents.delegation.compaction ?? 'fast',
      memory_extraction: config?.agents.delegation.memory_extraction ?? 'fast',
      classification: config?.agents.delegation.classification ?? 'fast',
      tool_summarisation: config?.agents.delegation.tool_summarisation ?? 'fast',
      complex_reasoning: config?.agents.delegation.complex_reasoning ?? 'complex',
    };

    agent = new AgentOrchestrator({
      modelRouter: this.config.modelClient as ModelRouter,
      systemPrompt: this.config.systemPrompt,
      session,
      toolRegistry: this.config.toolRegistry,
      toolExecutor: this.config.toolExecutor,
      primaryTier,
      delegation: delegationConfig,
      maxDelegationDepth: config?.agents.max_delegation_depth ?? 3,
      maxIterations: config?.agents.max_iterations,
      compaction: config?.compaction.enabled ? {
        thresholdPct: config.compaction.threshold_pct,
        keepTurns: config.compaction.keep_turns,
        summaryMaxTokens: config.compaction.summary_max_tokens,
      } : undefined,
      modelName: config?.models.default.model,
      contextWindow: config?.models.default.context_window,
      memoryStore: this.config.memoryStore,
    });

    this.agents.set(sessionId, agent);
  }
  return agent;
}
```

Update the `getAgent()` method signature and callers — they now get `AgentOrchestrator` which has the same `process()`, `reset()`, `setModelTier()`, `setOnToolUse()` methods.

Update `getAllUsage()` to use `AgentOrchestrator.getUsage()` which returns the richer `UsageReport` format:

```ts
getAllUsage(): Array<{
  sessionId: string;
  primary: { inputTokens: number; outputTokens: number; calls: number };
  delegation: Record<string, { inputTokens: number; outputTokens: number; calls: number }>;
  total: { inputTokens: number; outputTokens: number; calls: number; estimatedCost: number };
}> {
  const results = [];
  const seen = new Set<string>();
  for (const client of this.clients.values()) {
    if (seen.has(client.sessionId)) continue;
    seen.add(client.sessionId);
    const usage = client.agent.getUsage();
    results.push({ sessionId: client.sessionId, ...usage });
  }
  return results;
}
```

---

### Step 6: WebChat Agent Handler — Add `/model` Command

**Files**: `src/gateway/handlers/agent.ts`

**Changes**:
- [ ] Add `/model` command handling in `agent.send`
- [ ] Persist model tier change to session config

**Details**:

In the command handling section (around line 47), add model command support:

```ts
if (params.metadata?.isCommand) {
  try {
    if (params.metadata.command === 'reset') {
      agent.reset();
      // Clear session config
      const sessionId = deps.sessionBridge.getSessionId(connectionId);
      if (sessionId) {
        const session = deps.sessionManager.getSession('ws', sessionId);
        session.deleteConfig('modelTier');
      }
      send(makeEvent(request.id, 'done', { content: 'Session reset.' }));
      return;
    }

    if (params.metadata.command === 'model') {
      const modelArg = params.metadata.commandArgs as string | undefined;
      const sessionId = deps.sessionBridge.getSessionId(connectionId);

      if (!modelArg) {
        // Show current tier info
        const currentTier = agent.getModelTier();
        send(makeEvent(request.id, 'done', {
          content: `Current model tier: ${currentTier}`,
        }));
        return;
      }

      // Validate tier
      const validTiers = ['fast', 'default', 'complex', 'local'];
      const tier = modelArg as ModelTier;
      if (!validTiers.includes(tier)) {
        send(makeEvent(request.id, 'done', {
          content: `Invalid tier: ${modelArg}. Available: ${validTiers.join(', ')}`,
        }));
        return;
      }

      // Update agent tier
      agent.setModelTier(tier);

      // Persist to session config
      if (sessionId) {
        const session = deps.sessionManager.getSession('ws', sessionId);
        session.setConfig('modelTier', tier);
      }

      send(makeEvent(request.id, 'done', {
        content: `Switched to model tier: ${tier}`,
      }));
      return;
    }
  } finally {
    deps.sessionBridge.setBusy(connectionId, false);
    deps.metrics?.endRequest(requestId);
  }
}
```

This requires updating `AgentHandlerDeps` to include `sessionManager`:

```ts
export interface AgentHandlerDeps {
  sessionBridge: SessionBridge;
  laneQueue: LaneQueue;
  metrics?: MetricsCollector;
  sessionManager?: SessionManager;
}
```

And updating the handler registration in `server.ts` (line 126):

```ts
const agentHandlers = createAgentHandlers({
  sessionBridge: this.sessionBridge,
  laneQueue: this.laneQueue,
  metrics: this.metrics,
  sessionManager: this.config.sessionManager,
});
```

---

### Step 7: Telegram Adapter — Add `/model` Command

**Files**: `src/channels/telegram/adapter.ts`

**Changes**:
- [ ] Add `/model` command handler that dispatches through the message handler

**Details**:

After the `/reset` command handler (line 150), add:

```ts
this.bot.command('model', async (ctx) => {
  if (!this.messageHandler) return;

  const args = ctx.message?.text?.replace(/^\/model\s*/, '').trim() ?? '';

  this.messageHandler({
    id: String(ctx.message?.message_id ?? Date.now()),
    channel: 'telegram',
    senderId: String(ctx.chat.id),
    senderName: ctx.from?.first_name,
    text: `/model ${args}`.trim(),
    timestamp: Date.now(),
    metadata: {
      isCommand: true,
      command: 'model',
      commandArgs: args || undefined,
    },
  });
});
```

Also add `/local` and `/cloud` as convenience aliases:

```ts
this.bot.command('local', async (ctx) => {
  if (!this.messageHandler) return;
  this.messageHandler({
    id: String(ctx.message?.message_id ?? Date.now()),
    channel: 'telegram',
    senderId: String(ctx.chat.id),
    senderName: ctx.from?.first_name,
    text: '/model local',
    timestamp: Date.now(),
    metadata: { isCommand: true, command: 'model', commandArgs: 'local' },
  });
});

this.bot.command('cloud', async (ctx) => {
  if (!this.messageHandler) return;
  this.messageHandler({
    id: String(ctx.message?.message_id ?? Date.now()),
    channel: 'telegram',
    senderId: String(ctx.chat.id),
    senderName: ctx.from?.first_name,
    text: '/model default',
    timestamp: Date.now(),
    metadata: { isCommand: true, command: 'model', commandArgs: 'default' },
  });
});
```

---

### Step 8: Update Daemon `index.ts` — Stop Global Tier Sync

**Files**: `src/daemon/index.ts`

**Changes**:
- [ ] Keep `modelRouter.setOnTierChange` for persisting the global default, but document that this is the GLOBAL default only
- [ ] Remove `modelRouter.setOnTierChange` from triggering session-level changes

**Details**:

The current code (lines 104-110) is fine for the GLOBAL default:

```ts
// Restore persisted global model tier default
if (prefs.modelTier) {
  modelRouter.setTier(prefs.modelTier as ModelTier);
}
// Persist global default when changed via TUI
modelRouter.setOnTierChange((tier) => savePreference(dataDir, 'modelTier', tier));
```

This is correct — TUI `/model` changes the global default. The SessionBridge no longer syncs to this (per Step 5), so WebChat sessions are unaffected. Channel adapter sessions use their own session config.

No changes needed here, but the SessionBridge change in Step 5 is what decouples WebChat from this global sync.

---

### Step 9: Pass Additional Config to SessionBridge

**Files**: `src/daemon/services.ts`

**Changes**:
- [ ] Pass `config` and `memoryStore` to `GatewayServer` → `SessionBridge`

**Details**:

In `createGateway()` (line 126), the `GatewayServer` constructor already receives `config`. We need to also pass `memoryStore` if available.

Update `GatewayDeps` in `services.ts`:

```ts
export interface GatewayDeps {
  // ... existing fields ...
  memoryStore?: MemoryStore;
}
```

Update `createGateway()` to pass through:

```ts
const gateway = new GatewayServer({
  // ... existing config ...
  memoryStore: deps.memoryStore,
});
```

Update `GatewayServerConfig` in `server.ts`:

```ts
export interface GatewayServerConfig {
  // ... existing fields ...
  memoryStore?: MemoryStore;
}
```

And pass it to `SessionBridge` in `GatewayServer` constructor:

```ts
this.sessionBridge = new SessionBridge({
  sessionManager: config.sessionManager,
  modelClient: config.modelClient,
  systemPrompt: config.systemPrompt,
  toolRegistry: config.toolRegistry,
  toolExecutor: config.toolExecutor,
  config: config.config,
  memoryStore: config.memoryStore,
});
```

Update `startDaemon()` in `daemon/index.ts` to pass `memoryStore`:

```ts
const gateway = createGateway({
  config, sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor,
  channelRegistry, pairingManager, lifecycle,
  getChannelAgents: () => channelAgents,
  memoryStore,  // ADD THIS
});
```

This requires extracting `memoryStore` from `initMemory()` result (it's already destructured on line 97).

---

### Step 10: Update `InboundMessage` metadata Type

**Files**: `src/channels/types.ts`

**Changes**:
- [ ] Add `commandArgs` to the metadata type

**Details**:

Check if `metadata` is typed or `Record<string, unknown>`. If typed, add:

```ts
metadata?: {
  isCommand?: boolean;
  command?: string;
  commandArgs?: string;
  // ... other existing fields
  [key: string]: unknown;
};
```

If it's already `Record<string, unknown>`, no change needed (the field is dynamically accessed).

---

### Step 11: Gateway `agent.send` Handler — Update for AgentOrchestrator

**Files**: `src/gateway/handlers/agent.ts`

**Changes**:
- [ ] Update `agent.send` to work with `AgentOrchestrator` instead of `NativeAgent`
- [ ] The API is the same: `process()`, `reset()`, `setOnToolUse()` all exist on both

**Details**:

Since `AgentOrchestrator` exposes the same public API as `NativeAgent` for the operations used in `agent.ts`, the handler code largely works unchanged. The `getAgent()` method on `SessionBridge` now returns `AgentOrchestrator` instead of `NativeAgent`.

Key method compatibility check:
- `agent.process(message, attachments)` ✅ (exists on both)
- `agent.reset()` ✅ (exists on both)
- `agent.setOnToolUse(callback)` ✅ (exists on both)
- `agent.getUsage()` — returns `UsageReport` on orchestrator vs `{inputTokens, outputTokens, calls}` on NativeAgent. The handler at line 96 just calls `agent.process()`, so no usage call here.
- `agent.getModelTier()` ✅ (exists on both)
- `agent.setModelTier(tier)` ✅ (exists on both)

The `ToolUseEvent` type is the same for both.

---

## File Change Summary

| File | Change Type | Description |
|------|------------|-------------|
| `src/session/store.ts` | Modified | Add `session_config` table + CRUD methods |
| `src/session/manager.ts` | Modified | Add config methods to Session interface, ManagedSession, SessionManager |
| `src/daemon/routing.ts` | Modified | Session config resolution, `/model` command handler |
| `src/gateway/session-bridge.ts` | Modified | Replace NativeAgent with AgentOrchestrator, remove global tier sync |
| `src/gateway/handlers/agent.ts` | Modified | Add `/model` + `/reset` config clearing |
| `src/gateway/server.ts` | Modified | Pass sessionManager to agent handlers, memoryStore to SessionBridge |
| `src/channels/telegram/adapter.ts` | Modified | Add `/model`, `/local`, `/cloud` commands |
| `src/daemon/services.ts` | Modified | Pass memoryStore through to gateway |
| `src/daemon/index.ts` | Modified | Pass memoryStore to createGateway |
| `src/channels/types.ts` | Modified | Add `commandArgs` to metadata (if typed) |

## Testing

Validation run on 2026-02-13: `pnpm typecheck`, targeted model-persistence tests, full `pnpm test:run` (1586/1586), and `pnpm build` all passing.

### Unit Tests

- [x] **`src/session/store.test.ts`**: Test `session_config` CRUD — set, get, getAll, delete, clearAll
- [x] **`src/session/store.test.ts`**: Test `clearSession()` also clears config
- [x] **`src/session/store.test.ts`**: Test `pruneStale()` also cleans config
- [x] **`src/session/manager.test.ts`**: Test `ManagedSession.getConfig/setConfig/deleteConfig`
- [x] **`src/daemon/routing.test.ts`**: Test model resolution chain (session → agent → global)
- [x] **`src/daemon/routing.test.ts`**: Test `/model` command sets session config and updates agent tier
- [x] **`src/daemon/routing.test.ts`**: Test `/reset` command clears session config
- [x] **`src/gateway/session-bridge.test.ts`**: Test that tier changes in TUI do NOT affect WebChat sessions
- [x] **`src/gateway/session-bridge.test.ts`**: Test that new agents load tier from session config
- [x] **`src/gateway/handlers/agent.test.ts`**: Test `agent.send` with model command

### Integration Tests

- [ ] Start daemon, set model in TUI, verify WebChat session stays on its own model
- [ ] Set model via Telegram `/model fast`, restart daemon, verify Telegram session restores `fast`
- [ ] Set model via WebChat, reconnect WebSocket, verify session restores the tier
- [ ] `/reset` in any channel clears the model override back to global default

### Manual Tests

- [ ] TUI: `/model fast` → check TUI uses fast, WebChat still uses default
- [ ] Telegram: `/model local` → check Telegram uses local, TUI unaffected
- [ ] WebChat: send `{ "method": "agent.send", "params": { "message": "/model fast", "metadata": { "isCommand": true, "command": "model", "commandArgs": "fast" } } }` → verify tier switch
- [ ] Restart daemon → verify all channel sessions restore their per-session model tier

## Risks and Mitigations

| Risk | Mitigation |
|------|------------|
| WebChat upgrade to AgentOrchestrator may change behavior | AgentOrchestrator wraps NativeAgent with identical public API; test thoroughly |
| Cached agents in routing.ts may get stale tier | `setModelTier()` updates in place, no cache invalidation needed |
| Session config table migration on existing DBs | `CREATE TABLE IF NOT EXISTS` is safe; no migration needed |
| Breaking changes to gateway protocol | No protocol changes — uses existing `metadata.isCommand` pattern |
| Memory/performance impact of per-session config | Minimal — single row per session per config key; read on agent creation only |
| Existing `preferences.json` global default conflicts with session override | Clear precedence chain: session → agent config → global. Documented above |

## Open Questions

1. **Should `/model` in TUI also persist as a session override for the TUI session?** Currently TUI `/model` changes the global `ModelRouter` tier. Should it additionally write to the TUI session config? Recommendation: No — TUI's model switch is intentionally global (affects `preferences.json`). Per-session overrides are for remote channels.

2. **Should the WebChat dashboard show the per-session model tier?** The dashboard currently shows token usage per session. Showing the active model tier per session would be a nice UX enhancement but is out of scope for this plan.

3. **Should we expose a `sessions.config.get` / `sessions.config.set` gateway RPC method?** This would allow the WebChat UI to query/set arbitrary session config. Recommendation: Defer to a follow-up — the `/model` command via `agent.send` is sufficient for now.
