@oar-sh/oar 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. package/shared/worker-crash-guard.mjs +60 -0
@@ -0,0 +1,459 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Web Push dispatch: decides whether a push should go out at all (suppressed
5
+ * while any device is actively foregrounded), renders the notification per
6
+ * subscription according to that device's content preferences, and prunes
7
+ * subscriptions the push service reports as gone.
8
+ *
9
+ * A push failure must never break a turn: dispatch() swallows everything and
10
+ * reports outcomes through the status-events store instead.
11
+ */
12
+
13
+ export const PUSH_EVENT_TYPES = Object.freeze(['question', 'turnComplete', 'turnFailed', 'board', 'cliOffline']);
14
+
15
+ export const VAPID_PUBLIC_KEY_SETTING = 'push_vapid_public_key';
16
+ export const VAPID_PRIVATE_KEY_SETTING = 'push_vapid_private_key';
17
+
18
+ const PREVIEW_MODES = new Set(['none', 'truncated', 'full']);
19
+ const DEFAULT_PREVIEW_CHARS = 80;
20
+ const MIN_PREVIEW_CHARS = 20;
21
+ const MAX_PREVIEW_CHARS = 500;
22
+ // Push payloads are capped around 4 KB by the push services; keep the body
23
+ // well under that even in "full" mode.
24
+ const MAX_FULL_PREVIEW_CHARS = 2000;
25
+ const MAX_TITLE_CHARS = 120;
26
+ // A subscription that keeps failing for reasons other than 404/410 is dead
27
+ // weight; drop it after this many consecutive failures.
28
+ const FAILURE_PRUNE_THRESHOLD = 8;
29
+ const PUSH_TTL_SECONDS = 12 * 60 * 60;
30
+
31
+ const EVENT_LABELS = Object.freeze({
32
+ question: { title: 'Copilot needs your input', generic: 'The agent asked a question.' },
33
+ turnComplete: { title: 'Turn completed', generic: 'The agent finished its turn.' },
34
+ turnFailed: { title: 'Turn failed', generic: 'The agent turn failed.' },
35
+ board: { title: 'Plan ready for review', generic: 'The agent posted a board for review.' },
36
+ cliOffline: { title: 'CLI offline', generic: 'The relay lost its connection to the CLI host.' },
37
+ });
38
+
39
+ function clampInt(value, min, max, fallback) {
40
+ const numeric = Number(value);
41
+ if (!Number.isFinite(numeric)) return fallback;
42
+ return Math.max(min, Math.min(max, Math.trunc(numeric)));
43
+ }
44
+
45
+ /**
46
+ * Normalize a raw (possibly user-supplied) preference object into the
47
+ * canonical shape stored in preferences_json. Unknown fields are dropped,
48
+ * missing fields get safe defaults (enabled, all events on except cliOffline,
49
+ * generic content).
50
+ */
51
+ export function normalizePushPreferences(raw) {
52
+ const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
53
+ const rawEvents = source.events && typeof source.events === 'object' ? source.events : {};
54
+ const rawContent = source.content && typeof source.content === 'object' ? source.content : {};
55
+ const events = {};
56
+ for (const type of PUSH_EVENT_TYPES) {
57
+ const fallback = type !== 'cliOffline';
58
+ events[type] = typeof rawEvents[type] === 'boolean' ? rawEvents[type] : fallback;
59
+ }
60
+ const preview = PREVIEW_MODES.has(rawContent.preview) ? rawContent.preview : 'none';
61
+ return {
62
+ enabled: source.enabled !== false,
63
+ events,
64
+ content: {
65
+ includeTitle: rawContent.includeTitle === true,
66
+ preview,
67
+ previewChars: clampInt(rawContent.previewChars, MIN_PREVIEW_CHARS, MAX_PREVIEW_CHARS, DEFAULT_PREVIEW_CHARS),
68
+ },
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Render the notification payload for one subscription. Pure so every content
74
+ * preference combination is unit-testable.
75
+ *
76
+ * @param {{ type: string, conversationTitle?: string|null, body?: string|null, data?: object }} event
77
+ * @param {object} preferences canonical shape from normalizePushPreferences
78
+ */
79
+ export function renderPushNotification(event, preferences) {
80
+ const type = PUSH_EVENT_TYPES.includes(event?.type) ? event.type : 'question';
81
+ const prefs = normalizePushPreferences(preferences);
82
+ const labels = EVENT_LABELS[type];
83
+
84
+ let title = labels.title;
85
+ const conversationTitle = String(event?.conversationTitle || '').trim();
86
+ if (prefs.content.includeTitle && conversationTitle) {
87
+ title = `${labels.title} — ${conversationTitle}`.slice(0, MAX_TITLE_CHARS);
88
+ }
89
+
90
+ let body = labels.generic;
91
+ const text = String(event?.body || '').trim();
92
+ // cliOffline has no conversation text to preview; the generic body is the message.
93
+ if (text && type !== 'cliOffline') {
94
+ if (prefs.content.preview === 'full') {
95
+ body = text.slice(0, MAX_FULL_PREVIEW_CHARS);
96
+ } else if (prefs.content.preview === 'truncated') {
97
+ const limit = prefs.content.previewChars;
98
+ body = text.length > limit ? `${text.slice(0, limit)}…` : text;
99
+ }
100
+ }
101
+
102
+ const data = event?.data && typeof event.data === 'object' ? { ...event.data } : {};
103
+ data.type = type;
104
+
105
+ // A distinct tag per event so notifications stack instead of replacing each
106
+ // other (decision: one notification per event, no coalescing).
107
+ const tagSeed = String(data.questionId || data.boardId || data.messageId || data.conversationId || Date.now());
108
+ return {
109
+ title,
110
+ body,
111
+ tag: `copilot-${type}-${tagSeed}`,
112
+ timestamp: Date.now(),
113
+ data,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Generate VAPID keys into app_settings on first start; subsequent starts
119
+ * reuse the stored pair so subscriptions stay valid across restarts.
120
+ *
121
+ * @param {{ getAppSetting: { get(key: string): any }, upsertAppSetting: { run(key: string, value: string, updatedAt: string): any } }} stmts
122
+ * @param {{ generateVapidKeys: () => { publicKey: string, privateKey: string }, now?: () => string }} options
123
+ */
124
+ export function ensurePushVapidKeys(stmts, { generateVapidKeys, now = () => new Date().toISOString() }) {
125
+ const existingPublic = String(stmts.getAppSetting.get(VAPID_PUBLIC_KEY_SETTING)?.value || '').trim();
126
+ const existingPrivate = String(stmts.getAppSetting.get(VAPID_PRIVATE_KEY_SETTING)?.value || '').trim();
127
+ if (existingPublic && existingPrivate) {
128
+ return { publicKey: existingPublic, privateKey: existingPrivate, generated: false };
129
+ }
130
+ const keys = generateVapidKeys();
131
+ const timestamp = now();
132
+ stmts.upsertAppSetting.run(VAPID_PUBLIC_KEY_SETTING, keys.publicKey, timestamp);
133
+ stmts.upsertAppSetting.run(VAPID_PRIVATE_KEY_SETTING, keys.privateKey, timestamp);
134
+ return { publicKey: keys.publicKey, privateKey: keys.privateKey, generated: true };
135
+ }
136
+
137
+ function parseJsonObject(value) {
138
+ if (!value || typeof value !== 'string') return null;
139
+ try {
140
+ const parsed = JSON.parse(value);
141
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ export function formatPushDeviceRow(row) {
148
+ if (!row) return null;
149
+ return {
150
+ id: String(row.id || ''),
151
+ deviceId: String(row.device_id || ''),
152
+ label: String(row.device_label || '').trim() || null,
153
+ endpoint: String(row.endpoint || ''),
154
+ preferences: normalizePushPreferences(parseJsonObject(row.preferences_json)),
155
+ userAgent: String(row.user_agent || '').trim() || null,
156
+ createdAt: row.created_at || null,
157
+ updatedAt: row.updated_at || null,
158
+ lastSuccessAt: row.last_success_at || null,
159
+ lastError: String(row.last_error || '').trim() || null,
160
+ failureCount: Number(row.failure_count || 0),
161
+ };
162
+ }
163
+
164
+ /**
165
+ * @param {object} options
166
+ * @param {import('better-sqlite3').Database} options.db
167
+ * @param {{ sendNotification: Function }} options.webpush configured web-push module (VAPID details already set)
168
+ * @param {() => boolean} options.hasActiveDevice suppression source; a throw is treated as "unknown" and fails closed
169
+ * @param {(type: string, details?: object) => void} [options.recordStatusEvent]
170
+ * @param {() => string} options.uuid
171
+ * @param {Console} [options.logger]
172
+ */
173
+ export function createPushDispatchService({
174
+ db,
175
+ webpush,
176
+ hasActiveDevice,
177
+ recordStatusEvent = () => {},
178
+ uuid,
179
+ logger = console,
180
+ }) {
181
+ const sql = {
182
+ listAll: db.prepare(`
183
+ SELECT * FROM push_subscriptions
184
+ ORDER BY updated_at DESC
185
+ `),
186
+ getById: db.prepare(`SELECT * FROM push_subscriptions WHERE id = ?`),
187
+ getByEndpoint: db.prepare(`SELECT * FROM push_subscriptions WHERE endpoint = ?`),
188
+ insert: db.prepare(`
189
+ INSERT INTO push_subscriptions (
190
+ id, device_id, device_label, endpoint, keys_json, preferences_json,
191
+ user_agent, created_at, updated_at, last_success_at, last_error, failure_count
192
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 0)
193
+ `),
194
+ updateSubscription: db.prepare(`
195
+ UPDATE push_subscriptions
196
+ SET device_id = ?, device_label = ?, keys_json = ?, preferences_json = ?, user_agent = ?, updated_at = ?
197
+ WHERE id = ?
198
+ `),
199
+ updateLabel: db.prepare(`UPDATE push_subscriptions SET device_label = ?, updated_at = ? WHERE id = ?`),
200
+ updatePreferences: db.prepare(`UPDATE push_subscriptions SET preferences_json = ?, updated_at = ? WHERE id = ?`),
201
+ delete: db.prepare(`DELETE FROM push_subscriptions WHERE id = ?`),
202
+ markSuccess: db.prepare(`
203
+ UPDATE push_subscriptions
204
+ SET last_success_at = ?, last_error = NULL, failure_count = 0
205
+ WHERE id = ?
206
+ `),
207
+ markFailure: db.prepare(`
208
+ UPDATE push_subscriptions
209
+ SET last_error = ?, failure_count = failure_count + 1
210
+ WHERE id = ?
211
+ `),
212
+ getConversationTitle: db.prepare(`SELECT title FROM conversations WHERE id = ?`),
213
+ };
214
+
215
+ function listDevices() {
216
+ return sql.listAll.all().map(formatPushDeviceRow).filter(Boolean);
217
+ }
218
+
219
+ function getDevice(id) {
220
+ return formatPushDeviceRow(sql.getById.get(String(id || '')));
221
+ }
222
+
223
+ /**
224
+ * Register or refresh a device's subscription. Upserts on the unique
225
+ * endpoint so a browser that re-subscribes updates in place.
226
+ */
227
+ function upsertSubscription({ deviceId, label, endpoint, keys, preferences, userAgent }) {
228
+ const normalizedEndpoint = String(endpoint || '').trim();
229
+ const normalizedDeviceId = String(deviceId || '').trim();
230
+ if (!normalizedEndpoint || !normalizedDeviceId || !keys || typeof keys !== 'object') {
231
+ return { ok: false, error: 'Missing endpoint, deviceId, or keys' };
232
+ }
233
+ const now = new Date().toISOString();
234
+ const normalizedLabel = String(label || '').trim().slice(0, 80) || null;
235
+ const normalizedUserAgent = String(userAgent || '').trim().slice(0, 200) || null;
236
+ const preferencesJson = JSON.stringify(normalizePushPreferences(preferences));
237
+ const keysJson = JSON.stringify({
238
+ p256dh: String(keys.p256dh || ''),
239
+ auth: String(keys.auth || ''),
240
+ });
241
+ const existing = sql.getByEndpoint.get(normalizedEndpoint);
242
+ if (existing) {
243
+ sql.updateSubscription.run(
244
+ normalizedDeviceId,
245
+ normalizedLabel ?? existing.device_label,
246
+ keysJson,
247
+ preferencesJson,
248
+ normalizedUserAgent ?? existing.user_agent,
249
+ now,
250
+ existing.id,
251
+ );
252
+ return { ok: true, device: getDevice(existing.id) };
253
+ }
254
+ const id = uuid();
255
+ sql.insert.run(
256
+ id,
257
+ normalizedDeviceId,
258
+ normalizedLabel,
259
+ normalizedEndpoint,
260
+ keysJson,
261
+ preferencesJson,
262
+ normalizedUserAgent,
263
+ now,
264
+ now,
265
+ );
266
+ return { ok: true, device: getDevice(id) };
267
+ }
268
+
269
+ function updateDevice(id, { label, preferences } = {}) {
270
+ const row = sql.getById.get(String(id || ''));
271
+ if (!row) return { ok: false, error: 'Not found' };
272
+ const now = new Date().toISOString();
273
+ if (label !== undefined) {
274
+ sql.updateLabel.run(String(label || '').trim().slice(0, 80) || null, now, row.id);
275
+ }
276
+ if (preferences !== undefined) {
277
+ sql.updatePreferences.run(JSON.stringify(normalizePushPreferences(preferences)), now, row.id);
278
+ }
279
+ return { ok: true, device: getDevice(row.id) };
280
+ }
281
+
282
+ function deleteDevice(id) {
283
+ const result = sql.delete.run(String(id || ''));
284
+ return { ok: result.changes > 0 };
285
+ }
286
+
287
+ function conversationTitle(conversationId) {
288
+ const id = String(conversationId || '').trim();
289
+ if (!id) return null;
290
+ try {
291
+ return String(sql.getConversationTitle.get(id)?.title || '').trim() || null;
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
296
+
297
+ function safeRecordStatusEvent(type, details) {
298
+ try {
299
+ recordStatusEvent(type, details);
300
+ } catch {}
301
+ }
302
+
303
+ async function sendToSubscription(row, event) {
304
+ const preferences = normalizePushPreferences(parseJsonObject(row.preferences_json));
305
+ const payload = renderPushNotification(event, preferences);
306
+ const keys = parseJsonObject(row.keys_json) || {};
307
+ try {
308
+ await webpush.sendNotification(
309
+ { endpoint: row.endpoint, keys },
310
+ JSON.stringify(payload),
311
+ { TTL: PUSH_TTL_SECONDS, urgency: 'high' },
312
+ );
313
+ sql.markSuccess.run(new Date().toISOString(), row.id);
314
+ return { sent: true };
315
+ } catch (error) {
316
+ const statusCode = Number(error?.statusCode || 0);
317
+ if (statusCode === 404 || statusCode === 410) {
318
+ // The push service says this subscription no longer exists; drop it.
319
+ sql.delete.run(row.id);
320
+ safeRecordStatusEvent('push-subscription-pruned', {
321
+ deviceId: row.device_id,
322
+ label: row.device_label || undefined,
323
+ statusCode,
324
+ });
325
+ return { sent: false, pruned: true };
326
+ }
327
+ const message = String(error?.message || error || 'unknown').slice(0, 200);
328
+ sql.markFailure.run(message, row.id);
329
+ const failureCount = Number(sql.getById.get(row.id)?.failure_count || 0);
330
+ if (failureCount >= FAILURE_PRUNE_THRESHOLD) {
331
+ sql.delete.run(row.id);
332
+ safeRecordStatusEvent('push-subscription-pruned', {
333
+ deviceId: row.device_id,
334
+ label: row.device_label || undefined,
335
+ failureCount,
336
+ });
337
+ }
338
+ return { sent: false, error: message, statusCode: statusCode || undefined };
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Fire-and-forget push for one event. Never throws; suppression failures
344
+ * fail closed (no push when the active-device state is unknown).
345
+ */
346
+ async function dispatch({ type, conversationId = null, body = null, data = {} }) {
347
+ try {
348
+ if (!PUSH_EVENT_TYPES.includes(type)) return { outcome: 'invalid-type' };
349
+
350
+ let active = true; // fail closed: unknown state suppresses
351
+ try {
352
+ active = hasActiveDevice() === true;
353
+ } catch {
354
+ active = true;
355
+ }
356
+ if (active) {
357
+ safeRecordStatusEvent('push-suppressed', { eventType: type, conversationId: conversationId || undefined });
358
+ return { outcome: 'suppressed' };
359
+ }
360
+
361
+ const rows = sql.listAll.all().filter((row) => {
362
+ const preferences = normalizePushPreferences(parseJsonObject(row.preferences_json));
363
+ return preferences.enabled && preferences.events[type] === true;
364
+ });
365
+ if (!rows.length) {
366
+ return { outcome: 'no-subscribers' };
367
+ }
368
+
369
+ const event = {
370
+ type,
371
+ conversationTitle: conversationTitle(conversationId),
372
+ body,
373
+ data: { ...data, conversationId: conversationId || undefined },
374
+ };
375
+ const results = await Promise.all(rows.map((row) => sendToSubscription(row, event)));
376
+ const sent = results.filter((result) => result.sent).length;
377
+ const failed = results.length - sent;
378
+ safeRecordStatusEvent('push-dispatched', {
379
+ eventType: type,
380
+ conversationId: conversationId || undefined,
381
+ sent,
382
+ failed: failed || undefined,
383
+ });
384
+ return { outcome: 'dispatched', sent, failed };
385
+ } catch (error) {
386
+ // Never let a push failure surface into the calling turn.
387
+ try {
388
+ logger.warn(`[push] dispatch failed: ${error?.message || error}`);
389
+ } catch {}
390
+ return { outcome: 'error' };
391
+ }
392
+ }
393
+
394
+ // Semantic wrappers so each io.emit call site stays a single line and the
395
+ // payload construction lives in one place.
396
+ function notifyQuestion(question) {
397
+ // Short choices become notification action buttons. Long ones are skipped
398
+ // rather than truncated: the button text is also the answer that gets
399
+ // POSTed back, and a truncated answer would be wrong.
400
+ const choices = (Array.isArray(question?.choices) ? question.choices : [])
401
+ .map((choice) => String(choice || '').trim())
402
+ .filter((choice) => choice && choice.length <= 120)
403
+ .slice(0, 3);
404
+ return dispatch({
405
+ type: 'question',
406
+ conversationId: question?.conversationId || null,
407
+ body: question?.prompt || null,
408
+ data: {
409
+ questionId: question?.id || undefined,
410
+ choices: choices.length ? choices : undefined,
411
+ },
412
+ });
413
+ }
414
+
415
+ function notifyTurnComplete({ conversationId, messageId, text } = {}) {
416
+ return dispatch({
417
+ type: 'turnComplete',
418
+ conversationId: conversationId || null,
419
+ body: text || null,
420
+ data: { messageId: messageId || undefined },
421
+ });
422
+ }
423
+
424
+ function notifyTurnFailed({ conversationId, messageId, text } = {}) {
425
+ return dispatch({
426
+ type: 'turnFailed',
427
+ conversationId: conversationId || null,
428
+ body: text || null,
429
+ data: { messageId: messageId || undefined },
430
+ });
431
+ }
432
+
433
+ function notifyBoard(board) {
434
+ return dispatch({
435
+ type: 'board',
436
+ conversationId: board?.conversationId || null,
437
+ body: board?.title || board?.body || null,
438
+ data: { boardId: board?.id || undefined },
439
+ });
440
+ }
441
+
442
+ function notifyCliOffline() {
443
+ return dispatch({ type: 'cliOffline' });
444
+ }
445
+
446
+ return {
447
+ listDevices,
448
+ getDevice,
449
+ upsertSubscription,
450
+ updateDevice,
451
+ deleteDevice,
452
+ dispatch,
453
+ notifyQuestion,
454
+ notifyTurnComplete,
455
+ notifyTurnFailed,
456
+ notifyBoard,
457
+ notifyCliOffline,
458
+ };
459
+ }
@@ -0,0 +1,63 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Fixed-window rate limiter
3
+ //
4
+ // Extracted verbatim from the shared-presence limiter in routes/sessions-routes.mjs
5
+ // so the same behaviour can guard other endpoints. Buckets expire on a TTL and
6
+ // the oldest are evicted once maxBuckets is exceeded, so a hostile caller cannot
7
+ // grow the map without bound.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export function createFixedWindowRateLimiter({
11
+ windowMs = 10_000,
12
+ limit = 24,
13
+ bucketTtlMs = 60_000,
14
+ maxBuckets = 4_096,
15
+ nowMs = Date.now,
16
+ } = {}) {
17
+ const buckets = new Map();
18
+
19
+ function prune(reference = nowMs()) {
20
+ for (const [bucketKey, bucket] of buckets.entries()) {
21
+ const lastSeenAt = Number(bucket?.lastSeenAt || 0);
22
+ if (!Number.isFinite(lastSeenAt) || (reference - lastSeenAt) > bucketTtlMs) {
23
+ buckets.delete(bucketKey);
24
+ }
25
+ }
26
+ if (buckets.size <= maxBuckets) return;
27
+ const entries = Array.from(buckets.entries());
28
+ entries.sort((a, b) => Number(a[1]?.lastSeenAt || 0) - Number(b[1]?.lastSeenAt || 0));
29
+ const overflow = buckets.size - maxBuckets;
30
+ for (let index = 0; index < overflow; index += 1) {
31
+ const key = entries[index]?.[0];
32
+ if (!key) continue;
33
+ buckets.delete(key);
34
+ }
35
+ }
36
+
37
+ function consume(key, reference = nowMs()) {
38
+ const bucketKey = String(key || '').trim();
39
+ if (!bucketKey) return { ok: false, retryAfterSeconds: 1 };
40
+ const existing = buckets.get(bucketKey) || {
41
+ windowStartAt: reference,
42
+ count: 0,
43
+ lastSeenAt: reference,
44
+ };
45
+ if (!Number.isFinite(existing.windowStartAt) || (reference - existing.windowStartAt) >= windowMs) {
46
+ existing.windowStartAt = reference;
47
+ existing.count = 0;
48
+ }
49
+ existing.lastSeenAt = reference;
50
+ if (existing.count >= limit) {
51
+ buckets.set(bucketKey, existing);
52
+ const retryAfterMs = Math.max(250, windowMs - (reference - existing.windowStartAt));
53
+ prune(reference);
54
+ return { ok: false, retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)) };
55
+ }
56
+ existing.count += 1;
57
+ buckets.set(bucketKey, existing);
58
+ prune(reference);
59
+ return { ok: true, retryAfterSeconds: 0 };
60
+ }
61
+
62
+ return { consume, prune, size: () => buckets.size };
63
+ }
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ function normalizeInt(value) {
4
+ const num = Number.parseInt(String(value || ''), 10);
5
+ return Number.isInteger(num) && num > 0 ? num : null;
6
+ }
7
+
8
+ function normalizeText(value) {
9
+ const text = String(value || '').trim();
10
+ return text || null;
11
+ }
12
+
13
+ function identitiesMatch(owner, identity) {
14
+ if (!owner || !identity) return false;
15
+ if (owner.pid && identity.pid) return owner.pid === identity.pid;
16
+ if (owner.sessionId && identity.sessionId) return owner.sessionId === identity.sessionId;
17
+ return false;
18
+ }
19
+
20
+ export function createRelayBridgeOwnerService({ staleMs = 10_000, now = () => Date.now() } = {}) {
21
+ let owner = null;
22
+
23
+ function normalizeIdentity(raw = null) {
24
+ if (!raw || typeof raw !== 'object') return null;
25
+ const identity = {
26
+ pid: normalizeInt(raw.pid),
27
+ parentPid: normalizeInt(raw.parentPid),
28
+ sessionId: normalizeText(raw.sessionId),
29
+ conversationId: normalizeText(raw.conversationId),
30
+ };
31
+ if (!identity.pid && !identity.sessionId) return null;
32
+ return identity;
33
+ }
34
+
35
+ function isOwnerStale(current = owner) {
36
+ if (!current?.lastSeenAt) return true;
37
+ const lastSeenMs = new Date(current.lastSeenAt).getTime();
38
+ if (!Number.isFinite(lastSeenMs)) return true;
39
+ return (now() - lastSeenMs) > staleMs;
40
+ }
41
+
42
+ function observe(rawIdentity, { allowTakeover = false } = {}) {
43
+ const identity = normalizeIdentity(rawIdentity);
44
+ if (!identity) {
45
+ return { accepted: false, adopted: false, owner: owner ? { ...owner } : null };
46
+ }
47
+
48
+ const sameOwner = identitiesMatch(owner, identity);
49
+ const adopt = !owner || sameOwner || allowTakeover || isOwnerStale(owner);
50
+ if (!adopt) {
51
+ return { accepted: false, adopted: false, owner: owner ? { ...owner } : null };
52
+ }
53
+
54
+ owner = {
55
+ ...owner,
56
+ ...identity,
57
+ lastSeenAt: new Date(now()).toISOString(),
58
+ };
59
+ return { accepted: true, adopted: !sameOwner, owner: { ...owner } };
60
+ }
61
+
62
+ function isOwner(rawIdentity) {
63
+ const identity = normalizeIdentity(rawIdentity);
64
+ if (!identity || !owner) return false;
65
+ return identitiesMatch(owner, identity);
66
+ }
67
+
68
+ function clearOwner() {
69
+ owner = null;
70
+ }
71
+
72
+ function getOwner() {
73
+ return owner ? { ...owner } : null;
74
+ }
75
+
76
+ return {
77
+ normalizeIdentity,
78
+ observe,
79
+ isOwner,
80
+ isOwnerStale,
81
+ clearOwner,
82
+ getOwner,
83
+ };
84
+ }