# Task 1789 — Channel-row re-seat Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Re-seating a WhatsApp or Telegram admin row forks it as a channel session and repoints the sender at the fork, instead of forking it to webchat and destroying the channel seat.

**Architecture:** Two changes, both required. The re-seat route reads the source row's `.meta.json` sidecar and builds a channel-shaped fork payload when the source is an admin channel row. A new per-sender forward-pointer file in the account directory, written at that fork, is consulted at inbound resolution so the sender's deterministic id yields the fork instead. The pure spawn-request builders gain an optional override input; the filesystem read stays in `server/index.ts`.

**Tech Stack:** TypeScript, Hono, Vitest, Node `fs`.

## Global Constraints

- Working directory for every command: `maxy-code/platform/ui`.
- Test runner: `npx vitest run <path>`. Full suite: `npm test`.
- Scope is **admin** channel rows only. A `role:'public'` sidecar takes today's webchat path unchanged.
- Channels in scope: `whatsapp` and `telegram`. The standing audit (Task 8) is WhatsApp-only.
- The webchat re-seat payload must stay **byte-for-byte identical** to today. Every task that touches `resolveReseatPlan` re-runs the existing webchat assertions.
- Override reads must never throw. A malformed or absent file reads as `null` and routing falls back to `adminSessionIdFor`.
- Every commit ends with the two trailers:
  ```
  Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  Session: <id from `ls -t /Users/neo/.claude/projects/-Users-neo-getmaxy-maxy-code/*.jsonl | head -1`>
  ```

## File Structure

| File | Responsibility |
|---|---|
| `server/channel-session-override.ts` (new) | Read/write the per-sender forward pointer. Pure filesystem unit, no callers' knowledge. |
| `server/routes/webchat.ts` | `locateSidecar` widened to full `SidecarMeta`; new exported `readSourceRowMeta`. |
| `server/routes/admin/session-reseat.ts` | Sidecar read, channel-shaped payload, override write, lifeline fields. |
| `app/lib/whatsapp/gateway/spawn-request.ts` | Optional `overrideSessionId`. |
| `app/lib/telegram/gateway/spawn-request.ts` | Optional `overrideSessionId`. |
| `server/index.ts` | Resolve account dir, read override at both spawn sites, inject gateway dep, wire unseated audit. |
| `app/lib/whatsapp/gateway/wa-gateway.ts` | Detach-time map; `resolveSessionOverride` dep for the `op=inbound` line. |
| `app/lib/whatsapp/gateway/routes.ts` | `onAttached`/`onDetached` hooks. |
| `app/lib/telegram/gateway/telegram-gateway.ts` | `resolveSessionOverride` dep for the `op=inbound` line. |
| `app/lib/whatsapp/gateway/duplicate-sender-audit.ts` | `op=sender-unseated` emitter. |
| `app/components/SessionReseatControl.tsx` | Comment correction. |
| `../../.docs/admin-webchat-native-channel.md` | Document the override and the unbound window. |

---

### Task 1: The override file

**Files:**
- Create: `server/channel-session-override.ts`
- Test: `server/__tests__/channel-session-override.test.ts`

**Interfaces:**
- Consumes: nothing.
- Produces:
  - `channelOverridePath(accountDir: string): string`
  - `readChannelOverrideId(accountDir: string, channel: string, senderId: string): string | null`
  - `writeChannelOverrideId(accountDir: string, channel: string, senderId: string, sessionId: string): void`

- [ ] **Step 1: Write the failing test**

Create `server/__tests__/channel-session-override.test.ts`:

```ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { readChannelOverrideId, writeChannelOverrideId, channelOverridePath } from '../channel-session-override'

const FORK = 'fc397054-1111-4111-8111-111111111111'
const OTHER = 'aa11bb22-2222-4222-8222-222222222222'

let dir: string
beforeEach(() => {
  dir = mkdtempSync(join(tmpdir(), 'chan-override-'))
})
afterEach(() => {
  rmSync(dir, { recursive: true, force: true })
})

describe('channel-session-override', () => {
  it('round-trips a fork id for one sender', () => {
    writeChannelOverrideId(dir, 'whatsapp', '447504472444', FORK)
    expect(readChannelOverrideId(dir, 'whatsapp', '447504472444')).toBe(FORK)
  })

  it('returns null for an unwritten sender', () => {
    writeChannelOverrideId(dir, 'whatsapp', '447504472444', FORK)
    expect(readChannelOverrideId(dir, 'whatsapp', '447958391734')).toBeNull()
  })

  it('returns null when no file exists at all', () => {
    expect(readChannelOverrideId(dir, 'whatsapp', '447504472444')).toBeNull()
  })

  it('partitions by channel', () => {
    writeChannelOverrideId(dir, 'whatsapp', '123', FORK)
    expect(readChannelOverrideId(dir, 'telegram', '123')).toBeNull()
  })

  it('preserves other senders on a second write', () => {
    writeChannelOverrideId(dir, 'whatsapp', '111', FORK)
    writeChannelOverrideId(dir, 'telegram', '222', OTHER)
    expect(readChannelOverrideId(dir, 'whatsapp', '111')).toBe(FORK)
    expect(readChannelOverrideId(dir, 'telegram', '222')).toBe(OTHER)
  })

  it('overwrites the same sender on re-seat of a fork', () => {
    writeChannelOverrideId(dir, 'whatsapp', '111', FORK)
    writeChannelOverrideId(dir, 'whatsapp', '111', OTHER)
    expect(readChannelOverrideId(dir, 'whatsapp', '111')).toBe(OTHER)
  })

  it('reads null rather than throwing on a malformed file', () => {
    writeFileSync(channelOverridePath(dir), '{ not json', 'utf8')
    expect(() => readChannelOverrideId(dir, 'whatsapp', '111')).not.toThrow()
    expect(readChannelOverrideId(dir, 'whatsapp', '111')).toBeNull()
  })

  it('reads null when the stored value is not a UUID', () => {
    writeFileSync(channelOverridePath(dir), JSON.stringify({ bySender: { 'whatsapp:111': 'nope' } }), 'utf8')
    expect(readChannelOverrideId(dir, 'whatsapp', '111')).toBeNull()
  })
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run server/__tests__/channel-session-override.test.ts`
Expected: FAIL — cannot resolve `../channel-session-override`.

- [ ] **Step 3: Write minimal implementation**

Create `server/channel-session-override.ts`:

```ts
// Task 1789 — per-sender channel forward pointer.
//
// A channel session's id is adminSessionIdFor(accountId, senderId): sha256
// shaped into a v4 UUID, a pure function. When an operator re-seats a channel
// row, the fork gets a fresh id that hash will never return, so every future
// inbound from that sender would resolve back to the stranded original. This
// per-account file points the sender forward at the fork, keyed by
// `<channel>:<senderId>`.
//
// This is NOT Task 1526's per-firing seat, which Task 1746 removed. That seat
// was salted per scheduled dispatch and minted a NEW session every firing,
// which then attached under the sender's own hub key and swallowed their real
// messages. This pointer is stable: one session, repointed once by an operator
// action, resolving identically for every subsequent inbound. It can never
// yield a second id for a sender, so it cannot split a conversation.

import { readFileSync, writeFileSync, renameSync } from 'node:fs'
import { join } from 'node:path'

const FILE = 'channel-session-override.json'
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/

export function channelOverridePath(accountDir: string): string {
  return join(accountDir, FILE)
}

function senderKey(channel: string, senderId: string): string {
  return `${channel}:${senderId}`
}

type OverrideFile = { bySender?: Record<string, unknown> }

function readRaw(accountDir: string): OverrideFile | null {
  try {
    return JSON.parse(readFileSync(channelOverridePath(accountDir), 'utf8')) as OverrideFile
  } catch {
    return null
  }
}

/** The re-seated session id for this sender, or null when unset, malformed, or
 *  not UUID-shaped. Never throws: a corrupt file must degrade to the
 *  deterministic id, never wedge inbound routing. */
export function readChannelOverrideId(accountDir: string, channel: string, senderId: string): string | null {
  const raw = readRaw(accountDir)
  if (!raw || !raw.bySender || typeof raw.bySender !== 'object') return null
  const v = raw.bySender[senderKey(channel, senderId)]
  return typeof v === 'string' && UUID.test(v) ? v : null
}

/** Point this sender forward at a re-seated session. Merges into `bySender`,
 *  preserving every other sender's entry. Atomic tmp+rename so a concurrent
 *  read never sees a half-written file. */
export function writeChannelOverrideId(accountDir: string, channel: string, senderId: string, sessionId: string): void {
  const raw = readRaw(accountDir)
  const bySender: Record<string, string> = {}
  if (raw?.bySender && typeof raw.bySender === 'object') {
    for (const [k, v] of Object.entries(raw.bySender)) {
      if (typeof v === 'string') bySender[k] = v
    }
  }
  bySender[senderKey(channel, senderId)] = sessionId
  const path = channelOverridePath(accountDir)
  const tmp = `${path}.${process.pid}.tmp`
  writeFileSync(tmp, JSON.stringify({ bySender }, null, 2), 'utf8')
  renameSync(tmp, path)
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npx vitest run server/__tests__/channel-session-override.test.ts`
Expected: PASS, 8 tests.

- [ ] **Step 5: Commit**

```bash
git add platform/ui/server/channel-session-override.ts platform/ui/server/__tests__/channel-session-override.test.ts
git commit -m "feat(1789): per-sender channel session forward pointer"
```

---

### Task 2: Expose the source row's full sidecar

`locateSidecar` in `server/routes/webchat.ts` scans project slugs for a session's `.meta.json` but returns only three fields. The re-seat route needs `role` and `senderId` too. Widen it to return the full `SidecarMeta` and add an exported resolver that mirrors `readTargetOwner`'s two-path lookup (sidecar scan, then the meta adjacent to a located JSONL).

**Files:**
- Modify: `server/routes/webchat.ts:78-115`

**Interfaces:**
- Consumes: `readSidecarMeta`, `SidecarMeta` from `server/routes/admin/sidebar-sessions.ts` (already imported by this file).
- Produces: `readSourceRowMeta(sessionId: string): SidecarMeta | null` — the row's sidecar, or null when the session is unknown.

- [ ] **Step 1: Widen `locateSidecar` to the full sidecar**

Replace the signature and return at `server/routes/webchat.ts:78` and its two return sites:

```ts
function locateSidecar(sessionId: string): SidecarMeta | null {
  const cfg = claudeConfigDir()
  if (!cfg) return null
  const projectsRoot = join(cfg, 'projects')
  let slugs: string[]
  try {
    slugs = readdirSync(projectsRoot, { withFileTypes: true })
      .filter((d) => d.isDirectory())
      .map((d) => d.name)
  } catch {
    return null
  }
  for (const slug of slugs) {
    const metaPath = join(projectsRoot, slug, `${sessionId}.meta.json`)
    // Task 1789 — the whole sidecar, not a hand-picked triple: the re-seat
    // route needs role/senderId to decide whether the fork is channel-shaped.
    if (existsSync(metaPath)) return readSidecarMeta(metaPath)
  }
  return null
}
```

Ensure `SidecarMeta` is in the existing type import from `./admin/sidebar-sessions`. The two existing call sites (`:108` in `readTargetOwner`, `:657`) already read `.adminUserId` / `.channel` / `.accountId`, which the wider type still carries — no changes needed there.

- [ ] **Step 2: Add the exported resolver**

Insert immediately after `readTargetOwner` (around `server/routes/webchat.ts:115`):

```ts
/** Task 1789 — the source row's sidecar for the re-seat route, resolved by the
 *  same two paths as {@link readTargetOwner}: the slug-top-level scan for a
 *  JSONL-less fresh target, then the meta adjacent to a located JSONL (incl.
 *  archived). null when the session is unknown. */
export function readSourceRowMeta(sessionId: string): SidecarMeta | null {
  const sidecar = locateSidecar(sessionId)
  if (sidecar !== null) return sidecar
  const { projectDir } = locateSession(sessionId)
  if (projectDir !== null) return readSidecarMeta(join(projectDir, `${sessionId}.meta.json`))
  return null
}
```

- [ ] **Step 3: Run the existing webchat suite to prove no regression**

Run: `npx vitest run server/routes/__tests__/webchat.test.ts`
Expected: PASS, no new failures versus the pre-change baseline.

- [ ] **Step 4: Typecheck**

Run: `npx tsc --noEmit -p tsconfig.json`
Expected: no errors.

- [ ] **Step 5: Commit**

```bash
git add platform/ui/server/routes/webchat.ts
git commit -m "refactor(1789): widen locateSidecar to full SidecarMeta, export readSourceRowMeta"
```

---

### Task 3: `resolveReseatPlan` builds a channel-shaped payload

**Files:**
- Modify: `server/routes/admin/session-reseat.ts:45-81`
- Test: `server/routes/admin/__tests__/session-reseat.test.ts`

**Interfaces:**
- Consumes: `readSourceRowMeta` (Task 2) — used by the route in Task 4, not here.
- Produces: `resolveReseatPlan(body, mintId?, adminUserId?, authenticatedName?, source?)` where the new fifth positional parameter is:
  ```ts
  source?: { channel: string | null; senderId: string | null; role: string | null; accountId: string | null } | null
  ```

- [ ] **Step 1: Write the failing tests**

Append to `server/routes/admin/__tests__/session-reseat.test.ts`:

```ts
const WA_SOURCE = { channel: 'whatsapp', senderId: '447504472444', role: 'admin', accountId: '5e99bba5-1111-4111-8111-111111111111' }

describe('resolveReseatPlan — channel rows (Task 1789)', () => {
  it('inherits a whatsapp admin source channel and sender', () => {
    const plan = resolveReseatPlan({ fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined, WA_SOURCE)
    expect(plan.payload).toMatchObject({
      sessionId: 'NEW',
      forkFromSessionId: SRC,
      channel: 'whatsapp',
      senderId: '447504472444',
      role: 'admin',
      targetAccountId: WA_SOURCE.accountId,
    })
    expect(plan.payload.webchatChannel).toBeUndefined()
  })

  it('inherits a telegram admin source channel and sender', () => {
    const plan = resolveReseatPlan(
      { fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined,
      { ...WA_SOURCE, channel: 'telegram', senderId: '99887766' },
    )
    expect(plan.payload).toMatchObject({ channel: 'telegram', senderId: '99887766', role: 'admin' })
    expect(plan.payload.webchatChannel).toBeUndefined()
  })

  it('omits targetAccountId when the sidecar predates accountId', () => {
    const plan = resolveReseatPlan(
      { fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined,
      { ...WA_SOURCE, accountId: null },
    )
    expect(plan.payload.channel).toBe('whatsapp')
    expect('targetAccountId' in plan.payload).toBe(false)
  })

  it('takes the webchat path for a public whatsapp source', () => {
    const plan = resolveReseatPlan(
      { fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined,
      { ...WA_SOURCE, role: 'public' },
    )
    expect(plan.payload.channel).toBe('webchat')
    expect(plan.payload.senderId).toBe('session:NEW')
  })

  it('takes the webchat path when the source sidecar has no senderId', () => {
    const plan = resolveReseatPlan(
      { fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined,
      { ...WA_SOURCE, senderId: null },
    )
    expect(plan.payload.channel).toBe('webchat')
  })

  it('takes the webchat path for a webchat source and for no source at all', () => {
    const fromWebchat = resolveReseatPlan(
      { fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1', undefined,
      { channel: 'webchat', senderId: 'session:old', role: 'admin', accountId: null },
    )
    const fromNothing = resolveReseatPlan({ fromSessionId: SRC, model: 'claude-opus-4-8[1m]' }, () => 'NEW', 'user-1')
    expect(fromWebchat.payload).toEqual(fromNothing.payload)
  })
})
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `npx vitest run server/routes/admin/__tests__/session-reseat.test.ts`
Expected: FAIL — the channel assertions see `channel: 'webchat'`.

- [ ] **Step 3: Rewrite the header comment and the function**

Replace `server/routes/admin/session-reseat.ts:45-81` with:

```ts
/** The channel/sender shape a fork inherits from its source row. */
export interface ReseatSource {
  channel: string | null
  senderId: string | null
  role: string | null
  accountId: string | null
}

/** Task 1789 — is this source an admin channel row? All three must hold: the
 *  sidecar says role admin, its channel is a native channel, and it names a
 *  sender. Public rows are excluded deliberately: their session id keys on
 *  personId as well as senderId, so the per-sender forward pointer would be
 *  ambiguous for them, and public re-seat is not evidenced as broken. */
function isAdminChannelSource(source: ReseatSource | null | undefined): source is ReseatSource & { channel: 'whatsapp' | 'telegram'; senderId: string } {
  return (
    !!source &&
    source.role === 'admin' &&
    (source.channel === 'whatsapp' || source.channel === 'telegram') &&
    typeof source.senderId === 'string' &&
    source.senderId.length > 0
  )
}

/**
 * Pure: build the manager /rc-spawn fork payload. `mintId` is injected for
 * deterministic tests; production passes randomUUID. `forkFromSessionId` tells
 * the manager to seed history from the source; `model` pins the fork.
 *
 * Task 1789 — the fork inherits the SOURCE ROW's channel. An admin whatsapp or
 * telegram row forks as that channel, carrying the source's own senderId, so
 * the fork is a channel session rather than a webchat one. Before this, every
 * fork was webchat-shaped with senderId `session:<newId>`, which replaced the
 * per-sender channel server and detached the sender's seat. Every other source
 * — webchat, public, or a row with no sidecar — keeps the webchat shape, keyed
 * on `session:<newId>`, lining up with /send and the reply tool.
 *
 * The channel fork carries NO waChannel/telegramChannel descriptor: this route
 * has no gateway url or server path. The fork therefore has no reply tool until
 * the next inbound rebinds it through ensureChannelSession. That window is
 * logged by the caller (`op=channel-fork-unbound`), because the manager's own
 * `op=channel-binding-lost` invariant is gated on its channel store and cannot
 * fire for a freshly minted fork id.
 */
export function resolveReseatPlan(
  body: { fromSessionId: string; model: string; name?: string; permissionMode?: string; effort?: string },
  mintId: () => string = randomUUID,
  adminUserId?: string,
  authenticatedName?: string,
  source?: ReseatSource | null,
): ReseatPlan {
  const sessionId = mintId()
  const key = `session:${sessionId}`
  const payload: Record<string, unknown> = {
    sessionId,
    forkFromSessionId: body.fromSessionId,
    model: body.model,
    role: 'admin',
  }
  if (isAdminChannelSource(source)) {
    payload.channel = source.channel
    payload.senderId = source.senderId
    // Task 1789 — the fork must run under the SOURCE's account. Without this
    // the manager defaults it to the boot account, so a channel row belonging
    // to a client sub-account would fork into the house account and every
    // Neo4j write the fork makes would land under the wrong scope. Pre-983
    // sidecars carry no accountId; those keep the manager default.
    if (source.accountId) payload.targetAccountId = source.accountId
  } else {
    payload.channel = 'webchat'
    payload.senderId = key
    payload.webchatChannel = webchatChannelDescriptor(key)
  }
  if (body.name) payload.name = body.name
  // Task 928 — carry the picked mode/effort only when supplied; an absent lever
  // leaves the fork on the settings-resolved mode / harness-default effort.
  if (body.permissionMode) payload.permissionMode = body.permissionMode
  if (body.effort) payload.effort = body.effort
  if (adminUserId) payload.adminUserId = adminUserId
  // Task 889 — carry the resolved authenticated-admin name so the manager
  // injects the identity line on the fork's first turn.
  if (authenticatedName) payload.authenticatedName = authenticatedName
  return { sessionId, payload }
}
```

Also update the file's top-of-file header (`session-reseat.ts:1-17`): replace the sentence describing the route as webchat-only with:

```ts
// Operator-initiated model re-seat for an existing admin session — webchat rows
// in the Sessions panel and whatsapp/telegram channel rows alike. Claude reads
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `npx vitest run server/routes/admin/__tests__/session-reseat.test.ts`
Expected: PASS — the new channel tests and every pre-existing webchat test.

- [ ] **Step 5: Commit**

```bash
git add platform/ui/server/routes/admin/session-reseat.ts platform/ui/server/routes/admin/__tests__/session-reseat.test.ts
git commit -m "feat(1789): fork inherits the source row's channel"
```

---

### Task 4: Route wiring — sidecar read, override write, lifeline

**Files:**
- Modify: `server/routes/admin/session-reseat.ts` (route handler, ~`:91-212`)

**Interfaces:**
- Consumes: `readSourceRowMeta` (Task 2), `readChannelOverrideId` / `writeChannelOverrideId` (Task 1), `listValidAccounts` from `app/lib/claude-agent/account`.
- Produces: nothing new for later tasks.

- [ ] **Step 1: Add the imports**

At the top of `server/routes/admin/session-reseat.ts`:

```ts
import { WEBCHAT_ADMIN_KEY, resolvePrimaryAdminUserId, readTargetOwner, ownedByRequester, readSourceRowMeta } from '../webchat'
import { readCanonicalOverrideId, writeCanonicalOverrideId } from '../../canonical-webchat-override'
import { writeChannelOverrideId } from '../../channel-session-override'
import { listValidAccounts } from '../../../app/lib/claude-agent/account'
```

- [ ] **Step 2: Read the source sidecar and pass it to the plan**

Replace the `const plan = resolveReseatPlan(...)` call (currently `:141-146`) with:

```ts
  // Task 1789 — the fork inherits the source row's channel, so read the source
  // sidecar before building the payload. A null sidecar (unknown or husk row)
  // yields today's webchat shape.
  const sourceMeta = readSourceRowMeta(fromSessionId)
  const source = sourceMeta
    ? { channel: sourceMeta.channel, senderId: sourceMeta.senderId, role: sourceMeta.role, accountId: sourceMeta.accountId }
    : null
  const plan = resolveReseatPlan(
    { fromSessionId, model, ...(permissionMode ? { permissionMode } : {}), ...(effort ? { effort } : {}) },
    randomUUID,
    adminUserId,
    authenticatedName,
    source,
  )
  const isChannelFork = plan.payload.channel === 'whatsapp' || plan.payload.channel === 'telegram'
  const reseatReqId = randomUUID()
```

- [ ] **Step 3: Name the source row on the lifeline**

Replace the `op=request` line (currently `:152`) with:

```ts
  // Task 1789 — the lifeline names WHAT KIND of row was re-seated. `outcome=bound`
  // reported that the manager returned, not that the seat survived, so a re-seat
  // that destroyed a channel seat logged as a success.
  console.log(`[chat-reseat] op=request reseatReqId=${reseatReqId} from=${fromSessionId} from-channel=${source?.channel ?? 'none'} from-sender=${source?.senderId ?? 'none'} to-model=${model} to-mode=${permissionMode ?? 'none'} to-effort=${effort ?? 'none'} new=${plan.sessionId}`)
```

- [ ] **Step 4: Write the forward pointer after a successful channel fork**

Insert immediately after the existing canonical-forward `try/catch` block (currently ends `:201`):

```ts
  // Task 1789 — point this sender forward at the fork, so the next inbound
  // resolves to it instead of recomputing the stranded deterministic id.
  // Written under the SOURCE's account dir, the same account the gateway reads
  // under (its effectiveAccountId), so an account-manager-routed sub-account
  // sender resolves its own pointer and never the house's. Best-effort: a
  // failure here must not fail the re-seat, but it does leave the sender on the
  // original session, so it is logged loudly.
  if (isChannelFork) {
    const channel = plan.payload.channel as 'whatsapp' | 'telegram'
    const senderId = plan.payload.senderId as string
    try {
      const dir = source?.accountId
        ? (listValidAccounts().find((a) => a.accountId === source.accountId)?.accountDir ?? null)
        : (resolveAccount()?.accountDir ?? null)
      if (dir) {
        writeChannelOverrideId(dir, channel, senderId, plan.sessionId)
        console.log(`[chat-reseat] op=channel-forward reseatReqId=${reseatReqId} senderId=${senderId} from=${fromSessionId} to=${plan.sessionId}`)
      } else {
        console.error(`[session-reseat] channel-forward-failed reason=no-account-dir accountId=${source?.accountId ?? 'none'} senderId=${senderId}`)
      }
    } catch (err) {
      console.error(`[session-reseat] channel-forward-failed err=${err instanceof Error ? err.message : String(err)} senderId=${senderId}`)
    }
    // Task 1789 — the fork carries no channel descriptor (this route has no
    // gateway url or server path), so it has no reply tool until the next
    // inbound rebinds it via ensureChannelSession. The manager's own
    // op=channel-binding-lost invariant is gated on its channel store, which is
    // empty for a freshly minted fork id, so this window is otherwise silent.
    console.log(`[chat-reseat] op=channel-fork-unbound reseatReqId=${reseatReqId} senderId=${senderId} session=${plan.sessionId} note=binds-on-next-inbound`)
  }
```

**On the chained re-seat.** The write above is unconditional for a channel fork, and that alone handles the chained case: re-seating a fork writes the pointer again at the same `<channel>:<senderId>` key, moving it forward. No `fromSessionId` comparison is needed here, unlike the canonical pointer above it — the canonical pointer is keyed by *admin*, so it must check whether the re-seated row is the one that admin's bare `/chat` resolves to. The channel key is derived from the source row's own sender, so a channel fork is by construction the row the pointer belongs to.

- [ ] **Step 5: Typecheck and run the suite**

Run: `npx tsc --noEmit -p tsconfig.json && npx vitest run server/routes/admin/__tests__/session-reseat.test.ts`
Expected: no type errors; tests PASS.

- [ ] **Step 6: Commit**

```bash
git add platform/ui/server/routes/admin/session-reseat.ts
git commit -m "feat(1789): re-seat writes the channel forward pointer and names the source row"
```

---

### Task 5: Spawn builders accept an override

**Files:**
- Modify: `app/lib/whatsapp/gateway/spawn-request.ts`
- Modify: `app/lib/telegram/gateway/spawn-request.ts`
- Test: `app/lib/whatsapp/gateway/__tests__/spawn-request.test.ts`
- Test: `app/lib/telegram/gateway/__tests__/spawn-request.test.ts`

**Interfaces:**
- Produces: both builders accept an optional `overrideSessionId?: string | null` on their existing input object. When a non-empty string, it becomes `sessionId`; otherwise `adminSessionIdFor(...)` as today.

- [ ] **Step 1: Write the failing tests**

Append to `app/lib/whatsapp/gateway/__tests__/spawn-request.test.ts`:

```ts
const FORK = 'fc397054-1111-4111-8111-111111111111'

describe('buildWaSpawnRequest — session override (Task 1789)', () => {
  it('uses the override when one is set for the sender', () => {
    const req = buildWaSpawnRequest({
      accountId: 'acct', senderId: '+447700900123', role: 'admin', personId: null,
      gatewayUrl: 'http://g', serverPath: '/s', overrideSessionId: FORK,
    })
    expect(req.sessionId).toBe(FORK)
  })

  it('falls back to the deterministic id when the override is null or absent', () => {
    const base = { accountId: 'acct', senderId: '+447700900123', role: 'admin' as const, personId: null, gatewayUrl: 'http://g', serverPath: '/s' }
    expect(buildWaSpawnRequest({ ...base, overrideSessionId: null }).sessionId).toBe(adminSessionIdFor('acct', '+447700900123'))
    expect(buildWaSpawnRequest(base).sessionId).toBe(adminSessionIdFor('acct', '+447700900123'))
  })
})
```

Append the mirror to `app/lib/telegram/gateway/__tests__/spawn-request.test.ts`:

```ts
const FORK = 'fc397054-1111-4111-8111-111111111111'

describe('buildTelegramSpawnRequest — session override (Task 1789)', () => {
  it('uses the override when one is set for the sender', () => {
    const req = buildTelegramSpawnRequest({
      accountId: 'acct', senderId: '123', role: 'admin', personId: null,
      gatewayUrl: 'http://g', serverPath: '/s', overrideSessionId: FORK,
    })
    expect(req.sessionId).toBe(FORK)
  })

  it('falls back to the deterministic id when the override is null or absent', () => {
    const base = { accountId: 'acct', senderId: '123', role: 'admin' as const, personId: null, gatewayUrl: 'http://g', serverPath: '/s' }
    expect(buildTelegramSpawnRequest({ ...base, overrideSessionId: null }).sessionId).toBe(adminSessionIdFor('acct', '123', undefined))
    expect(buildTelegramSpawnRequest(base).sessionId).toBe(adminSessionIdFor('acct', '123', undefined))
  })
})
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `npx vitest run app/lib/whatsapp/gateway/__tests__/spawn-request.test.ts app/lib/telegram/gateway/__tests__/spawn-request.test.ts`
Expected: FAIL — TypeScript rejects `overrideSessionId`, or `sessionId` is the deterministic id.

- [ ] **Step 3: Add the parameter to the WhatsApp builder**

In `app/lib/whatsapp/gateway/spawn-request.ts`, add to the input type and replace the `sessionId` line and its comment:

```ts
export function buildWaSpawnRequest(input: {
  accountId: string
  senderId: string
  role: 'admin' | 'public'
  personId: string | null
  gatewayUrl: string
  serverPath: string
  /** Task 1789 — the sender's channel forward pointer, when the operator has
   *  re-seated this row. Read by the caller (server/index.ts) so this builder
   *  stays pure. */
  overrideSessionId?: string | null
}): {
```

```ts
    // Task 1746 — the deterministic per-sender id is the DEFAULT id a sender's
    // session has. Task 1526's per-firing seat override used to replace it on a
    // scheduled cold start, which spawned a second session per firing; that
    // session then attached under the sender's own hub key and swallowed their
    // real messages until it detached, splitting the conversation in two.
    //
    // Task 1789's override is not that. It is a stable per-sender forward
    // pointer written once by an operator re-seat and resolving identically for
    // every subsequent inbound, so it can never yield a second id for a sender.
    // Do not remove it without reading server/channel-session-override.ts.
    sessionId: input.overrideSessionId || adminSessionIdFor(input.accountId, input.senderId, personId),
```

- [ ] **Step 4: Add the parameter to the Telegram builder**

In `app/lib/telegram/gateway/spawn-request.ts`, add the identical `overrideSessionId?: string | null` field with the same doc comment, and replace the `sessionId` line:

```ts
    // Task 1746 / 1789 — the deterministic per-sender id is the DEFAULT; an
    // operator re-seat sets a stable per-sender forward pointer that wins here.
    // Mirror of the WhatsApp builder; see its comment for the full distinction
    // between this pointer and Task 1526's removed per-firing seat.
    sessionId: input.overrideSessionId || adminSessionIdFor(input.accountId, input.senderId, personId),
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `npx vitest run app/lib/whatsapp/gateway/__tests__/spawn-request.test.ts app/lib/telegram/gateway/__tests__/spawn-request.test.ts`
Expected: PASS, including every pre-existing assertion.

- [ ] **Step 6: Commit**

```bash
git add platform/ui/app/lib/whatsapp/gateway/spawn-request.ts platform/ui/app/lib/telegram/gateway/spawn-request.ts platform/ui/app/lib/whatsapp/gateway/__tests__/spawn-request.test.ts platform/ui/app/lib/telegram/gateway/__tests__/spawn-request.test.ts
git commit -m "feat(1789): spawn builders accept an optional session override"
```

---

### Task 6: `server/index.ts` reads the override at both spawn sites

**Files:**
- Modify: `server/index.ts:418` (WhatsApp spawn), `:700` (Telegram spawn), plus imports near `:2843-2858`

**Interfaces:**
- Consumes: `readChannelOverrideId` (Task 1), builders' `overrideSessionId` (Task 5), `listValidAccounts`.
- Produces: `resolveChannelOverride(accountId: string, channel: 'whatsapp' | 'telegram', senderId: string): string | null` — a module-local helper, also passed to both gateways in Task 7.

- [ ] **Step 1: Add the imports**

Alongside the existing imports at the bottom import block of `server/index.ts`:

```ts
import { readChannelOverrideId } from './channel-session-override.js'
import { listValidAccounts } from '../app/lib/claude-agent/account.js'
```

(If `listValidAccounts` is already imported in this file, do not duplicate it — check with `grep -n "listValidAccounts" server/index.ts` first.)

- [ ] **Step 2: Add the resolver helper**

Place it above the WhatsApp gateway construction block (before `:418`):

```ts
// Task 1789 — resolve a sender's channel forward pointer. Keyed on the EFFECTIVE
// account (the account the session runs under), matching the write side in
// session-reseat.ts, which keys on the source sidecar's accountId. An
// account-manager-routed sub-account sender therefore resolves its own pointer
// and never the house's. Best-effort: any failure yields null and the caller
// falls back to the deterministic id.
function resolveChannelOverride(accountId: string, channel: 'whatsapp' | 'telegram', senderId: string): string | null {
  try {
    const dir = listValidAccounts().find((a) => a.accountId === accountId)?.accountDir
    return dir ? readChannelOverrideId(dir, channel, senderId) : null
  } catch {
    return null
  }
}
```

- [ ] **Step 3: Thread it into the WhatsApp spawn**

Replace `server/index.ts:418`:

```ts
    const req = buildWaSpawnRequest({
      accountId: effectiveAccountId, senderId, role, personId, gatewayUrl, serverPath,
      // Task 1789 — admin rows only: a public session's id keys on personId as
      // well as senderId, so the per-sender pointer is not applicable to it.
      overrideSessionId: role === 'admin' ? resolveChannelOverride(effectiveAccountId, 'whatsapp', senderId) : null,
    })
```

- [ ] **Step 4: Thread it into the Telegram spawn**

Replace `server/index.ts:700`:

```ts
    const req = buildTelegramSpawnRequest({
      accountId, senderId, role, personId, gatewayUrl, serverPath,
      // Task 1789 — admin rows only; see the WhatsApp twin.
      overrideSessionId: role === 'admin' ? resolveChannelOverride(accountId, 'telegram', senderId) : null,
    })
```

- [ ] **Step 5: Typecheck**

Run: `npx tsc --noEmit -p tsconfig.json`
Expected: no errors.

- [ ] **Step 6: Commit**

```bash
git add platform/ui/server/index.ts
git commit -m "feat(1789): resolve the channel forward pointer at both spawn sites"
```

---

### Task 7: Gateway `op=inbound` lines name the real session

**Files:**
- Modify: `app/lib/whatsapp/gateway/wa-gateway.ts` (deps interface `:68`, `op=inbound` `:312-318`)
- Modify: `app/lib/telegram/gateway/telegram-gateway.ts` (deps interface, `op=inbound` `:181-188`)
- Modify: `server/index.ts` (both gateway constructions)
- Test: `app/lib/whatsapp/gateway/__tests__/` — add to the existing gateway test file

**Interfaces:**
- Produces: both gateway deps interfaces gain `resolveSessionOverride?: (accountId: string, senderId: string) => string | null`.

- [ ] **Step 1: Write the failing test**

Create `app/lib/whatsapp/gateway/__tests__/inbound-session-log.test.ts`:

```ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { WaGateway } from '../wa-gateway'
import { adminSessionIdFor } from '../../../channel-pty-bridge/admin-session-id'

const FORK = 'fc397054-1111-4111-8111-111111111111'
const SENDER = '447504472444'

function makeGateway(resolveSessionOverride?: (accountId: string, senderId: string) => string | null) {
  return new WaGateway({
    gatewayUrl: 'http://x',
    serverPath: '/x',
    ensureChannelSession: vi.fn(async () => {}),
    sendDocument: vi.fn(async () => ({ ok: true as const })),
    ...(resolveSessionOverride ? { resolveSessionOverride } : {}),
  })
}

async function deliver(gw: WaGateway) {
  gw.hub.attach(SENDER, () => {})
  gw.hub.markReady(SENDER, Date.now())
  await gw.handleInbound({
    accountId: 'house', effectiveAccountId: 'acct', senderId: SENDER, role: 'admin',
    text: 'hi', reply: vi.fn(async () => {}), media: [],
  })
}

// The gateway's op=inbound line must name the session the message actually
// reaches. Without the override consult it named the stranded deterministic id.
describe('wa-gateway op=inbound sessionId (Task 1789)', () => {
  let errs: string[]
  beforeEach(() => {
    errs = []
    vi.spyOn(console, 'error').mockImplementation((m: unknown) => { errs.push(String(m)) })
  })
  afterEach(() => { vi.restoreAllMocks() })

  it('names the override when one is set', async () => {
    await deliver(makeGateway(() => FORK))
    expect(errs.find((l) => l.includes('op=inbound'))).toContain(`sessionId=${FORK}`)
  })

  it('names the deterministic id when the override resolves null', async () => {
    await deliver(makeGateway(() => null))
    expect(errs.find((l) => l.includes('op=inbound'))).toContain(`sessionId=${adminSessionIdFor('acct', SENDER)}`)
  })

  it('names the deterministic id when no resolver is injected', async () => {
    await deliver(makeGateway())
    expect(errs.find((l) => l.includes('op=inbound'))).toContain(`sessionId=${adminSessionIdFor('acct', SENDER)}`)
  })

  it('passes the EFFECTIVE account to the resolver, not the socket account', async () => {
    const spy = vi.fn(() => null)
    await deliver(makeGateway(spy))
    expect(spy).toHaveBeenCalledWith('acct', SENDER)
  })
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run app/lib/whatsapp/gateway/__tests__/inbound-session-log.test.ts`
Expected: FAIL — `resolveSessionOverride` is not a known dep, and the line names the deterministic id in all three cases.

- [ ] **Step 3: Add the dep to both gateway interfaces**

In `WaGatewayDeps` (`app/lib/whatsapp/gateway/wa-gateway.ts:68`) and the Telegram equivalent, add:

```ts
  /** Task 1789 — the sender's channel forward pointer, when the operator has
   *  re-seated this row. Injected by server/index.ts so the gateway keeps no
   *  filesystem knowledge and stays testable with a stub. Absent => the
   *  deterministic id, which is what every pre-1789 caller got. */
  resolveSessionOverride?: (accountId: string, senderId: string) => string | null
```

- [ ] **Step 4: Consult it on the log line**

In `app/lib/whatsapp/gateway/wa-gateway.ts`, replace the `resolvedSessionId` assignment (`:312`):

```ts
    // Task 1746 — name the session this inbound resolves to. Two distinct
    // sessionId values for one sender IS the signature of the split defect.
    // Task 1789 — consult the sender's forward pointer first, so this names the
    // session the message actually REACHES rather than the stranded
    // deterministic id an operator re-seat left behind.
    const resolvedSessionId =
      (input.role === 'admin' ? this.deps.resolveSessionOverride?.(input.effectiveAccountId, senderId) : null) ||
      adminSessionIdFor(
        input.effectiveAccountId,
        senderId,
        input.role === 'public' && input.personId ? input.personId : undefined,
      )
```

In `app/lib/telegram/gateway/telegram-gateway.ts`, apply the mirror at `:181`, using `input.accountId` (Telegram's own account variable at that site) and `input.senderId`:

```ts
    // Task 1746 / 1789 — name the session this inbound reaches; the forward
    // pointer wins over the deterministic id. See the WhatsApp twin.
    const resolvedSessionId =
      (input.role === 'admin' ? this.deps.resolveSessionOverride?.(input.accountId, input.senderId) : null) ||
      adminSessionIdFor(
        input.accountId,
        input.senderId,
        input.role === 'public' && input.personId ? input.personId : undefined,
      )
```

- [ ] **Step 5: Inject the resolver at both gateway constructions**

In `server/index.ts`, add to the `WaGateway` deps object:

```ts
  resolveSessionOverride: (accountId, senderId) => resolveChannelOverride(accountId, 'whatsapp', senderId),
```

and to the Telegram gateway deps object:

```ts
  resolveSessionOverride: (accountId, senderId) => resolveChannelOverride(accountId, 'telegram', senderId),
```

- [ ] **Step 6: Run tests and typecheck**

Run: `npx vitest run app/lib/whatsapp/gateway/ app/lib/telegram/gateway/ && npx tsc --noEmit -p tsconfig.json`
Expected: PASS, no type errors.

- [ ] **Step 7: Commit**

```bash
git add platform/ui/app/lib/whatsapp/gateway/wa-gateway.ts platform/ui/app/lib/telegram/gateway/telegram-gateway.ts platform/ui/server/index.ts platform/ui/app/lib/whatsapp/gateway/__tests__/inbound-session-log.test.ts
git commit -m "feat(1789): op=inbound names the session the message reaches"
```

---

### Task 8: Standing check — `op=sender-unseated`

A sender whose channel server detached and never came back is invisible to action logging, because nothing happens. The sweep cannot see this today: `runDuplicateSenderAudit` walks sidecars on disk and has no event history. So the gateway records detach times and the sweep queries them.

**Files:**
- Modify: `app/lib/whatsapp/gateway/routes.ts` (`WaChannelRouteDeps`, the attach/detach sites `:48-56`)
- Modify: `app/lib/whatsapp/gateway/wa-gateway.ts` (`routes()` `:151-170`, new state + accessor)
- Modify: `app/lib/whatsapp/gateway/duplicate-sender-audit.ts`
- Modify: `server/index.ts` (`runDuplicateSenderAudit`)
- Test: `app/lib/whatsapp/gateway/__tests__/duplicate-sender-audit.test.ts`

**Interfaces:**
- Produces:
  - `WaChannelRouteDeps.onAttached?: (senderId: string) => void`, `onDetached?: (senderId: string) => void`
  - `WaGateway.unseatedSenders(nowMs: number, olderThanMs: number): Array<{ senderId: string; sinceMs: number }>`
  - `logUnseatedSenders(entries: Array<{ senderId: string; sinceMs: number }>, nowMs: number): void`

- [ ] **Step 1: Write the failing test**

Append to `app/lib/whatsapp/gateway/__tests__/duplicate-sender-audit.test.ts`:

First extend the file's existing vitest import to include `vi`, `beforeEach`, and `afterEach`, and its `duplicate-sender-audit` import to include `logUnseatedSenders`. Then append:

```ts
describe('logUnseatedSenders (Task 1789)', () => {
  let errs: string[]
  beforeEach(() => {
    errs = []
    vi.spyOn(console, 'error').mockImplementation((m: unknown) => { errs.push(String(m)) })
  })
  afterEach(() => { vi.restoreAllMocks() })

  it('emits one line per unseated sender', () => {
    logUnseatedSenders([{ senderId: '447504472444', sinceMs: 1_000 }], 901_000)
    expect(errs).toHaveLength(1)
    expect(errs[0]).toContain('op=sender-unseated')
    expect(errs[0]).toContain('senderId=447504472444')
  })

  it('is silent when nothing is unseated', () => {
    logUnseatedSenders([], 901_000)
    expect(errs).toHaveLength(0)
  })
})
```

Also add a unit test for the gateway's own accounting, in `app/lib/whatsapp/gateway/__tests__/wa-gateway-unseated.test.ts`:

```ts
import { describe, it, expect, vi } from 'vitest'
import { WaGateway } from '../wa-gateway'

function makeGateway() {
  return new WaGateway({
    gatewayUrl: 'http://x',
    serverPath: '/x',
    ensureChannelSession: vi.fn(async () => {}),
    sendDocument: vi.fn(async () => ({ ok: true as const })),
  })
}

describe('WaGateway.unseatedSenders (Task 1789)', () => {
  it('reports nothing when no sender has ever detached', () => {
    expect(makeGateway().unseatedSenders(1_000_000, 900_000)).toEqual([])
  })

  it('reports a sender detached longer than the interval', () => {
    const gw = makeGateway()
    gw.recordDetached('A', 1_000)
    expect(gw.unseatedSenders(1_000_000, 900_000)).toEqual([{ senderId: 'A', sinceMs: 1_000 }])
  })

  it('does not report a sender detached within the interval', () => {
    const gw = makeGateway()
    gw.recordDetached('A', 900_000)
    expect(gw.unseatedSenders(1_000_000, 900_000)).toEqual([])
  })

  it('does not report a sender that re-attached', () => {
    const gw = makeGateway()
    gw.recordDetached('A', 1_000)
    gw.recordAttached('A')
    expect(gw.unseatedSenders(1_000_000, 900_000)).toEqual([])
  })
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run app/lib/whatsapp/gateway/__tests__/duplicate-sender-audit.test.ts`
Expected: FAIL — `logUnseatedSenders` is not exported.

- [ ] **Step 3: Add the emitter**

Append to `app/lib/whatsapp/gateway/duplicate-sender-audit.ts`:

```ts
/** Task 1789 — a sender whose channel server detached and never re-attached has
 *  no seat, and nothing happens, so no transition line ever reports it. This is
 *  the standing reconciliation: every known sender has a seat, or is named here
 *  with how long it has been without one. 0 entries = healthy = silent. */
export function logUnseatedSenders(entries: Array<{ senderId: string; sinceMs: number }>, nowMs: number): void {
  for (const e of entries) {
    const secs = Math.round((nowMs - e.sinceMs) / 1000)
    console.error(`[whatsapp-native] op=sender-unseated senderId=${e.senderId} since=${new Date(e.sinceMs).toISOString()} forSeconds=${secs}`)
  }
}
```

- [ ] **Step 4: Record detach times in the gateway**

Add the hooks to `WaChannelRouteDeps` in `app/lib/whatsapp/gateway/routes.ts`:

```ts
  /** Task 1789 — seat transitions, so the gateway can answer "which senders
   *  have had no channel server since when" for the standing audit. */
  onAttached?: (senderId: string) => void
  onDetached?: (senderId: string) => void
```

Call them at the existing sites in `createWaChannelRoutes`:

```ts
      console.error(`[whatsapp-native] op=channel-attached senderId=${senderId}`)
      deps.onAttached?.(senderId)
      stream.onAbort(() => {
        deps.hub.detach(senderId)
        console.error(`[whatsapp-native] op=channel-detached senderId=${senderId}`)
        deps.onDetached?.(senderId)
      })
```

Add the state and accessor to `WaGateway` in `app/lib/whatsapp/gateway/wa-gateway.ts`, and wire the hooks in `routes()`:

```ts
  /** Task 1789 — senderId -> the ms at which its channel server last detached,
   *  cleared on attach. The standing audit reads this; nothing else does. */
  private readonly detachedAt = new Map<string, number>()

  /** Seat lost. `atMs` is injected so the audit's accounting is testable. */
  recordDetached(senderId: string, atMs: number): void {
    this.detachedAt.set(senderId, atMs)
  }

  /** Seat regained — the sender is no longer unseated. */
  recordAttached(senderId: string): void {
    this.detachedAt.delete(senderId)
  }

  /** Senders with no channel server for longer than `olderThanMs`. */
  unseatedSenders(nowMs: number, olderThanMs: number): Array<{ senderId: string; sinceMs: number }> {
    const out: Array<{ senderId: string; sinceMs: number }> = []
    for (const [senderId, sinceMs] of this.detachedAt) {
      if (nowMs - sinceMs > olderThanMs) out.push({ senderId, sinceMs })
    }
    return out.sort((a, b) => a.senderId.localeCompare(b.senderId))
  }
```

```ts
      onAttached: (senderId) => this.recordAttached(senderId),
      onDetached: (senderId) => this.recordDetached(senderId, Date.now()),
```

- [ ] **Step 5: Query it from the sweep**

In `server/index.ts`'s `runDuplicateSenderAudit`, after the existing `logDuplicateSenderGroups(...)` call:

```ts
    // Task 1789 — same sweep, second standing check: senders detached longer
    // than one interval with no re-attach.
    const now = Date.now()
    logUnseatedSenders(waGateway.unseatedSenders(now, 15 * 60_000), now)
```

and extend the existing import:

```ts
import { findDuplicateSenderGroups, logDuplicateSenderGroups, logUnseatedSenders, type SenderSessionRecord } from '../app/lib/whatsapp/gateway/duplicate-sender-audit.js'
```

- [ ] **Step 6: Run tests and typecheck**

Run: `npx vitest run app/lib/whatsapp/gateway/ && npx tsc --noEmit -p tsconfig.json`
Expected: PASS, no type errors.

- [ ] **Step 7: Commit**

```bash
git add platform/ui/app/lib/whatsapp/gateway/routes.ts platform/ui/app/lib/whatsapp/gateway/wa-gateway.ts platform/ui/app/lib/whatsapp/gateway/duplicate-sender-audit.ts platform/ui/app/lib/whatsapp/gateway/__tests__/duplicate-sender-audit.test.ts platform/ui/server/index.ts
git commit -m "feat(1789): standing op=sender-unseated check in the duplicate-sender sweep"
```

---

### Task 9: Correct the contradictory comments and the docs

`session-reseat.ts`'s header said the fork is always webchat-bound; `SessionReseatControl.tsx:10-13` said the same form serves both row kinds. The route's assumption won, which is the defect. Both must now describe the delivered behaviour. The `session-reseat.ts` header and both `spawn-request.ts` comments were already corrected in Tasks 3 and 5; this task closes the remaining two surfaces.

**Files:**
- Modify: `app/components/SessionReseatControl.tsx:7-23`
- Modify: `../../.docs/admin-webchat-native-channel.md`

- [ ] **Step 1: Correct the component comment**

Replace the second sentence of the `SessionReseatControl` doc comment:

```tsx
 * Task 928 — per-row Re-seat form for the operator dashboard. Re-seat is the
 * fork mechanism (Task 876): it forks the session onto a fresh id born on the
 * chosen model/mode/effort, history preserved, then navigates to the fork. The
 * same form serves webchat rows (the Sessions panel) and channel rows
 * (WhatsApp/Telegram), since both drive the one /api/admin/session-reseat fork.
 *
 * Task 1789 — the route now honours that. It reads the source row's sidecar and
 * forks an admin whatsapp/telegram row as THAT channel, carrying the source's
 * senderId, and writes a per-sender forward pointer so the sender's next inbound
 * resolves to the fork. Before this the route hardcoded a webchat payload for
 * every row, which replaced the sender's channel server and detached its seat.
 * A channel fork has no reply tool until that next inbound rebinds it; the
 * lifeline names that window as op=channel-fork-unbound.
```

- [ ] **Step 2: Document the override**

Append a section to `maxy-code/.docs/admin-webchat-native-channel.md`:

```markdown
## Channel-row re-seat and the per-sender forward pointer (Task 1789)

A channel session's id is `adminSessionIdFor(accountId, senderId)` — a pure hash,
so every inbound from a sender recomputes the same id. An operator re-seat forks
the row onto a freshly minted id that hash will never return.

Two mechanisms make a channel re-seat work, and neither is sufficient alone.
`session-reseat.ts` reads the source row's `.meta.json` and, when it is an
**admin** `whatsapp` or `telegram` row, builds the fork payload with that channel
and the source's own `senderId` instead of the webchat shape. `server/channel-session-override.ts`
then holds a per-account `<channel>:<senderId>` → fork-id pointer, written at that
fork and consulted at both spawn sites in `server/index.ts`, so the sender's next
inbound resolves to the fork.

Public rows are out of scope: their session id keys on `personId` as well as
`senderId`, so a per-sender pointer is ambiguous for them. They re-seat as
webchat, unchanged.

The pointer is not Task 1526's per-firing seat that Task 1746 removed. That seat
was salted per scheduled dispatch and minted a new session every firing, which
attached under the sender's own hub key and swallowed their real messages. This
pointer is stable — one session, repointed once — and can never yield a second id
for a sender.

A channel fork carries no `waChannel` / `telegramChannel` descriptor, because the
re-seat route holds no gateway url or server path. So the fork has no reply tool
until the next inbound rebinds it through `ensureChannelSession`. That window is
logged as `op=channel-fork-unbound`; the manager's own `op=channel-binding-lost`
invariant is gated on its channel store and cannot fire for a fresh fork id.
```

- [ ] **Step 3: Verify the docs hook is satisfied**

Run: `git status --short` and confirm both a code file and a docs file are staged.

- [ ] **Step 4: Commit**

```bash
git add platform/ui/app/components/SessionReseatControl.tsx ../../.docs/admin-webchat-native-channel.md
git commit -m "docs(1789): correct the re-seat comments and document the forward pointer"
```

---

### Task 10: Full verification

- [ ] **Step 1: Full test suite**

Run: `npm test`
Expected: PASS. Compare failures against the pre-sprint baseline; any pre-existing failure is recorded, not fixed here.

- [ ] **Step 2: Typecheck and build**

Run: `npx tsc --noEmit -p tsconfig.json && npm run build`
Expected: no type errors, build succeeds.

- [ ] **Step 3: Lint changed files**

Run: `npx eslint $(git diff --name-only main...HEAD | grep -E '\.tsx?$' | sed 's|^platform/ui/||')`
Expected: no new errors in changed files.

- [ ] **Step 4: Confirm the webchat payload is unchanged**

Run: `npx vitest run server/routes/admin/__tests__/session-reseat.test.ts -t 'mints a new id'`
Expected: PASS — the original Task 876 assertion still holds byte-for-byte.
