agent-relay-channels-host 0.105.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # agent-relay-channels-host
2
+
3
+ The **channels host** is a single `agent-relay.connector.v1` connector (`kind:"channel"`)
4
+ that bundles N lightweight egress adapters for the long tail of trivial third-party
5
+ integrations (ntfy, generic webhook, slack/discord-egress, …). It keeps Agent Relay
6
+ **core free of per-service transport code** — core owns only the registry, routing,
7
+ and the `channel.v1` contract; the actual HTTP/push for each service lives in an
8
+ adapter here.
9
+
10
+ Heavyweight, stateful, two-way integrations (Telegram-class) keep their own repo. This
11
+ host is for one-way / egress-only and other ~30-line adapters.
12
+
13
+ > Status: the host (#778) installs, registers as a channel connector, and is
14
+ > health-polled by the existing connector subsystem. **ntfy (#774)** is the first
15
+ > bundled adapter; it registers `ntfy:default` (outbound) only when `NTFY_TOPIC` is
16
+ > set, so an unconfigured install still registers zero channels cleanly.
17
+ >
18
+ > Productionized (#794): published as part of the agent-relay lockstep release set and
19
+ > installed into `~/.agent-relay/runtime` as a dependency of `agent-relay-server`. The
20
+ > relay **auto-registers and auto-starts** this connector on boot
21
+ > (`src/connectors.ts` → `ensureChannelsHostConnector`), so it survives deploys/restarts
22
+ > with no manual `register`/`start`. ntfy adapter config is persisted in the connector's
23
+ > `config.json` and migrated once from the legacy core `channel-type/ntfy` setting.
24
+
25
+ ## How it plugs into Agent Relay
26
+
27
+ The host reuses the existing connector lifecycle machinery (`src/connectors.ts`) — it
28
+ is a *client* of that subsystem, not a reimplementation:
29
+
30
+ - **Register the connector:** `POST /api/connectors` with the manifest
31
+ (`src/index.ts register` → `routes/connectors.ts`). The manifest declares the
32
+ `start/stop/restart/status/doctor` commands the relay drives, and an aggregated
33
+ `configSchema`.
34
+ - **Lifecycle:** the relay spawns `start` (which detaches the long-running `daemon`),
35
+ `stop`, `restart`, `status`, `doctor` with a 30s timeout and persists their JSON
36
+ output as connector state. The 60s status poller keeps the dashboard honest.
37
+
38
+ ## The adapter interface
39
+
40
+ Adapters implement `ChannelAdapter` (`src/adapter.ts`). The host owns **all** relay
41
+ plumbing, so an adapter never touches SSE, agent registration, or tokens:
42
+
43
+ | verb | owner | what it does |
44
+ | --- | --- | --- |
45
+ | `registerChannel()` | **host** | `POST /api/agents` with the integration token for each `ChannelSpec`: id `provider:account`, `kind:"channel"`, `meta.direction`. Core derives the channel row + directionality from this. |
46
+ | `onOutboundMessage()` | **host** | subscribe `GET /api/events?for=<channelAgentId>`; the relay fans out only `message.new` frames addressed to that agent. |
47
+ | `push()` | **adapter** | the only transport an adapter writes — deliver one outbound message to the service. |
48
+
49
+ ```ts
50
+ import type { ChannelAdapter } from "agent-relay-channels-host/adapter";
51
+
52
+ export const myAdapter: ChannelAdapter = {
53
+ provider: "ntfy",
54
+ displayName: "ntfy",
55
+ configSchema: { properties: { NTFY_TOPIC: { type: "string" } }, required: ["NTFY_TOPIC"] },
56
+ channels(ctx) {
57
+ const topic = ctx.get("NTFY_TOPIC");
58
+ return topic ? [{ account: "default", direction: "outbound", config: { topic } }] : [];
59
+ },
60
+ async push(message, channel, ctx) {
61
+ const topic = channel.config?.topic as string;
62
+ await fetch(`https://ntfy.sh/${topic}`, { method: "POST", body: message.body });
63
+ },
64
+ };
65
+ ```
66
+
67
+ Register it by appending to `src/adapters/index.ts` — no host or core changes:
68
+
69
+ ```ts
70
+ export const adapters: ChannelAdapter[] = [ntfyAdapter, myAdapter];
71
+ ```
72
+
73
+ See `src/adapters/ntfy.ts` for the reference implementation.
74
+
75
+ Each adapter's `configSchema` fragment is merged into the connector manifest's
76
+ aggregated `configSchema` (`src/manifest.ts`); dashboard-managed settings flow back to
77
+ the adapter through `ctx.get(name)` (precedence: `process.env` > connector
78
+ `config.json`).
79
+
80
+ ## CLI
81
+
82
+ ```
83
+ agent-relay-channels-host <start|stop|restart|status|doctor|register|manifest|daemon>
84
+ ```
85
+
86
+ - `register` — `POST /api/connectors` with the aggregated manifest.
87
+ - `manifest` — print the aggregated manifest as `{ manifest }` JSON (no relay call). The
88
+ relay's boot auto-register spawns this from the deployed binary, so the manifest's
89
+ `binary`/commands resolve to the installed runtime path, never a repo-tree path.
90
+ - `start` — detach the daemon, report `{status, running, endpoint}`.
91
+ - `status` / `doctor` — JSON the relay persists as connector state.
92
+ - `daemon` — internal long-running process (SSE subscriptions + push routing + status server).
93
+
94
+ ## Config
95
+
96
+ | key | default | purpose |
97
+ | --- | --- | --- |
98
+ | `AGENT_RELAY_URL` | `http://127.0.0.1:4850` | relay base the host registers channels against |
99
+ | `AGENT_RELAY_TOKEN` | — | integration/component token (`agent:write`); empty on a tokenless localhost relay |
100
+ | `CHANNELS_HOST_PORT` | `4863` | daemon status HTTP server port |
101
+
102
+ Plus every bundled adapter's namespaced keys. ntfy:
103
+
104
+ | key | default | purpose |
105
+ | --- | --- | --- |
106
+ | `NTFY_TOPIC` | — | topic to publish to; empty disables the ntfy channel |
107
+ | `NTFY_SERVER_URL` | `https://ntfy.sh` | ntfy server base URL |
108
+ | `NTFY_TOKEN` | — | optional access token (sent as `Bearer`) |
109
+ | `NTFY_DEFAULT_PRIORITY` | `default` | priority when the event omits one (`min`…`urgent`) |
110
+ | `NTFY_DEFAULT_TAGS` | — | comma-separated tags merged into every notification |
111
+ | `NTFY_DEFAULT_CLICK` | — | default click-through URL |
112
+ | `NTFY_TIMEOUT_MS` | `15000` | per-request timeout |
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "agent-relay-channels-host",
3
+ "version": "0.105.2",
4
+ "description": "Channels host connector for Agent Relay — bundles N lightweight egress adapters (ntfy, webhook, …) behind one agent-relay.connector.v1 channel connector. Core ships zero per-service transport.",
5
+ "type": "module",
6
+ "bin": {
7
+ "agent-relay-channels-host": "src/index.ts"
8
+ },
9
+ "files": [
10
+ "src/**/*.ts",
11
+ "!src/**/*.test.ts",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "start": "bun src/index.ts daemon",
16
+ "doctor": "bun src/index.ts doctor",
17
+ "register": "bun src/index.ts register",
18
+ "test": "bun test"
19
+ },
20
+ "license": "AGPL-3.0-or-later",
21
+ "author": "Edin Mujkanovic <edin@exelerus.com>",
22
+ "dependencies": {
23
+ "agent-relay-sdk": "0.2.92"
24
+ }
25
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,117 @@
1
+ import type { ChannelDirection } from "agent-relay-sdk";
2
+
3
+ /**
4
+ * The channels-host adapter contract.
5
+ *
6
+ * An adapter is a lightweight module (~30 lines) describing the egress for a single
7
+ * third-party provider — ntfy, a generic webhook, slack/discord-egress, etc. The
8
+ * host owns ALL relay plumbing so adapters never re-implement it:
9
+ *
10
+ * 1. registerChannel() — the host POSTs /api/agents for each ChannelSpec the
11
+ * adapter declares (`provider:account` id, kind:"channel",
12
+ * meta.direction). See src/relay.ts + src/host.ts.
13
+ * 2. onOutboundMessage() — the host subscribes GET /api/events?for=<channelAgentId>
14
+ * on the SSE bus for each registered channel and routes
15
+ * every relay message addressed to it into push().
16
+ * 3. push() — the ONLY transport the adapter implements: deliver one
17
+ * outbound message to the third-party service.
18
+ *
19
+ * configSchema fragments are aggregated across all bundled adapters into the
20
+ * connector manifest's `configSchema` (see src/manifest.ts), so dashboard-managed
21
+ * settings flow back to each adapter via AdapterContext.get().
22
+ *
23
+ * This split is the whole point of the epic (#773): core stays thin, the long tail
24
+ * of trivial egress integrations lives here, and a new adapter ships with zero core
25
+ * changes.
26
+ */
27
+ export interface ChannelAdapter {
28
+ /**
29
+ * Stable provider id, e.g. "ntfy". Forms the `provider` half of every channel
30
+ * agent id this adapter registers. Must be lowercase `[a-z0-9_-]`.
31
+ */
32
+ readonly provider: string;
33
+ /** Human-facing name shown in dashboards/logs. */
34
+ readonly displayName: string;
35
+ /**
36
+ * JSON-schema fragment (an object schema with `properties`/`required`) contributed
37
+ * to the connector's aggregated configSchema. Keys should be namespaced to the
38
+ * provider (e.g. NTFY_TOPIC) to avoid collisions across adapters.
39
+ */
40
+ readonly configSchema?: JsonSchemaObject;
41
+ /**
42
+ * Declare the channels this adapter owns, derived from current config. Each becomes
43
+ * a `provider:account` channel agent. Return [] when the adapter is unconfigured
44
+ * (e.g. no topic set) — the host skips it cleanly, which is the zero-adapter case
45
+ * the scaffold ships with. May be async (e.g. to probe the upstream).
46
+ */
47
+ channels(ctx: AdapterContext): ChannelSpec[] | Promise<ChannelSpec[]>;
48
+ /**
49
+ * Deliver one outbound relay message addressed to `channel`. Called by the host for
50
+ * every SSE `message.new` frame routed to the channel's agent. Resolve on success;
51
+ * throw to mark the push failed — the host logs it and surfaces it in connector
52
+ * status/doctor.
53
+ */
54
+ push(message: OutboundMessage, channel: ResolvedChannel, ctx: AdapterContext): Promise<void>;
55
+ }
56
+
57
+ /** Minimal JSON-schema object shape an adapter contributes for its settings. */
58
+ export interface JsonSchemaObject {
59
+ type?: "object";
60
+ properties?: Record<string, unknown>;
61
+ required?: string[];
62
+ }
63
+
64
+ /**
65
+ * One channel an adapter declares. The host turns it into a channel agent registered
66
+ * as `${provider}:${account}` with kind:"channel" and meta.direction.
67
+ */
68
+ export interface ChannelSpec {
69
+ /** Account half of the channel id; combined as `${provider}:${account}` (e.g. "default"). */
70
+ account: string;
71
+ /** Directionality, written to meta.direction. Egress adapters use "outbound". */
72
+ direction: ChannelDirection;
73
+ /** Optional human-facing channel name; defaults to the adapter displayName. */
74
+ displayName?: string;
75
+ /** Extra agent tags merged into the registration (channel/provider tags are added automatically). */
76
+ tags?: string[];
77
+ /** Extra agent capabilities merged into the registration. */
78
+ capabilities?: string[];
79
+ /** Extra agent meta merged into the registration (kind/provider/accountId/direction are set by the host). */
80
+ meta?: Record<string, unknown>;
81
+ /** Opaque per-channel config the adapter needs at push() time (e.g. the resolved topic/url). */
82
+ config?: Record<string, unknown>;
83
+ }
84
+
85
+ /** A ChannelSpec resolved with its concrete relay agent id. */
86
+ export type ResolvedChannel = ChannelSpec & {
87
+ /** `${provider}:${account}` — the relay channel agent id. */
88
+ readonly agentId: string;
89
+ /** The owning adapter's provider id. */
90
+ readonly provider: string;
91
+ };
92
+
93
+ /** A relay message handed to push() — the outbound channel.v1 envelope on the SSE bus. */
94
+ export interface OutboundMessage {
95
+ id: number;
96
+ from: string;
97
+ to: string;
98
+ kind: string;
99
+ body: string;
100
+ payload?: Record<string, unknown>;
101
+ createdAt: number;
102
+ }
103
+
104
+ /** Capabilities the host exposes to adapters. Keeps adapters free of relay plumbing. */
105
+ export interface AdapterContext {
106
+ /** Relay HTTP base, e.g. http://127.0.0.1:4850. */
107
+ readonly relayUrl: string;
108
+ /** Resolve a setting: process.env > connector config.json > undefined. */
109
+ get(name: string): string | undefined;
110
+ /** Structured log line, prefixed with the connector id by the host. */
111
+ log(message: string): void;
112
+ }
113
+
114
+ /** Build the `${provider}:${account}` relay channel agent id. */
115
+ export function channelAgentId(provider: string, account: string): string {
116
+ return `${provider}:${account}`;
117
+ }
@@ -0,0 +1,12 @@
1
+ import type { ChannelAdapter } from "../adapter";
2
+ import { ntfyAdapter } from "./ntfy";
3
+
4
+ /**
5
+ * The bundled adapter registry.
6
+ *
7
+ * Adding an adapter is a one-line append here plus its module — no host or core
8
+ * changes. ntfy (#774) is the first; it only registers a `ntfy:default` outbound
9
+ * channel when NTFY_TOPIC is configured, so an unconfigured install still registers
10
+ * zero channels cleanly (the scaffold property from #778).
11
+ */
12
+ export const adapters: ChannelAdapter[] = [ntfyAdapter];
@@ -0,0 +1,182 @@
1
+ import { isRecord } from "agent-relay-sdk";
2
+ import type { AdapterContext, ChannelAdapter, ChannelSpec, OutboundMessage, ResolvedChannel } from "../adapter";
3
+
4
+ /**
5
+ * ntfy egress adapter — the first channels-host adapter (#774).
6
+ *
7
+ * Ports the transport from the old in-core `src/ntfy.ts` (sendNtfyNotification /
8
+ * ntfy headers / config schema) into a `ChannelAdapter`. The host owns channel
9
+ * registration + the SSE subscription; this module only declares the channel and
10
+ * implements push().
11
+ *
12
+ * The outbound envelope it receives is the pinned `agent-relay.channel.v1` contract
13
+ * (#776): `{ direction:"outbound", channel:{provider:"ntfy",accountId:"default"},
14
+ * event:{ title, body, priority?, tags?, click? } }`, addressed to `ntfy:default`.
15
+ */
16
+
17
+ export const NTFY_PRIORITIES = ["min", "low", "default", "high", "urgent"] as const;
18
+ export type NtfyPriority = (typeof NTFY_PRIORITIES)[number];
19
+
20
+ const DEFAULT_SERVER_URL = "https://ntfy.sh";
21
+ const DEFAULT_TIMEOUT_MS = 15_000;
22
+ const MIN_TIMEOUT_MS = 1_000;
23
+ const MAX_TIMEOUT_MS = 60_000;
24
+
25
+ interface NtfyChannelConfig {
26
+ serverUrl: string;
27
+ topic: string;
28
+ defaultTags: string[];
29
+ defaultPriority?: number;
30
+ defaultClick?: string;
31
+ timeoutMs: number;
32
+ }
33
+
34
+ /** Settings the adapter contributes to the connector's aggregated configSchema. */
35
+ const configSchema = {
36
+ type: "object" as const,
37
+ properties: {
38
+ NTFY_SERVER_URL: { type: "string", maxLength: 500, default: DEFAULT_SERVER_URL, description: "ntfy server base URL." },
39
+ NTFY_TOPIC: { type: "string", maxLength: 200, description: "ntfy topic to publish to. Empty disables the ntfy channel." },
40
+ NTFY_TOKEN: { type: "string", maxLength: 4000, description: "Optional ntfy access token (sent as Bearer auth)." },
41
+ NTFY_DEFAULT_PRIORITY: {
42
+ type: "string",
43
+ enum: [...NTFY_PRIORITIES],
44
+ default: "default",
45
+ description: "Priority applied when the outbound event omits one.",
46
+ },
47
+ NTFY_DEFAULT_TAGS: { type: "string", maxLength: 500, description: "Comma-separated tags merged into every notification." },
48
+ NTFY_DEFAULT_CLICK: { type: "string", maxLength: 1000, description: "Default click-through URL when the event omits one." },
49
+ NTFY_TIMEOUT_MS: { type: "integer", minimum: MIN_TIMEOUT_MS, maximum: MAX_TIMEOUT_MS, default: DEFAULT_TIMEOUT_MS, description: "Per-request timeout." },
50
+ },
51
+ };
52
+
53
+ export const ntfyAdapter: ChannelAdapter = {
54
+ provider: "ntfy",
55
+ displayName: "ntfy",
56
+ configSchema,
57
+
58
+ channels(ctx: AdapterContext): ChannelSpec[] {
59
+ const config = resolveConfig(ctx);
60
+ // Unconfigured (no topic) → no channel, so the host registers nothing for ntfy
61
+ // and the connector stays clean. Mirrors the old `enabled` gate.
62
+ if (!config) return [];
63
+ return [
64
+ {
65
+ account: "default",
66
+ direction: "outbound",
67
+ displayName: "ntfy",
68
+ capabilities: ["notify"],
69
+ meta: { topicChannels: [config.topic] },
70
+ config: { ...config },
71
+ },
72
+ ];
73
+ },
74
+
75
+ async push(message: OutboundMessage, channel: ResolvedChannel, ctx: AdapterContext): Promise<void> {
76
+ const config = (channel.config as NtfyChannelConfig | undefined) ?? resolveConfig(ctx);
77
+ if (!config) throw new Error("ntfy channel is not configured (NTFY_TOPIC missing)");
78
+
79
+ const event = outboundEvent(message);
80
+ const body = event.body ?? message.body ?? "";
81
+ if (!body) throw new Error("ntfy push requires a non-empty event.body");
82
+
83
+ const tags = [...new Set([...config.defaultTags, ...event.tags].map((t) => t.trim()).filter(Boolean))];
84
+ const priority = event.priority ?? config.defaultPriority;
85
+ const click = event.click ?? config.defaultClick;
86
+
87
+ // #796 — publish via ntfy's JSON API (POST JSON to the server base URL) rather than
88
+ // the header-based API. HTTP header values are ISO-8859-1/ASCII-only, so a non-ASCII
89
+ // `Title` header (emoji/unicode/em-dash) was rejected upstream ("Header 'Title' has
90
+ // invalid value"). The JSON body is UTF-8 by construction, so title/message survive.
91
+ // https://docs.ntfy.sh/publish/#publish-as-json
92
+ const payload: Record<string, unknown> = { topic: config.topic, message: body };
93
+ if (event.title) payload.title = event.title;
94
+ if (priority !== undefined) payload.priority = priority;
95
+ if (tags.length) payload.tags = tags;
96
+ if (click) payload.click = click;
97
+
98
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
99
+ const token = ctx.get("NTFY_TOKEN");
100
+ if (token) headers.Authorization = `Bearer ${token}`;
101
+
102
+ const controller = new AbortController();
103
+ const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
104
+ let res: Response;
105
+ try {
106
+ res = await fetch(ntfyServerUrl(config), { method: "POST", headers, body: JSON.stringify(payload), signal: controller.signal });
107
+ } catch (e) {
108
+ if (controller.signal.aborted) throw new Error(`ntfy notify timed out after ${config.timeoutMs}ms`);
109
+ throw new Error(`ntfy notify failed: ${(e as Error).message}`);
110
+ } finally {
111
+ clearTimeout(timeout);
112
+ }
113
+ if (!res.ok) {
114
+ const text = await res.text().catch(() => "");
115
+ throw new Error(`ntfy notify failed: HTTP ${res.status}${text ? `: ${text.slice(0, 300)}` : ""}`);
116
+ }
117
+ },
118
+ };
119
+
120
+ interface ParsedEvent {
121
+ title?: string;
122
+ body?: string;
123
+ priority?: number;
124
+ tags: string[];
125
+ click?: string;
126
+ }
127
+
128
+ /** Extract the `agent-relay.channel.v1` outbound event from a relay message payload. */
129
+ function outboundEvent(message: OutboundMessage): ParsedEvent {
130
+ const event = isRecord(message.payload?.event) ? (message.payload!.event as Record<string, unknown>) : {};
131
+ return {
132
+ title: typeof event.title === "string" ? event.title : undefined,
133
+ body: typeof event.body === "string" ? event.body : undefined,
134
+ priority: ntfyPriority(event.priority),
135
+ tags: Array.isArray(event.tags) ? event.tags.filter((t): t is string => typeof t === "string") : [],
136
+ click: typeof event.click === "string" ? event.click : undefined,
137
+ };
138
+ }
139
+
140
+ function resolveConfig(ctx: AdapterContext): NtfyChannelConfig | null {
141
+ const topic = ctx.get("NTFY_TOPIC");
142
+ if (!topic) return null;
143
+ if (topic.includes("/") || topic.includes("?") || topic.includes("#")) {
144
+ throw new Error("NTFY_TOPIC must be a single topic name, not a path or URL");
145
+ }
146
+ return {
147
+ serverUrl: ctx.get("NTFY_SERVER_URL") ?? DEFAULT_SERVER_URL,
148
+ topic,
149
+ defaultTags: (ctx.get("NTFY_DEFAULT_TAGS") ?? "").split(",").map((t) => t.trim()).filter(Boolean),
150
+ defaultPriority: ntfyPriority(ctx.get("NTFY_DEFAULT_PRIORITY")),
151
+ defaultClick: ctx.get("NTFY_DEFAULT_CLICK"),
152
+ timeoutMs: clampTimeout(ctx.get("NTFY_TIMEOUT_MS")),
153
+ };
154
+ }
155
+
156
+ /** Normalize a priority (numeric ntfy 1-5, or a named level) to the numeric scale. */
157
+ function ntfyPriority(value: unknown): number | undefined {
158
+ if (typeof value === "number" && Number.isFinite(value)) return Math.min(5, Math.max(1, Math.round(value)));
159
+ if (typeof value === "string") {
160
+ const trimmed = value.trim();
161
+ if (/^[1-5]$/.test(trimmed)) return Number(trimmed);
162
+ const idx = NTFY_PRIORITIES.indexOf(trimmed as NtfyPriority);
163
+ if (idx >= 0) return idx + 1;
164
+ }
165
+ return undefined;
166
+ }
167
+
168
+ function clampTimeout(raw: string | undefined): number {
169
+ const n = Number(raw);
170
+ if (!Number.isFinite(n)) return DEFAULT_TIMEOUT_MS;
171
+ return Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, Math.round(n)));
172
+ }
173
+
174
+ // The JSON publish API posts to the server base URL (the topic travels in the body),
175
+ // unlike the header API which posts to `${base}/${topic}`.
176
+ function ntfyServerUrl(config: NtfyChannelConfig): string {
177
+ try {
178
+ return new URL(config.serverUrl.endsWith("/") ? config.serverUrl : `${config.serverUrl}/`).toString();
179
+ } catch {
180
+ throw new Error("NTFY_SERVER_URL must be a valid URL");
181
+ }
182
+ }
package/src/config.ts ADDED
@@ -0,0 +1,78 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ /**
6
+ * Runtime configuration for the channels host, resolved from the environment.
7
+ *
8
+ * The relay injects AGENT_RELAY_CONNECTOR_ID / AGENT_RELAY_CONNECTORS_DIR when it
9
+ * spawns lifecycle actions (see src/connectors.ts:execConnectorCommand). Everything
10
+ * else has a sane localhost default. Durable per-connector settings live in the
11
+ * connector's config.json (written by the dashboard / PUT /connectors/:id/config);
12
+ * the relay spawns commands with only its own process.env, so adapters resolve
13
+ * their keys through `get()` with precedence: process.env > config.json > default.
14
+ */
15
+ export interface HostConfig {
16
+ /** Connector id as registered with the relay (default "channels-host"). */
17
+ connectorId: string;
18
+ /** Base dir the relay uses for its connector registry. */
19
+ connectorsDir: string;
20
+ /** Per-connector runtime dir (pidfile, logs). */
21
+ runtimeDir: string;
22
+ /** Relay HTTP base, e.g. http://127.0.0.1:4850. */
23
+ relayUrl: string;
24
+ /** Integration/component token used to register channel agents; localhost relays accept tokenless requests. */
25
+ relayToken: string | null;
26
+ /** Port the daemon's status HTTP server listens on. */
27
+ port: number;
28
+ /**
29
+ * Resolve a per-connector setting. Precedence: process.env > config.json > undefined.
30
+ * Adapters read their own keys (e.g. NTFY_TOPIC) through this so dashboard-managed
31
+ * settings and ambient env both work without each adapter re-reading config.json.
32
+ */
33
+ get(name: string): string | undefined;
34
+ }
35
+
36
+ export const DEFAULT_CONNECTOR_ID = "channels-host";
37
+ export const DEFAULT_PORT = 4863;
38
+
39
+ function loadFileConfig(connectorsDir: string, connectorId: string): Record<string, string> {
40
+ const out: Record<string, string> = {};
41
+ try {
42
+ const raw = JSON.parse(readFileSync(join(connectorsDir, connectorId, "config.json"), "utf8"));
43
+ if (raw && typeof raw === "object") {
44
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
45
+ if (v != null) out[k] = String(v);
46
+ }
47
+ }
48
+ } catch {
49
+ /* no config file — env + defaults */
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /** Build a precedence-aware setting resolver: process.env > config.json > undefined. */
55
+ function makeResolver(fileConfig: Record<string, string>): (name: string) => string | undefined {
56
+ return (name: string): string | undefined => {
57
+ const v = process.env[name] ?? fileConfig[name];
58
+ return v && v.trim() ? v.trim() : undefined;
59
+ };
60
+ }
61
+
62
+ export function loadConfig(): HostConfig {
63
+ const connectorId = process.env.AGENT_RELAY_CONNECTOR_ID?.trim() || DEFAULT_CONNECTOR_ID;
64
+ const connectorsDir = resolve(
65
+ process.env.AGENT_RELAY_CONNECTORS_DIR?.trim() || join(homedir(), ".agent-relay", "connectors"),
66
+ );
67
+ const fileConfig = loadFileConfig(connectorsDir, connectorId);
68
+ const get = makeResolver(fileConfig);
69
+ return {
70
+ connectorId,
71
+ connectorsDir,
72
+ runtimeDir: join(connectorsDir, connectorId, "runtime"),
73
+ relayUrl: (get("AGENT_RELAY_URL") ?? "http://127.0.0.1:4850").replace(/\/$/, ""),
74
+ relayToken: get("AGENT_RELAY_TOKEN") ?? null,
75
+ port: Number(get("CHANNELS_HOST_PORT") ?? "") || DEFAULT_PORT,
76
+ get,
77
+ };
78
+ }
package/src/daemon.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { adapters } from "./adapters";
2
+ import type { HostConfig } from "./config";
3
+ import { ChannelsHost } from "./host";
4
+
5
+ /**
6
+ * Long-running daemon: keeps the channels host alive (SSE subscriptions + push routing)
7
+ * and serves a tiny status HTTP server the `status` lifecycle action and the relay's
8
+ * connector poller read. Runs under the internal `daemon` action; the relay-spawned
9
+ * `start` action detaches it and returns within the 30s lifecycle timeout.
10
+ */
11
+ export class ChannelsHostDaemon {
12
+ private readonly host: ChannelsHost;
13
+ private server: ReturnType<typeof Bun.serve> | null = null;
14
+
15
+ constructor(private readonly cfg: HostConfig) {
16
+ this.host = new ChannelsHost(cfg, adapters);
17
+ }
18
+
19
+ async start(): Promise<void> {
20
+ await this.host.start();
21
+ this.server = Bun.serve({
22
+ port: this.cfg.port,
23
+ idleTimeout: 0,
24
+ fetch: async (req) => {
25
+ const url = new URL(req.url);
26
+ if (url.pathname === "/health") return Response.json({ ok: true });
27
+ if (url.pathname === "/status") return Response.json(await this.host.status());
28
+ return new Response("not found", { status: 404 });
29
+ },
30
+ });
31
+ const shutdown = (): void => {
32
+ this.host.stop();
33
+ this.server?.stop(true);
34
+ process.exit(0);
35
+ };
36
+ process.on("SIGTERM", shutdown);
37
+ process.on("SIGINT", shutdown);
38
+ }
39
+ }
package/src/host.ts ADDED
@@ -0,0 +1,182 @@
1
+ import type { AdapterContext, ChannelAdapter, OutboundMessage, ResolvedChannel } from "./adapter";
2
+ import { channelAgentId } from "./adapter";
3
+ import type { HostConfig } from "./config";
4
+ import { RelayClient } from "./relay";
5
+
6
+ /**
7
+ * The channels-host runtime. Owns all relay plumbing so adapters stay trivial:
8
+ * for each bundled adapter it resolves the channels to register, POSTs each as a
9
+ * `provider:account` channel agent, subscribes the SSE bus for it, and routes every
10
+ * outbound message into the adapter's push(). With no adapters it registers nothing
11
+ * and idles — the zero-adapter case the scaffold ships with (#778).
12
+ */
13
+
14
+ export interface RegisteredChannel {
15
+ provider: string;
16
+ agentId: string;
17
+ direction: string;
18
+ connected: boolean;
19
+ pushOk: number;
20
+ pushErr: number;
21
+ lastError?: string;
22
+ }
23
+
24
+ export interface HostStatus {
25
+ adapters: number;
26
+ channels: number;
27
+ relayConnected: boolean;
28
+ registered: RegisteredChannel[];
29
+ }
30
+
31
+ // Cap on remembered delivered message ids per channel (#793). Notifications are low-volume;
32
+ // this bounds memory while comfortably covering any realistic reconnect-replay window.
33
+ const MAX_DELIVERED_IDS = 2_000;
34
+
35
+ interface ChannelEntry extends RegisteredChannel {
36
+ adapter: ChannelAdapter;
37
+ resolved: ResolvedChannel;
38
+ stop: () => void;
39
+ // #793 — message ids already pushed for this channel. The relay re-flushes queued
40
+ // messages on reconnect/re-registration (flush-on-availability), so without a cursor an
41
+ // already-delivered notification would be pushed again. Insertion-ordered for cheap
42
+ // oldest-eviction once the cap is hit.
43
+ delivered: Set<number>;
44
+ }
45
+
46
+ export class ChannelsHost {
47
+ private readonly relay: RelayClient;
48
+ private readonly ctx: AdapterContext;
49
+ private readonly channels: ChannelEntry[] = [];
50
+ private started = false;
51
+
52
+ constructor(
53
+ private readonly cfg: HostConfig,
54
+ private readonly adapters: ChannelAdapter[],
55
+ ) {
56
+ this.relay = new RelayClient(cfg.relayUrl, cfg.relayToken);
57
+ this.ctx = {
58
+ relayUrl: cfg.relayUrl,
59
+ get: cfg.get,
60
+ log: (message: string) => this.log(message),
61
+ };
62
+ }
63
+
64
+ private log(message: string): void {
65
+ process.stderr.write(`[${this.cfg.connectorId}] ${message}\n`);
66
+ }
67
+
68
+ /** Register every adapter's channels and start routing outbound traffic. */
69
+ async start(): Promise<void> {
70
+ if (this.started) return;
71
+ this.started = true;
72
+ for (const adapter of this.adapters) {
73
+ let specs;
74
+ try {
75
+ specs = await adapter.channels(this.ctx);
76
+ } catch (e) {
77
+ this.log(`adapter ${adapter.provider} channels() failed: ${(e as Error).message}`);
78
+ continue;
79
+ }
80
+ for (const spec of specs) {
81
+ const resolved: ResolvedChannel = {
82
+ ...spec,
83
+ provider: adapter.provider,
84
+ agentId: channelAgentId(adapter.provider, spec.account),
85
+ };
86
+ await this.registerAndSubscribe(adapter, resolved);
87
+ }
88
+ }
89
+ this.log(`started: ${this.adapters.length} adapter(s), ${this.channels.length} channel(s)`);
90
+ }
91
+
92
+ private async registerAndSubscribe(adapter: ChannelAdapter, resolved: ResolvedChannel): Promise<void> {
93
+ try {
94
+ await this.relay.registerChannel(resolved);
95
+ } catch (e) {
96
+ this.log(`register ${resolved.agentId} failed: ${(e as Error).message}`);
97
+ return;
98
+ }
99
+ const entry: ChannelEntry = {
100
+ provider: adapter.provider,
101
+ agentId: resolved.agentId,
102
+ direction: resolved.direction,
103
+ connected: false,
104
+ pushOk: 0,
105
+ pushErr: 0,
106
+ adapter,
107
+ resolved,
108
+ stop: () => {},
109
+ delivered: new Set<number>(),
110
+ };
111
+ entry.stop = this.relay.subscribeOutbound(
112
+ resolved.agentId,
113
+ (msg) => void this.dispatch(entry, msg),
114
+ (connected) => {
115
+ entry.connected = connected;
116
+ },
117
+ );
118
+ this.channels.push(entry);
119
+ this.log(`registered ${resolved.agentId} (${resolved.direction})`);
120
+ }
121
+
122
+ private async dispatch(entry: ChannelEntry, msg: OutboundMessage): Promise<void> {
123
+ // #793 — drop messages already delivered for this channel. On reconnect/re-registration
124
+ // the relay re-flushes queued messages addressed to the channel agent, replaying ones we
125
+ // already pushed; without this guard each replay would fire a duplicate notification.
126
+ // Reserve the id BEFORE pushing so a concurrent live+replay of the same id can't both
127
+ // push; release it on failure so a genuinely failed push is retried (at-least-once).
128
+ const id = typeof msg.id === "number" ? msg.id : null;
129
+ if (id !== null) {
130
+ if (entry.delivered.has(id)) return;
131
+ entry.delivered.add(id);
132
+ }
133
+ try {
134
+ await entry.adapter.push(msg, entry.resolved, this.ctx);
135
+ entry.pushOk += 1;
136
+ entry.lastError = undefined;
137
+ if (id !== null) {
138
+ this.capDelivered(entry);
139
+ // Ack delivered so the relay marks the message delivered and won't re-flush it.
140
+ void this.relay.ackDelivered(id, entry.agentId).catch(() => {});
141
+ }
142
+ } catch (e) {
143
+ if (id !== null) entry.delivered.delete(id);
144
+ entry.pushErr += 1;
145
+ entry.lastError = (e as Error).message;
146
+ this.log(`push to ${entry.agentId} failed: ${entry.lastError}`);
147
+ }
148
+ }
149
+
150
+ // Bound the per-channel delivered-id set, evicting oldest-first (insertion order).
151
+ private capDelivered(entry: ChannelEntry): void {
152
+ while (entry.delivered.size > MAX_DELIVERED_IDS) {
153
+ const oldest = entry.delivered.values().next().value;
154
+ if (oldest === undefined) break;
155
+ entry.delivered.delete(oldest);
156
+ }
157
+ }
158
+
159
+ /** Snapshot for the daemon's /status endpoint and the `status` lifecycle action. */
160
+ async status(): Promise<HostStatus> {
161
+ return {
162
+ adapters: this.adapters.length,
163
+ channels: this.channels.length,
164
+ relayConnected: await this.relay.reachable(),
165
+ registered: this.channels.map((c) => ({
166
+ provider: c.provider,
167
+ agentId: c.agentId,
168
+ direction: c.direction,
169
+ connected: c.connected,
170
+ pushOk: c.pushOk,
171
+ pushErr: c.pushErr,
172
+ lastError: c.lastError,
173
+ })),
174
+ };
175
+ }
176
+
177
+ stop(): void {
178
+ for (const channel of this.channels) channel.stop();
179
+ this.channels.length = 0;
180
+ this.started = false;
181
+ }
182
+ }
package/src/index.ts ADDED
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { adapters } from "./adapters";
5
+ import { loadConfig, type HostConfig } from "./config";
6
+ import { ChannelsHostDaemon } from "./daemon";
7
+ import { buildManifest } from "./manifest";
8
+ import { RelayClient } from "./relay";
9
+
10
+ /**
11
+ * CLI entry + connector lifecycle. The relay spawns these actions with a 30s timeout
12
+ * (src/connectors.ts), so `start` must detach the daemon and return immediately; the
13
+ * daemon itself runs under the internal `daemon` action.
14
+ */
15
+
16
+ const SELF = join(import.meta.dir, "index.ts");
17
+ const VERSION = "0.1.0";
18
+
19
+ function pidFile(cfg: HostConfig): string {
20
+ return join(cfg.runtimeDir, "channels-host.pid");
21
+ }
22
+
23
+ function readPid(cfg: HostConfig): number | null {
24
+ const f = pidFile(cfg);
25
+ if (!existsSync(f)) return null;
26
+ const pid = Number(readFileSync(f, "utf8").trim());
27
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
28
+ }
29
+
30
+ function isAlive(pid: number): boolean {
31
+ try {
32
+ process.kill(pid, 0);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function out(obj: unknown): void {
40
+ process.stdout.write(JSON.stringify(obj) + "\n");
41
+ }
42
+
43
+ function statusBase(cfg: HostConfig): string {
44
+ return `http://127.0.0.1:${cfg.port}`;
45
+ }
46
+
47
+ async function actionStart(cfg: HostConfig): Promise<void> {
48
+ mkdirSync(cfg.runtimeDir, { recursive: true });
49
+ const existing = readPid(cfg);
50
+ if (existing && isAlive(existing)) {
51
+ out({ status: "ok", running: true, enabled: true, endpoint: statusBase(cfg), detail: `already running (pid ${existing})` });
52
+ return;
53
+ }
54
+ const logFd = openSync(join(cfg.runtimeDir, "channels-host.log"), "a");
55
+ const child = Bun.spawn({
56
+ cmd: ["bun", SELF, "daemon"],
57
+ env: { ...process.env },
58
+ stdin: "ignore",
59
+ stdout: logFd,
60
+ stderr: logFd,
61
+ });
62
+ child.unref();
63
+ writeFileSync(pidFile(cfg), String(child.pid), { mode: 0o600 });
64
+
65
+ // Give the daemon a moment to bind its status port before reporting.
66
+ let up = false;
67
+ for (let i = 0; i < 25; i++) {
68
+ try {
69
+ const res = await fetch(`${statusBase(cfg)}/health`, { signal: AbortSignal.timeout(500) });
70
+ if (res.ok) {
71
+ up = true;
72
+ break;
73
+ }
74
+ } catch {
75
+ /* not up yet */
76
+ }
77
+ await new Promise((r) => setTimeout(r, 200));
78
+ }
79
+ out({
80
+ status: up ? "ok" : "warn",
81
+ running: up,
82
+ enabled: true,
83
+ endpoint: statusBase(cfg),
84
+ detail: up ? `channels host listening on :${cfg.port}` : "daemon spawned but health check timed out",
85
+ });
86
+ }
87
+
88
+ function actionStop(cfg: HostConfig): void {
89
+ const pid = readPid(cfg);
90
+ if (pid && isAlive(pid)) {
91
+ try {
92
+ process.kill(pid);
93
+ } catch {
94
+ /* already gone */
95
+ }
96
+ }
97
+ try {
98
+ rmSync(pidFile(cfg));
99
+ } catch {
100
+ /* none */
101
+ }
102
+ out({ status: "ok", running: false, enabled: true, detail: "stopped" });
103
+ }
104
+
105
+ async function actionStatus(cfg: HostConfig): Promise<void> {
106
+ const pid = readPid(cfg);
107
+ const running = Boolean(pid && isAlive(pid));
108
+ let detail = running ? `running (pid ${pid})` : "not running";
109
+ if (running) {
110
+ try {
111
+ const res = await fetch(`${statusBase(cfg)}/status`, { signal: AbortSignal.timeout(1500) });
112
+ if (res.ok) {
113
+ const s = (await res.json()) as { adapters: number; channels: number; relayConnected: boolean };
114
+ detail = `adapters=${s.adapters} channels=${s.channels} relay=${s.relayConnected ? "up" : "down"}`;
115
+ }
116
+ } catch {
117
+ /* daemon not answering; keep pid detail */
118
+ }
119
+ }
120
+ out({ status: running ? "ok" : "warn", running, enabled: true, endpoint: statusBase(cfg), detail });
121
+ }
122
+
123
+ async function actionDoctor(cfg: HostConfig): Promise<void> {
124
+ const checks: Array<{ name: string; status: "ok" | "warn" | "error"; detail: string }> = [];
125
+
126
+ const relayUp = await new RelayClient(cfg.relayUrl, cfg.relayToken).reachable();
127
+ checks.push({ name: "relay", status: relayUp ? "ok" : "warn", detail: relayUp ? cfg.relayUrl : `unreachable at ${cfg.relayUrl}` });
128
+
129
+ checks.push({
130
+ name: "adapters",
131
+ status: "ok",
132
+ detail: adapters.length ? adapters.map((a) => a.provider).join(", ") : "no adapters bundled (scaffold)",
133
+ });
134
+
135
+ const pid = readPid(cfg);
136
+ const running = Boolean(pid && isAlive(pid));
137
+ checks.push({ name: "daemon", status: running ? "ok" : "warn", detail: running ? `running (pid ${pid})` : "not running" });
138
+
139
+ out({ checks, summary: `relay ${relayUp ? "reachable" : "unreachable"}, ${adapters.length} adapter(s)` });
140
+ }
141
+
142
+ async function actionRegister(cfg: HostConfig): Promise<void> {
143
+ const manifest = buildManifest({ connectorId: cfg.connectorId, binary: SELF, version: VERSION, adapters });
144
+ const headers: Record<string, string> = { "content-type": "application/json" };
145
+ if (cfg.relayToken) headers.authorization = `Bearer ${cfg.relayToken}`;
146
+ const res = await fetch(`${cfg.relayUrl}/api/connectors`, { method: "POST", headers, body: JSON.stringify({ manifest }) });
147
+ if (!res.ok) {
148
+ out({ status: "error", detail: `register failed: ${res.status} ${await res.text().catch(() => "")}` });
149
+ process.exit(1);
150
+ }
151
+ out({ status: "ok", detail: `registered connector '${cfg.connectorId}' with ${cfg.relayUrl}` });
152
+ }
153
+
154
+ async function main(): Promise<void> {
155
+ const action = (process.argv[2] || "").toLowerCase();
156
+ const cfg = loadConfig();
157
+ switch (action) {
158
+ case "daemon":
159
+ await new ChannelsHostDaemon(cfg).start();
160
+ return; // long-running, never returns
161
+ case "start":
162
+ await actionStart(cfg);
163
+ return;
164
+ case "stop":
165
+ actionStop(cfg);
166
+ return;
167
+ case "restart":
168
+ actionStop(cfg);
169
+ await new Promise((r) => setTimeout(r, 300));
170
+ await actionStart(cfg);
171
+ return;
172
+ case "status":
173
+ await actionStatus(cfg);
174
+ return;
175
+ case "doctor":
176
+ await actionDoctor(cfg);
177
+ return;
178
+ case "register":
179
+ await actionRegister(cfg);
180
+ return;
181
+ default:
182
+ process.stderr.write("usage: agent-relay-channels-host <start|stop|restart|status|doctor|register|daemon>\n");
183
+ process.exit(2);
184
+ }
185
+ }
186
+
187
+ void main();
@@ -0,0 +1,97 @@
1
+ import type { ConnectorManifest } from "agent-relay-sdk";
2
+ import type { ChannelAdapter } from "./adapter";
3
+ import { DEFAULT_PORT } from "./config";
4
+
5
+ /**
6
+ * Build the `agent-relay.connector.v1` manifest for the channels host.
7
+ *
8
+ * The configSchema is aggregated: a host-level base (relay URL/token, status port)
9
+ * merged with every bundled adapter's contributed `properties`/`required`. Adapter
10
+ * keys are expected to be provider-namespaced (e.g. NTFY_TOPIC); on a duplicate key
11
+ * the first adapter wins and a warning is collected (surfaced by doctor).
12
+ */
13
+
14
+ /** Host-level settings every install needs, independent of which adapters are bundled. */
15
+ function baseConfigSchema(): { properties: Record<string, unknown>; required: string[] } {
16
+ return {
17
+ properties: {
18
+ AGENT_RELAY_URL: {
19
+ type: "string",
20
+ description: "Relay HTTP base the host registers channels against.",
21
+ default: "http://127.0.0.1:4850",
22
+ },
23
+ AGENT_RELAY_TOKEN: {
24
+ type: "string",
25
+ description: "Integration/component token used to register channel agents (agent:write scope). Empty on a tokenless localhost relay.",
26
+ },
27
+ CHANNELS_HOST_PORT: {
28
+ type: "number",
29
+ description: "Port the host daemon's status HTTP server listens on.",
30
+ default: DEFAULT_PORT,
31
+ },
32
+ },
33
+ required: [],
34
+ };
35
+ }
36
+
37
+ export interface AggregatedConfigSchema {
38
+ type: "object";
39
+ properties: Record<string, unknown>;
40
+ required: string[];
41
+ /** Keys an adapter tried to contribute that collided with an existing one (first-wins). */
42
+ collisions: string[];
43
+ }
44
+
45
+ export function aggregateConfigSchema(adapters: ChannelAdapter[]): AggregatedConfigSchema {
46
+ const base = baseConfigSchema();
47
+ const properties: Record<string, unknown> = { ...base.properties };
48
+ const required = new Set<string>(base.required);
49
+ const collisions: string[] = [];
50
+
51
+ for (const adapter of adapters) {
52
+ const schema = adapter.configSchema;
53
+ if (!schema) continue;
54
+ for (const [key, value] of Object.entries(schema.properties ?? {})) {
55
+ if (key in properties) {
56
+ collisions.push(key);
57
+ continue;
58
+ }
59
+ properties[key] = value;
60
+ }
61
+ for (const key of schema.required ?? []) required.add(key);
62
+ }
63
+
64
+ return { type: "object", properties, required: [...required], collisions };
65
+ }
66
+
67
+ export function buildManifest(opts: {
68
+ connectorId: string;
69
+ binary: string;
70
+ version: string;
71
+ adapters: ChannelAdapter[];
72
+ }): ConnectorManifest {
73
+ const { properties, required } = aggregateConfigSchema(opts.adapters);
74
+ const providers = opts.adapters.map((a) => a.provider).sort();
75
+ const description =
76
+ providers.length > 0
77
+ ? `Channels host bundling lightweight egress adapters: ${providers.join(", ")}.`
78
+ : "Channels host for lightweight egress adapters (no adapters bundled yet).";
79
+ return {
80
+ schema: "agent-relay.connector.v1",
81
+ id: opts.connectorId,
82
+ kind: "channel",
83
+ displayName: "Channels Host",
84
+ description,
85
+ version: opts.version,
86
+ binary: opts.binary,
87
+ capabilities: ["channel", "channels-host", ...providers],
88
+ commands: {
89
+ start: ["bun", opts.binary, "start"],
90
+ stop: ["bun", opts.binary, "stop"],
91
+ restart: ["bun", opts.binary, "restart"],
92
+ status: ["bun", opts.binary, "status"],
93
+ doctor: ["bun", opts.binary, "doctor"],
94
+ },
95
+ configSchema: { type: "object", properties, ...(required.length ? { required } : {}) },
96
+ };
97
+ }
package/src/relay.ts ADDED
@@ -0,0 +1,151 @@
1
+ import { ReconnectionManager, RelayHttpClient } from "agent-relay-sdk";
2
+ import { parseSseFrame } from "agent-relay-sdk/sse";
3
+ import type { OutboundMessage, ResolvedChannel } from "./adapter";
4
+
5
+ /**
6
+ * Minimal relay client for the channels host.
7
+ *
8
+ * registerChannel(): POST /api/agents with the integration token. The channel is a
9
+ * `provider:account` agent of kind:"channel" carrying meta.direction;
10
+ * core derives the channel row + directionality from it
11
+ * (channelDirectionForAgent, src/db/channels.ts).
12
+ * subscribeOutbound(): GET /api/events?for=<channelAgentId>. The relay only fans out
13
+ * `message.new` frames addressed to that agent (createSSEStream +
14
+ * messageMatchesAgent), so each subscription receives exactly the
15
+ * outbound traffic for one channel. Auto-reconnects on drop.
16
+ */
17
+ export class RelayClient {
18
+ private readonly http: RelayHttpClient;
19
+
20
+ constructor(
21
+ private readonly relayUrl: string,
22
+ relayToken: string | null,
23
+ ) {
24
+ // Auth goes in the relay token header (RELAY_TOKEN_HEADER via RelayHttpClient),
25
+ // NOT `Authorization: Bearer` — the relay only honours the former.
26
+ this.http = new RelayHttpClient({ baseUrl: relayUrl, token: relayToken ?? undefined });
27
+ }
28
+
29
+ /** True when the relay answers /api/health. */
30
+ async reachable(): Promise<boolean> {
31
+ try {
32
+ const res = await this.http.requestRaw("GET", "/api/health", undefined, {}, { timeoutMs: 2500 });
33
+ return res.ok;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ /** Register one channel as a `provider:account` channel agent. Idempotent (upsert). */
40
+ async registerChannel(channel: ResolvedChannel): Promise<void> {
41
+ const name = channel.displayName ?? channel.provider;
42
+ await this.http.registerAgent({
43
+ id: channel.agentId,
44
+ name,
45
+ kind: "channel",
46
+ status: "online",
47
+ ready: true,
48
+ tags: dedupe(["channel", channel.provider, `channel:${channel.provider}`, ...(channel.tags ?? [])]),
49
+ capabilities: dedupe(["channel", channel.provider, ...(channel.capabilities ?? [])]),
50
+ meta: {
51
+ ...(channel.meta ?? {}),
52
+ kind: "channel",
53
+ provider: channel.provider,
54
+ accountId: channel.account,
55
+ channelType: channel.provider,
56
+ transport: channel.provider,
57
+ direction: channel.direction,
58
+ displayName: name,
59
+ },
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Ack a delivered outbound message (#793). Marks the relay-side message `delivered` so the
65
+ * flush-on-availability path (resolveQueuedDirectMessages, which only re-emits `queued`
66
+ * messages) won't re-push it on the next reconnect/re-registration — and so the relay's
67
+ * delivery health doesn't count a pushed notification as silently lost. The cursor: once
68
+ * acked, the message is no longer replayable to this channel.
69
+ */
70
+ async ackDelivered(messageId: number, channelAgentId: string): Promise<void> {
71
+ await this.http.recordMessageDeliveryAttempt(messageId, { agentId: channelAgentId, status: "delivered" });
72
+ }
73
+
74
+ /**
75
+ * Subscribe to outbound messages for one channel agent. Calls `onMessage` for every
76
+ * `message.new` frame and auto-reconnects on drop. Returns a stop() function.
77
+ */
78
+ subscribeOutbound(
79
+ channelAgentId: string,
80
+ onMessage: (msg: OutboundMessage) => void,
81
+ onState?: (connected: boolean) => void,
82
+ ): () => void {
83
+ let stopped = false;
84
+ let controller: AbortController | null = null;
85
+ const reconnect = new ReconnectionManager();
86
+ const path = `/api/events?for=${encodeURIComponent(channelAgentId)}`;
87
+
88
+ const run = async (): Promise<void> => {
89
+ while (!stopped) {
90
+ controller = new AbortController();
91
+ try {
92
+ const res = await this.http.requestRaw(
93
+ "GET",
94
+ path,
95
+ undefined,
96
+ { accept: "text/event-stream" },
97
+ { signal: controller.signal, timeoutMs: null },
98
+ );
99
+ if (!res.ok || !res.body) throw new Error(`GET ${path} -> ${res.status}`);
100
+ onState?.(true);
101
+ reconnect.reset();
102
+ await this.consumeSse(res.body, onMessage);
103
+ } catch {
104
+ /* fall through to reconnect */
105
+ }
106
+ onState?.(false);
107
+ if (stopped) break;
108
+ await reconnect.schedule();
109
+ }
110
+ };
111
+ void run();
112
+
113
+ return () => {
114
+ stopped = true;
115
+ reconnect.cancel();
116
+ controller?.abort();
117
+ };
118
+ }
119
+
120
+ private async consumeSse(body: ReadableStream<Uint8Array>, onMessage: (msg: OutboundMessage) => void): Promise<void> {
121
+ const reader = body.getReader();
122
+ const decoder = new TextDecoder();
123
+ let buffer = "";
124
+ for (;;) {
125
+ const { value, done } = await reader.read();
126
+ if (done) break;
127
+ buffer += decoder.decode(value, { stream: true });
128
+ let sep: number;
129
+ // SSE events are separated by a blank line.
130
+ while ((sep = buffer.indexOf("\n\n")) !== -1) {
131
+ const chunk = buffer.slice(0, sep);
132
+ buffer = buffer.slice(sep + 2);
133
+ this.dispatchSse(chunk, onMessage);
134
+ }
135
+ }
136
+ }
137
+
138
+ private dispatchSse(chunk: string, onMessage: (msg: OutboundMessage) => void): void {
139
+ const { event, data } = parseSseFrame(chunk);
140
+ if (event !== "message.new" || data.length === 0) return;
141
+ try {
142
+ onMessage(JSON.parse(data.join("\n")) as OutboundMessage);
143
+ } catch {
144
+ /* ignore malformed frame */
145
+ }
146
+ }
147
+ }
148
+
149
+ function dedupe(values: string[]): string[] {
150
+ return [...new Set(values.map((v) => v.trim()).filter(Boolean))];
151
+ }