@loicngr/kobo 1.11.43 → 1.11.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/AGENTS.md +74 -1
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +13 -1
  4. package/dist/mcp-server/kobo-tasks-handlers.js +33 -75
  5. package/dist/mcp-server/kobo-tasks-server.js +82 -9
  6. package/dist/server/db/auto-loop-schema.js +44 -0
  7. package/dist/server/db/migrations.js +18 -0
  8. package/dist/server/db/review-return-schema.js +10 -0
  9. package/dist/server/db/schema.js +8 -1
  10. package/dist/server/db/session-handoff-schema.js +24 -0
  11. package/dist/server/index.js +12 -9
  12. package/dist/server/routes/auto-loop-messages.js +51 -0
  13. package/dist/server/routes/documents.js +1 -1
  14. package/dist/server/routes/workspaces.js +119 -145
  15. package/dist/server/services/agent/engines/claude-code/engine.js +16 -0
  16. package/dist/server/services/agent/engines/codex/engine.js +92 -31
  17. package/dist/server/services/agent/orchestrator.js +298 -96
  18. package/dist/server/services/agent/session-controller.js +42 -2
  19. package/dist/server/services/auto-loop-message-service.js +79 -0
  20. package/dist/server/services/auto-loop-service.js +225 -86
  21. package/dist/server/services/auto-loop-state-service.js +30 -0
  22. package/dist/server/services/cron-service.js +30 -16
  23. package/dist/server/services/engine-handoff-service.js +5 -5
  24. package/dist/server/services/quota-backoff-service.js +42 -10
  25. package/dist/server/services/review-return-service.js +64 -0
  26. package/dist/server/services/review-service.js +187 -0
  27. package/dist/server/services/session-activity-service.js +17 -0
  28. package/dist/server/services/session-handoff-runtime.js +26 -0
  29. package/dist/server/services/session-handoff-service.js +479 -0
  30. package/dist/server/services/settings-service.js +3 -3
  31. package/dist/server/services/task-mutations.js +186 -0
  32. package/dist/server/services/wakeup-service.js +24 -1
  33. package/dist/server/services/workspace-message-service.js +16 -2
  34. package/dist/server/services/workspace-service.js +28 -36
  35. package/dist/server/utils/workspace-lifecycle-guard.js +35 -20
  36. package/dist/shared/auto-loop-prompts.js +22 -11
  37. package/dist/shared/auto-loop-types.js +1 -0
  38. package/dist/shared/document-roots.js +2 -0
  39. package/dist/shared/review.js +6 -0
  40. package/dist/shared/session-handoff.js +4 -0
  41. package/dist/shared/task-verification.js +11 -0
  42. package/package.json +12 -12
  43. package/src/client/dist/pwa/assets/ActivityFeed-CLXjdqS8.js +8 -0
  44. package/src/client/dist/pwa/assets/{ChangelogPage-DX1WGFea.js → ChangelogPage-CnXTnFhe.js} +1 -1
  45. package/src/client/dist/pwa/assets/{ClosePopup-BqW5dy-O.js → ClosePopup-CWjIRHxu.js} +1 -1
  46. package/src/client/dist/pwa/assets/{ComparisonPanel-B4ne55Xj.js → ComparisonPanel-CKylN45q.js} +2 -2
  47. package/src/client/dist/pwa/assets/CreatePage-SoCcvxuD.js +2 -0
  48. package/src/client/dist/pwa/assets/{DashboardPage-Cv6HJEcO.js → DashboardPage-CZQLfoV1.js} +1 -1
  49. package/src/client/dist/pwa/assets/{DiffViewer-COwGxPt6.js → DiffViewer-Csf3WmLc.js} +4 -4
  50. package/src/client/dist/pwa/assets/{DocumentsPanel-DCXpLLb7.js → DocumentsPanel-BeCttB-x.js} +1 -1
  51. package/src/client/dist/pwa/assets/{DrawerToggleButton-DRL3RCkM.js → DrawerToggleButton-BJBBYeOc.js} +1 -1
  52. package/src/client/dist/pwa/assets/GitPanel-B-ZHgghQ.js +3 -0
  53. package/src/client/dist/pwa/assets/{HealthPage-XrOuMoDJ.js → HealthPage-l88TEk-k.js} +1 -1
  54. package/src/client/dist/pwa/assets/MainLayout-BAAQLq95.css +1 -0
  55. package/src/client/dist/pwa/assets/MainLayout-Cf4Q9x1u.js +2 -0
  56. package/src/client/dist/pwa/assets/{QBadge-DbkxGtht.js → QBadge-FWC1zDCq.js} +1 -1
  57. package/src/client/dist/pwa/assets/{QBanner-DhqH95GW.js → QBanner-CcRI_QIy.js} +1 -1
  58. package/src/client/dist/pwa/assets/QBtn-DEdx_Y1V.js +1 -0
  59. package/src/client/dist/pwa/assets/{QBtnDropdown-163oM6T_.js → QBtnDropdown-44Mj-y_1.js} +1 -1
  60. package/src/client/dist/pwa/assets/{QBtnGroup-DELKYo3u.js → QBtnGroup-CAoD6G0Q.js} +1 -1
  61. package/src/client/dist/pwa/assets/{QBtnToggle-BI3k3Ch2.js → QBtnToggle-C_hQ2UCQ.js} +1 -1
  62. package/src/client/dist/pwa/assets/{QChip-owciRuZu.js → QChip-DCPUhqXX.js} +1 -1
  63. package/src/client/dist/pwa/assets/QDrawer-B4qHw8wt.js +1 -0
  64. package/src/client/dist/pwa/assets/QExpansionItem-Bok1m8Wi.js +1 -0
  65. package/src/client/dist/pwa/assets/{QItemSection-D_5_0UrX.js → QItemSection-BhqphaH6.js} +1 -1
  66. package/src/client/dist/pwa/assets/{QList-qupzuEqO.js → QList-CHm4JNP0.js} +1 -1
  67. package/src/client/dist/pwa/assets/{QMarkupTable-B9QuKtK_.js → QMarkupTable-CNpiA9oq.js} +1 -1
  68. package/src/client/dist/pwa/assets/{QPage-DaX9sMQC.js → QPage-BXBw2Alx.js} +1 -1
  69. package/src/client/dist/pwa/assets/QSelect-BbtD5Iks.js +1 -0
  70. package/src/client/dist/pwa/assets/{QSpinnerDots-DnCV3VzI.js → QSpinnerDots-BkFiCsH3.js} +1 -1
  71. package/src/client/dist/pwa/assets/{QTooltip-BQWY7aeM.js → QTooltip-BDihn2aY.js} +1 -1
  72. package/src/client/dist/pwa/assets/SchedulePanel-DiWCDNZ8.js +1 -0
  73. package/src/client/dist/pwa/assets/{SearchPage-DOHkP5lj.js → SearchPage-BwZ2TX1G.js} +1 -1
  74. package/src/client/dist/pwa/assets/SettingsPage-BUh0WfNf.js +9 -0
  75. package/src/client/dist/pwa/assets/{SplitWorkspacePage-DL4kp9Ah.js → SplitWorkspacePage-Bg6nKBcA.js} +1 -1
  76. package/src/client/dist/pwa/assets/{TerminalPanel-DHDnyiYh.js → TerminalPanel-Cq-FR7A7.js} +9 -9
  77. package/src/client/dist/pwa/assets/{TouchPan-BnHXDR6F.js → TouchPan-BQ1cvzRC.js} +1 -1
  78. package/src/client/dist/pwa/assets/{TourReplayButton-ClVcB6mO.js → TourReplayButton-Dz1yEz_z.js} +1 -1
  79. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-Bxe6oIJI.js → WorkspaceAttentionLabels-BCVOP8vp.js} +1 -1
  80. package/src/client/dist/pwa/assets/WorkspacePage-BbUvR1Vf.css +1 -0
  81. package/src/client/dist/pwa/assets/WorkspacePage-Cl8gap4b.js +4 -0
  82. package/src/client/dist/pwa/assets/{browser-Bkx4fbxl.js → browser-BmWgBx9M.js} +1 -1
  83. package/src/client/dist/pwa/assets/build-path-tree-6EjBmYYY.js +1 -0
  84. package/src/client/dist/pwa/assets/de-COObokpu.js +17 -0
  85. package/src/client/dist/pwa/assets/documents-BZ8bwcjr.js +1 -0
  86. package/src/client/dist/pwa/assets/{editor.api-DreEG0If.js → editor.api-CK7--ckY.js} +1 -1
  87. package/src/client/dist/pwa/assets/editor.worker-iO5iN0DB.js +33 -0
  88. package/src/client/dist/pwa/assets/efforts-Bw0o2Bud.js +9 -0
  89. package/src/client/dist/pwa/assets/engineFeatures-B-9jHocY.css +1 -0
  90. package/src/client/dist/pwa/assets/engineFeatures-DQO9HHXI.js +1 -0
  91. package/src/client/dist/pwa/assets/es-By2Q9Tca.js +17 -0
  92. package/src/client/dist/pwa/assets/expand-template-C8VyjASo.js +1 -0
  93. package/src/client/dist/pwa/assets/{format-B7z3F-Mw.js → format-LtTLLitJ.js} +3 -3
  94. package/src/client/dist/pwa/assets/{formatters-BTBiU0v3.js → formatters-Bl9zA7Ai.js} +1 -1
  95. package/src/client/dist/pwa/assets/fr-D-sd0ddn.js +17 -0
  96. package/src/client/dist/pwa/assets/{freemarker2-BZNTVQ3K.js → freemarker2-DxezMaje.js} +1 -1
  97. package/src/client/dist/pwa/assets/{handlebars-k8tN0dI3.js → handlebars-DK_Bj6Rx.js} +1 -1
  98. package/src/client/dist/pwa/assets/{html-iZL3bbXq.js → html-DFfsfStD.js} +1 -1
  99. package/src/client/dist/pwa/assets/i18n-aDWy4KHt.js +1 -0
  100. package/src/client/dist/pwa/assets/index-0wtBh1Xt.css +1 -0
  101. package/src/client/dist/pwa/assets/index-BLd8DAzg.js +20 -0
  102. package/src/client/dist/pwa/assets/it-CeK5lyIA.js +17 -0
  103. package/src/client/dist/pwa/assets/{javascript-TV6gg0MO.js → javascript-DuNbcaL7.js} +1 -1
  104. package/src/client/dist/pwa/assets/{liquid-Cifa5ujF.js → liquid-CM3JKyhv.js} +1 -1
  105. package/src/client/dist/pwa/assets/{mdx-Bnj_8yGE.js → mdx-BG3zYwNF.js} +1 -1
  106. package/src/client/dist/pwa/assets/{monaco-contributions-pEh0n9Wk.js → monaco-contributions-DLscZGH-.js} +1 -1
  107. package/src/client/dist/pwa/assets/monaco.contribution-DnhZHwXT.js +2 -0
  108. package/src/client/dist/pwa/assets/{network-auth-3_5p4fdc.js → network-auth-BJiPbO5l.js} +1 -1
  109. package/src/client/dist/pwa/assets/{notify-theme-D_nf9AeF.js → notify-theme-D4p3qPkp.js} +1 -1
  110. package/src/client/dist/pwa/assets/{pending-diff-open-CUnxC1xw.js → pending-diff-open-dYIFdS4r.js} +1 -1
  111. package/src/client/dist/pwa/assets/permissionModes-B7fuUegU.js +36 -0
  112. package/src/client/dist/pwa/assets/{pinia-BKqmoVru.js → pinia-CTJ3OfP3.js} +1 -1
  113. package/src/client/dist/pwa/assets/{private.use-form-C7umbGO-.js → private.use-form-Bk8ti264.js} +1 -1
  114. package/src/client/dist/pwa/assets/{project-color-CihwpDAA.js → project-color-R_1je4vW.js} +1 -1
  115. package/src/client/dist/pwa/assets/{python-DOoNBSNL.js → python-COs6pHk2.js} +1 -1
  116. package/src/client/dist/pwa/assets/{razor-Bvq5vVpJ.js → razor-Jl1VqMsk.js} +1 -1
  117. package/src/client/dist/pwa/assets/reactivity.esm-bundler-Bo9anJjg.js +1 -0
  118. package/src/client/dist/pwa/assets/render-chat-markdown-AaOo0Tgq.js +67 -0
  119. package/src/client/dist/pwa/assets/{runtime-core.esm-bundler-CDnVUfeZ.js → runtime-core.esm-bundler-CNFKm4ij.js} +1 -1
  120. package/src/client/dist/pwa/assets/{typescript-VVzxoeyM.js → typescript-BU_TUuAM.js} +1 -1
  121. package/src/client/dist/pwa/assets/{use-checkbox-FnauAgTp.js → use-checkbox-CnvHBhSK.js} +1 -1
  122. package/src/client/dist/pwa/assets/{use-id-BIuaWKkB.js → use-id-wXd4c8FA.js} +1 -1
  123. package/src/client/dist/pwa/assets/use-is-mobile-DpKI3XMy.js +1 -0
  124. package/src/client/dist/pwa/assets/{use-search-index-status-DtLzygcy.js → use-search-index-status-Bluf9L99.js} +1 -1
  125. package/src/client/dist/pwa/assets/use-slide-transition-CiaDm-tR.js +1 -0
  126. package/src/client/dist/pwa/assets/use-tours-CVn874kD.js +2 -0
  127. package/src/client/dist/pwa/assets/{workspace-templates-1ui80Kqo.js → workspace-templates-BCKR8TXr.js} +1 -1
  128. package/src/client/dist/pwa/assets/{xml-CzGFIxQa.js → xml-gFFsobxE.js} +1 -1
  129. package/src/client/dist/pwa/assets/{yaml-Bl7nDJeC.js → yaml-DaXaqaGD.js} +1 -1
  130. package/src/client/dist/pwa/index.html +10 -10
  131. package/src/client/dist/pwa/sw.js +1 -1
  132. package/src/mcp-server/README.md +8 -1
  133. package/src/mcp-server/kobo-tasks-handlers.ts +72 -105
  134. package/src/mcp-server/kobo-tasks-server.ts +94 -9
  135. package/src/client/dist/pwa/assets/ActivityFeed-D1e-zV3O.js +0 -8
  136. package/src/client/dist/pwa/assets/CreatePage-Bx_GpUni.js +0 -2
  137. package/src/client/dist/pwa/assets/GitPanel-C9mz4GXF.js +0 -3
  138. package/src/client/dist/pwa/assets/MainLayout-BwV89R-l.css +0 -1
  139. package/src/client/dist/pwa/assets/MainLayout-CwWavQjV.js +0 -2
  140. package/src/client/dist/pwa/assets/QBtn-CZaBclNw.js +0 -1
  141. package/src/client/dist/pwa/assets/QDrawer-DNEj8wLZ.js +0 -1
  142. package/src/client/dist/pwa/assets/QSelect-BUlOQe5k.js +0 -1
  143. package/src/client/dist/pwa/assets/SchedulePanel-Bos94OyR.js +0 -1
  144. package/src/client/dist/pwa/assets/SettingsPage-DtGsd0XH.js +0 -9
  145. package/src/client/dist/pwa/assets/WorkspacePage-Clc4Fl3B.js +0 -4
  146. package/src/client/dist/pwa/assets/WorkspacePage-DCg9zSxz.css +0 -1
  147. package/src/client/dist/pwa/assets/build-path-tree-BpSY7N5O.js +0 -1
  148. package/src/client/dist/pwa/assets/de-BU0dILBs.js +0 -17
  149. package/src/client/dist/pwa/assets/documents-BIlWh3o-.js +0 -1
  150. package/src/client/dist/pwa/assets/editor.worker-CwQAd_xr.js +0 -30
  151. package/src/client/dist/pwa/assets/efforts-D_yTzymv.js +0 -9
  152. package/src/client/dist/pwa/assets/engineFeatures-C33LTVkK.js +0 -1
  153. package/src/client/dist/pwa/assets/es-CNp0s9na.js +0 -17
  154. package/src/client/dist/pwa/assets/expand-template-wRhAXT-1.js +0 -1
  155. package/src/client/dist/pwa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
  156. package/src/client/dist/pwa/assets/fr-RqJyWSdm.js +0 -17
  157. package/src/client/dist/pwa/assets/i18n--PCet00a.js +0 -1
  158. package/src/client/dist/pwa/assets/index-CPfrY38K.js +0 -20
  159. package/src/client/dist/pwa/assets/index-CUoUy6tH.css +0 -1
  160. package/src/client/dist/pwa/assets/it-DSxNuCwL.js +0 -17
  161. package/src/client/dist/pwa/assets/monaco.contribution-D7NmJUJH.js +0 -2
  162. package/src/client/dist/pwa/assets/permissionModes-DC51Pz84.js +0 -36
  163. package/src/client/dist/pwa/assets/reactivity.esm-bundler-DEJj9cFv.js +0 -1
  164. package/src/client/dist/pwa/assets/render-chat-markdown-BQ_saiy5.js +0 -67
  165. package/src/client/dist/pwa/assets/use-is-mobile-CWjqe8gC.js +0 -1
  166. package/src/client/dist/pwa/assets/use-tours-BKeYrJaQ.js +0 -2
@@ -3,13 +3,17 @@ 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
+ import { listLoopMessages, settleLoopMessages } from '../auto-loop-message-service.js';
7
8
  import * as autoLoopService from '../auto-loop-service.js';
8
9
  import * as cleanupScriptService from '../cleanup-script-service.js';
9
10
  import * as cronService from '../cron-service.js';
10
11
  import { resolveForge } from '../forge/resolve.js';
11
12
  import * as lifecycleHookService from '../lifecycle-hook-service.js';
12
13
  import * as quotaBackoffService from '../quota-backoff-service.js';
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';
13
17
  import { getEffectiveSettings, getGlobalSettings } from '../settings-service.js';
14
18
  import { refreshNow } from '../usage/poller.js';
15
19
  import * as wakeupService from '../wakeup-service.js';
@@ -41,6 +45,8 @@ export function getBackendPort() {
41
45
  }
42
46
  /** workspaceId -> SessionController */
43
47
  const controllers = new Map();
48
+ const pendingTerminalControllers = new WeakSet();
49
+ const finalizedControllers = new WeakSet();
44
50
  /** Replacements waiting for the previous controller to release its worktree. */
45
51
  const pendingStarts = new Map();
46
52
  let shuttingDown = false;
@@ -261,6 +267,10 @@ let availableSkills = (() => {
261
267
  })();
262
268
  /** workspaceId -> retry count (for quota backoff) */
263
269
  const retryCounts = new Map();
270
+ const retryReasons = new Map();
271
+ const quotaLookups = new Map();
272
+ /** Multiple error surfaces and the terminal watchdog can describe one incident. */
273
+ const transientRecoveryControllers = new WeakSet();
264
274
  /** Tracks agent sessions that failed due to a stale --resume session ID. */
265
275
  const resumeFailedSessions = new Map();
266
276
  // ── Watchdog ──────────────────────────────────────────────────────────────────
@@ -285,7 +295,7 @@ function runWatchdog() {
285
295
  // window) must not be raced here: doing so would report a false "Agent
286
296
  // process died unexpectedly", force the workspace to `error`, and set an
287
297
  // unread badge — all on a stop the user explicitly asked for.
288
- if (ctrl.status === 'stopping')
298
+ if (ctrl.status === 'stopping' || pendingTerminalControllers.has(ctrl))
289
299
  continue;
290
300
  const ep = ctrl.engineProcess;
291
301
  if (!ep) {
@@ -294,6 +304,17 @@ function runWatchdog() {
294
304
  if (Date.now() - ctrl.startedAt < CONTROLLER_STARTUP_GRACE_MS)
295
305
  continue;
296
306
  console.error(`[watchdog] Controller for workspace '${workspaceId}' produced no engine process within ${CONTROLLER_STARTUP_GRACE_MS}ms — cleaning up`);
307
+ // The start may still complete and acquire a process. Keep ownership
308
+ // until its stop is confirmed instead of dispatching a second writer.
309
+ void stopAgentAndWait(workspaceId, STOP_AGENT_TIMEOUT_MS, 'replacement').then((outcome) => {
310
+ if (outcome === 'timeout' || outcome === 'failed') {
311
+ autoLoopService.block(workspaceId, 'engine-start-timeout');
312
+ }
313
+ else if (getWs(workspaceId)?.autoLoop && !shuttingDown) {
314
+ scheduleTransientAutoLoopRecovery(workspaceId, ctrl);
315
+ }
316
+ });
317
+ continue;
297
318
  }
298
319
  else {
299
320
  // D1 — the pid is never a liveness criterion. The Claude engine exposes
@@ -304,50 +325,23 @@ function runWatchdog() {
304
325
  continue;
305
326
  console.error(`[watchdog] Agent engine for workspace '${workspaceId}' reports dead — cleaning up`);
306
327
  }
307
- // Emit an error + session:ended AgentEvent pair so clients can react uniformly
328
+ // Use the same terminal path as an engine watchdog: it preserves the
329
+ // loop's retry intent and releases capacity only after actual closure.
308
330
  try {
309
- routeEvent(workspaceId, ctrl.agentSessionId, {
331
+ handleEvent(workspaceId, ctrl.agentSessionId, ctrl, {
310
332
  kind: 'error',
311
333
  category: 'other',
312
334
  message: 'Agent process died unexpectedly',
313
335
  });
314
- routeEvent(workspaceId, ctrl.agentSessionId, {
336
+ handleEvent(workspaceId, ctrl.agentSessionId, ctrl, {
315
337
  kind: 'session:ended',
316
- reason: 'killed',
338
+ reason: getWs(workspaceId)?.autoLoop ? 'watchdog' : 'error',
317
339
  exitCode: null,
318
340
  });
319
341
  }
320
342
  catch (err) {
321
- console.warn('[watchdog] Failed to route death notification events:', err);
322
- }
323
- if (controllers.get(workspaceId) === ctrl) {
324
- controllers.delete(workspaceId);
325
- notifyCapacityAvailable(workspaceId);
326
- }
327
- preCompactionStatus.delete(ctrl);
328
- retryCounts.delete(workspaceId);
329
- // This end never goes through handleEvent → onSessionEnded, so the user's
330
- // hook would otherwise miss precisely the case it is most useful for.
331
- fireSessionEndedHook(workspaceId, ctrl.agentSessionId, 'killed', null, ctrl);
332
- try {
333
- const db = getDb();
334
- db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ? WHERE id = ?').run('error', new Date().toISOString(), ctrl.agentSessionId);
335
- }
336
- catch (err) {
337
- console.error('[watchdog] Failed to update agent_sessions:', err);
338
- }
339
- try {
340
- updateWorkspaceStatus(workspaceId, 'error');
341
- }
342
- catch (err) {
343
- console.warn('[watchdog] Failed to transition workspace to error (likely invalid transition):', err);
344
- }
345
- try {
346
- markWorkspaceUnread(workspaceId);
347
- emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
348
- }
349
- catch (err) {
350
- console.warn('[watchdog] Failed to mark workspace unread:', err);
343
+ console.warn('[watchdog] Failed to recover a dead agent:', err);
344
+ autoLoopService.block(workspaceId, 'engine-recovery-failed');
351
345
  }
352
346
  }
353
347
  }
@@ -482,7 +476,7 @@ function readEffectiveSettingsSafe(projectPath) {
482
476
  };
483
477
  }
484
478
  }
485
- function buildMcpServers(workspaceId) {
479
+ function buildMcpServers(workspaceId, extraEnv) {
486
480
  const mcpServerCompiled = getCompiledMcpServerPath();
487
481
  const mcpServerSource = getMcpServerSourcePath();
488
482
  return [
@@ -492,6 +486,7 @@ function buildMcpServers(workspaceId) {
492
486
  args: mcpServerCompiled ? [mcpServerCompiled] : ['tsx', mcpServerSource],
493
487
  env: {
494
488
  KOBO_WORKSPACE_ID: workspaceId,
489
+ ...extraEnv,
495
490
  KOBO_DB_PATH: getDbPath(),
496
491
  KOBO_SETTINGS_PATH: getSettingsPath(),
497
492
  KOBO_BACKEND_URL: `http://127.0.0.1:${backendPort}`,
@@ -531,7 +526,7 @@ function buildAgentPrompt(prompt, projectPath) {
531
526
  prompt,
532
527
  ].join('\n');
533
528
  }
534
- function resolveSessionForResume(workspaceId, existingSessionId, model) {
529
+ function resolveSessionForResume(workspaceId, existingSessionId, model, engineId) {
535
530
  const db = getDb();
536
531
  let lastSession;
537
532
  if (existingSessionId) {
@@ -542,13 +537,18 @@ function resolveSessionForResume(workspaceId, existingSessionId, model) {
542
537
  throw new Error(`Cannot resume session '${existingSessionId}' for workspace '${workspaceId}': ` +
543
538
  'session not found or has no associated engine conversation');
544
539
  }
540
+ if ((lastSession.engine ?? 'claude-code') !== engineId) {
541
+ throw new Error(`Cannot resume session '${existingSessionId}' with engine '${engineId}': the conversation belongs to '${lastSession.engine}'`);
542
+ }
545
543
  }
546
544
  else {
547
545
  lastSession = db
548
- .prepare('SELECT id, engine_session_id, engine FROM agent_sessions WHERE workspace_id = ? AND engine_session_id IS NOT NULL ORDER BY started_at DESC LIMIT 1')
549
- .get(workspaceId);
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`)
547
+ .get(workspaceId, engineId);
550
548
  }
551
- const engineSessionId = lastSession?.engine_session_id ?? (existingSessionId ? undefined : sessionIds.get(workspaceId));
549
+ // A workspace can now contain conversations from multiple engines. The
550
+ // unscoped in-memory ID may belong to the reviewer, so only trust its DB row.
551
+ const engineSessionId = lastSession?.engine_session_id;
552
552
  if (engineSessionId) {
553
553
  const existingId = lastSession?.id ??
554
554
  db
@@ -615,6 +615,10 @@ function captureTaskProgressBaseline(workspaceId, agentSessionId) {
615
615
  getDb()
616
616
  .prepare('UPDATE agent_sessions SET task_progress_baseline = ? WHERE id = ?')
617
617
  .run(JSON.stringify(snapshot), agentSessionId);
618
+ const baseline = JSON.parse(snapshot.stateSignature || '[]');
619
+ const insert = getDb().prepare('INSERT OR IGNORE INTO auto_loop_progress (workspace_id, task_id, milestone) VALUES (?, ?, ?)');
620
+ for (const task of baseline)
621
+ insert.run(workspaceId, task.id, taskMilestone(task.status));
618
622
  }
619
623
  catch (err) {
620
624
  console.warn('[orchestrator] Failed to persist task progress baseline:', err);
@@ -687,7 +691,7 @@ function getTaskStateSignature(workspaceId) {
687
691
  try {
688
692
  const db = getDb();
689
693
  const rows = db
690
- .prepare('SELECT id, status, updated_at FROM tasks WHERE workspace_id = ? ORDER BY id')
694
+ .prepare('SELECT id, status FROM tasks WHERE workspace_id = ? ORDER BY id')
691
695
  .all(workspaceId);
692
696
  return JSON.stringify(rows);
693
697
  }
@@ -696,6 +700,42 @@ function getTaskStateSignature(workspaceId) {
696
700
  return '';
697
701
  }
698
702
  }
703
+ function taskMilestone(status) {
704
+ return status === 'done' ? 2 : status === 'in_progress' ? 1 : 0;
705
+ }
706
+ /** Credit each forward task milestone only once during the current loop cycle. */
707
+ function consumeMonotoneTaskProgress(workspaceId, snapshot) {
708
+ if (!snapshot)
709
+ return 0;
710
+ try {
711
+ const before = new Map(JSON.parse(snapshot.stateSignature || '[]').map((task) => [
712
+ task.id,
713
+ taskMilestone(task.status),
714
+ ]));
715
+ const db = getDb();
716
+ return db.transaction(() => {
717
+ const rows = db
718
+ .prepare(`SELECT tasks.id, tasks.status, COALESCE(auto_loop_progress.milestone, 0) AS milestone
719
+ FROM tasks LEFT JOIN auto_loop_progress ON auto_loop_progress.workspace_id = tasks.workspace_id
720
+ AND auto_loop_progress.task_id = tasks.id WHERE tasks.workspace_id = ?`)
721
+ .all(workspaceId);
722
+ const record = db.prepare(`INSERT INTO auto_loop_progress (workspace_id, task_id, milestone) VALUES (?, ?, ?)
723
+ ON CONFLICT(workspace_id, task_id) DO UPDATE SET milestone = MAX(milestone, excluded.milestone)`);
724
+ let progressed = false;
725
+ for (const task of rows) {
726
+ const milestone = taskMilestone(task.status);
727
+ if (milestone > (before.get(task.id) ?? 0) && milestone > task.milestone)
728
+ progressed = true;
729
+ record.run(workspaceId, task.id, milestone);
730
+ }
731
+ return progressed ? 1 : 0;
732
+ })();
733
+ }
734
+ catch (err) {
735
+ console.warn('[orchestrator] Failed to record monotone task progress:', err);
736
+ return 0;
737
+ }
738
+ }
699
739
  /** Clear the in-memory done-count snapshot for a workspace (called on delete). */
700
740
  export function forgetTasksDoneSnapshot(workspaceId) {
701
741
  taskProgressSnapshots.delete(workspaceId);
@@ -732,7 +772,42 @@ export function forgetSessionId(workspaceId) {
732
772
  sessionIds.delete(workspaceId);
733
773
  sessionLifecycleOwners.delete(workspaceId);
734
774
  }
735
- function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
775
+ function handleEvent(workspaceId, agentSessionId, sourceController, ev, terminalAfterClose = false) {
776
+ if (sourceController && !terminalAfterClose) {
777
+ if (ev.kind === 'session:ended') {
778
+ if (pendingTerminalControllers.has(sourceController) || finalizedControllers.has(sourceController))
779
+ return;
780
+ // Real engines may announce their logical result before their writer
781
+ // exits. Delay all terminal effects, including review return and quota
782
+ // retries, until SessionController confirms closure.
783
+ if (!sourceController.isClosed && (sourceController.engineProcess?.closed || !sourceController.engineProcess)) {
784
+ pendingTerminalControllers.add(sourceController);
785
+ const closeDeadline = setTimeout(() => {
786
+ if (controllers.get(workspaceId) === sourceController && !sourceController.isClosed) {
787
+ autoLoopService.block(workspaceId, 'engine-stop-unconfirmed');
788
+ }
789
+ }, STOP_AGENT_TIMEOUT_MS);
790
+ closeDeadline.unref?.();
791
+ void sourceController.closed
792
+ .then(() => {
793
+ clearTimeout(closeDeadline);
794
+ pendingTerminalControllers.delete(sourceController);
795
+ finalizedControllers.add(sourceController);
796
+ handleEvent(workspaceId, agentSessionId, sourceController, ev, true);
797
+ })
798
+ .catch((err) => {
799
+ clearTimeout(closeDeadline);
800
+ console.error('[orchestrator] Terminal recovery failed:', err);
801
+ autoLoopService.block(workspaceId, 'engine-recovery-failed');
802
+ });
803
+ return;
804
+ }
805
+ finalizedControllers.add(sourceController);
806
+ }
807
+ else if (pendingTerminalControllers.has(sourceController) || finalizedControllers.has(sourceController)) {
808
+ return;
809
+ }
810
+ }
736
811
  const registeredController = controllers.get(workspaceId);
737
812
  const hasReplacement = sourceController !== undefined && registeredController !== undefined && registeredController !== sourceController;
738
813
  const sourceNoLongerOwnsWorkspace = sourceController !== undefined && registeredController !== sourceController;
@@ -769,6 +844,13 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
769
844
  return;
770
845
  const routedEvent = ev.kind === 'session:ended' && (hasReplacement || sourceIsSuperseded) ? { ...ev, superseded: true } : ev;
771
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;
772
854
  if (sourceController && !sourceControllerIsStopping) {
773
855
  if (ev.kind === 'session:compacting' && ev.active) {
774
856
  const status = getWs(workspaceId)?.status;
@@ -789,7 +871,7 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
789
871
  if (ev.kind === 'tool:call' && !sourceControllerIsStopping && getWs(workspaceId)?.status === 'quota') {
790
872
  updateWorkspaceStatus(workspaceId, 'executing');
791
873
  quotaBackoffService.cancel(workspaceId, 'completed');
792
- retryCounts.delete(workspaceId);
874
+ resetAutoLoopRetries(workspaceId);
793
875
  emitEphemeral(workspaceId, 'agent:quota-recovered', {});
794
876
  }
795
877
  if (ev.kind === 'rate_limit') {
@@ -886,15 +968,21 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
886
968
  console.error('[orchestrator] Failed to transition to executing:', err);
887
969
  }
888
970
  }
889
- if (ev.kind === 'error' && ev.category === 'quota' && !sourceControllerIsStopping) {
971
+ const transferring = workspaceLifecycleReason(workspaceId) === 'session-handoff';
972
+ const returningReview = (ev.kind === 'error' || ev.kind === 'session:ended') && !!getReviewReturn(workspaceId, agentSessionId);
973
+ const quotaError = ev.kind === 'error' && (ev.category === 'quota' || /\b429\b/.test(ev.message));
974
+ if (quotaError && !sourceControllerIsStopping && !returningReview && !transferring) {
890
975
  void handleQuota(workspaceId, agentSessionId);
891
976
  }
892
977
  if (ev.kind === 'error' &&
893
- ev.category === 'other' &&
978
+ !quotaError &&
979
+ ev.category !== 'resume_failed' &&
894
980
  TRANSIENT_SERVER_ERROR_PATTERN.test(ev.message) &&
981
+ !returningReview &&
982
+ !transferring &&
895
983
  getWs(workspaceId)?.autoLoop &&
896
984
  !sourceControllerIsStopping) {
897
- void handleTransientAutoLoopFailure(workspaceId);
985
+ scheduleTransientAutoLoopRecovery(workspaceId, sourceController);
898
986
  }
899
987
  if (ev.kind === 'error' && ev.category === 'resume_failed') {
900
988
  rememberResumeFailed(workspaceId, agentSessionId);
@@ -908,17 +996,27 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
908
996
  markSessionLifecycleOwnerEnded(workspaceId, agentSessionId, sourceController);
909
997
  const isResumeFailed = consumeResumeFailed(workspaceId, agentSessionId);
910
998
  const snapshot = consumeTaskProgressSnapshot(workspaceId, agentSessionId);
911
- const before = snapshot?.doneCount ?? getDoneTaskCount(workspaceId);
912
- const after = getDoneTaskCount(workspaceId);
913
- const completedDelta = Math.max(0, after - before);
914
- const taskStateBefore = snapshot?.stateSignature ?? getTaskStateSignature(workspaceId);
915
- const taskStateAfter = getTaskStateSignature(workspaceId);
916
- const progressDelta = completedDelta > 0 || taskStateBefore !== taskStateAfter ? 1 : 0;
917
999
  clearPendingForSession(workspaceId, agentSessionId);
918
1000
  // A completed/failed SDK session cannot resolve canUseTool anymore. Drop
919
1001
  // its persisted prompts too, otherwise a reconnect resurrects a panel
920
1002
  // whose backend callback has already been discarded.
921
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
+ }
922
1020
  // A watchdog end means Kōbō forced the stream closed after the engine
923
1021
  // failed to drain. It is neither a clean completion nor evidence of an
924
1022
  // agent stall. Move auto-loop work onto the existing persisted, bounded
@@ -932,7 +1030,11 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
932
1030
  // dead-engine branch evicts a controller from the map WITHOUT ever
933
1031
  // calling `.stop()` on it, so an old controller can still be 'running'
934
1032
  // by the time its own drain watchdog reports a late `session:ended`.
935
- const watchdogRecovery = !hasReplacement && ev.reason === 'watchdog' && getWs(workspaceId)?.autoLoop === true;
1033
+ const watchdogRecovery = !returningReview &&
1034
+ !hasReplacement &&
1035
+ ev.reason === 'watchdog' &&
1036
+ getWs(workspaceId)?.autoLoop === true &&
1037
+ autoLoopService.getStatus(workspaceId).state !== 'blocked';
936
1038
  // A drain watchdog can fire mid-`stop()`: `stopController` already cancelled
937
1039
  // any pending quota backoff (`quotaBackoffService.cancel(id, 'user')`), and
938
1040
  // re-arming one here would resurrect a session the user just stopped — the
@@ -943,14 +1045,58 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
943
1045
  // could matter.
944
1046
  if (watchdogRecovery && !sourceControllerIsStopping) {
945
1047
  console.warn(`[auto-loop] watchdog recovery scheduled for workspace '${workspaceId}'`);
946
- void handleTransientAutoLoopFailure(workspaceId);
1048
+ scheduleTransientAutoLoopRecovery(workspaceId, sourceController);
947
1049
  }
948
1050
  // Must run BEFORE autoLoopService.onSessionEnded → spawnNextIteration →
949
1051
  // startAgent, otherwise startAgent throws "Agent already running" because
950
1052
  // the just-ended controller is still in the map.
951
1053
  const ownsWorkspaceLifecycle = onSessionEnded(workspaceId, agentSessionId, sourceController, ev.exitCode, ev.reason, isResumeFailed);
1054
+ // A technical stop deliberately skips normal loop continuation, but its
1055
+ // instructions still need a definitive delivery state after closure.
1056
+ // A superseded controller must never settle a replacement's instructions.
1057
+ let instructionIntake = false;
1058
+ if (!hasReplacement) {
1059
+ instructionIntake = settleLoopMessages(workspaceId, agentSessionId, ev.reason === 'completed') > 0;
1060
+ if (listLoopMessages(workspaceId).some((message) => message.state === 'unknown')) {
1061
+ restoreReviewConfiguration(workspaceId, agentSessionId);
1062
+ autoLoopService.block(workspaceId, 'message-delivery-unknown');
1063
+ if (ownsWorkspaceLifecycle)
1064
+ notifyCapacityAvailable(workspaceId);
1065
+ return;
1066
+ }
1067
+ }
952
1068
  if (!ownsWorkspaceLifecycle)
953
1069
  return;
1070
+ const progressDelta = consumeMonotoneTaskProgress(workspaceId, snapshot);
1071
+ if (returningReview) {
1072
+ const pending = restoreReviewConfiguration(workspaceId, agentSessionId);
1073
+ const workspace = getWs(workspaceId);
1074
+ if (pending &&
1075
+ workspace &&
1076
+ !shuttingDown &&
1077
+ !workspace.archivedAt &&
1078
+ !workspace.worktreePurgedAt &&
1079
+ autoLoopService.getStatus(workspaceId).state !== 'blocked' &&
1080
+ (ev.reason === 'completed' || ev.reason === 'watchdog') &&
1081
+ (ev.exitCode === null || ev.exitCode === 0)) {
1082
+ try {
1083
+ const prompt = buildReviewReturnPrompt(workspaceId, agentSessionId);
1084
+ const original = pending.original;
1085
+ startAgent(workspaceId, workspace.worktreePath, prompt, original.sessionModel ?? original.model, true, original.agentPermissionMode, pending.originalSessionId, original.reasoningEffort);
1086
+ updateWorkspaceStatus(workspaceId, 'executing');
1087
+ emit(workspaceId, 'user:message', { content: prompt, sender: 'system-prompt' }, pending.originalSessionId);
1088
+ }
1089
+ catch (err) {
1090
+ routeEvent(workspaceId, agentSessionId, {
1091
+ kind: 'error',
1092
+ category: 'other',
1093
+ message: `Could not return to the original session: ${err instanceof Error ? err.message : String(err)}`,
1094
+ });
1095
+ }
1096
+ }
1097
+ notifyCapacityAvailable(workspaceId);
1098
+ return;
1099
+ }
954
1100
  if (watchdogRecovery) {
955
1101
  notifyCapacityAvailable(workspaceId);
956
1102
  return;
@@ -963,7 +1109,7 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
963
1109
  // disable() clears it, and the cleanup hook needs to know whether this was
964
1110
  // a mid-loop session (never cleans) or a standalone one.
965
1111
  const wasAutoLoop = autoLoopService.getStatus(workspaceId).auto_loop;
966
- autoLoopService.onSessionEnded(workspaceId, effectiveReason, progressDelta);
1112
+ autoLoopService.onSessionEnded(workspaceId, effectiveReason, progressDelta, instructionIntake);
967
1113
  // A slot just freed. Any auto-loop workspace parked on the concurrency
968
1114
  // limit has no session of its own to bring it back; this one does it.
969
1115
  autoLoopService.resumeWaitingWorkspaces();
@@ -1072,6 +1218,11 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1072
1218
  preCompactionStatus.delete(sourceController);
1073
1219
  if (isSuperseded)
1074
1220
  return false;
1221
+ if (workspaceLifecycleReason(workspaceId) === 'session-handoff') {
1222
+ if (registeredController === sourceController && sourceController?.status !== 'stopping')
1223
+ controllers.delete(workspaceId);
1224
+ return false;
1225
+ }
1075
1226
  // A superseded end (above) is a session that was replaced, not one that
1076
1227
  // ended; everything past this point is a real end the user may want to act
1077
1228
  // on, whatever the reason — the script reads KOBO_SESSION_END_REASON.
@@ -1083,7 +1234,7 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1083
1234
  controllers.delete(workspaceId);
1084
1235
  }
1085
1236
  if (!preserveQuotaBackoff) {
1086
- retryCounts.delete(workspaceId);
1237
+ resetAutoLoopRetries(workspaceId);
1087
1238
  }
1088
1239
  if (wasStopping)
1089
1240
  return false;
@@ -1136,6 +1287,7 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1136
1287
  }
1137
1288
  return true;
1138
1289
  }
1290
+ const launchOptions = new WeakMap();
1139
1291
  // ── Public API ────────────────────────────────────────────────────────────────
1140
1292
  /**
1141
1293
  * Spawn an agent (via the resolved engine) for a workspace. Returns
@@ -1143,10 +1295,10 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1143
1295
  * after `engine.start` resolves — callers should subscribe to WS events or
1144
1296
  * query the controller via `_getControllers()` for tests.
1145
1297
  */
1146
- 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) {
1147
1299
  if (shuttingDown)
1148
1300
  throw new Error('Cannot start an agent while the server is shutting down');
1149
- assertWorkspaceLifecycleAvailable(workspaceId);
1301
+ assertWorkspaceLifecycleAvailable(workspaceId, launch?.lifecycleOwner);
1150
1302
  const workspace = getWs(workspaceId);
1151
1303
  if (!workspace)
1152
1304
  throw new Error(`Workspace '${workspaceId}' not found`);
@@ -1204,7 +1356,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1204
1356
  let agentSessionId;
1205
1357
  let resumeFromEngineSessionId;
1206
1358
  if (resume) {
1207
- const r = resolveSessionForResume(workspaceId, existingSessionId, model);
1359
+ const r = resolveSessionForResume(workspaceId, existingSessionId, model, engineId);
1208
1360
  agentSessionId = r.agentSessionId;
1209
1361
  resumeFromEngineSessionId = r.engineSessionId;
1210
1362
  // A native resume is only used by the currently selected workspace engine.
@@ -1216,6 +1368,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1216
1368
  }
1217
1369
  // Persist before the asynchronous engine can emit its first event. This
1218
1370
  // survives a Kōbō restart and does not depend on SDK system:init delivery.
1371
+ activateSession(workspaceId, agentSessionId);
1219
1372
  captureTaskProgressBaseline(workspaceId, agentSessionId);
1220
1373
  const settings = ws ? readEffectiveSettingsSafe(ws.projectPath) : readEffectiveSettingsSafe(workingDir);
1221
1374
  const options = {
@@ -1237,12 +1390,14 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1237
1390
  }
1238
1391
  })(),
1239
1392
  settings,
1240
- mcpServers: buildMcpServers(workspaceId),
1393
+ mcpServers: buildMcpServers(workspaceId, launch?.mcpEnv),
1241
1394
  env: ws ? buildAgentEnv(ws.projectPath) : undefined,
1242
1395
  };
1243
1396
  beforeDispatch?.();
1244
1397
  let controller;
1245
1398
  controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, controller, ev));
1399
+ if (launch)
1400
+ launchOptions.set(controller, launch);
1246
1401
  registerSessionLifecycleOwner(workspaceId, agentSessionId, controller);
1247
1402
  if (!existingCtrl)
1248
1403
  controllers.set(workspaceId, controller);
@@ -1259,7 +1414,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1259
1414
  return;
1260
1415
  if (pendingStarts.get(workspaceId) === controller)
1261
1416
  pendingStarts.delete(workspaceId);
1262
- assertWorkspaceLifecycleAvailable(workspaceId);
1417
+ assertWorkspaceLifecycleAvailable(workspaceId, launch?.lifecycleOwner);
1263
1418
  const current = getWs(workspaceId);
1264
1419
  if (!current || current.archivedAt || current.worktreePurgedAt)
1265
1420
  throw new Error('Workspace is no longer available');
@@ -1269,7 +1424,11 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1269
1424
  controllers.set(workspaceId, controller);
1270
1425
  return controller.start(options);
1271
1426
  })
1272
- .then(() => {
1427
+ .then(async () => {
1428
+ if (launch?.onStarted)
1429
+ await controller.engineProcess?.ready;
1430
+ if (controller.status !== 'stopping')
1431
+ launch?.onStarted?.();
1273
1432
  const pid = controller.pid;
1274
1433
  if (pid !== undefined) {
1275
1434
  try {
@@ -1331,6 +1490,7 @@ export function interruptAgent(workspaceId, options = {}) {
1331
1490
  export const STOP_AGENT_TIMEOUT_MS = 15_000;
1332
1491
  async function stopController(workspaceId, ctrl, cause = 'user') {
1333
1492
  ctrl.stopCause = cause;
1493
+ restoreReviewConfiguration(workspaceId, ctrl.agentSessionId);
1334
1494
  preCompactionStatus.delete(ctrl);
1335
1495
  // Normalize the state synchronously so callers (archive, delete, manual
1336
1496
  // stop) see a clean workspace immediately — without waiting for the async
@@ -1405,6 +1565,9 @@ async function raceWithTimeout(promise, timeoutMs, onTimeout) {
1405
1565
  export async function stopAgentAndWait(workspaceId, timeoutMs = STOP_AGENT_TIMEOUT_MS,
1406
1566
  /** Only `user` disables auto-loop; technical stops preserve its persisted intent. */
1407
1567
  cause = 'user') {
1568
+ const finishHandoffStop = ['user', 'delete', 'purge', 'archive'].includes(cause)
1569
+ ? requestHandoffStop(workspaceId)
1570
+ : undefined;
1408
1571
  const pending = pendingStarts.get(workspaceId);
1409
1572
  let pendingStop = Promise.resolve();
1410
1573
  if (pending) {
@@ -1425,7 +1588,8 @@ cause = 'user') {
1425
1588
  // Shutdown only suspends timers: persisted intent belongs to the next boot.
1426
1589
  const cancelSchedules = new Promise((resolve) => {
1427
1590
  if (cause !== 'shutdown') {
1428
- wakeupService.cancel(workspaceId, 'stopped');
1591
+ if (cause !== 'handoff')
1592
+ wakeupService.cancel(workspaceId, 'stopped');
1429
1593
  quotaBackoffService.cancel(workspaceId, 'user');
1430
1594
  }
1431
1595
  resolve();
@@ -1442,6 +1606,7 @@ cause = 'user') {
1442
1606
  if (outcome === 'timeout') {
1443
1607
  console.error(`[orchestrator] Agent '${workspaceId}' did not stop within ${timeoutMs}ms; retaining its controller`);
1444
1608
  }
1609
+ finishHandoffStop?.(outcome);
1445
1610
  return outcome;
1446
1611
  }
1447
1612
  /**
@@ -1465,6 +1630,7 @@ export async function stopAllAgents(timeoutMs = 3_000) {
1465
1630
  wakeupService.suspendForShutdown();
1466
1631
  cronService.suspendForShutdown();
1467
1632
  shuttingDown = true;
1633
+ suspendHandoffTransfers();
1468
1634
  if (capacityTimer)
1469
1635
  clearTimeout(capacityTimer);
1470
1636
  capacityTimer = undefined;
@@ -1484,6 +1650,8 @@ export function isShuttingDown() {
1484
1650
  }
1485
1651
  /** Deliver a scheduled check without stopping background work or cancelling the wakeup as user input. */
1486
1652
  export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
1653
+ if (workspaceLifecycleReason(workspaceId) === 'session-handoff')
1654
+ return false;
1487
1655
  const ctrl = controllers.get(workspaceId);
1488
1656
  if (ctrl?.status !== 'running')
1489
1657
  return false;
@@ -1501,6 +1669,7 @@ export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
1501
1669
  }
1502
1670
  /** Write a user message to the running agent. */
1503
1671
  export async function sendMessage(workspaceId, content, expectedSessionId, beforeDispatch) {
1672
+ assertWorkspaceLifecycleAvailable(workspaceId);
1504
1673
  assertWorkspaceNotCompacting(workspaceId);
1505
1674
  const ctrl = controllers.get(workspaceId);
1506
1675
  if (!ctrl) {
@@ -1514,6 +1683,7 @@ export async function sendMessage(workspaceId, content, expectedSessionId, befor
1514
1683
  await ctrl.sendMessage(content);
1515
1684
  }
1516
1685
  export async function sendMessageForFallback(workspaceId, content) {
1686
+ assertWorkspaceLifecycleAvailable(workspaceId);
1517
1687
  assertWorkspaceNotCompacting(workspaceId);
1518
1688
  const capturedController = controllers.get(workspaceId);
1519
1689
  wakeupService.cancel(workspaceId, 'user-message');
@@ -1524,6 +1694,7 @@ export async function sendMessageForFallback(workspaceId, content) {
1524
1694
  return { status: 'sent', sessionId: capturedController.agentSessionId };
1525
1695
  }
1526
1696
  catch {
1697
+ assertWorkspaceLifecycleAvailable(workspaceId);
1527
1698
  assertWorkspaceNotCompacting(workspaceId);
1528
1699
  const deadline = Date.now() + FALLBACK_CONTROLLER_TURNOVER_TIMEOUT_MS;
1529
1700
  while (controllers.get(workspaceId) === capturedController) {
@@ -1538,6 +1709,7 @@ export async function sendMessageForFallback(workspaceId, content) {
1538
1709
  const replacementController = controllers.get(workspaceId);
1539
1710
  if (!replacementController)
1540
1711
  return { status: 'stopped' };
1712
+ assertWorkspaceLifecycleAvailable(workspaceId);
1541
1713
  assertWorkspaceNotCompacting(workspaceId);
1542
1714
  await replacementController.sendMessage(content);
1543
1715
  return { status: 'sent', sessionId: replacementController.agentSessionId };
@@ -1932,15 +2104,17 @@ function clearStaleEngineSessionId(workspaceId) {
1932
2104
  }
1933
2105
  }
1934
2106
  /** Retryable upstream failures for an auto-loop iteration (not coding errors). */
1935
- export const TRANSIENT_SERVER_ERROR_PATTERN = /\b(?:http\s*)?500\b|internal server error|service unavailable|temporarily unavailable|overloaded/i;
2107
+ export const TRANSIENT_SERVER_ERROR_PATTERN = /\b(?:http\s*)?(?:500|502|503|504)\b|internal server error|bad gateway|gateway timeout|service unavailable|temporarily unavailable|overloaded|\b(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EPIPE)\b|fetch failed|network (?:error|connection)|connection (?:reset|closed|terminated)|socket hang up|request timed out|stopped reporting activity/i;
1936
2108
  const QUOTA_SAFETY_MARGIN_MS = 30_000;
1937
- const QUOTA_MAX_BACKOFF_MS = 24 * 60 * 60 * 1000;
2109
+ // Weekly and monthly provider windows are legitimate. The scheduler chunks
2110
+ // long delays so they never overflow Node's signed 32-bit timeout.
2111
+ const QUOTA_MAX_BACKOFF_MS = 366 * 24 * 60 * 60 * 1000;
1938
2112
  const QUOTA_SATURATION_THRESHOLD_PCT = 95;
1939
2113
  /**
1940
2114
  * Fallback backoff ladder (in minutes) used when the `rate_limit` info
1941
2115
  * isn't usable. Indexed by `retryCount`; anything past the last entry
1942
- * clamps to the final value (5 h) — long enough to cross a weekly
1943
- * bucket reset if the rate_limit info truly never arrives.
2116
+ * clamps to the final value (5 h). Genuine quota retries retain their intent
2117
+ * until recovery, including when no provider reset time is available.
1944
2118
  */
1945
2119
  const QUOTA_FALLBACK_LADDER_MINUTES = [15, 30, 60, 180, 300];
1946
2120
  /**
@@ -1975,9 +2149,11 @@ export async function computeQuotaBackoffMs(workspaceId, retryCount, preferQuota
1975
2149
  }
1976
2150
  // 1.5. Try the official usage API (Claude subscription). Best-effort; never throws.
1977
2151
  try {
1978
- const snap = await refreshNow('claude-code');
2152
+ const snap = (getWs(workspaceId)?.engine ?? 'claude-code') === 'claude-code' ? await refreshNow('claude-code') : null;
1979
2153
  if (snap) {
1980
- const fiveHour = snap.buckets.find((b) => b.id === 'five_hour');
2154
+ const fiveHour = snap.buckets
2155
+ .filter((b) => b.usedPct >= QUOTA_SATURATION_THRESHOLD_PCT && b.resetsAt)
2156
+ .sort((a, b) => Date.parse(b.resetsAt) - Date.parse(a.resetsAt))[0];
1981
2157
  if (fiveHour &&
1982
2158
  typeof fiveHour.usedPct === 'number' &&
1983
2159
  fiveHour.usedPct >= QUOTA_SATURATION_THRESHOLD_PCT &&
@@ -2019,23 +2195,26 @@ async function handleQuota(workspaceId, _agentSessionId) {
2019
2195
  // would otherwise kill Kōbō and every agent running under it.
2020
2196
  try {
2021
2197
  const retryCount = retryCounts.get(workspaceId) ?? 0;
2022
- const autoLoopEnabled = getWs(workspaceId)?.autoLoop === true;
2023
- const maxRetries = autoLoopEnabled ? (getGlobalSettings().autoLoopMaxRetries ?? 5) : 5;
2024
- if (autoLoopEnabled && retryCount >= maxRetries) {
2025
- autoLoopService.disable(workspaceId, 'error');
2026
- try {
2027
- updateWorkspaceStatus(workspaceId, 'error');
2028
- }
2029
- catch {
2030
- // The loop is disabled even if an already-terminal status rejects this transition.
2031
- }
2032
- return;
2033
- }
2198
+ const generation = Symbol('quota-lookup');
2199
+ quotaLookups.set(workspaceId, generation);
2200
+ retryCounts.set(workspaceId, retryCount + 1);
2201
+ retryReasons.set(workspaceId, 'quota');
2202
+ // Establish durable ownership BEFORE the usage provider can yield. A
2203
+ // restart or failed lookup retains this conservative fallback deadline.
2204
+ const fallbackIndex = Math.min(retryCount, QUOTA_FALLBACK_LADDER_MINUTES.length - 1);
2205
+ quotaBackoffService.arm(workspaceId, QUOTA_FALLBACK_LADDER_MINUTES[fallbackIndex] * 60_000, {
2206
+ resetsAt: null,
2207
+ source: 'fallback_ladder',
2208
+ reason: 'quota',
2209
+ retryCount: retryCount + 1,
2210
+ });
2034
2211
  const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount);
2035
- // Usage lookup can finish after the agent has already resumed.
2036
- if (getWs(workspaceId)?.status !== 'quota')
2212
+ // A stop, recovery, replacement or newer quota may supersede this lookup.
2213
+ if (quotaLookups.get(workspaceId) !== generation)
2214
+ return;
2215
+ quotaLookups.delete(workspaceId);
2216
+ if (getWs(workspaceId)?.status !== 'quota' || !quotaBackoffService.getPending(workspaceId))
2037
2217
  return;
2038
- retryCounts.set(workspaceId, retryCount + 1);
2039
2218
  // The quotaBackoffService owns the timer + the persistent row + the
2040
2219
  // 'agent:quota-backoff' WS emit. Hand off everything to it.
2041
2220
  quotaBackoffService.arm(workspaceId, delayMs, {
@@ -2055,13 +2234,12 @@ async function handleQuota(workspaceId, _agentSessionId) {
2055
2234
  }
2056
2235
  }
2057
2236
  /**
2058
- * Last resort when the retry machinery itself fails: stop the loop and move the
2059
- * workspace to a status the UI actually surfaces, so the user sees something to
2060
- * act on instead of a workspace that has quietly stopped moving.
2237
+ * Last resort when retry scheduling fails: retain loop intent in a visible
2238
+ * blocked state so an explicit human resume can recover it.
2061
2239
  */
2062
2240
  function failVisibly(workspaceId) {
2063
2241
  try {
2064
- autoLoopService.disable(workspaceId, 'error');
2242
+ autoLoopService.block(workspaceId, 'retry-unavailable');
2065
2243
  }
2066
2244
  catch (err) {
2067
2245
  console.error(`[orchestrator] Could not disable the auto-loop for workspace '${workspaceId}':`, err);
@@ -2077,6 +2255,18 @@ function failVisibly(workspaceId) {
2077
2255
  * quota ladder's 15-minute floor is tuned for real quota windows — a forced
2078
2256
  * kill or a server blip deserves a much faster first attempt. */
2079
2257
  const TRANSIENT_FIRST_RETRY_MS = 2 * 60_000;
2258
+ /** Charge an incident once, until this controller demonstrably starts working again. */
2259
+ function scheduleTransientAutoLoopRecovery(workspaceId, controller) {
2260
+ if (controller && transientRecoveryControllers.has(controller))
2261
+ return;
2262
+ // Preserve whichever backoff already owns the next start, including a real
2263
+ // provider quota followed by its engine's drain-watchdog diagnostic.
2264
+ if (getWs(workspaceId)?.status === 'quota' && quotaBackoffService.getPending(workspaceId))
2265
+ return;
2266
+ if (controller)
2267
+ transientRecoveryControllers.add(controller);
2268
+ void handleTransientAutoLoopFailure(workspaceId);
2269
+ }
2080
2270
  /**
2081
2271
  * Use the same persisted retry path for temporary upstream failures (HTTP 500
2082
2272
  * and overloads) while deliberately ignoring quota-reset data: a server error
@@ -2092,10 +2282,10 @@ async function handleTransientAutoLoopFailure(workspaceId) {
2092
2282
  // Same reasoning as handleQuota: fire-and-forget call sites plus a fatal
2093
2283
  // unhandled-rejection policy mean a failure here must stay contained.
2094
2284
  try {
2095
- const retryCount = retryCounts.get(workspaceId) ?? 0;
2285
+ const retryCount = retryReasons.get(workspaceId) === 'quota' ? 0 : (retryCounts.get(workspaceId) ?? 0);
2096
2286
  const maxRetries = getGlobalSettings().autoLoopMaxRetries ?? 5;
2097
2287
  if (retryCount >= maxRetries) {
2098
- autoLoopService.disable(workspaceId, 'error');
2288
+ autoLoopService.block(workspaceId, 'retry-exhausted');
2099
2289
  try {
2100
2290
  updateWorkspaceStatus(workspaceId, 'error');
2101
2291
  }
@@ -2104,12 +2294,14 @@ async function handleTransientAutoLoopFailure(workspaceId) {
2104
2294
  }
2105
2295
  return;
2106
2296
  }
2107
- const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount, false);
2297
+ const idx = Math.min(retryCount, QUOTA_FALLBACK_LADDER_MINUTES.length - 1);
2298
+ const delayMs = QUOTA_FALLBACK_LADDER_MINUTES[idx] * 60_000;
2108
2299
  const effectiveDelayMs = retryCount === 0 ? Math.min(delayMs, TRANSIENT_FIRST_RETRY_MS) : delayMs;
2109
2300
  retryCounts.set(workspaceId, retryCount + 1);
2301
+ retryReasons.set(workspaceId, 'transient');
2110
2302
  quotaBackoffService.arm(workspaceId, effectiveDelayMs, {
2111
- resetsAt: resetsAt ?? null,
2112
- source,
2303
+ resetsAt: null,
2304
+ source: 'fallback_ladder',
2113
2305
  reason: 'transient',
2114
2306
  retryCount: retryCount + 1,
2115
2307
  });
@@ -2132,8 +2324,18 @@ export const _handleTransientAutoLoopFailure = handleTransientAutoLoopFailure;
2132
2324
  export function restoreRetryCountsFromDb() {
2133
2325
  for (const pending of quotaBackoffService.listPending()) {
2134
2326
  retryCounts.set(pending.workspaceId, pending.retryCount);
2327
+ retryReasons.set(pending.workspaceId, pending.reason);
2135
2328
  }
2136
2329
  }
2330
+ /** An explicit human resume starts a new bounded transient-recovery attempt. */
2331
+ export function resetAutoLoopRetries(workspaceId) {
2332
+ retryCounts.delete(workspaceId);
2333
+ retryReasons.delete(workspaceId);
2334
+ quotaLookups.delete(workspaceId);
2335
+ const controller = controllers.get(workspaceId);
2336
+ if (controller)
2337
+ transientRecoveryControllers.delete(controller);
2338
+ }
2137
2339
  // One-time wire: when the persisted backoff timer fires (or a row is
2138
2340
  // restored at boot), hand the workspace off to auto-loop. The auto-loop
2139
2341
  // service decides whether to spawn the next iteration or fall back to a
@@ -2142,8 +2344,8 @@ export function restoreRetryCountsFromDb() {
2142
2344
  // IMPORTANT — behavioural contract: only auto-loop workspaces auto-resume
2143
2345
  // after a quota backoff. `onQuotaBackoffExpired` no-ops if `auto_loop !== 1`
2144
2346
  // (see auto-loop-service). Workspaces hit by quota WITHOUT auto-loop stay
2145
- // in `quota` status and require manual user action (resume / new message)
2146
- // to leave that state. This is intentional: without an auto-loop intent,
2347
+ // in `quota` status until manual user action (resume / new message) or an
2348
+ // explicitly scheduled wakeup/cron resumes them after expiry. Without either intent,
2147
2349
  // firing a fresh agent run in the user's absence would surprise them.
2148
2350
  quotaBackoffService.setOnFireCallback((workspaceId, pending) => {
2149
2351
  autoLoopService.onQuotaBackoffExpired(workspaceId, pending);