@gajae-code/coding-agent 0.7.1 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/dist/types/cli/notify-cli.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +39 -2
- package/dist/types/extensibility/shared-events.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +1 -1
- package/dist/types/lsp/types.d.ts +2 -0
- package/dist/types/notifications/attachment-registry.d.ts +17 -0
- package/dist/types/notifications/chat-adapters.d.ts +9 -0
- package/dist/types/notifications/config.d.ts +9 -1
- package/dist/types/notifications/engine.d.ts +59 -0
- package/dist/types/notifications/managed-daemon.d.ts +48 -0
- package/dist/types/notifications/telegram-daemon.d.ts +19 -0
- package/dist/types/notifications/threaded-inbound.d.ts +19 -0
- package/dist/types/notifications/threaded-render.d.ts +6 -1
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/tools/fetch.d.ts +23 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/telegram-send.d.ts +32 -0
- package/dist/types/web/insane/bridge.d.ts +103 -0
- package/dist/types/web/insane/url-guard.d.ts +22 -0
- package/dist/types/web/search/provider.d.ts +18 -1
- package/dist/types/web/search/providers/insane.d.ts +53 -0
- package/dist/types/web/search/providers/text-citations.d.ts +23 -0
- package/dist/types/web/search/types.d.ts +12 -4
- package/package.json +10 -8
- package/scripts/verify-insane-vendor.ts +132 -0
- package/src/cli/args.ts +1 -1
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/notify-cli.ts +152 -5
- package/src/commands/team.ts +1 -1
- package/src/config/settings-schema.ts +30 -1
- package/src/defaults/gjc/skills/ralplan/SKILL.md +11 -4
- package/src/extensibility/shared-events.ts +1 -0
- package/src/gjc-runtime/launch-tmux.ts +17 -3
- package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
- package/src/gjc-runtime/ralplan-runtime.ts +2 -2
- package/src/gjc-runtime/workflow-manifest.generated.json +29 -0
- package/src/gjc-runtime/workflow-manifest.ts +7 -2
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/lsp/config.ts +16 -3
- package/src/lsp/defaults.json +7 -0
- package/src/lsp/types.ts +2 -0
- package/src/modes/controllers/event-controller.ts +15 -0
- package/src/modes/interactive-mode.ts +46 -2
- package/src/modes/utils/context-usage.ts +2 -2
- package/src/notifications/attachment-registry.ts +23 -0
- package/src/notifications/chat-adapters.ts +147 -0
- package/src/notifications/config.ts +23 -2
- package/src/notifications/engine.ts +100 -0
- package/src/notifications/index.ts +180 -38
- package/src/notifications/managed-daemon.ts +163 -0
- package/src/notifications/telegram-daemon.ts +235 -14
- package/src/notifications/threaded-inbound.ts +60 -4
- package/src/notifications/threaded-render.ts +20 -2
- package/src/session/agent-session.ts +82 -51
- package/src/tools/fetch.ts +78 -1
- package/src/tools/index.ts +3 -0
- package/src/tools/telegram-send.ts +137 -0
- package/src/web/insane/bridge.ts +350 -0
- package/src/web/insane/url-guard.ts +155 -0
- package/src/web/search/provider.ts +77 -18
- package/src/web/search/providers/anthropic.ts +70 -3
- package/src/web/search/providers/codex.ts +1 -119
- package/src/web/search/providers/gemini.ts +99 -0
- package/src/web/search/providers/insane.ts +551 -0
- package/src/web/search/providers/openai-compatible.ts +66 -32
- package/src/web/search/providers/text-citations.ts +111 -0
- package/src/web/search/types.ts +13 -2
- package/vendor/insane-search/LICENSE +21 -0
- package/vendor/insane-search/MANIFEST.json +24 -0
- package/vendor/insane-search/engine/__init__.py +23 -0
- package/vendor/insane-search/engine/__main__.py +128 -0
- package/vendor/insane-search/engine/bias_check.py +183 -0
- package/vendor/insane-search/engine/executor.py +254 -0
- package/vendor/insane-search/engine/fetch_chain.py +725 -0
- package/vendor/insane-search/engine/learning.py +175 -0
- package/vendor/insane-search/engine/phase0.py +214 -0
- package/vendor/insane-search/engine/safety.py +91 -0
- package/vendor/insane-search/engine/templates/package.json +11 -0
- package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
- package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
- package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
- package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
- package/vendor/insane-search/engine/tests/test_u1.py +200 -0
- package/vendor/insane-search/engine/tests/test_u4.py +131 -0
- package/vendor/insane-search/engine/tests/test_u5.py +163 -0
- package/vendor/insane-search/engine/tests/test_u7.py +124 -0
- package/vendor/insane-search/engine/transport.py +211 -0
- package/vendor/insane-search/engine/url_transforms.py +98 -0
- package/vendor/insane-search/engine/validators.py +331 -0
- package/vendor/insane-search/engine/waf_detector.py +214 -0
- package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
|
@@ -24,11 +24,13 @@ import * as fs from "node:fs";
|
|
|
24
24
|
import * as os from "node:os";
|
|
25
25
|
import * as path from "node:path";
|
|
26
26
|
import { promisify } from "node:util";
|
|
27
|
+
import type { ImageContent, TextContent } from "@gajae-code/ai";
|
|
27
28
|
import { NotificationServer } from "@gajae-code/natives";
|
|
28
29
|
import { logger } from "@gajae-code/utils";
|
|
29
30
|
import { Settings } from "../config/settings";
|
|
30
31
|
import type { ExtensionCommandContext, ExtensionContext, ExtensionFactory } from "../extensibility/extensions";
|
|
31
32
|
import { registerAskAnswerSource } from "../tools/ask-answer-registry";
|
|
33
|
+
import { registerTelegramFileSink } from "./attachment-registry";
|
|
32
34
|
import {
|
|
33
35
|
getNotificationConfig,
|
|
34
36
|
isGloballyConfigured,
|
|
@@ -136,6 +138,8 @@ interface SessionRuntime {
|
|
|
136
138
|
pendingInteractive: Map<string, PendingInteractiveAsk>;
|
|
137
139
|
/** Deregisters this session's ask answer source. */
|
|
138
140
|
disposeAnswerSource: () => void;
|
|
141
|
+
/** Deregisters this session's Telegram file sink. */
|
|
142
|
+
disposeFileSink: () => void;
|
|
139
143
|
redact: boolean;
|
|
140
144
|
sessionTag: string;
|
|
141
145
|
/** Whether the agent loop is currently running (drives the typing indicator). */
|
|
@@ -157,6 +161,16 @@ interface ResolvedSettings {
|
|
|
157
161
|
|
|
158
162
|
const defaultConfig: NotificationConfig = {
|
|
159
163
|
enabled: false,
|
|
164
|
+
botToken: undefined,
|
|
165
|
+
chatId: undefined,
|
|
166
|
+
discord: {
|
|
167
|
+
botToken: undefined,
|
|
168
|
+
channelId: undefined,
|
|
169
|
+
},
|
|
170
|
+
slack: {
|
|
171
|
+
botToken: undefined,
|
|
172
|
+
channelId: undefined,
|
|
173
|
+
},
|
|
160
174
|
redact: false,
|
|
161
175
|
verbosity: "lean",
|
|
162
176
|
idleTimeoutMs: 60_000,
|
|
@@ -228,6 +242,56 @@ function mapAnswerToGate(
|
|
|
228
242
|
return { selected: [] };
|
|
229
243
|
}
|
|
230
244
|
|
|
245
|
+
/** Register the interactive `ask` answer source for a session (the ask tool
|
|
246
|
+
* races the local UI against a remote reply). Returns the deregister disposer. */
|
|
247
|
+
function registerInteractiveAnswerSource(
|
|
248
|
+
id: string,
|
|
249
|
+
server: NotificationServer,
|
|
250
|
+
pendingInteractive: Map<string, PendingInteractiveAsk>,
|
|
251
|
+
redact: boolean,
|
|
252
|
+
tag: string,
|
|
253
|
+
): () => void {
|
|
254
|
+
return registerAskAnswerSource(id, {
|
|
255
|
+
awaitAnswer(question, options, signal) {
|
|
256
|
+
if (signal?.aborted) return Promise.resolve(undefined);
|
|
257
|
+
const askId = `ask:${crypto.randomUUID()}`;
|
|
258
|
+
try {
|
|
259
|
+
server.registerAsk(
|
|
260
|
+
JSON.stringify(
|
|
261
|
+
notificationActionPayload(
|
|
262
|
+
{ id: askId, kind: "ask", sessionId: id, question, options },
|
|
263
|
+
{ redact, sessionTag: tag },
|
|
264
|
+
),
|
|
265
|
+
),
|
|
266
|
+
true,
|
|
267
|
+
);
|
|
268
|
+
} catch (e) {
|
|
269
|
+
logger.warn(`notifications: registerAsk failed: ${String(e)}`);
|
|
270
|
+
return Promise.resolve(undefined);
|
|
271
|
+
}
|
|
272
|
+
return new Promise<string | undefined>(resolve => {
|
|
273
|
+
pendingInteractive.set(askId, { resolve, options });
|
|
274
|
+
signal?.addEventListener("abort", () => {
|
|
275
|
+
if (!pendingInteractive.delete(askId)) return;
|
|
276
|
+
// Local UI answered: mark the remote action resolved-locally.
|
|
277
|
+
try {
|
|
278
|
+
server.resolveLocal(askId, undefined);
|
|
279
|
+
} catch {}
|
|
280
|
+
resolve(undefined);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Extract the session id from a `<timestamp>_<uuid>.jsonl` session file path. */
|
|
288
|
+
function sessionIdFromFile(file: string | undefined): string | undefined {
|
|
289
|
+
if (!file) return undefined;
|
|
290
|
+
const base = path.basename(file).replace(/\.jsonl$/, "");
|
|
291
|
+
const underscore = base.indexOf("_");
|
|
292
|
+
return underscore >= 0 ? base.slice(underscore + 1) : undefined;
|
|
293
|
+
}
|
|
294
|
+
|
|
231
295
|
export const createNotificationsExtension: ExtensionFactory = api => {
|
|
232
296
|
const runtimes = new Map<string, SessionRuntime>();
|
|
233
297
|
const disabledSessions = new Set<string>();
|
|
@@ -239,6 +303,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
239
303
|
runtimes.delete(id);
|
|
240
304
|
try {
|
|
241
305
|
rt.disposeAnswerSource();
|
|
306
|
+
rt.disposeFileSink();
|
|
242
307
|
} catch {}
|
|
243
308
|
// Resolve any still-pending interactive asks so the ask tool is not left hanging.
|
|
244
309
|
for (const pending of rt.pendingInteractive.values()) pending.resolve(undefined);
|
|
@@ -271,6 +336,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
271
336
|
const pendingInteractive = new Map<string, PendingInteractiveAsk>();
|
|
272
337
|
const tag = sessionTag(id);
|
|
273
338
|
const redact = cfg.redact;
|
|
339
|
+
let runtime: SessionRuntime | undefined;
|
|
274
340
|
|
|
275
341
|
// The SDK can always answer now (interactive via the answer source, or the
|
|
276
342
|
// unattended gate), so the endpoint advertises a resolver.
|
|
@@ -317,23 +383,34 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
317
383
|
// thread (forwarded by the daemon over the WS, fail-closed at the daemon).
|
|
318
384
|
server.onInbound((err, inbound) => {
|
|
319
385
|
if (err || !inbound) return;
|
|
320
|
-
if (inbound.kind === "user_message"
|
|
386
|
+
if (inbound.kind === "user_message") {
|
|
321
387
|
// Inject as a user turn (steers/continues the agent; the resulting
|
|
322
388
|
// turn streams back via the turn_end handler even when not idle).
|
|
323
389
|
// Record the update id so it can be acked as "consumed" on the next
|
|
324
390
|
// turn_start, and steer (vs start a fresh turn) when already busy.
|
|
325
|
-
const
|
|
326
|
-
|
|
391
|
+
const text = inbound.text ?? "";
|
|
392
|
+
const images = inbound.images ?? [];
|
|
393
|
+
if (!text && images.length === 0) return;
|
|
394
|
+
if (runtime && typeof inbound.updateId === "number") runtime.pendingInbound.add(inbound.updateId);
|
|
395
|
+
const content: string | (TextContent | ImageContent)[] =
|
|
396
|
+
images.length > 0
|
|
397
|
+
? [
|
|
398
|
+
...(text ? [{ type: "text", text } as TextContent] : []),
|
|
399
|
+
...images.map(
|
|
400
|
+
img =>
|
|
401
|
+
({ type: "image", data: img.data, mimeType: img.mime ?? "image/jpeg" }) as ImageContent,
|
|
402
|
+
),
|
|
403
|
+
]
|
|
404
|
+
: text;
|
|
327
405
|
try {
|
|
328
|
-
api.sendUserMessage(
|
|
406
|
+
api.sendUserMessage(content, runtime?.busy ? { deliverAs: "steer" } : undefined);
|
|
329
407
|
} catch (e) {
|
|
330
408
|
logger.warn(`notifications: sendUserMessage failed: ${String(e)}`);
|
|
331
409
|
}
|
|
332
410
|
return;
|
|
333
411
|
}
|
|
334
412
|
if (inbound.kind === "config_command") {
|
|
335
|
-
|
|
336
|
-
if (rt && typeof inbound.redact === "boolean") rt.redact = inbound.redact;
|
|
413
|
+
if (runtime && typeof inbound.redact === "boolean") runtime.redact = inbound.redact;
|
|
337
414
|
}
|
|
338
415
|
});
|
|
339
416
|
|
|
@@ -341,48 +418,37 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
341
418
|
const endpoint = await server.start();
|
|
342
419
|
|
|
343
420
|
// Interactive answer source: the ask tool races the local UI against this.
|
|
344
|
-
const disposeAnswerSource =
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
return new Promise<string | undefined>(resolve => {
|
|
363
|
-
pendingInteractive.set(askId, { resolve, options });
|
|
364
|
-
signal?.addEventListener("abort", () => {
|
|
365
|
-
if (!pendingInteractive.delete(askId)) return;
|
|
366
|
-
// Local UI answered: mark the remote action resolved-locally.
|
|
367
|
-
try {
|
|
368
|
-
server.resolveLocal(askId, undefined);
|
|
369
|
-
} catch {}
|
|
370
|
-
resolve(undefined);
|
|
371
|
-
});
|
|
372
|
-
});
|
|
373
|
-
},
|
|
421
|
+
const disposeAnswerSource = registerInteractiveAnswerSource(id, server, pendingInteractive, redact, tag);
|
|
422
|
+
const disposeFileSink = registerTelegramFileSink(id, async file => {
|
|
423
|
+
try {
|
|
424
|
+
const data = await fs.promises.readFile(file.path);
|
|
425
|
+
server.pushFrame(
|
|
426
|
+
JSON.stringify({
|
|
427
|
+
type: "file_attachment",
|
|
428
|
+
sessionId: id,
|
|
429
|
+
name: path.basename(file.path),
|
|
430
|
+
data: data.toString("base64"),
|
|
431
|
+
caption: file.caption,
|
|
432
|
+
}),
|
|
433
|
+
);
|
|
434
|
+
return { ok: true };
|
|
435
|
+
} catch (e) {
|
|
436
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
437
|
+
}
|
|
374
438
|
});
|
|
375
439
|
|
|
376
|
-
|
|
440
|
+
runtime = {
|
|
377
441
|
server,
|
|
378
442
|
idleSeq: 0,
|
|
379
443
|
pendingInteractive,
|
|
380
444
|
disposeAnswerSource,
|
|
445
|
+
disposeFileSink,
|
|
381
446
|
redact,
|
|
382
447
|
sessionTag: tag,
|
|
383
448
|
busy: false,
|
|
384
449
|
pendingInbound: new Set<number>(),
|
|
385
|
-
}
|
|
450
|
+
};
|
|
451
|
+
runtimes.set(id, runtime);
|
|
386
452
|
logger.info(`notifications: serving session ${id} at ${endpoint.url} (unattended=${unattended})`);
|
|
387
453
|
|
|
388
454
|
if (settingsAvailable && settings && isGloballyConfigured(cfg)) {
|
|
@@ -512,6 +578,82 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
512
578
|
await startSession(ctx);
|
|
513
579
|
});
|
|
514
580
|
|
|
581
|
+
// A session id change within the same process needs reason-aware handling.
|
|
582
|
+
// `/new` and fork CONTINUE the same terminal thread (e.g. plan "approve and
|
|
583
|
+
// execute" clears into a fresh session), so re-key the existing runtime
|
|
584
|
+
// old→new WITHOUT recreating the NotificationServer: the server, its endpoint
|
|
585
|
+
// discovery file, and the daemon's forum topic are all keyed by the original
|
|
586
|
+
// session id and the daemon routes by socket, so the existing topic is reused
|
|
587
|
+
// and the next identity frame renames it in place instead of spawning a new
|
|
588
|
+
// thread. `resume`, by contrast, loads a DIFFERENT, already-persisted session
|
|
589
|
+
// that owns its own topic — tear the previous runtime down and start fresh
|
|
590
|
+
// under the resumed id so the daemon attaches to (or recreates) that
|
|
591
|
+
// session's own discovery + topic rather than hijacking this terminal's.
|
|
592
|
+
api.on("session_switch", async (event, ctx) => {
|
|
593
|
+
const newId = sessionId(ctx);
|
|
594
|
+
const prevId = sessionIdFromFile(event.previousSessionFile);
|
|
595
|
+
if (!prevId || prevId === newId) return;
|
|
596
|
+
|
|
597
|
+
if (event.reason === "resume") {
|
|
598
|
+
stopSession(prevId);
|
|
599
|
+
await startSession(ctx);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// `/new` / fork: re-key in place and rename the existing topic.
|
|
604
|
+
if (disabledSessions.delete(prevId)) disabledSessions.add(newId);
|
|
605
|
+
const rt = runtimes.get(prevId);
|
|
606
|
+
if (!rt || runtimes.has(newId)) return;
|
|
607
|
+
runtimes.delete(prevId);
|
|
608
|
+
runtimes.set(newId, rt);
|
|
609
|
+
// Re-bind the interactive ask answer source: the ask tool resolves the
|
|
610
|
+
// source by the current session id, which just changed.
|
|
611
|
+
try {
|
|
612
|
+
rt.disposeAnswerSource();
|
|
613
|
+
rt.disposeFileSink();
|
|
614
|
+
} catch {}
|
|
615
|
+
rt.disposeAnswerSource = registerInteractiveAnswerSource(
|
|
616
|
+
newId,
|
|
617
|
+
rt.server,
|
|
618
|
+
rt.pendingInteractive,
|
|
619
|
+
rt.redact,
|
|
620
|
+
rt.sessionTag,
|
|
621
|
+
);
|
|
622
|
+
rt.disposeFileSink = registerTelegramFileSink(newId, async file => {
|
|
623
|
+
try {
|
|
624
|
+
const data = await fs.promises.readFile(file.path);
|
|
625
|
+
rt.server.pushFrame(
|
|
626
|
+
JSON.stringify({
|
|
627
|
+
type: "file_attachment",
|
|
628
|
+
sessionId: newId,
|
|
629
|
+
name: path.basename(file.path),
|
|
630
|
+
data: data.toString("base64"),
|
|
631
|
+
caption: file.caption,
|
|
632
|
+
}),
|
|
633
|
+
);
|
|
634
|
+
return { ok: true };
|
|
635
|
+
} catch (e) {
|
|
636
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
// Rename the existing topic now when the new session already has a name; a
|
|
640
|
+
// fresh unnamed session is renamed on its next agent_end re-assert, which
|
|
641
|
+
// avoids a transient rename to bare "repo/branch".
|
|
642
|
+
if (ctx.sessionManager.getSessionName()) {
|
|
643
|
+
try {
|
|
644
|
+
rt.server.pushFrame(
|
|
645
|
+
JSON.stringify({
|
|
646
|
+
type: "identity_header",
|
|
647
|
+
sessionId: newId,
|
|
648
|
+
...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
|
|
649
|
+
}),
|
|
650
|
+
);
|
|
651
|
+
} catch (e) {
|
|
652
|
+
logger.warn(`notifications: identity_header (switch) failed: ${String(e)}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
515
657
|
// Drive the live typing indicator: mark busy when the agent loop starts so
|
|
516
658
|
// the daemon shows "typing…" in the thread while the agent is thinking,
|
|
517
659
|
// before any turn output exists. Cleared on `agent_end` below.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { Settings } from "../config/settings";
|
|
3
|
+
import { RateLimitPool } from "./rate-limit-pool";
|
|
4
|
+
import {
|
|
5
|
+
CLIENT_PING_PONG_CAPABILITY,
|
|
6
|
+
HEARTBEAT_INTERVAL_MS,
|
|
7
|
+
HEARTBEAT_TTL_MS,
|
|
8
|
+
NOTIFICATION_PROTOCOL_VERSION,
|
|
9
|
+
} from "./telegram-daemon";
|
|
10
|
+
import { readEndpoint } from "./telegram-reference";
|
|
11
|
+
import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
|
|
12
|
+
|
|
13
|
+
export interface ManagedNotificationDaemonFs {
|
|
14
|
+
readdir(path: string): Promise<string[]>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ManagedSessionSocket {
|
|
18
|
+
sessionId: string;
|
|
19
|
+
token: string;
|
|
20
|
+
ws: WebSocket;
|
|
21
|
+
pending: Map<string, { sessionId: string; actionId: string }>;
|
|
22
|
+
capable: boolean;
|
|
23
|
+
lastPongAt: number;
|
|
24
|
+
awaitingNonce: string | undefined;
|
|
25
|
+
pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ManagedNotificationDaemonOptions {
|
|
29
|
+
settings: Settings;
|
|
30
|
+
fs: ManagedNotificationDaemonFs;
|
|
31
|
+
WebSocketImpl?: typeof WebSocket;
|
|
32
|
+
now?: () => number;
|
|
33
|
+
setIntervalImpl?: typeof setInterval;
|
|
34
|
+
clearIntervalImpl?: typeof clearInterval;
|
|
35
|
+
rateLimitPool?: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export abstract class ManagedNotificationDaemon {
|
|
39
|
+
readonly sessions = new Map<string, ManagedSessionSocket>();
|
|
40
|
+
readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
|
|
41
|
+
|
|
42
|
+
protected constructor(protected readonly opts: ManagedNotificationDaemonOptions) {
|
|
43
|
+
this.pool = opts.rateLimitPool ?? new RateLimitPool<{ send: ThreadedSend; topicId?: string }>({ now: opts.now });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async scanRoots(): Promise<void> {
|
|
47
|
+
const rootState = await this.readRoots();
|
|
48
|
+
for (const root of rootState) {
|
|
49
|
+
const dir = path.join(root, "notifications");
|
|
50
|
+
let files: string[];
|
|
51
|
+
try {
|
|
52
|
+
files = await this.opts.fs.readdir(dir);
|
|
53
|
+
} catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
for (const file of files.filter(item => item.endsWith(".json"))) {
|
|
57
|
+
const sessionId = path.basename(file, ".json");
|
|
58
|
+
if (this.sessions.has(sessionId)) continue;
|
|
59
|
+
try {
|
|
60
|
+
const endpoint = readEndpoint(path.join(dir, file));
|
|
61
|
+
this.connectSession(sessionId, endpoint.url, endpoint.token);
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
connectSession(sessionId: string, url: string, token: string): ManagedSessionSocket {
|
|
68
|
+
const WS = this.opts.WebSocketImpl ?? WebSocket;
|
|
69
|
+
const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
|
|
70
|
+
const session: ManagedSessionSocket = {
|
|
71
|
+
sessionId,
|
|
72
|
+
token,
|
|
73
|
+
ws,
|
|
74
|
+
pending: new Map(),
|
|
75
|
+
capable: false,
|
|
76
|
+
lastPongAt: 0,
|
|
77
|
+
awaitingNonce: undefined,
|
|
78
|
+
pingTimer: undefined,
|
|
79
|
+
};
|
|
80
|
+
this.sessions.set(sessionId, session);
|
|
81
|
+
ws.addEventListener("open", () => this.sendHello(session));
|
|
82
|
+
ws.addEventListener("message", ev => {
|
|
83
|
+
if (this.sessions.get(sessionId) !== session) return;
|
|
84
|
+
void this.handleSessionMessage(session, JSON.parse(String(ev.data))).catch(() => undefined);
|
|
85
|
+
});
|
|
86
|
+
ws.addEventListener("close", () => this.dropSession(session));
|
|
87
|
+
return session;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
protected async handleSessionMessage(session: ManagedSessionSocket, msg: Record<string, unknown>): Promise<boolean> {
|
|
91
|
+
if (msg.type === "hello") {
|
|
92
|
+
const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
|
|
93
|
+
if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
|
|
94
|
+
session.capable = true;
|
|
95
|
+
this.startLiveness(session);
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
if (msg.type === "pong") {
|
|
100
|
+
if (typeof msg.nonce === "string" && msg.nonce === session.awaitingNonce) {
|
|
101
|
+
session.awaitingNonce = undefined;
|
|
102
|
+
session.lastPongAt = (this.opts.now ?? Date.now)();
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
protected renderFrame(frame: Record<string, unknown>): ThreadedSend | undefined {
|
|
110
|
+
return renderThreadedFrame(frame);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
protected dropSession(session: ManagedSessionSocket): void {
|
|
114
|
+
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
115
|
+
if (session.pingTimer) {
|
|
116
|
+
clearIntervalImpl(session.pingTimer);
|
|
117
|
+
session.pingTimer = undefined;
|
|
118
|
+
}
|
|
119
|
+
if (this.sessions.get(session.sessionId) === session) this.sessions.delete(session.sessionId);
|
|
120
|
+
if (session.ws.readyState !== WebSocket.CLOSED) {
|
|
121
|
+
try {
|
|
122
|
+
session.ws.close();
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
protected abstract readRoots(): Promise<string[]>;
|
|
128
|
+
|
|
129
|
+
private sendHello(session: ManagedSessionSocket): void {
|
|
130
|
+
if (session.ws.readyState !== WebSocket.OPEN) return;
|
|
131
|
+
try {
|
|
132
|
+
session.ws.send(
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
type: "hello",
|
|
135
|
+
protocolVersion: NOTIFICATION_PROTOCOL_VERSION,
|
|
136
|
+
capabilities: [CLIENT_PING_PONG_CAPABILITY],
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private startLiveness(session: ManagedSessionSocket): void {
|
|
143
|
+
if (session.pingTimer) return;
|
|
144
|
+
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
145
|
+
const now = () => (this.opts.now ?? Date.now)();
|
|
146
|
+
session.lastPongAt = now();
|
|
147
|
+
session.pingTimer = setIntervalImpl(() => {
|
|
148
|
+
if (this.sessions.get(session.sessionId) !== session) return;
|
|
149
|
+
const t = now();
|
|
150
|
+
if (t - session.lastPongAt >= HEARTBEAT_TTL_MS) {
|
|
151
|
+
this.dropSession(session);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (session.ws.readyState === WebSocket.OPEN) {
|
|
155
|
+
const nonce = `${session.sessionId}:${t}:${Math.random().toString(36).slice(2)}`;
|
|
156
|
+
session.awaitingNonce = nonce;
|
|
157
|
+
try {
|
|
158
|
+
session.ws.send(JSON.stringify({ type: "ping", nonce }));
|
|
159
|
+
} catch {}
|
|
160
|
+
}
|
|
161
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
162
|
+
}
|
|
163
|
+
}
|