@gajae-code/coding-agent 0.7.0 → 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.
Files changed (101) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/types/cli/notify-cli.d.ts +2 -0
  3. package/dist/types/config/settings-schema.d.ts +39 -2
  4. package/dist/types/extensibility/shared-events.d.ts +1 -0
  5. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  6. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +1 -1
  7. package/dist/types/gjc-runtime/tmux-common.d.ts +3 -0
  8. package/dist/types/gjc-runtime/tmux-sessions.d.ts +2 -0
  9. package/dist/types/lsp/types.d.ts +2 -0
  10. package/dist/types/notifications/attachment-registry.d.ts +17 -0
  11. package/dist/types/notifications/chat-adapters.d.ts +9 -0
  12. package/dist/types/notifications/config.d.ts +9 -1
  13. package/dist/types/notifications/engine.d.ts +59 -0
  14. package/dist/types/notifications/managed-daemon.d.ts +48 -0
  15. package/dist/types/notifications/telegram-daemon.d.ts +19 -0
  16. package/dist/types/notifications/threaded-inbound.d.ts +19 -0
  17. package/dist/types/notifications/threaded-render.d.ts +6 -1
  18. package/dist/types/session/agent-session.d.ts +2 -0
  19. package/dist/types/tools/fetch.d.ts +23 -0
  20. package/dist/types/tools/index.d.ts +1 -0
  21. package/dist/types/tools/telegram-send.d.ts +32 -0
  22. package/dist/types/web/insane/bridge.d.ts +103 -0
  23. package/dist/types/web/insane/url-guard.d.ts +22 -0
  24. package/dist/types/web/search/provider.d.ts +18 -1
  25. package/dist/types/web/search/providers/insane.d.ts +53 -0
  26. package/dist/types/web/search/providers/text-citations.d.ts +23 -0
  27. package/dist/types/web/search/types.d.ts +12 -4
  28. package/package.json +10 -8
  29. package/scripts/verify-insane-vendor.ts +132 -0
  30. package/src/cli/args.ts +1 -1
  31. package/src/cli/fast-help.ts +1 -1
  32. package/src/cli/notify-cli.ts +152 -5
  33. package/src/cli.ts +1 -3
  34. package/src/commands/team.ts +1 -1
  35. package/src/config/settings-schema.ts +30 -1
  36. package/src/defaults/gjc/skills/ralplan/SKILL.md +11 -4
  37. package/src/edit/modes/replace.ts +1 -1
  38. package/src/extensibility/shared-events.ts +1 -0
  39. package/src/gjc-runtime/launch-tmux.ts +27 -5
  40. package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
  41. package/src/gjc-runtime/ralplan-runtime.ts +2 -2
  42. package/src/gjc-runtime/tmux-common.ts +8 -0
  43. package/src/gjc-runtime/tmux-sessions.ts +8 -1
  44. package/src/gjc-runtime/workflow-manifest.generated.json +29 -0
  45. package/src/gjc-runtime/workflow-manifest.ts +7 -2
  46. package/src/hashline/hash.ts +1 -1
  47. package/src/internal-urls/docs-index.generated.ts +9 -8
  48. package/src/lsp/config.ts +16 -3
  49. package/src/lsp/defaults.json +7 -0
  50. package/src/lsp/types.ts +2 -0
  51. package/src/modes/controllers/event-controller.ts +15 -0
  52. package/src/modes/interactive-mode.ts +46 -2
  53. package/src/modes/utils/context-usage.ts +2 -2
  54. package/src/notifications/attachment-registry.ts +23 -0
  55. package/src/notifications/chat-adapters.ts +147 -0
  56. package/src/notifications/config.ts +23 -2
  57. package/src/notifications/engine.ts +100 -0
  58. package/src/notifications/index.ts +224 -45
  59. package/src/notifications/managed-daemon.ts +163 -0
  60. package/src/notifications/telegram-daemon.ts +235 -14
  61. package/src/notifications/threaded-inbound.ts +60 -4
  62. package/src/notifications/threaded-render.ts +20 -2
  63. package/src/session/agent-session.ts +82 -51
  64. package/src/tools/ask.ts +3 -2
  65. package/src/tools/fetch.ts +78 -1
  66. package/src/tools/index.ts +3 -0
  67. package/src/tools/telegram-send.ts +137 -0
  68. package/src/web/insane/bridge.ts +350 -0
  69. package/src/web/insane/url-guard.ts +155 -0
  70. package/src/web/search/provider.ts +77 -18
  71. package/src/web/search/providers/anthropic.ts +70 -3
  72. package/src/web/search/providers/codex.ts +1 -119
  73. package/src/web/search/providers/gemini.ts +99 -0
  74. package/src/web/search/providers/insane.ts +551 -0
  75. package/src/web/search/providers/openai-compatible.ts +66 -32
  76. package/src/web/search/providers/text-citations.ts +111 -0
  77. package/src/web/search/types.ts +13 -2
  78. package/vendor/insane-search/LICENSE +21 -0
  79. package/vendor/insane-search/MANIFEST.json +24 -0
  80. package/vendor/insane-search/engine/__init__.py +23 -0
  81. package/vendor/insane-search/engine/__main__.py +128 -0
  82. package/vendor/insane-search/engine/bias_check.py +183 -0
  83. package/vendor/insane-search/engine/executor.py +254 -0
  84. package/vendor/insane-search/engine/fetch_chain.py +725 -0
  85. package/vendor/insane-search/engine/learning.py +175 -0
  86. package/vendor/insane-search/engine/phase0.py +214 -0
  87. package/vendor/insane-search/engine/safety.py +91 -0
  88. package/vendor/insane-search/engine/templates/package.json +11 -0
  89. package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
  90. package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
  91. package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
  92. package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
  93. package/vendor/insane-search/engine/tests/test_u1.py +200 -0
  94. package/vendor/insane-search/engine/tests/test_u4.py +131 -0
  95. package/vendor/insane-search/engine/tests/test_u5.py +163 -0
  96. package/vendor/insane-search/engine/tests/test_u7.py +124 -0
  97. package/vendor/insane-search/engine/transport.py +211 -0
  98. package/vendor/insane-search/engine/url_transforms.py +98 -0
  99. package/vendor/insane-search/engine/validators.py +331 -0
  100. package/vendor/insane-search/engine/waf_detector.py +214 -0
  101. 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,12 +138,19 @@ 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). */
142
146
  busy: boolean;
143
147
  /** Inbound Telegram update ids injected but not yet consumed by a turn. */
144
148
  pendingInbound: Set<number>;
149
+ /** Latest assistant text of the in-flight turn (from message_update). */
150
+ currentTurnText?: string;
151
+ /** Assistant text already flushed before an ask this turn (turn-scoped dedupe
152
+ * so turn_end does not re-emit the pre-ask lead-in). Reset each turn. */
153
+ preAskFlushedText?: string;
145
154
  }
146
155
 
147
156
  interface ResolvedSettings {
@@ -152,6 +161,16 @@ interface ResolvedSettings {
152
161
 
153
162
  const defaultConfig: NotificationConfig = {
154
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
+ },
155
174
  redact: false,
156
175
  verbosity: "lean",
157
176
  idleTimeoutMs: 60_000,
@@ -223,6 +242,56 @@ function mapAnswerToGate(
223
242
  return { selected: [] };
224
243
  }
225
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
+
226
295
  export const createNotificationsExtension: ExtensionFactory = api => {
227
296
  const runtimes = new Map<string, SessionRuntime>();
228
297
  const disabledSessions = new Set<string>();
@@ -234,6 +303,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
234
303
  runtimes.delete(id);
235
304
  try {
236
305
  rt.disposeAnswerSource();
306
+ rt.disposeFileSink();
237
307
  } catch {}
238
308
  // Resolve any still-pending interactive asks so the ask tool is not left hanging.
239
309
  for (const pending of rt.pendingInteractive.values()) pending.resolve(undefined);
@@ -266,6 +336,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
266
336
  const pendingInteractive = new Map<string, PendingInteractiveAsk>();
267
337
  const tag = sessionTag(id);
268
338
  const redact = cfg.redact;
339
+ let runtime: SessionRuntime | undefined;
269
340
 
270
341
  // The SDK can always answer now (interactive via the answer source, or the
271
342
  // unattended gate), so the endpoint advertises a resolver.
@@ -312,23 +383,34 @@ export const createNotificationsExtension: ExtensionFactory = api => {
312
383
  // thread (forwarded by the daemon over the WS, fail-closed at the daemon).
313
384
  server.onInbound((err, inbound) => {
314
385
  if (err || !inbound) return;
315
- if (inbound.kind === "user_message" && inbound.text) {
386
+ if (inbound.kind === "user_message") {
316
387
  // Inject as a user turn (steers/continues the agent; the resulting
317
388
  // turn streams back via the turn_end handler even when not idle).
318
389
  // Record the update id so it can be acked as "consumed" on the next
319
390
  // turn_start, and steer (vs start a fresh turn) when already busy.
320
- const rt = runtimes.get(id);
321
- if (rt && typeof inbound.updateId === "number") rt.pendingInbound.add(inbound.updateId);
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;
322
405
  try {
323
- api.sendUserMessage(inbound.text, rt?.busy ? { deliverAs: "steer" } : undefined);
406
+ api.sendUserMessage(content, runtime?.busy ? { deliverAs: "steer" } : undefined);
324
407
  } catch (e) {
325
408
  logger.warn(`notifications: sendUserMessage failed: ${String(e)}`);
326
409
  }
327
410
  return;
328
411
  }
329
412
  if (inbound.kind === "config_command") {
330
- const rt = runtimes.get(id);
331
- if (rt && typeof inbound.redact === "boolean") rt.redact = inbound.redact;
413
+ if (runtime && typeof inbound.redact === "boolean") runtime.redact = inbound.redact;
332
414
  }
333
415
  });
334
416
 
@@ -336,48 +418,37 @@ export const createNotificationsExtension: ExtensionFactory = api => {
336
418
  const endpoint = await server.start();
337
419
 
338
420
  // Interactive answer source: the ask tool races the local UI against this.
339
- const disposeAnswerSource = registerAskAnswerSource(id, {
340
- awaitAnswer(question, options, signal) {
341
- if (signal?.aborted) return Promise.resolve(undefined);
342
- const askId = `ask:${crypto.randomUUID()}`;
343
- try {
344
- server.registerAsk(
345
- JSON.stringify(
346
- notificationActionPayload(
347
- { id: askId, kind: "ask", sessionId: id, question, options },
348
- { redact, sessionTag: tag },
349
- ),
350
- ),
351
- true,
352
- );
353
- } catch (e) {
354
- logger.warn(`notifications: registerAsk failed: ${String(e)}`);
355
- return Promise.resolve(undefined);
356
- }
357
- return new Promise<string | undefined>(resolve => {
358
- pendingInteractive.set(askId, { resolve, options });
359
- signal?.addEventListener("abort", () => {
360
- if (!pendingInteractive.delete(askId)) return;
361
- // Local UI answered: mark the remote action resolved-locally.
362
- try {
363
- server.resolveLocal(askId, undefined);
364
- } catch {}
365
- resolve(undefined);
366
- });
367
- });
368
- },
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
+ }
369
438
  });
370
439
 
371
- runtimes.set(id, {
440
+ runtime = {
372
441
  server,
373
442
  idleSeq: 0,
374
443
  pendingInteractive,
375
444
  disposeAnswerSource,
445
+ disposeFileSink,
376
446
  redact,
377
447
  sessionTag: tag,
378
448
  busy: false,
379
449
  pendingInbound: new Set<number>(),
380
- });
450
+ };
451
+ runtimes.set(id, runtime);
381
452
  logger.info(`notifications: serving session ${id} at ${endpoint.url} (unattended=${unattended})`);
382
453
 
383
454
  if (settingsAvailable && settings && isGloballyConfigured(cfg)) {
@@ -507,6 +578,82 @@ export const createNotificationsExtension: ExtensionFactory = api => {
507
578
  await startSession(ctx);
508
579
  });
509
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
+
510
657
  // Drive the live typing indicator: mark busy when the agent loop starts so
511
658
  // the daemon shows "typing…" in the thread while the agent is thinking,
512
659
  // before any turn output exists. Cleared on `agent_end` below.
@@ -620,18 +767,42 @@ export const createNotificationsExtension: ExtensionFactory = api => {
620
767
  // Redaction suppresses streamed content (only the one-time identity header
621
768
  // survives redaction). The daemon coalesces/throttles these via its shared
622
769
  // rate-limit pool before sending to Telegram.
623
- api.on("turn_end", (event, ctx) => {
624
- const id = sessionId(ctx);
625
- const rt = runtimes.get(id);
626
- if (!rt) return;
627
- if (rt.redact) return;
628
- const text = summaryFromMessage(event.message, 3500);
629
- if (!text) return;
770
+ // Push the in-flight turn's assistant text as a finalized turn_stream, deduped
771
+ // against what was already flushed for this turn (the pre-ask lead-in).
772
+ const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined): void => {
773
+ if (!text || text === rt.preAskFlushedText) return;
774
+ rt.preAskFlushedText = text;
630
775
  try {
631
776
  rt.server.pushFrame(JSON.stringify({ type: "turn_stream", sessionId: id, phase: "finalized", text }));
632
777
  } catch (e) {
633
778
  logger.warn(`notifications: pushFrame (turn) failed: ${String(e)}`);
634
779
  }
780
+ };
781
+
782
+ // Emit the assistant text that precedes an ask BEFORE the ask's action_needed
783
+ // is broadcast, so the remote (e.g. Telegram) shows the lead-in first instead
784
+ // of only after the ask resolves at turn_end. The text is captured on
785
+ // message_end (which, like tool_execution_start, is on the awaited extension
786
+ // path and ordered before it — unlike message_update, which is queued async),
787
+ // then flushed here before the ask tool's execute calls registerAsk.
788
+ api.on("tool_execution_start", (event, ctx) => {
789
+ if (event.toolName !== "ask") return;
790
+ const id = sessionId(ctx);
791
+ const rt = runtimes.get(id);
792
+ if (!rt || rt.redact) return;
793
+ flushTurnText(rt, id, rt.currentTurnText);
794
+ });
795
+
796
+ api.on("turn_end", (event, ctx) => {
797
+ const id = sessionId(ctx);
798
+ const rt = runtimes.get(id);
799
+ if (!rt) return;
800
+ const text = rt.redact ? undefined : summaryFromMessage(event.message, 3500);
801
+ if (text) flushTurnText(rt, id, text);
802
+ // Reset per-turn streaming state so the next turn starts fresh and a later
803
+ // turn with identical text is not falsely deduped.
804
+ rt.currentTurnText = undefined;
805
+ rt.preAskFlushedText = undefined;
635
806
  });
636
807
 
637
808
  // Stream agent-produced images (computer/browser/tool screenshots) as
@@ -640,6 +811,14 @@ export const createNotificationsExtension: ExtensionFactory = api => {
640
811
  const id = sessionId(ctx);
641
812
  const rt = runtimes.get(id);
642
813
  if (!rt || rt.redact) return;
814
+ // Capture the in-flight ASSISTANT text here (message_end is on the awaited
815
+ // extension path and ordered before tool_execution_start) so the pre-ask
816
+ // flush can emit it before the ask prompt. Role-scoped: message_end also
817
+ // fires for the user prompt, which must never be mirrored back as turn output.
818
+ if ((event.message as { role?: unknown }).role === "assistant") {
819
+ const turnText = summaryFromMessage(event.message, 3500);
820
+ if (turnText) rt.currentTurnText = turnText;
821
+ }
643
822
  for (const img of imageAttachmentsFromMessage(event.message)) {
644
823
  try {
645
824
  rt.server.pushFrame(
@@ -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
+ }