@loicngr/kobo 1.11.43 → 1.11.45

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 (166) hide show
  1. package/AGENTS.md +74 -1
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +13 -1
  4. package/dist/mcp-server/kobo-tasks-handlers.js +33 -75
  5. package/dist/mcp-server/kobo-tasks-server.js +82 -9
  6. package/dist/server/db/auto-loop-schema.js +44 -0
  7. package/dist/server/db/migrations.js +18 -0
  8. package/dist/server/db/review-return-schema.js +10 -0
  9. package/dist/server/db/schema.js +8 -1
  10. package/dist/server/db/session-handoff-schema.js +24 -0
  11. package/dist/server/index.js +12 -9
  12. package/dist/server/routes/auto-loop-messages.js +51 -0
  13. package/dist/server/routes/documents.js +1 -1
  14. package/dist/server/routes/workspaces.js +119 -145
  15. package/dist/server/services/agent/engines/claude-code/engine.js +16 -0
  16. package/dist/server/services/agent/engines/codex/engine.js +92 -31
  17. package/dist/server/services/agent/orchestrator.js +298 -96
  18. package/dist/server/services/agent/session-controller.js +42 -2
  19. package/dist/server/services/auto-loop-message-service.js +79 -0
  20. package/dist/server/services/auto-loop-service.js +225 -86
  21. package/dist/server/services/auto-loop-state-service.js +30 -0
  22. package/dist/server/services/cron-service.js +30 -16
  23. package/dist/server/services/engine-handoff-service.js +5 -5
  24. package/dist/server/services/quota-backoff-service.js +42 -10
  25. package/dist/server/services/review-return-service.js +64 -0
  26. package/dist/server/services/review-service.js +187 -0
  27. package/dist/server/services/session-activity-service.js +17 -0
  28. package/dist/server/services/session-handoff-runtime.js +26 -0
  29. package/dist/server/services/session-handoff-service.js +479 -0
  30. package/dist/server/services/settings-service.js +3 -3
  31. package/dist/server/services/task-mutations.js +186 -0
  32. package/dist/server/services/wakeup-service.js +24 -1
  33. package/dist/server/services/workspace-message-service.js +16 -2
  34. package/dist/server/services/workspace-service.js +28 -36
  35. package/dist/server/utils/workspace-lifecycle-guard.js +35 -20
  36. package/dist/shared/auto-loop-prompts.js +22 -11
  37. package/dist/shared/auto-loop-types.js +1 -0
  38. package/dist/shared/document-roots.js +2 -0
  39. package/dist/shared/review.js +6 -0
  40. package/dist/shared/session-handoff.js +4 -0
  41. package/dist/shared/task-verification.js +11 -0
  42. package/package.json +12 -12
  43. package/src/client/dist/pwa/assets/ActivityFeed-CLXjdqS8.js +8 -0
  44. package/src/client/dist/pwa/assets/{ChangelogPage-DX1WGFea.js → ChangelogPage-CnXTnFhe.js} +1 -1
  45. package/src/client/dist/pwa/assets/{ClosePopup-BqW5dy-O.js → ClosePopup-CWjIRHxu.js} +1 -1
  46. package/src/client/dist/pwa/assets/{ComparisonPanel-B4ne55Xj.js → ComparisonPanel-CKylN45q.js} +2 -2
  47. package/src/client/dist/pwa/assets/CreatePage-SoCcvxuD.js +2 -0
  48. package/src/client/dist/pwa/assets/{DashboardPage-Cv6HJEcO.js → DashboardPage-CZQLfoV1.js} +1 -1
  49. package/src/client/dist/pwa/assets/{DiffViewer-COwGxPt6.js → DiffViewer-Csf3WmLc.js} +4 -4
  50. package/src/client/dist/pwa/assets/{DocumentsPanel-DCXpLLb7.js → DocumentsPanel-BeCttB-x.js} +1 -1
  51. package/src/client/dist/pwa/assets/{DrawerToggleButton-DRL3RCkM.js → DrawerToggleButton-BJBBYeOc.js} +1 -1
  52. package/src/client/dist/pwa/assets/GitPanel-B-ZHgghQ.js +3 -0
  53. package/src/client/dist/pwa/assets/{HealthPage-XrOuMoDJ.js → HealthPage-l88TEk-k.js} +1 -1
  54. package/src/client/dist/pwa/assets/MainLayout-BAAQLq95.css +1 -0
  55. package/src/client/dist/pwa/assets/MainLayout-Cf4Q9x1u.js +2 -0
  56. package/src/client/dist/pwa/assets/{QBadge-DbkxGtht.js → QBadge-FWC1zDCq.js} +1 -1
  57. package/src/client/dist/pwa/assets/{QBanner-DhqH95GW.js → QBanner-CcRI_QIy.js} +1 -1
  58. package/src/client/dist/pwa/assets/QBtn-DEdx_Y1V.js +1 -0
  59. package/src/client/dist/pwa/assets/{QBtnDropdown-163oM6T_.js → QBtnDropdown-44Mj-y_1.js} +1 -1
  60. package/src/client/dist/pwa/assets/{QBtnGroup-DELKYo3u.js → QBtnGroup-CAoD6G0Q.js} +1 -1
  61. package/src/client/dist/pwa/assets/{QBtnToggle-BI3k3Ch2.js → QBtnToggle-C_hQ2UCQ.js} +1 -1
  62. package/src/client/dist/pwa/assets/{QChip-owciRuZu.js → QChip-DCPUhqXX.js} +1 -1
  63. package/src/client/dist/pwa/assets/QDrawer-B4qHw8wt.js +1 -0
  64. package/src/client/dist/pwa/assets/QExpansionItem-Bok1m8Wi.js +1 -0
  65. package/src/client/dist/pwa/assets/{QItemSection-D_5_0UrX.js → QItemSection-BhqphaH6.js} +1 -1
  66. package/src/client/dist/pwa/assets/{QList-qupzuEqO.js → QList-CHm4JNP0.js} +1 -1
  67. package/src/client/dist/pwa/assets/{QMarkupTable-B9QuKtK_.js → QMarkupTable-CNpiA9oq.js} +1 -1
  68. package/src/client/dist/pwa/assets/{QPage-DaX9sMQC.js → QPage-BXBw2Alx.js} +1 -1
  69. package/src/client/dist/pwa/assets/QSelect-BbtD5Iks.js +1 -0
  70. package/src/client/dist/pwa/assets/{QSpinnerDots-DnCV3VzI.js → QSpinnerDots-BkFiCsH3.js} +1 -1
  71. package/src/client/dist/pwa/assets/{QTooltip-BQWY7aeM.js → QTooltip-BDihn2aY.js} +1 -1
  72. package/src/client/dist/pwa/assets/SchedulePanel-DiWCDNZ8.js +1 -0
  73. package/src/client/dist/pwa/assets/{SearchPage-DOHkP5lj.js → SearchPage-BwZ2TX1G.js} +1 -1
  74. package/src/client/dist/pwa/assets/SettingsPage-BUh0WfNf.js +9 -0
  75. package/src/client/dist/pwa/assets/{SplitWorkspacePage-DL4kp9Ah.js → SplitWorkspacePage-Bg6nKBcA.js} +1 -1
  76. package/src/client/dist/pwa/assets/{TerminalPanel-DHDnyiYh.js → TerminalPanel-Cq-FR7A7.js} +9 -9
  77. package/src/client/dist/pwa/assets/{TouchPan-BnHXDR6F.js → TouchPan-BQ1cvzRC.js} +1 -1
  78. package/src/client/dist/pwa/assets/{TourReplayButton-ClVcB6mO.js → TourReplayButton-Dz1yEz_z.js} +1 -1
  79. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-Bxe6oIJI.js → WorkspaceAttentionLabels-BCVOP8vp.js} +1 -1
  80. package/src/client/dist/pwa/assets/WorkspacePage-BbUvR1Vf.css +1 -0
  81. package/src/client/dist/pwa/assets/WorkspacePage-Cl8gap4b.js +4 -0
  82. package/src/client/dist/pwa/assets/{browser-Bkx4fbxl.js → browser-BmWgBx9M.js} +1 -1
  83. package/src/client/dist/pwa/assets/build-path-tree-6EjBmYYY.js +1 -0
  84. package/src/client/dist/pwa/assets/de-COObokpu.js +17 -0
  85. package/src/client/dist/pwa/assets/documents-BZ8bwcjr.js +1 -0
  86. package/src/client/dist/pwa/assets/{editor.api-DreEG0If.js → editor.api-CK7--ckY.js} +1 -1
  87. package/src/client/dist/pwa/assets/editor.worker-iO5iN0DB.js +33 -0
  88. package/src/client/dist/pwa/assets/efforts-Bw0o2Bud.js +9 -0
  89. package/src/client/dist/pwa/assets/engineFeatures-B-9jHocY.css +1 -0
  90. package/src/client/dist/pwa/assets/engineFeatures-DQO9HHXI.js +1 -0
  91. package/src/client/dist/pwa/assets/es-By2Q9Tca.js +17 -0
  92. package/src/client/dist/pwa/assets/expand-template-C8VyjASo.js +1 -0
  93. package/src/client/dist/pwa/assets/{format-B7z3F-Mw.js → format-LtTLLitJ.js} +3 -3
  94. package/src/client/dist/pwa/assets/{formatters-BTBiU0v3.js → formatters-Bl9zA7Ai.js} +1 -1
  95. package/src/client/dist/pwa/assets/fr-D-sd0ddn.js +17 -0
  96. package/src/client/dist/pwa/assets/{freemarker2-BZNTVQ3K.js → freemarker2-DxezMaje.js} +1 -1
  97. package/src/client/dist/pwa/assets/{handlebars-k8tN0dI3.js → handlebars-DK_Bj6Rx.js} +1 -1
  98. package/src/client/dist/pwa/assets/{html-iZL3bbXq.js → html-DFfsfStD.js} +1 -1
  99. package/src/client/dist/pwa/assets/i18n-aDWy4KHt.js +1 -0
  100. package/src/client/dist/pwa/assets/index-0wtBh1Xt.css +1 -0
  101. package/src/client/dist/pwa/assets/index-BLd8DAzg.js +20 -0
  102. package/src/client/dist/pwa/assets/it-CeK5lyIA.js +17 -0
  103. package/src/client/dist/pwa/assets/{javascript-TV6gg0MO.js → javascript-DuNbcaL7.js} +1 -1
  104. package/src/client/dist/pwa/assets/{liquid-Cifa5ujF.js → liquid-CM3JKyhv.js} +1 -1
  105. package/src/client/dist/pwa/assets/{mdx-Bnj_8yGE.js → mdx-BG3zYwNF.js} +1 -1
  106. package/src/client/dist/pwa/assets/{monaco-contributions-pEh0n9Wk.js → monaco-contributions-DLscZGH-.js} +1 -1
  107. package/src/client/dist/pwa/assets/monaco.contribution-DnhZHwXT.js +2 -0
  108. package/src/client/dist/pwa/assets/{network-auth-3_5p4fdc.js → network-auth-BJiPbO5l.js} +1 -1
  109. package/src/client/dist/pwa/assets/{notify-theme-D_nf9AeF.js → notify-theme-D4p3qPkp.js} +1 -1
  110. package/src/client/dist/pwa/assets/{pending-diff-open-CUnxC1xw.js → pending-diff-open-dYIFdS4r.js} +1 -1
  111. package/src/client/dist/pwa/assets/permissionModes-B7fuUegU.js +36 -0
  112. package/src/client/dist/pwa/assets/{pinia-BKqmoVru.js → pinia-CTJ3OfP3.js} +1 -1
  113. package/src/client/dist/pwa/assets/{private.use-form-C7umbGO-.js → private.use-form-Bk8ti264.js} +1 -1
  114. package/src/client/dist/pwa/assets/{project-color-CihwpDAA.js → project-color-R_1je4vW.js} +1 -1
  115. package/src/client/dist/pwa/assets/{python-DOoNBSNL.js → python-COs6pHk2.js} +1 -1
  116. package/src/client/dist/pwa/assets/{razor-Bvq5vVpJ.js → razor-Jl1VqMsk.js} +1 -1
  117. package/src/client/dist/pwa/assets/reactivity.esm-bundler-Bo9anJjg.js +1 -0
  118. package/src/client/dist/pwa/assets/render-chat-markdown-AaOo0Tgq.js +67 -0
  119. package/src/client/dist/pwa/assets/{runtime-core.esm-bundler-CDnVUfeZ.js → runtime-core.esm-bundler-CNFKm4ij.js} +1 -1
  120. package/src/client/dist/pwa/assets/{typescript-VVzxoeyM.js → typescript-BU_TUuAM.js} +1 -1
  121. package/src/client/dist/pwa/assets/{use-checkbox-FnauAgTp.js → use-checkbox-CnvHBhSK.js} +1 -1
  122. package/src/client/dist/pwa/assets/{use-id-BIuaWKkB.js → use-id-wXd4c8FA.js} +1 -1
  123. package/src/client/dist/pwa/assets/use-is-mobile-DpKI3XMy.js +1 -0
  124. package/src/client/dist/pwa/assets/{use-search-index-status-DtLzygcy.js → use-search-index-status-Bluf9L99.js} +1 -1
  125. package/src/client/dist/pwa/assets/use-slide-transition-CiaDm-tR.js +1 -0
  126. package/src/client/dist/pwa/assets/use-tours-CVn874kD.js +2 -0
  127. package/src/client/dist/pwa/assets/{workspace-templates-1ui80Kqo.js → workspace-templates-BCKR8TXr.js} +1 -1
  128. package/src/client/dist/pwa/assets/{xml-CzGFIxQa.js → xml-gFFsobxE.js} +1 -1
  129. package/src/client/dist/pwa/assets/{yaml-Bl7nDJeC.js → yaml-DaXaqaGD.js} +1 -1
  130. package/src/client/dist/pwa/index.html +10 -10
  131. package/src/client/dist/pwa/sw.js +1 -1
  132. package/src/mcp-server/README.md +8 -1
  133. package/src/mcp-server/kobo-tasks-handlers.ts +72 -105
  134. package/src/mcp-server/kobo-tasks-server.ts +94 -9
  135. package/src/client/dist/pwa/assets/ActivityFeed-D1e-zV3O.js +0 -8
  136. package/src/client/dist/pwa/assets/CreatePage-Bx_GpUni.js +0 -2
  137. package/src/client/dist/pwa/assets/GitPanel-C9mz4GXF.js +0 -3
  138. package/src/client/dist/pwa/assets/MainLayout-BwV89R-l.css +0 -1
  139. package/src/client/dist/pwa/assets/MainLayout-CwWavQjV.js +0 -2
  140. package/src/client/dist/pwa/assets/QBtn-CZaBclNw.js +0 -1
  141. package/src/client/dist/pwa/assets/QDrawer-DNEj8wLZ.js +0 -1
  142. package/src/client/dist/pwa/assets/QSelect-BUlOQe5k.js +0 -1
  143. package/src/client/dist/pwa/assets/SchedulePanel-Bos94OyR.js +0 -1
  144. package/src/client/dist/pwa/assets/SettingsPage-DtGsd0XH.js +0 -9
  145. package/src/client/dist/pwa/assets/WorkspacePage-Clc4Fl3B.js +0 -4
  146. package/src/client/dist/pwa/assets/WorkspacePage-DCg9zSxz.css +0 -1
  147. package/src/client/dist/pwa/assets/build-path-tree-BpSY7N5O.js +0 -1
  148. package/src/client/dist/pwa/assets/de-BU0dILBs.js +0 -17
  149. package/src/client/dist/pwa/assets/documents-BIlWh3o-.js +0 -1
  150. package/src/client/dist/pwa/assets/editor.worker-CwQAd_xr.js +0 -30
  151. package/src/client/dist/pwa/assets/efforts-D_yTzymv.js +0 -9
  152. package/src/client/dist/pwa/assets/engineFeatures-C33LTVkK.js +0 -1
  153. package/src/client/dist/pwa/assets/es-CNp0s9na.js +0 -17
  154. package/src/client/dist/pwa/assets/expand-template-wRhAXT-1.js +0 -1
  155. package/src/client/dist/pwa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
  156. package/src/client/dist/pwa/assets/fr-RqJyWSdm.js +0 -17
  157. package/src/client/dist/pwa/assets/i18n--PCet00a.js +0 -1
  158. package/src/client/dist/pwa/assets/index-CPfrY38K.js +0 -20
  159. package/src/client/dist/pwa/assets/index-CUoUy6tH.css +0 -1
  160. package/src/client/dist/pwa/assets/it-DSxNuCwL.js +0 -17
  161. package/src/client/dist/pwa/assets/monaco.contribution-D7NmJUJH.js +0 -2
  162. package/src/client/dist/pwa/assets/permissionModes-DC51Pz84.js +0 -36
  163. package/src/client/dist/pwa/assets/reactivity.esm-bundler-DEJj9cFv.js +0 -1
  164. package/src/client/dist/pwa/assets/render-chat-markdown-BQ_saiy5.js +0 -67
  165. package/src/client/dist/pwa/assets/use-is-mobile-CWjqe8gC.js +0 -1
  166. package/src/client/dist/pwa/assets/use-tours-BKeYrJaQ.js +0 -2
@@ -41,7 +41,9 @@ import { reconcileMessageRequests } from './services/mcp-message-request-service
41
41
  import { authorizeWsUpgrade, generateToken, getLanHostnames, getLanUrls, isAllowedOrigin, isAllowedRequestHost, resolveBindHost, resolveDevClientOrigin, resolveNetworkAccessEnvOverrides, resolveProxyHostname, } from './services/network-access-service.js';
42
42
  import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
43
43
  import * as quotaBackoffService from './services/quota-backoff-service.js';
44
+ import { reconcileReviewReturns } from './services/review-return-service.js';
44
45
  import { startSearchIndex, stopSearchIndex } from './services/search-service.js';
46
+ import { reconcileSessionHandoffs } from './services/session-handoff-service.js';
45
47
  import { getGlobalSettings, updateNetworkAccessSettings } from './services/settings-service.js';
46
48
  import { reloadDefaultTemplates } from './services/templates-service.js';
47
49
  import { createTerminal, destroyAllTerminals, getTerminal } from './services/terminal-service.js';
@@ -116,15 +118,8 @@ const dailyBackupScheduler = startDailyDbBackupScheduler(async () => {
116
118
  });
117
119
  // Initialize process cleanup, agent watchdog, PR watcher, and wakeup rehydration
118
120
  reconcileOrphanSessions();
119
- startWatchdog();
120
- wakeupService.rehydrate();
121
- autoLoopService.rehydrate();
122
- // Restore in-memory retry counts BEFORE re-arming the persisted backoff timers,
123
- // otherwise the next arm() after restart would compute the next ladder rung
124
- // from retryCount=0 and undo the progression.
125
- restoreRetryCountsFromDb();
126
- quotaBackoffService.restoreOnBoot((workspaceId, pending) => autoLoopService.onQuotaBackoffExpired(workspaceId, pending));
127
- cronService.restoreOnBoot();
121
+ reconcileReviewReturns();
122
+ reconcileSessionHandoffs();
128
123
  // Deliver any new default prompt templates to existing installs (seed-once via the
129
124
  // seededDefaultSlugs watermark; never overwrites or re-adds deleted defaults).
130
125
  try {
@@ -229,6 +224,14 @@ const server = serve({
229
224
  hostname: bindHost,
230
225
  }, (info) => {
231
226
  setBackendPort(info.port);
227
+ // Restored agents receive the actual listening endpoint, including an
228
+ // ephemeral or SERVER_PORT-selected port. Quota ownership comes first.
229
+ restoreRetryCountsFromDb();
230
+ quotaBackoffService.restoreOnBoot((workspaceId, pending) => autoLoopService.onQuotaBackoffExpired(workspaceId, pending));
231
+ autoLoopService.rehydrate();
232
+ wakeupService.rehydrate();
233
+ cronService.restoreOnBoot();
234
+ startWatchdog();
232
235
  startUpdateChecker();
233
236
  stopStartupSpinner();
234
237
  const settings = getGlobalSettings();
@@ -0,0 +1,51 @@
1
+ import { Hono } from 'hono';
2
+ import { listLoopMessages, resolveLoopMessage } from '../services/auto-loop-message-service.js';
3
+ import { deliverWorkspaceMessage } from '../services/workspace-message-service.js';
4
+ import { getWorkspace } from '../services/workspace-service.js';
5
+ import { assertWorkspaceLifecycleAvailable } from '../utils/workspace-lifecycle-guard.js';
6
+ const app = new Hono();
7
+ app.get('/:id/auto-loop/messages', (c) => {
8
+ try {
9
+ const id = c.req.param('id');
10
+ if (!getWorkspace(id))
11
+ return c.json({ error: 'Workspace not found' }, 404);
12
+ return c.json(listLoopMessages(id));
13
+ }
14
+ catch (error) {
15
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
16
+ }
17
+ });
18
+ app.post('/:id/auto-loop/messages', async (c) => {
19
+ try {
20
+ const body = await c.req.json();
21
+ if (typeof body.content !== 'string' || typeof body.clientMessageId !== 'string')
22
+ return c.json({ error: 'content and clientMessageId are required' }, 400);
23
+ await deliverWorkspaceMessage(c.req.param('id'), {
24
+ content: body.content,
25
+ clientMessageId: body.clientMessageId,
26
+ delivery: 'next_iteration',
27
+ });
28
+ return c.json({ accepted: true });
29
+ }
30
+ catch (error) {
31
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
32
+ }
33
+ });
34
+ app.patch('/:id/auto-loop/messages/:messageId', async (c) => {
35
+ try {
36
+ const id = c.req.param('id');
37
+ assertWorkspaceLifecycleAvailable(id);
38
+ const workspace = getWorkspace(id);
39
+ if (!workspace || workspace.archivedAt || workspace.worktreePurgedAt)
40
+ return c.json({ error: 'Workspace unavailable' }, 409);
41
+ const { action } = await c.req.json();
42
+ if (!['cancel', 'acknowledge', 'retry'].includes(action))
43
+ return c.json({ error: 'Invalid resolution' }, 400);
44
+ resolveLoopMessage(id, Number(c.req.param('messageId')), action);
45
+ return c.json({ ok: true });
46
+ }
47
+ catch (error) {
48
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
49
+ }
50
+ });
51
+ export default app;
@@ -1,6 +1,7 @@
1
1
  import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { Hono } from 'hono';
4
+ import { DOCUMENT_DIRS } from '../../shared/document-roots.js';
4
5
  import * as workspaceService from '../services/workspace-service.js';
5
6
  import { isPathInside, resolveExistingPathInside } from '../utils/safe-path.js';
6
7
  /** Hono sub-router for workspace document browsing (read-only). */
@@ -13,7 +14,6 @@ const app = new Hono();
13
14
  * Kept intentionally narrow to avoid leaking unrelated project docs
14
15
  * (README, product specs, …) into the panel.
15
16
  */
16
- const DOCUMENT_DIRS = ['docs/plans', 'docs/superpowers', '.ai/thoughts'];
17
17
  /** Only .md files are listed. */
18
18
  const MD_EXT = '.md';
19
19
  /** Depth cap to keep recursion bounded even on pathological symlink loops. */
@@ -1,8 +1,12 @@
1
1
  import { execFile as execFileCb, execFileSync, spawn } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import { nanoid } from 'nanoid';
3
4
  import { getSearchIndexStatus, searchEvents } from '../services/search-service.js';
5
+ import { createSessionHandoff, decideSessionHandoff, getCurrentSessionHandoff, SessionHandoffError, submitSessionHandoff, waitForSessionHandoff, } from '../services/session-handoff-service.js';
4
6
  import { AgentStopError, assertAgentStopped } from '../utils/agent-stop-result.js';
5
7
  import { withGitRepoLock } from '../utils/git-repo-lock.js';
8
+ import { workspaceLifecycleReason } from '../utils/workspace-lifecycle-guard.js';
9
+ import autoLoopMessagesRoutes from './auto-loop-messages.js';
6
10
  const execFileAsync = promisify(execFileCb);
7
11
  import fs from 'node:fs';
8
12
  import path from 'node:path';
@@ -34,11 +38,12 @@ import * as notionService from '../services/notion-service.js';
34
38
  import { renderPrTemplate } from '../services/pr-template-service.js';
35
39
  import { clearPrSnapshotCache, getAllGitStats, getAllPrSnapshots, refreshPrSnapshot, } from '../services/pr-watcher-service.js';
36
40
  import * as quotaBackoffService from '../services/quota-backoff-service.js';
37
- import { getActiveReviewTemplate, renderReviewTemplate } from '../services/review-template-service.js';
41
+ import { ReviewRequestError, startWorkspaceReview } from '../services/review-service.js';
38
42
  import * as sentryService from '../services/sentry-service.js';
39
43
  import * as settingsService from '../services/settings-service.js';
40
44
  import { runSetupScript } from '../services/setup-script-service.js';
41
45
  import { getSuitePrompts } from '../services/skill-suite-prompts.js';
46
+ import { TaskValidationError } from '../services/task-mutations.js';
42
47
  import * as terminalService from '../services/terminal-service.js';
43
48
  import * as wakeupService from '../services/wakeup-service.js';
44
49
  import * as wsService from '../services/websocket-service.js';
@@ -62,6 +67,28 @@ function workspaceErrorStatus(err) {
62
67
  }
63
68
  /** Hono sub-router for workspace CRUD, tasks, agents, Git and PR creation. */
64
69
  const app = new Hono();
70
+ // Transfers reserve the writer across multiple HTTP requests and engine invocations.
71
+ app.use('/:id/*', async (c, next) => {
72
+ const id = c.req.param('id');
73
+ const route = c.req.path.split(`/api/workspaces/${id}/`)[1] ?? '';
74
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method) &&
75
+ workspaceLifecycleReason(id) === 'session-handoff' &&
76
+ !route.startsWith('session-handoffs') &&
77
+ ![
78
+ 'stop',
79
+ 'mark-read',
80
+ 'chat-history',
81
+ 'deferred-tool-use/answer',
82
+ 'deferred-tool-use/cancel',
83
+ 'deferred-permission/decision',
84
+ ].includes(route)) {
85
+ return c.json({
86
+ error: 'A session transfer is pending. Finish or cancel it before changing this workspace.',
87
+ code: 'workspace-busy',
88
+ }, 409);
89
+ }
90
+ await next();
91
+ });
65
92
  // These handlers own teardown across awaits; purge/delete/restore own their guard in the service.
66
93
  for (const route of ['/:id/archive', '/:id/run-setup-script', '/:id/cancel-source-change', '/:id/rollback-file']) {
67
94
  app.use(route, async (c, next) => {
@@ -295,6 +322,44 @@ app.get('/', (c) => {
295
322
  return c.json({ error: message }, workspaceErrorStatus(err));
296
323
  }
297
324
  });
325
+ function handoffError(c, error) {
326
+ return c.json({ error: error instanceof Error ? error.message : 'Session transfer failed' }, error instanceof SessionHandoffError
327
+ ? error.status
328
+ : error instanceof SyntaxError
329
+ ? 400
330
+ : workspaceErrorStatus(error));
331
+ }
332
+ app.post('/:id/session-handoffs', migrationGuard, async (c) => {
333
+ try {
334
+ return c.json({ handoff: createSessionHandoff(c.req.param('id'), await c.req.json()) }, 202);
335
+ }
336
+ catch (error) {
337
+ return handoffError(c, error);
338
+ }
339
+ });
340
+ app.get('/:id/session-handoffs/current', (c) => {
341
+ if (!workspaceService.getWorkspace(c.req.param('id')))
342
+ return c.json({ error: 'Workspace not found' }, 404);
343
+ return c.json({ handoff: getCurrentSessionHandoff(c.req.param('id')) });
344
+ });
345
+ app.post('/:id/session-handoffs/:handoffId/decision', migrationGuard, async (c) => {
346
+ try {
347
+ const body = await c.req.json();
348
+ return c.json({ handoff: await decideSessionHandoff(c.req.param('id'), c.req.param('handoffId'), body.action) });
349
+ }
350
+ catch (error) {
351
+ return handoffError(c, error);
352
+ }
353
+ });
354
+ app.post('/:id/session-handoffs/:handoffId/report', migrationGuard, async (c) => {
355
+ try {
356
+ const body = await c.req.json();
357
+ return c.json(submitSessionHandoff(c.req.param('id'), c.req.param('handoffId'), body.token, body.report));
358
+ }
359
+ catch (error) {
360
+ return handoffError(c, error);
361
+ }
362
+ });
298
363
  // POST /api/workspaces/:id/engine-handoff-preview — build an editable, deterministic handoff.
299
364
  app.post('/:id/engine-handoff-preview', migrationGuard, async (c) => {
300
365
  try {
@@ -346,15 +411,14 @@ app.post('/:id/switch-engine', migrationGuard, async (c) => {
346
411
  error: 'Workspace is compacting its context; wait until compaction finishes before sending a message',
347
412
  }, 409);
348
413
  }
349
- // A new engine is started three lines below: the previous one must be dead
350
- // first, or two agents share the worktree.
351
- if (agentManager.hasController(id))
352
- assertAgentStopped(await agentManager.stopAgentAndWait(id, undefined, 'replacement'));
353
- const updated = workspaceService.updateWorkspaceEngineConfiguration(id, body.engine, model, effort, body.agentPermissionMode);
354
- const session = agentManager.startAgent(id, updated.worktreePath, handoff, updated.model, false, updated.agentPermissionMode, undefined, updated.reasoningEffort);
355
- workspaceService.updateWorkspaceStatus(id, 'executing');
356
- wsService.emit(id, 'user:message', { content: handoff, sender: 'user', kind: 'engine-handoff' }, session.agentSessionId);
357
- return c.json({ workspace: workspaceService.getWorkspace(id), sessionId: session.agentSessionId });
414
+ const operation = createSessionHandoff(id, {
415
+ requestId: nanoid(),
416
+ sourceSessionId: workspaceService.getActiveSession(id)?.id ?? null,
417
+ generateSummary: false,
418
+ target: { engine: body.engine, model, reasoningEffort: effort, agentPermissionMode: body.agentPermissionMode },
419
+ }, handoff);
420
+ const completed = await waitForSessionHandoff(operation.id);
421
+ return c.json({ workspace: workspaceService.getWorkspace(id), sessionId: completed.targetSessionId });
358
422
  }
359
423
  catch (err) {
360
424
  return c.json({ error: err instanceof Error ? err.message : 'Failed to switch engine' }, 500);
@@ -1601,9 +1665,7 @@ app.get('/auto-loop-states', (c) => {
1601
1665
  const out = {};
1602
1666
  for (const r of rows) {
1603
1667
  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,
1668
+ ...autoLoopService.getStatus(r.id),
1607
1669
  tasks_done: r.tasks_done ?? 0,
1608
1670
  tasks_total: r.tasks_total ?? 0,
1609
1671
  crons_count: r.crons_count ?? 0,
@@ -1616,6 +1678,7 @@ app.get('/auto-loop-states', (c) => {
1616
1678
  return c.json({ error: message }, workspaceErrorStatus(err));
1617
1679
  }
1618
1680
  });
1681
+ app.route('/', autoLoopMessagesRoutes);
1619
1682
  // GET /api/workspaces/:id/auto-loop — current auto-loop status for one workspace.
1620
1683
  app.get('/:id/auto-loop', (c) => {
1621
1684
  try {
@@ -2011,67 +2074,68 @@ app.delete('/:id/events/:eventId', (c) => {
2011
2074
  app.post('/:id/tasks', async (c) => {
2012
2075
  try {
2013
2076
  const id = c.req.param('id');
2014
- const workspace = workspaceService.getWorkspace(id);
2015
- if (!workspace) {
2077
+ if (!workspaceService.getWorkspace(id))
2016
2078
  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()) {
2079
+ const body = await c.req.json().catch(() => null);
2080
+ if (!body || typeof body.title !== 'string' || !body.title.trim()) {
2022
2081
  return c.json({ error: 'Title is required' }, 400);
2023
2082
  }
2024
- const existing = workspaceService.listTasks(id);
2025
- const nextSortOrder = existing.length > 0 ? Math.max(...existing.map((t) => t.sortOrder)) + 1 : 0;
2026
2083
  const task = workspaceService.createTask(id, {
2027
2084
  title: body.title.trim(),
2028
- isAcceptanceCriterion: !!body.isAcceptanceCriterion,
2029
- sortOrder: nextSortOrder,
2085
+ isAcceptanceCriterion: body.isAcceptanceCriterion,
2086
+ sortOrder: body.sortOrder,
2087
+ afterTaskId: body.afterTaskId,
2088
+ role: body.role,
2030
2089
  });
2031
2090
  return c.json(task, 201);
2032
2091
  }
2033
2092
  catch (err) {
2034
2093
  const message = err instanceof Error ? err.message : String(err);
2035
- return c.json({ error: message }, workspaceErrorStatus(err));
2094
+ return c.json({ error: message }, err instanceof TaskValidationError ? 400 : workspaceErrorStatus(err));
2036
2095
  }
2037
2096
  });
2038
- // PATCH /api/workspaces/:id/tasks/:taskId — update task status and/or title
2097
+ // PATCH /api/workspaces/:id/tasks/:taskId — validate and apply every field atomically
2039
2098
  app.patch('/:id/tasks/:taskId', async (c) => {
2040
2099
  try {
2041
2100
  const id = c.req.param('id');
2042
2101
  const taskId = c.req.param('taskId');
2043
- const workspace = workspaceService.getWorkspace(id);
2044
- if (!workspace) {
2102
+ if (!workspaceService.getWorkspace(id))
2045
2103
  return c.json({ error: `Workspace '${id}' not found` }, 404);
2046
- }
2047
- const task = workspaceService.getTask(taskId, id);
2048
- if (!task) {
2104
+ if (!workspaceService.getTask(taskId, id)) {
2049
2105
  return c.json({ error: `Task '${taskId}' not found in workspace '${id}'` }, 404);
2050
2106
  }
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);
2107
+ const body = await c.req.json().catch(() => null);
2108
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
2109
+ return c.json({ error: 'Request body must be a JSON object' }, 400);
2069
2110
  }
2111
+ const fields = {};
2112
+ for (const key of [
2113
+ 'title',
2114
+ 'status',
2115
+ 'isAcceptanceCriterion',
2116
+ 'sortOrder',
2117
+ 'afterTaskId',
2118
+ 'verification',
2119
+ ]) {
2120
+ if (body[key] !== undefined)
2121
+ Object.assign(fields, { [key]: body[key] });
2122
+ }
2123
+ if (Object.keys(fields).length === 0)
2124
+ return c.json({ error: 'At least one task field is required' }, 400);
2125
+ if (fields.title !== undefined && (typeof fields.title !== 'string' || !fields.title.trim())) {
2126
+ return c.json({ error: 'Title cannot be empty' }, 400);
2127
+ }
2128
+ if (fields.status !== undefined && !['pending', 'in_progress', 'done'].includes(fields.status)) {
2129
+ return c.json({ error: 'Invalid status. Must be one of: pending, in_progress, done' }, 400);
2130
+ }
2131
+ if (fields.title !== undefined)
2132
+ fields.title = fields.title.trim();
2133
+ workspaceService.updateTask(taskId, fields);
2070
2134
  return c.json({ ok: true });
2071
2135
  }
2072
2136
  catch (err) {
2073
2137
  const message = err instanceof Error ? err.message : String(err);
2074
- return c.json({ error: message }, workspaceErrorStatus(err));
2138
+ return c.json({ error: message }, err instanceof TaskValidationError ? 400 : workspaceErrorStatus(err));
2075
2139
  }
2076
2140
  });
2077
2141
  // DELETE /api/workspaces/:id/tasks/:taskId — delete a task
@@ -2627,6 +2691,8 @@ app.put('/:id/tags', async (c) => {
2627
2691
  });
2628
2692
  // PATCH /api/workspaces/:id — update workspace fields (status, model, agentPermissionMode, name)
2629
2693
  app.patch('/:id', migrationGuard, async (c) => {
2694
+ if (workspaceLifecycleReason(c.req.param('id')) === 'session-handoff')
2695
+ return c.json({ error: 'A session transfer is pending', code: 'workspace-busy' }, 409);
2630
2696
  try {
2631
2697
  const id = c.req.param('id');
2632
2698
  const parsed = await c.req.json();
@@ -4302,104 +4368,12 @@ app.post('/:id/open-pr', async (c) => {
4302
4368
  }
4303
4369
  });
4304
4370
  // POST /api/workspaces/:id/start-review — ask the agent to review committed + uncommitted changes
4305
- app.post('/:id/start-review', async (c) => {
4371
+ app.post('/:id/start-review', migrationGuard, async (c) => {
4306
4372
  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 });
4373
+ return c.json(await startWorkspaceReview(c.req.param('id'), await c.req.json().catch(() => ({}))));
4399
4374
  }
4400
4375
  catch (err) {
4401
- const message = err instanceof Error ? err.message : String(err);
4402
- return c.json({ error: message }, workspaceErrorStatus(err));
4376
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, err instanceof ReviewRequestError ? err.status : workspaceErrorStatus(err));
4403
4377
  }
4404
4378
  });
4405
4379
  // POST /api/workspaces/:id/start-ci-fix — dispatch the configured CI-fix
@@ -240,11 +240,25 @@ export function createClaudeCodeEngine() {
240
240
  sdkOptions.pathToClaudeCodeExecutable = explicitBinary;
241
241
  const inputStream = new ClaudeInputStream(effectivePrompt);
242
242
  const q = query({ prompt: inputStream, options: sdkOptions });
243
+ let resolveReady;
244
+ let rejectReady;
245
+ const ready = new Promise((resolve, reject) => {
246
+ resolveReady = resolve;
247
+ rejectReady = reject;
248
+ });
249
+ // Legacy callers do not await readiness; still observe an early rejection.
250
+ void ready.catch(() => { });
243
251
  let discoveredSessionId;
244
252
  // A throwing onEvent handler (e.g. DB query against a closed connection
245
253
  // during async test teardown) must not escape as an unhandled rejection.
246
254
  const emitDirect = (ev) => {
247
255
  try {
256
+ if (ev.kind === 'session:started')
257
+ resolveReady();
258
+ if (ev.kind === 'error')
259
+ rejectReady(new Error(ev.message));
260
+ if (ev.kind === 'session:ended')
261
+ rejectReady(new Error('Claude ended before confirming session initialization'));
248
262
  onEvent(ev);
249
263
  }
250
264
  catch (err) {
@@ -588,6 +602,8 @@ export function createClaudeCodeEngine() {
588
602
  }
589
603
  })();
590
604
  const engineProcess = {
605
+ ready,
606
+ closed: iteratorPromise.then(() => { }),
591
607
  get pid() {
592
608
  return undefined;
593
609
  },