@loicngr/kobo 2.1.13 → 2.1.16

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 (132) hide show
  1. package/AGENTS.md +38 -6
  2. package/CHANGELOG.md +12 -0
  3. package/CONFIGURATION.md +41 -5
  4. package/README.md +4 -2
  5. package/dist/mcp-server/kobo-tasks-server.js +62 -105
  6. package/dist/mcp-server/workspace-dialogue-client.js +15 -3
  7. package/dist/server/db/auto-loop-final-review-schema.js +18 -0
  8. package/dist/server/db/migrations.js +12 -1
  9. package/dist/server/db/review-return-schema.js +18 -1
  10. package/dist/server/db/schema.js +4 -0
  11. package/dist/server/db/workspace-message-batch-schema.js +9 -0
  12. package/dist/server/index.js +8 -1
  13. package/dist/server/routes/mcp.js +69 -3
  14. package/dist/server/routes/workspace-messages.js +22 -0
  15. package/dist/server/routes/workspaces.js +119 -4
  16. package/dist/server/services/agent/engines/claude-code/engine.js +37 -1
  17. package/dist/server/services/agent/orchestrator.js +159 -40
  18. package/dist/server/services/agent/session-controller.js +4 -1
  19. package/dist/server/services/auto-loop-final-review-service.js +329 -0
  20. package/dist/server/services/auto-loop-service.js +53 -10
  21. package/dist/server/services/pr-checkout-rollback-service.js +51 -0
  22. package/dist/server/services/pr-checkout-service.js +21 -1
  23. package/dist/server/services/review-launch-runtime.js +17 -0
  24. package/dist/server/services/review-return-service.js +58 -10
  25. package/dist/server/services/review-service.js +59 -11
  26. package/dist/server/services/workspace-creation-mcp-service.js +198 -0
  27. package/dist/server/services/workspace-group-message-mcp-service.js +58 -0
  28. package/dist/server/services/workspace-group-message-service.js +182 -0
  29. package/dist/server/services/workspace-lifecycle-mcp-service.js +72 -0
  30. package/dist/server/services/workspace-message-service.js +5 -0
  31. package/dist/server/services/workspace-service.js +11 -0
  32. package/dist/server/services/workspace-template-service.js +10 -0
  33. package/dist/shared/auto-loop-review.js +1 -0
  34. package/dist/shared/pr-checkout-steps.js +86 -0
  35. package/dist/shared/review-return.js +1 -0
  36. package/dist/shared/workspace-creation-tools.js +182 -0
  37. package/dist/shared/workspace-group-message-tools.js +134 -0
  38. package/dist/shared/workspace-group-messages.js +27 -0
  39. package/dist/shared/workspace-lifecycle-tools.js +112 -0
  40. package/package.json +1 -1
  41. package/src/client/dist/pwa/assets/ActivityFeed-tJmJhmtl.js +2 -0
  42. package/src/client/dist/pwa/assets/AutoLoopFinalReviewSettings-Bbrq02Xt.js +1 -0
  43. package/src/client/dist/pwa/assets/AutoLoopFinalReviewSettings-Q-OlSQ1c.css +1 -0
  44. package/src/client/dist/pwa/assets/{ChangelogPage-DD90Nl6c.js → ChangelogPage-BJfkAVYp.js} +1 -1
  45. package/src/client/dist/pwa/assets/{ComparisonPanel-D_BQFWiT.js → ComparisonPanel-BfP8APKY.js} +1 -1
  46. package/src/client/dist/pwa/assets/CreatePage-BbtNfo0x.js +2 -0
  47. package/src/client/dist/pwa/assets/CreatePage-DxOes_cF.css +1 -0
  48. package/src/client/dist/pwa/assets/{DashboardPage-DyvTdmBg.js → DashboardPage-DZYsAh5y.js} +1 -1
  49. package/src/client/dist/pwa/assets/DiffViewer-4Q-GJgyA.js +8 -0
  50. package/src/client/dist/pwa/assets/{DocumentsPanel-bm639sIu.js → DocumentsPanel-BfPlfiwU.js} +1 -1
  51. package/src/client/dist/pwa/assets/{DrawerToggleButton-KfwSzjeY.js → DrawerToggleButton-DG1Qjqk1.js} +1 -1
  52. package/src/client/dist/pwa/assets/GitPanel-ECnbb_rx.js +3 -0
  53. package/src/client/dist/pwa/assets/{HealthPage-37IHOIW-.js → HealthPage-_VXG1Zeo.js} +1 -1
  54. package/src/client/dist/pwa/assets/MainLayout-BH6grTuW.js +2 -0
  55. package/src/client/dist/pwa/assets/MainLayout-DnmUxNk7.css +1 -0
  56. package/src/client/dist/pwa/assets/{MemoryPanel-CucZMhjy.js → MemoryPanel-CVlvGZXZ.js} +1 -1
  57. package/src/client/dist/pwa/assets/{MemoryProposalList-CUQybzVR.js → MemoryProposalList-SJsnzUAM.js} +1 -1
  58. package/src/client/dist/pwa/assets/{QBtnDropdown-x6qPyl_7.js → QBtnDropdown-DBgGsN-D.js} +1 -1
  59. package/src/client/dist/pwa/assets/{QExpansionItem-D33aoRoG.js → QExpansionItem-DKgaMJ_l.js} +1 -1
  60. package/src/client/dist/pwa/assets/{QScrollArea-Di-D6t2u.js → QScrollArea-D3OCsq3g.js} +1 -1
  61. package/src/client/dist/pwa/assets/QSelect-Cb_s_nox.js +1 -0
  62. package/src/client/dist/pwa/assets/{QTooltip-BhwG7lp2.js → QTooltip-X5ps8Opw.js} +1 -1
  63. package/src/client/dist/pwa/assets/{SchedulePanel-qFi9Wxzh.js → SchedulePanel-Ww_FVlW5.js} +1 -1
  64. package/src/client/dist/pwa/assets/{SearchPage-DA9nRmsA.js → SearchPage-B87WEHf3.js} +1 -1
  65. package/src/client/dist/pwa/assets/SettingsPage-BAZLGUyg.js +9 -0
  66. package/src/client/dist/pwa/assets/{SplitWorkspacePage-Brw2XsYJ.js → SplitWorkspacePage-B99AJUbG.js} +1 -1
  67. package/src/client/dist/pwa/assets/{TerminalPanel-DVP18-nk.js → TerminalPanel-DnA1MXwu.js} +9 -9
  68. package/src/client/dist/pwa/assets/{ToolCallItem-cSHh3p_F.js → ToolCallItem-2vFggaur.js} +3 -3
  69. package/src/client/dist/pwa/assets/{TouchPan-C6LZZ3rw.js → TouchPan-BSU3z0aO.js} +1 -1
  70. package/src/client/dist/pwa/assets/{TourReplayButton-Czlqo18H.js → TourReplayButton-DQwS2xdD.js} +1 -1
  71. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-BDYGq0pC.js → WorkspaceAttentionLabels-CM1tGDje.js} +1 -1
  72. package/src/client/dist/pwa/assets/WorkspacePage-B248nKIU.css +1 -0
  73. package/src/client/dist/pwa/assets/WorkspacePage-CQN6EKIn.js +4 -0
  74. package/src/client/dist/pwa/assets/{browser-ifCwUGpE.js → browser-DPsupI9Q.js} +1 -1
  75. package/src/client/dist/pwa/assets/{build-path-tree-BBkGq3lN.js → build-path-tree-CbIdt-p9.js} +1 -1
  76. package/src/client/dist/pwa/assets/custom-sounds-DR68eyF8.js +1 -0
  77. package/src/client/dist/pwa/assets/de-D3Ycvf_F.js +17 -0
  78. package/src/client/dist/pwa/assets/{editor.api-CV_seGJR.js → editor.api-DvgAtnOj.js} +1 -1
  79. package/src/client/dist/pwa/assets/{efforts-ttw0bL63.js → efforts-nmxfTVQD.js} +1 -1
  80. package/src/client/dist/pwa/assets/es-TgeS5898.js +17 -0
  81. package/src/client/dist/pwa/assets/{expand-template-C8biMRXo.js → expand-template-Brn5O4a1.js} +1 -1
  82. package/src/client/dist/pwa/assets/{format-Cu95dbxt.js → format-DfJ3Fv1i.js} +1 -1
  83. package/src/client/dist/pwa/assets/{formatters-C25D1RSd.js → formatters-B30bNFhq.js} +1 -1
  84. package/src/client/dist/pwa/assets/fr-CJafryNb.js +17 -0
  85. package/src/client/dist/pwa/assets/{freemarker2-DJEFr74q.js → freemarker2-DnZe74tc.js} +1 -1
  86. package/src/client/dist/pwa/assets/{handlebars-C_lzsMjX.js → handlebars-CmuMfxzO.js} +1 -1
  87. package/src/client/dist/pwa/assets/{html-CWmLFAmY.js → html-BD7OJFGX.js} +1 -1
  88. package/src/client/dist/pwa/assets/i18n-BY4JeBjk.js +1 -0
  89. package/src/client/dist/pwa/assets/index-C5fAw9ll.js +20 -0
  90. package/src/client/dist/pwa/assets/it-C5mN8Y61.js +17 -0
  91. package/src/client/dist/pwa/assets/{javascript-tsuiEUe-.js → javascript-Y-OrgcmW.js} +1 -1
  92. package/src/client/dist/pwa/assets/{liquid-BBr9BvUk.js → liquid-BqOkKePH.js} +1 -1
  93. package/src/client/dist/pwa/assets/{mdx-gjBH1cXG.js → mdx-D-58x2h0.js} +1 -1
  94. package/src/client/dist/pwa/assets/{monaco-contributions-CvGcBIsb.js → monaco-contributions-BW4ZMKO6.js} +1 -1
  95. package/src/client/dist/pwa/assets/{monaco.contribution-Uw-Lfb8Z.js → monaco.contribution-DJWqzNG-.js} +2 -2
  96. package/src/client/dist/pwa/assets/network-auth-BGPRhUa6.js +1 -0
  97. package/src/client/dist/pwa/assets/{notify-theme-PMSxqP6t.js → notify-theme-DZDzfxph.js} +1 -1
  98. package/src/client/dist/pwa/assets/{project-color-BDQa2TgU.js → project-color-gB5yeZPN.js} +1 -1
  99. package/src/client/dist/pwa/assets/{python-DlIgD1n6.js → python-BkBj1wDC.js} +1 -1
  100. package/src/client/dist/pwa/assets/{razor-AraFPlpM.js → razor-DOOJ_0b8.js} +1 -1
  101. package/src/client/dist/pwa/assets/{typescript-CB8UpXHM.js → typescript-Bheo43G5.js} +1 -1
  102. package/src/client/dist/pwa/assets/{use-search-index-status-CSREHQM4.js → use-search-index-status-CBpnyV-R.js} +1 -1
  103. package/src/client/dist/pwa/assets/use-tours-BHNasCvg.js +2 -0
  104. package/src/client/dist/pwa/assets/{use-worktree-restore-BM3Giig1.js → use-worktree-restore-CgABsWno.js} +1 -1
  105. package/src/client/dist/pwa/assets/{workspace-templates-BPMFvTnQ.js → workspace-templates-BnFkigYg.js} +1 -1
  106. package/src/client/dist/pwa/assets/{xml-uRjL1P43.js → xml-vHt2BD26.js} +1 -1
  107. package/src/client/dist/pwa/assets/{yaml-C9CFR4qV.js → yaml-BvhLPzP9.js} +1 -1
  108. package/src/client/dist/pwa/index.html +1 -1
  109. package/src/client/dist/pwa/sw.js +1 -1
  110. package/src/mcp-server/README.md +156 -12
  111. package/src/mcp-server/kobo-tasks-server.ts +73 -106
  112. package/src/mcp-server/workspace-dialogue-client.ts +18 -3
  113. package/src/client/dist/pwa/assets/ActivityFeed-BTRzc60o.js +0 -2
  114. package/src/client/dist/pwa/assets/CreatePage-BZAoCukb.js +0 -2
  115. package/src/client/dist/pwa/assets/CreatePage-CdCs8PRS.css +0 -1
  116. package/src/client/dist/pwa/assets/DiffViewer-D0K2PlZD.js +0 -8
  117. package/src/client/dist/pwa/assets/GitPanel-C-Ejlc9_.js +0 -3
  118. package/src/client/dist/pwa/assets/MainLayout-BQOOup9o.css +0 -1
  119. package/src/client/dist/pwa/assets/MainLayout-DXQ1ouYl.js +0 -2
  120. package/src/client/dist/pwa/assets/QSelect-MdPXvwm4.js +0 -1
  121. package/src/client/dist/pwa/assets/SettingsPage-DcV6apqE.js +0 -9
  122. package/src/client/dist/pwa/assets/WorkspacePage-BwUkijpS.js +0 -4
  123. package/src/client/dist/pwa/assets/WorkspacePage-Bwfv559o.css +0 -1
  124. package/src/client/dist/pwa/assets/custom-sounds-CeSgjIY7.js +0 -1
  125. package/src/client/dist/pwa/assets/de-CHY2BAfL.js +0 -17
  126. package/src/client/dist/pwa/assets/es-DbcSKp38.js +0 -17
  127. package/src/client/dist/pwa/assets/fr-CyyJ8S2c.js +0 -17
  128. package/src/client/dist/pwa/assets/i18n-CobVQiTM.js +0 -1
  129. package/src/client/dist/pwa/assets/index-ImUH7Bbt.js +0 -20
  130. package/src/client/dist/pwa/assets/it-XQJW2Xkr.js +0 -17
  131. package/src/client/dist/pwa/assets/network-auth-D0Vm6a09.js +0 -1
  132. package/src/client/dist/pwa/assets/use-tours-6yxNVmjH.js +0 -2
@@ -32,9 +32,10 @@ import soundsRouter from './routes/sounds.js';
32
32
  import templatesRouter from './routes/templates.js';
33
33
  import usageRoutes from './routes/usage.js';
34
34
  import voiceRouter from './routes/voice.js';
35
+ import workspaceMessagesRouter from './routes/workspace-messages.js';
35
36
  import workspaceTemplatesRouter from './routes/workspace-templates.js';
36
37
  import workspacesRouter from './routes/workspaces.js';
37
- import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, setBackendPort, startAgent, startWatchdog, stopAgentAndWait, stopAllAgents, stopWatchdog, } from './services/agent/orchestrator.js';
38
+ import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, resumePendingReviewReturns, setBackendPort, startAgent, startWatchdog, stopAgentAndWait, stopAllAgents, stopWatchdog, } from './services/agent/orchestrator.js';
38
39
  import * as autoLoopService from './services/auto-loop-service.js';
39
40
  import { startAwaitingUserReminder, stopAwaitingUserReminder } from './services/awaiting-user-reminder-service.js';
40
41
  import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
@@ -57,6 +58,7 @@ import { startUpdateChecker, stopUpdateChecker } from './services/update-check-s
57
58
  import { startUsagePoller, stopUsagePoller } from './services/usage/index.js';
58
59
  import * as wakeupService from './services/wakeup-service.js';
59
60
  import { handleConnection, setMessageHandler } from './services/websocket-service.js';
61
+ import { reconcileGroupMessageBatches, stopGroupMessageBatches } from './services/workspace-group-message-service.js';
60
62
  import { deliverWorkspaceMessage } from './services/workspace-message-service.js';
61
63
  import { getWorkspace } from './services/workspace-service.js';
62
64
  import { resolveRetentionConfig } from './services/ws-events-retention-service.js';
@@ -84,6 +86,7 @@ catch (err) {
84
86
  }
85
87
  runMigrations(db);
86
88
  reconcileMessageRequests(db);
89
+ reconcileGroupMessageBatches(db);
87
90
  reconcileMemoryContextsOnStartup();
88
91
  startSearchIndex();
89
92
  // Event retention. OPT-IN: disabled by default, so this is a no-op until the
@@ -155,6 +158,7 @@ app.use('/api/*', networkAuthMiddleware);
155
158
  app.get('/api/health', (c) => c.json({ status: 'ok', version: getPackageVersion() }));
156
159
  // Mount route sub-routers
157
160
  app.route('/api/workspaces', workspacesRouter);
161
+ app.route('/api/workspace-messages', workspaceMessagesRouter);
158
162
  app.route('/api/mcp', mcpRouter);
159
163
  app.route('/api/memory', memoryRouter);
160
164
  app.route('/api/pull-requests', pullRequestsRouter);
@@ -240,6 +244,7 @@ const server = serve({
240
244
  // ephemeral or SERVER_PORT-selected port. Quota ownership comes first.
241
245
  restoreRetryCountsFromDb();
242
246
  quotaBackoffService.restoreOnBoot((workspaceId, pending) => autoLoopService.onQuotaBackoffExpired(workspaceId, pending));
247
+ resumePendingReviewReturns();
243
248
  autoLoopService.rehydrate();
244
249
  wakeupService.rehydrate();
245
250
  cronService.restoreOnBoot();
@@ -412,6 +417,7 @@ async function gracefulShutdown(signal, exitCode = 0) {
412
417
  return;
413
418
  isShuttingDown = true;
414
419
  stopUpdateChecker();
420
+ const groupMessagesStopped = stopGroupMessageBatches();
415
421
  console.log(`\n[kobo] Received ${signal}, shutting down gracefully…`);
416
422
  const forceExitTimer = setTimeout(() => {
417
423
  console.error('[kobo] Graceful shutdown timed out; forcing exit');
@@ -478,6 +484,7 @@ async function gracefulShutdown(signal, exitCode = 0) {
478
484
  finally {
479
485
  try {
480
486
  const stopped = await Promise.allSettled([
487
+ groupMessagesStopped,
481
488
  stopWsEventsRetention(),
482
489
  stopSearchIndex(),
483
490
  dailyBackupScheduler.stop(),
@@ -4,21 +4,73 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
4
4
  import { Hono } from 'hono';
5
5
  import { bodyLimit } from 'hono/body-limit';
6
6
  import { EXTERNAL_MEMORY_TOOL_DEFINITIONS, isMemoryToolName } from '../../shared/memory-tools.js';
7
+ import { isWorkspaceCreationTool, MAX_MCP_CREATION_REQUEST_BYTES, WORKSPACE_CREATION_TOOLS, } from '../../shared/workspace-creation-tools.js';
7
8
  import { EXTERNAL_MCP_TOOLS } from '../../shared/workspace-dialogue-tools.js';
9
+ import { isWorkspaceGroupMessageTool, WORKSPACE_GROUP_MESSAGE_TOOLS, } from '../../shared/workspace-group-message-tools.js';
10
+ import { isWorkspaceLifecycleTool, WORKSPACE_LIFECYCLE_TOOLS } from '../../shared/workspace-lifecycle-tools.js';
8
11
  import { createMcpClientContext } from '../services/mcp-client-context.js';
9
12
  import { executeMemoryMcpTool } from '../services/memory-mcp-service.js';
13
+ import { executeWorkspaceCreationTool, WorkspaceCreationMcpError } from '../services/workspace-creation-mcp-service.js';
10
14
  import { executeWorkspaceDialogueTool } from '../services/workspace-dialogue-service.js';
15
+ import { executeWorkspaceGroupMessageTool, WorkspaceGroupMessageMcpError, } from '../services/workspace-group-message-mcp-service.js';
16
+ import { executeWorkspaceLifecycleTool, WorkspaceLifecycleMcpError, } from '../services/workspace-lifecycle-mcp-service.js';
11
17
  import { prepareMemoryToolEnvelope } from '../utils/memory-token-budget.js';
12
18
  const app = new Hono();
13
- app.use('*', bodyLimit({ maxSize: 1024 * 1024 }));
19
+ // Only creation envelopes may carry the larger, base64 attachment payload.
20
+ // Other tools retain their existing 1 MiB limit, including malformed envelopes.
21
+ app.use('*', bodyLimit({ maxSize: MAX_MCP_CREATION_REQUEST_BYTES }));
22
+ app.use('*', async (c, next) => {
23
+ if (c.req.raw.body) {
24
+ const body = await c.req.raw.clone().arrayBuffer();
25
+ if (body.byteLength > 1024 * 1024) {
26
+ let creation = false;
27
+ try {
28
+ const envelope = JSON.parse(new TextDecoder().decode(body));
29
+ creation = envelope?.method === 'tools/call' && envelope?.params?.name === 'create_workspace';
30
+ }
31
+ catch {
32
+ /* Preserve the normal body-size rejection for malformed input. */
33
+ }
34
+ if (!creation)
35
+ return c.json({ error: 'MCP request is too large' }, 413);
36
+ }
37
+ }
38
+ return next();
39
+ });
40
+ /** Dispatch only the supported MCP operations through the existing UI handlers. */
41
+ async function dispatchWorkspaceRequest(pathname, init) {
42
+ if (pathname === '/api/workspaces/archived')
43
+ throw new Error('Bulk operations are not supported by workspace MCP tools');
44
+ const isWorkspaceOperation = (init.method === 'POST' &&
45
+ (pathname === '/api/workspaces' ||
46
+ /^\/api\/workspaces\/[^/]+\/(unarchive|archive|purge-worktree|restore-worktree)$/.test(pathname))) ||
47
+ ((init.method === 'GET' || init.method === 'DELETE') && /^\/api\/workspaces\/[^/]+$/.test(pathname));
48
+ if (isWorkspaceOperation) {
49
+ const { default: routes } = await import('./workspaces.js');
50
+ return routes.request(`http://kobo.internal${pathname.slice('/api/workspaces'.length) || '/'}`, init);
51
+ }
52
+ if (init.method === 'POST' &&
53
+ (pathname === '/api/pull-requests/diagnose' || pathname === '/api/pull-requests/resolve')) {
54
+ const { default: routes } = await import('./pull-requests.js');
55
+ return routes.request(`http://kobo.internal${pathname.slice('/api/pull-requests'.length)}`, init);
56
+ }
57
+ throw new Error('Unsupported workspace operation');
58
+ }
14
59
  app.all('/', async (c) => {
15
60
  const server = new Server({ name: 'kobo-workspaces', version: '1.0.0' }, { capabilities: { tools: {} } });
16
61
  const transport = new WebStandardStreamableHTTPServerTransport({
17
62
  sessionIdGenerator: undefined,
18
63
  enableJsonResponse: true,
64
+ maxRequestBodySize: MAX_MCP_CREATION_REQUEST_BYTES,
19
65
  });
20
66
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
21
- tools: [...EXTERNAL_MCP_TOOLS, ...EXTERNAL_MEMORY_TOOL_DEFINITIONS],
67
+ tools: [
68
+ ...EXTERNAL_MCP_TOOLS,
69
+ ...WORKSPACE_CREATION_TOOLS,
70
+ ...WORKSPACE_LIFECYCLE_TOOLS,
71
+ ...WORKSPACE_GROUP_MESSAGE_TOOLS,
72
+ ...EXTERNAL_MEMORY_TOOL_DEFINITIONS,
73
+ ],
22
74
  }));
23
75
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
24
76
  const isMemory = isMemoryToolName(request.params.name);
@@ -26,7 +78,13 @@ app.all('/', async (c) => {
26
78
  const source = createMcpClientContext(c.req.header('X-Kobo-Client-Name'), c.req.header('X-Kobo-Client-Transport') === 'stdio' ? 'stdio' : 'http', c.req.header('X-Kobo-Client-Name-Encoding'));
27
79
  const data = isMemory
28
80
  ? executeMemoryMcpTool(request.params.name, request.params.arguments ?? {}, source)
29
- : await executeWorkspaceDialogueTool(request.params.name, request.params.arguments ?? {}, source);
81
+ : isWorkspaceCreationTool(request.params.name)
82
+ ? await executeWorkspaceCreationTool(request.params.name, request.params.arguments ?? {}, dispatchWorkspaceRequest)
83
+ : isWorkspaceLifecycleTool(request.params.name)
84
+ ? await executeWorkspaceLifecycleTool(request.params.name, request.params.arguments ?? {}, dispatchWorkspaceRequest)
85
+ : isWorkspaceGroupMessageTool(request.params.name)
86
+ ? executeWorkspaceGroupMessageTool(request.params.name, request.params.arguments ?? {}, source)
87
+ : await executeWorkspaceDialogueTool(request.params.name, request.params.arguments ?? {}, source);
30
88
  if (isMemory) {
31
89
  const bounded = prepareMemoryToolEnvelope(data, { targetTokens: 1_000, transport: 'external' });
32
90
  if (bounded.data.memoryOutputSuppressed === true)
@@ -46,6 +104,14 @@ app.all('/', async (c) => {
46
104
  };
47
105
  }
48
106
  catch (error) {
107
+ if (error instanceof WorkspaceGroupMessageMcpError) {
108
+ const data = { error: error.message, status: error.status };
109
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
110
+ }
111
+ if (error instanceof WorkspaceCreationMcpError || error instanceof WorkspaceLifecycleMcpError) {
112
+ const data = { error: error.message, status: error.status, stage: error.stage, details: error.details };
113
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
114
+ }
49
115
  if (isMemory) {
50
116
  const bounded = prepareMemoryToolEnvelope({
51
117
  error: error instanceof Error ? error.message : String(error),
@@ -0,0 +1,22 @@
1
+ import { Hono } from 'hono';
2
+ import { bodyLimit } from 'hono/body-limit';
3
+ import { MAX_GROUP_MESSAGE_REQUEST_BYTES } from '../../shared/workspace-group-messages.js';
4
+ import { GroupMessageError, getGroupMessageBatch, startGroupMessageBatch, } from '../services/workspace-group-message-service.js';
5
+ const app = new Hono();
6
+ app.use('*', bodyLimit({ maxSize: MAX_GROUP_MESSAGE_REQUEST_BYTES }));
7
+ app.post('/', async (c) => {
8
+ const body = await c.req.json().catch(() => null);
9
+ try {
10
+ return c.json(startGroupMessageBatch(body), 202);
11
+ }
12
+ catch (error) {
13
+ if (error instanceof GroupMessageError)
14
+ return c.json({ error: error.message }, error.status);
15
+ throw error;
16
+ }
17
+ });
18
+ app.get('/:id', (c) => {
19
+ const batch = getGroupMessageBatch(c.req.param('id'));
20
+ return batch ? c.json(batch) : c.json({ error: 'Group message not found' }, 404);
21
+ });
22
+ export default app;
@@ -2,6 +2,8 @@ import { execFile as execFileCb, execFileSync, spawn } from 'node:child_process'
2
2
  import { promisify } from 'node:util';
3
3
  import { nanoid } from 'nanoid';
4
4
  import { isWorkflowPolicy, resolveWorkflowPolicy, WORKFLOW_ACTIONS, } from '../../shared/workflow-policy.js';
5
+ import * as finalReviewService from '../services/auto-loop-final-review-service.js';
6
+ import { getReviewReturnStatus } from '../services/review-return-service.js';
5
7
  import { getSearchIndexStatus, searchEvents } from '../services/search-service.js';
6
8
  import { createSessionHandoff, decideSessionHandoff, getCurrentSessionHandoff, SessionHandoffError, submitSessionHandoff, waitForSessionHandoff, } from '../services/session-handoff-service.js';
7
9
  import { AgentStopError, assertAgentStopped } from '../utils/agent-stop-result.js';
@@ -103,8 +105,16 @@ app.use('/:id/*', async (c, next) => {
103
105
  }
104
106
  await next();
105
107
  });
106
- // These handlers own teardown across awaits; purge/delete/restore own their guard in the service.
107
- for (const route of ['/:id/archive', '/:id/run-setup-script', '/:id/cancel-source-change', '/:id/rollback-file']) {
108
+ // Branch/path mutations must also exclude teardown across awaits.
109
+ // Purge/delete/restore own their guard in the service.
110
+ for (const route of [
111
+ '/:id/archive',
112
+ '/:id/run-setup-script',
113
+ '/:id/cancel-source-change',
114
+ '/:id/rollback-file',
115
+ '/:id/rename-branch',
116
+ '/:id/resync-branch',
117
+ ]) {
108
118
  app.use(route, async (c, next) => {
109
119
  try {
110
120
  await withWorkspaceLifecycleGuard(c.req.param('id'), next);
@@ -466,6 +476,14 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
466
476
  let setupScriptConfigured = false;
467
477
  try {
468
478
  const { body, attachments } = await readWorkspaceCreationRequest(c.req.raw);
479
+ if (body.autoLoopFinalReview !== undefined) {
480
+ try {
481
+ finalReviewService.parseFinalReviewConfiguration(body.autoLoopFinalReview);
482
+ }
483
+ catch (error) {
484
+ return c.json({ error: error instanceof Error ? error.message : 'Invalid final review configuration' }, 400);
485
+ }
486
+ }
469
487
  if (body.workflowPolicy !== undefined && !isWorkflowPolicy(body.workflowPolicy))
470
488
  return c.json({ error: 'Invalid workflowPolicy' }, 400);
471
489
  // workingBranch is derived from git when worktreePath is provided, so
@@ -560,6 +578,7 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
560
578
  // the working branch from git itself — the body.workingBranch is ignored.
561
579
  let useReusedWorktree = false;
562
580
  let reusedDerivedBranch = null;
581
+ let reusedIdentity = null;
563
582
  if (body.worktreePath) {
564
583
  currentStep = 'inspect-worktree';
565
584
  emitCreateProgress(creationId, currentStep);
@@ -588,6 +607,14 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
588
607
  return c.json({ error: failure, step: 'inspect-worktree' }, 422);
589
608
  }
590
609
  reusedDerivedBranch = branch;
610
+ const directory = fs.statSync(body.worktreePath);
611
+ const gitEntry = fs.statSync(path.join(body.worktreePath, '.git'));
612
+ reusedIdentity = {
613
+ device: directory.dev,
614
+ inode: directory.ino,
615
+ gitDevice: gitEntry.dev,
616
+ gitInode: gitEntry.ino,
617
+ };
591
618
  }
592
619
  catch (err) {
593
620
  const message = err instanceof Error ? err.message : String(err);
@@ -771,6 +798,36 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
771
798
  const creation = await withGitRepoLock(body.projectPath, async () => {
772
799
  let prospectiveWorktreePath;
773
800
  if (useReusedWorktree) {
801
+ // Imports above may yield long enough for a failed PR creation to remove
802
+ // its checkout. Recheck the exact directory and branch under the same
803
+ // repository lock as compensation before any record/context files exist.
804
+ try {
805
+ const worktreePath = body.worktreePath;
806
+ const directory = fs.statSync(worktreePath);
807
+ const gitEntry = fs.statSync(path.join(worktreePath, '.git'));
808
+ const commonDir = execFileSync('git', ['-C', worktreePath, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
809
+ encoding: 'utf-8',
810
+ timeout: 30_000,
811
+ }).trim();
812
+ const branch = execFileSync('git', ['-C', worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'], {
813
+ encoding: 'utf-8',
814
+ timeout: 30_000,
815
+ }).trim();
816
+ if (!reusedIdentity ||
817
+ !directory.isDirectory() ||
818
+ directory.dev !== reusedIdentity.device ||
819
+ directory.ino !== reusedIdentity.inode ||
820
+ gitEntry.dev !== reusedIdentity.gitDevice ||
821
+ gitEntry.ino !== reusedIdentity.gitInode ||
822
+ realPathOrResolved(commonDir) !== realPathOrResolved(path.join(body.projectPath, '.git')) ||
823
+ branch !== reusedDerivedBranch)
824
+ throw new Error('The worktree directory, repository or branch changed during creation; retry from a fresh diagnosis');
825
+ }
826
+ catch (err) {
827
+ const failure = `Failed to revalidate worktree: ${err instanceof Error ? err.message : String(err)}`;
828
+ emitCreateFailed(creationId, 'inspect-worktree', failure);
829
+ return c.json({ error: failure, step: 'inspect-worktree' }, 422);
830
+ }
774
831
  // Another attachment may have completed while this request waited for the lock.
775
832
  const existing = getDb().prepare('SELECT id FROM workspaces WHERE worktree_path = ?').get(body.worktreePath);
776
833
  if (existing) {
@@ -821,6 +878,8 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
821
878
  ...(useReusedWorktree ? {} : { worktreesPath: globalSettings.worktreesPath }),
822
879
  });
823
880
  createdWorkspace = workspace;
881
+ if (body.autoLoopFinalReview !== undefined)
882
+ finalReviewService.configureFinalReview(workspace.id, body.autoLoopFinalReview);
824
883
  // Enable auto-loop before starting the initial brainstorming session so
825
884
  // its model override is available when selecting that session's model.
826
885
  if (body.autoLoop === true) {
@@ -1700,6 +1759,54 @@ app.get('/auto-loop-states', (c) => {
1700
1759
  }
1701
1760
  });
1702
1761
  app.route('/', autoLoopMessagesRoutes);
1762
+ app.get('/:id/auto-loop/final-review', (c) => {
1763
+ const id = c.req.param('id');
1764
+ if (!workspaceService.getWorkspace(id))
1765
+ return c.json({ error: 'Workspace not found' }, 404);
1766
+ return c.json(finalReviewService.getFinalReviewStatus(id));
1767
+ });
1768
+ app.patch('/:id/auto-loop/final-review', migrationGuard, async (c) => {
1769
+ try {
1770
+ const body = await c.req.json();
1771
+ return c.json(finalReviewService.configureFinalReview(c.req.param('id'), body?.configuration));
1772
+ }
1773
+ catch (error) {
1774
+ return c.json({ error: error instanceof Error ? error.message : 'Invalid request' }, error instanceof ReviewRequestError ? error.status : 400);
1775
+ }
1776
+ });
1777
+ app.post('/:id/auto-loop/final-review/report', migrationGuard, async (c) => {
1778
+ try {
1779
+ const body = await c.req.json();
1780
+ return c.json(finalReviewService.submitFinalReviewReport(c.req.param('id'), body.sessionId, body.token, body.report));
1781
+ }
1782
+ catch (error) {
1783
+ return c.json({ error: error instanceof Error ? error.message : 'Invalid review report' }, error instanceof ReviewRequestError ? error.status : 400);
1784
+ }
1785
+ });
1786
+ app.get('/:id/review-return', (c) => {
1787
+ const id = c.req.param('id');
1788
+ if (!workspaceService.getWorkspace(id))
1789
+ return c.json({ error: 'Workspace not found' }, 404);
1790
+ return c.json(getReviewReturnStatus(id));
1791
+ });
1792
+ app.post('/:id/review-return/retry', migrationGuard, (c) => {
1793
+ try {
1794
+ agentManager.retryReviewReturn(c.req.param('id'));
1795
+ return c.json(getReviewReturnStatus(c.req.param('id')));
1796
+ }
1797
+ catch (error) {
1798
+ return c.json({ error: error instanceof Error ? error.message : 'Review recovery failed' }, 409);
1799
+ }
1800
+ });
1801
+ app.post('/:id/review-return/cancel', migrationGuard, (c) => {
1802
+ try {
1803
+ agentManager.cancelReviewReturn(c.req.param('id'));
1804
+ return c.json({ ok: true });
1805
+ }
1806
+ catch (error) {
1807
+ return c.json({ error: error instanceof Error ? error.message : 'Review cancellation failed' }, 409);
1808
+ }
1809
+ });
1703
1810
  // GET /api/workspaces/:id/auto-loop — current auto-loop status for one workspace.
1704
1811
  // Admission inspection never changes deadlines, pending schedules or lifecycle state.
1705
1812
  app.get('/:id/automatic-admission', (c) => {
@@ -1979,7 +2086,7 @@ app.delete('/:id/sessions/:sessionId', migrationGuard, (c) => {
1979
2086
  }
1980
2087
  catch (err) {
1981
2088
  const message = err instanceof Error ? err.message : String(err);
1982
- return c.json({ error: message }, message.includes('active session') ? 409 : 500);
2089
+ return c.json({ error: message }, message.includes('active session') || err instanceof workspaceService.ReviewSessionInUseError ? 409 : 500);
1983
2090
  }
1984
2091
  });
1985
2092
  // POST /api/workspaces/:id/deferred-tool-use/answer — resume a deferred
@@ -3120,6 +3227,12 @@ app.post('/:id/unarchive', migrationGuard, (c) => {
3120
3227
  return c.json({ error: message }, workspaceErrorStatus(err));
3121
3228
  }
3122
3229
  });
3230
+ class WorkspaceBranchConfirmationError extends Error {
3231
+ code = 'branch-confirmation-mismatch';
3232
+ constructor() {
3233
+ super('The workspace branch changed or does not match confirmationBranch. Confirm its current branch before deleting.');
3234
+ }
3235
+ }
3123
3236
  // Shared teardown for a single workspace: stops the agent, destroys the
3124
3237
  // terminal, removes the owned worktree, optionally deletes local/remote
3125
3238
  // branches, then deletes the DB row (cascades to tasks/sessions/events).
@@ -3131,6 +3244,8 @@ async function deleteWorkspaceWithSideEffects(workspace, opts) {
3131
3244
  const current = workspaceService.getWorkspace(workspace.id);
3132
3245
  if (!current)
3133
3246
  throw new Error(`Workspace '${workspace.id}' not found`);
3247
+ if (opts.confirmationBranch !== undefined && opts.confirmationBranch !== current.workingBranch)
3248
+ throw new WorkspaceBranchConfirmationError();
3134
3249
  // A bulk deletion may have captured the list before restoration completed.
3135
3250
  if (workspace.archivedAt && !current.archivedAt) {
3136
3251
  throw new Error('Workspace was unarchived while deletion was pending. Retry from its current state.');
@@ -3275,7 +3390,7 @@ app.delete('/:id', migrationGuard, async (c) => {
3275
3390
  return c.json({ ok: true, warnings }, 200);
3276
3391
  }
3277
3392
  catch (err) {
3278
- if (err instanceof WorkspaceLifecycleBusyError)
3393
+ if (err instanceof WorkspaceLifecycleBusyError || err instanceof WorkspaceBranchConfirmationError)
3279
3394
  return c.json({ code: err.code, error: err.message }, 409);
3280
3395
  const message = err instanceof Error ? err.message : String(err);
3281
3396
  return c.json({ error: message }, workspaceErrorStatus(err));
@@ -75,8 +75,19 @@ class ClaudeInputStream {
75
75
  closed = false;
76
76
  queuedForcedMessages = 0;
77
77
  unansweredMessages = new Set();
78
+ initialUuid;
79
+ initialYielded = false;
80
+ acceptInitial;
81
+ rejectInitial;
82
+ initialPromptAccepted = new Promise((resolve, reject) => {
83
+ this.acceptInitial = resolve;
84
+ this.rejectInitial = reject;
85
+ });
78
86
  constructor(initialPrompt) {
79
- this.messages = [{ message: this.toUserMessage(initialPrompt), forced: false }];
87
+ const initial = this.toUserMessage(initialPrompt);
88
+ this.initialUuid = initial.uuid;
89
+ this.messages = [{ message: initial, forced: false }];
90
+ void this.initialPromptAccepted.catch(() => { });
80
91
  }
81
92
  send(text) {
82
93
  if (this.closed)
@@ -91,6 +102,7 @@ class ClaudeInputStream {
91
102
  wake?.();
92
103
  }
93
104
  close() {
105
+ this.rejectInitial(new Error('Claude ended before confirming consumption of the initial prompt'));
94
106
  this.closed = true;
95
107
  const wake = this.waiting;
96
108
  this.waiting = undefined;
@@ -99,6 +111,19 @@ class ClaudeInputStream {
99
111
  hasUnansweredInput() {
100
112
  return this.queuedForcedMessages > 0 || this.unansweredMessages.size > 0;
101
113
  }
114
+ /** Only provider-correlated foreground frames acknowledge this new prompt, never init/history/children. */
115
+ acknowledgeInitialInput(message) {
116
+ if (!this.initialYielded || ('parent_tool_use_id' in message && message.parent_tool_use_id != null))
117
+ return;
118
+ if (!['assistant', 'stream_event', 'result', 'user'].includes(message.type))
119
+ return;
120
+ const frame = message;
121
+ const ids = [...(frame.user_message_uuids ?? []), ...(frame.user_message_uuid ? [frame.user_message_uuid] : [])];
122
+ if (frame.type === 'user' && frame.uuid)
123
+ ids.push(frame.uuid);
124
+ if (ids.includes(this.initialUuid))
125
+ this.acceptInitial();
126
+ }
102
127
  acknowledgeResult(result) {
103
128
  // One Claude turn can consume several queued prompts. A result counts
104
129
  // turns, not messages: only retire the sends explicitly covered by it.
@@ -133,6 +158,8 @@ class ClaudeInputStream {
133
158
  if (next.forced)
134
159
  this.queuedForcedMessages--;
135
160
  this.unansweredMessages.add(next.message.uuid);
161
+ if (next.message.uuid === this.initialUuid)
162
+ this.initialYielded = true;
136
163
  yield next.message;
137
164
  continue;
138
165
  }
@@ -229,6 +256,13 @@ export function createClaudeCodeEngine() {
229
256
  // would approve ExitPlanMode and then every edit. Anything that needs a
230
257
  // permission, a question included, is refused outright instead.
231
258
  if (options.readOnly) {
259
+ // This capability-scoped report changes orchestration metadata only.
260
+ // The backend validates its token and current reviewer session again.
261
+ if (toolName === 'mcp__kobo-tasks__submit_final_review' &&
262
+ options.mcpServers?.some((server) => server.name === 'kobo-tasks' &&
263
+ server.env.KOBO_FINAL_REVIEW_TOKEN &&
264
+ server.env.KOBO_FINAL_REVIEW_SESSION_ID))
265
+ return Promise.resolve({ behavior: 'allow', updatedInput: input });
232
266
  return Promise.resolve({
233
267
  behavior: 'deny',
234
268
  message: 'This is a read-only review session: do not modify anything or ask the user. Put the finding in your final report instead.',
@@ -677,6 +711,7 @@ export function createClaudeCodeEngine() {
677
711
  turnLiveness.start();
678
712
  try {
679
713
  for await (const msg of q) {
714
+ inputStream.acknowledgeInitialInput(msg);
680
715
  const events = mapSdkMessage(msg, mapperState);
681
716
  const isForeground = !('parent_tool_use_id' in msg && msg.parent_tool_use_id != null);
682
717
  const streamingContent = hasStreamingContent(msg);
@@ -867,6 +902,7 @@ export function createClaudeCodeEngine() {
867
902
  })();
868
903
  const engineProcess = {
869
904
  ready,
905
+ initialPromptAccepted: inputStream.initialPromptAccepted,
870
906
  closed: iteratorPromise.then(() => runtimeClosed),
871
907
  get pid() {
872
908
  return undefined;