@loicngr/kobo 1.11.39 → 1.11.41

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 (154) hide show
  1. package/AGENTS.md +101 -32
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +6 -4
  4. package/dist/mcp-server/kobo-tasks-handlers.js +16 -9
  5. package/dist/mcp-server/kobo-tasks-server.js +13 -2
  6. package/dist/mcp-server/workspace-dialogue-client.js +28 -0
  7. package/dist/server/db/mcp-message-schema.js +16 -0
  8. package/dist/server/db/migrations.js +4 -0
  9. package/dist/server/db/schema.js +4 -0
  10. package/dist/server/db/search-schema.js +53 -0
  11. package/dist/server/index.js +17 -86
  12. package/dist/server/middleware/network-auth-middleware.js +5 -1
  13. package/dist/server/routes/changelog.js +4 -49
  14. package/dist/server/routes/git.js +2 -2
  15. package/dist/server/routes/images.js +65 -5
  16. package/dist/server/routes/mcp.js +47 -0
  17. package/dist/server/routes/pull-requests.js +1 -1
  18. package/dist/server/routes/search.js +13 -4
  19. package/dist/server/routes/settings.js +5 -0
  20. package/dist/server/routes/workspaces.js +250 -248
  21. package/dist/server/services/agent/engines/claude-code/engine.js +31 -33
  22. package/dist/server/services/agent/engines/codex/engine.js +37 -8
  23. package/dist/server/services/agent/engines/codex/event-mapper.js +3 -1
  24. package/dist/server/services/agent/engines/codex/options-builder.js +4 -3
  25. package/dist/server/services/agent/engines/codex/server-requests.js +8 -1
  26. package/dist/server/services/agent/orchestrator.js +20 -3
  27. package/dist/server/services/attachment-service.js +122 -0
  28. package/dist/server/services/change-source-branch-service.js +10 -42
  29. package/dist/server/services/chat-receipt-service.js +33 -0
  30. package/dist/server/services/cron-service.js +27 -1
  31. package/dist/server/services/image-service.js +16 -7
  32. package/dist/server/services/mcp-client-context.js +12 -0
  33. package/dist/server/services/mcp-connection-info-service.js +63 -0
  34. package/dist/server/services/mcp-message-delivery-service.js +59 -0
  35. package/dist/server/services/mcp-message-request-service.js +64 -0
  36. package/dist/server/services/pr-checkout-service.js +10 -10
  37. package/dist/server/services/pr-watcher-service.js +14 -9
  38. package/dist/server/services/search/indexer.js +232 -0
  39. package/dist/server/services/search/worker.js +76 -0
  40. package/dist/server/services/search-service.js +104 -110
  41. package/dist/server/services/update-check-service.js +119 -0
  42. package/dist/server/services/websocket-service.js +121 -35
  43. package/dist/server/services/workspace-dialogue-service.js +128 -0
  44. package/dist/server/services/workspace-message-service.js +58 -0
  45. package/dist/server/services/workspace-permission-policy-service.js +33 -3
  46. package/dist/server/services/worktree-restore-service.js +2 -2
  47. package/dist/server/services/worktree-service.js +197 -124
  48. package/dist/server/utils/bounded-process.js +137 -0
  49. package/dist/server/utils/git-ops.js +76 -128
  50. package/dist/server/utils/git-repo-lock.js +27 -13
  51. package/dist/shared/attachments.js +58 -0
  52. package/dist/shared/workspace-dialogue-tools.js +89 -0
  53. package/dist/shared/workspace-message-types.js +19 -0
  54. package/package.json +2 -1
  55. package/src/client/dist/pwa/assets/{ActivityFeed-Chsw1Mh7.css → ActivityFeed-BYR5x7aa.css} +1 -1
  56. package/src/client/dist/pwa/assets/ActivityFeed-DTIe6O9o.js +8 -0
  57. package/src/client/dist/pwa/assets/{ChangelogPage-D8563008.js → ChangelogPage-BWeu0YeI.js} +1 -1
  58. package/src/client/dist/pwa/assets/{ComparisonPanel-Bd0X08sg.js → ComparisonPanel-o_EHlTwV.js} +2 -2
  59. package/src/client/dist/pwa/assets/CreatePage-CGi88k6z.css +1 -0
  60. package/src/client/dist/pwa/assets/CreatePage-DbY5gBv8.js +2 -0
  61. package/src/client/dist/pwa/assets/DashboardPage-CIo_gesD.js +1 -0
  62. package/src/client/dist/pwa/assets/{DiffViewer-DnR79ycb.css → DiffViewer-DEPOa3D7.css} +1 -1
  63. package/src/client/dist/pwa/assets/DiffViewer-DEcf48KI.js +8 -0
  64. package/src/client/dist/pwa/assets/{DocumentsPanel-BJsJ0fNs.js → DocumentsPanel-D9vcB91W.js} +1 -1
  65. package/src/client/dist/pwa/assets/{DrawerToggleButton-BVLO5K5d.js → DrawerToggleButton-Ca0_CxSl.js} +1 -1
  66. package/src/client/dist/pwa/assets/GitPanel-DCmB2R5i.js +3 -0
  67. package/src/client/dist/pwa/assets/{HealthPage-RFZS7Lao.js → HealthPage-_q3vy_xn.js} +1 -1
  68. package/src/client/dist/pwa/assets/MainLayout-Duwnq3cc.js +2 -0
  69. package/src/client/dist/pwa/assets/{QBtnDropdown-X8oNU5Mb.js → QBtnDropdown-BeiZIHVA.js} +1 -1
  70. package/src/client/dist/pwa/assets/{QDrawer-Cij17skX.js → QDrawer-Fj3M9QGD.js} +1 -1
  71. package/src/client/dist/pwa/assets/QSelect-DtSUuK8t.js +1 -0
  72. package/src/client/dist/pwa/assets/{QTooltip-CwcCGSWq.js → QTooltip-DwkX8eQ6.js} +1 -1
  73. package/src/client/dist/pwa/assets/SchedulePanel-BAA4Aajn.css +1 -0
  74. package/src/client/dist/pwa/assets/SchedulePanel-BlZDd7TK.js +1 -0
  75. package/src/client/dist/pwa/assets/SearchPage-BGc1oQSM.css +1 -0
  76. package/src/client/dist/pwa/assets/SearchPage-BGi1yRxU.js +1 -0
  77. package/src/client/dist/pwa/assets/SettingsPage-DnO-ImWE.js +9 -0
  78. package/src/client/dist/pwa/assets/SettingsPage-sVlsJQ0b.css +1 -0
  79. package/src/client/dist/pwa/assets/{SplitWorkspacePage-D7lGFRKs.js → SplitWorkspacePage-vz6jN1DM.js} +1 -1
  80. package/src/client/dist/pwa/assets/{TerminalPanel-DI8DHb9e.js → TerminalPanel-IdPIGp2_.js} +9 -9
  81. package/src/client/dist/pwa/assets/{TouchPan-B3nrkCY8.js → TouchPan-DTJqxVp9.js} +1 -1
  82. package/src/client/dist/pwa/assets/{TourReplayButton-DU1UShMY.js → TourReplayButton-Bj2GGyhU.js} +1 -1
  83. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-C-yN5IX8.js → WorkspaceAttentionLabels-BBaGZlwv.js} +1 -1
  84. package/src/client/dist/pwa/assets/WorkspacePage-DCg9zSxz.css +1 -0
  85. package/src/client/dist/pwa/assets/WorkspacePage-Dy0spsfi.js +4 -0
  86. package/src/client/dist/pwa/assets/{browser-f9MHQjfi.js → browser-BFj5wwbS.js} +1 -1
  87. package/src/client/dist/pwa/assets/{build-path-tree-BWBzP8os.js → build-path-tree-C-HSiySk.js} +1 -1
  88. package/src/client/dist/pwa/assets/de-BdLe7apJ.js +17 -0
  89. package/src/client/dist/pwa/assets/{documents-C2cLnEBQ.js → documents-C2Xnjh9Z.js} +1 -1
  90. package/src/client/dist/pwa/assets/{editor.api-sw146MWN.js → editor.api-BbKfnNmv.js} +1 -1
  91. package/src/client/dist/pwa/assets/{efforts-C5ZZgCH4.js → efforts-BM7fdaaL.js} +1 -1
  92. package/src/client/dist/pwa/assets/engineFeatures-b74YRODT.js +1 -0
  93. package/src/client/dist/pwa/assets/es-C1LM-3aq.js +17 -0
  94. package/src/client/dist/pwa/assets/expand-template-DiDWiIkx.js +1 -0
  95. package/src/client/dist/pwa/assets/{format-BwWP6Aay.js → format-CnbUxAmw.js} +1 -1
  96. package/src/client/dist/pwa/assets/{formatters-DyWtO-nR.js → formatters-B2uUIbAY.js} +1 -1
  97. package/src/client/dist/pwa/assets/fr-KCK6r5ga.js +17 -0
  98. package/src/client/dist/pwa/assets/{freemarker2-DZ5Hm8UH.js → freemarker2-Cu1vGY0x.js} +1 -1
  99. package/src/client/dist/pwa/assets/{handlebars-BRUGpTA7.js → handlebars-CETuqLyz.js} +1 -1
  100. package/src/client/dist/pwa/assets/{html-DNOayNNs.js → html-BTNpr39P.js} +1 -1
  101. package/src/client/dist/pwa/assets/i18n-BkUirOzl.js +1 -0
  102. package/src/client/dist/pwa/assets/index-mvh5w1cM.js +20 -0
  103. package/src/client/dist/pwa/assets/it-BM6t0IGU.js +17 -0
  104. package/src/client/dist/pwa/assets/{javascript-C0tDQU3z.js → javascript-C7Ncb6V7.js} +1 -1
  105. package/src/client/dist/pwa/assets/{liquid-CN46O6OF.js → liquid-D0hUdg4x.js} +1 -1
  106. package/src/client/dist/pwa/assets/{mdx-DM-lrr8Q.js → mdx-BK5H1Xst.js} +1 -1
  107. package/src/client/dist/pwa/assets/{monaco-contributions-CkN7y-8c.js → monaco-contributions-BBW6JNgz.js} +1 -1
  108. package/src/client/dist/pwa/assets/{monaco.contribution-2BcAOLjc.js → monaco.contribution-1LZrAlY4.js} +2 -2
  109. package/src/client/dist/pwa/assets/network-auth-CVGS__6a.js +1 -0
  110. package/src/client/dist/pwa/assets/{notify-theme-a91Tcr6o.js → notify-theme-DIKLxr8F.js} +1 -1
  111. package/src/client/dist/pwa/assets/{pending-diff-open-DOtELZfB.js → pending-diff-open-B02zuH1j.js} +1 -1
  112. package/src/client/dist/pwa/assets/{permissionModes-C-9Crj-c.js → permissionModes-C3otN1Vk.js} +1 -1
  113. package/src/client/dist/pwa/assets/{project-color-BRe4oyNx.js → project-color-BUcW_eZA.js} +1 -1
  114. package/src/client/dist/pwa/assets/{python-BRpJG6sZ.js → python-DEeUVvnp.js} +1 -1
  115. package/src/client/dist/pwa/assets/{razor-CehCmvHl.js → razor-CG6cL8ya.js} +1 -1
  116. package/src/client/dist/pwa/assets/{render-chat-markdown-DlbQLZ02.js → render-chat-markdown-Ct_T5TPN.js} +1 -1
  117. package/src/client/dist/pwa/assets/{typescript-Dt_8mVuj.js → typescript-CSQAg5UC.js} +1 -1
  118. package/src/client/dist/pwa/assets/use-search-index-status-DtLzygcy.js +1 -0
  119. package/src/client/dist/pwa/assets/use-tours-_6HwJ_Rp.js +2 -0
  120. package/src/client/dist/pwa/assets/{workspace-templates-GCObcjO6.js → workspace-templates-B8WWsWEI.js} +1 -1
  121. package/src/client/dist/pwa/assets/{xml-_gOwdzB4.js → xml-BSB4-DXR.js} +1 -1
  122. package/src/client/dist/pwa/assets/{yaml-BxeBqpGf.js → yaml-CSGKph85.js} +1 -1
  123. package/src/client/dist/pwa/index.html +1 -1
  124. package/src/client/dist/pwa/sw.js +1 -1
  125. package/src/mcp-server/README.md +169 -9
  126. package/src/mcp-server/kobo-tasks-handlers.ts +14 -11
  127. package/src/mcp-server/kobo-tasks-server.ts +19 -2
  128. package/src/mcp-server/workspace-dialogue-client.ts +34 -0
  129. package/src/client/dist/pwa/assets/ActivityFeed-BzzvcOmq.js +0 -8
  130. package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +0 -1
  131. package/src/client/dist/pwa/assets/CreatePage-Bon8qIka.js +0 -2
  132. package/src/client/dist/pwa/assets/DashboardPage-DYxdxEul.js +0 -1
  133. package/src/client/dist/pwa/assets/DiffViewer-BpsLNxnj.js +0 -8
  134. package/src/client/dist/pwa/assets/GitPanel-Cal_wzC0.js +0 -3
  135. package/src/client/dist/pwa/assets/MainLayout-vvyiRcql.js +0 -2
  136. package/src/client/dist/pwa/assets/QSelect-xd-AlGo_.js +0 -1
  137. package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +0 -1
  138. package/src/client/dist/pwa/assets/SchedulePanel-CXRUsz6G.js +0 -1
  139. package/src/client/dist/pwa/assets/SearchPage-CVgRa025.css +0 -1
  140. package/src/client/dist/pwa/assets/SearchPage-DopbgLxh.js +0 -1
  141. package/src/client/dist/pwa/assets/SettingsPage-Do9d0Pov.css +0 -1
  142. package/src/client/dist/pwa/assets/SettingsPage-Jx6TnOzR.js +0 -9
  143. package/src/client/dist/pwa/assets/WorkspacePage-BAC6u9OP.css +0 -1
  144. package/src/client/dist/pwa/assets/WorkspacePage-DKRpdpPg.js +0 -4
  145. package/src/client/dist/pwa/assets/de-QkDLrcGq.js +0 -17
  146. package/src/client/dist/pwa/assets/engineFeatures-CAVMDawq.js +0 -1
  147. package/src/client/dist/pwa/assets/es-BgQFKlo8.js +0 -17
  148. package/src/client/dist/pwa/assets/expand-template-B6xAlAaM.js +0 -1
  149. package/src/client/dist/pwa/assets/fr-DAdLWjNM.js +0 -17
  150. package/src/client/dist/pwa/assets/i18n-CBez_vip.js +0 -1
  151. package/src/client/dist/pwa/assets/index-VL3fI9a3.js +0 -20
  152. package/src/client/dist/pwa/assets/it-Bumaewp1.js +0 -17
  153. package/src/client/dist/pwa/assets/network-auth-KYS4snQH.js +0 -1
  154. package/src/client/dist/pwa/assets/use-tours-CSVM_54K.js +0 -2
@@ -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.
@@ -0,0 +1,128 @@
1
+ import { nanoid } from 'nanoid';
2
+ import { validateDialogueArguments } from '../../shared/workspace-dialogue-tools.js';
3
+ import { parseMessageSource } from '../../shared/workspace-message-types.js';
4
+ import { getDb } from '../db/index.js';
5
+ import { assertWorkspaceLifecycleAvailable } from '../utils/workspace-lifecycle-guard.js';
6
+ import { answerPendingQuestion, getPendingInputs } from './agent/orchestrator.js';
7
+ import { createMcpClientContext } from './mcp-client-context.js';
8
+ import { deliverKeyedMcpMessage } from './mcp-message-delivery-service.js';
9
+ import { deliverWorkspaceMessage } from './workspace-message-service.js';
10
+ import { getActiveSession, getWorkspace, listSessions, listTasks, listWorkspaces } from './workspace-service.js';
11
+ function readableMessage(row) {
12
+ let payload;
13
+ try {
14
+ payload = JSON.parse(row.payload);
15
+ }
16
+ catch {
17
+ return [];
18
+ }
19
+ if (!payload || typeof payload !== 'object')
20
+ return [];
21
+ let text;
22
+ if (row.type === 'user:message' && typeof payload.content === 'string')
23
+ text = payload.content;
24
+ if (row.type === 'agent:event' && payload.kind === 'message:text' && typeof payload.text === 'string')
25
+ text = payload.text;
26
+ if (row.type === 'agent:output') {
27
+ const content = payload.message?.content;
28
+ if (Array.isArray(content))
29
+ text = content
30
+ .flatMap((block) => (block?.type === 'text' && typeof block.text === 'string' ? [block.text] : []))
31
+ .join('\n');
32
+ }
33
+ return text === undefined
34
+ ? []
35
+ : [
36
+ {
37
+ id: row.id,
38
+ sessionId: row.session_id,
39
+ role: row.type === 'user:message' ? 'user' : 'assistant',
40
+ text,
41
+ messageId: typeof payload.messageId === 'string' ? payload.messageId : row.id,
42
+ createdAt: row.created_at,
43
+ ...(parseMessageSource(payload.source) ? { source: parseMessageSource(payload.source) } : {}),
44
+ ...(typeof payload.clientMessageId === 'string' ? { clientMessageId: payload.clientMessageId } : {}),
45
+ },
46
+ ];
47
+ }
48
+ export async function executeWorkspaceDialogueTool(name, input, source = createMcpClientContext()) {
49
+ const args = validateDialogueArguments(name, input);
50
+ if (name === 'list_workspaces')
51
+ return listWorkspaces(args.include_archived === true).map((ws) => ({
52
+ id: ws.id,
53
+ name: ws.name,
54
+ status: ws.status,
55
+ projectPath: ws.projectPath,
56
+ archivedAt: ws.archivedAt,
57
+ }));
58
+ const workspaceId = args.workspace_id;
59
+ const workspace = getWorkspace(workspaceId);
60
+ if (!workspace)
61
+ throw new Error(`Workspace '${workspaceId}' not found`);
62
+ if (name === 'get_workspace')
63
+ return { workspace, tasks: listTasks(workspaceId), activeSession: getActiveSession(workspaceId) };
64
+ if (name === 'list_workspace_sessions')
65
+ return listSessions(workspaceId);
66
+ if (name === 'get_workspace_questions') {
67
+ const pending = getPendingInputs(workspaceId);
68
+ return {
69
+ questions: pending.filter((item) => item.kind === 'question'),
70
+ requiresHumanApproval: pending[0]?.kind === 'permission',
71
+ };
72
+ }
73
+ if (name === 'answer_workspace_question') {
74
+ assertWorkspaceLifecycleAvailable(workspaceId);
75
+ if (workspace.archivedAt || workspace.worktreePurgedAt)
76
+ throw new Error('Restore the workspace before answering a question');
77
+ const head = getPendingInputs(workspaceId)[0];
78
+ if (head?.kind !== 'question' || head.toolCallId !== args.tool_call_id)
79
+ throw new Error('The requested question is not the current pending question');
80
+ await answerPendingQuestion(workspaceId, args.answers, args.tool_call_id, {
81
+ source,
82
+ });
83
+ return { accepted: true };
84
+ }
85
+ if (name === 'send_workspace_message' && args.idempotency_key)
86
+ return deliverKeyedMcpMessage(workspaceId, args.idempotency_key, {
87
+ content: args.content,
88
+ sessionId: args.session_id,
89
+ source,
90
+ });
91
+ if (name === 'send_workspace_message')
92
+ return {
93
+ accepted: true,
94
+ ...(await deliverWorkspaceMessage(workspaceId, {
95
+ content: args.content,
96
+ sessionId: args.session_id,
97
+ clientMessageId: `mcp-${nanoid()}`,
98
+ source,
99
+ })),
100
+ };
101
+ const db = getDb();
102
+ let after = 0;
103
+ if (args.after_cursor) {
104
+ const cursor = db
105
+ .prepare('SELECT rowid FROM ws_events WHERE id = ? AND workspace_id = ?')
106
+ .get(args.after_cursor, workspaceId);
107
+ if (!cursor)
108
+ throw new Error('Unknown or expired history cursor for this workspace; restart reading without after_cursor');
109
+ after = cursor.rowid;
110
+ }
111
+ const limit = args.limit ?? 100;
112
+ const params = [workspaceId, after];
113
+ if (args.session_id)
114
+ params.push(args.session_id);
115
+ params.push(limit + 1);
116
+ const rows = db
117
+ .prepare(`SELECT id,session_id,type,payload,created_at FROM ws_events WHERE workspace_id = ? AND rowid > ?
118
+ ${args.session_id ? 'AND (session_id = ? OR session_id IS NULL)' : ''} ORDER BY rowid LIMIT ?`)
119
+ .all(...params);
120
+ const page = rows.slice(0, limit);
121
+ return {
122
+ messages: page.flatMap(readableMessage),
123
+ nextCursor: page.at(-1)?.id ?? args.after_cursor ?? null,
124
+ hasMore: rows.length > limit,
125
+ workspaceStatus: workspace.status,
126
+ activeSessionId: getActiveSession(workspaceId)?.id ?? null,
127
+ };
128
+ }
@@ -0,0 +1,58 @@
1
+ import { assertWorkspaceLifecycleAvailable } from '../utils/workspace-lifecycle-guard.js';
2
+ import { isAgentUnavailableError, isShuttingDown, sendMessage, startAgent } from './agent/orchestrator.js';
3
+ import * as autoLoop from './auto-loop-service.js';
4
+ import { createChatReceipt } from './chat-receipt-service.js';
5
+ import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './workspace-service.js';
6
+ /** Shared delivery for WebSocket chat and external MCP clients. */
7
+ export async function deliverWorkspaceMessage(workspaceId, message, hooks) {
8
+ const receipt = createChatReceipt(workspaceId, message, hooks?.persist);
9
+ let reason;
10
+ try {
11
+ if (!message.content.trim())
12
+ throw new Error('Message content must not be empty');
13
+ if (isShuttingDown())
14
+ throw new Error('Kōbō is shutting down');
15
+ assertWorkspaceLifecycleAvailable(workspaceId);
16
+ const workspace = getWorkspace(workspaceId);
17
+ if (!workspace)
18
+ throw new Error(`Workspace '${workspaceId}' not found`);
19
+ if (workspace.archivedAt || workspace.worktreePurgedAt)
20
+ throw new Error('Restore the workspace before sending a message');
21
+ if (workspace.status === 'compacting') {
22
+ reason = 'compacting';
23
+ throw new Error('Workspace is compacting its context; wait until compaction finishes before sending a message');
24
+ }
25
+ if (workspace.status === 'awaiting-user') {
26
+ reason = 'awaiting-user';
27
+ throw new Error('Answer the pending question or permission request before sending a chat message');
28
+ }
29
+ const loop = autoLoop.getStatus(workspaceId);
30
+ if (loop.auto_loop && loop.auto_loop_ready)
31
+ autoLoop.disable(workspaceId, 'user-action');
32
+ let sessionId = message.sessionId ?? getActiveSession(workspaceId)?.id;
33
+ try {
34
+ if (hooks?.beforeDispatch)
35
+ await sendMessage(workspaceId, message.content, message.sessionId, hooks.beforeDispatch);
36
+ else
37
+ await sendMessage(workspaceId, message.content, message.sessionId);
38
+ }
39
+ catch (error) {
40
+ if (!isAgentUnavailableError(error instanceof Error ? error.message : String(error)))
41
+ throw error;
42
+ // Re-read after asynchronous delivery: metadata may have changed meanwhile.
43
+ assertWorkspaceLifecycleAvailable(workspaceId);
44
+ const current = getWorkspace(workspaceId);
45
+ if (!current || current.archivedAt || current.worktreePurgedAt)
46
+ throw new Error('Workspace is unavailable');
47
+ const started = startAgent(workspaceId, current.worktreePath, message.content, current.model, true, message.agentPermissionModeOverride ?? current.agentPermissionMode, message.sessionId, current.reasoningEffort, ...(hooks?.beforeDispatch ? [hooks.beforeDispatch] : []));
48
+ updateWorkspaceStatus(workspaceId, 'executing');
49
+ sessionId = message.sessionId ?? started.agentSessionId;
50
+ }
51
+ receipt.accept(sessionId);
52
+ return { sessionId };
53
+ }
54
+ catch (error) {
55
+ receipt.reject(error instanceof Error ? error.message : String(error), reason);
56
+ throw error;
57
+ }
58
+ }
@@ -27,8 +27,33 @@ function stable(value) {
27
27
  export function permissionFingerprint(payload) {
28
28
  return createHash('sha256').update(stable(payload)).digest('hex');
29
29
  }
30
+ const CODEX_EDIT_FINGERPRINT_PREFIX = 'codex-edit-v2:';
31
+ function operationFingerprint(request) {
32
+ if (request.engine !== 'codex' || request.toolName !== 'Edit')
33
+ return permissionFingerprint(request.payload);
34
+ const payload = request.payload;
35
+ if (payload?.operationApprovalAvailable !== true || !Array.isArray(payload.changes) || payload.changes.length === 0)
36
+ return null;
37
+ if (typeof payload.cwd !== 'string' ||
38
+ payload.changes.some((change) => !change ||
39
+ typeof change.path !== 'string' ||
40
+ typeof change.diff !== 'string' ||
41
+ !change.kind ||
42
+ !['add', 'delete', 'update'].includes(change.kind.type)))
43
+ return null;
44
+ return (CODEX_EDIT_FINGERPRINT_PREFIX +
45
+ permissionFingerprint({
46
+ changes: payload.changes,
47
+ cwd: payload.cwd,
48
+ grantRoot: payload.grantRoot ?? null,
49
+ }));
50
+ }
30
51
  function toRule(row) {
31
52
  return {
53
+ active: !(row.scope === 'operation' &&
54
+ row.tool_name === 'Edit' &&
55
+ (row.engine ?? row.workspace_engine) === 'codex' &&
56
+ !String(row.fingerprint ?? '').startsWith(CODEX_EDIT_FINGERPRINT_PREFIX)),
32
57
  id: String(row.id),
33
58
  workspaceId: String(row.workspace_id),
34
59
  engine: row.engine,
@@ -42,13 +67,17 @@ function toRule(row) {
42
67
  }
43
68
  export function listWorkspacePermissionRules(workspaceId) {
44
69
  return getDb()
45
- .prepare('SELECT * FROM workspace_permission_rules WHERE workspace_id=? ORDER BY created_at DESC')
70
+ .prepare(`SELECT rules.*, workspaces.engine AS workspace_engine
71
+ FROM workspace_permission_rules AS rules JOIN workspaces ON workspaces.id=rules.workspace_id
72
+ WHERE workspace_id=? ORDER BY rules.created_at DESC`)
46
73
  .all(workspaceId).map(toRule);
47
74
  }
48
75
  export function createWorkspacePermissionRule(workspaceId, request, scope) {
49
76
  const now = new Date().toISOString();
50
77
  const id = nanoid();
51
- const fingerprint = scope === 'operation' ? permissionFingerprint(request.payload) : null;
78
+ const fingerprint = scope === 'operation' ? operationFingerprint(request) : null;
79
+ if (scope === 'operation' && fingerprint === null)
80
+ throw new Error('Exact operation details are unavailable');
52
81
  const displayLabel = scope === 'tool' ? request.toolName : `${request.toolName} — opération précise`;
53
82
  getDb()
54
83
  .prepare('INSERT INTO workspace_permission_rules (id,workspace_id,engine,tool_name,scope,fingerprint,display_label,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)')
@@ -61,6 +90,7 @@ export function createWorkspacePermissionRule(workspaceId, request, scope) {
61
90
  scope,
62
91
  fingerprint,
63
92
  displayLabel,
93
+ active: true,
64
94
  createdAt: now,
65
95
  updatedAt: now,
66
96
  };
@@ -72,7 +102,7 @@ export function removeWorkspacePermissionRule(workspaceId, ruleId) {
72
102
  export function isWorkspacePermissionAllowed(workspaceId, request) {
73
103
  if (turnRules.get(workspaceId)?.has(turnKey(request)))
74
104
  return true;
75
- const fingerprint = permissionFingerprint(request.payload);
105
+ const fingerprint = operationFingerprint(request);
76
106
  try {
77
107
  return Boolean(getDb()
78
108
  .prepare(`SELECT 1 FROM workspace_permission_rules WHERE workspace_id=? AND tool_name=? AND (engine IS NULL OR engine=?) AND (scope='tool' OR (scope='operation' AND fingerprint=?)) LIMIT 1`)
@@ -43,13 +43,13 @@ export async function restorePurgedWorktree(id) {
43
43
  headCommitSha: savedHead(workspace.worktreePurgeRestoreData),
44
44
  };
45
45
  if (!workspace.worktreePurgedAt) {
46
- if (!isMatchingWorkspaceWorktree(input)) {
46
+ if (!(await isMatchingWorkspaceWorktree(input))) {
47
47
  throw new WorktreeRestoreError('not-purged', 'Workspace is not marked as purged and its checkout is missing or different.');
48
48
  }
49
49
  return { workspace, outcome: 'already-restored', source: 'existing-worktree' };
50
50
  }
51
51
  const checkout = await restoreWorktreeCheckoutUnlocked(input);
52
- if (!isMatchingWorkspaceWorktree(input)) {
52
+ if (!(await isMatchingWorkspaceWorktree(input))) {
53
53
  throw new WorktreeRestoreError('path-conflict', 'The recreated checkout does not match this workspace.');
54
54
  }
55
55
  // Preserve a valid checkout on DB failure. A retry or manual detection can