@loicngr/kobo 1.11.27 → 1.11.28

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 (167) hide show
  1. package/AGENTS.md +19 -1
  2. package/CHANGELOG.md +6 -0
  3. package/dist/mcp-server/kobo-tasks-handlers.js +17 -1
  4. package/dist/server/db/migrations.js +34 -0
  5. package/dist/server/db/schema.js +3 -1
  6. package/dist/server/index.js +80 -23
  7. package/dist/server/middleware/host-check-middleware.js +80 -0
  8. package/dist/server/routes/changelog.js +51 -3
  9. package/dist/server/routes/notion.js +1 -1
  10. package/dist/server/routes/pull-requests.js +6 -2
  11. package/dist/server/routes/sentry.js +1 -1
  12. package/dist/server/routes/settings.js +23 -8
  13. package/dist/server/routes/usage.js +12 -0
  14. package/dist/server/routes/workspaces.js +212 -80
  15. package/dist/server/services/agent/engines/claude-code/engine.js +3 -0
  16. package/dist/server/services/agent/engines/codex/engine.js +18 -0
  17. package/dist/server/services/agent/orchestrator.js +129 -34
  18. package/dist/server/services/agent/session-controller.js +28 -0
  19. package/dist/server/services/auto-loop-service.js +65 -0
  20. package/dist/server/services/awaiting-user-reminder-service.js +115 -0
  21. package/dist/server/services/change-source-branch-service.js +1 -1
  22. package/dist/server/services/forge/resolve.js +29 -1
  23. package/dist/server/services/lifecycle-hook-service.js +114 -0
  24. package/dist/server/services/network-access-service.js +187 -6
  25. package/dist/server/services/pr-watcher-service.js +27 -1
  26. package/dist/server/services/settings-service.js +147 -4
  27. package/dist/server/services/usage/reliability.js +81 -0
  28. package/dist/server/services/wakeup-service.js +23 -0
  29. package/dist/server/services/websocket-service.js +31 -3
  30. package/dist/server/services/workspace-service.js +36 -4
  31. package/dist/server/services/worktree-purge-service.js +1 -1
  32. package/dist/server/utils/git-ops.js +159 -1
  33. package/dist/server/utils/paths.js +19 -0
  34. package/dist/server/utils/safe-path.js +21 -1
  35. package/dist/server/utils/script-runner.js +2 -1
  36. package/dist/server/utils/workspace-name.js +37 -0
  37. package/dist/shared/consts.js +22 -0
  38. package/dist/shared/models.js +4 -4
  39. package/package.json +3 -2
  40. package/src/client/dist/pwa/assets/{ActivityFeed-LEd9NEbI.css → ActivityFeed-C6XSxdJ0.css} +1 -1
  41. package/src/client/dist/pwa/assets/ActivityFeed-LDzg5XPx.js +8 -0
  42. package/src/client/dist/pwa/assets/ChangelogPage-CjiWI9Qa.js +1 -0
  43. package/src/client/dist/pwa/assets/ClosePopup-CK6wvsMy.js +1 -0
  44. package/src/client/dist/pwa/assets/ComparisonPanel-C0hI2D1O.css +1 -0
  45. package/src/client/dist/pwa/assets/ComparisonPanel-D6eEciwQ.js +1 -0
  46. package/src/client/dist/pwa/assets/CreatePage-DHBa7ZL5.js +2 -0
  47. package/src/client/dist/pwa/assets/CreatePage-DeKYK6uC.css +1 -0
  48. package/src/client/dist/pwa/assets/DashboardPage-BqgK4lAf.css +1 -0
  49. package/src/client/dist/pwa/assets/DashboardPage-Xev4suQ7.js +1 -0
  50. package/src/client/dist/pwa/assets/DiffViewer-D0BQ5Dip.js +8 -0
  51. package/src/client/dist/pwa/assets/{DiffViewer-BV8SvUIi.css → DiffViewer-DnR79ycb.css} +1 -1
  52. package/src/client/dist/pwa/assets/DocumentsPanel-7hMCYUTd.js +1 -0
  53. package/src/client/dist/pwa/assets/DrawerToggleButton-BASeszPg.js +1 -0
  54. package/src/client/dist/pwa/assets/GitPanel-D-rwHnFn.js +3 -0
  55. package/src/client/dist/pwa/assets/HealthPage-BKXqP1li.js +1 -0
  56. package/src/client/dist/pwa/assets/MainLayout-DnPtpWgP.js +2 -0
  57. package/src/client/dist/pwa/assets/MainLayout-ExB0xYHB.css +1 -0
  58. package/src/client/dist/pwa/assets/QBanner-CDiXUya7.js +1 -0
  59. package/src/client/dist/pwa/assets/QBtnDropdown-52YHuBbx.js +1 -0
  60. package/src/client/dist/pwa/assets/QChip-DpUYTCOJ.js +1 -0
  61. package/src/client/dist/pwa/assets/QItemSection-CEUi4Tz0.js +1 -0
  62. package/src/client/dist/pwa/assets/QMarkupTable-DcoQpjPu.js +1 -0
  63. package/src/client/dist/pwa/assets/SchedulePanel-C7NK1hIK.js +1 -0
  64. package/src/client/dist/pwa/assets/SearchPage-Cm8chltA.js +1 -0
  65. package/src/client/dist/pwa/assets/{SettingsPage-CDZhPV6A.css → SettingsPage-2vdOZu1C.css} +1 -1
  66. package/src/client/dist/pwa/assets/SettingsPage-B4xs1RUl.js +10 -0
  67. package/src/client/dist/pwa/assets/{TerminalPanel-HIuH2z-Y.js → TerminalPanel-CSPk8QtK.js} +3 -3
  68. package/src/client/dist/pwa/assets/TouchPan-CpO61wPy.js +1 -0
  69. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +1 -0
  70. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DZrbJ0vD.js +1 -0
  71. package/src/client/dist/pwa/assets/WorkspacePage-D0-FkRVJ.js +4 -0
  72. package/src/client/dist/pwa/assets/browser-Bg25_sDh.js +8 -0
  73. package/src/client/dist/pwa/assets/build-path-tree-DH4SSsEz.js +1 -0
  74. package/src/client/dist/pwa/assets/de-Bf_OsgDw.js +17 -0
  75. package/src/client/dist/pwa/assets/documents-CDZqd_g5.js +1 -0
  76. package/src/client/dist/pwa/assets/editor-BJU3Xb3h.css +1 -0
  77. package/src/client/dist/pwa/assets/editor.api-CMsYdZTX.js +413 -0
  78. package/src/client/dist/pwa/assets/{efforts-Dn1yRgeo.js → efforts-Db4w0alg.js} +1 -1
  79. package/src/client/dist/pwa/assets/engineFeatures-BjtQR0vB.js +1 -0
  80. package/src/client/dist/pwa/assets/es-1ib0e9jm.js +17 -0
  81. package/src/client/dist/pwa/assets/expand-template-DY8ntqUb.js +1 -0
  82. package/src/client/dist/pwa/assets/format-C-9jMgWb.js +435 -0
  83. package/src/client/dist/pwa/assets/format-CX6Cv1cD.css +1 -0
  84. package/src/client/dist/pwa/assets/{formatters-D0dU6VcA.js → formatters-CQ90Icr4.js} +1 -1
  85. package/src/client/dist/pwa/assets/fr-nj0BrM3X.js +17 -0
  86. package/src/client/dist/pwa/assets/{freemarker2-qQC_1HDe.js → freemarker2-KxB1BtrL.js} +1 -1
  87. package/src/client/dist/pwa/assets/{handlebars-pyc5TcAt.js → handlebars-7TByd_Zc.js} +1 -1
  88. package/src/client/dist/pwa/assets/{html-DOaftBCT.js → html-BoVZ_gre.js} +1 -1
  89. package/src/client/dist/pwa/assets/i18n-BubYp85n.js +1 -0
  90. package/src/client/dist/pwa/assets/index-Ck1_qrff.js +18 -0
  91. package/src/client/dist/pwa/assets/it-B9MEgvg1.js +17 -0
  92. package/src/client/dist/pwa/assets/{javascript-BTsYFFLK.js → javascript-BLL8vPQy.js} +1 -1
  93. package/src/client/dist/pwa/assets/layout-CFuk4mdG.js +1 -0
  94. package/src/client/dist/pwa/assets/{liquid-BdTymdAY.js → liquid-DzcT-R6k.js} +1 -1
  95. package/src/client/dist/pwa/assets/{mdx-DzJktAFj.js → mdx-Bguo999w.js} +1 -1
  96. package/src/client/dist/pwa/assets/monaco-contributions-CM7FH-iZ.js +51 -0
  97. package/src/client/dist/pwa/assets/monaco-contributions-Cb0afGfW.css +1 -0
  98. package/src/client/dist/pwa/assets/monaco.contribution-Dkq0qtWA.js +2 -0
  99. package/src/client/dist/pwa/assets/network-auth-C0moem8_.js +1 -0
  100. package/src/client/dist/pwa/assets/{notify-theme-D1FTBWWF.js → notify-theme-C7mNmzKx.js} +1 -1
  101. package/src/client/dist/pwa/assets/pending-diff-open-BaQ_SNEF.js +1 -0
  102. package/src/client/dist/pwa/assets/permissionModes-CJZsT9Fl.js +36 -0
  103. package/src/client/dist/pwa/assets/pinia-ClsyLh3i.js +1 -0
  104. package/src/client/dist/pwa/assets/{project-color-eoGJvY_F.js → project-color-54JZHVy2.js} +1 -1
  105. package/src/client/dist/pwa/assets/{python-onIjs5sJ.js → python-D5RQNiPF.js} +1 -1
  106. package/src/client/dist/pwa/assets/{razor-DqQGiaiD.js → razor-CVwoDJol.js} +1 -1
  107. package/src/client/dist/pwa/assets/{render-chat-markdown-BwbknTum.js → render-chat-markdown-NAmPVIQp.js} +1 -1
  108. package/src/client/dist/pwa/assets/rolldown-runtime-hePW80VL.js +1 -0
  109. package/src/client/dist/pwa/assets/{typescript-BnI60_wU.js → typescript-qdgwClKe.js} +1 -1
  110. package/src/client/dist/pwa/assets/use-checkbox-3io6bTPe.js +1 -0
  111. package/src/client/dist/pwa/assets/use-onboarding-DbYF8ZX_.js +2 -0
  112. package/src/client/dist/pwa/assets/use-quasar-B4eIgSfZ.js +1 -0
  113. package/src/client/dist/pwa/assets/vue-i18n-DtajJy_8.js +3 -0
  114. package/src/client/dist/pwa/assets/{xml-DQUlBYuZ.js → xml-DNSfQSlk.js} +1 -1
  115. package/src/client/dist/pwa/assets/{yaml-D3GiqGbP.js → yaml-6gnX2NnP.js} +1 -1
  116. package/src/client/dist/pwa/index.html +5 -4
  117. package/src/client/dist/pwa/sw.js +1 -1
  118. package/src/mcp-server/kobo-tasks-handlers.ts +17 -1
  119. package/src/client/dist/pwa/assets/ActivityFeed-XJeDwa82.js +0 -8
  120. package/src/client/dist/pwa/assets/ChangelogPage-Bjls89ZX.js +0 -1
  121. package/src/client/dist/pwa/assets/ClosePopup-BK6mNWsy.js +0 -1
  122. package/src/client/dist/pwa/assets/CreatePage-BCuDTB_0.css +0 -1
  123. package/src/client/dist/pwa/assets/CreatePage-BzHQd4uE.js +0 -2
  124. package/src/client/dist/pwa/assets/DiffViewer-DNPD0uVB.js +0 -8
  125. package/src/client/dist/pwa/assets/DocumentsPanel-Osdun-Om.js +0 -1
  126. package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +0 -1
  127. package/src/client/dist/pwa/assets/GitPanel-8_xQ7V2r.js +0 -3
  128. package/src/client/dist/pwa/assets/HealthPage-CSEPHslq.js +0 -1
  129. package/src/client/dist/pwa/assets/MainLayout-Bdh2JQfn.css +0 -1
  130. package/src/client/dist/pwa/assets/MainLayout-X1T4sh65.js +0 -2
  131. package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +0 -1
  132. package/src/client/dist/pwa/assets/QBtnDropdown-aUlDUJBU.js +0 -1
  133. package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +0 -1
  134. package/src/client/dist/pwa/assets/QItemSection-5GBV9MR2.js +0 -1
  135. package/src/client/dist/pwa/assets/SchedulePanel-DIoiKpwZ.js +0 -1
  136. package/src/client/dist/pwa/assets/SearchPage-CKNNtnwv.js +0 -1
  137. package/src/client/dist/pwa/assets/SettingsPage-njeIse66.js +0 -16
  138. package/src/client/dist/pwa/assets/TouchPan-CMDAeqj6.js +0 -1
  139. package/src/client/dist/pwa/assets/WorkspacePage-Bk7pIvC0.js +0 -4
  140. package/src/client/dist/pwa/assets/build-path-tree-B07ATM5W.js +0 -1
  141. package/src/client/dist/pwa/assets/css.worker-JKZQyld4.js +0 -93
  142. package/src/client/dist/pwa/assets/cssMode-BvD76b3P.js +0 -1
  143. package/src/client/dist/pwa/assets/documents-D7AZJAiR.js +0 -1
  144. package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +0 -1
  145. package/src/client/dist/pwa/assets/editor.api-GJX9JYVf.js +0 -847
  146. package/src/client/dist/pwa/assets/engineFeatures-C4wJRlwO.js +0 -1
  147. package/src/client/dist/pwa/assets/expand-template-0hMEF4rZ.js +0 -1
  148. package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +0 -506
  149. package/src/client/dist/pwa/assets/htmlMode-DRXGGPOb.js +0 -1
  150. package/src/client/dist/pwa/assets/i18n-mEG61A5h.js +0 -1
  151. package/src/client/dist/pwa/assets/index-DZ4aWwQs.js +0 -82
  152. package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +0 -62
  153. package/src/client/dist/pwa/assets/jsonMode-3Glcck88.js +0 -7
  154. package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +0 -1
  155. package/src/client/dist/pwa/assets/lspLanguageFeatures-CvwRMMY9.js +0 -4
  156. package/src/client/dist/pwa/assets/network-auth-D59LKtK4.js +0 -1
  157. package/src/client/dist/pwa/assets/pending-diff-open-DrdnFOI2.js +0 -1
  158. package/src/client/dist/pwa/assets/permissionModes-_rSNmU9M.js +0 -36
  159. package/src/client/dist/pwa/assets/ts.worker-C-oGa8JT.js +0 -67738
  160. package/src/client/dist/pwa/assets/tsMode-B24qEjDH.js +0 -11
  161. package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +0 -1
  162. package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +0 -1
  163. package/src/client/dist/pwa/assets/use-onboarding-WR8vvxBw.js +0 -2
  164. package/src/client/dist/pwa/assets/vs-DVt5kpUB.js +0 -56
  165. package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +0 -1
  166. package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +0 -3
  167. package/src/client/dist/pwa/assets/workers-D28S2iKq.js +0 -1
@@ -6,6 +6,7 @@ import * as autoLoopService from '../auto-loop-service.js';
6
6
  import * as cleanupScriptService from '../cleanup-script-service.js';
7
7
  import * as cronService from '../cron-service.js';
8
8
  import { resolveForge } from '../forge/resolve.js';
9
+ import * as lifecycleHookService from '../lifecycle-hook-service.js';
9
10
  import * as quotaBackoffService from '../quota-backoff-service.js';
10
11
  import { getEffectiveSettings, getGlobalSettings } from '../settings-service.js';
11
12
  import { refreshNow } from '../usage/poller.js';
@@ -268,6 +269,9 @@ function runWatchdog() {
268
269
  if (controllers.get(workspaceId) === ctrl)
269
270
  controllers.delete(workspaceId);
270
271
  retryCounts.delete(workspaceId);
272
+ // This end never goes through handleEvent → onSessionEnded, so the user's
273
+ // hook would otherwise miss precisely the case it is most useful for.
274
+ fireSessionEndedHook(workspaceId, ctrl.agentSessionId, 'killed', null, ctrl);
271
275
  try {
272
276
  const db = getDb();
273
277
  db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ? WHERE id = ?').run('error', new Date().toISOString(), ctrl.agentSessionId);
@@ -413,6 +417,9 @@ function readEffectiveSettingsSafe(projectPath) {
413
417
  cleanupScriptOnlyOnChanges: false,
414
418
  archiveScript: '',
415
419
  changeSourceBranchScript: '',
420
+ sessionEndedScript: '',
421
+ prMergedScript: '',
422
+ autoLoopDisabledScript: '',
416
423
  notionStatusProperty: '',
417
424
  notionInProgressStatus: '',
418
425
  };
@@ -856,6 +863,9 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
856
863
  // a mid-loop session (never cleans) or a standalone one.
857
864
  const wasAutoLoop = autoLoopService.getStatus(workspaceId).auto_loop;
858
865
  autoLoopService.onSessionEnded(workspaceId, effectiveReason, progressDelta);
866
+ // A slot just freed. Any auto-loop workspace parked on the concurrency
867
+ // limit has no session of its own to bring it back; this one does it.
868
+ autoLoopService.resumeWaitingWorkspaces();
859
869
  cleanupScriptService.onSessionEnded(workspaceId, effectiveReason, { wasAutoLoop });
860
870
  }
861
871
  if (ev.kind === 'session:user-input-requested' && !sourceControllerIsStopping) {
@@ -913,11 +923,43 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
913
923
  }
914
924
  }
915
925
  }
926
+ /**
927
+ * Sessions whose session-ended hook already ran. Two paths can report the
928
+ * same end — the watchdog's dead-engine sweep, then the engine's own late
929
+ * `session:ended` — and the user's script must run once per session, not
930
+ * once per report. Bounded: cleared past a size no single process reaches.
931
+ */
932
+ const sessionEndedHookFired = new Set();
933
+ function fireSessionEndedHook(workspaceId, agentSessionId, reason, exitCode, controller) {
934
+ if (sessionEndedHookFired.has(agentSessionId))
935
+ return;
936
+ if (sessionEndedHookFired.size > 10_000)
937
+ sessionEndedHookFired.clear();
938
+ sessionEndedHookFired.add(agentSessionId);
939
+ // The worktree is about to be removed by the very operation that stopped
940
+ // this session; a script spawned in it would lose its cwd mid-run.
941
+ const cause = controller?.stopCause;
942
+ if (cause === 'delete' || cause === 'purge')
943
+ return;
944
+ void lifecycleHookService.onSessionEnded(workspaceId, {
945
+ sessionId: agentSessionId,
946
+ reason,
947
+ exitCode,
948
+ stopCause: controller?.status === 'stopping' ? (cause ?? 'user') : '',
949
+ // Read BEFORE auto-loop reacts to this end (which may disable it): the
950
+ // hook wants to know whether the next iteration is about to start.
951
+ autoLoopActive: getWs(workspaceId)?.autoLoop === true,
952
+ });
953
+ }
916
954
  function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode, reason, resumeFailed = false) {
917
955
  // Update the agent_sessions row
918
956
  try {
919
957
  const db = getDb();
920
- db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ? WHERE id = ?').run(exitCode === 0 ? 'completed' : 'error', new Date().toISOString(), agentSessionId);
958
+ // `status` only distinguishes completed from error, by exit code. Keep the
959
+ // reason too: "the watchdog force-ended it", "the user stopped it" and "it
960
+ // finished on its own" all landed on the same two values, so after the fact
961
+ // nothing could say why a session stopped.
962
+ db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ?, end_reason = ? WHERE id = ?').run(exitCode === 0 ? 'completed' : 'error', new Date().toISOString(), reason, agentSessionId);
921
963
  }
922
964
  catch (err) {
923
965
  console.error('[orchestrator] Failed to update agent_sessions on exit:', err);
@@ -926,6 +968,10 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
926
968
  const isSuperseded = sourceController !== undefined && registeredController !== undefined && registeredController !== sourceController;
927
969
  if (isSuperseded)
928
970
  return false;
971
+ // A superseded end (above) is a session that was replaced, not one that
972
+ // ended; everything past this point is a real end the user may want to act
973
+ // on, whatever the reason — the script reads KOBO_SESSION_END_REASON.
974
+ fireSessionEndedHook(workspaceId, agentSessionId, reason, exitCode, sourceController);
929
975
  const currentWorkspace = getWs(workspaceId);
930
976
  const preserveQuotaBackoff = currentWorkspace?.status === 'quota';
931
977
  const wasStopping = sourceController?.status === 'stopping';
@@ -1170,7 +1216,8 @@ export function interruptAgent(workspaceId, options = {}) {
1170
1216
  * is untracked by anyone in this process; only the OS or the user can end it.
1171
1217
  */
1172
1218
  export const STOP_AGENT_TIMEOUT_MS = 15_000;
1173
- async function stopController(workspaceId, ctrl) {
1219
+ async function stopController(workspaceId, ctrl, cause = 'user') {
1220
+ ctrl.stopCause = cause;
1174
1221
  wakeupService.cancel(workspaceId, 'stopped');
1175
1222
  // Normalize the state synchronously so callers (archive, delete, manual
1176
1223
  // stop) see a clean workspace immediately — without waiting for the async
@@ -1256,11 +1303,13 @@ async function stopWithTimeout(ctrl, timeoutMs) {
1256
1303
  * switch — MUST use this: the fire-and-forget `stopAgent` returns while the
1257
1304
  * engine may still be writing files.
1258
1305
  */
1259
- export async function stopAgentAndWait(workspaceId, timeoutMs = STOP_AGENT_TIMEOUT_MS) {
1306
+ export async function stopAgentAndWait(workspaceId, timeoutMs = STOP_AGENT_TIMEOUT_MS,
1307
+ /** Why. `delete` and `purge` tell the session-ended hook to stand down. */
1308
+ cause = 'user') {
1260
1309
  const ctrl = controllers.get(workspaceId);
1261
1310
  if (!ctrl)
1262
1311
  return 'not-running';
1263
- const finished = await raceWithTimeout(stopController(workspaceId, ctrl).then(() => true, (err) => {
1312
+ const finished = await raceWithTimeout(stopController(workspaceId, ctrl, cause).then(() => true, (err) => {
1264
1313
  console.error(`[orchestrator] controller.stop failed for '${workspaceId}':`, err);
1265
1314
  return true;
1266
1315
  }), timeoutMs, () => false);
@@ -1647,6 +1696,10 @@ export function getAgentStatus(workspaceId) {
1647
1696
  export function hasController(workspaceId) {
1648
1697
  return controllers.has(workspaceId);
1649
1698
  }
1699
+ /** How many agent sessions are live right now, across every workspace. */
1700
+ export function runningAgentCount() {
1701
+ return controllers.size;
1702
+ }
1650
1703
  /** The agent_session_id of the active controller for the workspace, if any. */
1651
1704
  export function getActiveSessionId(workspaceId) {
1652
1705
  return controllers.get(workspaceId)?.agentSessionId;
@@ -1791,24 +1844,58 @@ async function handleQuota(workspaceId, _agentSessionId) {
1791
1844
  // Never silent: a refused transition here loses the backoff two lines down.
1792
1845
  console.warn(`[orchestrator] Could not transition workspace '${workspaceId}' to quota:`, err);
1793
1846
  }
1794
- const retryCount = retryCounts.get(workspaceId) ?? 0;
1795
- const autoLoopEnabled = getWs(workspaceId)?.autoLoop === true;
1796
- const maxRetries = autoLoopEnabled ? (getGlobalSettings().autoLoopMaxRetries ?? 5) : 5;
1797
- if (autoLoopEnabled && retryCount >= maxRetries) {
1798
- autoLoopService.disable(workspaceId, 'error');
1799
- try {
1800
- updateWorkspaceStatus(workspaceId, 'error');
1801
- }
1802
- catch {
1803
- // The loop is disabled even if an already-terminal status rejects this transition.
1847
+ // Everything below reads the settings file, the workspace row and the
1848
+ // backoff table. Both call sites are fire-and-forget (`void handleQuota(…)`)
1849
+ // and the process treats an unhandled rejection as fatal, so a transient
1850
+ // failure here must not escape: hitting a quota while the database is busy
1851
+ // would otherwise kill Kōbō and every agent running under it.
1852
+ try {
1853
+ const retryCount = retryCounts.get(workspaceId) ?? 0;
1854
+ const autoLoopEnabled = getWs(workspaceId)?.autoLoop === true;
1855
+ const maxRetries = autoLoopEnabled ? (getGlobalSettings().autoLoopMaxRetries ?? 5) : 5;
1856
+ if (autoLoopEnabled && retryCount >= maxRetries) {
1857
+ autoLoopService.disable(workspaceId, 'error');
1858
+ try {
1859
+ updateWorkspaceStatus(workspaceId, 'error');
1860
+ }
1861
+ catch {
1862
+ // The loop is disabled even if an already-terminal status rejects this transition.
1863
+ }
1864
+ return;
1804
1865
  }
1805
- return;
1866
+ const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount);
1867
+ retryCounts.set(workspaceId, retryCount + 1);
1868
+ // The quotaBackoffService owns the timer + the persistent row + the
1869
+ // 'agent:quota-backoff' WS emit. Hand off everything to it.
1870
+ quotaBackoffService.arm(workspaceId, delayMs, { resetsAt: resetsAt ?? null, source, reason: 'quota' });
1871
+ }
1872
+ catch (err) {
1873
+ console.error(`[orchestrator] Could not arm the quota backoff for workspace '${workspaceId}':`, err);
1874
+ // The status was already moved to 'quota' above, and the banner offering
1875
+ // "resume now" only renders once a backoff row exists. Leaving it there
1876
+ // would strand the workspace waiting on a timer nobody armed, with the
1877
+ // console line as its only trace. 'error' has a banner of its own.
1878
+ failVisibly(workspaceId);
1879
+ }
1880
+ }
1881
+ /**
1882
+ * Last resort when the retry machinery itself fails: stop the loop and move the
1883
+ * workspace to a status the UI actually surfaces, so the user sees something to
1884
+ * act on instead of a workspace that has quietly stopped moving.
1885
+ */
1886
+ function failVisibly(workspaceId) {
1887
+ try {
1888
+ autoLoopService.disable(workspaceId, 'error');
1889
+ }
1890
+ catch (err) {
1891
+ console.error(`[orchestrator] Could not disable the auto-loop for workspace '${workspaceId}':`, err);
1892
+ }
1893
+ try {
1894
+ updateWorkspaceStatus(workspaceId, 'error');
1895
+ }
1896
+ catch {
1897
+ // An already-terminal status refusing this transition is fine.
1806
1898
  }
1807
- const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount);
1808
- retryCounts.set(workspaceId, retryCount + 1);
1809
- // The quotaBackoffService owns the timer + the persistent row + the
1810
- // 'agent:quota-backoff' WS emit. Hand off everything to it.
1811
- quotaBackoffService.arm(workspaceId, delayMs, { resetsAt: resetsAt ?? null, source, reason: 'quota' });
1812
1899
  }
1813
1900
  /** First retry after a transient failure (watchdog recovery, HTTP 500). The
1814
1901
  * quota ladder's 15-minute floor is tuned for real quota windows — a forced
@@ -1826,22 +1913,30 @@ async function handleTransientAutoLoopFailure(workspaceId) {
1826
1913
  catch (err) {
1827
1914
  console.warn(`[orchestrator] Could not transition workspace '${workspaceId}' to quota for a transient failure:`, err);
1828
1915
  }
1829
- const retryCount = retryCounts.get(workspaceId) ?? 0;
1830
- const maxRetries = getGlobalSettings().autoLoopMaxRetries ?? 5;
1831
- if (retryCount >= maxRetries) {
1832
- autoLoopService.disable(workspaceId, 'error');
1833
- try {
1834
- updateWorkspaceStatus(workspaceId, 'error');
1835
- }
1836
- catch {
1837
- // The loop is disabled even if an already-terminal status rejects this transition.
1916
+ // Same reasoning as handleQuota: fire-and-forget call sites plus a fatal
1917
+ // unhandled-rejection policy mean a failure here must stay contained.
1918
+ try {
1919
+ const retryCount = retryCounts.get(workspaceId) ?? 0;
1920
+ const maxRetries = getGlobalSettings().autoLoopMaxRetries ?? 5;
1921
+ if (retryCount >= maxRetries) {
1922
+ autoLoopService.disable(workspaceId, 'error');
1923
+ try {
1924
+ updateWorkspaceStatus(workspaceId, 'error');
1925
+ }
1926
+ catch {
1927
+ // The loop is disabled even if an already-terminal status rejects this transition.
1928
+ }
1929
+ return;
1838
1930
  }
1839
- return;
1931
+ const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount, false);
1932
+ const effectiveDelayMs = retryCount === 0 ? Math.min(delayMs, TRANSIENT_FIRST_RETRY_MS) : delayMs;
1933
+ retryCounts.set(workspaceId, retryCount + 1);
1934
+ quotaBackoffService.arm(workspaceId, effectiveDelayMs, { resetsAt: resetsAt ?? null, source, reason: 'transient' });
1935
+ }
1936
+ catch (err) {
1937
+ console.error(`[orchestrator] Could not arm the transient retry for workspace '${workspaceId}':`, err);
1938
+ failVisibly(workspaceId);
1840
1939
  }
1841
- const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount, false);
1842
- const effectiveDelayMs = retryCount === 0 ? Math.min(delayMs, TRANSIENT_FIRST_RETRY_MS) : delayMs;
1843
- retryCounts.set(workspaceId, retryCount + 1);
1844
- quotaBackoffService.arm(workspaceId, effectiveDelayMs, { resetsAt: resetsAt ?? null, source, reason: 'transient' });
1845
1940
  }
1846
1941
  /** @internal test-only — re-export of `handleQuota` for direct testing. */
1847
1942
  export const _handleQuota = handleQuota;
@@ -3,9 +3,21 @@ export class SessionController {
3
3
  agentSessionId;
4
4
  engine;
5
5
  onEvent;
6
+ /**
7
+ * Set by the orchestrator when it stops this controller on purpose. A
8
+ * delete or purge is about to remove the worktree, and a hook spawned in it
9
+ * would run in a directory being deleted — the hook path checks this.
10
+ */
11
+ stopCause;
6
12
  _engineProcess;
7
13
  _startPromise;
8
14
  _status = 'running';
15
+ /**
16
+ * Set by `stop()`. Distinct from `_status` so the check in `startEngine` is
17
+ * not narrowed away by the compiler: `stop()` can flip it while that method
18
+ * is awaiting the engine.
19
+ */
20
+ _stopRequested = false;
9
21
  /**
10
22
  * Wall-clock creation time. The watchdog needs it: `engineProcess` stays
11
23
  * undefined until `engine.start` resolves, and without a grace window any
@@ -38,6 +50,21 @@ export class SessionController {
38
50
  await this._startPromise;
39
51
  }
40
52
  async startEngine(options) {
53
+ // Already stopped before we got here. The orchestrator chains `start`
54
+ // behind a zombie eviction that can take up to STOP_AGENT_TIMEOUT_MS, and a
55
+ // stop landing in that window resolves immediately and reports "stopped" —
56
+ // so the caller (delete, purge-worktree) is already removing the worktree.
57
+ // Spawning into it and stopping afterwards, as the check below does, is too
58
+ // late; the process must never exist.
59
+ //
60
+ // Still report an end: the orchestrator already created the session row,
61
+ // and only session:ended closes it. Without this the row stayed `running`
62
+ // until the next boot — blocking deleteSession and tagging the next
63
+ // messages onto a ghost session.
64
+ if (this._stopRequested) {
65
+ this.handle({ kind: 'session:ended', reason: 'killed', exitCode: null });
66
+ return;
67
+ }
41
68
  const process = await this.engine.start(options, (ev) => this.handle(ev));
42
69
  this._engineProcess = process;
43
70
  if (this._status === 'stopping') {
@@ -63,6 +90,7 @@ export class SessionController {
63
90
  this._engineProcess.interrupt();
64
91
  }
65
92
  async stop() {
93
+ this._stopRequested = true;
66
94
  this._status = 'stopping';
67
95
  // `startEngine` may still be in flight: `_engineProcess` stays undefined
68
96
  // until `engine.start` resolves, so without this wait `stop()` would
@@ -5,6 +5,7 @@ import { slugifyProjectName } from '../utils/project-slug.js';
5
5
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
6
6
  import * as orchestrator from './agent/orchestrator.js';
7
7
  import * as cleanupScriptService from './cleanup-script-service.js';
8
+ import * as lifecycleHookService from './lifecycle-hook-service.js';
8
9
  import * as settingsService from './settings-service.js';
9
10
  import { getSuitePrompts } from './skill-suite-prompts.js';
10
11
  import { emit, emitEphemeral } from './websocket-service.js';
@@ -91,6 +92,13 @@ export function disable(workspaceId, reason) {
91
92
  console.info(`[auto-loop] disabled workspace '${workspaceId}' reason=${reason}` +
92
93
  ` pending=${diagnostic.tasksPending} streak=${diagnostic.noProgressStreak} status=${diagnostic.status}`);
93
94
  emitEphemeral(workspaceId, 'autoloop:disabled', diagnostic);
95
+ // The user's own hook, if they wrote one. Deliberately fired for EVERY
96
+ // reason, not only 'completed' like the cleanup script below: a loop that
97
+ // stalled is precisely the case worth being told about.
98
+ void lifecycleHookService.onAutoLoopDisabled(workspaceId, {
99
+ reason,
100
+ tasksPending: diagnostic.tasksPending,
101
+ });
94
102
  // The loop finished every task — run the project's cleanup script. Other
95
103
  // disable reasons (stall / error / user-action) leave tasks unfinished, so
96
104
  // they intentionally skip the cleanup.
@@ -262,6 +270,51 @@ function computeIterationNumber(workspaceId) {
262
270
  * Worktree-missing edge: if the worktree directory has been deleted on disk,
263
271
  * `orchestrator.startAgent` throws during engine.start — caught below.
264
272
  */
273
+ /**
274
+ * Whether another unattended session may start right now. `0` — the default —
275
+ * means no limit. Only the auto-loop consults this: a session the user starts
276
+ * by hand always goes through.
277
+ */
278
+ function hasFreeAgentSlot() {
279
+ const limit = settingsService.getGlobalSettings().maxConcurrentAgents;
280
+ if (!limit || limit <= 0)
281
+ return true;
282
+ return orchestrator.runningAgentCount() < limit;
283
+ }
284
+ /**
285
+ * Give every auto-loop workspace parked on "waiting for a free agent slot"
286
+ * another chance. Called by the orchestrator after ANY session ends: a parked
287
+ * workspace has no session of its own to end, so nothing else would ever ask
288
+ * spawnNextIteration on its behalf again.
289
+ *
290
+ * Stops as soon as the slots are full again, so with three parked and one
291
+ * free, exactly one starts and the other two keep waiting rather than each
292
+ * re-announcing that they wait.
293
+ */
294
+ export function resumeWaitingWorkspaces() {
295
+ const db = getDb();
296
+ const rows = db
297
+ .prepare(`SELECT id, status FROM workspaces
298
+ WHERE auto_loop = 1 AND auto_loop_ready = 1 AND archived_at IS NULL
299
+ ORDER BY updated_at ASC`)
300
+ .all();
301
+ for (const row of rows) {
302
+ if (!hasFreeAgentSlot())
303
+ return;
304
+ if (orchestrator.hasController(row.id))
305
+ continue;
306
+ // Same guards as spawnNextIteration's own callers: a quota backoff or a
307
+ // pending question owns the next start of that workspace.
308
+ if (row.status === 'awaiting-user' || row.status === 'quota')
309
+ continue;
310
+ try {
311
+ spawnNextIteration(row.id);
312
+ }
313
+ catch (err) {
314
+ console.error(`[auto-loop] resume after slot freed failed for workspace '${row.id}':`, err);
315
+ }
316
+ }
317
+ }
265
318
  function spawnNextIteration(workspaceId, opts = {}) {
266
319
  const row = getRow(workspaceId);
267
320
  if (!row)
@@ -274,6 +327,18 @@ function spawnNextIteration(workspaceId, opts = {}) {
274
327
  disable(workspaceId, 'completed');
275
328
  return;
276
329
  }
330
+ // Hold off rather than pile on. Ten auto-loop workspaces waking together
331
+ // hammer the same rate limit and each land in their own backoff, so nobody
332
+ // gets through. The loop stays enabled: the next session to end anywhere
333
+ // calls back in here, and by then a slot has freed.
334
+ if (!hasFreeAgentSlot()) {
335
+ console.log(`[auto-loop] workspace '${workspaceId}' is waiting for a free agent slot`);
336
+ emitEphemeral(workspaceId, 'autoloop:waiting-for-slot', {
337
+ running: orchestrator.runningAgentCount(),
338
+ limit: settingsService.getGlobalSettings().maxConcurrentAgents,
339
+ });
340
+ return;
341
+ }
277
342
  const iterationNumber = computeIterationNumber(workspaceId);
278
343
  // Override block: replaces the standard iteration prompt body when the task
279
344
  // title carries a recognized prefix (case-sensitive, trailing space required).
@@ -0,0 +1,115 @@
1
+ import { getDb } from '../db/index.js';
2
+ import { getGlobalSettings } from './settings-service.js';
3
+ import { broadcastAll } from './websocket-service.js';
4
+ /** How often the reminder loop looks at the clock. */
5
+ const TICK_INTERVAL_MS = 60_000;
6
+ /**
7
+ * Which waiting workspaces are owed a reminder now.
8
+ *
9
+ * `state` is updated in place: entries appear when a workspace starts waiting
10
+ * and disappear when it stops, so answering a question resets its clock.
11
+ *
12
+ * Pure apart from that mutation, which is what makes the interval arithmetic
13
+ * — the part that is easy to get wrong — testable without a timer or a DB.
14
+ */
15
+ export function computeDueReminders(params) {
16
+ const { awaiting, now, intervalMs, state } = params;
17
+ // Disabled. Clear the state too: re-enabling later should start from now,
18
+ // not from a clock that kept running while the feature was off.
19
+ if (intervalMs <= 0) {
20
+ state.clear();
21
+ return [];
22
+ }
23
+ const stillWaiting = new Set(awaiting.map((w) => w.id));
24
+ for (const id of state.keys()) {
25
+ if (!stillWaiting.has(id))
26
+ state.delete(id);
27
+ }
28
+ const due = [];
29
+ for (const workspace of awaiting) {
30
+ const existing = state.get(workspace.id);
31
+ if (!existing) {
32
+ // First sight. The question was just asked, so nothing is overdue yet.
33
+ state.set(workspace.id, { firstSeenAt: now, lastRemindedAt: null, remindersSent: 0 });
34
+ continue;
35
+ }
36
+ // Measured from the LAST reminder (or the first sighting), not as a
37
+ // multiple of the interval since the start: that way a change of interval
38
+ // applies from the next reminder, and a laptop waking from sleep owing
39
+ // four reminders sends one, not four, for the same unanswered question.
40
+ const since = existing.lastRemindedAt ?? existing.firstSeenAt;
41
+ if (now - since < intervalMs)
42
+ continue;
43
+ existing.lastRemindedAt = now;
44
+ existing.remindersSent += 1;
45
+ due.push({
46
+ id: workspace.id,
47
+ name: workspace.name,
48
+ waitingMs: now - existing.firstSeenAt,
49
+ reminderNumber: existing.remindersSent,
50
+ });
51
+ }
52
+ return due;
53
+ }
54
+ const reminderState = new Map();
55
+ let timer = null;
56
+ function listAwaitingWorkspaces() {
57
+ return getDb()
58
+ .prepare(`SELECT id, name FROM workspaces WHERE status = 'awaiting-user' AND archived_at IS NULL`)
59
+ .all();
60
+ }
61
+ /**
62
+ * One pass of the reminder loop. Broadcast rather than emitted to the
63
+ * workspace's subscribers: the whole point is to reach the user who is looking
64
+ * at something else, or at nothing.
65
+ *
66
+ * Never throws — it runs on a timer nobody is watching, and an exception here
67
+ * would silently end the loop for the rest of the process's life.
68
+ */
69
+ export function runReminderTick(now = Date.now()) {
70
+ try {
71
+ const minutes = getGlobalSettings().awaitingUserReminderMinutes;
72
+ // The API refuses more than a day; a hand-edited settings.json gets the
73
+ // same ceiling here rather than a reminder that fires next week.
74
+ const intervalMs = typeof minutes === 'number' && minutes > 0 ? Math.min(minutes, 1440) * 60_000 : 0;
75
+ if (intervalMs <= 0) {
76
+ reminderState.clear();
77
+ return;
78
+ }
79
+ for (const reminder of computeDueReminders({
80
+ awaiting: listAwaitingWorkspaces(),
81
+ now,
82
+ intervalMs,
83
+ state: reminderState,
84
+ })) {
85
+ broadcastAll('workspace:awaiting-reminder', {
86
+ workspaceId: reminder.id,
87
+ workspaceName: reminder.name,
88
+ waitingMinutes: Math.round(reminder.waitingMs / 60_000),
89
+ reminderNumber: reminder.reminderNumber,
90
+ });
91
+ }
92
+ }
93
+ catch (err) {
94
+ console.error('[awaiting-user-reminder] tick failed:', err);
95
+ }
96
+ }
97
+ /** Start the reminder loop. Idempotent. */
98
+ export function startAwaitingUserReminder() {
99
+ if (timer)
100
+ return;
101
+ timer = setInterval(() => runReminderTick(), TICK_INTERVAL_MS);
102
+ timer.unref?.();
103
+ }
104
+ /** Stop the reminder loop. */
105
+ export function stopAwaitingUserReminder() {
106
+ if (timer) {
107
+ clearInterval(timer);
108
+ timer = null;
109
+ }
110
+ }
111
+ /** @internal */
112
+ export function _resetForTest() {
113
+ stopAwaitingUserReminder();
114
+ reminderState.clear();
115
+ }
@@ -109,7 +109,7 @@ async function runBuiltInStrategy(workspaceId, worktreePath, workingBranch, proj
109
109
  // the branchExists check below is the authoritative gate for the new base
110
110
  // (it throws a clean error, mapped to a 400, rather than a raw fetch error).
111
111
  try {
112
- gitOps.fetchAllBranches(worktreePath);
112
+ await gitOps.fetchAllBranchesAsync(worktreePath);
113
113
  }
114
114
  catch {
115
115
  // offline / no remote — proceed with local refs
@@ -18,12 +18,30 @@ function readRemoteUrl(projectPath) {
18
18
  return execFileSync('git', ['remote', 'get-url', 'origin'], {
19
19
  cwd: projectPath,
20
20
  encoding: 'utf-8',
21
+ // Without a bound, a git hanging on a network filesystem blocks the whole
22
+ // event loop: this runs synchronously, and the pr-watcher calls it for
23
+ // every workspace on every tick.
24
+ timeout: 5_000,
21
25
  }).trim();
22
26
  }
23
27
  catch {
24
28
  return '';
25
29
  }
26
30
  }
31
+ /**
32
+ * Auto-detected forge per project path. The remote of a checkout does not
33
+ * change while Kōbō runs, and resolving it costs a synchronous subprocess that
34
+ * the pr-watcher would otherwise pay per workspace, per 30 s tick, twice.
35
+ * Bounded by TTL so a remote that is added or repointed is picked up without a
36
+ * restart, and only the `auto` path is cached — an explicit setting is already
37
+ * free to read.
38
+ */
39
+ const autoForgeCache = new Map();
40
+ const AUTO_FORGE_TTL_MS = 5 * 60_000;
41
+ /** Drop the cached auto-detection. Exported for tests. */
42
+ export function _clearForgeCache() {
43
+ autoForgeCache.clear();
44
+ }
27
45
  /**
28
46
  * Resolve the forge for a project: the explicit per-project setting wins;
29
47
  * `auto` (the default) classifies the origin remote URL.
@@ -33,5 +51,15 @@ export function resolveForge(projectPath) {
33
51
  if (setting === 'github' || setting === 'gitlab' || setting === 'bitbucket-community' || setting === 'none') {
34
52
  return setting;
35
53
  }
36
- return forgeFromRemoteUrl(readRemoteUrl(projectPath));
54
+ const cached = autoForgeCache.get(projectPath);
55
+ if (cached && Date.now() - cached.readAt < AUTO_FORGE_TTL_MS)
56
+ return cached.forge;
57
+ const remoteUrl = readRemoteUrl(projectPath);
58
+ const forge = forgeFromRemoteUrl(remoteUrl);
59
+ // A read that failed (git timing out on a network mount, a transient lock)
60
+ // is not knowledge: caching it would disable PR actions for five minutes on
61
+ // a project that has a perfectly good remote.
62
+ if (remoteUrl)
63
+ autoForgeCache.set(projectPath, { forge, readAt: Date.now() });
64
+ return forge;
37
65
  }