@loicngr/kobo 1.11.44 → 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 (113) hide show
  1. package/AGENTS.md +40 -2
  2. package/CHANGELOG.md +5 -0
  3. package/README.md +13 -1
  4. package/dist/mcp-server/kobo-tasks-handlers.js +2 -1
  5. package/dist/mcp-server/kobo-tasks-server.js +31 -5
  6. package/dist/server/db/migrations.js +14 -0
  7. package/dist/server/db/schema.js +4 -1
  8. package/dist/server/db/session-handoff-schema.js +24 -0
  9. package/dist/server/index.js +2 -0
  10. package/dist/server/routes/documents.js +1 -1
  11. package/dist/server/routes/workspaces.js +73 -9
  12. package/dist/server/services/agent/engines/claude-code/engine.js +15 -0
  13. package/dist/server/services/agent/engines/codex/engine.js +1 -0
  14. package/dist/server/services/agent/orchestrator.js +63 -10
  15. package/dist/server/services/engine-handoff-service.js +5 -5
  16. package/dist/server/services/session-activity-service.js +17 -0
  17. package/dist/server/services/session-handoff-runtime.js +26 -0
  18. package/dist/server/services/session-handoff-service.js +479 -0
  19. package/dist/server/services/wakeup-service.js +8 -0
  20. package/dist/server/services/workspace-service.js +5 -2
  21. package/dist/server/utils/workspace-lifecycle-guard.js +35 -20
  22. package/dist/shared/document-roots.js +2 -0
  23. package/dist/shared/session-handoff.js +4 -0
  24. package/package.json +1 -1
  25. package/src/client/dist/pwa/assets/ActivityFeed-CLXjdqS8.js +8 -0
  26. package/src/client/dist/pwa/assets/{ChangelogPage-DSKCvpr2.js → ChangelogPage-CnXTnFhe.js} +1 -1
  27. package/src/client/dist/pwa/assets/{ComparisonPanel-DDxcsxyC.js → ComparisonPanel-CKylN45q.js} +2 -2
  28. package/src/client/dist/pwa/assets/CreatePage-SoCcvxuD.js +2 -0
  29. package/src/client/dist/pwa/assets/{DashboardPage-Dg2s9nnv.js → DashboardPage-CZQLfoV1.js} +1 -1
  30. package/src/client/dist/pwa/assets/{DiffViewer-BSzNScIr.js → DiffViewer-Csf3WmLc.js} +4 -4
  31. package/src/client/dist/pwa/assets/{DocumentsPanel-DfEoI_f7.js → DocumentsPanel-BeCttB-x.js} +1 -1
  32. package/src/client/dist/pwa/assets/{DrawerToggleButton-D85PwhKb.js → DrawerToggleButton-BJBBYeOc.js} +1 -1
  33. package/src/client/dist/pwa/assets/GitPanel-B-ZHgghQ.js +3 -0
  34. package/src/client/dist/pwa/assets/{HealthPage-CaLCpejR.js → HealthPage-l88TEk-k.js} +1 -1
  35. package/src/client/dist/pwa/assets/MainLayout-BAAQLq95.css +1 -0
  36. package/src/client/dist/pwa/assets/MainLayout-Cf4Q9x1u.js +2 -0
  37. package/src/client/dist/pwa/assets/{QBtnDropdown-qJWGcZpC.js → QBtnDropdown-44Mj-y_1.js} +1 -1
  38. package/src/client/dist/pwa/assets/{QDrawer-DX3PhMqC.js → QDrawer-B4qHw8wt.js} +1 -1
  39. package/src/client/dist/pwa/assets/QExpansionItem-Bok1m8Wi.js +1 -0
  40. package/src/client/dist/pwa/assets/QSelect-BbtD5Iks.js +1 -0
  41. package/src/client/dist/pwa/assets/{QTooltip-cDK5AOQ5.js → QTooltip-BDihn2aY.js} +1 -1
  42. package/src/client/dist/pwa/assets/SchedulePanel-DiWCDNZ8.js +1 -0
  43. package/src/client/dist/pwa/assets/{SearchPage-BO-J4aNc.js → SearchPage-BwZ2TX1G.js} +1 -1
  44. package/src/client/dist/pwa/assets/SettingsPage-BUh0WfNf.js +9 -0
  45. package/src/client/dist/pwa/assets/{SplitWorkspacePage-BIEceNwH.js → SplitWorkspacePage-Bg6nKBcA.js} +1 -1
  46. package/src/client/dist/pwa/assets/{TerminalPanel-DitOCad1.js → TerminalPanel-Cq-FR7A7.js} +9 -9
  47. package/src/client/dist/pwa/assets/{TouchPan-DvPAhZsa.js → TouchPan-BQ1cvzRC.js} +1 -1
  48. package/src/client/dist/pwa/assets/{TourReplayButton-CJYdH3Jf.js → TourReplayButton-Dz1yEz_z.js} +1 -1
  49. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-CY-9z9nw.js → WorkspaceAttentionLabels-BCVOP8vp.js} +1 -1
  50. package/src/client/dist/pwa/assets/{WorkspacePage-DAVo-E4p.css → WorkspacePage-BbUvR1Vf.css} +1 -1
  51. package/src/client/dist/pwa/assets/WorkspacePage-Cl8gap4b.js +4 -0
  52. package/src/client/dist/pwa/assets/{browser-BsB6cFv4.js → browser-BmWgBx9M.js} +1 -1
  53. package/src/client/dist/pwa/assets/build-path-tree-6EjBmYYY.js +1 -0
  54. package/src/client/dist/pwa/assets/de-COObokpu.js +17 -0
  55. package/src/client/dist/pwa/assets/{documents-SCd3w8xV.js → documents-BZ8bwcjr.js} +1 -1
  56. package/src/client/dist/pwa/assets/{editor.api-BUdM7I6y.js → editor.api-CK7--ckY.js} +1 -1
  57. package/src/client/dist/pwa/assets/{efforts-DTJQP9or.js → efforts-Bw0o2Bud.js} +2 -2
  58. package/src/client/dist/pwa/assets/engineFeatures-DQO9HHXI.js +1 -0
  59. package/src/client/dist/pwa/assets/es-By2Q9Tca.js +17 -0
  60. package/src/client/dist/pwa/assets/expand-template-C8VyjASo.js +1 -0
  61. package/src/client/dist/pwa/assets/{format-BjQxf4pe.js → format-LtTLLitJ.js} +1 -1
  62. package/src/client/dist/pwa/assets/{formatters-BejhmNTV.js → formatters-Bl9zA7Ai.js} +1 -1
  63. package/src/client/dist/pwa/assets/fr-D-sd0ddn.js +17 -0
  64. package/src/client/dist/pwa/assets/{freemarker2-5d5GhyRp.js → freemarker2-DxezMaje.js} +1 -1
  65. package/src/client/dist/pwa/assets/{handlebars-DaafEsDK.js → handlebars-DK_Bj6Rx.js} +1 -1
  66. package/src/client/dist/pwa/assets/{html-CwHBDd2A.js → html-DFfsfStD.js} +1 -1
  67. package/src/client/dist/pwa/assets/i18n-aDWy4KHt.js +1 -0
  68. package/src/client/dist/pwa/assets/index-BLd8DAzg.js +20 -0
  69. package/src/client/dist/pwa/assets/it-CeK5lyIA.js +17 -0
  70. package/src/client/dist/pwa/assets/{javascript-DSNIEEH5.js → javascript-DuNbcaL7.js} +1 -1
  71. package/src/client/dist/pwa/assets/{liquid-Dma1UpA1.js → liquid-CM3JKyhv.js} +1 -1
  72. package/src/client/dist/pwa/assets/{mdx-D8rqEAws.js → mdx-BG3zYwNF.js} +1 -1
  73. package/src/client/dist/pwa/assets/{monaco-contributions-2E7O2zoi.js → monaco-contributions-DLscZGH-.js} +1 -1
  74. package/src/client/dist/pwa/assets/monaco.contribution-DnhZHwXT.js +2 -0
  75. package/src/client/dist/pwa/assets/{network-auth-DNmhJTZn.js → network-auth-BJiPbO5l.js} +1 -1
  76. package/src/client/dist/pwa/assets/{notify-theme-B6Hiwvwb.js → notify-theme-D4p3qPkp.js} +1 -1
  77. package/src/client/dist/pwa/assets/{pending-diff-open-CeiVIFZn.js → pending-diff-open-dYIFdS4r.js} +1 -1
  78. package/src/client/dist/pwa/assets/permissionModes-B7fuUegU.js +36 -0
  79. package/src/client/dist/pwa/assets/{project-color-BCp9SWoI.js → project-color-R_1je4vW.js} +1 -1
  80. package/src/client/dist/pwa/assets/{python-f-9NqRlI.js → python-COs6pHk2.js} +1 -1
  81. package/src/client/dist/pwa/assets/{razor-DsCmoX0h.js → razor-Jl1VqMsk.js} +1 -1
  82. package/src/client/dist/pwa/assets/{render-chat-markdown-DK5BD9mU.js → render-chat-markdown-AaOo0Tgq.js} +1 -1
  83. package/src/client/dist/pwa/assets/{typescript-cEHg4L10.js → typescript-BU_TUuAM.js} +1 -1
  84. package/src/client/dist/pwa/assets/use-slide-transition-CiaDm-tR.js +1 -0
  85. package/src/client/dist/pwa/assets/{use-tours-BmTbosmB.js → use-tours-CVn874kD.js} +2 -2
  86. package/src/client/dist/pwa/assets/{workspace-templates-DUWDLMIX.js → workspace-templates-BCKR8TXr.js} +1 -1
  87. package/src/client/dist/pwa/assets/{xml-Plt-Y7we.js → xml-gFFsobxE.js} +1 -1
  88. package/src/client/dist/pwa/assets/{yaml-ScEmaAeN.js → yaml-DaXaqaGD.js} +1 -1
  89. package/src/client/dist/pwa/index.html +1 -1
  90. package/src/client/dist/pwa/sw.js +1 -1
  91. package/src/mcp-server/README.md +8 -1
  92. package/src/mcp-server/kobo-tasks-handlers.ts +4 -1
  93. package/src/mcp-server/kobo-tasks-server.ts +38 -5
  94. package/src/client/dist/pwa/assets/ActivityFeed-D_y4aikp.js +0 -8
  95. package/src/client/dist/pwa/assets/CreatePage-DKVEknbL.js +0 -2
  96. package/src/client/dist/pwa/assets/GitPanel-CuG8MlgF.js +0 -3
  97. package/src/client/dist/pwa/assets/MainLayout-CDxYIJ_s.js +0 -2
  98. package/src/client/dist/pwa/assets/MainLayout-DTLNunOi.css +0 -1
  99. package/src/client/dist/pwa/assets/QSelect-CY4TWW_j.js +0 -1
  100. package/src/client/dist/pwa/assets/SchedulePanel-LvHiZ6ZH.js +0 -1
  101. package/src/client/dist/pwa/assets/SettingsPage-Aw89fRSq.js +0 -9
  102. package/src/client/dist/pwa/assets/WorkspacePage-xH_OyA3r.js +0 -4
  103. package/src/client/dist/pwa/assets/build-path-tree-CW-ER1Mt.js +0 -1
  104. package/src/client/dist/pwa/assets/de-Mu7biceO.js +0 -17
  105. package/src/client/dist/pwa/assets/engineFeatures-Bj6Ob41d.js +0 -1
  106. package/src/client/dist/pwa/assets/es-BY5IfNI2.js +0 -17
  107. package/src/client/dist/pwa/assets/expand-template-SM8vvYyO.js +0 -1
  108. package/src/client/dist/pwa/assets/fr-BMXCzFCM.js +0 -17
  109. package/src/client/dist/pwa/assets/i18n-CgWW5vfb.js +0 -1
  110. package/src/client/dist/pwa/assets/index-Di9Sz5E8.js +0 -20
  111. package/src/client/dist/pwa/assets/it-ByPZQK_K.js +0 -17
  112. package/src/client/dist/pwa/assets/monaco.contribution-DqOgo72G.js +0 -2
  113. package/src/client/dist/pwa/assets/permissionModes-DWdtf81n.js +0 -36
@@ -3,7 +3,7 @@ import { nanoid } from 'nanoid';
3
3
  import { getDb } from '../../db/index.js';
4
4
  import { assertAgentStopped } from '../../utils/agent-stop-result.js';
5
5
  import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
6
- import { assertWorkspaceLifecycleAvailable } from '../../utils/workspace-lifecycle-guard.js';
6
+ import { assertWorkspaceLifecycleAvailable, workspaceLifecycleReason } from '../../utils/workspace-lifecycle-guard.js';
7
7
  import { listLoopMessages, settleLoopMessages } from '../auto-loop-message-service.js';
8
8
  import * as autoLoopService from '../auto-loop-service.js';
9
9
  import * as cleanupScriptService from '../cleanup-script-service.js';
@@ -12,6 +12,8 @@ import { resolveForge } from '../forge/resolve.js';
12
12
  import * as lifecycleHookService from '../lifecycle-hook-service.js';
13
13
  import * as quotaBackoffService from '../quota-backoff-service.js';
14
14
  import { buildReviewReturnPrompt, getReviewReturn, restoreReviewConfiguration } from '../review-return-service.js';
15
+ import { activateSession, SESSION_RECENCY_ORDER } from '../session-activity-service.js';
16
+ import { requestHandoffStop, suspendHandoffTransfers } from '../session-handoff-runtime.js';
15
17
  import { getEffectiveSettings, getGlobalSettings } from '../settings-service.js';
16
18
  import { refreshNow } from '../usage/poller.js';
17
19
  import * as wakeupService from '../wakeup-service.js';
@@ -474,7 +476,7 @@ function readEffectiveSettingsSafe(projectPath) {
474
476
  };
475
477
  }
476
478
  }
477
- function buildMcpServers(workspaceId) {
479
+ function buildMcpServers(workspaceId, extraEnv) {
478
480
  const mcpServerCompiled = getCompiledMcpServerPath();
479
481
  const mcpServerSource = getMcpServerSourcePath();
480
482
  return [
@@ -484,6 +486,7 @@ function buildMcpServers(workspaceId) {
484
486
  args: mcpServerCompiled ? [mcpServerCompiled] : ['tsx', mcpServerSource],
485
487
  env: {
486
488
  KOBO_WORKSPACE_ID: workspaceId,
489
+ ...extraEnv,
487
490
  KOBO_DB_PATH: getDbPath(),
488
491
  KOBO_SETTINGS_PATH: getSettingsPath(),
489
492
  KOBO_BACKEND_URL: `http://127.0.0.1:${backendPort}`,
@@ -540,7 +543,7 @@ function resolveSessionForResume(workspaceId, existingSessionId, model, engineId
540
543
  }
541
544
  else {
542
545
  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")
546
+ .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
547
  .get(workspaceId, engineId);
545
548
  }
546
549
  // A workspace can now contain conversations from multiple engines. The
@@ -841,6 +844,13 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev, terminal
841
844
  return;
842
845
  const routedEvent = ev.kind === 'session:ended' && (hasReplacement || sourceIsSuperseded) ? { ...ev, superseded: true } : ev;
843
846
  routeEvent(workspaceId, agentSessionId, routedEvent);
847
+ const launch = sourceController ? launchOptions.get(sourceController) : undefined;
848
+ if (ev.kind === 'error')
849
+ launch?.onError?.(ev.message);
850
+ // Summary turns can read and report, but must not drive loop/quota/scheduler side effects.
851
+ if (launch?.handoffGeneration &&
852
+ !['session:started', 'session:ended', 'session:user-input-requested'].includes(ev.kind))
853
+ return;
844
854
  if (sourceController && !sourceControllerIsStopping) {
845
855
  if (ev.kind === 'session:compacting' && ev.active) {
846
856
  const status = getWs(workspaceId)?.status;
@@ -958,9 +968,10 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev, terminal
958
968
  console.error('[orchestrator] Failed to transition to executing:', err);
959
969
  }
960
970
  }
971
+ const transferring = workspaceLifecycleReason(workspaceId) === 'session-handoff';
961
972
  const returningReview = (ev.kind === 'error' || ev.kind === 'session:ended') && !!getReviewReturn(workspaceId, agentSessionId);
962
973
  const quotaError = ev.kind === 'error' && (ev.category === 'quota' || /\b429\b/.test(ev.message));
963
- if (quotaError && !sourceControllerIsStopping && !returningReview) {
974
+ if (quotaError && !sourceControllerIsStopping && !returningReview && !transferring) {
964
975
  void handleQuota(workspaceId, agentSessionId);
965
976
  }
966
977
  if (ev.kind === 'error' &&
@@ -968,6 +979,7 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev, terminal
968
979
  ev.category !== 'resume_failed' &&
969
980
  TRANSIENT_SERVER_ERROR_PATTERN.test(ev.message) &&
970
981
  !returningReview &&
982
+ !transferring &&
971
983
  getWs(workspaceId)?.autoLoop &&
972
984
  !sourceControllerIsStopping) {
973
985
  scheduleTransientAutoLoopRecovery(workspaceId, sourceController);
@@ -989,6 +1001,22 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev, terminal
989
1001
  // its persisted prompts too, otherwise a reconnect resurrects a panel
990
1002
  // whose backend callback has already been discarded.
991
1003
  purgeAllPersistedUserInputRequests(workspaceId, agentSessionId);
1004
+ if (transferring) {
1005
+ onSessionEnded(workspaceId, agentSessionId, sourceController, ev.exitCode, ev.reason, isResumeFailed);
1006
+ launch?.onEnded?.(ev);
1007
+ if (workspaceLifecycleReason(workspaceId) === 'session-handoff') {
1008
+ if (!launch?.handoffGeneration) {
1009
+ settleLoopMessages(workspaceId, agentSessionId, ev.reason === 'completed');
1010
+ if (listLoopMessages(workspaceId).some((message) => message.state === 'unknown'))
1011
+ autoLoopService.block(workspaceId, 'message-delivery-unknown');
1012
+ }
1013
+ notifyCapacityAvailable(workspaceId);
1014
+ return;
1015
+ }
1016
+ // A target can finish before its ready/start promise settles. Its callback
1017
+ // completed the transfer and released ownership: process this actual work
1018
+ // session through the ordinary terminal/auto-loop path below exactly once.
1019
+ }
992
1020
  // A watchdog end means Kōbō forced the stream closed after the engine
993
1021
  // failed to drain. It is neither a clean completion nor evidence of an
994
1022
  // agent stall. Move auto-loop work onto the existing persisted, bounded
@@ -1190,6 +1218,11 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1190
1218
  preCompactionStatus.delete(sourceController);
1191
1219
  if (isSuperseded)
1192
1220
  return false;
1221
+ if (workspaceLifecycleReason(workspaceId) === 'session-handoff') {
1222
+ if (registeredController === sourceController && sourceController?.status !== 'stopping')
1223
+ controllers.delete(workspaceId);
1224
+ return false;
1225
+ }
1193
1226
  // A superseded end (above) is a session that was replaced, not one that
1194
1227
  // ended; everything past this point is a real end the user may want to act
1195
1228
  // on, whatever the reason — the script reads KOBO_SESSION_END_REASON.
@@ -1254,6 +1287,7 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1254
1287
  }
1255
1288
  return true;
1256
1289
  }
1290
+ const launchOptions = new WeakMap();
1257
1291
  // ── Public API ────────────────────────────────────────────────────────────────
1258
1292
  /**
1259
1293
  * Spawn an agent (via the resolved engine) for a workspace. Returns
@@ -1261,10 +1295,10 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1261
1295
  * after `engine.start` resolves — callers should subscribe to WS events or
1262
1296
  * query the controller via `_getControllers()` for tests.
1263
1297
  */
1264
- export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort, beforeDispatch) {
1298
+ export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort, beforeDispatch, launch) {
1265
1299
  if (shuttingDown)
1266
1300
  throw new Error('Cannot start an agent while the server is shutting down');
1267
- assertWorkspaceLifecycleAvailable(workspaceId);
1301
+ assertWorkspaceLifecycleAvailable(workspaceId, launch?.lifecycleOwner);
1268
1302
  const workspace = getWs(workspaceId);
1269
1303
  if (!workspace)
1270
1304
  throw new Error(`Workspace '${workspaceId}' not found`);
@@ -1334,6 +1368,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1334
1368
  }
1335
1369
  // Persist before the asynchronous engine can emit its first event. This
1336
1370
  // survives a Kōbō restart and does not depend on SDK system:init delivery.
1371
+ activateSession(workspaceId, agentSessionId);
1337
1372
  captureTaskProgressBaseline(workspaceId, agentSessionId);
1338
1373
  const settings = ws ? readEffectiveSettingsSafe(ws.projectPath) : readEffectiveSettingsSafe(workingDir);
1339
1374
  const options = {
@@ -1355,12 +1390,14 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1355
1390
  }
1356
1391
  })(),
1357
1392
  settings,
1358
- mcpServers: buildMcpServers(workspaceId),
1393
+ mcpServers: buildMcpServers(workspaceId, launch?.mcpEnv),
1359
1394
  env: ws ? buildAgentEnv(ws.projectPath) : undefined,
1360
1395
  };
1361
1396
  beforeDispatch?.();
1362
1397
  let controller;
1363
1398
  controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, controller, ev));
1399
+ if (launch)
1400
+ launchOptions.set(controller, launch);
1364
1401
  registerSessionLifecycleOwner(workspaceId, agentSessionId, controller);
1365
1402
  if (!existingCtrl)
1366
1403
  controllers.set(workspaceId, controller);
@@ -1377,7 +1414,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1377
1414
  return;
1378
1415
  if (pendingStarts.get(workspaceId) === controller)
1379
1416
  pendingStarts.delete(workspaceId);
1380
- assertWorkspaceLifecycleAvailable(workspaceId);
1417
+ assertWorkspaceLifecycleAvailable(workspaceId, launch?.lifecycleOwner);
1381
1418
  const current = getWs(workspaceId);
1382
1419
  if (!current || current.archivedAt || current.worktreePurgedAt)
1383
1420
  throw new Error('Workspace is no longer available');
@@ -1387,7 +1424,11 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1387
1424
  controllers.set(workspaceId, controller);
1388
1425
  return controller.start(options);
1389
1426
  })
1390
- .then(() => {
1427
+ .then(async () => {
1428
+ if (launch?.onStarted)
1429
+ await controller.engineProcess?.ready;
1430
+ if (controller.status !== 'stopping')
1431
+ launch?.onStarted?.();
1391
1432
  const pid = controller.pid;
1392
1433
  if (pid !== undefined) {
1393
1434
  try {
@@ -1524,6 +1565,9 @@ async function raceWithTimeout(promise, timeoutMs, onTimeout) {
1524
1565
  export async function stopAgentAndWait(workspaceId, timeoutMs = STOP_AGENT_TIMEOUT_MS,
1525
1566
  /** Only `user` disables auto-loop; technical stops preserve its persisted intent. */
1526
1567
  cause = 'user') {
1568
+ const finishHandoffStop = ['user', 'delete', 'purge', 'archive'].includes(cause)
1569
+ ? requestHandoffStop(workspaceId)
1570
+ : undefined;
1527
1571
  const pending = pendingStarts.get(workspaceId);
1528
1572
  let pendingStop = Promise.resolve();
1529
1573
  if (pending) {
@@ -1544,7 +1588,8 @@ cause = 'user') {
1544
1588
  // Shutdown only suspends timers: persisted intent belongs to the next boot.
1545
1589
  const cancelSchedules = new Promise((resolve) => {
1546
1590
  if (cause !== 'shutdown') {
1547
- wakeupService.cancel(workspaceId, 'stopped');
1591
+ if (cause !== 'handoff')
1592
+ wakeupService.cancel(workspaceId, 'stopped');
1548
1593
  quotaBackoffService.cancel(workspaceId, 'user');
1549
1594
  }
1550
1595
  resolve();
@@ -1561,6 +1606,7 @@ cause = 'user') {
1561
1606
  if (outcome === 'timeout') {
1562
1607
  console.error(`[orchestrator] Agent '${workspaceId}' did not stop within ${timeoutMs}ms; retaining its controller`);
1563
1608
  }
1609
+ finishHandoffStop?.(outcome);
1564
1610
  return outcome;
1565
1611
  }
1566
1612
  /**
@@ -1584,6 +1630,7 @@ export async function stopAllAgents(timeoutMs = 3_000) {
1584
1630
  wakeupService.suspendForShutdown();
1585
1631
  cronService.suspendForShutdown();
1586
1632
  shuttingDown = true;
1633
+ suspendHandoffTransfers();
1587
1634
  if (capacityTimer)
1588
1635
  clearTimeout(capacityTimer);
1589
1636
  capacityTimer = undefined;
@@ -1603,6 +1650,8 @@ export function isShuttingDown() {
1603
1650
  }
1604
1651
  /** Deliver a scheduled check without stopping background work or cancelling the wakeup as user input. */
1605
1652
  export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
1653
+ if (workspaceLifecycleReason(workspaceId) === 'session-handoff')
1654
+ return false;
1606
1655
  const ctrl = controllers.get(workspaceId);
1607
1656
  if (ctrl?.status !== 'running')
1608
1657
  return false;
@@ -1620,6 +1669,7 @@ export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
1620
1669
  }
1621
1670
  /** Write a user message to the running agent. */
1622
1671
  export async function sendMessage(workspaceId, content, expectedSessionId, beforeDispatch) {
1672
+ assertWorkspaceLifecycleAvailable(workspaceId);
1623
1673
  assertWorkspaceNotCompacting(workspaceId);
1624
1674
  const ctrl = controllers.get(workspaceId);
1625
1675
  if (!ctrl) {
@@ -1633,6 +1683,7 @@ export async function sendMessage(workspaceId, content, expectedSessionId, befor
1633
1683
  await ctrl.sendMessage(content);
1634
1684
  }
1635
1685
  export async function sendMessageForFallback(workspaceId, content) {
1686
+ assertWorkspaceLifecycleAvailable(workspaceId);
1636
1687
  assertWorkspaceNotCompacting(workspaceId);
1637
1688
  const capturedController = controllers.get(workspaceId);
1638
1689
  wakeupService.cancel(workspaceId, 'user-message');
@@ -1643,6 +1694,7 @@ export async function sendMessageForFallback(workspaceId, content) {
1643
1694
  return { status: 'sent', sessionId: capturedController.agentSessionId };
1644
1695
  }
1645
1696
  catch {
1697
+ assertWorkspaceLifecycleAvailable(workspaceId);
1646
1698
  assertWorkspaceNotCompacting(workspaceId);
1647
1699
  const deadline = Date.now() + FALLBACK_CONTROLLER_TURNOVER_TIMEOUT_MS;
1648
1700
  while (controllers.get(workspaceId) === capturedController) {
@@ -1657,6 +1709,7 @@ export async function sendMessageForFallback(workspaceId, content) {
1657
1709
  const replacementController = controllers.get(workspaceId);
1658
1710
  if (!replacementController)
1659
1711
  return { status: 'stopped' };
1712
+ assertWorkspaceLifecycleAvailable(workspaceId);
1660
1713
  assertWorkspaceNotCompacting(workspaceId);
1661
1714
  await replacementController.sendMessage(content);
1662
1715
  return { status: 'sent', sessionId: replacementController.agentSessionId };
@@ -8,13 +8,13 @@ function runGit(worktreePath, args) {
8
8
  return '';
9
9
  }
10
10
  }
11
- function recentConversation(workspaceId) {
11
+ function recentConversation(workspaceId, sessionId) {
12
12
  const rows = getDb()
13
13
  .prepare(`SELECT type, payload FROM ws_events
14
- WHERE workspace_id = ?
14
+ WHERE workspace_id = ? AND (? IS NULL OR session_id = ?)
15
15
  AND (type = 'user:message' OR (type = 'agent:event' AND json_extract(payload, '$.kind') = 'message:text'))
16
16
  ORDER BY rowid DESC LIMIT 6`)
17
- .all(workspaceId);
17
+ .all(workspaceId, sessionId ?? null, sessionId ?? null);
18
18
  return rows
19
19
  .reverse()
20
20
  .flatMap((row) => {
@@ -30,12 +30,12 @@ function recentConversation(workspaceId) {
30
30
  .join('\n\n');
31
31
  }
32
32
  /** Build a deterministic, secret-free handoff for a fresh session on another engine. */
33
- export function buildEngineHandoff(workspace, sourceEngine, targetEngine) {
33
+ export function buildEngineHandoff(workspace, sourceEngine, targetEngine, sourceSessionId) {
34
34
  const taskLines = workspace.tasks.length
35
35
  ? workspace.tasks.map((task) => `- [${task.status === 'done' ? 'x' : ' '}] ${task.title}`).join('\n')
36
36
  : '- No Kōbō task recorded.';
37
37
  const changedFiles = runGit(workspace.worktreePath, 'status --short') || 'No uncommitted changes detected.';
38
38
  const diffStat = runGit(workspace.worktreePath, 'diff --stat') || 'No uncommitted diff stat available.';
39
- const history = recentConversation(workspace.id) || 'No recent conversation message available.';
39
+ const history = recentConversation(workspace.id, sourceSessionId) || 'No recent conversation message available.';
40
40
  return `# Kōbō engine handoff\n\nYou are taking over this workspace from ${sourceEngine} using ${targetEngine}. Work in the existing worktree and verify the repository state before making changes.\n\n## Objective\n${workspace.description ?? workspace.name}\n\n## Tasks\n${taskLines}\n\n## Git state\n- Branch: ${workspace.workingBranch}\n- Base: ${workspace.sourceBranch}\n- Worktree: ${workspace.worktreePath}\n\nChanged files:\n\`\`\`\n${changedFiles}\n\`\`\`\n\nDiff summary:\n\`\`\`\n${diffStat}\n\`\`\`\n\n## Recent conversation\n${history}\n\n## Recover more context\nIf this handoff is insufficient, use \`kobo__read_workspace_events_csv\` to read the previous workspace conversation in pages (optionally filter by \`session_id\`). Use \`kobo__search_codebase\` to find a precise past decision or user request. Read only the context needed, then continue the task.\n`;
41
41
  }
@@ -0,0 +1,17 @@
1
+ import { getDb } from '../db/index.js';
2
+ // Preserve legacy last-use ordering until a session is started or restored.
3
+ export const SESSION_RECENCY_ORDER = 'activation_order DESC, MAX(started_at, COALESCE(ended_at, started_at)) DESC, started_at DESC';
4
+ /** Make a conversation current without changing its execution history or starting an agent. */
5
+ export function activateSession(workspaceId, sessionId) {
6
+ getDb()
7
+ .prepare(`UPDATE agent_sessions SET activation_order =
8
+ (SELECT COALESCE(MAX(activation_order), 0) + 1 FROM agent_sessions WHERE workspace_id = ? AND activation_order >= 0)
9
+ WHERE workspace_id = ? AND id = ? AND status != 'idle'`)
10
+ .run(workspaceId, workspaceId, sessionId);
11
+ }
12
+ /** Keep a rolled-back target in history without selecting or resuming it implicitly. */
13
+ export function deactivateSession(workspaceId, sessionId) {
14
+ getDb()
15
+ .prepare("UPDATE agent_sessions SET activation_order = -1 WHERE workspace_id = ? AND id = ? AND status != 'running'")
16
+ .run(workspaceId, sessionId);
17
+ }
@@ -0,0 +1,26 @@
1
+ /** Decouples the orchestrator's explicit Stop path from the transfer service. */
2
+ const handlers = new Map();
3
+ export function registerHandoffStopHandler(workspaceId, handler) {
4
+ handlers.set(workspaceId, handler);
5
+ return () => {
6
+ if (handlers.get(workspaceId) === handler)
7
+ handlers.delete(workspaceId);
8
+ };
9
+ }
10
+ export function requestHandoffStop(workspaceId) {
11
+ const handler = handlers.get(workspaceId);
12
+ handler?.requested();
13
+ if (!handler)
14
+ return undefined;
15
+ let settled = false;
16
+ return (outcome) => {
17
+ if (settled)
18
+ return;
19
+ settled = true;
20
+ handler.settled(outcome);
21
+ };
22
+ }
23
+ export function suspendHandoffTransfers() {
24
+ for (const handler of handlers.values())
25
+ handler.shutdown();
26
+ }