@vellumai/vellum-gateway 0.4.56 → 0.4.57

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/ARCHITECTURE.md CHANGED
@@ -215,13 +215,13 @@ Channel readiness endpoints are exposed directly by the gateway and forwarded to
215
215
 
216
216
  ### Channel Binding Lifecycle (Lane Separation)
217
217
 
218
- Each channel (desktop, Telegram, etc.) operates in its own **lane**: conversations created by an external channel are never displayed in the desktop conversation list, and desktop conversations are never exposed to external channels. The `channelBinding` metadata on a conversation is used solely for routing inbound/outbound messages within that lane and for filtering sessions during desktop session restoration.
218
+ Each channel (desktop, Telegram, etc.) operates in its own **lane**: conversations created by an external channel are never displayed in the desktop conversation list, and desktop conversations are never exposed to external channels. The `channelBinding` metadata on a conversation is used solely for routing inbound/outbound messages within that lane and for filtering conversations during desktop conversation restoration.
219
219
 
220
220
  Channel bindings follow a three-phase lifecycle:
221
221
 
222
222
  1. **Bind** — An inbound message from an external channel (e.g., Telegram chat) arrives at the gateway, which normalizes it and forwards it to the runtime's `/v1/channels/inbound` endpoint. The runtime creates or reuses a conversation, establishing the channel binding (`sourceChannel` metadata on the conversation).
223
223
 
224
- 2. **Route** — Subsequent messages on the same external chat are routed to the same conversation via the channel binding. Replies from the assistant are delivered back through the gateway's `/deliver/telegram` endpoint. The desktop client filters out channel-bound sessions during session restoration (`ConversationRestorer`) so they never appear in the desktop conversation list.
224
+ 2. **Route** — Subsequent messages on the same external chat are routed to the same conversation via the channel binding. Replies from the assistant are delivered back through the gateway's `/deliver/telegram` endpoint. The desktop client filters out channel-bound conversations during conversation restoration (`ConversationRestorer`) so they never appear in the desktop conversation list.
225
225
 
226
226
  3. **Rebind** — If a message arrives on an external chat whose conversation was previously deleted, the channel inbound handler treats it as a new conversation and establishes a fresh binding. The external chat ID is reused, but the conversation is new.
227
227
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.4.56",
3
+ "version": "0.4.57",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./twilio/verify": "./src/twilio/verify.ts",
@@ -13,7 +13,7 @@ describe("config: hardcoded defaults", () => {
13
13
  telegram: 20 * 1024 * 1024,
14
14
  slack: 100 * 1024 * 1024,
15
15
  whatsapp: 16 * 1024 * 1024,
16
- default: 50 * 1024 * 1024,
16
+ default: 100 * 1024 * 1024,
17
17
  });
18
18
  expect(config.maxAttachmentConcurrency).toBe(3);
19
19
  expect(config.runtimeProxyEnabled).toBe(false);
@@ -78,24 +78,16 @@ function getMachineEntropy(): string {
78
78
  return parts.join(":");
79
79
  }
80
80
 
81
- function writeEncryptedStore(entries: Record<string, string>): void {
82
- const storePath = join(testDir, ".vellum", "protected", "keys.enc");
83
- mkdirSync(join(testDir, ".vellum", "protected"), { recursive: true });
84
-
85
- const salt = randomBytes(16);
86
- const key = pbkdf2Sync(
87
- getMachineEntropy(),
88
- salt,
89
- PBKDF2_ITERATIONS,
90
- KEY_LENGTH,
91
- "sha512",
92
- );
81
+ function encryptEntries(
82
+ entries: Record<string, string>,
83
+ key: Buffer,
84
+ ): Record<string, { iv: string; tag: string; data: string }> {
93
85
  const encryptedEntries: Record<
94
86
  string,
95
87
  { iv: string; tag: string; data: string }
96
88
  > = {};
97
89
  for (const [account, value] of Object.entries(entries)) {
98
- const iv = randomBytes(12);
90
+ const iv = randomBytes(16);
99
91
  const cipher = createCipheriv(ALGORITHM, key, iv, {
100
92
  authTagLength: AUTH_TAG_LENGTH,
101
93
  });
@@ -110,15 +102,48 @@ function writeEncryptedStore(entries: Record<string, string>): void {
110
102
  data: encrypted.toString("hex"),
111
103
  };
112
104
  }
105
+ return encryptedEntries;
106
+ }
107
+
108
+ function writeEncryptedStore(entries: Record<string, string>): void {
109
+ const storePath = join(testDir, ".vellum", "protected", "keys.enc");
110
+ mkdirSync(join(testDir, ".vellum", "protected"), { recursive: true });
111
+
112
+ const salt = randomBytes(16);
113
+ const key = pbkdf2Sync(
114
+ getMachineEntropy(),
115
+ salt,
116
+ PBKDF2_ITERATIONS,
117
+ KEY_LENGTH,
118
+ "sha512",
119
+ );
113
120
 
114
121
  const store = {
115
122
  version: 1,
116
123
  salt: salt.toString("hex"),
117
- entries: encryptedEntries,
124
+ entries: encryptEntries(entries, key),
118
125
  };
119
126
  writeFileSync(storePath, JSON.stringify(store));
120
127
  }
121
128
 
129
+ /**
130
+ * Write a v2 encrypted store with a random store.key file.
131
+ * The store.key is used directly as the AES-256-GCM key (no PBKDF2).
132
+ */
133
+ function writeEncryptedStoreV2(entries: Record<string, string>): void {
134
+ const protectedDir = join(testDir, ".vellum", "protected");
135
+ mkdirSync(protectedDir, { recursive: true });
136
+
137
+ const storeKey = randomBytes(KEY_LENGTH);
138
+ writeFileSync(join(protectedDir, "store.key"), storeKey);
139
+
140
+ const store = {
141
+ version: 2,
142
+ entries: encryptEntries(entries, storeKey),
143
+ };
144
+ writeFileSync(join(protectedDir, "keys.enc"), JSON.stringify(store));
145
+ }
146
+
122
147
  // ---------------------------------------------------------------------------
123
148
  // Broker test helpers — mock UDS server
124
149
  // ---------------------------------------------------------------------------
@@ -264,6 +289,74 @@ describe("readTelegramCredentials", () => {
264
289
  });
265
290
  });
266
291
 
292
+ // ---------------------------------------------------------------------------
293
+ // Tests: v2 encrypted store (store.key)
294
+ // ---------------------------------------------------------------------------
295
+
296
+ describe("v2 encrypted store with store.key", () => {
297
+ test("reads credential from v2 store when store.key exists", async () => {
298
+ writeEncryptedStoreV2({
299
+ [credentialKey("test", "key")]: "v2-secret-value",
300
+ });
301
+
302
+ const result = await readCredential(credentialKey("test", "key"));
303
+ expect(result).toBe("v2-secret-value");
304
+ });
305
+
306
+ test("returns undefined for v2 store when store.key is missing", async () => {
307
+ // Write a v2 store but without the store.key file
308
+ const protectedDir = join(testDir, ".vellum", "protected");
309
+ mkdirSync(protectedDir, { recursive: true });
310
+
311
+ const storeKey = randomBytes(KEY_LENGTH);
312
+ const store = {
313
+ version: 2,
314
+ entries: encryptEntries(
315
+ { [credentialKey("test", "key")]: "v2-secret-value" },
316
+ storeKey,
317
+ ),
318
+ };
319
+ writeFileSync(join(protectedDir, "keys.enc"), JSON.stringify(store));
320
+ // Deliberately do NOT write store.key
321
+
322
+ const result = await readCredential(credentialKey("test", "key"));
323
+ expect(result).toBeUndefined();
324
+ });
325
+
326
+ test("returns Telegram credentials from v2 store", async () => {
327
+ writeMetadata([
328
+ { service: "telegram", field: "bot_token" },
329
+ { service: "telegram", field: "webhook_secret" },
330
+ ]);
331
+
332
+ writeEncryptedStoreV2({
333
+ [credentialKey("telegram", "bot_token")]: "v2-bot-token",
334
+ [credentialKey("telegram", "webhook_secret")]: "v2-webhook-secret",
335
+ });
336
+
337
+ const result = await readTelegramCredentials();
338
+ expect(result).toEqual({
339
+ botToken: "v2-bot-token",
340
+ webhookSecret: "v2-webhook-secret",
341
+ });
342
+ });
343
+ });
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // Tests: v1 encrypted store backward compatibility
347
+ // ---------------------------------------------------------------------------
348
+
349
+ describe("v1 encrypted store backward compatibility", () => {
350
+ test("v1 store continues to work with entropy-based key derivation", async () => {
351
+ writeEncryptedStore({
352
+ [credentialKey("test", "key")]: "v1-secret-value",
353
+ });
354
+
355
+ const result = await readCredential(credentialKey("test", "key"));
356
+ expect(result).toBe("v1-secret-value");
357
+ });
358
+ });
359
+
267
360
  // ---------------------------------------------------------------------------
268
361
  // Tests: broker credential reading
269
362
  // ---------------------------------------------------------------------------
@@ -68,7 +68,7 @@ function encrypt(
68
68
  value: string,
69
69
  key: Buffer,
70
70
  ): { iv: string; tag: string; data: string } {
71
- const iv = cryptoRandomBytes(12);
71
+ const iv = cryptoRandomBytes(16);
72
72
  const cipher = createCipheriv(ALGORITHM, key, iv, {
73
73
  authTagLength: AUTH_TAG_LENGTH,
74
74
  });
@@ -114,6 +114,31 @@ function writeEncryptedStore(botToken: string, webhookSecret: string): void {
114
114
  writeFileSync(storePath, JSON.stringify(store));
115
115
  }
116
116
 
117
+ /**
118
+ * Write Telegram credentials into a v2 encrypted store using a random
119
+ * store.key file (no PBKDF2 derivation).
120
+ */
121
+ function writeEncryptedStoreV2(
122
+ botToken: string,
123
+ webhookSecret: string,
124
+ ): void {
125
+ const protectedDir = join(testDir, ".vellum", "protected");
126
+ mkdirSync(protectedDir, { recursive: true });
127
+
128
+ const storeKey = cryptoRandomBytes(KEY_LENGTH);
129
+ writeFileSync(join(protectedDir, "store.key"), storeKey);
130
+
131
+ const store = {
132
+ version: 2,
133
+ entries: {
134
+ "credential/telegram/bot_token": encrypt(botToken, storeKey),
135
+ "credential/telegram/webhook_secret": encrypt(webhookSecret, storeKey),
136
+ },
137
+ };
138
+
139
+ writeFileSync(join(protectedDir, "keys.enc"), JSON.stringify(store));
140
+ }
141
+
117
142
  function metadataRecord(
118
143
  credentialId: string,
119
144
  service: string,
@@ -289,4 +314,38 @@ describe("gateway telegram hot-reload (e2e)", () => {
289
314
  });
290
315
  expect(after.status).toBe(401);
291
316
  }, 15_000);
317
+
318
+ test("gateway hot-reloads v2 encrypted store credentials written after startup", async () => {
319
+ // --- Setup: no credentials directory exists (fresh hatch) ---
320
+ mkdirSync(testDir, { recursive: true });
321
+
322
+ // Start the real gateway process
323
+ await startGateway();
324
+
325
+ const base = `http://localhost:${port}`;
326
+
327
+ // --- Step 1: confirm Telegram is NOT configured ---
328
+ const before = await fetch(`${base}/webhooks/telegram`, {
329
+ method: "POST",
330
+ });
331
+ expect(before.status).toBe(503);
332
+ const beforeBody = (await before.json()) as { error: string };
333
+ expect(beforeBody.error).toBe("Telegram integration not configured");
334
+
335
+ // --- Step 2: simulate daemon writing v2 credentials ---
336
+ writeEncryptedStoreV2("fake-v2-bot-token:XYZ", "fake-v2-webhook-secret");
337
+ writeCredentialMetadata();
338
+
339
+ // Wait for credential watcher debounce (500ms) + generous margin
340
+ await new Promise((resolve) => setTimeout(resolve, 2000));
341
+
342
+ // --- Step 3: query again — gateway should now recognize Telegram is configured.
343
+ // We expect 401 (webhook secret verification failed) rather than 503
344
+ // (not configured). Getting past the 503 gate proves the gateway
345
+ // hot-reloaded the v2 credentials from the credential store.
346
+ const after = await fetch(`${base}/webhooks/telegram`, {
347
+ method: "POST",
348
+ });
349
+ expect(after.status).toBe(401);
350
+ }, 15_000);
292
351
  });
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test";
2
+ import { SleepWakeDetector } from "../sleep-wake-detector.js";
3
+
4
+ // Suppress logger output during tests
5
+ mock.module("../logger.js", () => ({
6
+ getLogger: () => ({
7
+ info: () => {},
8
+ warn: () => {},
9
+ error: () => {},
10
+ debug: () => {},
11
+ }),
12
+ initLogger: () => {},
13
+ }));
14
+
15
+ describe("SleepWakeDetector", () => {
16
+ let detector: SleepWakeDetector;
17
+ let onWake: ReturnType<typeof mock>;
18
+ let originalDateNow: () => number;
19
+ let fakeNow: number;
20
+
21
+ beforeEach(() => {
22
+ onWake = mock(() => {});
23
+ originalDateNow = Date.now;
24
+ fakeNow = 1000000;
25
+ Date.now = () => fakeNow;
26
+ });
27
+
28
+ afterEach(() => {
29
+ detector?.stop();
30
+ Date.now = originalDateNow;
31
+ });
32
+
33
+ test("does not fire callback on normal ticks", async () => {
34
+ detector = new SleepWakeDetector(onWake, 50, 2);
35
+ detector.start();
36
+
37
+ // Advance time normally (within threshold)
38
+ fakeNow += 55;
39
+ await new Promise((r) => setTimeout(r, 70));
40
+
41
+ expect(onWake).not.toHaveBeenCalled();
42
+ });
43
+
44
+ test("fires callback when elapsed time exceeds threshold", async () => {
45
+ detector = new SleepWakeDetector(onWake, 50, 2);
46
+ detector.start();
47
+
48
+ // Wait for first tick at normal time
49
+ await new Promise((r) => setTimeout(r, 60));
50
+
51
+ // Simulate a sleep gap: jump time forward well past the threshold
52
+ fakeNow += 200; // 4x the interval
53
+
54
+ // Wait for next tick
55
+ await new Promise((r) => setTimeout(r, 60));
56
+
57
+ expect(onWake).toHaveBeenCalledTimes(1);
58
+ });
59
+
60
+ test("stop prevents further callbacks", async () => {
61
+ detector = new SleepWakeDetector(onWake, 50, 2);
62
+ detector.start();
63
+ detector.stop();
64
+
65
+ fakeNow += 500;
66
+ await new Promise((r) => setTimeout(r, 70));
67
+
68
+ expect(onWake).not.toHaveBeenCalled();
69
+ });
70
+
71
+ test("start after stop resets cleanly", async () => {
72
+ detector = new SleepWakeDetector(onWake, 50, 2);
73
+ detector.start();
74
+ detector.stop();
75
+ detector.start();
76
+
77
+ // Normal tick — should not fire
78
+ fakeNow += 55;
79
+ await new Promise((r) => setTimeout(r, 70));
80
+
81
+ expect(onWake).not.toHaveBeenCalled();
82
+ });
83
+ });
package/src/config.ts CHANGED
@@ -98,7 +98,9 @@ export function loadConfig(): GatewayConfig {
98
98
  const gw = (wsConfig.gateway ?? {}) as Record<string, unknown>;
99
99
 
100
100
  const runtimeProxyEnabled =
101
- gw.runtimeProxyEnabled === true || gw.runtimeProxyEnabled === "true";
101
+ gw.runtimeProxyEnabled === true ||
102
+ gw.runtimeProxyEnabled === "true" ||
103
+ process.env.RUNTIME_PROXY_ENABLED === "true";
102
104
  const runtimeProxyRequireAuth =
103
105
  gw.runtimeProxyRequireAuth !== false &&
104
106
  gw.runtimeProxyRequireAuth !== "false";
@@ -138,7 +140,7 @@ export function loadConfig(): GatewayConfig {
138
140
  telegram: 20 * 1024 * 1024, // Telegram Bot API getFile limit
139
141
  slack: 100 * 1024 * 1024, // Slack standard plan
140
142
  whatsapp: 16 * 1024 * 1024, // WhatsApp Business API limit
141
- default: 50 * 1024 * 1024, // Fallback; capped by runtime MAX_UPLOAD_BYTES (50 MB)
143
+ default: 100 * 1024 * 1024, // Fallback; capped by runtime MAX_UPLOAD_BYTES (100 MB)
142
144
  },
143
145
  maxAttachmentConcurrency: 3,
144
146
  maxWebhookPayloadBytes: 1024 * 1024,
@@ -26,12 +26,21 @@ interface EncryptedEntry {
26
26
  data: string;
27
27
  }
28
28
 
29
- interface StoreFile {
29
+ interface StoreFileV1 {
30
30
  version: 1;
31
31
  salt: string;
32
32
  entries: Record<string, EncryptedEntry>;
33
33
  }
34
34
 
35
+ interface StoreFileV2 {
36
+ version: 2;
37
+ entries: Record<string, EncryptedEntry>;
38
+ }
39
+
40
+ type StoreFile = StoreFileV1 | StoreFileV2;
41
+
42
+ const STORE_KEY_FILENAME = "store.key";
43
+
35
44
  function getPlatformName(): string {
36
45
  // Must match assistant/src/util/platform.ts#getPlatformName exactly.
37
46
  // Using user-friendly labels like "macOS" here changes PBKDF2 entropy and
@@ -66,6 +75,22 @@ function deriveKey(salt: Buffer): Buffer {
66
75
  return pbkdf2Sync(entropy, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha512");
67
76
  }
68
77
 
78
+ /**
79
+ * Read the v2 store key file (~/.vellum/protected/store.key).
80
+ * Returns null if the file doesn't exist or isn't exactly 32 bytes.
81
+ */
82
+ function readStoreKey(): Buffer | null {
83
+ const keyPath = join(getRootDir(), "protected", STORE_KEY_FILENAME);
84
+ if (!existsSync(keyPath)) return null;
85
+ try {
86
+ const buf = readFileSync(keyPath);
87
+ if (buf.length !== KEY_LENGTH) return null;
88
+ return buf;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
69
94
  function decrypt(entry: EncryptedEntry, key: Buffer): string {
70
95
  const iv = Buffer.from(entry.iv, "hex");
71
96
  const tag = Buffer.from(entry.tag, "hex");
@@ -83,17 +108,26 @@ function readStore(storePath: string): StoreFile | null {
83
108
 
84
109
  const raw = readFileSync(storePath, "utf-8");
85
110
  const parsed = JSON.parse(raw);
111
+
112
+ if (parsed.version === 2 && typeof parsed.entries === "object") {
113
+ const safeEntries: Record<string, EncryptedEntry> = Object.create(null);
114
+ Object.assign(safeEntries, parsed.entries);
115
+ parsed.entries = safeEntries;
116
+ return parsed as StoreFileV2;
117
+ }
118
+
86
119
  if (
87
- parsed.version !== 1 ||
88
- typeof parsed.salt !== "string" ||
89
- typeof parsed.entries !== "object"
120
+ parsed.version === 1 &&
121
+ typeof parsed.salt === "string" &&
122
+ typeof parsed.entries === "object"
90
123
  ) {
91
- throw new Error("Encrypted store has invalid format");
124
+ const safeEntries: Record<string, EncryptedEntry> = Object.create(null);
125
+ Object.assign(safeEntries, parsed.entries);
126
+ parsed.entries = safeEntries;
127
+ return parsed as StoreFileV1;
92
128
  }
93
- const safeEntries: Record<string, EncryptedEntry> = Object.create(null);
94
- Object.assign(safeEntries, parsed.entries);
95
- parsed.entries = safeEntries;
96
- return parsed as StoreFile;
129
+
130
+ throw new Error("Encrypted store has invalid format");
97
131
  }
98
132
 
99
133
  export function getRootDir(): string {
@@ -125,6 +159,9 @@ export function getMetadataPath(): string {
125
159
  * Read a single credential from the encrypted store.
126
160
  * Returns `undefined` if the store doesn't exist, the key is missing,
127
161
  * or decryption fails.
162
+ *
163
+ * For v2 stores, uses the store.key file directly as the AES key.
164
+ * For v1 stores, derives the key from machine entropy via PBKDF2.
128
165
  */
129
166
  function readEncryptedCredential(account: string): string | undefined {
130
167
  try {
@@ -134,8 +171,15 @@ function readEncryptedCredential(account: string): string | undefined {
134
171
  const entry = store.entries[account];
135
172
  if (!entry) return undefined;
136
173
 
137
- const salt = Buffer.from(store.salt, "hex");
138
- const key = deriveKey(salt);
174
+ let key: Buffer;
175
+ if (store.version === 2) {
176
+ const storeKey = readStoreKey();
177
+ if (!storeKey) return undefined;
178
+ key = storeKey;
179
+ } else {
180
+ const salt = Buffer.from(store.salt, "hex");
181
+ key = deriveKey(salt);
182
+ }
139
183
  return decrypt(entry, key);
140
184
  } catch (err) {
141
185
  log.debug({ err, account }, "Failed to read from encrypted store");
@@ -1,19 +1,20 @@
1
1
  /**
2
- * Watches the assistant's credential metadata file for changes and
3
- * triggers a callback when Telegram or Twilio credentials are added,
2
+ * Watches the assistant's credential metadata file and the v2 store key
3
+ * for changes, triggering a callback when channel credentials are added,
4
4
  * updated, or removed.
5
5
  *
6
- * Watches the parent directory rather than the file itself because
6
+ * Watches parent directories rather than files themselves because
7
7
  * metadata.json is rewritten via atomic rename. File-scoped fs.watch()
8
8
  * subscriptions can stay attached to the old inode after the first write,
9
9
  * causing later credential changes to be missed until restart.
10
10
  */
11
11
 
12
12
  import { mkdirSync, watch, type FSWatcher } from "node:fs";
13
- import { dirname } from "node:path";
13
+ import { dirname, join } from "node:path";
14
14
  import { getLogger } from "./logger.js";
15
15
  import {
16
16
  getMetadataPath,
17
+ getRootDir,
17
18
  readTelegramCredentials,
18
19
  readTwilioCredentials,
19
20
  readWhatsAppCredentials,
@@ -42,7 +43,7 @@ export type CredentialChangeEvent = {
42
43
  export type CredentialChangeCallback = (event: CredentialChangeEvent) => void;
43
44
 
44
45
  export class CredentialWatcher {
45
- private watcher: FSWatcher | null = null;
46
+ private watchers: FSWatcher[] = [];
46
47
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
47
48
  private lastSerialized: Map<string, string> = new Map();
48
49
  private polling = false;
@@ -58,28 +59,43 @@ export class CredentialWatcher {
58
59
  async start(): Promise<void> {
59
60
  await this.pollOnce();
60
61
 
61
- const watchTarget = dirname(this.metadataPath);
62
+ const metadataDir = dirname(this.metadataPath);
63
+ const protectedDir = join(getRootDir(), "protected");
62
64
 
63
- // Ensure the directory exists so fs.watch() doesn't throw ENOENT
65
+ // Ensure directories exist so fs.watch() doesn't throw ENOENT
64
66
  // on a fresh hatch where no credentials have been written yet.
65
- mkdirSync(watchTarget, { recursive: true });
67
+ mkdirSync(metadataDir, { recursive: true });
68
+ mkdirSync(protectedDir, { recursive: true });
66
69
 
70
+ // Watch the metadata directory for metadata.json changes.
71
+ this.startWatcher(metadataDir, "metadata.json");
72
+
73
+ // Watch the protected directory for store.key changes so that
74
+ // creating or restoring the v2 store key triggers a credential reload.
75
+ this.startWatcher(protectedDir, "store.key");
76
+ }
77
+
78
+ private startWatcher(dir: string, targetFilename: string): void {
67
79
  try {
68
- this.watcher = watch(
69
- watchTarget,
80
+ const watcher = watch(
81
+ dir,
70
82
  { persistent: false },
71
83
  (_event, filename) => {
72
- if (filename && filename !== "metadata.json") {
84
+ if (filename && filename !== targetFilename) {
73
85
  return;
74
86
  }
75
87
  this.scheduleCheck();
76
88
  },
77
89
  );
90
+ this.watchers.push(watcher);
78
91
 
79
- log.info({ path: watchTarget }, "Watching for credential changes");
92
+ log.info(
93
+ { path: dir, file: targetFilename },
94
+ "Watching for credential changes",
95
+ );
80
96
  } catch (err) {
81
97
  log.warn(
82
- { err, path: watchTarget },
98
+ { err, path: dir },
83
99
  "Failed to start credential file watcher",
84
100
  );
85
101
  }
@@ -91,10 +107,10 @@ export class CredentialWatcher {
91
107
  this.debounceTimer = null;
92
108
  }
93
109
  this.pendingPoll = false;
94
- if (this.watcher) {
95
- this.watcher.close();
96
- this.watcher = null;
110
+ for (const watcher of this.watchers) {
111
+ watcher.close();
97
112
  }
113
+ this.watchers = [];
98
114
  }
99
115
 
100
116
  private scheduleCheck(): void {
@@ -41,14 +41,6 @@
41
41
  "description": "Enable multi-file TSX app creation with esbuild compilation instead of single-HTML apps",
42
42
  "defaultEnabled": false
43
43
  },
44
- {
45
- "id": "sentry-testing",
46
- "scope": "macos",
47
- "key": "sentry_testing_enabled",
48
- "label": "Sentry Testing",
49
- "description": "Show the Sentry Testing tab in Settings for triggering test crash reports and error events",
50
- "defaultEnabled": false
51
- },
52
44
  {
53
45
  "id": "mobile-pairing",
54
46
  "scope": "macos",
@@ -226,19 +218,43 @@
226
218
  "defaultEnabled": false
227
219
  },
228
220
  {
229
- "id": "thread-starters",
230
- "scope": "macos",
231
- "key": "thread_starters_enabled",
232
- "label": "Thread Starters",
233
- "description": "Show personalized thread starter suggestions on the empty conversation page",
221
+ "id": "integration-notion",
222
+ "scope": "assistant",
223
+ "key": "feature_flags.integration-notion.enabled",
224
+ "label": "Notion Integration",
225
+ "description": "Enable the Notion setup skill for connecting to Notion",
226
+ "defaultEnabled": false
227
+ },
228
+ {
229
+ "id": "conversation-starters",
230
+ "scope": "assistant",
231
+ "key": "feature_flags.conversation-starters.enabled",
232
+ "label": "Recommended Starts",
233
+ "description": "Show a curated row of recommended starting actions on the empty conversation page, ordered by relevance as confident first-click options",
234
+ "defaultEnabled": true
235
+ },
236
+ {
237
+ "id": "deploy-to-vercel",
238
+ "scope": "assistant",
239
+ "key": "feature_flags.deploy-to-vercel.enabled",
240
+ "label": "Deploy to Vercel",
241
+ "description": "Enable the Deploy to Vercel / Publish option in the app workspace header share menu",
242
+ "defaultEnabled": false
243
+ },
244
+ {
245
+ "id": "managed-google-oauth",
246
+ "scope": "assistant",
247
+ "key": "feature_flags.managed-google-oauth.enabled",
248
+ "label": "Managed Google OAuth",
249
+ "description": "Show the Google OAuth service card in Models & Services settings",
234
250
  "defaultEnabled": false
235
251
  },
236
252
  {
237
- "id": "capability-feed",
253
+ "id": "quick-input",
238
254
  "scope": "macos",
239
- "key": "capability_feed_enabled",
240
- "label": "Capability Feed",
241
- "description": "Show the scrollable capability cards feed below the hero on the new thread page",
255
+ "key": "quick_input_enabled",
256
+ "label": "Quick Input",
257
+ "description": "Enable the Quick Input popover on right-click of the menu bar icon",
242
258
  "defaultEnabled": false
243
259
  }
244
260
  ]
package/src/index.ts CHANGED
@@ -67,6 +67,7 @@ import {
67
67
  type RouteDefinition,
68
68
  type GetClientIp,
69
69
  } from "./http/router.js";
70
+ import { SleepWakeDetector } from "./sleep-wake-detector.js";
70
71
  import { callTelegramApi } from "./telegram/api.js";
71
72
  import { reconcileTelegramWebhook } from "./telegram/webhook-manager.js";
72
73
 
@@ -663,6 +664,13 @@ async function main() {
663
664
  channelReadinessProxy.handleRefreshChannelReadiness(req),
664
665
  },
665
666
 
667
+ {
668
+ path: /^\/v1\/assistants\/([^/]+)\/channels\/readiness\/$/,
669
+ method: "GET",
670
+ auth: "edge",
671
+ handler: (req) => channelReadinessProxy.handleGetChannelReadiness(req),
672
+ },
673
+
666
674
  // ── Integration status ──
667
675
  {
668
676
  path: "/integrations/status",
@@ -675,6 +683,17 @@ async function main() {
675
683
  },
676
684
  }),
677
685
  },
686
+ {
687
+ path: /^\/v1\/assistants\/([^/]+)\/integrations\/status\/$/,
688
+ method: "GET",
689
+ auth: "edge",
690
+ handler: () =>
691
+ Response.json({
692
+ email: {
693
+ address: configFileCache.getString("email", "address") ?? null,
694
+ },
695
+ }),
696
+ },
678
697
 
679
698
  // ── Feature flags (scope-protected) ──
680
699
  {
@@ -684,6 +703,13 @@ async function main() {
684
703
  scope: "feature_flags.read",
685
704
  handler: (req) => handleFeatureFlagsGet(req),
686
705
  },
706
+ {
707
+ path: /^\/v1\/assistants\/([^/]+)\/feature-flags\/$/,
708
+ method: "GET",
709
+ auth: "edge-scoped",
710
+ scope: "feature_flags.read",
711
+ handler: (req) => handleFeatureFlagsGet(req),
712
+ },
687
713
  {
688
714
  path: /^\/v1\/feature-flags\/(.+)$/,
689
715
  method: "PATCH",
@@ -702,6 +728,24 @@ async function main() {
702
728
  return handleFeatureFlagsPatch(req, flagKey);
703
729
  },
704
730
  },
731
+ {
732
+ path: /^\/v1\/assistants\/([^/]+)\/feature-flags\/(.+)$/,
733
+ method: "PATCH",
734
+ auth: "edge-scoped",
735
+ scope: "feature_flags.write",
736
+ handler: (req, params) => {
737
+ let flagKey: string;
738
+ try {
739
+ flagKey = decodeURIComponent(params[1].replace(/\/$/, ""));
740
+ } catch {
741
+ return Response.json(
742
+ { error: "Invalid flag key encoding" },
743
+ { status: 400 },
744
+ );
745
+ }
746
+ return handleFeatureFlagsPatch(req, flagKey);
747
+ },
748
+ },
705
749
 
706
750
  // ── Privacy config (scope-protected) ──
707
751
  {
@@ -711,6 +755,13 @@ async function main() {
711
755
  scope: "settings.write",
712
756
  handler: (req) => handlePrivacyConfigPatch(req),
713
757
  },
758
+ {
759
+ path: /^\/v1\/assistants\/([^/]+)\/config\/privacy\/$/,
760
+ method: "PATCH",
761
+ auth: "edge-scoped",
762
+ scope: "settings.write",
763
+ handler: (req) => handlePrivacyConfigPatch(req),
764
+ },
714
765
  ];
715
766
 
716
767
  // The runtime proxy catch-all is only added when the proxy is enabled.
@@ -785,6 +836,25 @@ async function main() {
785
836
  if (draining) {
786
837
  return Response.json({ status: "draining" }, { status: 503 });
787
838
  }
839
+ // Check that the upstream assistant is also reachable so callers
840
+ // know the full stack is ready, not just the gateway process.
841
+ try {
842
+ const upstream = await fetch(
843
+ `${config.assistantRuntimeBaseUrl}/healthz`,
844
+ { signal: AbortSignal.timeout(3000) },
845
+ );
846
+ if (!upstream.ok) {
847
+ return Response.json(
848
+ { status: "upstream_unhealthy", upstream: upstream.status },
849
+ { status: 503 },
850
+ );
851
+ }
852
+ } catch {
853
+ return Response.json(
854
+ { status: "upstream_unreachable" },
855
+ { status: 503 },
856
+ );
857
+ }
788
858
  return Response.json({ status: "ok" });
789
859
  }
790
860
 
@@ -983,11 +1053,34 @@ async function main() {
983
1053
 
984
1054
  configFileWatcher.start();
985
1055
 
1056
+ // ── Sleep/wake detection ──
1057
+ // Detect system sleep/wake transitions and force-reconnect channels
1058
+ // that may have stale connections after the OS suspended the process.
1059
+ const sleepWakeDetector = new SleepWakeDetector(() => {
1060
+ log.info("System wake detected — reconnecting channels");
1061
+
1062
+ // Force-reconnect Slack WebSocket (may be half-open after sleep)
1063
+ slackSocketClient?.forceReconnect();
1064
+
1065
+ // Invalidate caches so next read picks up any config changes (e.g. new ngrok URL)
1066
+ configFileCache.invalidate();
1067
+ credentialCache.invalidate();
1068
+
1069
+ // Re-register Telegram webhook with current ingress URL
1070
+ if (telegramReady) {
1071
+ reconcileTelegramWebhook(telegramCaches).catch((err) => {
1072
+ log.error({ err }, "Failed to reconcile Telegram webhook after wake");
1073
+ });
1074
+ }
1075
+ });
1076
+ sleepWakeDetector.start();
1077
+
986
1078
  const drainMs = config.shutdownDrainMs;
987
1079
 
988
1080
  process.on("SIGTERM", () => {
989
1081
  log.info("SIGTERM received, starting graceful shutdown");
990
1082
  draining = true;
1083
+ sleepWakeDetector.stop();
991
1084
  credentialWatcher.stop();
992
1085
  configFileWatcher.stop();
993
1086
  telegramDedupCache.stopCleanup();
package/src/schema.ts CHANGED
@@ -1633,6 +1633,32 @@ export function buildSchema(): Record<string, unknown> {
1633
1633
  },
1634
1634
  },
1635
1635
  },
1636
+ "/v1/assistants/{assistantId}/channels/readiness/": {
1637
+ get: {
1638
+ summary: "Get channel readiness (scoped)",
1639
+ description:
1640
+ "Authenticated gateway endpoint that returns the readiness status of all configured channels from the assistant runtime. The assistantId path segment is used for routing but does not affect the response.",
1641
+ operationId: "channelReadinessScopedGet",
1642
+ parameters: [
1643
+ {
1644
+ name: "assistantId",
1645
+ in: "path",
1646
+ required: true,
1647
+ schema: { type: "string" },
1648
+ },
1649
+ ],
1650
+ security: [{ BearerAuth: [] }],
1651
+ responses: {
1652
+ "200": { description: "Channel readiness status returned" },
1653
+ "401": {
1654
+ description: "Unauthorized — missing or invalid bearer token",
1655
+ },
1656
+ "503": { description: "Bearer token not configured" },
1657
+ "502": { description: "Failed to reach assistant runtime" },
1658
+ "504": { description: "Assistant runtime request timed out" },
1659
+ },
1660
+ },
1661
+ },
1636
1662
  "/v1/channels/readiness/refresh": {
1637
1663
  post: {
1638
1664
  summary: "Refresh channel readiness",
@@ -1758,6 +1784,130 @@ export function buildSchema(): Record<string, unknown> {
1758
1784
  },
1759
1785
  },
1760
1786
  },
1787
+ "/v1/assistants/{assistantId}/feature-flags/": {
1788
+ get: {
1789
+ summary: "List feature flags (assistant-scoped)",
1790
+ description:
1791
+ "Assistant-scoped variant of the feature flags endpoint. Requires a bearer token with `feature_flags.read` scope.",
1792
+ operationId: "assistantFeatureFlagsGet",
1793
+ security: [{ BearerAuth: [] }],
1794
+ parameters: [
1795
+ {
1796
+ name: "assistantId",
1797
+ in: "path",
1798
+ required: true,
1799
+ schema: { type: "string" },
1800
+ description: "The assistant identifier.",
1801
+ },
1802
+ ],
1803
+ responses: {
1804
+ "200": { description: "Feature flags returned" },
1805
+ "401": {
1806
+ description: "Unauthorized — missing or invalid bearer token",
1807
+ },
1808
+ "403": { description: "Insufficient scope" },
1809
+ },
1810
+ },
1811
+ },
1812
+ "/v1/assistants/{assistantId}/feature-flags/{flagKey}": {
1813
+ patch: {
1814
+ summary: "Update a feature flag (assistant-scoped)",
1815
+ description:
1816
+ "Assistant-scoped variant of the feature flag update endpoint. Requires a bearer token with `feature_flags.write` scope.",
1817
+ operationId: "assistantFeatureFlagsPatch",
1818
+ security: [{ BearerAuth: [] }],
1819
+ parameters: [
1820
+ {
1821
+ name: "assistantId",
1822
+ in: "path",
1823
+ required: true,
1824
+ schema: { type: "string" },
1825
+ description: "The assistant identifier.",
1826
+ },
1827
+ {
1828
+ name: "flagKey",
1829
+ in: "path",
1830
+ required: true,
1831
+ schema: { type: "string" },
1832
+ description: "The feature flag key to update.",
1833
+ },
1834
+ ],
1835
+ requestBody: {
1836
+ required: true,
1837
+ content: {
1838
+ "application/json": {
1839
+ schema: { type: "object", additionalProperties: true },
1840
+ },
1841
+ },
1842
+ },
1843
+ responses: {
1844
+ "200": { description: "Feature flag updated" },
1845
+ "400": { description: "Invalid flag key encoding" },
1846
+ "401": {
1847
+ description: "Unauthorized — missing or invalid bearer token",
1848
+ },
1849
+ "403": { description: "Insufficient scope" },
1850
+ },
1851
+ },
1852
+ },
1853
+ "/v1/assistants/{assistantId}/config/privacy/": {
1854
+ patch: {
1855
+ summary: "Update privacy config (assistant-scoped)",
1856
+ description:
1857
+ "Assistant-scoped variant of the privacy config endpoint. Requires a bearer token with `settings.write` scope.",
1858
+ operationId: "assistantPrivacyConfigPatch",
1859
+ security: [{ BearerAuth: [] }],
1860
+ parameters: [
1861
+ {
1862
+ name: "assistantId",
1863
+ in: "path",
1864
+ required: true,
1865
+ schema: { type: "string" },
1866
+ description: "The assistant identifier.",
1867
+ },
1868
+ ],
1869
+ requestBody: {
1870
+ required: true,
1871
+ content: {
1872
+ "application/json": {
1873
+ schema: {
1874
+ type: "object",
1875
+ properties: {
1876
+ collectUsageData: { type: "boolean" },
1877
+ sendDiagnostics: { type: "boolean" },
1878
+ },
1879
+ anyOf: [
1880
+ { required: ["collectUsageData"] },
1881
+ { required: ["sendDiagnostics"] },
1882
+ ],
1883
+ },
1884
+ },
1885
+ },
1886
+ },
1887
+ responses: {
1888
+ "200": {
1889
+ description: "Privacy config updated",
1890
+ content: {
1891
+ "application/json": {
1892
+ schema: {
1893
+ type: "object",
1894
+ properties: {
1895
+ collectUsageData: { type: "boolean" },
1896
+ sendDiagnostics: { type: "boolean" },
1897
+ },
1898
+ },
1899
+ },
1900
+ },
1901
+ },
1902
+ "400": { description: "Invalid request body" },
1903
+ "401": {
1904
+ description: "Unauthorized — missing or invalid bearer token",
1905
+ },
1906
+ "403": { description: "Insufficient scope" },
1907
+ "500": { description: "Internal server error" },
1908
+ },
1909
+ },
1910
+ },
1761
1911
  "/integrations/status": {
1762
1912
  get: {
1763
1913
  summary: "Integration status",
@@ -1795,6 +1945,51 @@ export function buildSchema(): Record<string, unknown> {
1795
1945
  },
1796
1946
  },
1797
1947
  },
1948
+ "/v1/assistants/{assistantId}/integrations/status/": {
1949
+ get: {
1950
+ summary: "Integration status (scoped)",
1951
+ description:
1952
+ "Returns the current status of configured integrations, including the assistant's email address. Requires a valid bearer token. The assistantId path segment is used for routing but does not affect the response.",
1953
+ operationId: "integrationsStatusScoped",
1954
+ parameters: [
1955
+ {
1956
+ name: "assistantId",
1957
+ in: "path",
1958
+ required: true,
1959
+ schema: { type: "string" },
1960
+ },
1961
+ ],
1962
+ security: [{ BearerAuth: [] }],
1963
+ responses: {
1964
+ "200": {
1965
+ description: "Integration status",
1966
+ content: {
1967
+ "application/json": {
1968
+ schema: {
1969
+ $ref: "#/components/schemas/IntegrationsStatusResponse",
1970
+ },
1971
+ },
1972
+ },
1973
+ },
1974
+ "401": {
1975
+ description: "Unauthorized — missing or invalid bearer token",
1976
+ content: {
1977
+ "application/json": {
1978
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
1979
+ },
1980
+ },
1981
+ },
1982
+ "503": {
1983
+ description: "Bearer token not configured",
1984
+ content: {
1985
+ "application/json": {
1986
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
1987
+ },
1988
+ },
1989
+ },
1990
+ },
1991
+ },
1992
+ },
1798
1993
  "/deliver/telegram": {
1799
1994
  post: {
1800
1995
  summary: "Telegram delivery (internal)",
@@ -51,6 +51,7 @@ export class SlackSocketModeClient {
51
51
  private config: SlackSocketModeConfig;
52
52
  private onEvent: (event: NormalizedSlackEvent) => void;
53
53
  private ws: WebSocket | null = null;
54
+ private connecting = false;
54
55
  private running = false;
55
56
  private reconnectAttempt = 0;
56
57
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -133,6 +134,7 @@ export class SlackSocketModeClient {
133
134
 
134
135
  stop(): void {
135
136
  this.running = false;
137
+ this.connecting = false;
136
138
  this.stopDedupCleanup();
137
139
  if (this.reconnectTimer) {
138
140
  clearTimeout(this.reconnectTimer);
@@ -148,6 +150,89 @@ export class SlackSocketModeClient {
148
150
  }
149
151
  }
150
152
 
153
+ /**
154
+ * Force-close the current WebSocket and reconnect immediately.
155
+ * Used by the sleep/wake detector to recover from half-open connections
156
+ * that survive system sleep.
157
+ *
158
+ * Waits for the old socket to fully close before connecting a new one
159
+ * to prevent overlapping connections where stale message events could
160
+ * be ACKed on the wrong socket.
161
+ */
162
+ forceReconnect(): void {
163
+ if (!this.running) return;
164
+
165
+ log.info("Force-reconnecting Slack Socket Mode (sleep/wake recovery)");
166
+
167
+ if (this.reconnectTimer) {
168
+ clearTimeout(this.reconnectTimer);
169
+ this.reconnectTimer = null;
170
+ }
171
+
172
+ this.reconnectAttempt = 0;
173
+
174
+ const oldWs = this.ws;
175
+ this.ws = null;
176
+
177
+ // If a connect() call is already in-flight (awaiting getWebSocketUrl),
178
+ // don't start another one — the in-flight attempt will complete and
179
+ // establish a fresh connection. We still tear down the old socket and
180
+ // cancel the reconnect timer above so there's no stale state.
181
+ if (this.connecting) {
182
+ log.info(
183
+ "Connect already in-flight, skipping duplicate — tearing down old socket only",
184
+ );
185
+ if (oldWs) {
186
+ try {
187
+ oldWs.close(1000, "force reconnect");
188
+ } catch {
189
+ // ignore
190
+ }
191
+ }
192
+ return;
193
+ }
194
+
195
+ if (!oldWs || oldWs.readyState === WebSocket.CLOSED) {
196
+ this.connect().catch((err) => {
197
+ log.error({ err }, "Force reconnect failed");
198
+ });
199
+ return;
200
+ }
201
+
202
+ // Wait for the old socket to fully close before opening a new one.
203
+ // Use a timeout to avoid blocking indefinitely on half-open sockets
204
+ // that may never emit a close event (the exact scenario that triggers
205
+ // a force reconnect after sleep).
206
+ const CLOSE_TIMEOUT_MS = 5_000;
207
+ let settled = false;
208
+
209
+ const proceed = () => {
210
+ if (settled) return;
211
+ settled = true;
212
+ this.connect().catch((err) => {
213
+ log.error({ err }, "Force reconnect failed");
214
+ });
215
+ };
216
+
217
+ oldWs.addEventListener("close", proceed, { once: true });
218
+
219
+ setTimeout(() => {
220
+ if (!settled) {
221
+ log.warn(
222
+ "Old Slack socket did not close within timeout, proceeding with reconnect",
223
+ );
224
+ proceed();
225
+ }
226
+ }, CLOSE_TIMEOUT_MS);
227
+
228
+ try {
229
+ oldWs.close(1000, "force reconnect");
230
+ } catch {
231
+ // Socket may already be in a broken state — proceed immediately
232
+ proceed();
233
+ }
234
+ }
235
+
151
236
  /**
152
237
  * Register a thread as active so future replies (without @mention) are forwarded.
153
238
  */
@@ -157,12 +242,15 @@ export class SlackSocketModeClient {
157
242
 
158
243
  private async connect(): Promise<void> {
159
244
  if (!this.running) return;
245
+ if (this.connecting) return;
246
+ this.connecting = true;
160
247
 
161
248
  let wsUrl: string;
162
249
  try {
163
250
  wsUrl = await this.getWebSocketUrl();
164
251
  } catch (err) {
165
252
  log.error({ err }, "Failed to obtain Socket Mode WebSocket URL");
253
+ this.connecting = false;
166
254
  this.scheduleReconnect();
167
255
  return;
168
256
  }
@@ -172,6 +260,7 @@ export class SlackSocketModeClient {
172
260
  try {
173
261
  const ws = new WebSocket(wsUrl);
174
262
  this.ws = ws;
263
+ this.connecting = false;
175
264
 
176
265
  ws.addEventListener("open", () => {
177
266
  log.info("Slack Socket Mode connected");
@@ -179,7 +268,7 @@ export class SlackSocketModeClient {
179
268
  });
180
269
 
181
270
  ws.addEventListener("message", (messageEvent) => {
182
- this.handleMessage(messageEvent.data as string);
271
+ this.handleMessage(messageEvent.data as string, ws);
183
272
  });
184
273
 
185
274
  ws.addEventListener("close", (closeEvent) => {
@@ -187,8 +276,13 @@ export class SlackSocketModeClient {
187
276
  { code: closeEvent.code, reason: closeEvent.reason },
188
277
  "Slack Socket Mode disconnected",
189
278
  );
190
- this.ws = null;
191
- this.scheduleReconnect();
279
+ // Only reconnect if this socket is still the active one.
280
+ // forceReconnect nulls this.ws before initiating a new connection,
281
+ // so a stale close event should be ignored.
282
+ if (this.ws === ws) {
283
+ this.ws = null;
284
+ this.scheduleReconnect();
285
+ }
192
286
  });
193
287
 
194
288
  ws.addEventListener("error", (errorEvent) => {
@@ -200,6 +294,7 @@ export class SlackSocketModeClient {
200
294
  } catch (err) {
201
295
  log.error({ err }, "Failed to create WebSocket connection");
202
296
  this.ws = null;
297
+ this.connecting = false;
203
298
  this.scheduleReconnect();
204
299
  }
205
300
  }
@@ -242,7 +337,7 @@ export class SlackSocketModeClient {
242
337
  };
243
338
  }
244
339
 
245
- private handleMessage(raw: string): void {
340
+ private handleMessage(raw: string, originWs: WebSocket): void {
246
341
  let envelope: {
247
342
  envelope_id?: string;
248
343
  type?: string;
@@ -272,32 +367,31 @@ export class SlackSocketModeClient {
272
367
  return;
273
368
  }
274
369
 
275
- // ACK every envelope immediately
276
- if (
277
- envelope.envelope_id &&
278
- this.ws &&
279
- this.ws.readyState === WebSocket.OPEN
280
- ) {
281
- this.ws.send(JSON.stringify({ envelope_id: envelope.envelope_id }));
370
+ // ACK every envelope on the socket that received it — never cross-ACK
371
+ // onto a different connection (e.g. after forceReconnect replaces this.ws).
372
+ if (envelope.envelope_id && originWs.readyState === WebSocket.OPEN) {
373
+ originWs.send(JSON.stringify({ envelope_id: envelope.envelope_id }));
282
374
  }
283
375
 
284
- // Handle disconnect type: Slack asks us to reconnect
376
+ // Handle disconnect type: Slack asks us to reconnect.
377
+ // Only act if the requesting socket is still the active one —
378
+ // a stale socket's disconnect should not tear down a new connection.
285
379
  if (envelope.type === "disconnect") {
286
380
  log.info(
287
381
  { reason: envelope.reason },
288
382
  "Slack requested disconnect, reconnecting",
289
383
  );
290
- if (this.ws) {
384
+ if (this.ws === originWs) {
291
385
  try {
292
386
  this.ws.close(1000, "server requested disconnect");
293
387
  } catch {
294
388
  // ignore
295
389
  }
296
390
  this.ws = null;
391
+ // Reconnect immediately (attempt 0 = minimal backoff)
392
+ this.reconnectAttempt = 0;
393
+ this.scheduleReconnect();
297
394
  }
298
- // Reconnect immediately (attempt 0 = minimal backoff)
299
- this.reconnectAttempt = 0;
300
- this.scheduleReconnect();
301
395
  return;
302
396
  }
303
397
 
@@ -0,0 +1,44 @@
1
+ import { getLogger } from "./logger.js";
2
+
3
+ const log = getLogger("sleep-wake-detector");
4
+
5
+ /**
6
+ * Detects system sleep/wake transitions using the epoch-gap technique:
7
+ * a periodic timer checks if the elapsed time between ticks far exceeds
8
+ * the expected interval, which indicates the process was suspended.
9
+ */
10
+ export class SleepWakeDetector {
11
+ private timer: ReturnType<typeof setInterval> | null = null;
12
+ private lastTick: number = 0;
13
+
14
+ constructor(
15
+ private onWake: () => void,
16
+ private intervalMs: number = 10_000,
17
+ private thresholdMultiplier: number = 2,
18
+ ) {}
19
+
20
+ start(): void {
21
+ this.stop();
22
+ this.lastTick = Date.now();
23
+ this.timer = setInterval(() => {
24
+ const now = Date.now();
25
+ const elapsed = now - this.lastTick;
26
+ this.lastTick = now;
27
+
28
+ if (elapsed > this.intervalMs * this.thresholdMultiplier) {
29
+ log.info(
30
+ { elapsedMs: elapsed, expectedMs: this.intervalMs },
31
+ "System wake detected (epoch gap)",
32
+ );
33
+ this.onWake();
34
+ }
35
+ }, this.intervalMs);
36
+ }
37
+
38
+ stop(): void {
39
+ if (this.timer) {
40
+ clearInterval(this.timer);
41
+ this.timer = null;
42
+ }
43
+ }
44
+ }