@loicngr/kobo 1.11.42 → 1.11.44

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 (148) hide show
  1. package/AGENTS.md +49 -4
  2. package/CHANGELOG.md +10 -0
  3. package/dist/mcp-server/kobo-tasks-handlers.js +31 -74
  4. package/dist/mcp-server/kobo-tasks-server.js +51 -4
  5. package/dist/server/db/auto-loop-schema.js +44 -0
  6. package/dist/server/db/migrations.js +4 -0
  7. package/dist/server/db/review-return-schema.js +10 -0
  8. package/dist/server/db/schema.js +4 -0
  9. package/dist/server/index.js +10 -9
  10. package/dist/server/routes/auto-loop-messages.js +51 -0
  11. package/dist/server/routes/workspaces.js +46 -136
  12. package/dist/server/services/agent/engines/claude-code/engine.js +2 -0
  13. package/dist/server/services/agent/engines/codex/engine.js +91 -31
  14. package/dist/server/services/agent/orchestrator.js +242 -99
  15. package/dist/server/services/agent/session-controller.js +42 -2
  16. package/dist/server/services/auto-loop-message-service.js +79 -0
  17. package/dist/server/services/auto-loop-service.js +225 -86
  18. package/dist/server/services/auto-loop-state-service.js +30 -0
  19. package/dist/server/services/cron-service.js +30 -16
  20. package/dist/server/services/quota-backoff-service.js +42 -10
  21. package/dist/server/services/review-return-service.js +64 -0
  22. package/dist/server/services/review-service.js +187 -0
  23. package/dist/server/services/settings-service.js +3 -3
  24. package/dist/server/services/task-mutations.js +186 -0
  25. package/dist/server/services/wakeup-service.js +16 -1
  26. package/dist/server/services/workspace-message-service.js +16 -2
  27. package/dist/server/services/workspace-service.js +25 -36
  28. package/dist/shared/auto-loop-prompts.js +22 -11
  29. package/dist/shared/auto-loop-types.js +1 -0
  30. package/dist/shared/review.js +6 -0
  31. package/dist/shared/task-verification.js +11 -0
  32. package/package.json +12 -12
  33. package/src/client/dist/pwa/assets/{ActivityFeed-CyCGH0Zr.js → ActivityFeed-D_y4aikp.js} +1 -1
  34. package/src/client/dist/pwa/assets/{ChangelogPage-B6xjI-I4.js → ChangelogPage-DSKCvpr2.js} +1 -1
  35. package/src/client/dist/pwa/assets/{ClosePopup-BqW5dy-O.js → ClosePopup-CWjIRHxu.js} +1 -1
  36. package/src/client/dist/pwa/assets/{ComparisonPanel-BmRzCu1c.js → ComparisonPanel-DDxcsxyC.js} +1 -1
  37. package/src/client/dist/pwa/assets/{CreatePage-DHe0x63K.js → CreatePage-DKVEknbL.js} +2 -2
  38. package/src/client/dist/pwa/assets/{DashboardPage-CRTkx9P-.js → DashboardPage-Dg2s9nnv.js} +1 -1
  39. package/src/client/dist/pwa/assets/{DiffViewer-BJfLiYGs.js → DiffViewer-BSzNScIr.js} +4 -4
  40. package/src/client/dist/pwa/assets/{DocumentsPanel-B9jjzoqY.js → DocumentsPanel-DfEoI_f7.js} +1 -1
  41. package/src/client/dist/pwa/assets/{DrawerToggleButton-CBZpPa30.js → DrawerToggleButton-D85PwhKb.js} +1 -1
  42. package/src/client/dist/pwa/assets/GitPanel-CuG8MlgF.js +3 -0
  43. package/src/client/dist/pwa/assets/{HealthPage-9VEvoR_4.js → HealthPage-CaLCpejR.js} +1 -1
  44. package/src/client/dist/pwa/assets/MainLayout-CDxYIJ_s.js +2 -0
  45. package/src/client/dist/pwa/assets/MainLayout-DTLNunOi.css +1 -0
  46. package/src/client/dist/pwa/assets/{QBadge-DbkxGtht.js → QBadge-FWC1zDCq.js} +1 -1
  47. package/src/client/dist/pwa/assets/{QBanner-DhqH95GW.js → QBanner-CcRI_QIy.js} +1 -1
  48. package/src/client/dist/pwa/assets/QBtn-DEdx_Y1V.js +1 -0
  49. package/src/client/dist/pwa/assets/{QBtnDropdown-Cnkq_5eu.js → QBtnDropdown-qJWGcZpC.js} +1 -1
  50. package/src/client/dist/pwa/assets/{QBtnGroup-DELKYo3u.js → QBtnGroup-CAoD6G0Q.js} +1 -1
  51. package/src/client/dist/pwa/assets/{QBtnToggle-BI3k3Ch2.js → QBtnToggle-C_hQ2UCQ.js} +1 -1
  52. package/src/client/dist/pwa/assets/{QChip-owciRuZu.js → QChip-DCPUhqXX.js} +1 -1
  53. package/src/client/dist/pwa/assets/QDrawer-DX3PhMqC.js +1 -0
  54. package/src/client/dist/pwa/assets/{QItemSection-D_5_0UrX.js → QItemSection-BhqphaH6.js} +1 -1
  55. package/src/client/dist/pwa/assets/{QList-qupzuEqO.js → QList-CHm4JNP0.js} +1 -1
  56. package/src/client/dist/pwa/assets/{QMarkupTable-B9QuKtK_.js → QMarkupTable-CNpiA9oq.js} +1 -1
  57. package/src/client/dist/pwa/assets/{QPage-DaX9sMQC.js → QPage-BXBw2Alx.js} +1 -1
  58. package/src/client/dist/pwa/assets/QSelect-CY4TWW_j.js +1 -0
  59. package/src/client/dist/pwa/assets/{QSpinnerDots-DnCV3VzI.js → QSpinnerDots-BkFiCsH3.js} +1 -1
  60. package/src/client/dist/pwa/assets/{QTooltip-WIoDeLoZ.js → QTooltip-cDK5AOQ5.js} +1 -1
  61. package/src/client/dist/pwa/assets/{SchedulePanel-tVggDx-C.js → SchedulePanel-LvHiZ6ZH.js} +1 -1
  62. package/src/client/dist/pwa/assets/{SearchPage-l4m1qxKs.js → SearchPage-BO-J4aNc.js} +1 -1
  63. package/src/client/dist/pwa/assets/{SettingsPage-msHMBS7q.js → SettingsPage-Aw89fRSq.js} +2 -2
  64. package/src/client/dist/pwa/assets/{SplitWorkspacePage-AXMYd-9b.js → SplitWorkspacePage-BIEceNwH.js} +1 -1
  65. package/src/client/dist/pwa/assets/{TerminalPanel-CENXl8po.js → TerminalPanel-DitOCad1.js} +1 -1
  66. package/src/client/dist/pwa/assets/{TouchPan-DaEpS0Vf.js → TouchPan-DvPAhZsa.js} +1 -1
  67. package/src/client/dist/pwa/assets/{TourReplayButton-U3mwT0fV.js → TourReplayButton-CJYdH3Jf.js} +1 -1
  68. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-CpTA6VGb.js → WorkspaceAttentionLabels-CY-9z9nw.js} +1 -1
  69. package/src/client/dist/pwa/assets/WorkspacePage-DAVo-E4p.css +1 -0
  70. package/src/client/dist/pwa/assets/WorkspacePage-xH_OyA3r.js +4 -0
  71. package/src/client/dist/pwa/assets/{browser-B4pgwnXH.js → browser-BsB6cFv4.js} +1 -1
  72. package/src/client/dist/pwa/assets/build-path-tree-CW-ER1Mt.js +1 -0
  73. package/src/client/dist/pwa/assets/de-Mu7biceO.js +17 -0
  74. package/src/client/dist/pwa/assets/documents-SCd3w8xV.js +1 -0
  75. package/src/client/dist/pwa/assets/{editor.api-qQNqM6Y6.js → editor.api-BUdM7I6y.js} +1 -1
  76. package/src/client/dist/pwa/assets/editor.worker-iO5iN0DB.js +33 -0
  77. package/src/client/dist/pwa/assets/efforts-DTJQP9or.js +9 -0
  78. package/src/client/dist/pwa/assets/engineFeatures-B-9jHocY.css +1 -0
  79. package/src/client/dist/pwa/assets/engineFeatures-Bj6Ob41d.js +1 -0
  80. package/src/client/dist/pwa/assets/es-BY5IfNI2.js +17 -0
  81. package/src/client/dist/pwa/assets/expand-template-SM8vvYyO.js +1 -0
  82. package/src/client/dist/pwa/assets/{format-BVgznNN8.js → format-BjQxf4pe.js} +3 -3
  83. package/src/client/dist/pwa/assets/{formatters-DV1VAk5B.js → formatters-BejhmNTV.js} +1 -1
  84. package/src/client/dist/pwa/assets/fr-BMXCzFCM.js +17 -0
  85. package/src/client/dist/pwa/assets/{freemarker2-Nt_Z9A9p.js → freemarker2-5d5GhyRp.js} +1 -1
  86. package/src/client/dist/pwa/assets/{handlebars-sJjXCA98.js → handlebars-DaafEsDK.js} +1 -1
  87. package/src/client/dist/pwa/assets/{html-B2wF0NKO.js → html-CwHBDd2A.js} +1 -1
  88. package/src/client/dist/pwa/assets/i18n-CgWW5vfb.js +1 -0
  89. package/src/client/dist/pwa/assets/index-0wtBh1Xt.css +1 -0
  90. package/src/client/dist/pwa/assets/index-Di9Sz5E8.js +20 -0
  91. package/src/client/dist/pwa/assets/it-ByPZQK_K.js +17 -0
  92. package/src/client/dist/pwa/assets/{javascript-BPbCoYVZ.js → javascript-DSNIEEH5.js} +1 -1
  93. package/src/client/dist/pwa/assets/{liquid-DBwLDEnw.js → liquid-Dma1UpA1.js} +1 -1
  94. package/src/client/dist/pwa/assets/{mdx-D_Sphj-P.js → mdx-D8rqEAws.js} +1 -1
  95. package/src/client/dist/pwa/assets/{monaco-contributions-BBjimLYs.js → monaco-contributions-2E7O2zoi.js} +1 -1
  96. package/src/client/dist/pwa/assets/{monaco.contribution-DNP7HNKq.js → monaco.contribution-DqOgo72G.js} +2 -2
  97. package/src/client/dist/pwa/assets/{network-auth-BLGlHVxR.js → network-auth-DNmhJTZn.js} +1 -1
  98. package/src/client/dist/pwa/assets/{notify-theme-tvaTlaFw.js → notify-theme-B6Hiwvwb.js} +1 -1
  99. package/src/client/dist/pwa/assets/{pending-diff-open-CxtcrwVN.js → pending-diff-open-CeiVIFZn.js} +1 -1
  100. package/src/client/dist/pwa/assets/permissionModes-DWdtf81n.js +36 -0
  101. package/src/client/dist/pwa/assets/{pinia-BKqmoVru.js → pinia-CTJ3OfP3.js} +1 -1
  102. package/src/client/dist/pwa/assets/{private.use-form-C7umbGO-.js → private.use-form-Bk8ti264.js} +1 -1
  103. package/src/client/dist/pwa/assets/{project-color-D75XmBNA.js → project-color-BCp9SWoI.js} +1 -1
  104. package/src/client/dist/pwa/assets/{python-BdBTal4f.js → python-f-9NqRlI.js} +1 -1
  105. package/src/client/dist/pwa/assets/{razor-gJNlRRte.js → razor-DsCmoX0h.js} +1 -1
  106. package/src/client/dist/pwa/assets/reactivity.esm-bundler-Bo9anJjg.js +1 -0
  107. package/src/client/dist/pwa/assets/render-chat-markdown-DK5BD9mU.js +67 -0
  108. package/src/client/dist/pwa/assets/{runtime-core.esm-bundler-CDnVUfeZ.js → runtime-core.esm-bundler-CNFKm4ij.js} +1 -1
  109. package/src/client/dist/pwa/assets/{typescript-DAGixRVW.js → typescript-cEHg4L10.js} +1 -1
  110. package/src/client/dist/pwa/assets/{use-checkbox-FnauAgTp.js → use-checkbox-CnvHBhSK.js} +1 -1
  111. package/src/client/dist/pwa/assets/{use-id-BIuaWKkB.js → use-id-wXd4c8FA.js} +1 -1
  112. package/src/client/dist/pwa/assets/use-is-mobile-DpKI3XMy.js +1 -0
  113. package/src/client/dist/pwa/assets/{use-search-index-status-DtLzygcy.js → use-search-index-status-Bluf9L99.js} +1 -1
  114. package/src/client/dist/pwa/assets/use-tours-BmTbosmB.js +2 -0
  115. package/src/client/dist/pwa/assets/{workspace-templates-BoGUaV1C.js → workspace-templates-DUWDLMIX.js} +1 -1
  116. package/src/client/dist/pwa/assets/{xml-BC9pt2ZZ.js → xml-Plt-Y7we.js} +1 -1
  117. package/src/client/dist/pwa/assets/{yaml-CQpOdW-l.js → yaml-ScEmaAeN.js} +1 -1
  118. package/src/client/dist/pwa/index.html +10 -10
  119. package/src/client/dist/pwa/sw.js +1 -1
  120. package/src/mcp-server/kobo-tasks-handlers.ts +68 -104
  121. package/src/mcp-server/kobo-tasks-server.ts +56 -4
  122. package/src/client/dist/pwa/assets/GitPanel-gqySqt7G.js +0 -3
  123. package/src/client/dist/pwa/assets/MainLayout-BM14JTBB.js +0 -2
  124. package/src/client/dist/pwa/assets/MainLayout-Dhj28mOe.css +0 -1
  125. package/src/client/dist/pwa/assets/QBtn-CZaBclNw.js +0 -1
  126. package/src/client/dist/pwa/assets/QDrawer-C3b-lbmv.js +0 -1
  127. package/src/client/dist/pwa/assets/QSelect-DJL6yy1E.js +0 -1
  128. package/src/client/dist/pwa/assets/WorkspacePage-DCg9zSxz.css +0 -1
  129. package/src/client/dist/pwa/assets/WorkspacePage-DE6-wlf7.js +0 -4
  130. package/src/client/dist/pwa/assets/build-path-tree-6ENXqh7O.js +0 -1
  131. package/src/client/dist/pwa/assets/de-D9gPXN_a.js +0 -17
  132. package/src/client/dist/pwa/assets/documents-D88lOIxR.js +0 -1
  133. package/src/client/dist/pwa/assets/editor.worker-CwQAd_xr.js +0 -30
  134. package/src/client/dist/pwa/assets/efforts-CMQi0Rp2.js +0 -9
  135. package/src/client/dist/pwa/assets/engineFeatures-BPcXoXFd.js +0 -1
  136. package/src/client/dist/pwa/assets/es-B4Q1ncyP.js +0 -17
  137. package/src/client/dist/pwa/assets/expand-template-DuMdsD-k.js +0 -1
  138. package/src/client/dist/pwa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
  139. package/src/client/dist/pwa/assets/fr-Qv-edVJn.js +0 -17
  140. package/src/client/dist/pwa/assets/i18n-eiyc92m_.js +0 -1
  141. package/src/client/dist/pwa/assets/index-CUoUy6tH.css +0 -1
  142. package/src/client/dist/pwa/assets/index-DtsrXdzx.js +0 -20
  143. package/src/client/dist/pwa/assets/it-D-qzfo9T.js +0 -17
  144. package/src/client/dist/pwa/assets/permissionModes-CESk18Vk.js +0 -36
  145. package/src/client/dist/pwa/assets/reactivity.esm-bundler-DEJj9cFv.js +0 -1
  146. package/src/client/dist/pwa/assets/render-chat-markdown-K3tUW_1p.js +0 -67
  147. package/src/client/dist/pwa/assets/use-is-mobile-CWjqe8gC.js +0 -1
  148. package/src/client/dist/pwa/assets/use-tours-CS5oBFk6.js +0 -2
@@ -3,6 +3,7 @@ import { promisify } from 'node:util';
3
3
  import { getSearchIndexStatus, searchEvents } from '../services/search-service.js';
4
4
  import { AgentStopError, assertAgentStopped } from '../utils/agent-stop-result.js';
5
5
  import { withGitRepoLock } from '../utils/git-repo-lock.js';
6
+ import autoLoopMessagesRoutes from './auto-loop-messages.js';
6
7
  const execFileAsync = promisify(execFileCb);
7
8
  import fs from 'node:fs';
8
9
  import path from 'node:path';
@@ -34,11 +35,12 @@ import * as notionService from '../services/notion-service.js';
34
35
  import { renderPrTemplate } from '../services/pr-template-service.js';
35
36
  import { clearPrSnapshotCache, getAllGitStats, getAllPrSnapshots, refreshPrSnapshot, } from '../services/pr-watcher-service.js';
36
37
  import * as quotaBackoffService from '../services/quota-backoff-service.js';
37
- import { getActiveReviewTemplate, renderReviewTemplate } from '../services/review-template-service.js';
38
+ import { ReviewRequestError, startWorkspaceReview } from '../services/review-service.js';
38
39
  import * as sentryService from '../services/sentry-service.js';
39
40
  import * as settingsService from '../services/settings-service.js';
40
41
  import { runSetupScript } from '../services/setup-script-service.js';
41
42
  import { getSuitePrompts } from '../services/skill-suite-prompts.js';
43
+ import { TaskValidationError } from '../services/task-mutations.js';
42
44
  import * as terminalService from '../services/terminal-service.js';
43
45
  import * as wakeupService from '../services/wakeup-service.js';
44
46
  import * as wsService from '../services/websocket-service.js';
@@ -1601,9 +1603,7 @@ app.get('/auto-loop-states', (c) => {
1601
1603
  const out = {};
1602
1604
  for (const r of rows) {
1603
1605
  out[r.id] = {
1604
- auto_loop: r.auto_loop === 1,
1605
- auto_loop_ready: r.auto_loop_ready === 1,
1606
- no_progress_streak: r.no_progress_streak,
1606
+ ...autoLoopService.getStatus(r.id),
1607
1607
  tasks_done: r.tasks_done ?? 0,
1608
1608
  tasks_total: r.tasks_total ?? 0,
1609
1609
  crons_count: r.crons_count ?? 0,
@@ -1616,6 +1616,7 @@ app.get('/auto-loop-states', (c) => {
1616
1616
  return c.json({ error: message }, workspaceErrorStatus(err));
1617
1617
  }
1618
1618
  });
1619
+ app.route('/', autoLoopMessagesRoutes);
1619
1620
  // GET /api/workspaces/:id/auto-loop — current auto-loop status for one workspace.
1620
1621
  app.get('/:id/auto-loop', (c) => {
1621
1622
  try {
@@ -2011,67 +2012,68 @@ app.delete('/:id/events/:eventId', (c) => {
2011
2012
  app.post('/:id/tasks', async (c) => {
2012
2013
  try {
2013
2014
  const id = c.req.param('id');
2014
- const workspace = workspaceService.getWorkspace(id);
2015
- if (!workspace) {
2015
+ if (!workspaceService.getWorkspace(id))
2016
2016
  return c.json({ error: `Workspace '${id}' not found` }, 404);
2017
- }
2018
- const body = await c.req
2019
- .json()
2020
- .catch(() => ({}));
2021
- if (!body.title?.trim()) {
2017
+ const body = await c.req.json().catch(() => null);
2018
+ if (!body || typeof body.title !== 'string' || !body.title.trim()) {
2022
2019
  return c.json({ error: 'Title is required' }, 400);
2023
2020
  }
2024
- const existing = workspaceService.listTasks(id);
2025
- const nextSortOrder = existing.length > 0 ? Math.max(...existing.map((t) => t.sortOrder)) + 1 : 0;
2026
2021
  const task = workspaceService.createTask(id, {
2027
2022
  title: body.title.trim(),
2028
- isAcceptanceCriterion: !!body.isAcceptanceCriterion,
2029
- sortOrder: nextSortOrder,
2023
+ isAcceptanceCriterion: body.isAcceptanceCriterion,
2024
+ sortOrder: body.sortOrder,
2025
+ afterTaskId: body.afterTaskId,
2026
+ role: body.role,
2030
2027
  });
2031
2028
  return c.json(task, 201);
2032
2029
  }
2033
2030
  catch (err) {
2034
2031
  const message = err instanceof Error ? err.message : String(err);
2035
- return c.json({ error: message }, workspaceErrorStatus(err));
2032
+ return c.json({ error: message }, err instanceof TaskValidationError ? 400 : workspaceErrorStatus(err));
2036
2033
  }
2037
2034
  });
2038
- // PATCH /api/workspaces/:id/tasks/:taskId — update task status and/or title
2035
+ // PATCH /api/workspaces/:id/tasks/:taskId — validate and apply every field atomically
2039
2036
  app.patch('/:id/tasks/:taskId', async (c) => {
2040
2037
  try {
2041
2038
  const id = c.req.param('id');
2042
2039
  const taskId = c.req.param('taskId');
2043
- const workspace = workspaceService.getWorkspace(id);
2044
- if (!workspace) {
2040
+ if (!workspaceService.getWorkspace(id))
2045
2041
  return c.json({ error: `Workspace '${id}' not found` }, 404);
2046
- }
2047
- const task = workspaceService.getTask(taskId, id);
2048
- if (!task) {
2042
+ if (!workspaceService.getTask(taskId, id)) {
2049
2043
  return c.json({ error: `Task '${taskId}' not found in workspace '${id}'` }, 404);
2050
2044
  }
2051
- const body = await c.req
2052
- .json()
2053
- .catch(() => ({}));
2054
- if (body.status === undefined && body.title === undefined) {
2055
- return c.json({ error: 'At least one of status or title is required' }, 400);
2056
- }
2057
- if (body.title !== undefined) {
2058
- if (!body.title.trim()) {
2059
- return c.json({ error: 'Title cannot be empty' }, 400);
2060
- }
2061
- workspaceService.updateTaskTitle(taskId, body.title.trim());
2062
- }
2063
- if (body.status !== undefined) {
2064
- const validStatuses = ['pending', 'in_progress', 'done'];
2065
- if (!validStatuses.includes(body.status)) {
2066
- return c.json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` }, 400);
2067
- }
2068
- workspaceService.updateTaskStatus(taskId, body.status);
2045
+ const body = await c.req.json().catch(() => null);
2046
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
2047
+ return c.json({ error: 'Request body must be a JSON object' }, 400);
2069
2048
  }
2049
+ const fields = {};
2050
+ for (const key of [
2051
+ 'title',
2052
+ 'status',
2053
+ 'isAcceptanceCriterion',
2054
+ 'sortOrder',
2055
+ 'afterTaskId',
2056
+ 'verification',
2057
+ ]) {
2058
+ if (body[key] !== undefined)
2059
+ Object.assign(fields, { [key]: body[key] });
2060
+ }
2061
+ if (Object.keys(fields).length === 0)
2062
+ return c.json({ error: 'At least one task field is required' }, 400);
2063
+ if (fields.title !== undefined && (typeof fields.title !== 'string' || !fields.title.trim())) {
2064
+ return c.json({ error: 'Title cannot be empty' }, 400);
2065
+ }
2066
+ if (fields.status !== undefined && !['pending', 'in_progress', 'done'].includes(fields.status)) {
2067
+ return c.json({ error: 'Invalid status. Must be one of: pending, in_progress, done' }, 400);
2068
+ }
2069
+ if (fields.title !== undefined)
2070
+ fields.title = fields.title.trim();
2071
+ workspaceService.updateTask(taskId, fields);
2070
2072
  return c.json({ ok: true });
2071
2073
  }
2072
2074
  catch (err) {
2073
2075
  const message = err instanceof Error ? err.message : String(err);
2074
- return c.json({ error: message }, workspaceErrorStatus(err));
2076
+ return c.json({ error: message }, err instanceof TaskValidationError ? 400 : workspaceErrorStatus(err));
2075
2077
  }
2076
2078
  });
2077
2079
  // DELETE /api/workspaces/:id/tasks/:taskId — delete a task
@@ -4302,104 +4304,12 @@ app.post('/:id/open-pr', async (c) => {
4302
4304
  }
4303
4305
  });
4304
4306
  // POST /api/workspaces/:id/start-review — ask the agent to review committed + uncommitted changes
4305
- app.post('/:id/start-review', async (c) => {
4307
+ app.post('/:id/start-review', migrationGuard, async (c) => {
4306
4308
  try {
4307
- const id = c.req.param('id');
4308
- const workspace = workspaceService.getWorkspace(id);
4309
- if (!workspace) {
4310
- return c.json({ error: `Workspace '${id}' not found` }, 404);
4311
- }
4312
- const body = await c.req
4313
- .json()
4314
- .catch(() => ({}));
4315
- const additionalInstructions = (body.additionalInstructions ?? '').trim();
4316
- const newSession = body.newSession === true;
4317
- const worktreePath = workspace.worktreePath;
4318
- // Best-effort fetch so the base ref is fresh
4319
- try {
4320
- await execFileAsync('git', ['fetch', 'origin', workspace.sourceBranch], { cwd: worktreePath });
4321
- }
4322
- catch (err) {
4323
- const msg = err instanceof Error ? err.message : String(err);
4324
- console.warn(`[start-review] git fetch origin ${workspace.sourceBranch} failed: ${msg}`);
4325
- }
4326
- // Resolve base commit
4327
- let baseCommit;
4328
- try {
4329
- const { stdout } = await execFileAsync('git', ['rev-parse', `origin/${workspace.sourceBranch}`], {
4330
- cwd: worktreePath,
4331
- });
4332
- baseCommit = stdout.trim();
4333
- }
4334
- catch (err) {
4335
- const msg = err instanceof Error ? err.message : String(err);
4336
- return c.json({ error: `Cannot resolve base commit for branch ${workspace.sourceBranch}: ${msg}` }, 500);
4337
- }
4338
- // Build context
4339
- const commits = gitOps.getCommitsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
4340
- const committedStats = gitOps.getDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
4341
- const workingTreeStats = gitOps.getWorkingTreeDiffStats(worktreePath);
4342
- const diffStats = workingTreeStats.trim().length > 0
4343
- ? `${committedStats}\n\n— Working tree (uncommitted) —\n${workingTreeStats}`
4344
- : committedStats;
4345
- const template = getActiveReviewTemplate();
4346
- const rendered = renderReviewTemplate(template, {
4347
- workspace,
4348
- commits,
4349
- diffStats,
4350
- baseCommit,
4351
- additionalInstructions,
4352
- });
4353
- let messageSent = false;
4354
- let emitSessionId;
4355
- if (workspaceService.getWorkspace(workspace.id)?.status === 'compacting') {
4356
- return c.json({
4357
- code: 'compacting',
4358
- error: 'Workspace is compacting its context; wait until compaction finishes before sending a message',
4359
- }, 409);
4360
- }
4361
- if (newSession) {
4362
- // Stop current agent, wait for it, then start fresh.
4363
- try {
4364
- assertAgentStopped(await agentManager.stopAgentAndWait(workspace.id, undefined, 'replacement'));
4365
- }
4366
- catch (err) {
4367
- const msg = err instanceof Error ? err.message : String(err);
4368
- console.error(`[start-review] stopAgentAndWait failed (continuing): ${msg}`);
4369
- throw err;
4370
- }
4371
- try {
4372
- const agent = agentManager.startAgent(workspace.id, worktreePath, rendered, workspace.model, false /* resume */, workspace.agentPermissionMode, undefined, workspace.reasoningEffort);
4373
- workspaceService.updateWorkspaceStatus(workspace.id, 'executing');
4374
- emitSessionId = agent?.agentSessionId;
4375
- messageSent = true;
4376
- }
4377
- catch (err) {
4378
- const msg = err instanceof Error ? err.message : String(err);
4379
- return c.json({ error: `Failed to start review session: ${msg}` }, 500);
4380
- }
4381
- }
4382
- else {
4383
- const session = workspaceService.getActiveSession(workspace.id);
4384
- emitSessionId = session?.id;
4385
- try {
4386
- const delivery = await deliverAgentPrompt(workspace, worktreePath, rendered);
4387
- emitSessionId = delivery.agentSessionId;
4388
- messageSent = true;
4389
- }
4390
- catch (deliveryErr) {
4391
- const message = deliveryErr instanceof Error ? deliveryErr.message : String(deliveryErr);
4392
- return c.json({ error: `Failed to dispatch review prompt: ${message}` }, 500);
4393
- }
4394
- }
4395
- // Emit AFTER dispatch so the user:message lands in the correct session id —
4396
- // for newSession=true that's the freshly created session, not the previous one.
4397
- wsService.emit(workspace.id, 'user:message', { content: rendered, sender: 'user' }, emitSessionId);
4398
- return c.json({ ok: true, messageSent, newSession });
4309
+ return c.json(await startWorkspaceReview(c.req.param('id'), await c.req.json().catch(() => ({}))));
4399
4310
  }
4400
4311
  catch (err) {
4401
- const message = err instanceof Error ? err.message : String(err);
4402
- return c.json({ error: message }, workspaceErrorStatus(err));
4312
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, err instanceof ReviewRequestError ? err.status : workspaceErrorStatus(err));
4403
4313
  }
4404
4314
  });
4405
4315
  // POST /api/workspaces/:id/start-ci-fix — dispatch the configured CI-fix
@@ -315,6 +315,7 @@ export function createClaudeCodeEngine() {
315
315
  kind: 'error',
316
316
  category: 'other',
317
317
  message: 'Session force-ended by the liveness watchdog: no SDK activity within the deadline. If the agent was legitimately busy, this is a bug worth reporting.',
318
+ code: 'stream_idle_timeout',
318
319
  });
319
320
  emitSessionEnded('watchdog', null);
320
321
  }
@@ -587,6 +588,7 @@ export function createClaudeCodeEngine() {
587
588
  }
588
589
  })();
589
590
  const engineProcess = {
591
+ closed: iteratorPromise.then(() => { }),
590
592
  get pid() {
591
593
  return undefined;
592
594
  },
@@ -124,14 +124,41 @@ export function createCodexEngine() {
124
124
  });
125
125
  void childFailurePromise.catch(() => { });
126
126
  const child = spawnAppServer({ cwd: options.workingDir, env: options.env, signal: abortController.signal });
127
+ let childExited = false;
128
+ let resolveChildExited;
129
+ const childExitedPromise = new Promise((resolve) => {
130
+ resolveChildExited = resolve;
131
+ });
132
+ const confirmChildExited = () => {
133
+ childExited = true;
134
+ resolveChildExited();
135
+ };
136
+ let shutdownPromise;
137
+ const terminateChild = () => {
138
+ if (childExited)
139
+ return Promise.resolve();
140
+ shutdownPromise ??= (async () => {
141
+ if (!(await signalAndWaitForExit(child, 'SIGTERM', CODEX_FORCE_KILL_TIMEOUT_MS))) {
142
+ console.warn('[codex] app-server ignored SIGTERM — sending SIGKILL');
143
+ if (!(await signalAndWaitForExit(child, 'SIGKILL', CODEX_FORCE_KILL_TIMEOUT_MS))) {
144
+ throw new Error('Codex app-server did not exit after SIGKILL');
145
+ }
146
+ }
147
+ confirmChildExited();
148
+ })();
149
+ return shutdownPromise;
150
+ };
127
151
  child.on('error', (error) => {
128
152
  const isExpectedAbort = abortController.signal.aborted && (error.code === 'ABORT_ERR' || error.name === 'AbortError');
129
153
  if (isExpectedAbort)
130
154
  return;
155
+ if (child.pid === undefined)
156
+ confirmChildExited();
131
157
  console.error('[codex] child process error:', error);
132
158
  rejectChildFailure(error);
133
159
  });
134
160
  child.once('exit', (code, signal) => {
161
+ confirmChildExited();
135
162
  if (!iteratorRunning || abortController.signal.aborted)
136
163
  return;
137
164
  const detail = code === null ? `signal ${signal ?? 'unknown'}` : `code ${code}`;
@@ -154,6 +181,7 @@ export function createCodexEngine() {
154
181
  let rejectTurnDone;
155
182
  const activeSubagentThreads = new Map();
156
183
  let waitingForBackgroundSubagents = false;
184
+ let watchdogEnded = false;
157
185
  const turnDonePromise = new Promise((resolve, reject) => {
158
186
  resolveTurnDone = resolve;
159
187
  rejectTurnDone = reject;
@@ -169,6 +197,8 @@ export function createCodexEngine() {
169
197
  // deadline tracks the last activity, not the start of the wait.
170
198
  const armSubagentStallWatchdog = () => {
171
199
  clearSubagentStallWatchdog();
200
+ if (!waitingForBackgroundSubagents || pendingByCallId.size > 0 || activeSubagentThreads.size === 0)
201
+ return;
172
202
  subagentStallTimer = setTimeout(() => {
173
203
  subagentStallTimer = undefined;
174
204
  console.warn(`[codex-engine] Background subagent thread(s) still tracked active ${CODEX_SUBAGENT_STALL_TIMEOUT_MS}ms after the turn completed — forcing session drain.`);
@@ -176,7 +206,13 @@ export function createCodexEngine() {
176
206
  // Forced, unclean termination (a thread may still be alive
177
207
  // server-side) — never report it as a normal completion, so
178
208
  // auto-loop doesn't treat an orphaned run as forward progress.
179
- mapperState.sawErrorResult = true;
209
+ watchdogEnded = true;
210
+ safeEmit({
211
+ kind: 'error',
212
+ category: 'other',
213
+ code: 'subagent_idle_timeout',
214
+ message: 'Session force-ended: background subagents stopped reporting activity (watchdog).',
215
+ });
180
216
  if (waitingForBackgroundSubagents)
181
217
  resolveTurnDone();
182
218
  }, CODEX_SUBAGENT_STALL_TIMEOUT_MS);
@@ -209,8 +245,13 @@ export function createCodexEngine() {
209
245
  const turnLiveness = createTurnLiveness({
210
246
  timeoutMs: CODEX_TURN_IDLE_TIMEOUT_MS,
211
247
  onTimeout() {
212
- mapperState.sawErrorResult = true;
213
- safeEmit({ kind: 'error', category: 'other', message: 'Codex stopped reporting activity for this turn' });
248
+ watchdogEnded = true;
249
+ safeEmit({
250
+ kind: 'error',
251
+ category: 'other',
252
+ code: 'stream_idle_timeout',
253
+ message: 'Codex stopped reporting activity for this turn',
254
+ });
214
255
  rejectTurnDone(new CodexTurnTimeoutError());
215
256
  },
216
257
  });
@@ -221,6 +262,21 @@ export function createCodexEngine() {
221
262
  rejectTurnDone(err);
222
263
  });
223
264
  const fileChanges = new Map();
265
+ const isParentTurn = (threadId, turnId) => (!threadId || threadId === discoveredSessionId) && (!activeTurnId || !turnId || turnId === activeTurnId);
266
+ const otherTurnMappers = new Map();
267
+ const mapTurnEvents = (notification, map) => {
268
+ if (isParentTurn(notification.threadId, notification.turnId))
269
+ return map(mapperState);
270
+ const key = JSON.stringify([notification.threadId, notification.turnId]);
271
+ let state = otherTurnMappers.get(key);
272
+ if (!state) {
273
+ state = createMapperState();
274
+ otherTurnMappers.set(key, state);
275
+ }
276
+ // Child output remains visible, but its failures/compaction/markers
277
+ // cannot control the parent session or its auto-loop lifecycle.
278
+ return map(state).filter((event) => event.kind !== 'error' && !event.kind.startsWith('session:'));
279
+ };
224
280
  const itemKey = (threadId, turnId, itemId) => JSON.stringify([threadId, turnId, itemId]);
225
281
  const client = createAppServerClient({
226
282
  stdin: child.stdin,
@@ -228,6 +284,10 @@ export function createCodexEngine() {
228
284
  clientInfo: { name: 'kobo', version: getPackageVersion() },
229
285
  onNotification(method, params) {
230
286
  turnLiveness.activity();
287
+ const notification = params;
288
+ if (notification?.threadId && activeSubagentThreads.has(notification.threadId)) {
289
+ armSubagentStallWatchdog();
290
+ }
231
291
  // Ignored notifications — harmless bookkeeping by the server
232
292
  if (method === 'mcpServer/startupStatus/updated') {
233
293
  const status = (params ?? {});
@@ -257,7 +317,7 @@ export function createCodexEngine() {
257
317
  const n = params;
258
318
  if (n.item.type === 'fileChange')
259
319
  fileChanges.set(itemKey(n.threadId, n.turnId, n.item.id), n.item);
260
- const events = handleItemStarted(n.item, mapperState);
320
+ const events = mapTurnEvents(n, (state) => handleItemStarted(n.item, state));
261
321
  for (const ev of events)
262
322
  safeEmit(ev);
263
323
  if (n.item.type === 'collabAgentToolCall' && n.item.tool === 'spawnAgent') {
@@ -278,7 +338,7 @@ export function createCodexEngine() {
278
338
  const n = params;
279
339
  if (n.item.type === 'fileChange')
280
340
  fileChanges.set(itemKey(n.threadId, n.turnId, n.item.id), n.item);
281
- const events = handleItemCompleted(n.item, mapperState);
341
+ const events = mapTurnEvents(n, (state) => handleItemCompleted(n.item, state));
282
342
  for (const ev of events)
283
343
  safeEmit(ev);
284
344
  if (n.item.type === 'collabAgentToolCall') {
@@ -302,7 +362,7 @@ export function createCodexEngine() {
302
362
  }
303
363
  if (method === 'item/agentMessage/delta') {
304
364
  const n = params;
305
- for (const ev of handleAgentMessageDelta(n, mapperState))
365
+ for (const ev of mapTurnEvents(n, (state) => handleAgentMessageDelta(n, state)))
306
366
  safeEmit(ev);
307
367
  return;
308
368
  }
@@ -313,9 +373,9 @@ export function createCodexEngine() {
313
373
  if (threadId === n.threadId && turnId === n.turn.id)
314
374
  fileChanges.delete(key);
315
375
  }
316
- for (const ev of handleTurnCompleted(n, mapperState))
317
- safeEmit(ev);
318
- if (!activeTurnId || !n.turn?.id || n.turn.id === activeTurnId) {
376
+ if (isParentTurn(n.threadId, n.turn.id)) {
377
+ for (const ev of handleTurnCompleted(n, mapperState))
378
+ safeEmit(ev);
319
379
  if (n.turn?.status === 'completed' && activeSubagentThreads.size > 0) {
320
380
  waitingForBackgroundSubagents = true;
321
381
  turnLiveness.pause();
@@ -349,6 +409,8 @@ export function createCodexEngine() {
349
409
  }
350
410
  if (method === 'error') {
351
411
  const n = params;
412
+ if (!isParentTurn(n.threadId, n.turnId))
413
+ return;
352
414
  if (n.willRetry)
353
415
  return;
354
416
  const msg = n?.error?.message ?? 'unknown error';
@@ -371,6 +433,7 @@ export function createCodexEngine() {
371
433
  register(callId, pending) {
372
434
  pendingByCallId.set(callId, pending);
373
435
  turnLiveness.pause();
436
+ clearSubagentStallWatchdog();
374
437
  },
375
438
  respondError: (reqId, code, message) => client.peer.respondError(reqId, code, message),
376
439
  respond: (reqId, result) => client.peer.respond(reqId, result),
@@ -444,7 +507,9 @@ export function createCodexEngine() {
444
507
  ? 'error'
445
508
  : mapperState.sawTurnInterrupted
446
509
  ? 'killed'
447
- : 'completed';
510
+ : watchdogEnded
511
+ ? 'watchdog'
512
+ : 'completed';
448
513
  safeEmit({
449
514
  kind: 'session:ended',
450
515
  reason,
@@ -465,7 +530,11 @@ export function createCodexEngine() {
465
530
  safeEmit({ kind: 'session:ended', reason: 'killed', exitCode: null });
466
531
  }
467
532
  else if (error.name === 'CodexTurnTimeoutError') {
468
- safeEmit({ kind: 'session:ended', reason: 'error', exitCode: null });
533
+ safeEmit({
534
+ kind: 'session:ended',
535
+ reason: mapperState.sawErrorResult ? 'error' : 'watchdog',
536
+ exitCode: null,
537
+ });
469
538
  }
470
539
  else if (QUOTA_PATTERN.test(message)) {
471
540
  tryEmitQuota(mapperState, safeEmit, message);
@@ -497,18 +566,15 @@ export function createCodexEngine() {
497
566
  }
498
567
  }
499
568
  fileChanges.clear();
569
+ otherTurnMappers.clear();
500
570
  pendingByCallId.clear();
501
571
  pendingToolCalls.clear();
502
572
  client.close();
503
- try {
504
- child.kill('SIGTERM');
505
- }
506
- catch {
507
- // best-effort
508
- }
573
+ await terminateChild().catch((error) => console.error('[codex] runtime shutdown unconfirmed:', error));
509
574
  }
510
575
  })();
511
576
  const engineProcess = {
577
+ closed: Promise.all([iteratorPromise, childExitedPromise]).then(() => { }),
512
578
  get pid() {
513
579
  return child.pid;
514
580
  },
@@ -516,7 +582,7 @@ export function createCodexEngine() {
516
582
  return discoveredSessionId;
517
583
  },
518
584
  isAlive() {
519
- return iteratorRunning;
585
+ return iteratorRunning || !childExited;
520
586
  },
521
587
  sendMessage(text) {
522
588
  const steer = async () => {
@@ -581,17 +647,7 @@ export function createCodexEngine() {
581
647
  catch {
582
648
  // swallow
583
649
  }
584
- // Escalate. The SIGTERM in the `finally` below is a request, not a
585
- // guarantee: a wedged `codex app-server` that ignores it outlives the
586
- // stop, the archive, the delete and Kōbō's own shutdown, holding the
587
- // worktree and its share of the model quota, with nothing left in
588
- // this process tracking it.
589
- if (!(await signalAndWaitForExit(child, 'SIGTERM', CODEX_FORCE_KILL_TIMEOUT_MS))) {
590
- console.warn('[codex] app-server ignored SIGTERM — sending SIGKILL');
591
- if (!(await signalAndWaitForExit(child, 'SIGKILL', CODEX_FORCE_KILL_TIMEOUT_MS))) {
592
- throw new Error('Codex app-server did not exit after SIGKILL');
593
- }
594
- }
650
+ await terminateChild();
595
651
  },
596
652
  resolvePendingUserInput(callId, response) {
597
653
  const pending = pendingByCallId.get(callId);
@@ -601,8 +657,12 @@ export function createCodexEngine() {
601
657
  // Only resume the idle-timeout clock once every outstanding
602
658
  // approval has been answered — a sibling request may still be
603
659
  // waiting on a human decision.
604
- if (pendingByCallId.size === 0)
605
- turnLiveness.resume();
660
+ if (pendingByCallId.size === 0) {
661
+ if (waitingForBackgroundSubagents)
662
+ armSubagentStallWatchdog();
663
+ else
664
+ turnLiveness.resume();
665
+ }
606
666
  const result = buildResponseForResolve(pending, response);
607
667
  client.peer.respond(pending.requestId, result);
608
668
  return true;