kimaki 0.4.78 → 0.4.80

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.
Files changed (90) hide show
  1. package/dist/anthropic-auth-plugin.js +628 -0
  2. package/dist/channel-management.js +2 -2
  3. package/dist/cli.js +316 -129
  4. package/dist/commands/action-buttons.js +1 -1
  5. package/dist/commands/login.js +634 -277
  6. package/dist/commands/model.js +91 -6
  7. package/dist/commands/paginated-select.js +57 -0
  8. package/dist/commands/resume.js +2 -2
  9. package/dist/commands/tasks.js +205 -0
  10. package/dist/commands/undo-redo.js +80 -18
  11. package/dist/context-awareness-plugin.js +347 -0
  12. package/dist/database.js +103 -7
  13. package/dist/db.js +39 -1
  14. package/dist/discord-bot.js +42 -19
  15. package/dist/discord-urls.js +11 -0
  16. package/dist/discord-ws-proxy.js +350 -0
  17. package/dist/discord-ws-proxy.test.js +500 -0
  18. package/dist/errors.js +1 -1
  19. package/dist/gateway-session.js +163 -0
  20. package/dist/hrana-server.js +114 -4
  21. package/dist/interaction-handler.js +30 -7
  22. package/dist/ipc-tools-plugin.js +186 -0
  23. package/dist/message-preprocessing.js +56 -11
  24. package/dist/onboarding-welcome.js +1 -1
  25. package/dist/opencode-interrupt-plugin.js +133 -75
  26. package/dist/opencode-plugin.js +12 -389
  27. package/dist/opencode.js +59 -5
  28. package/dist/parse-permission-rules.test.js +117 -0
  29. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  30. package/dist/session-handler/thread-session-runtime.js +68 -29
  31. package/dist/startup-time.e2e.test.js +295 -0
  32. package/dist/store.js +1 -0
  33. package/dist/system-message.js +3 -1
  34. package/dist/task-runner.js +7 -3
  35. package/dist/task-schedule.js +12 -0
  36. package/dist/thread-message-queue.e2e.test.js +13 -1
  37. package/dist/undo-redo.e2e.test.js +166 -0
  38. package/dist/utils.js +4 -1
  39. package/dist/voice-attachment.js +34 -0
  40. package/dist/voice-handler.js +11 -9
  41. package/dist/voice-message.e2e.test.js +78 -0
  42. package/dist/voice.test.js +31 -0
  43. package/package.json +12 -7
  44. package/skills/egaki/SKILL.md +80 -15
  45. package/skills/errore/SKILL.md +13 -0
  46. package/skills/lintcn/SKILL.md +749 -0
  47. package/skills/npm-package/SKILL.md +17 -3
  48. package/skills/spiceflow/SKILL.md +14 -0
  49. package/skills/zele/SKILL.md +9 -0
  50. package/src/anthropic-auth-plugin.ts +732 -0
  51. package/src/channel-management.ts +2 -2
  52. package/src/cli.ts +354 -132
  53. package/src/commands/action-buttons.ts +1 -0
  54. package/src/commands/login.ts +836 -337
  55. package/src/commands/model.ts +102 -7
  56. package/src/commands/paginated-select.ts +81 -0
  57. package/src/commands/resume.ts +6 -1
  58. package/src/commands/tasks.ts +293 -0
  59. package/src/commands/undo-redo.ts +87 -20
  60. package/src/context-awareness-plugin.ts +469 -0
  61. package/src/database.ts +138 -7
  62. package/src/db.ts +40 -1
  63. package/src/discord-bot.ts +46 -19
  64. package/src/discord-urls.ts +12 -0
  65. package/src/errors.ts +1 -1
  66. package/src/hrana-server.ts +124 -3
  67. package/src/interaction-handler.ts +41 -9
  68. package/src/ipc-tools-plugin.ts +228 -0
  69. package/src/message-preprocessing.ts +82 -11
  70. package/src/onboarding-welcome.ts +1 -1
  71. package/src/opencode-interrupt-plugin.ts +164 -91
  72. package/src/opencode-plugin.ts +13 -483
  73. package/src/opencode.ts +60 -5
  74. package/src/parse-permission-rules.test.ts +127 -0
  75. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  76. package/src/session-handler/thread-runtime-state.ts +4 -1
  77. package/src/session-handler/thread-session-runtime.ts +82 -20
  78. package/src/startup-time.e2e.test.ts +372 -0
  79. package/src/store.ts +8 -0
  80. package/src/system-message.ts +10 -1
  81. package/src/task-runner.ts +9 -22
  82. package/src/task-schedule.ts +15 -0
  83. package/src/thread-message-queue.e2e.test.ts +14 -1
  84. package/src/undo-redo.e2e.test.ts +207 -0
  85. package/src/utils.ts +7 -0
  86. package/src/voice-attachment.ts +51 -0
  87. package/src/voice-handler.ts +15 -7
  88. package/src/voice-message.e2e.test.ts +95 -0
  89. package/src/voice.test.ts +36 -0
  90. package/src/onboarding-tutorial-plugin.ts +0 -93
@@ -0,0 +1,500 @@
1
+ // Tests for the Discord WS + REST reverse proxy.
2
+ // Spins up a mock Discord server (HTTP + WS) and verifies the proxy
3
+ // correctly forwards REST requests, rewrites /gateway/bot URLs,
4
+ // pipes WebSocket frames bidirectionally, and propagates close events.
5
+ import { describe, test, expect, beforeAll, afterAll } from 'vitest';
6
+ import http from 'node:http';
7
+ import WebSocket, { WebSocketServer } from 'ws';
8
+ import { createDiscordRestProxyHandler, createGatewayUpgradeHandler, deriveGatewayBaseFromRest, isLoopbackAddress, } from './discord-ws-proxy.js';
9
+ // ── Test infrastructure ──────────────────────────────────────────────────
10
+ const TEST_GATEWAY_TOKEN = 'test-client-id:test-secret';
11
+ let mockDiscordServer;
12
+ let mockDiscordPort;
13
+ let mockDiscordWss;
14
+ // Track active WS connections for cleanup
15
+ const mockWsConnections = [];
16
+ beforeAll(async () => {
17
+ // Start mock Discord server (simulates the gateway-proxy REST API)
18
+ mockDiscordWss = new WebSocketServer({ noServer: true });
19
+ mockDiscordServer = http.createServer((req, res) => {
20
+ const url = new URL(req.url || '/', 'http://localhost');
21
+ // Mock GET /api/v10/gateway/bot
22
+ if (url.pathname === '/api/v10/gateway/bot') {
23
+ const body = JSON.stringify({
24
+ url: `ws://127.0.0.1:${mockDiscordPort}/gateway-ws`,
25
+ shards: 1,
26
+ session_start_limit: {
27
+ total: 1000,
28
+ remaining: 999,
29
+ reset_after: 14400000,
30
+ max_concurrency: 1,
31
+ },
32
+ });
33
+ res.writeHead(200, {
34
+ 'content-type': 'application/json',
35
+ 'x-ratelimit-remaining': '39',
36
+ });
37
+ res.end(body);
38
+ return;
39
+ }
40
+ // Mock generic REST endpoint
41
+ if (url.pathname.startsWith('/api/')) {
42
+ const chunks = [];
43
+ req.on('data', (c) => {
44
+ chunks.push(c);
45
+ });
46
+ req.on('end', () => {
47
+ const requestBody = chunks.length > 0 ? Buffer.concat(chunks).toString() : undefined;
48
+ res.writeHead(200, { 'content-type': 'application/json' });
49
+ res.end(JSON.stringify({
50
+ echo: true,
51
+ method: req.method,
52
+ path: url.pathname,
53
+ body: requestBody,
54
+ authorization: req.headers['authorization'],
55
+ }));
56
+ });
57
+ return;
58
+ }
59
+ res.writeHead(404);
60
+ res.end();
61
+ });
62
+ // Handle WebSocket upgrades on mock Discord server
63
+ mockDiscordServer.on('upgrade', (req, socket, head) => {
64
+ mockDiscordWss.handleUpgrade(req, socket, head, (ws) => {
65
+ mockWsConnections.push(ws);
66
+ mockDiscordWss.emit('connection', ws, req);
67
+ });
68
+ });
69
+ await new Promise((resolve) => {
70
+ mockDiscordServer.listen(0, '127.0.0.1', () => {
71
+ const addr = mockDiscordServer.address();
72
+ mockDiscordPort = typeof addr === 'object' && addr ? addr.port : 0;
73
+ resolve();
74
+ });
75
+ });
76
+ });
77
+ afterAll(async () => {
78
+ for (const ws of mockWsConnections) {
79
+ if (ws.readyState === WebSocket.OPEN) {
80
+ ws.close();
81
+ }
82
+ }
83
+ mockWsConnections.length = 0;
84
+ await new Promise((r) => {
85
+ mockDiscordWss.close(() => {
86
+ mockDiscordServer.close(() => {
87
+ r();
88
+ });
89
+ });
90
+ });
91
+ });
92
+ // ── REST proxy tests ─────────────────────────────────────────────────────
93
+ describe('REST proxy', () => {
94
+ test('forwards GET request to upstream and returns response', async () => {
95
+ const { server, port } = await createTestProxy();
96
+ const res = await fetch(`http://127.0.0.1:${port}/api/v10/channels/123/messages`, { headers: { authorization: 'Bot test-token' } });
97
+ const body = await res.json();
98
+ expect(res.status).toBe(200);
99
+ expect(body.echo).toBe(true);
100
+ expect(body.method).toBe('GET');
101
+ expect(body.path).toBe('/api/v10/channels/123/messages');
102
+ expect(body.authorization).toBe('Bot test-token');
103
+ await closeServer(server);
104
+ });
105
+ test('forwards POST request with body', async () => {
106
+ const { server, port } = await createTestProxy();
107
+ const res = await fetch(`http://127.0.0.1:${port}/api/v10/channels/123/messages`, {
108
+ method: 'POST',
109
+ headers: {
110
+ 'content-type': 'application/json',
111
+ authorization: 'Bot test-token',
112
+ },
113
+ body: JSON.stringify({ content: 'hello' }),
114
+ });
115
+ const body = await res.json();
116
+ expect(body.method).toBe('POST');
117
+ expect(body.body).toBe('{"content":"hello"}');
118
+ await closeServer(server);
119
+ });
120
+ test('returns 404 for non-/api/ paths', async () => {
121
+ const { server, port } = await createTestProxy();
122
+ const res = await fetch(`http://127.0.0.1:${port}/something-else`);
123
+ expect(res.status).toBe(404);
124
+ await closeServer(server);
125
+ });
126
+ test('rewrites /gateway/bot URL to local WS proxy', async () => {
127
+ const { server, port } = await createTestProxy();
128
+ const res = await fetch(`http://127.0.0.1:${port}/api/v10/gateway/bot`, { headers: { authorization: 'Bot test-token' } });
129
+ const body = await res.json();
130
+ // The url should be rewritten to point to local proxy
131
+ expect(body.url).toBe(`ws://127.0.0.1:${port}/gateway`);
132
+ // Other fields preserved
133
+ expect(body.shards).toBe(1);
134
+ // Rate limit headers forwarded
135
+ expect(res.headers.get('x-ratelimit-remaining')).toBe('39');
136
+ await closeServer(server);
137
+ });
138
+ });
139
+ // ── WebSocket bridge tests ───────────────────────────────────────────────
140
+ describe('WebSocket bridge', () => {
141
+ test('bidirectional frame forwarding between gateway and local', async () => {
142
+ const { server, port } = await createTestProxyWithWs();
143
+ // Gateway connects first
144
+ const gateway = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
145
+ headers: { 'x-kimaki-role': 'gateway', authorization: TEST_GATEWAY_TOKEN },
146
+ });
147
+ await waitForOpen(gateway);
148
+ // Local (discord.js) connects
149
+ const local = new WebSocket(`ws://127.0.0.1:${port}/gateway?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`);
150
+ await waitForOpen(local);
151
+ const localMessages = [];
152
+ const gatewayMessages = [];
153
+ local.on('message', (data) => {
154
+ localMessages.push(data.toString());
155
+ });
156
+ gateway.on('message', (data) => {
157
+ gatewayMessages.push(data.toString());
158
+ });
159
+ // Gateway → Local
160
+ gateway.send('event-from-gateway');
161
+ await waitFor(() => localMessages.length >= 1, 2000);
162
+ expect(localMessages).toEqual(['event-from-gateway']);
163
+ // Local → Gateway
164
+ local.send('identify-from-local');
165
+ await waitFor(() => gatewayMessages.length >= 1, 2000);
166
+ expect(gatewayMessages).toEqual(['identify-from-local']);
167
+ gateway.close();
168
+ local.close();
169
+ await closeServer(server);
170
+ });
171
+ test('local connects before gateway', async () => {
172
+ const { server, port } = await createTestProxyWithWs();
173
+ // Local connects first
174
+ const local = new WebSocket(`ws://127.0.0.1:${port}/gateway?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`);
175
+ await waitForOpen(local);
176
+ const localMessages = [];
177
+ local.on('message', (data) => {
178
+ localMessages.push(data.toString());
179
+ });
180
+ // Gateway connects after
181
+ const gateway = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
182
+ headers: { 'x-kimaki-role': 'gateway', authorization: TEST_GATEWAY_TOKEN },
183
+ });
184
+ await waitForOpen(gateway);
185
+ gateway.send('hello-from-gateway');
186
+ await waitFor(() => localMessages.length >= 1, 2000);
187
+ expect(localMessages).toEqual(['hello-from-gateway']);
188
+ gateway.close();
189
+ local.close();
190
+ await closeServer(server);
191
+ });
192
+ test('gateway close propagates to local', async () => {
193
+ const { server, port } = await createTestProxyWithWs();
194
+ const gateway = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
195
+ headers: { 'x-kimaki-role': 'gateway', authorization: TEST_GATEWAY_TOKEN },
196
+ });
197
+ const local = new WebSocket(`ws://127.0.0.1:${port}/gateway?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`);
198
+ await waitForOpen(gateway);
199
+ await waitForOpen(local);
200
+ const localClosed = new Promise((resolve) => {
201
+ local.on('close', (code, reason) => {
202
+ resolve({ code, reason: reason.toString() });
203
+ });
204
+ });
205
+ gateway.close(4001, 'gateway shutdown');
206
+ const result = await localClosed;
207
+ expect(result.code).toBe(4001);
208
+ expect(result.reason).toBe('gateway shutdown');
209
+ await closeServer(server);
210
+ });
211
+ test('local close propagates to gateway', async () => {
212
+ const { server, port } = await createTestProxyWithWs();
213
+ const gateway = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
214
+ headers: { 'x-kimaki-role': 'gateway', authorization: TEST_GATEWAY_TOKEN },
215
+ });
216
+ const local = new WebSocket(`ws://127.0.0.1:${port}/gateway?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`);
217
+ await waitForOpen(gateway);
218
+ await waitForOpen(local);
219
+ const gatewayClosed = new Promise((resolve) => {
220
+ gateway.on('close', (code, reason) => {
221
+ resolve({ code, reason: reason.toString() });
222
+ });
223
+ });
224
+ local.close(4000, 'client done');
225
+ const result = await gatewayClosed;
226
+ expect(result.code).toBe(4000);
227
+ expect(result.reason).toBe('client done');
228
+ await closeServer(server);
229
+ });
230
+ test('rejects upgrade on non-/gateway path', async () => {
231
+ const { server, port } = await createTestProxyWithWs();
232
+ const client = new WebSocket(`ws://127.0.0.1:${port}/other-path`);
233
+ const error = await new Promise((resolve) => {
234
+ client.on('error', (err) => {
235
+ resolve(err);
236
+ });
237
+ });
238
+ expect(error).toBeDefined();
239
+ await closeServer(server);
240
+ });
241
+ test('rejects gateway connection with wrong authorization', async () => {
242
+ const { server, port } = await createTestProxyWithWs();
243
+ const client = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
244
+ headers: { 'x-kimaki-role': 'gateway', authorization: 'wrong-token' },
245
+ });
246
+ const error = await new Promise((resolve) => {
247
+ client.on('error', (err) => {
248
+ resolve(err);
249
+ });
250
+ });
251
+ expect(error).toBeDefined();
252
+ expect(error.message).toContain('401');
253
+ await closeServer(server);
254
+ });
255
+ test('new gateway connection replaces waiting one', async () => {
256
+ const { server, port } = await createTestProxyWithWs();
257
+ // First gateway connects and waits
258
+ const gateway1 = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
259
+ headers: { 'x-kimaki-role': 'gateway', authorization: TEST_GATEWAY_TOKEN },
260
+ });
261
+ await waitForOpen(gateway1);
262
+ const gateway1Closed = new Promise((resolve) => {
263
+ gateway1.on('close', (code) => {
264
+ resolve(code);
265
+ });
266
+ });
267
+ // Second gateway replaces it
268
+ const gateway2 = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
269
+ headers: { 'x-kimaki-role': 'gateway', authorization: TEST_GATEWAY_TOKEN },
270
+ });
271
+ await waitForOpen(gateway2);
272
+ // First should be closed
273
+ const code = await gateway1Closed;
274
+ expect(code).toBe(1000);
275
+ // Now local connects and pairs with gateway2
276
+ const local = new WebSocket(`ws://127.0.0.1:${port}/gateway?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`);
277
+ await waitForOpen(local);
278
+ const localMessages = [];
279
+ local.on('message', (data) => {
280
+ localMessages.push(data.toString());
281
+ });
282
+ gateway2.send('from-gateway2');
283
+ await waitFor(() => localMessages.length >= 1, 2000);
284
+ expect(localMessages).toEqual(['from-gateway2']);
285
+ gateway2.close();
286
+ local.close();
287
+ await closeServer(server);
288
+ });
289
+ test('multiple sequential bridge sessions', async () => {
290
+ const { server, port } = await createTestProxyWithWs();
291
+ // First session
292
+ const g1 = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
293
+ headers: { 'x-kimaki-role': 'gateway', authorization: TEST_GATEWAY_TOKEN },
294
+ });
295
+ const l1 = new WebSocket(`ws://127.0.0.1:${port}/gateway?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`);
296
+ await waitForOpen(g1);
297
+ await waitForOpen(l1);
298
+ const l1Messages = [];
299
+ l1.on('message', (data) => {
300
+ l1Messages.push(data.toString());
301
+ });
302
+ g1.send('session1');
303
+ await waitFor(() => l1Messages.length >= 1, 2000);
304
+ expect(l1Messages).toEqual(['session1']);
305
+ // Close first session
306
+ g1.close();
307
+ l1.close();
308
+ await waitFor(() => g1.readyState === WebSocket.CLOSED, 2000);
309
+ await waitFor(() => l1.readyState === WebSocket.CLOSED, 2000);
310
+ // Second session should work fine
311
+ const g2 = new WebSocket(`ws://127.0.0.1:${port}/gateway`, {
312
+ headers: { 'x-kimaki-role': 'gateway', authorization: TEST_GATEWAY_TOKEN },
313
+ });
314
+ const l2 = new WebSocket(`ws://127.0.0.1:${port}/gateway?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`);
315
+ await waitForOpen(g2);
316
+ await waitForOpen(l2);
317
+ const l2Messages = [];
318
+ l2.on('message', (data) => {
319
+ l2Messages.push(data.toString());
320
+ });
321
+ g2.send('session2');
322
+ await waitFor(() => l2Messages.length >= 1, 2000);
323
+ expect(l2Messages).toEqual(['session2']);
324
+ g2.close();
325
+ l2.close();
326
+ await closeServer(server);
327
+ });
328
+ });
329
+ // ── Gateway URL derivation tests ─────────────────────────────────────────
330
+ describe('deriveGatewayBaseFromRest', () => {
331
+ test('discord.com returns gateway.discord.gg', () => {
332
+ expect(deriveGatewayBaseFromRest('https://discord.com')).toBe('wss://gateway.discord.gg');
333
+ });
334
+ test('gateway proxy swaps https to wss', () => {
335
+ expect(deriveGatewayBaseFromRest('https://discord-gateway.kimaki.xyz')).toBe('wss://discord-gateway.kimaki.xyz');
336
+ });
337
+ test('http proxy swaps to ws', () => {
338
+ expect(deriveGatewayBaseFromRest('http://127.0.0.1:8080')).toBe('ws://127.0.0.1:8080');
339
+ });
340
+ test('strips trailing slash', () => {
341
+ expect(deriveGatewayBaseFromRest('https://proxy.example.com/')).toBe('wss://proxy.example.com');
342
+ });
343
+ test('invalid URL falls back to default', () => {
344
+ expect(deriveGatewayBaseFromRest('not-a-url')).toBe('wss://gateway.discord.gg');
345
+ });
346
+ });
347
+ describe('isLoopbackAddress', () => {
348
+ test('returns true for loopback variants', () => {
349
+ expect(isLoopbackAddress('::1')).toBe(true);
350
+ expect(isLoopbackAddress('127.0.0.1')).toBe(true);
351
+ expect(isLoopbackAddress('127.12.34.56')).toBe(true);
352
+ expect(isLoopbackAddress('::ffff:127.0.0.1')).toBe(true);
353
+ });
354
+ test('returns false for non-loopback addresses', () => {
355
+ expect(isLoopbackAddress('192.168.1.5')).toBe(false);
356
+ expect(isLoopbackAddress('10.0.0.1')).toBe(false);
357
+ expect(isLoopbackAddress('::ffff:192.168.1.5')).toBe(false);
358
+ expect(isLoopbackAddress('')).toBe(false);
359
+ });
360
+ });
361
+ // ── Helpers ──────────────────────────────────────────────────────────────
362
+ /**
363
+ * Create a test proxy server with REST handler only (pointing to mock Discord).
364
+ */
365
+ async function createTestProxy() {
366
+ const handler = createMockRestProxyHandler();
367
+ const server = http.createServer(handler);
368
+ const port = await new Promise((resolve) => {
369
+ server.listen(0, '127.0.0.1', () => {
370
+ const addr = server.address();
371
+ resolve(typeof addr === 'object' && addr ? addr.port : 0);
372
+ });
373
+ });
374
+ return { server, port };
375
+ }
376
+ /**
377
+ * Create a test proxy server with REST and WS bridge handling.
378
+ */
379
+ async function createTestProxyWithWs() {
380
+ const handler = createMockRestProxyHandler();
381
+ const server = http.createServer(handler);
382
+ // Mount the real bridge handler
383
+ const upgradeHandler = createGatewayUpgradeHandler({
384
+ getGatewayToken: () => TEST_GATEWAY_TOKEN,
385
+ });
386
+ server.on('upgrade', (req, socket, head) => {
387
+ upgradeHandler(req, socket, head);
388
+ });
389
+ const port = await new Promise((resolve) => {
390
+ server.listen(0, '127.0.0.1', () => {
391
+ const addr = server.address();
392
+ resolve(typeof addr === 'object' && addr ? addr.port : 0);
393
+ });
394
+ });
395
+ return { server, port };
396
+ }
397
+ /**
398
+ * REST proxy handler for tests — forwards to mock Discord server.
399
+ */
400
+ function createMockRestProxyHandler() {
401
+ return async (req, res) => {
402
+ const url = new URL(req.url || '/', 'http://localhost');
403
+ if (!url.pathname.startsWith('/api/')) {
404
+ res.writeHead(404);
405
+ res.end();
406
+ return;
407
+ }
408
+ const upstreamUrl = `http://127.0.0.1:${mockDiscordPort}${url.pathname}${url.search}`;
409
+ const headers = {};
410
+ for (const [key, value] of Object.entries(req.headers)) {
411
+ if (key === 'host' || key === 'connection') {
412
+ continue;
413
+ }
414
+ if (typeof value === 'string') {
415
+ headers[key] = value;
416
+ }
417
+ }
418
+ const chunks = [];
419
+ for await (const chunk of req) {
420
+ chunks.push(chunk);
421
+ }
422
+ const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined;
423
+ const upstreamRes = await fetch(upstreamUrl, {
424
+ method: req.method || 'GET',
425
+ headers,
426
+ body,
427
+ }).catch((err) => {
428
+ res.writeHead(502);
429
+ res.end(err.message);
430
+ return null;
431
+ });
432
+ if (!upstreamRes) {
433
+ return;
434
+ }
435
+ // Intercept /gateway/bot for URL rewrite
436
+ const isGatewayBot = url.pathname.endsWith('/gateway/bot');
437
+ if (isGatewayBot && upstreamRes.ok) {
438
+ const responseBody = await upstreamRes.json();
439
+ const addr = res.socket?.localPort;
440
+ responseBody.url = `ws://127.0.0.1:${addr}/gateway`;
441
+ const rewritten = JSON.stringify(responseBody);
442
+ const responseHeaders = {
443
+ 'content-type': 'application/json',
444
+ };
445
+ upstreamRes.headers.forEach((value, key) => {
446
+ if (key.startsWith('x-ratelimit') || key === 'retry-after') {
447
+ responseHeaders[key] = value;
448
+ }
449
+ });
450
+ res.writeHead(upstreamRes.status, responseHeaders);
451
+ res.end(rewritten);
452
+ return;
453
+ }
454
+ const responseBody = Buffer.from(await upstreamRes.arrayBuffer());
455
+ const responseHeaders = {};
456
+ upstreamRes.headers.forEach((value, key) => {
457
+ if (key !== 'transfer-encoding') {
458
+ responseHeaders[key] = value;
459
+ }
460
+ });
461
+ responseHeaders['content-length'] = responseBody.length.toString();
462
+ res.writeHead(upstreamRes.status, responseHeaders);
463
+ res.end(responseBody);
464
+ };
465
+ }
466
+ function closeServer(server) {
467
+ return new Promise((resolve) => {
468
+ server.close(() => {
469
+ resolve();
470
+ });
471
+ });
472
+ }
473
+ function sleep(ms) {
474
+ return new Promise((resolve) => {
475
+ setTimeout(resolve, ms);
476
+ });
477
+ }
478
+ function waitForOpen(ws) {
479
+ return new Promise((resolve, reject) => {
480
+ if (ws.readyState === WebSocket.OPEN) {
481
+ resolve();
482
+ return;
483
+ }
484
+ ws.on('open', () => {
485
+ resolve();
486
+ });
487
+ ws.on('error', (err) => {
488
+ reject(err);
489
+ });
490
+ });
491
+ }
492
+ async function waitFor(condition, timeoutMs) {
493
+ const start = Date.now();
494
+ while (!condition()) {
495
+ if (Date.now() - start > timeoutMs) {
496
+ throw new Error(`waitFor timed out after ${timeoutMs}ms`);
497
+ }
498
+ await sleep(20);
499
+ }
500
+ }
package/dist/errors.js CHANGED
@@ -147,7 +147,7 @@ export class NotFastForwardError extends createTaggedError({
147
147
  }
148
148
  export class ConflictingFilesError extends createTaggedError({
149
149
  name: 'ConflictingFilesError',
150
- message: 'Cannot merge: $target worktree has uncommitted changes in overlapping files',
150
+ message: 'Cannot merge: $target worktree has uncommitted changes in overlapping files. Commit changes in main worktree first, then run `/merge-worktree` again.',
151
151
  }) {
152
152
  }
153
153
  export class PushError extends createTaggedError({
@@ -0,0 +1,163 @@
1
+ // Persists Discord Gateway session data (session_id, resume_url, sequence)
2
+ // to disk so discord.js can RESUME instead of fresh IDENTIFY on restarts.
3
+ //
4
+ // RESUME skips the full Gateway handshake and guild hydration, saving
5
+ // 500-2000ms on cold starts. Critical for scale-to-zero where the bot
6
+ // process is killed and restarted frequently.
7
+ //
8
+ // Session data is stored as a JSON file in the kimaki data directory.
9
+ // It's ephemeral — if the file is missing or stale, discord.js falls
10
+ // back to IDENTIFY automatically.
11
+ //
12
+ // Implementation: uses the public `buildStrategy` hook in discord.js
13
+ // ClientOptions.ws. Inside buildStrategy, the @discordjs/ws manager's
14
+ // options.retrieveSessionInfo and options.updateSessionInfo callbacks
15
+ // are replaced with disk-backed versions BEFORE any shard connects.
16
+ // This is the approved extension point — no monkey-patching of private
17
+ // internals or method overrides needed.
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { SimpleShardingStrategy, } from '@discordjs/ws';
21
+ import { getDataDir } from './config.js';
22
+ import { createLogger, LogPrefix } from './logger.js';
23
+ const logger = createLogger(LogPrefix.DISCORD);
24
+ // Gateway sessions expire after 60 seconds of disconnection per Discord docs.
25
+ // Use a conservative 30s window — if the file is older than this, RESUME
26
+ // will likely fail anyway and discord.js falls back to IDENTIFY.
27
+ const MAX_SESSION_AGE_MS = 30_000;
28
+ function getSessionFilePath() {
29
+ return path.join(getDataDir(), 'gateway-session.json');
30
+ }
31
+ function loadPersistedSessions() {
32
+ const filePath = getSessionFilePath();
33
+ try {
34
+ const raw = fs.readFileSync(filePath, 'utf-8');
35
+ const parsed = JSON.parse(raw);
36
+ if (!parsed.shards || typeof parsed.savedAt !== 'number') {
37
+ return null;
38
+ }
39
+ if (Date.now() - parsed.savedAt > MAX_SESSION_AGE_MS) {
40
+ logger.log('Gateway session file too old, will IDENTIFY fresh');
41
+ return null;
42
+ }
43
+ return parsed;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ function savePersistedSessions(sessions) {
50
+ const filePath = getSessionFilePath();
51
+ try {
52
+ fs.writeFileSync(filePath, JSON.stringify(sessions), 'utf-8');
53
+ }
54
+ catch (error) {
55
+ logger.warn(`Failed to persist gateway session: ${error instanceof Error ? error.message : String(error)}`);
56
+ }
57
+ }
58
+ /**
59
+ * Creates a `buildStrategy` function for discord.js ClientOptions.ws
60
+ * that enables Gateway session persistence for RESUME on restarts.
61
+ *
62
+ * Usage:
63
+ * ```ts
64
+ * const client = new Client({
65
+ * ws: { buildStrategy: buildSessionResumeStrategy() },
66
+ * })
67
+ * ```
68
+ *
69
+ * How it works:
70
+ * - discord.js calls buildStrategy(manager) during WebSocketManager construction
71
+ * - We replace manager.options.retrieveSessionInfo/updateSessionInfo with
72
+ * disk-backed versions BEFORE any shard connects
73
+ * - On startup, persisted session data enables RESUME instead of IDENTIFY
74
+ * - On shutdown, flushPersist() writes the latest session data to disk
75
+ *
76
+ * Returns the buildStrategy function AND a flushPersist handle so the
77
+ * caller can ensure data is written on graceful shutdown.
78
+ */
79
+ export function buildSessionResumeStrategy() {
80
+ // In-memory cache of current session info per shard
81
+ const currentSessions = {};
82
+ // Load any persisted sessions from last run
83
+ const persisted = loadPersistedSessions();
84
+ if (persisted) {
85
+ Object.assign(currentSessions, persisted.shards);
86
+ const shardCount = Object.keys(persisted.shards).length;
87
+ logger.log(`Loaded ${shardCount} persisted gateway session(s) for RESUME (age: ${Math.round((Date.now() - persisted.savedAt) / 1000)}s)`);
88
+ }
89
+ // Debounced disk writer. updateSessionInfo fires on every Gateway dispatch
90
+ // (to update the sequence number), so writing on every call would thrash
91
+ // disk. Debounce to at most one write per second.
92
+ let pendingWrite = null;
93
+ const schedulePersist = () => {
94
+ if (frozen || pendingWrite) {
95
+ return;
96
+ }
97
+ pendingWrite = setTimeout(() => {
98
+ pendingWrite = null;
99
+ if (frozen) {
100
+ return;
101
+ }
102
+ savePersistedSessions({
103
+ shards: currentSessions,
104
+ savedAt: Date.now(),
105
+ });
106
+ }, 1_000);
107
+ };
108
+ // After flush, freeze persistence so destroy()'s updateSessionInfo(null)
109
+ // doesn't overwrite the saved session data via schedulePersist.
110
+ let frozen = false;
111
+ const flushPersist = () => {
112
+ if (pendingWrite) {
113
+ clearTimeout(pendingWrite);
114
+ pendingWrite = null;
115
+ }
116
+ const shardCount = Object.keys(currentSessions).length;
117
+ if (shardCount > 0) {
118
+ savePersistedSessions({
119
+ shards: currentSessions,
120
+ savedAt: Date.now(),
121
+ });
122
+ logger.log(`Persisted ${shardCount} gateway session(s) for next RESUME`);
123
+ }
124
+ frozen = true;
125
+ };
126
+ const buildStrategy = (manager) => {
127
+ // Wrap the original callbacks from discord.js (which store in-memory
128
+ // on the discord.js WebSocketShard.sessionInfo property).
129
+ const originalRetrieve = manager.options.retrieveSessionInfo;
130
+ const originalUpdate = manager.options.updateSessionInfo;
131
+ // Replace retrieveSessionInfo: try persisted data first, then in-memory.
132
+ // This runs BEFORE shard.connect() sends IDENTIFY or RESUME, so the
133
+ // persisted session is available for the first connection attempt.
134
+ manager.options.retrieveSessionInfo = (shardId) => {
135
+ // Check in-memory cache (populated from disk on startup, or from
136
+ // previous updateSessionInfo calls during this process lifetime)
137
+ const cached = currentSessions[String(shardId)];
138
+ if (cached) {
139
+ logger.log(`[shard ${shardId}] Attempting RESUME (session: ${cached.sessionId.slice(0, 8)}..., seq: ${cached.sequence})`);
140
+ return cached;
141
+ }
142
+ // Fall back to discord.js default (reads shard.sessionInfo)
143
+ return originalRetrieve(shardId);
144
+ };
145
+ // Replace updateSessionInfo: persist to disk alongside in-memory update.
146
+ manager.options.updateSessionInfo = (shardId, info) => {
147
+ // Call original so discord.js shard.sessionInfo stays in sync
148
+ originalUpdate(shardId, info);
149
+ if (info) {
150
+ currentSessions[String(shardId)] = info;
151
+ schedulePersist();
152
+ }
153
+ else {
154
+ // null = session invalidated (logout or invalid session).
155
+ // Only delete this shard's data, not other shards'.
156
+ delete currentSessions[String(shardId)];
157
+ schedulePersist();
158
+ }
159
+ };
160
+ return new SimpleShardingStrategy(manager);
161
+ };
162
+ return { buildStrategy, flushPersist };
163
+ }