@adaptic/maestro 1.10.8 → 1.11.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adaptic/maestro",
3
- "version": "1.10.8",
3
+ "version": "1.11.0",
4
4
  "description": "Maestro — Autonomous AI agent operating system. Deploy AI employees on dedicated Mac minis.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,95 @@
1
+ #!/bin/bash
2
+ # launchd-socket-mode-wrapper.sh — Bootstraps env for slack-cloud-relay-client.mjs
3
+ # under launchd.
4
+ #
5
+ # Mirrors launchd-wrapper.sh exactly: launchd's bare env doesn't include
6
+ # HOME, PATH, or AGENT_ROOT, so we hydrate them before exec'ing the
7
+ # Socket Mode listener. Logs land on the external SSD when available
8
+ # (same fallback semantics as the main daemon wrapper).
9
+ #
10
+ # This wrapper is exec'd by ai.adaptic.{firstname}-slack-cloud-relay.plist.
11
+
12
+ set -e
13
+
14
+ AGENT_ROOT="$(cd "$(dirname "$0")/../.." && pwd -P)"
15
+ export AGENT_ROOT
16
+ export HOME="${HOME:-/Users/$(whoami)}"
17
+ export USER="${USER:-$(whoami)}"
18
+ export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
19
+
20
+ # ── SSD redirect ────────────────────────────────────────────────────────────
21
+ # If an external SSD is mounted at /Volumes/{name}, redirect:
22
+ # - Claude Code per-cwd temp (CLAUDE_CODE_TMPDIR)
23
+ # - Listener stdout/stderr (via shell redirection at exec time)
24
+ #
25
+ # Detection mirrors launchd-wrapper.sh — first volume under /Volumes that's
26
+ # not a system mount; MAESTRO_SSD_VOLUME env var overrides if multiple SSDs.
27
+
28
+ SSD_VOLUME="${MAESTRO_SSD_VOLUME:-}"
29
+ if [ -z "$SSD_VOLUME" ]; then
30
+ for v in /Volumes/*-SSD /Volumes/*SSD* /Volumes/maestro-data; do
31
+ if [ -d "$v" ] && [ "$v" != "/Volumes/Macintosh HD" ]; then
32
+ SSD_VOLUME="$v"
33
+ break
34
+ fi
35
+ done
36
+ fi
37
+
38
+ AGENT_NAME="$(basename "$AGENT_ROOT" | sed 's/-ai$//')"
39
+ SSD_AGENT_ROOT=""
40
+ SSD_WRITABLE=0
41
+ if [ -n "$SSD_VOLUME" ] && [ -d "$SSD_VOLUME" ]; then
42
+ SSD_AGENT_ROOT="$SSD_VOLUME/maestro/$AGENT_NAME"
43
+ if mkdir -p "$SSD_AGENT_ROOT/claude-tmp" "$SSD_AGENT_ROOT/logs/slack-cloud-relay" 2>/dev/null && \
44
+ touch "$SSD_AGENT_ROOT/.write-test-$$" 2>/dev/null; then
45
+ rm -f "$SSD_AGENT_ROOT/.write-test-$$"
46
+ SSD_WRITABLE=1
47
+ export CLAUDE_CODE_TMPDIR="$SSD_AGENT_ROOT/claude-tmp"
48
+ fi
49
+ fi
50
+
51
+ cd "$AGENT_ROOT"
52
+
53
+ # Resolve node binary — prefer nvm, fall back to homebrew, then system.
54
+ NODE_BIN=""
55
+ for candidate in \
56
+ "$HOME/.nvm/versions/node/v24.11.1/bin/node" \
57
+ "$HOME/.nvm/versions/node/v24/bin/node" \
58
+ "$HOME/.nvm/versions/node/v22/bin/node" \
59
+ "$HOME/.nvm/versions/node/v20/bin/node" \
60
+ /opt/homebrew/bin/node \
61
+ /usr/local/bin/node \
62
+ /usr/bin/node; do
63
+ if [ -x "$candidate" ]; then
64
+ NODE_BIN="$candidate"
65
+ break
66
+ fi
67
+ done
68
+ if [ -z "$NODE_BIN" ] && [ -d "$HOME/.nvm/versions/node" ]; then
69
+ NODE_BIN=$(ls -1d "$HOME/.nvm/versions/node"/v*/bin/node 2>/dev/null | sort -V | tail -1)
70
+ fi
71
+ if [ -z "$NODE_BIN" ] || [ ! -x "$NODE_BIN" ]; then
72
+ echo "[slack-cloud-relay-wrapper] FATAL: could not find node binary" >&2
73
+ exit 127
74
+ fi
75
+
76
+ # Node 22.4+ is required for the global WebSocket. Warn (don't fail) on
77
+ # older versions — the user might have polyfilled via `--experimental-websocket`
78
+ # or installed the `ws` package as a fallback.
79
+ NODE_VERSION="$("$NODE_BIN" --version 2>/dev/null || echo 'v0.0.0')"
80
+ NODE_MAJOR="$(echo "$NODE_VERSION" | sed -E 's/^v([0-9]+).*/\1/')"
81
+ if [ "$NODE_MAJOR" -lt 22 ] 2>/dev/null; then
82
+ echo "[slack-cloud-relay-wrapper] WARNING: Node $NODE_VERSION is older than v22 — global WebSocket may be missing." >&2
83
+ fi
84
+
85
+ # Exec the listener. Prefer SSD log path if writable, otherwise fall back
86
+ # to internal disk so the listener stays up even when macOS denies launchd
87
+ # write access to /Volumes/{name}.
88
+ if [ "$SSD_WRITABLE" = "1" ]; then
89
+ LISTENER_LOG="$SSD_AGENT_ROOT/logs/slack-cloud-relay/listener-$(date +%Y-%m-%d).log"
90
+ exec "$NODE_BIN" "$AGENT_ROOT/scripts/poller/slack-cloud-relay-client.mjs" >> "$LISTENER_LOG" 2>&1
91
+ else
92
+ LISTENER_LOG="$AGENT_ROOT/logs/polling/slack-cloud-relay-$(date +%Y-%m-%d).log"
93
+ mkdir -p "$(dirname "$LISTENER_LOG")" 2>/dev/null || true
94
+ exec "$NODE_BIN" "$AGENT_ROOT/scripts/poller/slack-cloud-relay-client.mjs" >> "$LISTENER_LOG" 2>&1
95
+ fi
@@ -0,0 +1,59 @@
1
+ # Cloud Relay — Slack Events to Mac mini
2
+
3
+ Public HTTPS service that proxies Slack Events API webhooks into a
4
+ Mac-mini-resident maestro daemon over Server-Sent Events.
5
+
6
+ ## Why
7
+
8
+ Slack Socket Mode only delivers events for the **bot user** that owns the
9
+ app. DMs sent to the agent's underlying **user account** (e.g.,
10
+ `U099N1JE0LA` for Ravi) are invisible to Socket Mode — the bot is not a
11
+ member of those conversations. To get real-time push for those DMs we
12
+ need user-scope event subscriptions, which require an HTTPS endpoint, and
13
+ Mac minis don't have public IPs. This relay closes the gap.
14
+
15
+ ## Architecture
16
+
17
+ ```
18
+ Slack ──HTTPS POST──▶ Railway (this relay) ──SSE──▶ Mac mini (slack-cloud-relay-client.mjs)
19
+ │ │
20
+ │ └──▶ writeInboxItem() → state/inbox/slack/
21
+
22
+ └── verifies x-slack-signature, fans events to all SSE clients
23
+ ```
24
+
25
+ ## Deploy
26
+
27
+ ```
28
+ cd scripts/cloud-relay
29
+ railway init
30
+ railway up
31
+ railway variables set \
32
+ SLACK_SIGNING_SECRET=<from Slack app Basic Information> \
33
+ RELAY_SHARED_SECRET=<long random string, share with Mac mini>
34
+ railway domain # → returns https://<project>.up.railway.app
35
+ ```
36
+
37
+ Then in the Slack app config:
38
+ 1. Event Subscriptions → Enable Events → set Request URL to
39
+ `https://<project>.up.railway.app/slack/events`.
40
+ 2. Subscribe to **User events** (`message.im`, `message.mpim`,
41
+ `message.channels`, `message.groups`, `app_mention`).
42
+ 3. Save changes and reinstall the app.
43
+
44
+ Set the Mac mini's `.env`:
45
+ ```
46
+ CLOUD_RELAY_URL=https://<project>.up.railway.app
47
+ RELAY_SHARED_SECRET=<same string as on Railway>
48
+ ```
49
+
50
+ ## Endpoints
51
+
52
+ - `POST /slack/events` — Slack Events API. Verifies `x-slack-signature`
53
+ using `SLACK_SIGNING_SECRET`; responds to `url_verification` challenges;
54
+ fans event callbacks out to connected SSE clients.
55
+ - `GET /events/stream` — SSE stream. Requires
56
+ `Authorization: Bearer <RELAY_SHARED_SECRET>`. New connections receive
57
+ the last 100 events as catch-up; heartbeats every 25 s keep the stream
58
+ alive through proxies.
59
+ - `GET /health` — Liveness probe (returns connection + queue counts).
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Maestro — Slack Events Cloud Relay
3
+ *
4
+ * Public HTTPS service (deployed on Railway) that bridges Slack user-scope
5
+ * Events API webhooks into a Mac-mini-resident maestro daemon over SSE.
6
+ *
7
+ * Why this exists: Socket Mode is bot-scoped, so DMs sent to the agent's
8
+ * USER account (not its bot user) are invisible to a Socket Mode listener.
9
+ * To get real-time push for those DMs, the Slack app needs user-scope event
10
+ * subscriptions, which require an HTTPS webhook URL — and Mac minis don't
11
+ * have public IPs. This relay sits in front: Slack POSTs events here, the
12
+ * Mac mini holds an authenticated SSE connection, and events get pushed
13
+ * back over the open stream in milliseconds.
14
+ *
15
+ * Endpoints:
16
+ * POST /slack/events — Slack Events API receiver. Verifies the v0
17
+ * HMAC signature, handles url_verification on
18
+ * registration, fans event_callback envelopes
19
+ * out to every connected SSE client.
20
+ * GET /events/stream — Server-Sent Events feed for Mac-mini clients
21
+ * holding an open connection. Bearer-token
22
+ * authenticated against RELAY_SHARED_SECRET.
23
+ * New connections receive the last ~100 events
24
+ * so a brief disconnect doesn't drop messages.
25
+ * GET /health — Liveness probe used by Railway / external
26
+ * uptime monitors.
27
+ *
28
+ * Env (all required):
29
+ * PORT — provided by Railway
30
+ * SLACK_SIGNING_SECRET — from Slack app "Basic Information" page
31
+ * RELAY_SHARED_SECRET — operator-chosen long random string; the Mac
32
+ * mini client must send the same value in its
33
+ * Authorization: Bearer <...> header
34
+ */
35
+
36
+ import http from "node:http";
37
+ import crypto from "node:crypto";
38
+
39
+ const PORT = parseInt(process.env.PORT || "3100", 10);
40
+ const SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET || "";
41
+ const RELAY_SHARED_SECRET = process.env.RELAY_SHARED_SECRET || "";
42
+
43
+ if (!SIGNING_SECRET) {
44
+ console.error("FATAL: SLACK_SIGNING_SECRET env var is required");
45
+ process.exit(78);
46
+ }
47
+ if (!RELAY_SHARED_SECRET) {
48
+ console.error("FATAL: RELAY_SHARED_SECRET env var is required");
49
+ process.exit(78);
50
+ }
51
+
52
+ // Connected Mac-mini SSE clients (typically 1, but designed for fan-out).
53
+ const sseClients = new Set();
54
+
55
+ // Bounded ring buffer of recent events for reconnect replay. Slack-side
56
+ // "retry on 5xx" already gives us at-least-once delivery, so this is just
57
+ // for the case where the Mac mini's SSE connection drops between an event
58
+ // arriving and being acked: when it reconnects within ~5 minutes it gets
59
+ // the missing events replayed instead of waiting for Slack's next retry.
60
+ const REPLAY_CAPACITY = 100;
61
+ const eventQueue = [];
62
+
63
+ function log(level, message, data) {
64
+ const ts = new Date().toISOString();
65
+ const line = JSON.stringify({ ts, level, message, ...(data || {}) });
66
+ if (level === "error") console.error(line);
67
+ else console.log(line);
68
+ }
69
+
70
+ /**
71
+ * Verify Slack's v0 HMAC signature. Per Slack docs:
72
+ * sig_basestring = "v0:" + timestamp + ":" + raw_body
73
+ * expected = "v0=" + hex(hmac_sha256(SIGNING_SECRET, sig_basestring))
74
+ * Also enforce the 5-minute timestamp window to block replay attacks.
75
+ */
76
+ function verifySlackSignature(timestamp, body, signature) {
77
+ if (!timestamp || !signature) return false;
78
+ const tsNum = parseInt(timestamp, 10);
79
+ if (!Number.isFinite(tsNum)) return false;
80
+ const ageSec = Math.abs(Date.now() / 1000 - tsNum);
81
+ if (ageSec > 5 * 60) return false;
82
+ const basestring = `v0:${timestamp}:${body}`;
83
+ const expected = `v0=${crypto.createHmac("sha256", SIGNING_SECRET).update(basestring).digest("hex")}`;
84
+ if (expected.length !== signature.length) return false;
85
+ try {
86
+ return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ function fanOut(envelope) {
93
+ eventQueue.push(envelope);
94
+ if (eventQueue.length > REPLAY_CAPACITY) eventQueue.shift();
95
+ const data = `data: ${JSON.stringify(envelope)}\n\n`;
96
+ const dead = [];
97
+ for (const client of sseClients) {
98
+ try {
99
+ client.write(data);
100
+ } catch {
101
+ dead.push(client);
102
+ }
103
+ }
104
+ for (const c of dead) sseClients.delete(c);
105
+ log("info", "fanned-out", { sse_clients: sseClients.size, queue_size: eventQueue.length });
106
+ }
107
+
108
+ const server = http.createServer((req, res) => {
109
+ // Health probe
110
+ if (req.method === "GET" && req.url === "/health") {
111
+ res.writeHead(200, { "Content-Type": "application/json" });
112
+ res.end(JSON.stringify({
113
+ ok: true,
114
+ sse_clients: sseClients.size,
115
+ queue_size: eventQueue.length,
116
+ uptime_s: Math.round(process.uptime()),
117
+ }));
118
+ return;
119
+ }
120
+
121
+ // Slack Events API endpoint
122
+ if (req.method === "POST" && req.url === "/slack/events") {
123
+ let body = "";
124
+ req.on("data", (chunk) => { body += chunk; });
125
+ req.on("end", () => {
126
+ const ts = req.headers["x-slack-request-timestamp"];
127
+ const sig = req.headers["x-slack-signature"];
128
+
129
+ if (!verifySlackSignature(ts, body, sig)) {
130
+ log("warn", "rejected-bad-signature", { ts_header: !!ts });
131
+ res.writeHead(401, { "Content-Type": "text/plain" });
132
+ res.end("invalid signature");
133
+ return;
134
+ }
135
+
136
+ let payload;
137
+ try {
138
+ payload = JSON.parse(body);
139
+ } catch {
140
+ res.writeHead(400, { "Content-Type": "text/plain" });
141
+ res.end("bad json");
142
+ return;
143
+ }
144
+
145
+ // One-time URL verification when the operator first registers the
146
+ // webhook in the Slack app config. Slack expects an echo of the
147
+ // `challenge` field within 3 seconds.
148
+ if (payload.type === "url_verification") {
149
+ log("info", "url-verification-challenge", { challenge_len: (payload.challenge || "").length });
150
+ res.writeHead(200, { "Content-Type": "application/json" });
151
+ res.end(JSON.stringify({ challenge: payload.challenge }));
152
+ return;
153
+ }
154
+
155
+ // Event callback. Slack requires a 200 within 3 seconds or it
156
+ // retries, so ack immediately and fan out after.
157
+ res.writeHead(200, { "Content-Type": "text/plain" });
158
+ res.end("ok");
159
+ if (payload.type === "event_callback") {
160
+ fanOut(payload);
161
+ } else {
162
+ log("info", "ignored-envelope-type", { type: payload.type });
163
+ }
164
+ });
165
+ req.on("error", (err) => {
166
+ log("error", "request-error", { error: err.message });
167
+ });
168
+ return;
169
+ }
170
+
171
+ // SSE stream to Mac mini
172
+ if (req.method === "GET" && req.url === "/events/stream") {
173
+ const auth = req.headers.authorization || "";
174
+ const want = `Bearer ${RELAY_SHARED_SECRET}`;
175
+ if (auth.length !== want.length || !crypto.timingSafeEqual(Buffer.from(auth), Buffer.from(want))) {
176
+ res.writeHead(401, { "Content-Type": "text/plain" });
177
+ res.end("unauthorized");
178
+ return;
179
+ }
180
+
181
+ res.writeHead(200, {
182
+ "Content-Type": "text/event-stream",
183
+ "Cache-Control": "no-cache, no-transform",
184
+ "Connection": "keep-alive",
185
+ "X-Accel-Buffering": "no",
186
+ });
187
+ res.write(": connected\n\n");
188
+ sseClients.add(res);
189
+ log("info", "sse-client-connected", { sse_clients: sseClients.size, remote: req.socket.remoteAddress });
190
+
191
+ // Replay the recent backlog so a brief disconnect doesn't drop events.
192
+ for (const ev of eventQueue) {
193
+ try { res.write(`data: ${JSON.stringify(ev)}\n\n`); } catch { /* */ }
194
+ }
195
+
196
+ // Heartbeat — many proxies idle-timeout long-lived streams; a comment
197
+ // line every 25s keeps the connection visibly alive without polluting
198
+ // the event payloads.
199
+ const hb = setInterval(() => {
200
+ try {
201
+ res.write(": hb\n\n");
202
+ } catch {
203
+ clearInterval(hb);
204
+ }
205
+ }, 25_000);
206
+
207
+ req.on("close", () => {
208
+ sseClients.delete(res);
209
+ clearInterval(hb);
210
+ log("info", "sse-client-disconnected", { sse_clients: sseClients.size });
211
+ });
212
+ return;
213
+ }
214
+
215
+ res.writeHead(404, { "Content-Type": "text/plain" });
216
+ res.end("not found");
217
+ });
218
+
219
+ server.listen(PORT, () => {
220
+ log("info", "cloud-relay-listening", { port: PORT });
221
+ });
222
+
223
+ // Graceful shutdown on SIGTERM (Railway sends this on redeploy / scale-down).
224
+ const shutdown = (signal) => {
225
+ log("info", "shutdown-requested", { signal });
226
+ for (const client of sseClients) {
227
+ try { client.end(); } catch { /* */ }
228
+ }
229
+ server.close(() => process.exit(0));
230
+ setTimeout(() => process.exit(0), 5_000).unref();
231
+ };
232
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
233
+ process.on("SIGINT", () => shutdown("SIGINT"));
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@adaptic/maestro-cloud-relay",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Public HTTPS relay that proxies Slack Events API into a Mac-mini-resident maestro daemon over SSE.",
7
+ "main": "index.mjs",
8
+ "scripts": {
9
+ "start": "node index.mjs"
10
+ },
11
+ "engines": {
12
+ "node": ">=20"
13
+ },
14
+ "license": "UNLICENSED"
15
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://railway.com/railway.schema.json",
3
+ "build": {
4
+ "builder": "NIXPACKS"
5
+ },
6
+ "deploy": {
7
+ "startCommand": "node index.mjs",
8
+ "healthcheckPath": "/health",
9
+ "healthcheckTimeout": 100,
10
+ "restartPolicyType": "ON_FAILURE",
11
+ "restartPolicyMaxRetries": 10
12
+ }
13
+ }
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Maestro — Slack Cloud-Relay Client
3
+ *
4
+ * Persistent SSE consumer that connects to the Railway-hosted cloud-relay
5
+ * (scripts/cloud-relay/) and turns the Slack user-scope event stream into
6
+ * inbox YAML files via writeInboxItem. Drop-in replacement for the
7
+ * bot-scoped Socket Mode listener when the agent's primary message
8
+ * surface is the agent's USER account rather than a bot user.
9
+ *
10
+ * Lifecycle:
11
+ * 1. Honour .emergency-stop before any I/O.
12
+ * 2. Open an HTTPS SSE connection to CLOUD_RELAY_URL/events/stream with
13
+ * a Bearer header containing RELAY_SHARED_SECRET.
14
+ * 3. Parse `data:` lines as event_callback envelopes; reuse the
15
+ * shouldKeepEvent + eventToInboxItem helpers from slack-socket-mode
16
+ * so the inbox YAML shape is identical to what Socket Mode produces.
17
+ * 4. On disconnect, reconnect with exponential back-off + jitter.
18
+ *
19
+ * Env (read from .env):
20
+ * CLOUD_RELAY_URL — Railway service base URL (https://...)
21
+ * RELAY_SHARED_SECRET — auth bearer; must match Railway env var
22
+ * SLACK_BOT_TOKEN — used by shouldKeepEvent for own/peer
23
+ * filtering (no requests made on this path)
24
+ */
25
+
26
+ import { config as loadEnv } from "dotenv";
27
+ import { join, dirname, resolve } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ import { existsSync, readFileSync, appendFileSync, mkdirSync } from "node:fs";
30
+
31
+ import {
32
+ shouldKeepEvent,
33
+ eventToInboxItem,
34
+ } from "./slack-socket-mode.mjs";
35
+ import { writeInboxItem } from "./utils.mjs";
36
+
37
+ const __dirname = dirname(fileURLToPath(import.meta.url));
38
+ const AGENT_REPO_DIR = process.env.AGENT_DIR || resolve(__dirname, "../..");
39
+ loadEnv({ path: join(AGENT_REPO_DIR, ".env") });
40
+
41
+ const CLOUD_RELAY_URL = (process.env.CLOUD_RELAY_URL || "").replace(/\/$/, "");
42
+ const RELAY_SHARED_SECRET = process.env.RELAY_SHARED_SECRET || "";
43
+
44
+ const RECONNECT_INITIAL_MS = 2_000;
45
+ const RECONNECT_MAX_MS = 60_000;
46
+ const HEARTBEAT_GRACE_MS = 90_000; // SSE heartbeat is 25s — be generous before declaring dead
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Identity loading (mirrors slack-socket-mode)
50
+ // ---------------------------------------------------------------------------
51
+
52
+ function loadAgent() {
53
+ const path = join(AGENT_REPO_DIR, "config", "agent.json");
54
+ if (!existsSync(path)) return { firstName: "Agent" };
55
+ try {
56
+ return JSON.parse(readFileSync(path, "utf-8"));
57
+ } catch {
58
+ return { firstName: "Agent" };
59
+ }
60
+ }
61
+
62
+ function loadPeerSlackIds() {
63
+ // Read agents/index.json if present; otherwise empty set (lone agent).
64
+ const peers = new Set();
65
+ const idxPath = join(AGENT_REPO_DIR, "agents", "index.json");
66
+ if (!existsSync(idxPath)) return peers;
67
+ try {
68
+ const idx = JSON.parse(readFileSync(idxPath, "utf-8"));
69
+ for (const a of idx.agents || []) {
70
+ if (a.slackMemberId) peers.add(a.slackMemberId);
71
+ }
72
+ } catch { /* */ }
73
+ return peers;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Logging
78
+ // ---------------------------------------------------------------------------
79
+
80
+ const LOG_DIR = join(AGENT_REPO_DIR, "logs", "polling");
81
+ try { mkdirSync(LOG_DIR, { recursive: true }); } catch { /* */ }
82
+
83
+ function log(level, message, data) {
84
+ const ts = new Date().toISOString();
85
+ const line = { ts, level, message, ...(data || {}) };
86
+ if (level === "error") console.error(`[slack-cloud-relay] ${message}`, data || "");
87
+ else console.log(`[slack-cloud-relay] ${message}`, data || "");
88
+ try {
89
+ appendFileSync(
90
+ join(LOG_DIR, `${ts.slice(0, 10)}-slack-cloud-relay.jsonl`),
91
+ JSON.stringify(line) + "\n",
92
+ );
93
+ } catch { /* best-effort */ }
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // SSE parser — robust to multi-line `data:` and `:` comment heartbeats
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Push raw bytes from the relay into a stateful parser. Yields complete
102
+ * event payloads (the JSON in each `data:` block) as strings. Per the SSE
103
+ * spec, events are terminated by a blank line; `data:` may appear on
104
+ * multiple lines and concatenate with newlines, `:` lines are comments.
105
+ */
106
+ function createSseParser() {
107
+ let buffer = "";
108
+ let dataLines = [];
109
+ return {
110
+ push(chunk) {
111
+ buffer += chunk;
112
+ const out = [];
113
+ let nlIdx;
114
+ while ((nlIdx = buffer.indexOf("\n")) >= 0) {
115
+ const line = buffer.slice(0, nlIdx).replace(/\r$/, "");
116
+ buffer = buffer.slice(nlIdx + 1);
117
+ if (line === "") {
118
+ // End of event — flush
119
+ if (dataLines.length > 0) {
120
+ out.push(dataLines.join("\n"));
121
+ dataLines = [];
122
+ }
123
+ continue;
124
+ }
125
+ if (line.startsWith(":")) continue; // comment (heartbeat)
126
+ if (line.startsWith("data:")) {
127
+ dataLines.push(line.slice(5).replace(/^ /, ""));
128
+ }
129
+ // We ignore "event:" / "id:" / "retry:" lines — the relay
130
+ // doesn't emit them.
131
+ }
132
+ return out;
133
+ },
134
+ };
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Exponential back-off (mirrors slack-socket-mode)
139
+ // ---------------------------------------------------------------------------
140
+
141
+ function nextBackoffMs(currentMs) {
142
+ const doubled = Math.min(RECONNECT_MAX_MS, Math.max(RECONNECT_INITIAL_MS, currentMs * 2));
143
+ const jitter = Math.floor(Math.random() * 0.25 * doubled);
144
+ return Math.min(RECONNECT_MAX_MS, doubled + jitter);
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Main loop
149
+ // ---------------------------------------------------------------------------
150
+
151
+ let stopped = false;
152
+ let backoffMs = RECONNECT_INITIAL_MS;
153
+
154
+ async function connect(identity, peerSlackIds) {
155
+ if (stopped) return;
156
+ if (existsSync(join(AGENT_REPO_DIR, ".emergency-stop"))) {
157
+ log("info", ".emergency-stop present — refusing to connect");
158
+ return;
159
+ }
160
+ if (!CLOUD_RELAY_URL || !RELAY_SHARED_SECRET) {
161
+ log("error", "CLOUD_RELAY_URL or RELAY_SHARED_SECRET not set — cannot connect");
162
+ return;
163
+ }
164
+
165
+ const url = `${CLOUD_RELAY_URL}/events/stream`;
166
+ log("info", "connecting", { url });
167
+
168
+ let response;
169
+ try {
170
+ response = await fetch(url, {
171
+ headers: {
172
+ Authorization: `Bearer ${RELAY_SHARED_SECRET}`,
173
+ Accept: "text/event-stream",
174
+ },
175
+ });
176
+ } catch (err) {
177
+ log("error", `connect failed: ${err.message}`);
178
+ scheduleReconnect(identity, peerSlackIds);
179
+ return;
180
+ }
181
+
182
+ if (!response.ok) {
183
+ log("error", `relay rejected: HTTP ${response.status}`);
184
+ scheduleReconnect(identity, peerSlackIds);
185
+ return;
186
+ }
187
+
188
+ log("info", "connected");
189
+ backoffMs = RECONNECT_INITIAL_MS; // reset on a successful connect
190
+
191
+ const reader = response.body.getReader();
192
+ const decoder = new TextDecoder();
193
+ const parser = createSseParser();
194
+ let lastByteAt = Date.now();
195
+
196
+ // Heartbeat watchdog — if we haven't seen ANY bytes in HEARTBEAT_GRACE_MS,
197
+ // declare the connection dead and reconnect. Relay sends `: hb\n\n` every
198
+ // 25s, so a 90s grace catches both true silence and a proxy that froze.
199
+ const watchdog = setInterval(() => {
200
+ if (Date.now() - lastByteAt > HEARTBEAT_GRACE_MS) {
201
+ log("warn", "watchdog tripped — no bytes from relay", {
202
+ silent_ms: Date.now() - lastByteAt,
203
+ });
204
+ clearInterval(watchdog);
205
+ try { reader.cancel(); } catch { /* */ }
206
+ }
207
+ }, 30_000);
208
+
209
+ try {
210
+ while (true) {
211
+ const { done, value } = await reader.read();
212
+ if (done) {
213
+ log("info", "stream closed by relay");
214
+ break;
215
+ }
216
+ lastByteAt = Date.now();
217
+ const chunk = decoder.decode(value, { stream: true });
218
+ const events = parser.push(chunk);
219
+ for (const raw of events) {
220
+ try {
221
+ const webhook = JSON.parse(raw);
222
+ handleWebhook(webhook, identity, peerSlackIds);
223
+ } catch (err) {
224
+ log("warn", `bad event JSON: ${err.message}`, { sample: raw.slice(0, 200) });
225
+ }
226
+ }
227
+ }
228
+ } catch (err) {
229
+ log("warn", `stream error: ${err.message}`);
230
+ } finally {
231
+ clearInterval(watchdog);
232
+ }
233
+
234
+ scheduleReconnect(identity, peerSlackIds);
235
+ }
236
+
237
+ function scheduleReconnect(identity, peerSlackIds) {
238
+ if (stopped) return;
239
+ const delay = backoffMs;
240
+ backoffMs = nextBackoffMs(backoffMs);
241
+ log("info", "reconnect scheduled", { delay_ms: delay, next_backoff_ms: backoffMs });
242
+ setTimeout(() => connect(identity, peerSlackIds), delay);
243
+ }
244
+
245
+ /**
246
+ * Translate a Slack Events API webhook envelope into the shape that
247
+ * eventToInboxItem expects (Socket Mode-style `{payload: {event: ...}}`)
248
+ * then write it to the inbox.
249
+ *
250
+ * Events API payload shape:
251
+ * { token, team_id, api_app_id, type: "event_callback", event: {...}, event_id, event_time }
252
+ *
253
+ * Socket Mode payload shape that eventToInboxItem reads:
254
+ * envelope.payload.event
255
+ *
256
+ * We adapt by wrapping: { type: "events_api", payload: webhook }.
257
+ */
258
+ function handleWebhook(webhook, identity, peerSlackIds) {
259
+ if (!webhook || webhook.type !== "event_callback") {
260
+ log("info", "ignored-non-event-callback", { type: webhook?.type });
261
+ return;
262
+ }
263
+
264
+ const envelope = { type: "events_api", payload: webhook };
265
+ const decision = shouldKeepEvent(envelope, {
266
+ ownSlackId: identity.slackMemberId,
267
+ peerSlackIds,
268
+ });
269
+ if (!decision.keep) {
270
+ log("info", "event-dropped", { reason: decision.reason, event_id: webhook.event_id });
271
+ return;
272
+ }
273
+
274
+ let item;
275
+ try {
276
+ item = eventToInboxItem(envelope, {
277
+ ownSlackId: identity.slackMemberId,
278
+ principalSlackId: identity.principalSlackId,
279
+ agentFirstName: identity.firstName,
280
+ });
281
+ } catch (err) {
282
+ log("error", `translation failed: ${err.message}`);
283
+ return;
284
+ }
285
+ // Stamp the source so audit can tell relay-delivered events apart from
286
+ // poller- or socket-mode-delivered ones.
287
+ item.source = "cloud-relay";
288
+
289
+ try {
290
+ writeInboxItem("slack", item);
291
+ log("info", "inbox-item-written", {
292
+ id: item.id,
293
+ channel: item.channel,
294
+ sender: item.sender,
295
+ event_id: webhook.event_id,
296
+ });
297
+ } catch (err) {
298
+ log("error", `writeInboxItem failed: ${err.message}`);
299
+ }
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Bootstrap
304
+ // ---------------------------------------------------------------------------
305
+
306
+ const identity = loadAgent();
307
+ const peerSlackIds = loadPeerSlackIds();
308
+
309
+ const shutdown = (signal, code) => {
310
+ log("info", `received ${signal}, shutting down (exit ${code})`);
311
+ stopped = true;
312
+ // Match the slack-socket-mode pattern: SIGTERM signals a respawn from
313
+ // launchd (exit 143), SIGINT is human-initiated stop (exit 0).
314
+ setTimeout(() => process.exit(code), 250);
315
+ };
316
+ process.on("SIGTERM", () => shutdown("SIGTERM", 143));
317
+ process.on("SIGINT", () => shutdown("SIGINT", 0));
318
+ process.on("unhandledRejection", (reason) => {
319
+ log("error", `unhandled rejection: ${reason?.message || reason}`);
320
+ });
321
+ process.on("uncaughtException", (err) => {
322
+ log("error", `uncaught exception: ${err.message}`);
323
+ setTimeout(() => process.exit(1), 250);
324
+ });
325
+
326
+ connect(identity, peerSlackIds);