@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
@@ -142,6 +142,7 @@ export function createClaudeCodeEngine() {
142
142
  // A user decision is intentional inactivity — suspend the deadline
143
143
  // rather than cancel it, so it resumes the moment the card is answered.
144
144
  turnLiveness.pause();
145
+ clearSubagentStallWatchdog();
145
146
  const onAbort = () => {
146
147
  if (pendingResolvers.get(toolCallId) === resolver) {
147
148
  pendingResolvers.delete(toolCallId);
@@ -327,6 +328,10 @@ export function createClaudeCodeEngine() {
327
328
  // windows. Every call site that changes one of these three conditions
328
329
  // must re-evaluate through this helper, not duplicate the check.
329
330
  const reevaluateLivenessPause = () => {
331
+ if (pendingResolvers.size > 0 || activeSubagentTaskIds.size === 0)
332
+ clearSubagentStallWatchdog();
333
+ else if (!subagentStallTimer)
334
+ armSubagentStallWatchdog();
330
335
  if (activeSubagentTaskIds.size > 0 || pendingResolvers.size > 0 || isCompacting)
331
336
  turnLiveness.pause();
332
337
  else
@@ -355,16 +360,6 @@ export function createClaudeCodeEngine() {
355
360
  isCompacting = false;
356
361
  safeEmit({ kind: 'session:compacting', active: false });
357
362
  reevaluateLivenessPause();
358
- if (activeSubagentTaskIds.size > 0 && !subagentStallTimer) {
359
- // Resume declined because a subagent is still tracked — but the
360
- // subagent stall net is only armed from the `result` branch, which
361
- // a fully wedged generator may never reach. Arm it here as a
362
- // second stage so this combination can't hang forever. The
363
- // `!subagentStallTimer` guard matters: arming re-sets the
364
- // deadline, and a net already armed by a prior `result` must not
365
- // be extended by this backstop (it is not new activity).
366
- armSubagentStallWatchdog();
367
- }
368
363
  }, COMPACTION_STALL_TIMEOUT_MS);
369
364
  compactionStallTimer.unref?.();
370
365
  };
@@ -406,27 +401,24 @@ export function createClaudeCodeEngine() {
406
401
  clearTimeout(subagentStallTimer);
407
402
  subagentStallTimer = undefined;
408
403
  };
409
- // Re-armed (not merely armed-once) on every qualifying `result` so the
410
- // deadline tracks the last observed activity, matching the warning
411
- // message below — a long chain of legitimate subagent-backed turns
412
- // must not be cut off just because 10 minutes have passed in total.
404
+ // Runs from the first task_started, including before the first result.
405
+ // Human waits suspend it independently; genuine activity restarts it.
413
406
  const armSubagentStallWatchdog = () => {
414
407
  clearSubagentStallWatchdog();
408
+ if (pendingResolvers.size > 0 || activeSubagentTaskIds.size === 0)
409
+ return;
415
410
  subagentStallTimer = setTimeout(() => {
416
411
  subagentStallTimer = undefined;
417
- // A message queued between arming and firing means a new turn is
418
- // about to start on this same stream — don't discard the subagent
419
- // tracking or force-close under it; the next 'result' re-evaluates.
420
- if (inputStream.hasUnansweredInput(completedResponses))
412
+ if (pendingResolvers.size > 0 || activeSubagentTaskIds.size === 0)
421
413
  return;
422
- console.warn(`[claude-engine] Subagent(s) still tracked active ${SUBAGENT_STALL_TIMEOUT_MS}ms after the last turn ended — forcing session drain.`);
423
- activeSubagentTaskIds.clear();
424
- // This is a forced, unclean termination (the subagent may still be
425
- // alive server-side) — never report it as a normal completion, so
426
- // auto-loop doesn't treat an orphaned run as forward progress.
427
- mapperState.sawErrorResult = true;
428
- inputStream.close();
429
- armResultDrainWatchdog();
414
+ console.warn(`[claude-engine] No subagent activity for ${SUBAGENT_STALL_TIMEOUT_MS}ms — forcing session:ended`);
415
+ safeEmit({
416
+ kind: 'error',
417
+ category: 'other',
418
+ message: 'Session force-ended: background subagents stopped reporting activity (watchdog).',
419
+ });
420
+ emitSessionEnded('watchdog', null);
421
+ abortController.abort();
430
422
  }, SUBAGENT_STALL_TIMEOUT_MS);
431
423
  subagentStallTimer.unref?.();
432
424
  };
@@ -454,6 +446,14 @@ export function createClaudeCodeEngine() {
454
446
  else
455
447
  activeSubagentTaskIds.delete(ev.toolCallId);
456
448
  }
449
+ if (activeSubagentTaskIds.size > 0 &&
450
+ (events.some((ev) => ev.kind === 'subagent:progress' ||
451
+ ev.kind === 'message:text' ||
452
+ ev.kind === 'message:thinking' ||
453
+ ev.kind === 'tool:call' ||
454
+ ev.kind === 'tool:result') ||
455
+ ('parent_tool_use_id' in msg && msg.parent_tool_use_id != null)))
456
+ armSubagentStallWatchdog();
457
457
  for (const ev of events) {
458
458
  if (ev.kind === 'session:compacting') {
459
459
  isCompacting = ev.active;
@@ -485,10 +485,8 @@ export function createClaudeCodeEngine() {
485
485
  else if (ev.kind === 'tool:result')
486
486
  pendingToolCallIds.delete(ev.toolCallId);
487
487
  }
488
- // The stall watchdog is only armed while we're waiting purely on
489
- // background subagents (no further SDK turn expected unless one
490
- // reports done). React the moment the last one clears instead of
491
- // waiting for a 'result' message that may never come.
488
+ // After a settled result, the last background completion can
489
+ // drain the stream without waiting for another result.
492
490
  if (subagentStallTimer &&
493
491
  activeSubagentTaskIds.size === 0 &&
494
492
  !inputStream.hasUnansweredInput(completedResponses)) {
@@ -601,6 +599,7 @@ export function createClaudeCodeEngine() {
601
599
  if (!iteratorRunning)
602
600
  throw new Error(`Claude ${AGENT_NO_LONGER_RUNNING_TEXT}`);
603
601
  inputStream.send(text);
602
+ armSubagentStallWatchdog();
604
603
  },
605
604
  sendWakeupIfWaiting(text) {
606
605
  if (!iteratorRunning ||
@@ -615,9 +614,8 @@ export function createClaudeCodeEngine() {
615
614
  return false;
616
615
  inputStream.send(text);
617
616
  waitingForBackground = false;
618
- // The wakeup starts another turn on this stream; its result will
619
- // re-arm the stall deadline if background work is still outstanding.
620
- clearSubagentStallWatchdog();
617
+ // Sending a new turn restarts, but never removes, the stall bound.
618
+ armSubagentStallWatchdog();
621
619
  return true;
622
620
  },
623
621
  interrupt() {
@@ -220,6 +220,8 @@ export function createCodexEngine() {
220
220
  err.name = 'AbortError';
221
221
  rejectTurnDone(err);
222
222
  });
223
+ const fileChanges = new Map();
224
+ const itemKey = (threadId, turnId, itemId) => JSON.stringify([threadId, turnId, itemId]);
223
225
  const client = createAppServerClient({
224
226
  stdin: child.stdin,
225
227
  stdout: child.stdout,
@@ -253,6 +255,8 @@ export function createCodexEngine() {
253
255
  }
254
256
  if (method === 'item/started') {
255
257
  const n = params;
258
+ if (n.item.type === 'fileChange')
259
+ fileChanges.set(itemKey(n.threadId, n.turnId, n.item.id), n.item);
256
260
  const events = handleItemStarted(n.item, mapperState);
257
261
  for (const ev of events)
258
262
  safeEmit(ev);
@@ -272,6 +276,8 @@ export function createCodexEngine() {
272
276
  }
273
277
  if (method === 'item/completed') {
274
278
  const n = params;
279
+ if (n.item.type === 'fileChange')
280
+ fileChanges.set(itemKey(n.threadId, n.turnId, n.item.id), n.item);
275
281
  const events = handleItemCompleted(n.item, mapperState);
276
282
  for (const ev of events)
277
283
  safeEmit(ev);
@@ -302,6 +308,11 @@ export function createCodexEngine() {
302
308
  }
303
309
  if (method === 'turn/completed') {
304
310
  const n = params;
311
+ for (const key of fileChanges.keys()) {
312
+ const [threadId, turnId] = JSON.parse(key);
313
+ if (threadId === n.threadId && turnId === n.turn.id)
314
+ fileChanges.delete(key);
315
+ }
305
316
  for (const ev of handleTurnCompleted(n, mapperState))
306
317
  safeEmit(ev);
307
318
  if (!activeTurnId || !n.turn?.id || n.turn.id === activeTurnId) {
@@ -338,7 +349,9 @@ export function createCodexEngine() {
338
349
  }
339
350
  if (method === 'error') {
340
351
  const n = params;
341
- const msg = n?.message ?? 'unknown error';
352
+ if (n.willRetry)
353
+ return;
354
+ const msg = n?.error?.message ?? 'unknown error';
342
355
  if (QUOTA_PATTERN.test(msg)) {
343
356
  tryEmitQuota(mapperState, safeEmit, msg);
344
357
  }
@@ -361,6 +374,14 @@ export function createCodexEngine() {
361
374
  },
362
375
  respondError: (reqId, code, message) => client.peer.respondError(reqId, code, message),
363
376
  respond: (reqId, result) => client.peer.respond(reqId, result),
377
+ resolveFileChange: (params) => {
378
+ if (typeof params.threadId !== 'string' ||
379
+ typeof params.turnId !== 'string' ||
380
+ typeof params.itemId !== 'string')
381
+ return undefined;
382
+ const item = fileChanges.get(itemKey(params.threadId, params.turnId, params.itemId));
383
+ return item ? { changes: item.changes, cwd: options.workingDir } : undefined;
384
+ },
364
385
  autoApprove: (toolName, payload) => isWorkspacePermissionAllowed(options.workspaceId, { engine: 'codex', toolName, payload }),
365
386
  });
366
387
  },
@@ -379,8 +400,9 @@ export function createCodexEngine() {
379
400
  // install hangs.
380
401
  turnLiveness.start();
381
402
  await waitForChild(client.connect());
403
+ let threadResponse;
382
404
  if (isResume && options.resumeFromEngineSessionId) {
383
- await waitForChild(client.resumeThread({
405
+ threadResponse = await waitForChild(client.resumeThread({
384
406
  threadId: options.resumeFromEngineSessionId,
385
407
  cwd: options.workingDir,
386
408
  persistExtendedHistory: false,
@@ -394,8 +416,14 @@ export function createCodexEngine() {
394
416
  }));
395
417
  }
396
418
  else {
397
- const startResp = await waitForChild(client.startThread(threadParams));
398
- discoveredSessionId = startResp.thread.id;
419
+ threadResponse = await waitForChild(client.startThread(threadParams));
420
+ discoveredSessionId = threadResponse.thread.id;
421
+ }
422
+ // "auto" is a Kōbō sentinel, never a provider model identifier. The
423
+ // collaboration override must use the model the app-server resolved.
424
+ const model = threadParams.model ?? threadResponse.model;
425
+ if (typeof model !== 'string' || !model.trim() || model === 'auto') {
426
+ throw new Error('Codex did not return a resolved model. Select an explicit model or update the Codex CLI.');
399
427
  }
400
428
  for (const ev of emitSessionStarted(discoveredSessionId, mapperState))
401
429
  safeEmit(ev);
@@ -404,9 +432,9 @@ export function createCodexEngine() {
404
432
  const initialTurn = await waitForChild(client.startTurn({
405
433
  threadId: discoveredSessionId,
406
434
  input,
407
- collaborationMode,
435
+ collaborationMode: { ...collaborationMode, settings: { ...collaborationMode.settings, model } },
408
436
  }));
409
- activeTurnId = initialTurn.turnId;
437
+ activeTurnId = initialTurn.turn.id;
410
438
  turnLiveness.activity();
411
439
  readySettled = true;
412
440
  resolveReady();
@@ -468,6 +496,7 @@ export function createCodexEngine() {
468
496
  // best-effort
469
497
  }
470
498
  }
499
+ fileChanges.clear();
471
500
  pendingByCallId.clear();
472
501
  pendingToolCalls.clear();
473
502
  client.close();
@@ -526,9 +555,9 @@ export function createCodexEngine() {
526
555
  if (gracefulInterruptPromise)
527
556
  return;
528
557
  gracefulInterruptPromise = (async () => {
529
- if (discoveredSessionId) {
558
+ if (discoveredSessionId && activeTurnId) {
530
559
  await Promise.race([
531
- client.interruptTurn({ threadId: discoveredSessionId }).catch(() => { }),
560
+ client.interruptTurn({ threadId: discoveredSessionId, turnId: activeTurnId }).catch(() => { }),
532
561
  wait(CODEX_GRACEFUL_INTERRUPT_TIMEOUT_MS),
533
562
  ]);
534
563
  }
@@ -8,6 +8,8 @@ import { nanoid } from 'nanoid';
8
8
  // - "out of extra usage" / "out of usage"
9
9
  // - "usage limit exceeded"
10
10
  export const QUOTA_PATTERN = /\b429\b|rate[_ ]limit|quota|usage limit|insufficient[_ ]quota|out of (extra )?usage/i;
11
+ // Standalone provider notices only: normal assistant prose can discuss quotas.
12
+ const ASSISTANT_QUOTA_NOTICE = /^(?:rate[_ ]limit (?:exceeded|reached)|quota exceeded|insufficient[_ ]quota|out of (?:extra )?usage|usage limit (?:exceeded|reached)|you(?:'ve| have) (?:hit|reached) (?:your |the )?usage limit)(?:[.!]?(?:[ \t]*[·—-]?[ \t]*resets?\b[^\r\n]*)?)?[.!]?$/i;
11
13
  export function createMapperState(opts) {
12
14
  return {
13
15
  sessionStartedEmitted: false,
@@ -204,7 +206,7 @@ export function handleItemCompleted(item, state) {
204
206
  if (text.includes('[BRAINSTORM_COMPLETE]')) {
205
207
  events.push({ kind: 'session:brainstorm-complete' });
206
208
  }
207
- if (QUOTA_PATTERN.test(text)) {
209
+ if (ASSISTANT_QUOTA_NOTICE.test(text.trim())) {
208
210
  tryEmitQuotaInline(state, events, text);
209
211
  }
210
212
  events.push({ kind: 'message:end', messageId });
@@ -66,18 +66,19 @@ export function buildCodexOptions(input) {
66
66
  // Always emit collaborationMode — it's sticky server-side, so omitting it
67
67
  // would leave a resumed thread stuck in the previous turn's mode. Plan also
68
68
  // gates the `request_user_input` internal tool. Settings echo the resolved
69
- // model/effort because collaborationMode takes precedence over them.
69
+ // model/effort because collaborationMode takes precedence over them. Automatic
70
+ // model selection is filled from thread/start or thread/resume by the engine.
70
71
  const collaborationMode = {
71
72
  mode: input.agentPermissionMode === 'plan' ? 'plan' : 'default',
72
73
  settings: {
73
- model: threadParams.model ?? 'auto',
74
+ ...(threadParams.model ? { model: threadParams.model } : {}),
74
75
  reasoning_effort: threadParams.modelReasoningEffort ?? null,
75
76
  developer_instructions: null,
76
77
  },
77
78
  };
78
79
  return {
79
80
  threadParams,
80
- input: [{ type: 'text', text: effectivePrompt }],
81
+ input: [{ type: 'text', text: effectivePrompt, text_elements: [] }],
81
82
  isResume,
82
83
  collaborationMode,
83
84
  };
@@ -29,7 +29,14 @@ export function handleServerRequest(args) {
29
29
  return true;
30
30
  }
31
31
  if (method === 'item/fileChange/requestApproval' || method === 'applyPatchApproval') {
32
- const payload = { changes: p.changes, reason: p.reason };
32
+ const details = args.resolveFileChange?.(p);
33
+ const payload = {
34
+ changes: details?.changes ?? p.changes,
35
+ cwd: details?.cwd,
36
+ grantRoot: p.grantRoot ?? null,
37
+ reason: p.reason,
38
+ operationApprovalAvailable: Boolean(details && details.changes.length > 0),
39
+ };
33
40
  if (autoApprove?.('Edit', payload)) {
34
41
  respond?.(requestId, { decision: 'accept' });
35
42
  return true;
@@ -704,6 +704,21 @@ export function forgetTasksDoneSnapshot(workspaceId) {
704
704
  export function forgetResumeFailed(workspaceId) {
705
705
  resumeFailedSessions.delete(workspaceId);
706
706
  }
707
+ /** Public reconnect snapshot: select DTO fields and detach nested JSON inputs. */
708
+ export function getPendingInputs(workspaceId) {
709
+ return (pendingQueue.get(workspaceId) ?? []).map((item) => {
710
+ const identity = {
711
+ agentSessionId: item.agentSessionId,
712
+ toolCallId: item.toolCallId,
713
+ toolName: item.toolName,
714
+ };
715
+ // Engine tool inputs are JSON. Serialization also strips accidental callbacks.
716
+ const input = JSON.parse(JSON.stringify((item.kind === 'question' ? item.input : item.toolInput) ?? null));
717
+ return item.kind === 'question'
718
+ ? { kind: 'question', ...identity, input }
719
+ : { kind: 'permission', ...identity, toolInput: input };
720
+ });
721
+ }
707
722
  /** Drop the pending question/permission queue for a workspace (called on delete). */
708
723
  export function forgetPendingQueue(workspaceId) {
709
724
  pendingQueue.delete(workspaceId);
@@ -1134,7 +1149,7 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1134
1149
  * after `engine.start` resolves — callers should subscribe to WS events or
1135
1150
  * query the controller via `_getControllers()` for tests.
1136
1151
  */
1137
- export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort) {
1152
+ export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort, beforeDispatch) {
1138
1153
  if (shuttingDown)
1139
1154
  throw new Error('Cannot start an agent while the server is shutting down');
1140
1155
  assertWorkspaceLifecycleAvailable(workspaceId);
@@ -1231,6 +1246,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1231
1246
  mcpServers: buildMcpServers(workspaceId),
1232
1247
  env: ws ? buildAgentEnv(ws.projectPath) : undefined,
1233
1248
  };
1249
+ beforeDispatch?.();
1234
1250
  let controller;
1235
1251
  controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, controller, ev));
1236
1252
  registerSessionLifecycleOwner(workspaceId, agentSessionId, controller);
@@ -1490,7 +1506,7 @@ export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
1490
1506
  return true;
1491
1507
  }
1492
1508
  /** Write a user message to the running agent. */
1493
- export async function sendMessage(workspaceId, content, expectedSessionId) {
1509
+ export async function sendMessage(workspaceId, content, expectedSessionId, beforeDispatch) {
1494
1510
  assertWorkspaceNotCompacting(workspaceId);
1495
1511
  const ctrl = controllers.get(workspaceId);
1496
1512
  if (!ctrl) {
@@ -1500,6 +1516,7 @@ export async function sendMessage(workspaceId, content, expectedSessionId) {
1500
1516
  throw new Error(`Session '${expectedSessionId}' is not active for workspace '${workspaceId}'`);
1501
1517
  }
1502
1518
  wakeupService.cancel(workspaceId, 'user-message');
1519
+ beforeDispatch?.();
1503
1520
  await ctrl.sendMessage(content);
1504
1521
  }
1505
1522
  export async function sendMessageForFallback(workspaceId, content) {
@@ -1671,7 +1688,7 @@ export async function answerPendingQuestion(workspaceId, answers, expectedToolCa
1671
1688
  try {
1672
1689
  const formatted = formatDeferredAnswerForChat(questions, answers);
1673
1690
  if (formatted) {
1674
- emit(workspaceId, 'user:message', { content: formatted, sender: 'user' }, head.agentSessionId);
1691
+ emit(workspaceId, 'user:message', { content: formatted, sender: 'user', ...(opts?.source ? { source: opts.source } : {}) }, head.agentSessionId);
1675
1692
  }
1676
1693
  }
1677
1694
  catch (err) {
@@ -0,0 +1,122 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { nanoid } from 'nanoid';
4
+ import { attachmentFormat, validateAttachments } from '../../shared/attachments.js';
5
+ import { ensureDirectoryInside, resolvePathInside } from '../utils/safe-path.js';
6
+ import { deleteImage, saveImage } from './image-service.js';
7
+ export class AttachmentRequestError extends Error {
8
+ }
9
+ /** Existing JSON clients stay supported; browser attachments travel with the creation request. */
10
+ export async function readWorkspaceCreationRequest(request) {
11
+ let body;
12
+ let attachments = [];
13
+ try {
14
+ if (request.headers.get('content-type')?.toLowerCase().startsWith('multipart/form-data')) {
15
+ const form = await request.formData();
16
+ const metadata = form.get('workspace');
17
+ if (typeof metadata !== 'string' || form.getAll('workspace').length !== 1) {
18
+ throw new AttachmentRequestError('Expected one JSON workspace field');
19
+ }
20
+ body = JSON.parse(metadata);
21
+ const files = [...form.getAll('attachments'), ...form.getAll('images')];
22
+ if (files.some((file) => !(file instanceof File))) {
23
+ throw new AttachmentRequestError('Each attachment must be a file');
24
+ }
25
+ attachments = files;
26
+ }
27
+ else {
28
+ body = await request.json();
29
+ }
30
+ }
31
+ catch (error) {
32
+ if (error instanceof AttachmentRequestError)
33
+ throw error;
34
+ throw new AttachmentRequestError('Invalid workspace creation body');
35
+ }
36
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
37
+ throw new AttachmentRequestError('Workspace must be a JSON object');
38
+ }
39
+ if ('description' in body && body.description !== undefined && typeof body.description !== 'string') {
40
+ throw new AttachmentRequestError('description must be a string');
41
+ }
42
+ const error = validateAttachments(attachments);
43
+ if (error) {
44
+ const messages = {
45
+ type: 'Unsupported attachment type. Use PNG, JPEG, GIF, WebP, PDF, Markdown or text documents',
46
+ size: 'Each attachment must be non-empty and no larger than 50 MB',
47
+ count: 'A workspace can be created with at most 10 attachments',
48
+ total: 'Creation attachments must total no more than 50 MB',
49
+ };
50
+ throw new AttachmentRequestError(messages[error]);
51
+ }
52
+ return { body: body, attachments };
53
+ }
54
+ /** Remove only this creation's files, preserving existing worktree content. */
55
+ export async function removeAttachments(worktreePath, attachments) {
56
+ const warnings = [];
57
+ for (const attachment of attachments) {
58
+ try {
59
+ if (attachment.kind === 'image') {
60
+ await deleteImage(worktreePath, attachment.uid);
61
+ }
62
+ else {
63
+ const directory = ensureDirectoryInside(worktreePath, '.ai/attachments');
64
+ // Paths come exclusively from saveAttachments; never use the original upload name.
65
+ fs.unlinkSync(path.join(directory, path.basename(attachment.relativePath)));
66
+ }
67
+ }
68
+ catch (error) {
69
+ warnings.push(`Could not remove creation attachment '${attachment.uid}': ${error instanceof Error ? error.message : error}`);
70
+ }
71
+ }
72
+ return warnings;
73
+ }
74
+ export async function saveAttachments(worktreePath, files) {
75
+ const saved = [];
76
+ try {
77
+ for (const file of files) {
78
+ const format = attachmentFormat(file);
79
+ if (!format)
80
+ throw new AttachmentRequestError('Unsupported attachment type');
81
+ const buffer = Buffer.from(await file.arrayBuffer());
82
+ if (format.kind === 'image') {
83
+ const name = path.extname(file.name).toLowerCase() === format.extension
84
+ ? file.name
85
+ : `${file.name || 'image'}${format.extension}`;
86
+ saved.push({ ...(await saveImage(worktreePath, buffer, name)), kind: 'image', originalName: file.name });
87
+ }
88
+ else {
89
+ const directory = ensureDirectoryInside(worktreePath, '.ai/attachments');
90
+ const ignorePath = resolvePathInside(worktreePath, '.gitignore');
91
+ const ignore = fs.existsSync(ignorePath) ? fs.readFileSync(ignorePath, 'utf8') : '';
92
+ if (!ignore.split('\n').some((line) => line.trim() === '.ai/attachments/')) {
93
+ fs.appendFileSync(ignorePath, `${ignore && !ignore.endsWith('\n') ? '\n' : ''}.ai/attachments/\n`);
94
+ }
95
+ const uid = nanoid(10);
96
+ const filename = `${uid}${format.extension}`;
97
+ const descriptor = fs.openSync(path.join(directory, filename), 'wx', 0o600);
98
+ // Track ownership as soon as exclusive creation succeeds: a failed
99
+ // write can leave bytes on disk and must participate in rollback.
100
+ saved.push({ uid, relativePath: `.ai/attachments/${filename}`, kind: 'file', originalName: file.name });
101
+ try {
102
+ fs.writeFileSync(descriptor, buffer);
103
+ }
104
+ finally {
105
+ fs.closeSync(descriptor);
106
+ }
107
+ }
108
+ }
109
+ return saved;
110
+ }
111
+ catch (error) {
112
+ const warnings = await removeAttachments(worktreePath, saved);
113
+ for (const warning of warnings)
114
+ console.error(`[creation-attachments] ${warning}`);
115
+ throw error;
116
+ }
117
+ }
118
+ export function attachmentReference(attachment) {
119
+ return attachment.kind === 'image'
120
+ ? `[image: ${attachment.relativePath}]`
121
+ : `Attached document ${JSON.stringify(attachment.originalName)}: [file: ${attachment.relativePath}]`;
122
+ }
@@ -1,7 +1,7 @@
1
1
  import { withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
2
2
  // src/server/services/change-source-branch-service.ts
3
- import { spawn } from 'node:child_process';
4
3
  import * as path from 'node:path';
4
+ import { runBoundedProcess } from '../utils/bounded-process.js';
5
5
  import * as gitOps from '../utils/git-ops.js';
6
6
  import { withGitRepoLock } from '../utils/git-repo-lock.js';
7
7
  import { getAgentStatus } from './agent/orchestrator.js';
@@ -255,9 +255,10 @@ async function runCustomScript(workspace, oldBase, newBase, script) {
255
255
  return { status: 'dirty', forcePushNeeded: false, commitCount: 0 };
256
256
  if (getAgentStatus(workspace.id) !== null)
257
257
  throw new Error('Cannot change the source branch while the agent is running');
258
- return new Promise((resolve, reject) => {
259
- const child = spawn('bash', ['-c', script], {
258
+ try {
259
+ await runBoundedProcess('bash', ['-c', script], {
260
260
  cwd: workspace.worktreePath,
261
+ timeoutMs: SCRIPT_TIMEOUT_MS,
261
262
  env: {
262
263
  ...process.env,
263
264
  KOBO_NEW_BASE: newBase,
@@ -271,46 +272,13 @@ async function runCustomScript(workspace, oldBase, newBase, script) {
271
272
  KOBO_FORGE: forgeId,
272
273
  KOBO_PR_NUMBER: prNumber,
273
274
  },
274
- // Detached so `child` leads its own process group. Node's native
275
- // `timeout` option below only signals `child` itself — without this,
276
- // a script that backgrounds a long-running command (`docker compose
277
- // up -d &`) leaves it running as an orphan after the timeout fires.
278
- detached: true,
279
- timeout: SCRIPT_TIMEOUT_MS,
280
- killSignal: 'SIGTERM',
281
- });
282
- // Node's `timeout` option kills only `child`. Mirror that same signal to
283
- // the whole process group so a backgrounded child dies with it.
284
- const groupKillTimer = setTimeout(() => {
285
- try {
286
- if (child.pid)
287
- process.kill(-child.pid, 'SIGTERM');
288
- }
289
- catch {
290
- /* process group already gone */
291
- }
292
- }, SCRIPT_TIMEOUT_MS);
293
- let stderrBuf = '';
294
- child.stderr?.on('data', (chunk) => {
295
- stderrBuf += chunk.toString();
296
- if (stderrBuf.length > 8 * 1024)
297
- stderrBuf = stderrBuf.slice(-8 * 1024);
298
- });
299
- child.on('error', (err) => {
300
- clearTimeout(groupKillTimer);
301
- reject(new Error(`Custom change-source-branch script failed to spawn: ${err.message}`));
302
275
  });
303
- child.on('exit', (code, signal) => {
304
- clearTimeout(groupKillTimer);
305
- if (code === 0) {
306
- updateWorkspaceSourceBranch(workspace.id, newBase);
307
- resolve({ status: 'done', forcePushNeeded: false, commitCount: 0 });
308
- return;
309
- }
310
- const detail = stderrBuf.trim().slice(-500) || `exit code ${code ?? signal ?? 'unknown'}`;
311
- reject(new Error(`Custom change-source-branch script failed: ${detail}`));
312
- });
313
- });
276
+ }
277
+ catch (err) {
278
+ throw new Error(`Custom change-source-branch script failed: ${err instanceof Error ? err.message : String(err)}`);
279
+ }
280
+ updateWorkspaceSourceBranch(workspace.id, newBase);
281
+ return { status: 'done', forcePushNeeded: false, commitCount: 0 };
314
282
  }
315
283
  /** Async lookups must not authorize mutations using obsolete workspace metadata. */
316
284
  function assertWorkspaceUnchanged(id, worktreePath, branch, sourceBranch) {
@@ -0,0 +1,33 @@
1
+ import { emit, emitEphemeral } from './websocket-service.js';
2
+ /** Correlate delivery confirmations without changing the legacy queue protocol. */
3
+ export function createChatReceipt(workspaceId, delivery, persist) {
4
+ const correlation = delivery.clientMessageId ? { clientMessageId: delivery.clientMessageId } : {};
5
+ return {
6
+ accept(sessionId) {
7
+ const payload = {
8
+ content: delivery.content,
9
+ sender: 'user',
10
+ ...correlation,
11
+ ...(delivery.source ? { source: delivery.source } : {}),
12
+ };
13
+ if (persist)
14
+ persist(payload, sessionId);
15
+ else if (delivery.clientMessageId)
16
+ emit(workspaceId, 'user:message', payload, sessionId, { requirePersistence: true });
17
+ else
18
+ emit(workspaceId, 'user:message', payload, sessionId);
19
+ if (delivery.force || delivery.clientMessageId) {
20
+ emitEphemeral(workspaceId, 'chat:accepted', { sessionId, ...correlation });
21
+ }
22
+ },
23
+ reject(message, reason) {
24
+ emitEphemeral(workspaceId, 'chat:rejected', {
25
+ sessionId: delivery.sessionId,
26
+ content: delivery.content,
27
+ message,
28
+ ...correlation,
29
+ ...(reason ? { reason } : {}),
30
+ });
31
+ },
32
+ };
33
+ }