# Phase B Appendices — Source File Bodies

Complete source for every file referenced from the Phase B plan. Copy verbatim.

---

## Appendix A — `extensions/complaint-listener/src/subscriptions.test.ts`

```ts
import { describe, it, expect } from "vitest";
import { matchesChannelSub, matchesAgentSub, type Sub } from "./subscriptions.js";

const ev = (over: Partial<{ channel: string; accountId: string }> = {}) => ({
  channel: "telegram",
  accountId: "default",
  chatId: "c",
  content: "x",
  isGroup: false,
  ...over,
});

describe("matchesChannelSub", () => {
  it("matches exact channel+account", () => {
    const subs: Sub[] = [{ kind: "channel", channel: "telegram", accountId: "default" }];
    expect(matchesChannelSub(subs, ev())).toBe(true);
  });

  it("matches channel when accountId is omitted in sub", () => {
    const subs: Sub[] = [{ kind: "channel", channel: "telegram" }];
    expect(matchesChannelSub(subs, ev({ accountId: "other" }))).toBe(true);
  });

  it("rejects mismatched channel", () => {
    const subs: Sub[] = [{ kind: "channel", channel: "whatsapp" }];
    expect(matchesChannelSub(subs, ev())).toBe(false);
  });

  it("rejects mismatched account when accountId is specified", () => {
    const subs: Sub[] = [{ kind: "channel", channel: "telegram", accountId: "default" }];
    expect(matchesChannelSub(subs, ev({ accountId: "other" }))).toBe(false);
  });

  it("catch-all matches everything", () => {
    const subs: Sub[] = [{ kind: "channel-catchall" }];
    expect(matchesChannelSub(subs, ev())).toBe(true);
    expect(matchesChannelSub(subs, ev({ channel: "discord" }))).toBe(true);
  });

  it("empty subs matches nothing", () => {
    expect(matchesChannelSub([], ev())).toBe(false);
  });
});

describe("matchesAgentSub", () => {
  it("matches agentId", () => {
    const subs: Sub[] = [{ kind: "agent", agentId: "panik" }];
    expect(matchesAgentSub(subs, "panik")).toBe(true);
  });

  it("rejects when agentId is undefined", () => {
    const subs: Sub[] = [{ kind: "agent", agentId: "panik" }];
    expect(matchesAgentSub(subs, undefined)).toBe(false);
  });
});
```

---

## Appendix B — `extensions/complaint-listener/src/subscriptions.ts`

```ts
export type Sub =
  | { kind: "channel"; channel: string; accountId?: string }
  | { kind: "agent"; agentId: string }
  | { kind: "channel-catchall" };

export function matchesChannelSub(
  subs: Sub[],
  event: { channel: string; accountId: string },
): boolean {
  return subs.some((s) => {
    if (s.kind === "channel-catchall") return true;
    if (s.kind !== "channel") return false;
    if (s.channel !== event.channel) return false;
    if (s.accountId !== undefined && s.accountId !== event.accountId) return false;
    return true;
  });
}

export function matchesAgentSub(subs: Sub[], agentId: string | undefined): boolean {
  if (!agentId) return false;
  return subs.some((s) => s.kind === "agent" && s.agentId === agentId);
}
```

---

## Appendix C — `extensions/complaint-listener/src/context.test.ts`

```ts
import { DatabaseSync } from "node:sqlite";
import { describe, it, expect, beforeEach } from "vitest";
import { fetchContext } from "./context.js";

function seedLedger(db: DatabaseSync) {
  db.exec(`
    CREATE TABLE messages (
      id INTEGER PRIMARY KEY,
      channel TEXT, account_id TEXT, chat_id TEXT,
      content TEXT, sender_id TEXT, direction TEXT, timestamp INTEGER
    );
  `);
}

const ins = (db: DatabaseSync, row: { channel: string; accountId: string; chatId: string; content: string; senderId: string; direction: string; ts: number }) =>
  db.prepare(
    "INSERT INTO messages (channel, account_id, chat_id, content, sender_id, direction, timestamp) VALUES (?,?,?,?,?,?,?)",
  ).run(row.channel, row.accountId, row.chatId, row.content, row.senderId, row.direction, row.ts);

describe("fetchContext", () => {
  let db: DatabaseSync;

  beforeEach(() => {
    db = new DatabaseSync(":memory:");
    seedLedger(db);
  });

  it("returns empty array when no rows match", () => {
    expect(fetchContext(db, "telegram", "default", "c1", 5)).toEqual([]);
  });

  it("returns rows in chronological order ascending", () => {
    ins(db, { channel: "telegram", accountId: "default", chatId: "c1", content: "a", senderId: "u1", direction: "in", ts: 2000 });
    ins(db, { channel: "telegram", accountId: "default", chatId: "c1", content: "b", senderId: "u1", direction: "in", ts: 1000 });
    const rows = fetchContext(db, "telegram", "default", "c1", 5);
    expect(rows.map((r) => r.content)).toEqual(["b", "a"]);
  });

  it("respects limit (returns latest N)", () => {
    for (let i = 0; i < 8; i++) {
      ins(db, { channel: "telegram", accountId: "default", chatId: "c1", content: `m${i}`, senderId: "u1", direction: "in", ts: i * 100 });
    }
    const rows = fetchContext(db, "telegram", "default", "c1", 3);
    expect(rows).toHaveLength(3);
    expect(rows.map((r) => r.content)).toEqual(["m5", "m6", "m7"]);
  });

  it("filters by chat_id", () => {
    ins(db, { channel: "telegram", accountId: "default", chatId: "c1", content: "x", senderId: "u1", direction: "in", ts: 1000 });
    ins(db, { channel: "telegram", accountId: "default", chatId: "c2", content: "y", senderId: "u1", direction: "in", ts: 1000 });
    const rows = fetchContext(db, "telegram", "default", "c1", 10);
    expect(rows.map((r) => r.content)).toEqual(["x"]);
  });

  it("maps direction to role (in→user, out→agent)", () => {
    ins(db, { channel: "telegram", accountId: "default", chatId: "c1", content: "hi", senderId: "u1", direction: "in", ts: 1000 });
    ins(db, { channel: "telegram", accountId: "default", chatId: "c1", content: "hello", senderId: "bot", direction: "out", ts: 2000 });
    const rows = fetchContext(db, "telegram", "default", "c1", 10);
    expect(rows.map((r) => r.role)).toEqual(["user", "agent"]);
  });
});
```

---

## Appendix D — `extensions/complaint-listener/src/context.ts`

Also extends `types.ts` with `ComplaintHistoryEntry` (early because Task 5 needs it).

`types.ts` append:

```ts
export interface ComplaintHistoryEntry {
  role: "user" | "agent";
  content: string;
  senderId?: string;
  ts: number;
}
```

`context.ts`:

```ts
import type { DatabaseSync } from "node:sqlite";
import type { ComplaintHistoryEntry } from "./types.js";

interface LedgerRow {
  content: string;
  sender_id: string | null;
  direction: string;
  timestamp: number;
}

export function fetchContext(
  db: DatabaseSync,
  channel: string,
  accountId: string,
  chatId: string,
  limit: number,
): ComplaintHistoryEntry[] {
  const rows = db
    .prepare(
      `SELECT content, sender_id, direction, timestamp
       FROM messages
       WHERE channel = ? AND account_id = ? AND chat_id = ?
       ORDER BY timestamp DESC
       LIMIT ?`,
    )
    .all(channel, accountId, chatId, limit) as LedgerRow[];
  return rows
    .reverse()
    .map((r) => ({
      role: r.direction === "out" ? "agent" : "user",
      content: r.content,
      senderId: r.sender_id ?? undefined,
      ts: r.timestamp,
    }));
}
```

---

## Appendix E — `extensions/complaint-listener/src/store.ts` (rewritten)

```ts
import type { DatabaseSync } from "node:sqlite";
import type { ComplaintResult, ComplaintRow } from "./types.js";

export function initSchema(db: DatabaseSync): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS complaints (
      id          INTEGER PRIMARY KEY AUTOINCREMENT,
      agent_id    TEXT,
      channel_id  TEXT    NOT NULL,
      chat_id     TEXT    NOT NULL,
      from_sender TEXT    NOT NULL,
      content     TEXT    NOT NULL,
      is_complaint INTEGER NOT NULL DEFAULT 0,
      severity    TEXT    NOT NULL DEFAULT 'low',
      category    TEXT    NOT NULL DEFAULT 'other',
      summary     TEXT    NOT NULL DEFAULT '',
      created_at  INTEGER NOT NULL,
      notified_at INTEGER
    );
    CREATE INDEX IF NOT EXISTS idx_complaints_chat_id ON complaints(chat_id, created_at);
  `);
}

export interface InsertComplaintParams {
  agentId?: string | null;
  channelId: string;
  chatId: string;
  fromSender: string;
  content: string;
  result: ComplaintResult;
}

export function insertComplaint(db: DatabaseSync, p: InsertComplaintParams): number {
  const run = db
    .prepare(
      `INSERT INTO complaints
        (agent_id, channel_id, chat_id, from_sender, content, is_complaint, severity, category, summary, created_at)
       VALUES (?,?,?,?,?,?,?,?,?,?)`,
    )
    .run(
      p.agentId ?? null,
      p.channelId,
      p.chatId,
      p.fromSender,
      p.content,
      p.result.is_complaint ? 1 : 0,
      p.result.severity,
      p.result.category,
      p.result.summary,
      Date.now(),
    );
  return Number(run.lastInsertRowid);
}

export function markNotified(db: DatabaseSync, id: number): void {
  db.prepare("UPDATE complaints SET notified_at = ? WHERE id = ?").run(Date.now(), id);
}

export function getPendingNotifications(db: DatabaseSync): ComplaintRow[] {
  return db
    .prepare(
      "SELECT * FROM complaints WHERE is_complaint = 1 AND notified_at IS NULL ORDER BY created_at ASC",
    )
    .all() as ComplaintRow[];
}

export function getComplaintById(db: DatabaseSync, id: number): ComplaintRow | undefined {
  return db.prepare("SELECT * FROM complaints WHERE id = ?").get(id) as ComplaintRow | undefined;
}

export function countBySeverity(db: DatabaseSync, severity: string): number {
  const row = db
    .prepare("SELECT COUNT(*) as cnt FROM complaints WHERE severity = ? AND is_complaint = 1")
    .get(severity) as { cnt: number } | undefined;
  return row?.cnt ?? 0;
}

export function getComplaintsByChatId(
  db: DatabaseSync,
  chatId: string,
  limit: number,
): ComplaintRow[] {
  return db
    .prepare(
      "SELECT * FROM complaints WHERE chat_id = ? ORDER BY created_at DESC LIMIT ?",
    )
    .all(chatId, limit) as ComplaintRow[];
}

export function getRecentComplaints(db: DatabaseSync, limit: number): ComplaintRow[] {
  return db
    .prepare(
      "SELECT * FROM complaints WHERE is_complaint = 1 ORDER BY created_at DESC LIMIT ?",
    )
    .all(limit) as ComplaintRow[];
}
```

Also update `types.ts` `ComplaintRow` to add `chat_id`:

```ts
export type ComplaintRow = {
  id: number;
  agent_id: string | null;
  channel_id: string;
  chat_id: string;
  from_sender: string;
  content: string;
  is_complaint: number;
  severity: ComplaintSeverity;
  category: ComplaintCategory;
  summary: string;
  created_at: number;
  notified_at: number | null;
};
```

---

## Appendix F — `extensions/complaint-listener/src/destinations.test.ts`

```ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { sendAlerts, type Destination } from "./destinations.js";

function makeApi(opts: { wa?: ReturnType<typeof vi.fn>; tg?: ReturnType<typeof vi.fn>; dc?: ReturnType<typeof vi.fn> } = {}) {
  return {
    logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
    runtime: {
      channel: {
        whatsapp: { sendMessageWhatsApp: opts.wa ?? vi.fn().mockResolvedValue({}) },
        telegram: { sendMessageTelegram: opts.tg ?? vi.fn().mockResolvedValue({}) },
        discord: { sendMessageDiscord: opts.dc ?? vi.fn().mockResolvedValue({}) },
      },
    },
  };
}

const wa: Destination = { channel: "whatsapp", accountId: "default", to: "+51999" };
const tg: Destination = { channel: "telegram", accountId: "default", to: "8416955510" };
const dc: Destination = { channel: "discord", accountId: "default", to: "channel-id-42" };

describe("sendAlerts", () => {
  beforeEach(() => vi.clearAllMocks());

  it("sends to a WhatsApp destination", async () => {
    const api = makeApi() as any;
    const result = await sendAlerts(api, [wa], "hello");
    expect(api.runtime.channel.whatsapp.sendMessageWhatsApp).toHaveBeenCalledWith("+51999", "hello", expect.any(Object));
    expect(result.successCount).toBe(1);
  });

  it("sends to a Telegram destination", async () => {
    const api = makeApi() as any;
    await sendAlerts(api, [tg], "hello");
    expect(api.runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledWith("8416955510", "hello", expect.any(Object));
  });

  it("sends to a Discord destination", async () => {
    const api = makeApi() as any;
    await sendAlerts(api, [dc], "hello");
    expect(api.runtime.channel.discord.sendMessageDiscord).toHaveBeenCalledWith("channel-id-42", "hello", expect.any(Object));
  });

  it("fans out to multiple destinations in parallel", async () => {
    const api = makeApi() as any;
    const result = await sendAlerts(api, [wa, tg], "hi");
    expect(result.successCount).toBe(2);
    expect(result.total).toBe(2);
  });

  it("one fails, another succeeds, successCount=1", async () => {
    const api = makeApi({
      wa: vi.fn().mockRejectedValue(new Error("WA offline")),
      tg: vi.fn().mockResolvedValue({}),
    }) as any;
    const result = await sendAlerts(api, [wa, tg], "hi");
    expect(result.successCount).toBe(1);
    expect(result.total).toBe(2);
  });

  it("all fail → successCount=0, does not throw", async () => {
    const api = makeApi({
      wa: vi.fn().mockRejectedValue(new Error("x")),
      tg: vi.fn().mockRejectedValue(new Error("y")),
    }) as any;
    const result = await sendAlerts(api, [wa, tg], "hi");
    expect(result.successCount).toBe(0);
  });

  it("unknown channel logs warn and counts as failure", async () => {
    const api = makeApi() as any;
    const result = await sendAlerts(api, [{ channel: "carrier-pigeon", to: "wing-42" } as Destination], "hi");
    expect(api.logger.warn).toHaveBeenCalled();
    expect(result.successCount).toBe(0);
  });
});
```

---

## Appendix G — `extensions/complaint-listener/src/destinations.ts`

```ts
import type { MinionPluginApi } from "../../../src/plugins/types.js";

export interface Destination {
  channel: string;
  accountId?: string;
  to: string;
  label?: string;
}

export interface DeliveryReport {
  total: number;
  successCount: number;
  perDestination: Array<{ destination: Destination; ok: boolean; error?: string }>;
}

type Sender = (to: string, text: string, opts: Record<string, unknown>) => Promise<unknown>;

function resolveSender(api: MinionPluginApi, channel: string): Sender | null {
  const ch = (api.runtime.channel as Record<string, Record<string, Sender>>)[channel];
  if (!ch) return null;
  const key = `sendMessage${channel.charAt(0).toUpperCase() + channel.slice(1)}`;
  const fn = ch[key];
  return typeof fn === "function" ? fn : null;
}

export async function sendAlerts(
  api: MinionPluginApi,
  destinations: Destination[],
  text: string,
): Promise<DeliveryReport> {
  const results = await Promise.all(
    destinations.map(async (d) => {
      const sender = resolveSender(api, d.channel);
      if (!sender) {
        api.logger.warn(`complaint-listener: unknown channel '${d.channel}' for destination ${d.to}`);
        return { destination: d, ok: false, error: "unknown_channel" };
      }
      try {
        const opts: Record<string, unknown> = { verbose: false };
        if (d.accountId) opts.accountId = d.accountId;
        if (d.channel === "telegram") opts.textMode = "markdown";
        await sender(d.to, text, opts);
        return { destination: d, ok: true };
      } catch (err) {
        api.logger.error(`complaint-listener: send to ${d.channel}:${d.to} failed: ${String(err)}`);
        return { destination: d, ok: false, error: String(err) };
      }
    }),
  );
  return {
    total: destinations.length,
    successCount: results.filter((r) => r.ok).length,
    perDestination: results,
  };
}
```

---

## Appendix H — `extensions/complaint-listener/src/classifier.test.ts` (full rewrite)

```ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { classifyMessage } from "./classifier.js";
import type { ComplaintHistoryEntry } from "./types.js";

vi.mock("../../../src/agents/llm-tools/forced-tool-call.js", () => ({
  forcedToolCall: vi.fn(),
}));

import { forcedToolCall } from "../../../src/agents/llm-tools/forced-tool-call.js";

describe("classifyMessage with history", () => {
  beforeEach(() => vi.resetAllMocks());

  const history: ComplaintHistoryEntry[] = [
    { role: "user", content: "hi", ts: 1 },
    { role: "agent", content: "hello, how can I help?", ts: 2 },
    { role: "user", content: "your product is broken", ts: 3 },
  ];

  it("invokes forcedToolCall with history rendered in prompt", async () => {
    (forcedToolCall as any).mockResolvedValue({
      is_complaint: true,
      severity: "high",
      category: "product_defect",
      summary: "Broken product",
    });
    await classifyMessage(history, "k", "claude-haiku-4-5");
    const args = (forcedToolCall as any).mock.calls[0];
    expect(args[0]).toContain("hi");
    expect(args[0]).toContain("hello, how can I help?");
    expect(args[0]).toContain("your product is broken");
  });

  it("returns the parsed complaint result", async () => {
    (forcedToolCall as any).mockResolvedValue({
      is_complaint: true,
      severity: "high",
      category: "billing",
      summary: "Billing dispute",
    });
    const result = await classifyMessage(history, "k", "claude-haiku-4-5");
    expect(result.is_complaint).toBe(true);
    expect(result.category).toBe("billing");
  });

  it("handles non-complaint result", async () => {
    (forcedToolCall as any).mockResolvedValue({
      is_complaint: false,
      severity: "low",
      category: "other",
      summary: "",
    });
    const result = await classifyMessage(history, "k", "claude-haiku-4-5");
    expect(result.is_complaint).toBe(false);
  });

  it("passes signal through", async () => {
    (forcedToolCall as any).mockResolvedValue({ is_complaint: false, severity: "low", category: "other", summary: "" });
    const controller = new AbortController();
    await classifyMessage(history, "k", "claude-haiku-4-5", controller.signal);
    expect((forcedToolCall as any).mock.calls[0][2]?.signal).toBe(controller.signal);
  });
});
```

---

## Appendix I — `extensions/complaint-listener/src/classifier.ts` (rewritten)

```ts
import { Type } from "@sinclair/typebox";
import { forcedToolCall } from "../../../src/agents/llm-tools/forced-tool-call.js";
import type { ComplaintResult, ComplaintHistoryEntry } from "./types.js";

const ComplaintSchema = Type.Object({
  is_complaint: Type.Boolean(),
  severity: Type.Union([Type.Literal("low"), Type.Literal("med"), Type.Literal("high")]),
  category: Type.Union([
    Type.Literal("product_defect"),
    Type.Literal("billing"),
    Type.Literal("delivery"),
    Type.Literal("service"),
    Type.Literal("other"),
  ]),
  summary: Type.String(),
});

const SYSTEM_PROMPT = `You are a customer-complaint classifier.
The conversation history below is the most recent context from this customer (oldest first).
Classify whether the LATEST message indicates a genuine complaint or grievance.
Be strict: questions, greetings, and general inquiries are NOT complaints.
Call the result tool exactly once.`;

function renderHistory(history: ComplaintHistoryEntry[]): string {
  return history
    .map((h) => `[${h.role}] ${h.content}`)
    .join("\n");
}

export async function classifyMessage(
  history: ComplaintHistoryEntry[],
  apiKey: string,
  model: string,
  signal?: AbortSignal,
): Promise<ComplaintResult> {
  const prompt = `${SYSTEM_PROMPT}\n\nConversation history:\n${renderHistory(history)}`;
  const raw = await forcedToolCall(prompt, ComplaintSchema, {
    apiKey,
    model,
    toolName: "result",
    toolChoice: { type: "tool", name: "result" },
    maxTokens: 256,
    signal,
  });
  return {
    is_complaint: Boolean(raw.is_complaint),
    severity: raw.severity as ComplaintResult["severity"],
    category: raw.category as ComplaintResult["category"],
    summary: String(raw.summary),
  };
}
```

---

## Appendix J — `extensions/complaint-listener/index.test.ts` (rewritten)

```ts
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("../../src/plugin-sdk/index.js", () => ({ emptyPluginConfigSchema: () => ({}) }));
vi.mock("./src/classifier.js", () => ({ classifyMessage: vi.fn() }));
vi.mock("./src/store.js", () => ({
  initSchema: vi.fn(),
  insertComplaint: vi.fn(),
  markNotified: vi.fn(),
  getPendingNotifications: vi.fn(() => []),
}));
vi.mock("./src/destinations.js", () => ({ sendAlerts: vi.fn() }));
vi.mock("./src/context.js", () => ({ fetchContext: vi.fn(() => []) }));

import plugin from "./index.js";
import { classifyMessage } from "./src/classifier.js";
import { sendAlerts } from "./src/destinations.js";
import { insertComplaint, markNotified } from "./src/store.js";

const PLUGIN_ID = "complaint-listener";

function makeApi(config: any = {}, manualStart = false) {
  let capturedSvc: any = null;
  const ledger = { all: vi.fn(() => []) };
  const api = {
    id: PLUGIN_ID,
    config: {},
    pluginConfig: {
      subscriptions: [{ kind: "channel", channel: "telegram", accountId: "default" }],
      destinations: [{ channel: "whatsapp", accountId: "default", to: "+51999" }],
      ...config,
    },
    logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
    runtime: {
      channel: {
        whatsapp: { sendMessageWhatsApp: vi.fn() },
        telegram: { sendMessageTelegram: vi.fn() },
      },
      messageLedger: { getDb: () => ledger as any },
    },
    registerService: vi.fn((svc) => {
      capturedSvc = svc;
      if (!manualStart) svc.start({ stateDir: "/tmp", config: {}, logger: {} });
    }),
    on: vi.fn(),
    triggerStart: async () => capturedSvc.start({ stateDir: "/tmp", config: {}, logger: {} }),
  };
  return api;
}

const complaintResult = { is_complaint: true, severity: "high" as const, category: "product_defect" as const, summary: "Broken" };
const inboundEvent = {
  channel: "telegram",
  accountId: "default",
  chatId: "c1",
  content: "your product is broken",
  isGroup: false,
  senderId: "u1",
  messageId: "m1",
};

describe("complaint-listener wiring", () => {
  beforeEach(() => {
    vi.resetAllMocks();
    process.env.ANTHROPIC_API_KEY = "k";
    (insertComplaint as any).mockReturnValue(42);
    (sendAlerts as any).mockResolvedValue({ total: 1, successCount: 1, perDestination: [] });
  });

  it("registers both hooks + service", () => {
    const api = makeApi() as any;
    plugin.register(api);
    expect(api.on).toHaveBeenCalledWith("message_inbound", expect.any(Function));
    expect(api.on).toHaveBeenCalledWith("message_received", expect.any(Function));
    expect(api.registerService).toHaveBeenCalled();
  });

  it("channel sub: message_inbound matches and classifies", async () => {
    (classifyMessage as any).mockResolvedValue(complaintResult);
    const api = makeApi() as any;
    plugin.register(api);
    const inboundHandler = api.on.mock.calls.find((c: any) => c[0] === "message_inbound")[1];
    await inboundHandler(inboundEvent);
    expect(classifyMessage).toHaveBeenCalled();
    expect(insertComplaint).toHaveBeenCalled();
    expect(sendAlerts).toHaveBeenCalled();
    expect(markNotified).toHaveBeenCalledWith(expect.anything(), 42);
  });

  it("dedup: message_received does not re-process after message_inbound for same messageId", async () => {
    (classifyMessage as any).mockResolvedValue(complaintResult);
    const api = makeApi({
      subscriptions: [
        { kind: "channel", channel: "telegram", accountId: "default" },
        { kind: "agent", agentId: "panik" },
      ],
    }) as any;
    plugin.register(api);
    const inboundHandler = api.on.mock.calls.find((c: any) => c[0] === "message_inbound")[1];
    const receivedHandler = api.on.mock.calls.find((c: any) => c[0] === "message_received")[1];
    await inboundHandler(inboundEvent);
    await receivedHandler(
      { from: "u1", content: "your product is broken", messageId: "m1" },
      { channelId: "telegram", accountId: "default", chatId: "c1", agentId: "panik" },
    );
    expect(classifyMessage).toHaveBeenCalledTimes(1);
  });

  it("agent sub: message_received matches if no channel sub matched", async () => {
    (classifyMessage as any).mockResolvedValue(complaintResult);
    const api = makeApi({
      subscriptions: [{ kind: "agent", agentId: "panik" }],
    }) as any;
    plugin.register(api);
    const receivedHandler = api.on.mock.calls.find((c: any) => c[0] === "message_received")[1];
    await receivedHandler(
      { from: "u1", content: "x", messageId: "m9" },
      { channelId: "telegram", accountId: "default", chatId: "c1", agentId: "panik" },
    );
    expect(classifyMessage).toHaveBeenCalled();
  });

  it("non-complaint: no insert, no alert", async () => {
    (classifyMessage as any).mockResolvedValue({ is_complaint: false, severity: "low", category: "other", summary: "" });
    const api = makeApi() as any;
    plugin.register(api);
    const h = api.on.mock.calls.find((c: any) => c[0] === "message_inbound")[1];
    await h(inboundEvent);
    expect(insertComplaint).not.toHaveBeenCalled();
    expect(sendAlerts).not.toHaveBeenCalled();
  });

  it("all destinations fail → no markNotified", async () => {
    (classifyMessage as any).mockResolvedValue(complaintResult);
    (sendAlerts as any).mockResolvedValue({ total: 1, successCount: 0, perDestination: [] });
    const api = makeApi() as any;
    plugin.register(api);
    const h = api.on.mock.calls.find((c: any) => c[0] === "message_inbound")[1];
    await h(inboundEvent);
    expect(markNotified).not.toHaveBeenCalled();
  });

  it("ledger disabled: warns + no-ops", async () => {
    const api = makeApi() as any;
    api.runtime.messageLedger.getDb = () => null;
    plugin.register(api);
    const h = api.on.mock.calls.find((c: any) => c[0] === "message_inbound")[1];
    await h(inboundEvent);
    expect(api.logger.warn).toHaveBeenCalled();
    expect(classifyMessage).not.toHaveBeenCalled();
  });

  it("empty subscriptions: skips all classification", async () => {
    const api = makeApi({ subscriptions: [] }) as any;
    plugin.register(api);
    const h = api.on.mock.calls.find((c: any) => c[0] === "message_inbound")[1];
    await h(inboundEvent);
    expect(classifyMessage).not.toHaveBeenCalled();
  });
});
```

---

## Appendix K — `minion.plugin.json`

(see Task 16 inline)

---

## Appendix L — `extensions/complaint-listener/index.ts` (rewritten)

```ts
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import type { MinionPluginApi } from "../../src/plugins/types.js";
import { classifyMessage } from "./src/classifier.js";
import { fetchContext } from "./src/context.js";
import { createDedup, type Dedup } from "./src/dedup.js";
import { sendAlerts, type Destination } from "./src/destinations.js";
import { insertComplaint, markNotified, initSchema, getPendingNotifications } from "./src/store.js";
import { matchesAgentSub, matchesChannelSub, type Sub } from "./src/subscriptions.js";
import type { ComplaintResult } from "./src/types.js";

const PLUGIN_ID = "complaint-listener";

interface ResolvedConfig {
  subscriptions: Sub[];
  destinations: Destination[];
  classifier: {
    model: string;
    contextWindow: number;
    minSeverity: "low" | "med" | "high";
  };
}

const SEVERITY_ORDER: Record<string, number> = { low: 0, med: 1, high: 2 };

function resolveConfig(raw: Record<string, unknown>): ResolvedConfig {
  const c = (raw.classifier as Record<string, unknown> | undefined) ?? {};
  return {
    subscriptions: (raw.subscriptions as Sub[]) ?? [],
    destinations: (raw.destinations as Destination[]) ?? [],
    classifier: {
      model: (c.model as string) ?? "claude-haiku-4-5",
      contextWindow: (c.contextWindow as number) ?? 5,
      minSeverity: (c.minSeverity as "low" | "med" | "high") ?? "low",
    },
  };
}

function formatAlert(
  event: { channel: string; accountId: string; senderId?: string; senderUsername?: string; content: string },
  result: ComplaintResult,
  history: Array<{ role: string; content: string }>,
): string {
  const emoji = result.severity === "high" ? "🔴" : result.severity === "med" ? "🟠" : "🟡";
  const lines = [
    `${emoji} *Complaint detected* (${result.severity.toUpperCase()})`,
    `From: ${event.senderUsername ?? event.senderId ?? "unknown"} (${event.channel})`,
    `Category: ${result.category}`,
    `Latest: "${event.content}"`,
  ];
  if (history.length > 1) {
    lines.push("", `Context (last ${history.length}):`);
    for (const h of history) lines.push(`  • [${h.role}] ${h.content}`);
  }
  if (result.summary) lines.push("", `Summary: ${result.summary}`);
  return lines.join("\n");
}

export default {
  id: PLUGIN_ID,
  name: "Complaint Listener",
  description:
    "Watches subscribed channels/agents and fans out classifier-detected complaint alerts to configured destinations.",
  configSchema: { type: "object", additionalProperties: false, properties: {} },

  register(api: MinionPluginApi) {
    let db: DatabaseSync | null = null;
    const dedup: Dedup = createDedup(1000);

    api.registerService({
      id: PLUGIN_ID,
      async start(ctx) {
        const dbPath = path.join(ctx.stateDir, "plugins", PLUGIN_ID, "complaints.db");
        fs.mkdirSync(path.dirname(dbPath), { recursive: true });
        db = new DatabaseSync(dbPath);
        initSchema(db);

        const cfg = resolveConfig(api.pluginConfig as Record<string, unknown>);
        api.logger.info(
          `${PLUGIN_ID}: subscriptions=${cfg.subscriptions.length} destinations=${cfg.destinations.length}`,
        );

        const ledger = api.runtime.messageLedger?.getDb?.();
        if (!ledger) {
          api.logger.warn(`${PLUGIN_ID}: messageLedger disabled — alerts will fire on single-message context only`);
        }

        // Boot catch-up — drain pending rows.
        if (cfg.destinations.length > 0) {
          const pending = getPendingNotifications(db);
          for (const row of pending) {
            const text = formatAlert(
              { channel: row.channel_id, accountId: "default", senderId: row.from_sender, content: row.content },
              { is_complaint: true, severity: row.severity, category: row.category, summary: row.summary },
              [],
            );
            const r = await sendAlerts(api, cfg.destinations, text);
            if (r.successCount > 0) markNotified(db, row.id);
          }
        }
      },
      stop() {
        db = null;
        api.logger.info(`${PLUGIN_ID}: stopped`);
      },
    });

    async function process(
      event: {
        channel: string;
        accountId: string;
        chatId: string;
        senderId?: string;
        senderUsername?: string;
        content: string;
        messageId?: string;
      },
      agentId: string | undefined,
    ): Promise<void> {
      if (!db) return;
      const cfg = resolveConfig(api.pluginConfig as Record<string, unknown>);
      if (dedup.markSeen(event.messageId)) return;

      const apiKey = process.env.ANTHROPIC_API_KEY;
      if (!apiKey) {
        api.logger.warn(`${PLUGIN_ID}: ANTHROPIC_API_KEY not set`);
        return;
      }

      const ledger = api.runtime.messageLedger?.getDb?.();
      if (!ledger) {
        api.logger.warn(`${PLUGIN_ID}: ledger disabled — skipping classification`);
        return;
      }

      let history = fetchContext(ledger, event.channel, event.accountId, event.chatId, cfg.classifier.contextWindow);
      if (history.length === 0) {
        history = [{ role: "user", content: event.content, ts: Date.now() }];
      }

      let result: ComplaintResult;
      try {
        result = await classifyMessage(history, apiKey, cfg.classifier.model);
      } catch (err) {
        api.logger.error(`${PLUGIN_ID}: classifier error: ${String(err)}`);
        return;
      }
      if (!result.is_complaint) return;
      if (SEVERITY_ORDER[result.severity] < SEVERITY_ORDER[cfg.classifier.minSeverity]) return;

      let rowId = -1;
      try {
        rowId = insertComplaint(db, {
          agentId: agentId ?? null,
          channelId: event.channel,
          chatId: event.chatId,
          fromSender: event.senderId ?? "unknown",
          content: event.content,
          result,
        });
      } catch (err) {
        api.logger.error(`${PLUGIN_ID}: store error: ${String(err)}`);
      }

      const text = formatAlert(event, result, history);
      const delivery = await sendAlerts(api, cfg.destinations, text);
      if (delivery.successCount > 0 && rowId > 0) markNotified(db, rowId);

      api.logger.info(
        `${PLUGIN_ID}: complaint logged (id=${rowId}, severity=${result.severity}, alerts_delivered=${delivery.successCount}/${delivery.total})`,
      );
    }

    api.on("message_inbound", async (event) => {
      const cfg = resolveConfig(api.pluginConfig as Record<string, unknown>);
      if (!matchesChannelSub(cfg.subscriptions, event)) return;
      await process(event, undefined);
    });

    api.on("message_received", async (event, ctx) => {
      const cfg = resolveConfig(api.pluginConfig as Record<string, unknown>);
      if (!matchesAgentSub(cfg.subscriptions, ctx.agentId)) return;
      await process(
        {
          channel: ctx.channelId,
          accountId: (ctx as { accountId?: string }).accountId ?? "default",
          chatId: (ctx as { chatId?: string }).chatId ?? "unknown",
          senderId: event.from,
          content: event.content,
          messageId: (event as { messageId?: string }).messageId,
        },
        ctx.agentId,
      );
    });
  },
};
```

---

## Appendix M — `src/gateway/server-methods/complaints.test.ts`

```ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import { DatabaseSync } from "node:sqlite";
import { pluginsComplaintsRecent } from "./complaints.js";
import { initSchema, insertComplaint } from "../../../extensions/complaint-listener/src/store.js";

function seed(dir: string): DatabaseSync {
  const dbPath = path.join(dir, "plugins", "complaint-listener", "complaints.db");
  fsSync.mkdirSync(path.dirname(dbPath), { recursive: true });
  const db = new DatabaseSync(dbPath);
  initSchema(db);
  return db;
}

import * as fsSync from "node:fs";

describe("plugins.complaints.recent", () => {
  let stateDir: string;

  beforeEach(async () => {
    stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "cl-recent-"));
  });

  afterEach(async () => {
    await fs.rm(stateDir, { recursive: true, force: true });
  });

  it("returns empty array when no rows", async () => {
    seed(stateDir);
    const result = await pluginsComplaintsRecent({ stateDir, ctx: { isAdmin: true }, params: { limit: 10 } });
    expect(result).toEqual([]);
  });

  it("returns rows in DESC order", async () => {
    const db = seed(stateDir);
    insertComplaint(db, {
      agentId: null, channelId: "telegram", chatId: "c1", fromSender: "u1", content: "a",
      result: { is_complaint: true, severity: "low", category: "other", summary: "a" },
    });
    insertComplaint(db, {
      agentId: null, channelId: "telegram", chatId: "c1", fromSender: "u1", content: "b",
      result: { is_complaint: true, severity: "high", category: "other", summary: "b" },
    });
    const result = await pluginsComplaintsRecent({ stateDir, ctx: { isAdmin: true }, params: { limit: 10 } });
    expect(result).toHaveLength(2);
    expect(result[0]?.summary).toBe("b");
  });

  it("respects limit", async () => {
    const db = seed(stateDir);
    for (let i = 0; i < 5; i++) {
      insertComplaint(db, {
        agentId: null, channelId: "telegram", chatId: "c1", fromSender: "u1", content: `m${i}`,
        result: { is_complaint: true, severity: "low", category: "other", summary: `s${i}` },
      });
    }
    const result = await pluginsComplaintsRecent({ stateDir, ctx: { isAdmin: true }, params: { limit: 2 } });
    expect(result).toHaveLength(2);
  });

  it("rejects non-admin", async () => {
    seed(stateDir);
    await expect(
      pluginsComplaintsRecent({ stateDir, ctx: { isAdmin: false }, params: { limit: 10 } }),
    ).rejects.toThrow(/admin/i);
  });
});
```

---

## Appendix N — `src/gateway/server-methods/complaints.ts`

```ts
import { DatabaseSync } from "node:sqlite";
import fs from "node:fs";
import path from "node:path";
import { initSchema, getRecentComplaints } from "../../../extensions/complaint-listener/src/store.js";
import type { ComplaintRow } from "../../../extensions/complaint-listener/src/types.js";

export interface PluginsComplaintsRecentParams {
  limit?: number;
  chatId?: string;
}

export async function pluginsComplaintsRecent(deps: {
  stateDir: string;
  ctx: { isAdmin: boolean };
  params: PluginsComplaintsRecentParams;
}): Promise<ComplaintRow[]> {
  if (!deps.ctx.isAdmin) throw new Error("plugins.complaints.recent: admin only");
  const dbPath = path.join(deps.stateDir, "plugins", "complaint-listener", "complaints.db");
  if (!fs.existsSync(dbPath)) return [];
  const db = new DatabaseSync(dbPath);
  try {
    initSchema(db);
    return getRecentComplaints(db, deps.params.limit ?? 20);
  } finally {
    // node:sqlite Database has no .close() — GC handles it; fine for short-lived reads.
  }
}
```

---

## Appendix O — SPA scaffold files

### `extensions/complaint-listener/ui/package.json`

```json
{
  "name": "@minion-stack-internal/complaint-listener-ui",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@minion-stack/plugin-ui-bridge": "workspace:*",
    "svelte": "^5.0.0"
  },
  "devDependencies": {
    "@sveltejs/vite-plugin-svelte": "^4.0.0",
    "@tailwindcss/postcss": "^4.0.0",
    "autoprefixer": "^10.4.0",
    "postcss": "^8.4.0",
    "svelte-check": "^4.0.0",
    "tailwindcss": "^4.0.0",
    "typescript": "^5.5.0",
    "vite": "^6.0.0"
  }
}
```

### `extensions/complaint-listener/ui/tsconfig.json`

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"]
  },
  "include": ["src", "vite.config.ts"]
}
```

### `extensions/complaint-listener/ui/vite.config.ts`

```ts
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";

export default defineConfig({
  plugins: [svelte()],
  build: {
    outDir: "dist",
    emptyOutDir: true,
    target: "es2022",
  },
  server: { port: 5184 },
});
```

### `extensions/complaint-listener/ui/index.html`

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Complaint Listener</title>
    <link rel="stylesheet" href="/src/app.css" />
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
```

### `extensions/complaint-listener/ui/src/main.ts`

```ts
import { mount } from "svelte";
import App from "./App.svelte";
import "./app.css";

mount(App, { target: document.getElementById("app")! });
```

### `extensions/complaint-listener/ui/src/app.css`

```css
@import "tailwindcss";
```

### `extensions/complaint-listener/ui/tailwind.config.cjs`

```js
module.exports = {
  content: ["./index.html", "./src/**/*.{ts,svelte}"],
  theme: { extend: {} },
  plugins: [],
};
```

### `extensions/complaint-listener/ui/postcss.config.cjs`

```js
module.exports = {
  plugins: { "@tailwindcss/postcss": {}, autoprefixer: {} },
};
```

### `extensions/complaint-listener/ui/src/App.svelte` (minimal placeholder; Task 13 fills it)

```svelte
<script lang="ts">
  let title = $state("Complaint Listener");
</script>

<main class="p-6">
  <h1 class="text-2xl font-semibold">{title}</h1>
  <p class="text-sm text-gray-500">Loading…</p>
</main>
```

---

## Appendix P — `extensions/complaint-listener/ui/src/lib/bridge.ts`

```ts
import { PluginBridge, type HostToPlugin } from "@minion-stack/plugin-ui-bridge";

const EXPECTED_HOST_ORIGIN = (() => {
  try {
    const ref = document.referrer ? new URL(document.referrer) : null;
    return ref?.origin ?? "*";
  } catch {
    return "*";
  }
})();

const bridge = new PluginBridge({
  self: window,
  parent: window.parent,
  expectedHostOrigin: EXPECTED_HOST_ORIGIN,
});

export let theme = $state<"light" | "dark">("light");
export let gatewayUrl = $state("");
export let authToken = $state("");
export let ready = $state(false);

bridge.onHello((msg) => {
  theme = msg.theme;
  gatewayUrl = msg.gatewayUrl;
  authToken = msg.authToken;
  ready = true;
});

bridge.onThemeChange((msg) => {
  theme = msg.theme;
});

bridge.notifyReady();

export function notifyResize(height: number) {
  bridge.notifyResize(height);
}
```

---

## Appendix Q — `extensions/complaint-listener/ui/src/lib/rpc.ts`

```ts
let nextId = 1;
const pending = new Map<number, { resolve: (v: any) => void; reject: (e: any) => void }>();
let socket: WebSocket | null = null;

export function connectRpc(url: string, token: string): Promise<void> {
  return new Promise((resolve, reject) => {
    socket = new WebSocket(url);
    socket.addEventListener("open", () => {
      socket!.send(JSON.stringify({ method: "connect", params: { token } }));
      resolve();
    });
    socket.addEventListener("error", reject);
    socket.addEventListener("message", (ev) => {
      try {
        const msg = JSON.parse(ev.data);
        const p = pending.get(msg.id);
        if (!p) return;
        pending.delete(msg.id);
        if (msg.error) p.reject(new Error(msg.error.message ?? "rpc error"));
        else p.resolve(msg.result);
      } catch (err) {
        console.error("rpc parse error", err);
      }
    });
  });
}

export function call<T>(method: string, params: unknown): Promise<T> {
  if (!socket || socket.readyState !== 1) return Promise.reject(new Error("not connected"));
  const id = nextId++;
  return new Promise<T>((resolve, reject) => {
    pending.set(id, { resolve, reject });
    socket!.send(JSON.stringify({ id, method, params }));
  });
}

export const pluginsConfigGet = (pluginId: string) =>
  call<{ config: Record<string, unknown>; channels: any[]; agents: any[] }>("plugins.config.get", { pluginId });

export const pluginsConfigSet = (pluginId: string, config: Record<string, unknown>, enabled?: boolean) =>
  call<{ ok: boolean; restartRequired: boolean; errors?: string[] }>("plugins.config.set", { pluginId, config, enabled });

export const pluginsComplaintsRecent = (limit = 20) =>
  call<any[]>("plugins.complaints.recent", { limit });
```

---

## Appendix R — `extensions/complaint-listener/ui/src/lib/stores.ts`

```ts
export const cfg = $state<{
  subscriptions: any[];
  destinations: any[];
  classifier: { model?: string; contextWindow?: number; minSeverity?: "low" | "med" | "high" };
}>({
  subscriptions: [],
  destinations: [],
  classifier: {},
});

export const channels = $state<Array<{ channel: string; accountId: string; label?: string }>>([]);
export const agents = $state<Array<{ id: string; name?: string }>>([]);
export const recents = $state<any[]>([]);
export const saving = $state(false);
export const saveError = $state<string | null>(null);
```

---

## Appendix S — `extensions/complaint-listener/ui/src/lib/SubscriptionsPanel.svelte`

```svelte
<script lang="ts">
  interface Props {
    subs: any[];
    channels: any[];
    agents: any[];
    onAdd: (sub: any) => void;
    onRemove: (idx: number) => void;
  }
  let { subs, channels, agents, onAdd, onRemove }: Props = $props();

  let dialogOpen = $state(false);
  let kind = $state<"channel" | "agent" | "channel-catchall">("channel");
  let selectedChannel = $state("");
  let selectedAccountId = $state("");
  let selectedAgent = $state("");

  function add() {
    if (kind === "channel") {
      onAdd({ kind, channel: selectedChannel, accountId: selectedAccountId || undefined });
    } else if (kind === "agent") {
      onAdd({ kind, agentId: selectedAgent });
    } else {
      onAdd({ kind: "channel-catchall" });
    }
    dialogOpen = false;
    selectedChannel = "";
    selectedAccountId = "";
    selectedAgent = "";
  }
</script>

<section class="space-y-2">
  <header class="flex items-center justify-between">
    <h2 class="text-lg font-semibold">Subscriptions</h2>
    <button class="rounded bg-gray-900 px-3 py-1 text-sm text-white" onclick={() => (dialogOpen = true)}>+ Add</button>
  </header>
  {#if subs.length === 0}
    <p class="text-sm text-gray-500">No subscriptions configured.</p>
  {:else}
    <ul class="divide-y rounded border">
      {#each subs as s, i (i)}
        <li class="flex items-center justify-between p-3 text-sm">
          <span>
            {#if s.kind === "channel"}Channel: {s.channel}{#if s.accountId} ({s.accountId}){/if}
            {:else if s.kind === "agent"}Agent: {s.agentId}
            {:else}Catch-all (every channel){/if}
          </span>
          <button class="text-red-600" onclick={() => onRemove(i)}>✕</button>
        </li>
      {/each}
    </ul>
  {/if}

  {#if dialogOpen}
    <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
      <div class="w-96 rounded bg-white p-4 shadow-lg">
        <h3 class="mb-3 text-base font-semibold">Add subscription</h3>
        <div class="mb-3 flex gap-2 text-sm">
          <button class="rounded border px-2 py-1" class:bg-gray-900={kind === "channel"} class:text-white={kind === "channel"} onclick={() => (kind = "channel")}>Channel</button>
          <button class="rounded border px-2 py-1" class:bg-gray-900={kind === "agent"} class:text-white={kind === "agent"} onclick={() => (kind = "agent")}>Agent</button>
          <button class="rounded border px-2 py-1" class:bg-gray-900={kind === "channel-catchall"} class:text-white={kind === "channel-catchall"} onclick={() => (kind = "channel-catchall")}>Catch-all</button>
        </div>
        {#if kind === "channel"}
          <select class="mb-2 w-full rounded border p-2 text-sm" bind:value={selectedChannel}>
            <option value="">Select channel…</option>
            {#each channels as c}
              <option value={c.channel}>{c.channel} {c.accountId ? `(${c.accountId})` : ""}</option>
            {/each}
          </select>
          <input class="mb-2 w-full rounded border p-2 text-sm" placeholder="Account ID (optional)" bind:value={selectedAccountId} />
        {:else if kind === "agent"}
          <select class="mb-2 w-full rounded border p-2 text-sm" bind:value={selectedAgent}>
            <option value="">Select agent…</option>
            {#each agents as a}
              <option value={a.id}>{a.name ?? a.id}</option>
            {/each}
          </select>
        {:else}
          <p class="mb-2 text-xs text-amber-700">Subscribes to every inbound message. Use sparingly.</p>
        {/if}
        <div class="mt-3 flex justify-end gap-2">
          <button class="rounded border px-3 py-1 text-sm" onclick={() => (dialogOpen = false)}>Cancel</button>
          <button class="rounded bg-gray-900 px-3 py-1 text-sm text-white" onclick={add}>Add</button>
        </div>
      </div>
    </div>
  {/if}
</section>
```

---

## Appendix T — `extensions/complaint-listener/ui/src/lib/DestinationsPanel.svelte`

```svelte
<script lang="ts">
  interface Props {
    dests: any[];
    channels: any[];
    onAdd: (d: any) => void;
    onRemove: (i: number) => void;
  }
  let { dests, channels, onAdd, onRemove }: Props = $props();
  let dialogOpen = $state(false);
  let selectedChannel = $state("");
  let selectedAccountId = $state("");
  let to = $state("");
  let label = $state("");

  function add() {
    onAdd({ channel: selectedChannel, accountId: selectedAccountId || undefined, to, label: label || undefined });
    dialogOpen = false;
    selectedChannel = "";
    selectedAccountId = "";
    to = "";
    label = "";
  }
</script>

<section class="space-y-2">
  <header class="flex items-center justify-between">
    <h2 class="text-lg font-semibold">Destinations</h2>
    <button class="rounded bg-gray-900 px-3 py-1 text-sm text-white" onclick={() => (dialogOpen = true)}>+ Add</button>
  </header>
  {#if dests.length === 0}
    <p class="text-sm text-gray-500">No destinations configured.</p>
  {:else}
    <ul class="divide-y rounded border">
      {#each dests as d, i (i)}
        <li class="flex items-center justify-between p-3 text-sm">
          <span>{d.label ?? d.to} → {d.channel} {d.to}{#if d.accountId} ({d.accountId}){/if}</span>
          <button class="text-red-600" onclick={() => onRemove(i)}>✕</button>
        </li>
      {/each}
    </ul>
  {/if}

  {#if dialogOpen}
    <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
      <div class="w-96 rounded bg-white p-4 shadow-lg">
        <h3 class="mb-3 text-base font-semibold">Add destination</h3>
        <select class="mb-2 w-full rounded border p-2 text-sm" bind:value={selectedChannel}>
          <option value="">Select channel…</option>
          {#each channels as c}
            <option value={c.channel}>{c.channel} {c.accountId ? `(${c.accountId})` : ""}</option>
          {/each}
        </select>
        <input class="mb-2 w-full rounded border p-2 text-sm" placeholder="To (phone, chat ID, etc.)" bind:value={to} />
        <input class="mb-2 w-full rounded border p-2 text-sm" placeholder="Account ID (optional)" bind:value={selectedAccountId} />
        <input class="mb-2 w-full rounded border p-2 text-sm" placeholder="Label (optional)" bind:value={label} />
        <div class="mt-3 flex justify-end gap-2">
          <button class="rounded border px-3 py-1 text-sm" onclick={() => (dialogOpen = false)}>Cancel</button>
          <button class="rounded bg-gray-900 px-3 py-1 text-sm text-white" onclick={add}>Add</button>
        </div>
      </div>
    </div>
  {/if}
</section>
```

---

## Appendix U — `extensions/complaint-listener/ui/src/lib/ClassifierPanel.svelte`

```svelte
<script lang="ts">
  interface Props {
    classifier: { model?: string; contextWindow?: number; minSeverity?: "low" | "med" | "high" };
    onChange: (next: Props["classifier"]) => void;
  }
  let { classifier, onChange }: Props = $props();

  function update(key: keyof Props["classifier"], value: any) {
    onChange({ ...classifier, [key]: value });
  }
</script>

<section class="space-y-2">
  <h2 class="text-lg font-semibold">Classifier (advanced)</h2>
  <div class="grid grid-cols-3 gap-3 text-sm">
    <label class="block">
      Model
      <select class="mt-1 w-full rounded border p-2" value={classifier.model ?? "claude-haiku-4-5"} oninput={(e) => update("model", (e.target as HTMLSelectElement).value)}>
        <option value="claude-haiku-4-5">claude-haiku-4-5</option>
        <option value="claude-sonnet-4-5">claude-sonnet-4-5</option>
      </select>
    </label>
    <label class="block">
      Context window
      <input type="number" min="1" max="20" class="mt-1 w-full rounded border p-2" value={classifier.contextWindow ?? 5} oninput={(e) => update("contextWindow", Number((e.target as HTMLInputElement).value))} />
    </label>
    <label class="block">
      Min severity
      <select class="mt-1 w-full rounded border p-2" value={classifier.minSeverity ?? "low"} oninput={(e) => update("minSeverity", (e.target as HTMLSelectElement).value)}>
        <option value="low">low</option>
        <option value="med">med</option>
        <option value="high">high</option>
      </select>
    </label>
  </div>
</section>
```

---

## Appendix V — `extensions/complaint-listener/ui/src/App.svelte` (full)

```svelte
<script lang="ts">
  import { onMount } from "svelte";
  import * as bridge from "./lib/bridge.svelte";
  import * as rpc from "./lib/rpc";
  import * as stores from "./lib/stores.svelte";
  import SubscriptionsPanel from "./lib/SubscriptionsPanel.svelte";
  import DestinationsPanel from "./lib/DestinationsPanel.svelte";
  import ClassifierPanel from "./lib/ClassifierPanel.svelte";
  import RecentsPanel from "./lib/RecentsPanel.svelte";

  let mounted = $state(false);

  onMount(async () => {
    await new Promise<void>((resolve) => {
      const check = () => {
        if (bridge.ready) resolve();
        else setTimeout(check, 50);
      };
      check();
    });
    await rpc.connectRpc(bridge.gatewayUrl, bridge.authToken);
    const got = await rpc.pluginsConfigGet("complaint-listener");
    stores.cfg.subscriptions = (got.config.subscriptions as any[]) ?? [];
    stores.cfg.destinations = (got.config.destinations as any[]) ?? [];
    stores.cfg.classifier = (got.config.classifier as any) ?? {};
    stores.channels.splice(0, stores.channels.length, ...got.channels);
    stores.agents.splice(0, stores.agents.length, ...got.agents);
    stores.recents.splice(0, stores.recents.length, ...(await rpc.pluginsComplaintsRecent(20)));
    mounted = true;
  });

  async function save() {
    stores.saving = true;
    stores.saveError = null;
    try {
      const res = await rpc.pluginsConfigSet("complaint-listener", { ...stores.cfg });
      if (!res.ok) stores.saveError = (res.errors ?? []).join("; ") || "save failed";
    } catch (err) {
      stores.saveError = String(err);
    } finally {
      stores.saving = false;
    }
  }

  async function refreshRecents() {
    stores.recents.splice(0, stores.recents.length, ...(await rpc.pluginsComplaintsRecent(20)));
  }
</script>

{#if !mounted}
  <main class="p-6"><p class="text-sm text-gray-500">Loading…</p></main>
{:else}
  <main class="space-y-6 p-6">
    <header>
      <h1 class="text-2xl font-semibold">Complaint Listener</h1>
      <p class="text-sm text-gray-500">Watches subscribed channels and fans out alerts to configured destinations.</p>
    </header>

    <SubscriptionsPanel
      subs={stores.cfg.subscriptions}
      channels={stores.channels}
      agents={stores.agents}
      onAdd={(s) => (stores.cfg.subscriptions = [...stores.cfg.subscriptions, s])}
      onRemove={(i) => (stores.cfg.subscriptions = stores.cfg.subscriptions.filter((_, idx) => idx !== i))}
    />

    <DestinationsPanel
      dests={stores.cfg.destinations}
      channels={stores.channels}
      onAdd={(d) => (stores.cfg.destinations = [...stores.cfg.destinations, d])}
      onRemove={(i) => (stores.cfg.destinations = stores.cfg.destinations.filter((_, idx) => idx !== i))}
    />

    <ClassifierPanel classifier={stores.cfg.classifier} onChange={(c) => (stores.cfg.classifier = c)} />

    <div class="flex items-center gap-3">
      <button class="rounded bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-50" disabled={stores.saving} onclick={save}>
        {stores.saving ? "Saving…" : "Save changes"}
      </button>
      {#if stores.saveError}<span class="text-sm text-red-600">{stores.saveError}</span>{/if}
    </div>

    <section class="mt-8">
      <RecentsPanel recents={stores.recents} onRefresh={refreshRecents} />
    </section>
  </main>
{/if}
```

---

## Appendix W — `extensions/complaint-listener/ui/src/lib/RecentsPanel.svelte`

```svelte
<script lang="ts">
  interface Props {
    recents: any[];
    onRefresh: () => void;
  }
  let { recents, onRefresh }: Props = $props();

  const severityEmoji: Record<string, string> = { high: "🔴", med: "🟠", low: "🟡" };
</script>

<section class="space-y-2">
  <header class="flex items-center justify-between">
    <h2 class="text-lg font-semibold">Recent complaints</h2>
    <button class="rounded border px-3 py-1 text-sm" onclick={onRefresh}>↻ Refresh</button>
  </header>
  {#if recents.length === 0}
    <p class="text-sm text-gray-500">No complaints yet.</p>
  {:else}
    <table class="w-full text-sm">
      <thead class="text-left text-xs uppercase text-gray-500">
        <tr><th class="p-2">Time</th><th class="p-2">Sev</th><th class="p-2">Category</th><th class="p-2">From</th><th class="p-2">Summary</th></tr>
      </thead>
      <tbody>
        {#each recents as r (r.id)}
          <tr class="border-t">
            <td class="p-2 text-xs text-gray-500">{new Date(r.created_at).toLocaleString()}</td>
            <td class="p-2">{severityEmoji[r.severity] ?? ""} {r.severity}</td>
            <td class="p-2">{r.category}</td>
            <td class="p-2">{r.from_sender}</td>
            <td class="p-2">{r.summary}</td>
          </tr>
        {/each}
      </tbody>
    </table>
  {/if}
</section>
```

> Note on `bridge.svelte.ts` / `stores.svelte.ts`: Svelte 5 runes can only be used in `.svelte.ts` / `.svelte.js` modules. Rename `bridge.ts` → `bridge.svelte.ts` and `stores.ts` → `stores.svelte.ts` if your build complains. Update the imports in App.svelte accordingly. The plan's appendices use plain `.ts` for readability; adjust file extensions during implementation if the Svelte compiler enforces it.
