@loicngr/kobo 1.11.44 → 2.1.0

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 (196) hide show
  1. package/AGENTS.md +57 -4
  2. package/CHANGELOG.md +13 -0
  3. package/CODE_OF_CONDUCT.md +16 -0
  4. package/CONFIGURATION.md +1968 -0
  5. package/CONTRIBUTING.md +83 -0
  6. package/DESIGN.md +296 -0
  7. package/README.md +48 -8
  8. package/SECURITY.md +54 -0
  9. package/THIRD_PARTY_NOTICES.md +20 -0
  10. package/dist/mcp-server/kobo-tasks-handlers.js +2 -1
  11. package/dist/mcp-server/kobo-tasks-server.js +31 -5
  12. package/dist/server/db/migrations.js +37 -0
  13. package/dist/server/db/schema.js +6 -1
  14. package/dist/server/db/session-handoff-schema.js +24 -0
  15. package/dist/server/index.js +8 -0
  16. package/dist/server/routes/documents.js +1 -1
  17. package/dist/server/routes/environment.js +18 -0
  18. package/dist/server/routes/health.js +17 -2
  19. package/dist/server/routes/integrations.js +29 -0
  20. package/dist/server/routes/settings.js +5 -0
  21. package/dist/server/routes/sounds.js +60 -0
  22. package/dist/server/routes/workspaces.js +118 -20
  23. package/dist/server/services/agent/engines/claude-code/engine.js +15 -0
  24. package/dist/server/services/agent/engines/codex/engine.js +1 -0
  25. package/dist/server/services/agent/engines/codex/options-builder.js +1 -0
  26. package/dist/server/services/agent/engines/codex/spawn.js +2 -2
  27. package/dist/server/services/agent/integration-mcp.js +49 -0
  28. package/dist/server/services/agent/orchestrator.js +68 -11
  29. package/dist/server/services/auto-loop-service.js +45 -25
  30. package/dist/server/services/custom-sound-service.js +0 -0
  31. package/dist/server/services/engine-handoff-service.js +5 -5
  32. package/dist/server/services/environment-check-service.js +125 -0
  33. package/dist/server/services/initial-prompt-template-service.js +8 -21
  34. package/dist/server/services/integration-config-service.js +65 -0
  35. package/dist/server/services/legacy-public-prompts.js +112 -0
  36. package/dist/server/services/notion-service.js +25 -61
  37. package/dist/server/services/sentry-service.js +17 -12
  38. package/dist/server/services/session-activity-service.js +17 -0
  39. package/dist/server/services/session-handoff-runtime.js +26 -0
  40. package/dist/server/services/session-handoff-service.js +479 -0
  41. package/dist/server/services/settings-defaults.js +5 -0
  42. package/dist/server/services/settings-service.js +112 -23
  43. package/dist/server/services/skill-suite-prompts.js +29 -22
  44. package/dist/server/services/templates-service.js +31 -8
  45. package/dist/server/services/terminal-service.js +16 -0
  46. package/dist/server/services/wakeup-service.js +15 -5
  47. package/dist/server/services/workspace-service.js +30 -6
  48. package/dist/server/services/worktree-service.js +3 -1
  49. package/dist/server/utils/git-repo-lock.js +7 -3
  50. package/dist/server/utils/mcp-client.js +26 -12
  51. package/dist/server/utils/mcp-process.js +25 -0
  52. package/dist/server/utils/node-pty-spawn-helper.js +31 -0
  53. package/dist/server/utils/paths.js +7 -0
  54. package/dist/server/utils/workspace-lifecycle-guard.js +35 -20
  55. package/dist/shared/auto-loop-prompts.js +6 -2
  56. package/dist/shared/automatic-admission.js +1 -0
  57. package/dist/shared/document-roots.js +2 -0
  58. package/dist/shared/environment.js +1 -0
  59. package/dist/shared/notification-assets.js +73 -0
  60. package/dist/shared/session-handoff.js +4 -0
  61. package/dist/shared/skill-suite-prompts.js +6 -3
  62. package/dist/shared/workflow-policy.js +25 -0
  63. package/docs/getting-started.md +47 -0
  64. package/docs/troubleshooting.md +49 -0
  65. package/package.json +19 -7
  66. package/src/client/dist/pwa/assets/ActivityFeed-DRgetl1M.js +8 -0
  67. package/src/client/dist/pwa/assets/{ChangelogPage-DSKCvpr2.js → ChangelogPage-BJdICFvz.js} +1 -1
  68. package/src/client/dist/pwa/assets/{ComparisonPanel-DDxcsxyC.js → ComparisonPanel-2GLd8KIy.js} +1 -1
  69. package/src/client/dist/pwa/assets/CreatePage-B9AdNJZj.js +2 -0
  70. package/src/client/dist/pwa/assets/CreatePage-B9C9J9Pw.css +1 -0
  71. package/src/client/dist/pwa/assets/DashboardPage-CzVOvjSP.js +1 -0
  72. package/src/client/dist/pwa/assets/DiffViewer-X25vuf83.js +8 -0
  73. package/src/client/dist/pwa/assets/{DocumentsPanel-DfEoI_f7.js → DocumentsPanel-BastjrEm.js} +1 -1
  74. package/src/client/dist/pwa/assets/{DrawerToggleButton-D85PwhKb.js → DrawerToggleButton-DbipGmBL.js} +1 -1
  75. package/src/client/dist/pwa/assets/{GitPanel-BFCnyhE2.css → GitPanel-C4wOcZV2.css} +1 -1
  76. package/src/client/dist/pwa/assets/GitPanel-CnW4iVao.js +3 -0
  77. package/src/client/dist/pwa/assets/{HealthPage-CaLCpejR.js → HealthPage-aieNQly-.js} +1 -1
  78. package/src/client/dist/pwa/assets/MainLayout-Cc4XzCAy.css +1 -0
  79. package/src/client/dist/pwa/assets/MainLayout-DqBbE8o4.js +2 -0
  80. package/src/client/dist/pwa/assets/{QBtnDropdown-qJWGcZpC.js → QBtnDropdown-BWy-rh45.js} +1 -1
  81. package/src/client/dist/pwa/assets/QExpansionItem-DfmdBGuW.js +1 -0
  82. package/src/client/dist/pwa/assets/QSelect-BfqaWakg.js +1 -0
  83. package/src/client/dist/pwa/assets/{QTooltip-cDK5AOQ5.js → QTooltip-Cy0T0vk2.js} +1 -1
  84. package/src/client/dist/pwa/assets/SchedulePanel-BYKSFoFj.css +1 -0
  85. package/src/client/dist/pwa/assets/SchedulePanel-YQPObRR3.js +1 -0
  86. package/src/client/dist/pwa/assets/{SearchPage-BO-J4aNc.js → SearchPage-BoBjmItc.js} +1 -1
  87. package/src/client/dist/pwa/assets/SettingsPage-0GtDcXRC.js +9 -0
  88. package/src/client/dist/pwa/assets/{SettingsPage-W6N01tGz.css → SettingsPage-BYdLXJEJ.css} +1 -1
  89. package/src/client/dist/pwa/assets/SplitWorkspacePage-4wFh65Lh.js +1 -0
  90. package/src/client/dist/pwa/assets/{TerminalPanel-DitOCad1.js → TerminalPanel-b70x_jqH.js} +9 -9
  91. package/src/client/dist/pwa/assets/{TouchPan-DvPAhZsa.js → TouchPan-CcTVcxwd.js} +1 -1
  92. package/src/client/dist/pwa/assets/{TourReplayButton-CJYdH3Jf.js → TourReplayButton-CpEMnQS8.js} +1 -1
  93. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-C5lTHTp2.js +1 -0
  94. package/src/client/dist/pwa/assets/{WorkspacePage-DAVo-E4p.css → WorkspacePage--5HsnMQ1.css} +1 -1
  95. package/src/client/dist/pwa/assets/WorkspacePage-D_V9gNON.js +4 -0
  96. package/src/client/dist/pwa/assets/{browser-BsB6cFv4.js → browser-LBbxVwWf.js} +1 -1
  97. package/src/client/dist/pwa/assets/build-path-tree-DyvtWxb0.js +1 -0
  98. package/src/client/dist/pwa/assets/custom-sounds-BpeLZUDW.js +1 -0
  99. package/src/client/dist/pwa/assets/de-v6i2HlE2.js +17 -0
  100. package/src/client/dist/pwa/assets/{documents-SCd3w8xV.js → documents-Bjx8lz-h.js} +1 -1
  101. package/src/client/dist/pwa/assets/{editor.api-BUdM7I6y.js → editor.api-BC3PbJkx.js} +1 -1
  102. package/src/client/dist/pwa/assets/efforts-BDHAnNUs.js +9 -0
  103. package/src/client/dist/pwa/assets/engineFeatures-hlEiwqt-.js +1 -0
  104. package/src/client/dist/pwa/assets/es-Bc1KgkAT.js +17 -0
  105. package/src/client/dist/pwa/assets/expand-template-DLDl1qPx.js +1 -0
  106. package/src/client/dist/pwa/assets/{format-BjQxf4pe.js → format-Q94deO3I.js} +7 -7
  107. package/src/client/dist/pwa/assets/{formatters-BejhmNTV.js → formatters-CWxRHuUu.js} +1 -1
  108. package/src/client/dist/pwa/assets/fr-ByYPK5vr.js +17 -0
  109. package/src/client/dist/pwa/assets/{freemarker2-5d5GhyRp.js → freemarker2-MPhk0eTh.js} +1 -1
  110. package/src/client/dist/pwa/assets/{handlebars-DaafEsDK.js → handlebars-C60ru5gR.js} +1 -1
  111. package/src/client/dist/pwa/assets/{html-CwHBDd2A.js → html-ClzchR9j.js} +1 -1
  112. package/src/client/dist/pwa/assets/i18n-Sy42tm-V.js +1 -0
  113. package/src/client/dist/pwa/assets/index-CqZ7sDcM.js +20 -0
  114. package/src/client/dist/pwa/assets/it-BYF_88z6.js +17 -0
  115. package/src/client/dist/pwa/assets/{javascript-DSNIEEH5.js → javascript-C43UJmMx.js} +1 -1
  116. package/src/client/dist/pwa/assets/{liquid-Dma1UpA1.js → liquid-CYTrvTKF.js} +1 -1
  117. package/src/client/dist/pwa/assets/{mdx-D8rqEAws.js → mdx-CxsXOvkQ.js} +1 -1
  118. package/src/client/dist/pwa/assets/{monaco-contributions-2E7O2zoi.js → monaco-contributions-Cl4-iVpK.js} +1 -1
  119. package/src/client/dist/pwa/assets/monaco.contribution-DkjLUmyA.js +2 -0
  120. package/src/client/dist/pwa/assets/network-auth-CXQn2wO7.js +1 -0
  121. package/src/client/dist/pwa/assets/notify-theme-BrueBr2j.js +1 -0
  122. package/src/client/dist/pwa/assets/{pending-diff-open-CeiVIFZn.js → pending-diff-open-B4POItbR.js} +1 -1
  123. package/src/client/dist/pwa/assets/permissionModes-B-ciuUbz.js +36 -0
  124. package/src/client/dist/pwa/assets/{project-color-BCp9SWoI.js → project-color-CM3hflLH.js} +1 -1
  125. package/src/client/dist/pwa/assets/{python-f-9NqRlI.js → python-s2IMpsoV.js} +1 -1
  126. package/src/client/dist/pwa/assets/{razor-DsCmoX0h.js → razor-DVNXiVBh.js} +1 -1
  127. package/src/client/dist/pwa/assets/render-chat-markdown-TU6CDfD8.js +69 -0
  128. package/src/client/dist/pwa/assets/{typescript-cEHg4L10.js → typescript-BYT_8ABE.js} +1 -1
  129. package/src/client/dist/pwa/assets/use-slide-transition-CiaDm-tR.js +1 -0
  130. package/src/client/dist/pwa/assets/use-tours-CNe8qb1q.js +2 -0
  131. package/src/client/dist/pwa/assets/workspace-templates-tBxXE52A.js +1 -0
  132. package/src/client/dist/pwa/assets/{xml-Plt-Y7we.js → xml-BIG-fmxk.js} +1 -1
  133. package/src/client/dist/pwa/assets/{yaml-ScEmaAeN.js → yaml-D7gu2CQh.js} +1 -1
  134. package/src/client/dist/pwa/index.html +1 -1
  135. package/src/client/dist/pwa/sounds/neutral.wav +0 -0
  136. package/src/client/dist/pwa/sounds/ready.wav +0 -0
  137. package/src/client/dist/pwa/sw.js +1 -1
  138. package/src/mcp-server/README.md +8 -1
  139. package/src/mcp-server/kobo-tasks-handlers.ts +4 -1
  140. package/src/mcp-server/kobo-tasks-server.ts +38 -5
  141. package/src/client/dist/pwa/assets/ActivityFeed-D_y4aikp.js +0 -8
  142. package/src/client/dist/pwa/assets/CreatePage-DKVEknbL.js +0 -2
  143. package/src/client/dist/pwa/assets/CreatePage-TY9g3hZb.css +0 -1
  144. package/src/client/dist/pwa/assets/DashboardPage-Dg2s9nnv.js +0 -1
  145. package/src/client/dist/pwa/assets/DiffViewer-BSzNScIr.js +0 -8
  146. package/src/client/dist/pwa/assets/GitPanel-CuG8MlgF.js +0 -3
  147. package/src/client/dist/pwa/assets/MainLayout-CDxYIJ_s.js +0 -2
  148. package/src/client/dist/pwa/assets/MainLayout-DTLNunOi.css +0 -1
  149. package/src/client/dist/pwa/assets/QDrawer-DX3PhMqC.js +0 -1
  150. package/src/client/dist/pwa/assets/QSelect-CY4TWW_j.js +0 -1
  151. package/src/client/dist/pwa/assets/SchedulePanel-BAA4Aajn.css +0 -1
  152. package/src/client/dist/pwa/assets/SchedulePanel-LvHiZ6ZH.js +0 -1
  153. package/src/client/dist/pwa/assets/SettingsPage-Aw89fRSq.js +0 -9
  154. package/src/client/dist/pwa/assets/SplitWorkspacePage-BIEceNwH.js +0 -1
  155. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CY-9z9nw.js +0 -1
  156. package/src/client/dist/pwa/assets/WorkspacePage-xH_OyA3r.js +0 -4
  157. package/src/client/dist/pwa/assets/build-path-tree-CW-ER1Mt.js +0 -1
  158. package/src/client/dist/pwa/assets/de-Mu7biceO.js +0 -17
  159. package/src/client/dist/pwa/assets/efforts-DTJQP9or.js +0 -9
  160. package/src/client/dist/pwa/assets/engineFeatures-Bj6Ob41d.js +0 -1
  161. package/src/client/dist/pwa/assets/es-BY5IfNI2.js +0 -17
  162. package/src/client/dist/pwa/assets/expand-template-SM8vvYyO.js +0 -1
  163. package/src/client/dist/pwa/assets/fouet-ahh-DdxrUXRj.mp3 +0 -0
  164. package/src/client/dist/pwa/assets/fr-BMXCzFCM.js +0 -17
  165. package/src/client/dist/pwa/assets/i18n-CgWW5vfb.js +0 -1
  166. package/src/client/dist/pwa/assets/index-Di9Sz5E8.js +0 -20
  167. package/src/client/dist/pwa/assets/it-ByPZQK_K.js +0 -17
  168. package/src/client/dist/pwa/assets/monaco.contribution-DqOgo72G.js +0 -2
  169. package/src/client/dist/pwa/assets/network-auth-DNmhJTZn.js +0 -1
  170. package/src/client/dist/pwa/assets/notify-theme-B6Hiwvwb.js +0 -1
  171. package/src/client/dist/pwa/assets/permissionModes-DWdtf81n.js +0 -36
  172. package/src/client/dist/pwa/assets/render-chat-markdown-DK5BD9mU.js +0 -67
  173. package/src/client/dist/pwa/assets/use-tours-BmTbosmB.js +0 -2
  174. package/src/client/dist/pwa/assets/workspace-templates-DUWDLMIX.js +0 -1
  175. package/src/client/dist/pwa/sounds/7eme-compagnie-03.mp3 +0 -0
  176. package/src/client/dist/pwa/sounds/aller-ftg.mp3 +0 -0
  177. package/src/client/dist/pwa/sounds/arrete-de-mentir.mp3 +0 -0
  178. package/src/client/dist/pwa/sounds/arretez-les-messages.mp3 +0 -0
  179. package/src/client/dist/pwa/sounds/bah-alors-on-est-nul.mp3 +0 -0
  180. package/src/client/dist/pwa/sounds/basic-notification.mp3 +0 -0
  181. package/src/client/dist/pwa/sounds/ca_va_peter.mp3 +0 -0
  182. package/src/client/dist/pwa/sounds/dry-fart.mp3 +0 -0
  183. package/src/client/dist/pwa/sounds/faaah.mp3 +0 -0
  184. package/src/client/dist/pwa/sounds/for-shure.mp3 +0 -0
  185. package/src/client/dist/pwa/sounds/gta-v-death.mp3 +0 -0
  186. package/src/client/dist/pwa/sounds/hey.mp3 +0 -0
  187. package/src/client/dist/pwa/sounds/mais-laisse-moi-dormir-zebi.mp3 +0 -0
  188. package/src/client/dist/pwa/sounds/nan-tu-degages.mp3 +0 -0
  189. package/src/client/dist/pwa/sounds/nan-wallah-pardon.mp3 +0 -0
  190. package/src/client/dist/pwa/sounds/ouais-cest-greg.mp3 +0 -0
  191. package/src/client/dist/pwa/sounds/pas-ca-zinedine.mp3 +0 -0
  192. package/src/client/dist/pwa/sounds/ta-gueule.mp3 +0 -0
  193. package/src/client/dist/pwa/sounds/travail_termine.mp3 +0 -0
  194. package/src/client/dist/pwa/sounds/tu-vas-la-fermer.mp3 +0 -0
  195. package/src/client/dist/pwa/sounds/warcraft-3-humain-travail.mp3 +0 -0
  196. package/src/client/dist/pwa/sounds/yaaa.mp3 +0 -0
@@ -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';
4
+ import { isWorkflowPolicy, resolveWorkflowPolicy, WORKFLOW_ACTIONS, } from '../../shared/workflow-policy.js';
3
5
  import { getSearchIndexStatus, searchEvents } from '../services/search-service.js';
6
+ import { createSessionHandoff, decideSessionHandoff, getCurrentSessionHandoff, SessionHandoffError, submitSessionHandoff, waitForSessionHandoff, } from '../services/session-handoff-service.js';
4
7
  import { AgentStopError, assertAgentStopped } from '../utils/agent-stop-result.js';
5
8
  import { withGitRepoLock } from '../utils/git-repo-lock.js';
9
+ import { isWorkspaceLifecycleBusy, workspaceLifecycleReason } from '../utils/workspace-lifecycle-guard.js';
6
10
  import autoLoopMessagesRoutes from './auto-loop-messages.js';
7
11
  const execFileAsync = promisify(execFileCb);
8
12
  import fs from 'node:fs';
@@ -59,11 +63,42 @@ import { WorkspaceLifecycleBusyError, withWorkspaceLifecycleGuard } from '../uti
59
63
  import { resolveExtractedName } from '../utils/workspace-name.js';
60
64
  import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
61
65
  /** Lifecycle conflicts can be retried once the current owner has stopped. */
66
+ /** Symlink-resolved path, or the lexically resolved one when it does not exist. */
67
+ function realPathOrResolved(p) {
68
+ try {
69
+ return fs.realpathSync(p);
70
+ }
71
+ catch {
72
+ return path.resolve(p);
73
+ }
74
+ }
62
75
  function workspaceErrorStatus(err) {
63
76
  return err instanceof AgentStopError || err instanceof WorkspaceLifecycleBusyError ? 409 : 500;
64
77
  }
65
78
  /** Hono sub-router for workspace CRUD, tasks, agents, Git and PR creation. */
66
79
  const app = new Hono();
80
+ // Transfers reserve the writer across multiple HTTP requests and engine invocations.
81
+ app.use('/:id/*', async (c, next) => {
82
+ const id = c.req.param('id');
83
+ const route = c.req.path.split(`/api/workspaces/${id}/`)[1] ?? '';
84
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method) &&
85
+ workspaceLifecycleReason(id) === 'session-handoff' &&
86
+ !route.startsWith('session-handoffs') &&
87
+ ![
88
+ 'stop',
89
+ 'mark-read',
90
+ 'chat-history',
91
+ 'deferred-tool-use/answer',
92
+ 'deferred-tool-use/cancel',
93
+ 'deferred-permission/decision',
94
+ ].includes(route)) {
95
+ return c.json({
96
+ error: 'A session transfer is pending. Finish or cancel it before changing this workspace.',
97
+ code: 'workspace-busy',
98
+ }, 409);
99
+ }
100
+ await next();
101
+ });
67
102
  // These handlers own teardown across awaits; purge/delete/restore own their guard in the service.
68
103
  for (const route of ['/:id/archive', '/:id/run-setup-script', '/:id/cancel-source-change', '/:id/rollback-file']) {
69
104
  app.use(route, async (c, next) => {
@@ -297,6 +332,44 @@ app.get('/', (c) => {
297
332
  return c.json({ error: message }, workspaceErrorStatus(err));
298
333
  }
299
334
  });
335
+ function handoffError(c, error) {
336
+ return c.json({ error: error instanceof Error ? error.message : 'Session transfer failed' }, error instanceof SessionHandoffError
337
+ ? error.status
338
+ : error instanceof SyntaxError
339
+ ? 400
340
+ : workspaceErrorStatus(error));
341
+ }
342
+ app.post('/:id/session-handoffs', migrationGuard, async (c) => {
343
+ try {
344
+ return c.json({ handoff: createSessionHandoff(c.req.param('id'), await c.req.json()) }, 202);
345
+ }
346
+ catch (error) {
347
+ return handoffError(c, error);
348
+ }
349
+ });
350
+ app.get('/:id/session-handoffs/current', (c) => {
351
+ if (!workspaceService.getWorkspace(c.req.param('id')))
352
+ return c.json({ error: 'Workspace not found' }, 404);
353
+ return c.json({ handoff: getCurrentSessionHandoff(c.req.param('id')) });
354
+ });
355
+ app.post('/:id/session-handoffs/:handoffId/decision', migrationGuard, async (c) => {
356
+ try {
357
+ const body = await c.req.json();
358
+ return c.json({ handoff: await decideSessionHandoff(c.req.param('id'), c.req.param('handoffId'), body.action) });
359
+ }
360
+ catch (error) {
361
+ return handoffError(c, error);
362
+ }
363
+ });
364
+ app.post('/:id/session-handoffs/:handoffId/report', migrationGuard, async (c) => {
365
+ try {
366
+ const body = await c.req.json();
367
+ return c.json(submitSessionHandoff(c.req.param('id'), c.req.param('handoffId'), body.token, body.report));
368
+ }
369
+ catch (error) {
370
+ return handoffError(c, error);
371
+ }
372
+ });
300
373
  // POST /api/workspaces/:id/engine-handoff-preview — build an editable, deterministic handoff.
301
374
  app.post('/:id/engine-handoff-preview', migrationGuard, async (c) => {
302
375
  try {
@@ -348,15 +421,14 @@ app.post('/:id/switch-engine', migrationGuard, async (c) => {
348
421
  error: 'Workspace is compacting its context; wait until compaction finishes before sending a message',
349
422
  }, 409);
350
423
  }
351
- // A new engine is started three lines below: the previous one must be dead
352
- // first, or two agents share the worktree.
353
- if (agentManager.hasController(id))
354
- assertAgentStopped(await agentManager.stopAgentAndWait(id, undefined, 'replacement'));
355
- const updated = workspaceService.updateWorkspaceEngineConfiguration(id, body.engine, model, effort, body.agentPermissionMode);
356
- const session = agentManager.startAgent(id, updated.worktreePath, handoff, updated.model, false, updated.agentPermissionMode, undefined, updated.reasoningEffort);
357
- workspaceService.updateWorkspaceStatus(id, 'executing');
358
- wsService.emit(id, 'user:message', { content: handoff, sender: 'user', kind: 'engine-handoff' }, session.agentSessionId);
359
- return c.json({ workspace: workspaceService.getWorkspace(id), sessionId: session.agentSessionId });
424
+ const operation = createSessionHandoff(id, {
425
+ requestId: nanoid(),
426
+ sourceSessionId: workspaceService.getActiveSession(id)?.id ?? null,
427
+ generateSummary: false,
428
+ target: { engine: body.engine, model, reasoningEffort: effort, agentPermissionMode: body.agentPermissionMode },
429
+ }, handoff);
430
+ const completed = await waitForSessionHandoff(operation.id);
431
+ return c.json({ workspace: workspaceService.getWorkspace(id), sessionId: completed.targetSessionId });
360
432
  }
361
433
  catch (err) {
362
434
  return c.json({ error: err instanceof Error ? err.message : 'Failed to switch engine' }, 500);
@@ -390,6 +462,8 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
390
462
  let setupScriptConfigured = false;
391
463
  try {
392
464
  const { body, attachments } = await readWorkspaceCreationRequest(c.req.raw);
465
+ if (body.workflowPolicy !== undefined && !isWorkflowPolicy(body.workflowPolicy))
466
+ return c.json({ error: 'Invalid workflowPolicy' }, 400);
393
467
  // workingBranch is derived from git when worktreePath is provided, so
394
468
  // it's not required in that flow. The other 3 fields stay mandatory.
395
469
  if (!body.name || !body.projectPath || !body.sourceBranch) {
@@ -490,11 +564,10 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
490
564
  return c.json({ error: failure, step: 'inspect-worktree' }, 422);
491
565
  }
492
566
  try {
493
- const commonDir = execFileSync('git', ['-C', body.worktreePath, 'rev-parse', '--git-common-dir'], {
494
- encoding: 'utf-8',
495
- }).trim();
496
- const expectedCommonDir = path.join(body.projectPath, '.git');
497
- if (path.resolve(commonDir) !== path.resolve(expectedCommonDir)) {
567
+ const commonDir = execFileSync('git', ['-C', body.worktreePath, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf-8' }).trim();
568
+ // Git reports the common dir symlink-resolved while the project path may be
569
+ // reached through a link (macOS temp dirs: /var -> /private/var).
570
+ if (realPathOrResolved(commonDir) !== realPathOrResolved(path.join(body.projectPath, '.git'))) {
498
571
  const failure = `Worktree '${body.worktreePath}' belongs to a different repository`;
499
572
  emitCreateFailed(creationId, 'inspect-worktree', failure);
500
573
  return c.json({ error: failure, step: 'inspect-worktree' }, 422);
@@ -732,6 +805,7 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
732
805
  model: body.model,
733
806
  brainstormModel: body.brainstormModel,
734
807
  reasoningEffort: body.reasoningEffort,
808
+ workflowPolicy: resolveWorkflowPolicy(globalSettings.workflowPolicy, projectSettings?.workflowPolicy, body.workflowPolicy),
735
809
  agentPermissionMode: resolveCreateAgentPermissionMode(body.agentPermissionMode, body.projectPath, globalSettings, body.engine ?? 'claude-code'),
736
810
  engine: body.engine,
737
811
  // Set by the engine-comparison flow, which posts here once per engine
@@ -1047,9 +1121,9 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
1047
1121
  `## Additional Context\n${extra}\n\n` +
1048
1122
  `## MCP Tools for deeper analysis\n` +
1049
1123
  `If you need more context, the following Sentry MCP tools are available:\n` +
1050
- `- \`mcp__sentry__get_sentry_resource(url, resourceType)\` — fetch the issue, breadcrumbs, replay, or trace\n` +
1051
- `- \`mcp__sentry__search_issue_events(organizationSlug, issueId='${sentryContent.issueId}')\` — recent events for this issue\n` +
1052
- `- \`mcp__sentry__get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key)\` — filter by tag (environment, user, browser, …)\n`;
1124
+ `- \`get_sentry_resource(url, resourceType) [use the Kōbō-managed Sentry namespace supplied for this session]\` — fetch the issue, breadcrumbs, replay, or trace\n` +
1125
+ `- \`search_issue_events(organizationSlug, issueId='${sentryContent.issueId}')\` — recent events for this issue\n` +
1126
+ `- \`get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key)\` — filter by tag (environment, user, browser, …)\n`;
1053
1127
  fs.writeFileSync(sentryFilePath, md, 'utf-8');
1054
1128
  workspaceService.createTask(workspace.id, {
1055
1129
  title: `Fix: ${sentryContent.title || sentryContent.issueId || `Sentry #${sentryContent.issueNumericId}`}`,
@@ -1170,9 +1244,9 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
1170
1244
  `5. Confirm the test passes, run related tests\n` +
1171
1245
  `6. Commit referencing the Sentry Short-ID (e.g. "fix(scope): description (${sentryContent.issueId})") — Sentry auto-closes the issue when the commit is merged\n` +
1172
1246
  `\nIf you need more context, Sentry MCP tools are available:\n` +
1173
- `- mcp__sentry__get_sentry_resource(url, resourceType) — fetch the issue, breadcrumbs, replay or trace\n` +
1174
- `- mcp__sentry__search_issue_events(organizationSlug, issueId='${sentryContent.issueId}') — recent events\n` +
1175
- `- mcp__sentry__get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key) — filter by tag\n`;
1247
+ `- get_sentry_resource(url, resourceType) [use the Kōbō-managed Sentry namespace supplied for this session] — fetch the issue, breadcrumbs, replay or trace\n` +
1248
+ `- search_issue_events(organizationSlug, issueId='${sentryContent.issueId}') — recent events\n` +
1249
+ `- get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key) — filter by tag\n`;
1176
1250
  if (sentryTpl.trim().length > 0) {
1177
1251
  const renderedSentry = renderSentryInitialPrompt(sentryTpl, {
1178
1252
  issueId: sentryContent.issueId,
@@ -1618,6 +1692,13 @@ app.get('/auto-loop-states', (c) => {
1618
1692
  });
1619
1693
  app.route('/', autoLoopMessagesRoutes);
1620
1694
  // GET /api/workspaces/:id/auto-loop — current auto-loop status for one workspace.
1695
+ // Admission inspection never changes deadlines, pending schedules or lifecycle state.
1696
+ app.get('/:id/automatic-admission', (c) => {
1697
+ const id = c.req.param('id');
1698
+ if (!workspaceService.getWorkspace(id))
1699
+ return c.json({ error: 'Workspace not found' }, 404);
1700
+ return c.json(autoLoopService.getAutomaticAdmissionStatus(id));
1701
+ });
1621
1702
  app.get('/:id/auto-loop', (c) => {
1622
1703
  try {
1623
1704
  const id = c.req.param('id');
@@ -2629,6 +2710,8 @@ app.put('/:id/tags', async (c) => {
2629
2710
  });
2630
2711
  // PATCH /api/workspaces/:id — update workspace fields (status, model, agentPermissionMode, name)
2631
2712
  app.patch('/:id', migrationGuard, async (c) => {
2713
+ if (workspaceLifecycleReason(c.req.param('id')) === 'session-handoff')
2714
+ return c.json({ error: 'A session transfer is pending', code: 'workspace-busy' }, 409);
2632
2715
  try {
2633
2716
  const id = c.req.param('id');
2634
2717
  const parsed = await c.req.json();
@@ -2644,6 +2727,20 @@ app.patch('/:id', migrationGuard, async (c) => {
2644
2727
  if ('agent_description' in body) {
2645
2728
  return c.json({ error: 'agent_description must be set via the agent MCP tool, not via the API' }, 400);
2646
2729
  }
2730
+ if (body.workflowPolicy !== undefined && !isWorkflowPolicy(body.workflowPolicy))
2731
+ return c.json({ error: 'Invalid workflowPolicy' }, 400);
2732
+ if (body.workflowPolicy !== undefined) {
2733
+ const policy = resolveWorkflowPolicy(workspace.workflowPolicy, body.workflowPolicy);
2734
+ const changed = WORKFLOW_ACTIONS.some((action) => policy[action] !== workspace.workflowPolicy[action]);
2735
+ // A live engine already received its policy. Persisting a different one cannot
2736
+ // revoke that prompt; wait for confirmed closure before accepting the change.
2737
+ if (changed && (agentManager.hasController(id) || isWorkspaceLifecycleBusy(id))) {
2738
+ return c.json({
2739
+ error: 'Stop the agent and wait for workspace operations to finish before changing workflow preferences.',
2740
+ code: 'workspace-busy',
2741
+ }, 409);
2742
+ }
2743
+ }
2647
2744
  if (body.model !== undefined && (typeof body.model !== 'string' || !body.model.trim())) {
2648
2745
  return c.json({ error: 'model must be a non-empty string' }, 400);
2649
2746
  }
@@ -2685,6 +2782,7 @@ app.patch('/:id', migrationGuard, async (c) => {
2685
2782
  }
2686
2783
  }
2687
2784
  if (body.status === undefined &&
2785
+ body.workflowPolicy === undefined &&
2688
2786
  body.model === undefined &&
2689
2787
  body.reasoningEffort === undefined &&
2690
2788
  body.agentPermissionMode === undefined &&
@@ -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,7 @@ export function createClaudeCodeEngine() {
588
602
  }
589
603
  })();
590
604
  const engineProcess = {
605
+ ready,
591
606
  closed: iteratorPromise.then(() => { }),
592
607
  get pid() {
593
608
  return undefined;
@@ -574,6 +574,7 @@ export function createCodexEngine() {
574
574
  }
575
575
  })();
576
576
  const engineProcess = {
577
+ ready: readyPromise,
577
578
  closed: Promise.all([iteratorPromise, childExitedPromise]).then(() => { }),
578
579
  get pid() {
579
580
  return child.pid;
@@ -54,6 +54,7 @@ export function buildCodexOptions(input) {
54
54
  const mcpServersConfig = {};
55
55
  for (const srv of input.mcpServers) {
56
56
  mcpServersConfig[srv.name] = {
57
+ enabled: true,
57
58
  command: srv.command,
58
59
  args: srv.args,
59
60
  env: srv.env,
@@ -1,5 +1,6 @@
1
1
  import { spawn as nodeSpawn } from 'node:child_process';
2
2
  import { createRequire } from 'node:module';
3
+ import path from 'node:path';
3
4
  const requireFn = createRequire(import.meta.url);
4
5
  export function resolveCodexBinary() {
5
6
  try {
@@ -7,8 +8,7 @@ export function resolveCodexBinary() {
7
8
  const pkg = requireFn(pkgPath);
8
9
  const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.codex;
9
10
  if (binRel) {
10
- const url = new URL(binRel, `file://${pkgPath}`);
11
- return url.pathname;
11
+ return path.resolve(path.dirname(pkgPath), binRel);
12
12
  }
13
13
  }
14
14
  catch {
@@ -0,0 +1,49 @@
1
+ import { nanoid } from 'nanoid';
2
+ import { readClaudeMcpEntry } from '../../utils/mcp-client.js';
3
+ import { getIntegrationConfig } from '../integration-config-service.js';
4
+ import { buildNotionMcpConfig } from '../notion-service.js';
5
+ import { readSentryMcpConfig } from '../sentry-service.js';
6
+ /** Availability only: no provider process or credential-bearing response. */
7
+ export function hasIntegrationMcpConfig(integration, selectedKey) {
8
+ if (getIntegrationConfig(integration))
9
+ return true;
10
+ if (integration === 'notion' &&
11
+ (process.env.NOTION_API_TOKEN || process.env.NOTION_TOKEN || process.env.OPENAPI_MCP_HEADERS))
12
+ return true;
13
+ const key = selectedKey?.trim();
14
+ return Boolean(readClaudeMcpEntry((name) => key ? name === key : integration === 'notion' ? name === 'notion' : /sentry/i.test(name)));
15
+ }
16
+ /** Resolve at every launch/resume; never write credentials into a project file. */
17
+ export function buildIntegrationMcpServers(settings, onUnavailable) {
18
+ const servers = [];
19
+ // Fresh names avoid Codex recursively merging stale native config fields.
20
+ const launch = nanoid(12);
21
+ for (const integration of ['notion', 'sentry']) {
22
+ if (!settings[`${integration}Enabled`])
23
+ continue;
24
+ try {
25
+ const key = settings[`${integration}McpKey`];
26
+ if (!hasIntegrationMcpConfig(integration, key))
27
+ continue;
28
+ const config = integration === 'notion' ? buildNotionMcpConfig(key, false) : readSentryMcpConfig(key, false);
29
+ servers.push({ name: `kobo-${integration}-${launch}`, ...config });
30
+ }
31
+ catch {
32
+ // Optional connections cannot block ordinary missions. Never expose raw
33
+ // config/parse errors, which may contain credential material.
34
+ onUnavailable?.(integration);
35
+ }
36
+ }
37
+ return servers;
38
+ }
39
+ export function integrationMcpPrompt(servers, unavailable = []) {
40
+ if (!servers.length && !unavailable.length)
41
+ return '';
42
+ return [
43
+ '[Kōbō managed integrations]',
44
+ ...unavailable.map((name) => `${name}: Kōbō could not load this connection. Ask the user to check Settings if needed; do not silently substitute personal credentials.`),
45
+ 'For these integrations, use the MCP servers listed below, configured in Kōbō Settings. Prefer them over personal/native servers and namespaces from earlier turns. Discover their available tools; do not assume all servers implement the same tools. If unavailable, report the connection problem instead of silently switching credentials.',
46
+ ...servers.map((server) => `${server.name.startsWith('kobo-notion-') ? 'Notion' : 'Sentry'}: ${server.name}`),
47
+ 'These connections do not grant permission to modify remote data. Follow the user request and engine permissions.',
48
+ ].join('\n');
49
+ }
@@ -1,9 +1,10 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { nanoid } from 'nanoid';
3
+ import { renderWorkflowPolicy } from '../../../shared/workflow-policy.js';
3
4
  import { getDb } from '../../db/index.js';
4
5
  import { assertAgentStopped } from '../../utils/agent-stop-result.js';
5
6
  import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
6
- import { assertWorkspaceLifecycleAvailable } from '../../utils/workspace-lifecycle-guard.js';
7
+ import { assertWorkspaceLifecycleAvailable, workspaceLifecycleReason } from '../../utils/workspace-lifecycle-guard.js';
7
8
  import { listLoopMessages, settleLoopMessages } from '../auto-loop-message-service.js';
8
9
  import * as autoLoopService from '../auto-loop-service.js';
9
10
  import * as cleanupScriptService from '../cleanup-script-service.js';
@@ -12,6 +13,8 @@ import { resolveForge } from '../forge/resolve.js';
12
13
  import * as lifecycleHookService from '../lifecycle-hook-service.js';
13
14
  import * as quotaBackoffService from '../quota-backoff-service.js';
14
15
  import { buildReviewReturnPrompt, getReviewReturn, restoreReviewConfiguration } from '../review-return-service.js';
16
+ import { activateSession, SESSION_RECENCY_ORDER } from '../session-activity-service.js';
17
+ import { requestHandoffStop, suspendHandoffTransfers } from '../session-handoff-runtime.js';
15
18
  import { getEffectiveSettings, getGlobalSettings } from '../settings-service.js';
16
19
  import { refreshNow } from '../usage/poller.js';
17
20
  import * as wakeupService from '../wakeup-service.js';
@@ -21,6 +24,7 @@ import { getWorkspace as getWs, markWorkspaceUnread, updateWorkspaceStatus, } fr
21
24
  import { resolveEngine } from './engines/registry.js';
22
25
  import { AGENT_NO_LONGER_RUNNING_TEXT, } from './engines/types.js';
23
26
  import { routeEvent } from './event-router.js';
27
+ import { buildIntegrationMcpServers, integrationMcpPrompt } from './integration-mcp.js';
24
28
  import { SessionController } from './session-controller.js';
25
29
  export class InterruptAgentError extends Error {
26
30
  code;
@@ -474,7 +478,7 @@ function readEffectiveSettingsSafe(projectPath) {
474
478
  };
475
479
  }
476
480
  }
477
- function buildMcpServers(workspaceId) {
481
+ function buildMcpServers(workspaceId, extraEnv) {
478
482
  const mcpServerCompiled = getCompiledMcpServerPath();
479
483
  const mcpServerSource = getMcpServerSourcePath();
480
484
  return [
@@ -484,6 +488,7 @@ function buildMcpServers(workspaceId) {
484
488
  args: mcpServerCompiled ? [mcpServerCompiled] : ['tsx', mcpServerSource],
485
489
  env: {
486
490
  KOBO_WORKSPACE_ID: workspaceId,
491
+ ...extraEnv,
487
492
  KOBO_DB_PATH: getDbPath(),
488
493
  KOBO_SETTINGS_PATH: getSettingsPath(),
489
494
  KOBO_BACKEND_URL: `http://127.0.0.1:${backendPort}`,
@@ -540,7 +545,7 @@ function resolveSessionForResume(workspaceId, existingSessionId, model, engineId
540
545
  }
541
546
  else {
542
547
  lastSession = db
543
- .prepare("SELECT id, engine_session_id, engine FROM agent_sessions WHERE workspace_id = ? AND COALESCE(engine, 'claude-code') = ? AND engine_session_id IS NOT NULL ORDER BY started_at DESC LIMIT 1")
548
+ .prepare(`SELECT id, engine_session_id, engine FROM agent_sessions WHERE workspace_id = ? AND COALESCE(engine, 'claude-code') = ? AND engine_session_id IS NOT NULL AND activation_order >= 0 ORDER BY ${SESSION_RECENCY_ORDER} LIMIT 1`)
544
549
  .get(workspaceId, engineId);
545
550
  }
546
551
  // A workspace can now contain conversations from multiple engines. The
@@ -841,6 +846,13 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev, terminal
841
846
  return;
842
847
  const routedEvent = ev.kind === 'session:ended' && (hasReplacement || sourceIsSuperseded) ? { ...ev, superseded: true } : ev;
843
848
  routeEvent(workspaceId, agentSessionId, routedEvent);
849
+ const launch = sourceController ? launchOptions.get(sourceController) : undefined;
850
+ if (ev.kind === 'error')
851
+ launch?.onError?.(ev.message);
852
+ // Summary turns can read and report, but must not drive loop/quota/scheduler side effects.
853
+ if (launch?.handoffGeneration &&
854
+ !['session:started', 'session:ended', 'session:user-input-requested'].includes(ev.kind))
855
+ return;
844
856
  if (sourceController && !sourceControllerIsStopping) {
845
857
  if (ev.kind === 'session:compacting' && ev.active) {
846
858
  const status = getWs(workspaceId)?.status;
@@ -958,9 +970,10 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev, terminal
958
970
  console.error('[orchestrator] Failed to transition to executing:', err);
959
971
  }
960
972
  }
973
+ const transferring = workspaceLifecycleReason(workspaceId) === 'session-handoff';
961
974
  const returningReview = (ev.kind === 'error' || ev.kind === 'session:ended') && !!getReviewReturn(workspaceId, agentSessionId);
962
975
  const quotaError = ev.kind === 'error' && (ev.category === 'quota' || /\b429\b/.test(ev.message));
963
- if (quotaError && !sourceControllerIsStopping && !returningReview) {
976
+ if (quotaError && !sourceControllerIsStopping && !returningReview && !transferring) {
964
977
  void handleQuota(workspaceId, agentSessionId);
965
978
  }
966
979
  if (ev.kind === 'error' &&
@@ -968,6 +981,7 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev, terminal
968
981
  ev.category !== 'resume_failed' &&
969
982
  TRANSIENT_SERVER_ERROR_PATTERN.test(ev.message) &&
970
983
  !returningReview &&
984
+ !transferring &&
971
985
  getWs(workspaceId)?.autoLoop &&
972
986
  !sourceControllerIsStopping) {
973
987
  scheduleTransientAutoLoopRecovery(workspaceId, sourceController);
@@ -989,6 +1003,22 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev, terminal
989
1003
  // its persisted prompts too, otherwise a reconnect resurrects a panel
990
1004
  // whose backend callback has already been discarded.
991
1005
  purgeAllPersistedUserInputRequests(workspaceId, agentSessionId);
1006
+ if (transferring) {
1007
+ onSessionEnded(workspaceId, agentSessionId, sourceController, ev.exitCode, ev.reason, isResumeFailed);
1008
+ launch?.onEnded?.(ev);
1009
+ if (workspaceLifecycleReason(workspaceId) === 'session-handoff') {
1010
+ if (!launch?.handoffGeneration) {
1011
+ settleLoopMessages(workspaceId, agentSessionId, ev.reason === 'completed');
1012
+ if (listLoopMessages(workspaceId).some((message) => message.state === 'unknown'))
1013
+ autoLoopService.block(workspaceId, 'message-delivery-unknown');
1014
+ }
1015
+ notifyCapacityAvailable(workspaceId);
1016
+ return;
1017
+ }
1018
+ // A target can finish before its ready/start promise settles. Its callback
1019
+ // completed the transfer and released ownership: process this actual work
1020
+ // session through the ordinary terminal/auto-loop path below exactly once.
1021
+ }
992
1022
  // A watchdog end means Kōbō forced the stream closed after the engine
993
1023
  // failed to drain. It is neither a clean completion nor evidence of an
994
1024
  // agent stall. Move auto-loop work onto the existing persisted, bounded
@@ -1190,6 +1220,11 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1190
1220
  preCompactionStatus.delete(sourceController);
1191
1221
  if (isSuperseded)
1192
1222
  return false;
1223
+ if (workspaceLifecycleReason(workspaceId) === 'session-handoff') {
1224
+ if (registeredController === sourceController && sourceController?.status !== 'stopping')
1225
+ controllers.delete(workspaceId);
1226
+ return false;
1227
+ }
1193
1228
  // A superseded end (above) is a session that was replaced, not one that
1194
1229
  // ended; everything past this point is a real end the user may want to act
1195
1230
  // on, whatever the reason — the script reads KOBO_SESSION_END_REASON.
@@ -1254,6 +1289,7 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1254
1289
  }
1255
1290
  return true;
1256
1291
  }
1292
+ const launchOptions = new WeakMap();
1257
1293
  // ── Public API ────────────────────────────────────────────────────────────────
1258
1294
  /**
1259
1295
  * Spawn an agent (via the resolved engine) for a workspace. Returns
@@ -1261,10 +1297,10 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1261
1297
  * after `engine.start` resolves — callers should subscribe to WS events or
1262
1298
  * query the controller via `_getControllers()` for tests.
1263
1299
  */
1264
- export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort, beforeDispatch) {
1300
+ export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort, beforeDispatch, launch) {
1265
1301
  if (shuttingDown)
1266
1302
  throw new Error('Cannot start an agent while the server is shutting down');
1267
- assertWorkspaceLifecycleAvailable(workspaceId);
1303
+ assertWorkspaceLifecycleAvailable(workspaceId, launch?.lifecycleOwner);
1268
1304
  const workspace = getWs(workspaceId);
1269
1305
  if (!workspace)
1270
1306
  throw new Error(`Workspace '${workspaceId}' not found`);
@@ -1334,12 +1370,15 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1334
1370
  }
1335
1371
  // Persist before the asynchronous engine can emit its first event. This
1336
1372
  // survives a Kōbō restart and does not depend on SDK system:init delivery.
1373
+ activateSession(workspaceId, agentSessionId);
1337
1374
  captureTaskProgressBaseline(workspaceId, agentSessionId);
1338
1375
  const settings = ws ? readEffectiveSettingsSafe(ws.projectPath) : readEffectiveSettingsSafe(workingDir);
1376
+ const unavailableIntegrations = [];
1377
+ const integrationServers = buildIntegrationMcpServers(getGlobalSettings(), (name) => unavailableIntegrations.push(name));
1339
1378
  const options = {
1340
1379
  workspaceId,
1341
1380
  workingDir,
1342
- prompt: buildAgentPrompt(prompt, ws?.projectPath),
1381
+ prompt: buildAgentPrompt(`${ws ? renderWorkflowPolicy(ws.workflowPolicy) : ''}\n${integrationMcpPrompt(integrationServers, unavailableIntegrations)}\n${prompt}`, ws?.projectPath),
1343
1382
  model,
1344
1383
  effort: reasoningEffort,
1345
1384
  // Cascade: explicit caller override → workspace setting → 'bypass'.
@@ -1355,12 +1394,14 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1355
1394
  }
1356
1395
  })(),
1357
1396
  settings,
1358
- mcpServers: buildMcpServers(workspaceId),
1397
+ mcpServers: [...buildMcpServers(workspaceId, launch?.mcpEnv), ...integrationServers],
1359
1398
  env: ws ? buildAgentEnv(ws.projectPath) : undefined,
1360
1399
  };
1361
1400
  beforeDispatch?.();
1362
1401
  let controller;
1363
1402
  controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, controller, ev));
1403
+ if (launch)
1404
+ launchOptions.set(controller, launch);
1364
1405
  registerSessionLifecycleOwner(workspaceId, agentSessionId, controller);
1365
1406
  if (!existingCtrl)
1366
1407
  controllers.set(workspaceId, controller);
@@ -1377,7 +1418,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1377
1418
  return;
1378
1419
  if (pendingStarts.get(workspaceId) === controller)
1379
1420
  pendingStarts.delete(workspaceId);
1380
- assertWorkspaceLifecycleAvailable(workspaceId);
1421
+ assertWorkspaceLifecycleAvailable(workspaceId, launch?.lifecycleOwner);
1381
1422
  const current = getWs(workspaceId);
1382
1423
  if (!current || current.archivedAt || current.worktreePurgedAt)
1383
1424
  throw new Error('Workspace is no longer available');
@@ -1387,7 +1428,11 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1387
1428
  controllers.set(workspaceId, controller);
1388
1429
  return controller.start(options);
1389
1430
  })
1390
- .then(() => {
1431
+ .then(async () => {
1432
+ if (launch?.onStarted)
1433
+ await controller.engineProcess?.ready;
1434
+ if (controller.status !== 'stopping')
1435
+ launch?.onStarted?.();
1391
1436
  const pid = controller.pid;
1392
1437
  if (pid !== undefined) {
1393
1438
  try {
@@ -1524,6 +1569,9 @@ async function raceWithTimeout(promise, timeoutMs, onTimeout) {
1524
1569
  export async function stopAgentAndWait(workspaceId, timeoutMs = STOP_AGENT_TIMEOUT_MS,
1525
1570
  /** Only `user` disables auto-loop; technical stops preserve its persisted intent. */
1526
1571
  cause = 'user') {
1572
+ const finishHandoffStop = ['user', 'delete', 'purge', 'archive'].includes(cause)
1573
+ ? requestHandoffStop(workspaceId)
1574
+ : undefined;
1527
1575
  const pending = pendingStarts.get(workspaceId);
1528
1576
  let pendingStop = Promise.resolve();
1529
1577
  if (pending) {
@@ -1544,7 +1592,8 @@ cause = 'user') {
1544
1592
  // Shutdown only suspends timers: persisted intent belongs to the next boot.
1545
1593
  const cancelSchedules = new Promise((resolve) => {
1546
1594
  if (cause !== 'shutdown') {
1547
- wakeupService.cancel(workspaceId, 'stopped');
1595
+ if (cause !== 'handoff')
1596
+ wakeupService.cancel(workspaceId, 'stopped');
1548
1597
  quotaBackoffService.cancel(workspaceId, 'user');
1549
1598
  }
1550
1599
  resolve();
@@ -1561,6 +1610,7 @@ cause = 'user') {
1561
1610
  if (outcome === 'timeout') {
1562
1611
  console.error(`[orchestrator] Agent '${workspaceId}' did not stop within ${timeoutMs}ms; retaining its controller`);
1563
1612
  }
1613
+ finishHandoffStop?.(outcome);
1564
1614
  return outcome;
1565
1615
  }
1566
1616
  /**
@@ -1584,6 +1634,7 @@ export async function stopAllAgents(timeoutMs = 3_000) {
1584
1634
  wakeupService.suspendForShutdown();
1585
1635
  cronService.suspendForShutdown();
1586
1636
  shuttingDown = true;
1637
+ suspendHandoffTransfers();
1587
1638
  if (capacityTimer)
1588
1639
  clearTimeout(capacityTimer);
1589
1640
  capacityTimer = undefined;
@@ -1603,6 +1654,8 @@ export function isShuttingDown() {
1603
1654
  }
1604
1655
  /** Deliver a scheduled check without stopping background work or cancelling the wakeup as user input. */
1605
1656
  export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
1657
+ if (workspaceLifecycleReason(workspaceId) === 'session-handoff')
1658
+ return false;
1606
1659
  const ctrl = controllers.get(workspaceId);
1607
1660
  if (ctrl?.status !== 'running')
1608
1661
  return false;
@@ -1620,6 +1673,7 @@ export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
1620
1673
  }
1621
1674
  /** Write a user message to the running agent. */
1622
1675
  export async function sendMessage(workspaceId, content, expectedSessionId, beforeDispatch) {
1676
+ assertWorkspaceLifecycleAvailable(workspaceId);
1623
1677
  assertWorkspaceNotCompacting(workspaceId);
1624
1678
  const ctrl = controllers.get(workspaceId);
1625
1679
  if (!ctrl) {
@@ -1633,6 +1687,7 @@ export async function sendMessage(workspaceId, content, expectedSessionId, befor
1633
1687
  await ctrl.sendMessage(content);
1634
1688
  }
1635
1689
  export async function sendMessageForFallback(workspaceId, content) {
1690
+ assertWorkspaceLifecycleAvailable(workspaceId);
1636
1691
  assertWorkspaceNotCompacting(workspaceId);
1637
1692
  const capturedController = controllers.get(workspaceId);
1638
1693
  wakeupService.cancel(workspaceId, 'user-message');
@@ -1643,6 +1698,7 @@ export async function sendMessageForFallback(workspaceId, content) {
1643
1698
  return { status: 'sent', sessionId: capturedController.agentSessionId };
1644
1699
  }
1645
1700
  catch {
1701
+ assertWorkspaceLifecycleAvailable(workspaceId);
1646
1702
  assertWorkspaceNotCompacting(workspaceId);
1647
1703
  const deadline = Date.now() + FALLBACK_CONTROLLER_TURNOVER_TIMEOUT_MS;
1648
1704
  while (controllers.get(workspaceId) === capturedController) {
@@ -1657,6 +1713,7 @@ export async function sendMessageForFallback(workspaceId, content) {
1657
1713
  const replacementController = controllers.get(workspaceId);
1658
1714
  if (!replacementController)
1659
1715
  return { status: 'stopped' };
1716
+ assertWorkspaceLifecycleAvailable(workspaceId);
1660
1717
  assertWorkspaceNotCompacting(workspaceId);
1661
1718
  await replacementController.sendMessage(content);
1662
1719
  return { status: 'sent', sessionId: replacementController.agentSessionId };