@loicngr/kobo 1.11.39 → 1.11.40

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 (145) hide show
  1. package/AGENTS.md +12 -0
  2. package/CHANGELOG.md +4 -0
  3. package/dist/mcp-server/kobo-tasks-handlers.js +16 -9
  4. package/dist/mcp-server/kobo-tasks-server.js +13 -2
  5. package/dist/mcp-server/workspace-dialogue-client.js +28 -0
  6. package/dist/server/db/mcp-message-schema.js +16 -0
  7. package/dist/server/db/migrations.js +4 -0
  8. package/dist/server/db/schema.js +4 -0
  9. package/dist/server/db/search-schema.js +53 -0
  10. package/dist/server/index.js +17 -86
  11. package/dist/server/middleware/network-auth-middleware.js +5 -1
  12. package/dist/server/routes/changelog.js +4 -49
  13. package/dist/server/routes/git.js +2 -2
  14. package/dist/server/routes/mcp.js +47 -0
  15. package/dist/server/routes/pull-requests.js +1 -1
  16. package/dist/server/routes/search.js +13 -4
  17. package/dist/server/routes/settings.js +5 -0
  18. package/dist/server/routes/workspaces.js +225 -241
  19. package/dist/server/services/agent/engines/claude-code/engine.js +31 -33
  20. package/dist/server/services/agent/engines/codex/engine.js +37 -8
  21. package/dist/server/services/agent/engines/codex/event-mapper.js +3 -1
  22. package/dist/server/services/agent/engines/codex/options-builder.js +4 -3
  23. package/dist/server/services/agent/engines/codex/server-requests.js +8 -1
  24. package/dist/server/services/agent/orchestrator.js +20 -3
  25. package/dist/server/services/change-source-branch-service.js +10 -42
  26. package/dist/server/services/chat-receipt-service.js +33 -0
  27. package/dist/server/services/cron-service.js +27 -1
  28. package/dist/server/services/mcp-client-context.js +12 -0
  29. package/dist/server/services/mcp-connection-info-service.js +63 -0
  30. package/dist/server/services/mcp-message-delivery-service.js +59 -0
  31. package/dist/server/services/mcp-message-request-service.js +64 -0
  32. package/dist/server/services/pr-checkout-service.js +10 -10
  33. package/dist/server/services/pr-watcher-service.js +14 -9
  34. package/dist/server/services/search/indexer.js +232 -0
  35. package/dist/server/services/search/worker.js +76 -0
  36. package/dist/server/services/search-service.js +104 -110
  37. package/dist/server/services/update-check-service.js +119 -0
  38. package/dist/server/services/websocket-service.js +121 -35
  39. package/dist/server/services/workspace-dialogue-service.js +128 -0
  40. package/dist/server/services/workspace-message-service.js +58 -0
  41. package/dist/server/services/workspace-permission-policy-service.js +33 -3
  42. package/dist/server/services/worktree-restore-service.js +2 -2
  43. package/dist/server/services/worktree-service.js +197 -124
  44. package/dist/server/utils/bounded-process.js +137 -0
  45. package/dist/server/utils/git-ops.js +76 -128
  46. package/dist/server/utils/git-repo-lock.js +27 -13
  47. package/dist/shared/workspace-dialogue-tools.js +89 -0
  48. package/dist/shared/workspace-message-types.js +19 -0
  49. package/package.json +2 -1
  50. package/src/client/dist/pwa/assets/{ActivityFeed-Chsw1Mh7.css → ActivityFeed-BYR5x7aa.css} +1 -1
  51. package/src/client/dist/pwa/assets/ActivityFeed-MqsC-95i.js +8 -0
  52. package/src/client/dist/pwa/assets/{ChangelogPage-D8563008.js → ChangelogPage-BxURWYxa.js} +1 -1
  53. package/src/client/dist/pwa/assets/{ComparisonPanel-Bd0X08sg.js → ComparisonPanel-DjiMDAUi.js} +2 -2
  54. package/src/client/dist/pwa/assets/CreatePage-B-rhBmqv.js +2 -0
  55. package/src/client/dist/pwa/assets/DashboardPage-mA5RZf1t.js +1 -0
  56. package/src/client/dist/pwa/assets/DiffViewer-Ci7ucmy3.js +8 -0
  57. package/src/client/dist/pwa/assets/{DiffViewer-DnR79ycb.css → DiffViewer-DEPOa3D7.css} +1 -1
  58. package/src/client/dist/pwa/assets/{DocumentsPanel-BJsJ0fNs.js → DocumentsPanel-RZTvcJkT.js} +1 -1
  59. package/src/client/dist/pwa/assets/{DrawerToggleButton-BVLO5K5d.js → DrawerToggleButton-CTOc3yA0.js} +1 -1
  60. package/src/client/dist/pwa/assets/GitPanel-_kL2gMoy.js +3 -0
  61. package/src/client/dist/pwa/assets/{HealthPage-RFZS7Lao.js → HealthPage-D3__hKJ5.js} +1 -1
  62. package/src/client/dist/pwa/assets/MainLayout-B3JKUm3n.js +2 -0
  63. package/src/client/dist/pwa/assets/{QBtnDropdown-X8oNU5Mb.js → QBtnDropdown-td0RAVPx.js} +1 -1
  64. package/src/client/dist/pwa/assets/{QDrawer-Cij17skX.js → QDrawer-BkyFPV7a.js} +1 -1
  65. package/src/client/dist/pwa/assets/QSelect-WspK3KC7.js +1 -0
  66. package/src/client/dist/pwa/assets/{QTooltip-CwcCGSWq.js → QTooltip-7pw7iuV0.js} +1 -1
  67. package/src/client/dist/pwa/assets/SchedulePanel-BAA4Aajn.css +1 -0
  68. package/src/client/dist/pwa/assets/SchedulePanel-CLNM13oo.js +1 -0
  69. package/src/client/dist/pwa/assets/SearchPage-BGc1oQSM.css +1 -0
  70. package/src/client/dist/pwa/assets/SearchPage-CmRgIvu4.js +1 -0
  71. package/src/client/dist/pwa/assets/SettingsPage-Cj6Y_TlN.js +9 -0
  72. package/src/client/dist/pwa/assets/SettingsPage-sVlsJQ0b.css +1 -0
  73. package/src/client/dist/pwa/assets/{SplitWorkspacePage-D7lGFRKs.js → SplitWorkspacePage-CCHGEsKf.js} +1 -1
  74. package/src/client/dist/pwa/assets/{TerminalPanel-DI8DHb9e.js → TerminalPanel-ijmsYk-2.js} +9 -9
  75. package/src/client/dist/pwa/assets/{TouchPan-B3nrkCY8.js → TouchPan-DuQFjlQF.js} +1 -1
  76. package/src/client/dist/pwa/assets/{TourReplayButton-DU1UShMY.js → TourReplayButton-C5RF0lOK.js} +1 -1
  77. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-C-yN5IX8.js → WorkspaceAttentionLabels-BR_XElog.js} +1 -1
  78. package/src/client/dist/pwa/assets/WorkspacePage-BGCildyP.js +4 -0
  79. package/src/client/dist/pwa/assets/{WorkspacePage-BAC6u9OP.css → WorkspacePage-CqcMweNL.css} +1 -1
  80. package/src/client/dist/pwa/assets/{browser-f9MHQjfi.js → browser-DUqzdkbg.js} +1 -1
  81. package/src/client/dist/pwa/assets/{build-path-tree-BWBzP8os.js → build-path-tree-ImiSpzfI.js} +1 -1
  82. package/src/client/dist/pwa/assets/de-CaHID9mW.js +17 -0
  83. package/src/client/dist/pwa/assets/{documents-C2cLnEBQ.js → documents-2C1hn1pw.js} +1 -1
  84. package/src/client/dist/pwa/assets/{editor.api-sw146MWN.js → editor.api-Bm1vVaBr.js} +1 -1
  85. package/src/client/dist/pwa/assets/{efforts-C5ZZgCH4.js → efforts-_D8DFYXo.js} +1 -1
  86. package/src/client/dist/pwa/assets/engineFeatures-CZ9qfBYq.js +1 -0
  87. package/src/client/dist/pwa/assets/es-ZhzlCmer.js +17 -0
  88. package/src/client/dist/pwa/assets/{expand-template-B6xAlAaM.js → expand-template-CbDNnTPP.js} +1 -1
  89. package/src/client/dist/pwa/assets/{format-BwWP6Aay.js → format-pPImdFUy.js} +1 -1
  90. package/src/client/dist/pwa/assets/{formatters-DyWtO-nR.js → formatters-dTX38ZsV.js} +1 -1
  91. package/src/client/dist/pwa/assets/fr-BfXw7L7J.js +17 -0
  92. package/src/client/dist/pwa/assets/{freemarker2-DZ5Hm8UH.js → freemarker2-DGluDnAm.js} +1 -1
  93. package/src/client/dist/pwa/assets/{handlebars-BRUGpTA7.js → handlebars-BgviTnpD.js} +1 -1
  94. package/src/client/dist/pwa/assets/{html-DNOayNNs.js → html-CV-MrunL.js} +1 -1
  95. package/src/client/dist/pwa/assets/i18n-CmXBenUp.js +1 -0
  96. package/src/client/dist/pwa/assets/index-9tiw3NjK.js +20 -0
  97. package/src/client/dist/pwa/assets/it-CHAurcTF.js +17 -0
  98. package/src/client/dist/pwa/assets/{javascript-C0tDQU3z.js → javascript-C2VFcHf2.js} +1 -1
  99. package/src/client/dist/pwa/assets/{liquid-CN46O6OF.js → liquid-Dg9ZhbP5.js} +1 -1
  100. package/src/client/dist/pwa/assets/{mdx-DM-lrr8Q.js → mdx-ImgrPYNl.js} +1 -1
  101. package/src/client/dist/pwa/assets/{monaco-contributions-CkN7y-8c.js → monaco-contributions-waWZXS1T.js} +1 -1
  102. package/src/client/dist/pwa/assets/{monaco.contribution-2BcAOLjc.js → monaco.contribution-BB2rtCHM.js} +2 -2
  103. package/src/client/dist/pwa/assets/network-auth-h6O0qCMA.js +1 -0
  104. package/src/client/dist/pwa/assets/{notify-theme-a91Tcr6o.js → notify-theme-Dyg-FGI6.js} +1 -1
  105. package/src/client/dist/pwa/assets/{pending-diff-open-DOtELZfB.js → pending-diff-open-Dfpg92li.js} +1 -1
  106. package/src/client/dist/pwa/assets/{permissionModes-C-9Crj-c.js → permissionModes-DDWmNBW1.js} +1 -1
  107. package/src/client/dist/pwa/assets/{project-color-BRe4oyNx.js → project-color-XUfY_zbw.js} +1 -1
  108. package/src/client/dist/pwa/assets/{python-BRpJG6sZ.js → python-DVLs-DBA.js} +1 -1
  109. package/src/client/dist/pwa/assets/{razor-CehCmvHl.js → razor-D34BqMwB.js} +1 -1
  110. package/src/client/dist/pwa/assets/{render-chat-markdown-DlbQLZ02.js → render-chat-markdown-DwUkPy-O.js} +1 -1
  111. package/src/client/dist/pwa/assets/{typescript-Dt_8mVuj.js → typescript-C7-UpNXx.js} +1 -1
  112. package/src/client/dist/pwa/assets/use-search-index-status-DtLzygcy.js +1 -0
  113. package/src/client/dist/pwa/assets/use-tours-DfE-kwrX.js +2 -0
  114. package/src/client/dist/pwa/assets/{workspace-templates-GCObcjO6.js → workspace-templates-DwwdOEAq.js} +1 -1
  115. package/src/client/dist/pwa/assets/{xml-_gOwdzB4.js → xml-Cx1oQhEr.js} +1 -1
  116. package/src/client/dist/pwa/assets/{yaml-BxeBqpGf.js → yaml-BQOiJsdx.js} +1 -1
  117. package/src/client/dist/pwa/index.html +1 -1
  118. package/src/client/dist/pwa/sw.js +1 -1
  119. package/src/mcp-server/README.md +118 -2
  120. package/src/mcp-server/kobo-tasks-handlers.ts +14 -11
  121. package/src/mcp-server/kobo-tasks-server.ts +19 -2
  122. package/src/mcp-server/workspace-dialogue-client.ts +34 -0
  123. package/src/client/dist/pwa/assets/ActivityFeed-BzzvcOmq.js +0 -8
  124. package/src/client/dist/pwa/assets/CreatePage-Bon8qIka.js +0 -2
  125. package/src/client/dist/pwa/assets/DashboardPage-DYxdxEul.js +0 -1
  126. package/src/client/dist/pwa/assets/DiffViewer-BpsLNxnj.js +0 -8
  127. package/src/client/dist/pwa/assets/GitPanel-Cal_wzC0.js +0 -3
  128. package/src/client/dist/pwa/assets/MainLayout-vvyiRcql.js +0 -2
  129. package/src/client/dist/pwa/assets/QSelect-xd-AlGo_.js +0 -1
  130. package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +0 -1
  131. package/src/client/dist/pwa/assets/SchedulePanel-CXRUsz6G.js +0 -1
  132. package/src/client/dist/pwa/assets/SearchPage-CVgRa025.css +0 -1
  133. package/src/client/dist/pwa/assets/SearchPage-DopbgLxh.js +0 -1
  134. package/src/client/dist/pwa/assets/SettingsPage-Do9d0Pov.css +0 -1
  135. package/src/client/dist/pwa/assets/SettingsPage-Jx6TnOzR.js +0 -9
  136. package/src/client/dist/pwa/assets/WorkspacePage-DKRpdpPg.js +0 -4
  137. package/src/client/dist/pwa/assets/de-QkDLrcGq.js +0 -17
  138. package/src/client/dist/pwa/assets/engineFeatures-CAVMDawq.js +0 -1
  139. package/src/client/dist/pwa/assets/es-BgQFKlo8.js +0 -17
  140. package/src/client/dist/pwa/assets/fr-DAdLWjNM.js +0 -17
  141. package/src/client/dist/pwa/assets/i18n-CBez_vip.js +0 -1
  142. package/src/client/dist/pwa/assets/index-VL3fI9a3.js +0 -20
  143. package/src/client/dist/pwa/assets/it-Bumaewp1.js +0 -17
  144. package/src/client/dist/pwa/assets/network-auth-KYS4snQH.js +0 -1
  145. package/src/client/dist/pwa/assets/use-tours-CSVM_54K.js +0 -2
@@ -1,117 +1,111 @@
1
+ import { Worker } from 'node:worker_threads';
1
2
  import { getDb } from '../db/index.js';
2
- /** Event types that carry user-authored or assistant-authored readable text. */
3
- const SEARCHABLE_TYPES = ['user:message', 'agent:event', 'agent:output'];
4
- const SNIPPET_CONTEXT = 100; // chars on each side of the match
5
- /**
6
- * Extract the readable text content of a ws_events payload, or `null` when
7
- * the event carries no natural-language content (system events, rate-limit
8
- * pings, etc.).
9
- */
10
- function extractReadableText(type, payload) {
11
- if (!payload || typeof payload !== 'object')
12
- return null;
13
- const p = payload;
14
- if (type === 'user:message') {
15
- return typeof p.content === 'string' ? p.content : null;
16
- }
17
- if (type === 'agent:output') {
18
- // Claude Code streams `{type: 'assistant', message: {content: [{type: 'text', text: '...'}, ...]}}`
19
- const msg = p.message;
20
- if (msg && Array.isArray(msg.content)) {
21
- const parts = [];
22
- for (const block of msg.content) {
23
- if (block && typeof block === 'object') {
24
- const b = block;
25
- if (b.type === 'text' && typeof b.text === 'string')
26
- parts.push(b.text);
27
- }
28
- }
29
- return parts.length > 0 ? parts.join('\n') : null;
3
+ let active;
4
+ let requestId = 0;
5
+ /** Start lazily for service consumers, eagerly at server boot. Never opens another home directory. */
6
+ export function startSearchIndex() {
7
+ const dbPath = getDb().name;
8
+ if (active?.dbPath === dbPath)
9
+ return;
10
+ if (active)
11
+ void stopSearchIndex();
12
+ const development = import.meta.url.endsWith('.ts');
13
+ const entry = new URL(`./search/worker.${development ? 'ts' : 'js'}`, import.meta.url);
14
+ const worker = development
15
+ ? new Worker(`const { workerData } = require('node:worker_threads'); import('tsx/esm/api').then(({ tsImport }) => tsImport(workerData.entry, workerData.parent));`, {
16
+ eval: true,
17
+ workerData: { dbPath, entry: entry.href, parent: import.meta.url },
18
+ })
19
+ : new Worker(entry, { workerData: { dbPath } });
20
+ const state = {
21
+ worker,
22
+ dbPath,
23
+ pending: new Map(),
24
+ status: { state: 'building', processed: 0, total: 0 },
25
+ };
26
+ active = state;
27
+ worker.unref();
28
+ const fail = (error) => {
29
+ state.status = { ...state.status, state: 'error', error: error.message };
30
+ for (const pending of state.pending.values()) {
31
+ pending.cleanup();
32
+ pending.reject(error);
30
33
  }
31
- }
32
- if (type === 'agent:event' && p.kind === 'message:text') {
33
- return typeof p.text === 'string' ? p.text : null;
34
- }
35
- return null;
34
+ state.pending.clear();
35
+ };
36
+ worker.on('error', fail);
37
+ worker.on('exit', (code) => {
38
+ if (active === state)
39
+ fail(new Error(`Search worker stopped (${code})`));
40
+ });
41
+ worker.on('message', (message) => {
42
+ if (message.status)
43
+ state.status = message.status;
44
+ if (message.id !== undefined) {
45
+ const pending = state.pending.get(message.id);
46
+ if (!pending)
47
+ return;
48
+ state.pending.delete(message.id);
49
+ pending.cleanup();
50
+ if (message.error)
51
+ pending.reject(new Error(message.error));
52
+ else
53
+ pending.resolve(message.results ?? []);
54
+ }
55
+ });
36
56
  }
37
- function buildSnippet(text, matchIndex, query) {
38
- const start = Math.max(0, matchIndex - SNIPPET_CONTEXT);
39
- const end = Math.min(text.length, matchIndex + query.length + SNIPPET_CONTEXT);
40
- const prefix = start > 0 ? '…' : '';
41
- const suffix = end < text.length ? '…' : '';
42
- return `${prefix}${text.slice(start, end).trim()}${suffix}`;
57
+ export function getSearchIndexStatus() {
58
+ startSearchIndex();
59
+ return { ...active.status };
43
60
  }
44
- /**
45
- * Full-text-ish search across `ws_events.payload` joined with `workspaces`.
46
- *
47
- * - Trimmed empty queries return `[]` without hitting the database.
48
- * - SQLite does the first filter via `LIKE '%q%'` on the raw payload JSON.
49
- * Results are then post-filtered in JS against the **readable** text
50
- * (extracted from the JSON) to avoid false positives like matches inside
51
- * field names or schema strings.
52
- * - Snippets are 100 chars of context on either side of the first match.
53
- */
54
- export function searchEvents(query, options = {}) {
55
- const trimmed = query.trim();
56
- if (trimmed.length === 0)
61
+ export async function searchEvents(query, options = {}, signal) {
62
+ if (!query.trim())
57
63
  return [];
58
- const { limit = 50, includeArchived = false } = options;
59
- const db = getDb();
60
- const typePlaceholders = SEARCHABLE_TYPES.map(() => '?').join(', ');
61
- const archiveFilter = includeArchived ? '' : 'AND w.archived_at IS NULL';
62
- const escapedQuery = trimmed.replace(/[\\%_]/g, '\\$&');
63
- const likePattern = `%${escapedQuery}%`;
64
- const needle = trimmed.toLowerCase();
65
- const results = [];
66
- const batchSize = Math.min(Math.max(limit * 3, 100), 500);
67
- const statement = db.prepare(`SELECT e.id, e.workspace_id, e.session_id, e.type, e.created_at, e.payload,
68
- w.name AS workspace_name, w.archived_at
69
- FROM ws_events e
70
- JOIN workspaces w ON e.workspace_id = w.id
71
- WHERE e.type IN (${typePlaceholders})
72
- AND e.payload LIKE ? ESCAPE '\\'
73
- ${archiveFilter}
74
- AND (? IS NULL OR e.created_at < ? OR (e.created_at = ? AND e.id < ?))
75
- ORDER BY e.created_at DESC, e.id DESC
76
- LIMIT ?`);
77
- let cursorCreatedAt = null;
78
- let cursorId = null;
79
- while (results.length < limit) {
80
- const rows = statement.all(...SEARCHABLE_TYPES, likePattern, cursorCreatedAt, cursorCreatedAt, cursorCreatedAt, cursorId, batchSize);
81
- for (const row of rows) {
82
- let parsed;
83
- try {
84
- parsed = JSON.parse(row.payload);
85
- }
86
- catch {
87
- continue;
88
- }
89
- const text = extractReadableText(row.type, parsed);
90
- if (!text)
91
- continue;
92
- const idx = text.toLowerCase().indexOf(needle);
93
- if (idx < 0)
94
- continue;
95
- results.push({
96
- eventId: row.id,
97
- sessionId: row.session_id,
98
- workspaceId: row.workspace_id,
99
- workspaceName: row.workspace_name,
100
- archived: row.archived_at !== null,
101
- type: row.type,
102
- timestamp: row.created_at,
103
- snippet: buildSnippet(text, idx, trimmed),
104
- });
105
- if (results.length >= limit)
106
- break;
107
- }
108
- if (rows.length < batchSize)
109
- break;
110
- const last = rows.at(-1);
111
- if (!last)
112
- break;
113
- cursorCreatedAt = last.created_at;
114
- cursorId = last.id;
64
+ if (signal?.aborted)
65
+ throw new Error('Search cancelled');
66
+ startSearchIndex();
67
+ const state = active;
68
+ if (state.status.state === 'error')
69
+ throw new Error(state.status.error ?? 'Search index unavailable');
70
+ const id = ++requestId;
71
+ return new Promise((resolve, reject) => {
72
+ const cancel = (message) => {
73
+ state.pending.delete(id);
74
+ cleanup();
75
+ state.worker.postMessage({ id, cancel: true });
76
+ reject(new Error(message));
77
+ };
78
+ const aborted = () => cancel('Search cancelled');
79
+ const timeout = setTimeout(() => cancel('Search timed out'), 10_000);
80
+ const cleanup = () => {
81
+ clearTimeout(timeout);
82
+ signal?.removeEventListener('abort', aborted);
83
+ };
84
+ state.pending.set(id, { resolve, reject, cleanup });
85
+ signal?.addEventListener('abort', aborted, { once: true });
86
+ state.worker.postMessage({ id, query, options });
87
+ });
88
+ }
89
+ export async function stopSearchIndex() {
90
+ const state = active;
91
+ if (!state)
92
+ return;
93
+ active = undefined;
94
+ for (const pending of state.pending.values()) {
95
+ pending.cleanup();
96
+ pending.reject(new Error('Search index stopped'));
115
97
  }
116
- return results;
98
+ state.pending.clear();
99
+ if (state.worker.threadId === -1)
100
+ return;
101
+ await new Promise((resolve) => {
102
+ const timeout = setTimeout(() => {
103
+ void state.worker.terminate();
104
+ }, 5_000);
105
+ state.worker.once('exit', () => {
106
+ clearTimeout(timeout);
107
+ resolve();
108
+ });
109
+ state.worker.postMessage({ stop: true });
110
+ });
117
111
  }
@@ -0,0 +1,119 @@
1
+ import { getPackageVersion } from '../utils/paths.js';
2
+ import { broadcastAll } from './websocket-service.js';
3
+ export const UPDATE_CHECK_INTERVAL_MS = 20 * 60 * 1000;
4
+ const REGISTRY_URL = 'https://registry.npmjs.org/@loicngr/kobo/latest';
5
+ const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
6
+ let latestVersion = null;
7
+ let lastAttempt = null;
8
+ let lastSuccessAt = null;
9
+ let checkStatus = 'unknown';
10
+ let inFlight = null;
11
+ let controller = null;
12
+ let timer = null;
13
+ let running = false;
14
+ let generation = 0;
15
+ let stopped = false;
16
+ export function getUpdateCheckSnapshot() {
17
+ return {
18
+ currentVersion: getPackageVersion(),
19
+ latestVersion,
20
+ lastAttemptAt: lastAttempt === null ? null : new Date(lastAttempt).toISOString(),
21
+ lastSuccessAt,
22
+ checkStatus,
23
+ };
24
+ }
25
+ /** Shared by HTTP loads and the single process-wide poller; failures are cached too. */
26
+ export function refreshUpdateCheck() {
27
+ if (inFlight)
28
+ return inFlight;
29
+ if (stopped || (lastAttempt !== null && Date.now() - lastAttempt < UPDATE_CHECK_INTERVAL_MS)) {
30
+ return Promise.resolve(getUpdateCheckSnapshot());
31
+ }
32
+ lastAttempt = Date.now();
33
+ const owner = generation;
34
+ const abort = new AbortController();
35
+ controller = abort;
36
+ const timeout = setTimeout(() => abort.abort(), 5000);
37
+ timeout.unref?.();
38
+ const aborted = new Promise((_, reject) => {
39
+ abort.signal.addEventListener('abort', () => reject(new Error('Update lookup cancelled')), { once: true });
40
+ });
41
+ const lookup = (async () => {
42
+ const res = await fetch(REGISTRY_URL, { signal: abort.signal });
43
+ if (!res.ok)
44
+ throw new Error('Registry lookup failed');
45
+ const body = await res.json();
46
+ if (!body ||
47
+ typeof body !== 'object' ||
48
+ !('version' in body) ||
49
+ typeof body.version !== 'string' ||
50
+ !STABLE_VERSION.test(body.version)) {
51
+ throw new Error('Invalid registry version');
52
+ }
53
+ return body.version;
54
+ })();
55
+ inFlight = (async () => {
56
+ try {
57
+ const version = await Promise.race([lookup, aborted]);
58
+ if (owner !== generation)
59
+ return getUpdateCheckSnapshot();
60
+ latestVersion = version;
61
+ lastSuccessAt = new Date().toISOString();
62
+ checkStatus = 'success';
63
+ }
64
+ catch {
65
+ if (owner !== generation)
66
+ return getUpdateCheckSnapshot();
67
+ checkStatus = 'failed';
68
+ }
69
+ finally {
70
+ clearTimeout(timeout);
71
+ if (owner === generation) {
72
+ inFlight = null;
73
+ controller = null;
74
+ }
75
+ }
76
+ const snapshot = getUpdateCheckSnapshot();
77
+ broadcastAll('kobo:update-checked', snapshot);
78
+ return snapshot;
79
+ })();
80
+ return inFlight;
81
+ }
82
+ function scheduleNextCheck(owner) {
83
+ if (!running || owner !== generation)
84
+ return;
85
+ const delay = lastAttempt === null ? 0 : Math.max(0, UPDATE_CHECK_INTERVAL_MS - (Date.now() - lastAttempt));
86
+ timer = setTimeout(() => {
87
+ timer = null;
88
+ void refreshUpdateCheck().finally(() => scheduleNextCheck(owner));
89
+ }, delay);
90
+ timer.unref?.();
91
+ }
92
+ export function startUpdateChecker() {
93
+ if (running)
94
+ return;
95
+ running = true;
96
+ stopped = false;
97
+ const owner = generation;
98
+ void refreshUpdateCheck().finally(() => scheduleNextCheck(owner));
99
+ }
100
+ export function stopUpdateChecker() {
101
+ running = false;
102
+ if (timer)
103
+ clearTimeout(timer);
104
+ timer = null;
105
+ stopped = true;
106
+ generation++;
107
+ controller?.abort();
108
+ controller = null;
109
+ inFlight = null;
110
+ }
111
+ /** @internal test-only — clear the scheduler and registry cache. */
112
+ export function _clearLatestVersionCache() {
113
+ stopUpdateChecker();
114
+ stopped = false;
115
+ latestVersion = null;
116
+ lastAttempt = null;
117
+ lastSuccessAt = null;
118
+ checkStatus = 'unknown';
119
+ }
@@ -5,10 +5,69 @@ import { getAllPersistedSnapshots } from './usage/db.js';
5
5
  // ── State ──────────────────────────────────────────────────────────────────────
6
6
  /** Maps each WS client to the set of workspaceIds they are subscribed to */
7
7
  const clients = new Map();
8
- /** Above this many bytes queued for a single client, we stop sending it events
9
- * rather than growing the process heap on its behalf. It will catch up through
8
+ /** Above this many bytes queued for a single client, we terminate the connection
9
+ * before a later event can advance its cursor past an undelivered frame. It will catch up through
10
10
  * `sync:request` on its next reconnect. */
11
11
  const MAX_BUFFERED_BYTES = 1024 * 1024;
12
+ /** A dropped frame must end the connection so its replay cursor cannot skip it. */
13
+ function sendFrame(ws, message) {
14
+ if (ws.readyState !== 1)
15
+ return;
16
+ if (ws.bufferedAmount > MAX_BUFFERED_BYTES) {
17
+ clients.delete(ws);
18
+ ws.terminate();
19
+ return;
20
+ }
21
+ try {
22
+ ws.send(message);
23
+ }
24
+ catch {
25
+ clients.delete(ws);
26
+ ws.terminate();
27
+ }
28
+ }
29
+ function isRecord(value) {
30
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
31
+ }
32
+ function validMessage(value) {
33
+ if (!isRecord(value) || typeof value.type !== 'string')
34
+ return false;
35
+ const p = value.payload;
36
+ if (!isRecord(p))
37
+ return false;
38
+ if (value.type === 'sync:request') {
39
+ return ((p.lastEventId === undefined || typeof p.lastEventId === 'string') &&
40
+ (p.workspaceIds === undefined ||
41
+ (Array.isArray(p.workspaceIds) && p.workspaceIds.every((id) => typeof id === 'string' && id.length > 0))));
42
+ }
43
+ if (![
44
+ 'subscribe',
45
+ 'unsubscribe',
46
+ 'chat:message',
47
+ 'workspace:start',
48
+ 'workspace:stop',
49
+ 'devserver:start',
50
+ 'devserver:stop',
51
+ ].includes(value.type))
52
+ return true;
53
+ if (typeof p.workspaceId !== 'string' || !p.workspaceId)
54
+ return false;
55
+ if (p.sessionId !== undefined && typeof p.sessionId !== 'string')
56
+ return false;
57
+ if (value.type === 'workspace:start' && p.prompt !== undefined && typeof p.prompt !== 'string')
58
+ return false;
59
+ if (value.type === 'chat:message') {
60
+ return (typeof p.content === 'string' &&
61
+ p.content.length > 0 &&
62
+ (p.sessionId === undefined || typeof p.sessionId === 'string') &&
63
+ (p.clientMessageId === undefined || (typeof p.clientMessageId === 'string' && p.clientMessageId.length > 0)) &&
64
+ (p.force === undefined || typeof p.force === 'boolean') &&
65
+ (p.agentPermissionModeOverride === undefined ||
66
+ (typeof p.agentPermissionModeOverride === 'string' &&
67
+ ['plan', 'bypass', 'strict', 'interactive'].includes(p.agentPermissionModeOverride))));
68
+ }
69
+ return true;
70
+ }
12
71
  let messageHandler = null;
13
72
  /** Register the handler that processes routed WS messages (e.g. chat:message, workspace:start). */
14
73
  export function setMessageHandler(handler) {
@@ -23,7 +82,7 @@ export function handleConnection(ws) {
23
82
  // immediately instead of waiting up to POLL_INTERVAL_MS for the next tick.
24
83
  try {
25
84
  for (const snap of getAllPersistedSnapshots()) {
26
- ws.send(JSON.stringify({
85
+ sendFrame(ws, JSON.stringify({
27
86
  type: 'usage:snapshot',
28
87
  payload: { providerId: snap.providerId, snapshot: snap },
29
88
  }));
@@ -35,10 +94,18 @@ export function handleConnection(ws) {
35
94
  ws.on('message', (data) => {
36
95
  let msg;
37
96
  try {
38
- msg = JSON.parse(data.toString());
97
+ const parsed = JSON.parse(data.toString());
98
+ if (!validMessage(parsed)) {
99
+ sendFrame(ws, JSON.stringify({
100
+ type: 'error',
101
+ payload: { message: 'Invalid message payload (check workspaceId and field types)' },
102
+ }));
103
+ return;
104
+ }
105
+ msg = parsed;
39
106
  }
40
107
  catch {
41
- ws.send(JSON.stringify({ type: 'error', payload: { message: 'Invalid JSON' } }));
108
+ sendFrame(ws, JSON.stringify({ type: 'error', payload: { message: 'Invalid JSON' } }));
42
109
  return;
43
110
  }
44
111
  const { type, payload } = msg;
@@ -46,23 +113,23 @@ export function handleConnection(ws) {
46
113
  case 'subscribe': {
47
114
  const workspaceId = payload?.workspaceId;
48
115
  if (!workspaceId) {
49
- ws.send(JSON.stringify({ type: 'error', payload: { message: 'Missing workspaceId' } }));
116
+ sendFrame(ws, JSON.stringify({ type: 'error', payload: { message: 'Missing workspaceId' } }));
50
117
  return;
51
118
  }
52
119
  const subs = clients.get(ws);
53
120
  subs?.add(workspaceId);
54
- ws.send(JSON.stringify({ type: 'subscribed', payload: { workspaceId } }));
121
+ sendFrame(ws, JSON.stringify({ type: 'subscribed', payload: { workspaceId } }));
55
122
  break;
56
123
  }
57
124
  case 'unsubscribe': {
58
125
  const workspaceId = payload?.workspaceId;
59
126
  if (!workspaceId) {
60
- ws.send(JSON.stringify({ type: 'error', payload: { message: 'Missing workspaceId' } }));
127
+ sendFrame(ws, JSON.stringify({ type: 'error', payload: { message: 'Missing workspaceId' } }));
61
128
  return;
62
129
  }
63
130
  const subs = clients.get(ws);
64
131
  subs?.delete(workspaceId);
65
- ws.send(JSON.stringify({ type: 'unsubscribed', payload: { workspaceId } }));
132
+ sendFrame(ws, JSON.stringify({ type: 'unsubscribed', payload: { workspaceId } }));
66
133
  break;
67
134
  }
68
135
  case 'sync:request': {
@@ -89,13 +156,26 @@ export function handleConnection(ws) {
89
156
  // Every other branch of this switch answers on failure; a routed
90
157
  // message that vanishes silently leaves the user waiting forever.
91
158
  const message = err instanceof Error ? err.message : String(err);
92
- ws.send(JSON.stringify({ type: 'error', payload: { message: `Failed to handle '${type}': ${message}` } }));
159
+ if (type === 'chat:message' && isRecord(payload) && typeof payload.clientMessageId === 'string') {
160
+ sendFrame(ws, JSON.stringify({
161
+ type: 'chat:rejected',
162
+ workspaceId: payload.workspaceId,
163
+ payload: {
164
+ clientMessageId: payload.clientMessageId,
165
+ sessionId: payload.sessionId,
166
+ content: payload.content,
167
+ message,
168
+ },
169
+ }));
170
+ return;
171
+ }
172
+ sendFrame(ws, JSON.stringify({ type: 'error', payload: { message: `Failed to handle '${type}': ${message}` } }));
93
173
  });
94
174
  }
95
175
  break;
96
176
  }
97
177
  default:
98
- ws.send(JSON.stringify({ type: 'error', payload: { message: `Unknown message type: ${type}` } }));
178
+ sendFrame(ws, JSON.stringify({ type: 'error', payload: { message: `Unknown message type: ${type}` } }));
99
179
  }
100
180
  });
101
181
  // Heartbeat with a verdict. Pinging without ever waiting for a pong detected
@@ -154,7 +234,7 @@ function insertEventStatement() {
154
234
  }
155
235
  return cachedInsert.statement;
156
236
  }
157
- export function emit(workspaceId, type, payload, sessionId) {
237
+ export function emit(workspaceId, type, payload, sessionId, options) {
158
238
  recordActivity(workspaceId, type, payload, sessionId);
159
239
  const id = nanoid();
160
240
  const createdAt = new Date().toISOString();
@@ -165,35 +245,48 @@ export function emit(workspaceId, type, payload, sessionId) {
165
245
  replayable = true;
166
246
  }
167
247
  catch (err) {
248
+ if (options?.requirePersistence)
249
+ throw err;
168
250
  console.error(`[websocket-service] Failed to persist event (workspace=${workspaceId}, type=${type}):`, err);
169
251
  }
170
252
  // Build the event object to send
171
253
  const event = { id, workspaceId, type, payload, sessionId, createdAt, replayable };
254
+ broadcastPersistedEvent(event);
255
+ return id;
256
+ }
257
+ /** Insert durably within the caller's transaction; broadcast only after commit. */
258
+ export function persistWorkspaceEvent(workspaceId, type, payload, sessionId) {
259
+ const event = {
260
+ id: nanoid(),
261
+ workspaceId,
262
+ type,
263
+ payload,
264
+ sessionId,
265
+ createdAt: new Date().toISOString(),
266
+ replayable: true,
267
+ };
268
+ insertEventStatement().run(event.id, workspaceId, type, JSON.stringify(payload), sessionId ?? null, event.createdAt);
269
+ recordActivity(workspaceId, type, payload, sessionId);
270
+ return event;
271
+ }
272
+ export function broadcastPersistedEvent(event) {
172
273
  const message = JSON.stringify(event);
173
274
  // Broadcast to subscribed clients. Wrap `.send` in try/catch so a dropped
174
275
  // client doesn't throw and abort delivery to the remaining subscribers.
175
276
  let emitSendErrorLogged = false;
176
277
  for (const [ws, subs] of clients) {
177
- if (subs.has(workspaceId) && ws.readyState === 1 /* WebSocket.OPEN */) {
178
- if ((ws.bufferedAmount ?? 0) > MAX_BUFFERED_BYTES) {
179
- if (!emitSendErrorLogged) {
180
- console.warn(`[ws] client backlogged, dropping live events (workspace=${workspaceId}, type=${type})`);
181
- emitSendErrorLogged = true;
182
- }
183
- continue;
184
- }
278
+ if (subs.has(event.workspaceId) && ws.readyState === 1 /* WebSocket.OPEN */) {
185
279
  try {
186
- ws.send(message);
280
+ sendFrame(ws, message);
187
281
  }
188
282
  catch (err) {
189
283
  if (!emitSendErrorLogged) {
190
- console.warn(`[ws] emit send failed (workspace=${workspaceId}, type=${type}):`, err);
284
+ console.warn(`[ws] emit send failed (workspace=${event.workspaceId}, type=${event.type}):`, err);
191
285
  emitSendErrorLogged = true;
192
286
  }
193
287
  }
194
288
  }
195
289
  }
196
- return id;
197
290
  }
198
291
  /**
199
292
  * Broadcast an event to subscribed clients WITHOUT persisting to the database.
@@ -216,15 +309,8 @@ export function emitEphemeral(workspaceId, type, payload, sessionId) {
216
309
  let sendErrorLogged = false;
217
310
  for (const [ws, subs] of clients) {
218
311
  if (subs.has(workspaceId) && ws.readyState === 1 /* WebSocket.OPEN */) {
219
- if ((ws.bufferedAmount ?? 0) > MAX_BUFFERED_BYTES) {
220
- if (!sendErrorLogged) {
221
- console.warn(`[ws] client backlogged, dropping ephemeral event (workspace=${workspaceId}, type=${type})`);
222
- sendErrorLogged = true;
223
- }
224
- continue;
225
- }
226
312
  try {
227
- ws.send(message);
313
+ sendFrame(ws, message);
228
314
  }
229
315
  catch (err) {
230
316
  if (!sendErrorLogged) {
@@ -248,7 +334,7 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
248
334
  return subs ? [...subs] : [];
249
335
  })();
250
336
  if (resolvedIds.length === 0) {
251
- ws.send(JSON.stringify({ type: 'sync:empty', payload: { message: 'No subscriptions' } }));
337
+ sendFrame(ws, JSON.stringify({ type: 'sync:empty', payload: { message: 'No subscriptions' } }));
252
338
  return;
253
339
  }
254
340
  // Initial window size: on a fresh connection (no lastEventId), we only
@@ -329,14 +415,14 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
329
415
  replayable: true,
330
416
  };
331
417
  });
332
- ws.send(JSON.stringify({ type: 'sync:response', payload: { events, mode, truncated } }));
418
+ sendFrame(ws, JSON.stringify({ type: 'sync:response', payload: { events, mode, truncated } }));
333
419
  }
334
420
  catch (err) {
335
421
  // This handler runs inside ws's 'message' callback: an uncaught SQLite
336
422
  // error here used to reach the process, not the client.
337
423
  console.error('[ws] sync request failed:', err);
338
424
  try {
339
- ws.send(JSON.stringify({
425
+ sendFrame(ws, JSON.stringify({
340
426
  type: 'sync:error',
341
427
  payload: { message: err instanceof Error ? err.message : String(err) },
342
428
  }));
@@ -372,7 +458,7 @@ export function broadcastAll(type, payload) {
372
458
  for (const client of clients.keys()) {
373
459
  if (client.readyState === 1 /* WebSocket.OPEN */) {
374
460
  try {
375
- client.send(message);
461
+ sendFrame(client, message);
376
462
  }
377
463
  catch (err) {
378
464
  // client dropped; next iteration will fail its .readyState check.