{"version":3,"file":"router-DBAZfE7X.mjs","names":[],"sources":["../src/notifications/providers/ntfy.ts","../src/notifications/providers/whatsapp.ts","../src/notifications/providers/macos.ts","../src/notifications/providers/cli.ts","../src/notifications/router.ts"],"sourcesContent":["/**\n * ntfy.ts — ntfy.sh notification provider\n *\n * Sends notifications to a configured ntfy.sh topic via HTTP.\n */\n\nimport type {\n  NotificationProvider,\n  NotificationPayload,\n  NotificationConfig,\n} from \"../types.js\";\n\nexport class NtfyProvider implements NotificationProvider {\n  readonly channelId = \"ntfy\" as const;\n\n  async send(\n    payload: NotificationPayload,\n    config: NotificationConfig\n  ): Promise<boolean> {\n    const cfg = config.channels.ntfy;\n    if (!cfg.enabled || !cfg.url) return false;\n\n    try {\n      const headers: Record<string, string> = {\n        \"Content-Type\": \"text/plain; charset=utf-8\",\n      };\n\n      if (payload.title) {\n        headers[\"Title\"] = payload.title;\n      }\n\n      if (cfg.priority && cfg.priority !== \"default\") {\n        headers[\"Priority\"] = cfg.priority;\n      }\n\n      const response = await fetch(cfg.url, {\n        method: \"POST\",\n        headers,\n        body: payload.message,\n      });\n\n      return response.ok;\n    } catch {\n      return false;\n    }\n  }\n}\n","/**\n * whatsapp.ts — WhatsApp notification provider (via the AIBroker hub)\n *\n * Sends through AIBroker's hub socket, which proxies to the whazaa adapter.\n *\n * It used to dial /tmp/whazaa.sock and call a method named `whatsapp_send`.\n * Both were wrong, and had been since AIBroker became the runtime hub and\n * adapters became thin transports: Whazaa no longer owns an IPC socket of its\n * own (it registers with the hub, currently at /tmp/whazaa-watcher.sock, which\n * is the hub's business and not ours), and `whatsapp_send` is an MCP TOOL name\n * — the adapter itself takes `send` and `tts`.\n *\n * So every WhatsApp notification failed. Silently, because a failed channel\n * writes one line to stderr and the router has no fallback: on 2026-08-04 four\n * task-bus escalations about a job that had not run for nine hours reached\n * /tmp/pai-scheduler.log and nowhere else, while the user had no idea.\n *\n * Routing through the hub rather than at the adapter directly is deliberate:\n * the hub knows where its adapters are, and that is exactly the knowledge whose\n * absence broke this. Connect-per-call, no persistent state.\n */\n\nimport { connect } from \"node:net\";\nimport { randomUUID } from \"node:crypto\";\nimport type {\n  NotificationProvider,\n  NotificationPayload,\n  NotificationConfig,\n} from \"../types.js\";\n\n/**\n * AIBroker's hub socket.\n *\n * Hardcoded rather than imported: PAI must not depend on AIBroker. This is a\n * protocol constant between them, like the agent mark in tasks/poller.ts.\n */\nconst HUB_SOCKET = \"/tmp/aibroker.sock\";\nconst HUB_TIMEOUT_MS = 10_000;\n\n/**\n * Send a single IPC call to the hub.\n * Returns true on success, false if the hub is not available or errors.\n */\nfunction callHub(\n  method: string,\n  params: Record<string, unknown>\n): Promise<boolean> {\n  return new Promise((resolve) => {\n    let done = false;\n    let buffer = \"\";\n    let timer: ReturnType<typeof setTimeout> | null = null;\n\n    function finish(ok: boolean): void {\n      if (done) return;\n      done = true;\n      if (timer) { clearTimeout(timer); timer = null; }\n      try { socket?.destroy(); } catch { /* ignore */ }\n      resolve(ok);\n    }\n\n    const socket = connect(HUB_SOCKET, () => {\n      const request = {\n        jsonrpc: \"2.0\",\n        id: randomUUID(),\n        method,\n        params,\n      };\n      socket.write(JSON.stringify(request) + \"\\n\");\n    });\n\n    socket.on(\"data\", (chunk: Buffer) => {\n      buffer += chunk.toString();\n      const nl = buffer.indexOf(\"\\n\");\n      if (nl === -1) return;\n      try {\n        const resp = JSON.parse(buffer.slice(0, nl)) as { error?: unknown; ok?: boolean };\n        // The hub reports failure as {ok:false,error}; JSON-RPC reports {error}.\n        // Checking only `error` counted an {ok:false} reply as a delivered\n        // notification, which is the one mistake this file must never make.\n        finish(!resp.error && resp.ok !== false);\n      } catch {\n        finish(false);\n      }\n    });\n\n    socket.on(\"error\", () => finish(false));\n    socket.on(\"end\", () => finish(false));\n\n    timer = setTimeout(() => finish(false), HUB_TIMEOUT_MS);\n  });\n}\n\nexport class WhatsAppProvider implements NotificationProvider {\n  readonly channelId = \"whatsapp\" as const;\n\n  async send(\n    payload: NotificationPayload,\n    config: NotificationConfig\n  ): Promise<boolean> {\n    const cfg = config.channels.whatsapp;\n    if (!cfg.enabled) return false;\n\n    const isVoiceMode = config.mode === \"voice\" || config.channels.voice.enabled;\n    const asVoice = isVoiceMode && config.mode === \"voice\";\n\n    // The adapter's own vocabulary is `send` and `tts`, reached through the\n    // hub's `adapter_call`. `whatsapp_send` — what this used to ask for — is the\n    // name of the MCP TOOL that wraps it, and the adapter has never answered to\n    // it. The two vocabularies are easy to confuse because the MCP tool exists\n    // and works; it just is not this interface.\n    const inner: Record<string, unknown> = asVoice\n      ? { text: payload.message, voice: config.channels.voice.voiceName ?? \"bm_george\" }\n      : { message: payload.message };\n\n    if (cfg.recipient) {\n      // `send` addresses by `recipient`, `tts` by `jid` — same destination,\n      // different key, per the adapter's interface.\n      inner[asVoice ? \"jid\" : \"recipient\"] = cfg.recipient;\n    }\n\n    return callHub(\"adapter_call\", {\n      adapter: \"whazaa\",\n      method: asVoice ? \"tts\" : \"send\",\n      params: inner,\n    });\n  }\n}\n","/**\n * macos.ts — macOS notification provider\n *\n * Uses the `osascript` command to display a macOS system notification.\n * Non-blocking: spawns the process and returns success without waiting.\n */\n\nimport { spawn } from \"node:child_process\";\nimport type {\n  NotificationProvider,\n  NotificationPayload,\n  NotificationConfig,\n} from \"../types.js\";\n\nexport class MacOsProvider implements NotificationProvider {\n  readonly channelId = \"macos\" as const;\n\n  async send(\n    payload: NotificationPayload,\n    config: NotificationConfig\n  ): Promise<boolean> {\n    const cfg = config.channels.macos;\n    if (!cfg.enabled) return false;\n\n    try {\n      const title = payload.title ?? \"PAI\";\n      // Escape single quotes in title and message for AppleScript\n      const safeTitle = title.replace(/'/g, \"\\\\'\");\n      const safeMessage = payload.message.replace(/'/g, \"\\\\'\");\n\n      const script = `display notification \"${safeMessage}\" with title \"${safeTitle}\"`;\n\n      return new Promise((resolve) => {\n        const child = spawn(\"osascript\", [\"-e\", script], {\n          detached: true,\n          stdio: \"ignore\",\n        });\n        child.unref();\n\n        // Give the process a moment to start, then assume success.\n        // osascript is always present on macOS.\n        child.on(\"error\", () => resolve(false));\n\n        // Resolve after a short timeout — osascript exits quickly\n        setTimeout(() => resolve(true), 200);\n      });\n    } catch {\n      return false;\n    }\n  }\n}\n","/**\n * cli.ts — CLI notification provider\n *\n * Writes notifications to the PAI daemon log (stderr).\n * Always succeeds — it's the fallback channel.\n */\n\nimport type {\n  NotificationProvider,\n  NotificationPayload,\n  NotificationConfig,\n} from \"../types.js\";\n\nexport class CliProvider implements NotificationProvider {\n  readonly channelId = \"cli\" as const;\n\n  async send(\n    payload: NotificationPayload,\n    _config: NotificationConfig\n  ): Promise<boolean> {\n    const prefix = `[pai-notify:${payload.event}]`;\n    const title = payload.title ? ` ${payload.title}:` : \"\";\n    process.stderr.write(`${prefix}${title} ${payload.message}\\n`);\n    return true;\n  }\n}\n","/**\n * router.ts — Notification router\n *\n * Routes notification events to the appropriate channels based on the\n * current mode and per-event routing config.\n *\n * Channel providers are instantiated lazily and cached.\n */\n\nimport type {\n  NotificationPayload,\n  NotificationConfig,\n  NotificationProvider,\n  ChannelId,\n  SendResult,\n  NotificationMode,\n} from \"./types.js\";\nimport { NtfyProvider } from \"./providers/ntfy.js\";\nimport { WhatsAppProvider } from \"./providers/whatsapp.js\";\nimport { MacOsProvider } from \"./providers/macos.js\";\nimport { CliProvider } from \"./providers/cli.js\";\n\n// ---------------------------------------------------------------------------\n// Provider registry (singletons — stateless, safe to reuse)\n// ---------------------------------------------------------------------------\n\nconst PROVIDERS: Record<ChannelId, NotificationProvider> = {\n  ntfy:      new NtfyProvider(),\n  whatsapp:  new WhatsAppProvider(),\n  macos:     new MacOsProvider(),\n  voice:     new WhatsAppProvider(), // Voice uses WhatsApp TTS; handled in WhatsAppProvider\n  cli:       new CliProvider(),\n};\n\n// ---------------------------------------------------------------------------\n// Channel resolution\n// ---------------------------------------------------------------------------\n\n/**\n * Given the current config, resolve which channels should receive a\n * notification for the given event type.\n *\n * Mode overrides:\n *   \"off\"       → no channels\n *   \"auto\"      → use routing table, filtered by enabled channels\n *   \"voice\"     → whatsapp (TTS enabled in provider)\n *   \"whatsapp\"  → whatsapp\n *   \"ntfy\"      → ntfy\n *   \"macos\"     → macos\n *   \"cli\"       → cli\n */\nfunction resolveChannels(\n  config: NotificationConfig,\n  event: NotificationPayload[\"event\"]\n): ChannelId[] {\n  const { mode, channels, routing } = config;\n\n  if (mode === \"off\") return [];\n\n  // Non-auto modes: force a single channel\n  const modeToChannel: Partial<Record<NotificationMode, ChannelId>> = {\n    voice:     \"whatsapp\",  // WhatsAppProvider checks mode === \"voice\" for TTS\n    whatsapp:  \"whatsapp\",\n    ntfy:      \"ntfy\",\n    macos:     \"macos\",\n    cli:       \"cli\",\n  };\n\n  if (mode !== \"auto\") {\n    const ch = modeToChannel[mode];\n    if (!ch) return [];\n    // Check the channel is enabled\n    const cfg = channels[ch];\n    if (cfg && !cfg.enabled) return [ch]; // Still send — mode override bypasses enabled check\n    return [ch];\n  }\n\n  // Auto mode: use routing table, filter to enabled channels\n  const candidates = routing[event] ?? [];\n  return candidates.filter((ch) => {\n    const cfg = channels[ch];\n    // \"voice\" channel is virtual — it overlaps with whatsapp.\n    // Skip \"voice\" as an independent channel; voice is handled by checking config.mode.\n    if (ch === \"voice\") return false;\n    return cfg?.enabled === true;\n  });\n}\n\n// ---------------------------------------------------------------------------\n// Router\n// ---------------------------------------------------------------------------\n\n/**\n * Route a notification to the appropriate channels.\n *\n * Sends to all resolved channels in parallel.\n * Individual channel failures are non-fatal and logged to stderr.\n *\n * @param payload  The notification to send\n * @param config   The current notification config (from daemon state)\n */\nexport async function routeNotification(\n  payload: NotificationPayload,\n  config: NotificationConfig\n): Promise<SendResult> {\n  const channels = resolveChannels(config, payload.event);\n\n  if (channels.length === 0) {\n    return {\n      channelsAttempted: [],\n      channelsSucceeded: [],\n      channelsFailed: [],\n      mode: config.mode,\n    };\n  }\n\n  const results = await Promise.allSettled(\n    channels.map(async (ch) => {\n      const provider = PROVIDERS[ch];\n      const ok = await provider.send(payload, config);\n      if (!ok) {\n        process.stderr.write(\n          `[pai-notify] Channel ${ch} failed for event ${payload.event}\\n`\n        );\n      }\n      return { ch, ok };\n    })\n  );\n\n  const succeeded: ChannelId[] = [];\n  const failed: ChannelId[] = [];\n\n  for (const r of results) {\n    if (r.status === \"fulfilled\") {\n      if (r.value.ok) {\n        succeeded.push(r.value.ch);\n      } else {\n        failed.push(r.value.ch);\n      }\n    } else {\n      // Provider threw — treat as failure\n      failed.push(channels[results.indexOf(r)]);\n    }\n  }\n\n  return {\n    channelsAttempted: channels,\n    channelsSucceeded: succeeded,\n    channelsFailed: failed,\n    mode: config.mode,\n  };\n}\n"],"mappings":"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"}