@loicngr/kobo 1.11.26 → 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 (182) hide show
  1. package/AGENTS.md +19 -1
  2. package/CHANGELOG.md +10 -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 +240 -91
  15. package/dist/server/services/agent/engines/claude-code/engine.js +112 -15
  16. package/dist/server/services/agent/engines/codex/engine.js +18 -0
  17. package/dist/server/services/agent/engines/codex/server-requests.js +5 -20
  18. package/dist/server/services/agent/orchestrator.js +143 -34
  19. package/dist/server/services/agent/session-controller.js +28 -0
  20. package/dist/server/services/agent/streaming-batcher.js +1 -0
  21. package/dist/server/services/agent/turn-liveness.js +12 -1
  22. package/dist/server/services/auto-loop-service.js +65 -0
  23. package/dist/server/services/awaiting-user-reminder-service.js +115 -0
  24. package/dist/server/services/change-source-branch-service.js +1 -1
  25. package/dist/server/services/file-editor-service.js +2 -46
  26. package/dist/server/services/forge/bitbucket/provider.js +9 -8
  27. package/dist/server/services/forge/github/provider.js +4 -3
  28. package/dist/server/services/forge/gitlab/provider.js +4 -3
  29. package/dist/server/services/forge/parse-cli-json.js +14 -0
  30. package/dist/server/services/forge/resolve.js +29 -1
  31. package/dist/server/services/lifecycle-hook-service.js +114 -0
  32. package/dist/server/services/network-access-service.js +187 -6
  33. package/dist/server/services/pr-watcher-service.js +27 -1
  34. package/dist/server/services/settings-service.js +147 -4
  35. package/dist/server/services/usage/reliability.js +81 -0
  36. package/dist/server/services/wakeup-service.js +23 -0
  37. package/dist/server/services/websocket-service.js +31 -3
  38. package/dist/server/services/workspace-service.js +36 -4
  39. package/dist/server/services/worktree-purge-service.js +1 -1
  40. package/dist/server/utils/git-ops.js +159 -1
  41. package/dist/server/utils/paths.js +19 -0
  42. package/dist/server/utils/safe-path.js +21 -1
  43. package/dist/server/utils/script-runner.js +2 -1
  44. package/dist/server/utils/workspace-name.js +37 -0
  45. package/dist/shared/consts.js +22 -0
  46. package/dist/shared/models.js +4 -4
  47. package/package.json +3 -2
  48. package/src/client/dist/pwa/assets/{ActivityFeed-DUjY6lCm.css → ActivityFeed-C6XSxdJ0.css} +1 -1
  49. package/src/client/dist/pwa/assets/ActivityFeed-LDzg5XPx.js +8 -0
  50. package/src/client/dist/pwa/assets/ChangelogPage-CjiWI9Qa.js +1 -0
  51. package/src/client/dist/pwa/assets/ClosePopup-CK6wvsMy.js +1 -0
  52. package/src/client/dist/pwa/assets/ComparisonPanel-C0hI2D1O.css +1 -0
  53. package/src/client/dist/pwa/assets/ComparisonPanel-D6eEciwQ.js +1 -0
  54. package/src/client/dist/pwa/assets/CreatePage-DHBa7ZL5.js +2 -0
  55. package/src/client/dist/pwa/assets/CreatePage-DeKYK6uC.css +1 -0
  56. package/src/client/dist/pwa/assets/DashboardPage-BqgK4lAf.css +1 -0
  57. package/src/client/dist/pwa/assets/DashboardPage-Xev4suQ7.js +1 -0
  58. package/src/client/dist/pwa/assets/DiffViewer-D0BQ5Dip.js +8 -0
  59. package/src/client/dist/pwa/assets/DiffViewer-DnR79ycb.css +1 -0
  60. package/src/client/dist/pwa/assets/DocumentsPanel-7hMCYUTd.js +1 -0
  61. package/src/client/dist/pwa/assets/DrawerToggleButton-BASeszPg.js +1 -0
  62. package/src/client/dist/pwa/assets/GitPanel-D-rwHnFn.js +3 -0
  63. package/src/client/dist/pwa/assets/{GitPanel-BKrtkPrV.css → GitPanel-DVs5BCU-.css} +1 -1
  64. package/src/client/dist/pwa/assets/HealthPage-BKXqP1li.js +1 -0
  65. package/src/client/dist/pwa/assets/MainLayout-DnPtpWgP.js +2 -0
  66. package/src/client/dist/pwa/assets/MainLayout-ExB0xYHB.css +1 -0
  67. package/src/client/dist/pwa/assets/QBanner-CDiXUya7.js +1 -0
  68. package/src/client/dist/pwa/assets/QBtnDropdown-52YHuBbx.js +1 -0
  69. package/src/client/dist/pwa/assets/QChip-DpUYTCOJ.js +1 -0
  70. package/src/client/dist/pwa/assets/QItemSection-CEUi4Tz0.js +1 -0
  71. package/src/client/dist/pwa/assets/QMarkupTable-DcoQpjPu.js +1 -0
  72. package/src/client/dist/pwa/assets/SchedulePanel-C7NK1hIK.js +1 -0
  73. package/src/client/dist/pwa/assets/SearchPage-Cm8chltA.js +1 -0
  74. package/src/client/dist/pwa/assets/{SettingsPage-CDZhPV6A.css → SettingsPage-2vdOZu1C.css} +1 -1
  75. package/src/client/dist/pwa/assets/SettingsPage-B4xs1RUl.js +10 -0
  76. package/src/client/dist/pwa/assets/TerminalPanel-CSPk8QtK.js +18 -0
  77. package/src/client/dist/pwa/assets/{TerminalPanel-B6xJJs8p.css → TerminalPanel-DbVZk0vD.css} +1 -1
  78. package/src/client/dist/pwa/assets/TouchPan-CpO61wPy.js +1 -0
  79. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +1 -0
  80. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DZrbJ0vD.js +1 -0
  81. package/src/client/dist/pwa/assets/WorkspacePage-D0-FkRVJ.js +4 -0
  82. package/src/client/dist/pwa/assets/browser-Bg25_sDh.js +8 -0
  83. package/src/client/dist/pwa/assets/build-path-tree-DH4SSsEz.js +1 -0
  84. package/src/client/dist/pwa/assets/de-Bf_OsgDw.js +17 -0
  85. package/src/client/dist/pwa/assets/documents-CDZqd_g5.js +1 -0
  86. package/src/client/dist/pwa/assets/editor-BJU3Xb3h.css +1 -0
  87. package/src/client/dist/pwa/assets/editor.api-CMsYdZTX.js +413 -0
  88. package/src/client/dist/pwa/assets/{efforts-BPZzMyrc.js → efforts-Db4w0alg.js} +1 -1
  89. package/src/client/dist/pwa/assets/engineFeatures-BjtQR0vB.js +1 -0
  90. package/src/client/dist/pwa/assets/es-1ib0e9jm.js +17 -0
  91. package/src/client/dist/pwa/assets/expand-template-CTUNyJrv.css +1 -0
  92. package/src/client/dist/pwa/assets/expand-template-DY8ntqUb.js +1 -0
  93. package/src/client/dist/pwa/assets/format-C-9jMgWb.js +435 -0
  94. package/src/client/dist/pwa/assets/format-CX6Cv1cD.css +1 -0
  95. package/src/client/dist/pwa/assets/{formatters-D0dU6VcA.js → formatters-CQ90Icr4.js} +1 -1
  96. package/src/client/dist/pwa/assets/fr-nj0BrM3X.js +17 -0
  97. package/src/client/dist/pwa/assets/{freemarker2-BqVyQSGe.js → freemarker2-KxB1BtrL.js} +1 -1
  98. package/src/client/dist/pwa/assets/{handlebars-9K762gzW.js → handlebars-7TByd_Zc.js} +1 -1
  99. package/src/client/dist/pwa/assets/{html-CRmp4AC_.js → html-BoVZ_gre.js} +1 -1
  100. package/src/client/dist/pwa/assets/i18n-BubYp85n.js +1 -0
  101. package/src/client/dist/pwa/assets/{index-BpmxQlZi.css → index-6H3m6vH7.css} +1 -1
  102. package/src/client/dist/pwa/assets/index-Ck1_qrff.js +18 -0
  103. package/src/client/dist/pwa/assets/it-B9MEgvg1.js +17 -0
  104. package/src/client/dist/pwa/assets/{javascript-DB9a_ZGm.js → javascript-BLL8vPQy.js} +1 -1
  105. package/src/client/dist/pwa/assets/layout-CFuk4mdG.js +1 -0
  106. package/src/client/dist/pwa/assets/{liquid-C67ZTBj7.js → liquid-DzcT-R6k.js} +1 -1
  107. package/src/client/dist/pwa/assets/{mdx-C_K9rc9_.js → mdx-Bguo999w.js} +1 -1
  108. package/src/client/dist/pwa/assets/monaco-contributions-CM7FH-iZ.js +51 -0
  109. package/src/client/dist/pwa/assets/monaco-contributions-Cb0afGfW.css +1 -0
  110. package/src/client/dist/pwa/assets/monaco.contribution-Dkq0qtWA.js +2 -0
  111. package/src/client/dist/pwa/assets/network-auth-C0moem8_.js +1 -0
  112. package/src/client/dist/pwa/assets/{notify-theme-thUiJn9n.js → notify-theme-C7mNmzKx.js} +1 -1
  113. package/src/client/dist/pwa/assets/pending-diff-open-BaQ_SNEF.js +1 -0
  114. package/src/client/dist/pwa/assets/permissionModes-CJZsT9Fl.js +36 -0
  115. package/src/client/dist/pwa/assets/pinia-ClsyLh3i.js +1 -0
  116. package/src/client/dist/pwa/assets/{project-color-hgNWqp0x.js → project-color-54JZHVy2.js} +1 -1
  117. package/src/client/dist/pwa/assets/{python-D1CBO9Uf.js → python-D5RQNiPF.js} +1 -1
  118. package/src/client/dist/pwa/assets/{razor-BCiKSj6_.js → razor-CVwoDJol.js} +1 -1
  119. package/src/client/dist/pwa/assets/{render-chat-markdown-Cw1_NLqs.js → render-chat-markdown-NAmPVIQp.js} +1 -1
  120. package/src/client/dist/pwa/assets/rolldown-runtime-hePW80VL.js +1 -0
  121. package/src/client/dist/pwa/assets/{typescript-D-8UENST.js → typescript-qdgwClKe.js} +1 -1
  122. package/src/client/dist/pwa/assets/use-checkbox-3io6bTPe.js +1 -0
  123. package/src/client/dist/pwa/assets/use-onboarding-DbYF8ZX_.js +2 -0
  124. package/src/client/dist/pwa/assets/use-quasar-B4eIgSfZ.js +1 -0
  125. package/src/client/dist/pwa/assets/vue-i18n-DtajJy_8.js +3 -0
  126. package/src/client/dist/pwa/assets/{xml-BBPvJ9QP.js → xml-DNSfQSlk.js} +1 -1
  127. package/src/client/dist/pwa/assets/{yaml-D4q1p7HX.js → yaml-6gnX2NnP.js} +1 -1
  128. package/src/client/dist/pwa/index.html +6 -5
  129. package/src/client/dist/pwa/sw.js +1 -1
  130. package/src/mcp-server/kobo-tasks-handlers.ts +17 -1
  131. package/src/client/dist/pwa/assets/ActivityFeed-CGMEOMn5.js +0 -8
  132. package/src/client/dist/pwa/assets/ChangelogPage-rvISZfJb.js +0 -1
  133. package/src/client/dist/pwa/assets/ClosePopup-BK6mNWsy.js +0 -1
  134. package/src/client/dist/pwa/assets/CreatePage-BCuDTB_0.css +0 -1
  135. package/src/client/dist/pwa/assets/CreatePage-BptAgVvu.js +0 -2
  136. package/src/client/dist/pwa/assets/DiffViewer-ZrvQ0YWN.js +0 -8
  137. package/src/client/dist/pwa/assets/DiffViewer-ahWtFrRy.css +0 -1
  138. package/src/client/dist/pwa/assets/DocumentsPanel-DuWbZiID.js +0 -1
  139. package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +0 -1
  140. package/src/client/dist/pwa/assets/GitPanel-BiIXQPfL.js +0 -3
  141. package/src/client/dist/pwa/assets/HealthPage-DTrmhh3X.js +0 -1
  142. package/src/client/dist/pwa/assets/MainLayout-PSmlvfJd.js +0 -2
  143. package/src/client/dist/pwa/assets/MainLayout-Vq1iUPjx.css +0 -1
  144. package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +0 -1
  145. package/src/client/dist/pwa/assets/QBtnDropdown-Ckvd3IVR.js +0 -1
  146. package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +0 -1
  147. package/src/client/dist/pwa/assets/QItemSection-5GBV9MR2.js +0 -1
  148. package/src/client/dist/pwa/assets/SchedulePanel-DJWMsAoh.js +0 -1
  149. package/src/client/dist/pwa/assets/SearchPage-BI8w9utc.js +0 -1
  150. package/src/client/dist/pwa/assets/SettingsPage-Dr8oSevA.js +0 -16
  151. package/src/client/dist/pwa/assets/TerminalPanel-DVC58DJb.js +0 -18
  152. package/src/client/dist/pwa/assets/TouchPan-pqksSni0.js +0 -1
  153. package/src/client/dist/pwa/assets/WorkspacePage-DRP9Ypmz.js +0 -4
  154. package/src/client/dist/pwa/assets/build-path-tree-CQ4zZqo0.js +0 -1
  155. package/src/client/dist/pwa/assets/css.worker-JKZQyld4.js +0 -93
  156. package/src/client/dist/pwa/assets/cssMode-MGWKgqj7.js +0 -1
  157. package/src/client/dist/pwa/assets/documents-B6eOZeFK.js +0 -1
  158. package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +0 -1
  159. package/src/client/dist/pwa/assets/editor.api-Dq41Mm99.js +0 -847
  160. package/src/client/dist/pwa/assets/engineFeatures-Dd_dxoOS.js +0 -1
  161. package/src/client/dist/pwa/assets/expand-template-B4LAsXAF.css +0 -1
  162. package/src/client/dist/pwa/assets/expand-template-BfZqON3q.js +0 -1
  163. package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +0 -506
  164. package/src/client/dist/pwa/assets/htmlMode-CFxFF9iH.js +0 -1
  165. package/src/client/dist/pwa/assets/i18n-BsWCIFmf.js +0 -1
  166. package/src/client/dist/pwa/assets/index-xTf0VfFG.js +0 -82
  167. package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +0 -62
  168. package/src/client/dist/pwa/assets/jsonMode-JhLTE1-C.js +0 -7
  169. package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +0 -1
  170. package/src/client/dist/pwa/assets/lspLanguageFeatures-BIDd3ml0.js +0 -4
  171. package/src/client/dist/pwa/assets/network-auth-BPxfeLcl.js +0 -1
  172. package/src/client/dist/pwa/assets/pending-diff-open-BdA8vrDx.js +0 -1
  173. package/src/client/dist/pwa/assets/permissionModes-D09rnNgv.js +0 -36
  174. package/src/client/dist/pwa/assets/ts.worker-C-oGa8JT.js +0 -67738
  175. package/src/client/dist/pwa/assets/tsMode-Db77HzVu.js +0 -11
  176. package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +0 -1
  177. package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +0 -1
  178. package/src/client/dist/pwa/assets/use-onboarding-BafrD9nc.js +0 -2
  179. package/src/client/dist/pwa/assets/vs-Bb18eQC8.js +0 -56
  180. package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +0 -1
  181. package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +0 -3
  182. package/src/client/dist/pwa/assets/workers-CImko7IX.js +0 -1
@@ -11,6 +11,7 @@ export function createTurnLiveness(input) {
11
11
  let active = false;
12
12
  let paused = false;
13
13
  let timedOut = false;
14
+ let currentTimeoutMs = input.timeoutMs;
14
15
  const arm = () => {
15
16
  if (!active || paused || timedOut)
16
17
  return;
@@ -22,7 +23,8 @@ export function createTurnLiveness(input) {
22
23
  return;
23
24
  timedOut = true;
24
25
  input.onTimeout();
25
- }, input.timeoutMs);
26
+ }, currentTimeoutMs);
27
+ timer.unref?.();
26
28
  };
27
29
  return {
28
30
  start() {
@@ -53,5 +55,14 @@ export function createTurnLiveness(input) {
53
55
  clearTimeout(timer);
54
56
  timer = undefined;
55
57
  },
58
+ setTimeoutMs(ms) {
59
+ if (ms === currentTimeoutMs)
60
+ return;
61
+ currentTimeoutMs = ms;
62
+ // Re-arm only if a deadline is currently pending; a paused/stopped
63
+ // liveness picks the new duration up on its next arm().
64
+ if (timer)
65
+ arm();
66
+ },
56
67
  };
57
68
  }
@@ -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
@@ -1,7 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { realpathSync } from 'node:fs';
3
- import * as path from 'node:path';
4
2
  import { getFileContent, writeFileInWorktree } from '../utils/git-ops.js';
3
+ import { resolvePathInside } from '../utils/safe-path.js';
5
4
  const MAX_FILE_BYTES = 1024 * 1024;
6
5
  export function shaOf(content) {
7
6
  return createHash('sha256').update(content, 'utf8').digest('hex');
@@ -12,7 +11,7 @@ export function shaOf(content) {
12
11
  * escapes the worktree (including via symlinks), or when content exceeds 1 MB.
13
12
  */
14
13
  export function saveWorkspaceFile(worktreePath, relativePath, content, baseSha) {
15
- const absPath = resolveSafe(worktreePath, relativePath);
14
+ const absPath = resolvePathInside(worktreePath, relativePath);
16
15
  if (Buffer.byteLength(content, 'utf8') > MAX_FILE_BYTES) {
17
16
  throw new Error(`File too large (max ${MAX_FILE_BYTES / 1024 / 1024} MB)`);
18
17
  }
@@ -24,46 +23,3 @@ export function saveWorkspaceFile(worktreePath, relativePath, content, baseSha)
24
23
  writeFileInWorktree(absPath, content);
25
24
  return { status: 'saved' };
26
25
  }
27
- function resolveSafe(worktreePath, relativePath) {
28
- const abs = path.resolve(worktreePath, relativePath);
29
- const lexicalRoot = path.resolve(worktreePath);
30
- // Lexical containment check FIRST: a path that escapes the worktree via `..`
31
- // (or an absolute path) must be reported as an escape deterministically —
32
- // independent of whether the traversal happens to land on a directory that
33
- // exists on disk. Without this, `../../etc/passwd` surfaces as "parent
34
- // directory does not exist" instead of "escapes the worktree" whenever the
35
- // worktree sits deep enough that the `..` chain lands on a missing dir.
36
- if (abs !== lexicalRoot && !abs.startsWith(lexicalRoot + path.sep)) {
37
- throw new Error(`Path '${relativePath}' escapes the worktree`);
38
- }
39
- const root = realpathSync(lexicalRoot);
40
- const rootWithSep = root + path.sep;
41
- // Resolve the parent's realpath (parent must exist; if it doesn't, the path
42
- // is invalid anyway and we surface that). Then join the lexical basename so
43
- // a non-existent leaf doesn't trigger ENOENT.
44
- let realParent;
45
- try {
46
- realParent = realpathSync(path.dirname(abs));
47
- }
48
- catch {
49
- throw new Error(`Path '${relativePath}' is invalid (parent directory does not exist)`);
50
- }
51
- const realAbs = path.join(realParent, path.basename(abs));
52
- if (realAbs !== root && !realAbs.startsWith(rootWithSep)) {
53
- throw new Error(`Path '${relativePath}' escapes the worktree`);
54
- }
55
- // If the leaf exists and is itself a symlink, follow it and re-check
56
- // containment so we never write through a symlink that escapes the worktree.
57
- try {
58
- const leafReal = realpathSync(realAbs);
59
- if (leafReal !== root && !leafReal.startsWith(rootWithSep)) {
60
- throw new Error(`Path '${relativePath}' escapes the worktree`);
61
- }
62
- return leafReal;
63
- }
64
- catch (err) {
65
- if (err.code === 'ENOENT')
66
- return realAbs;
67
- throw err;
68
- }
69
- }
@@ -1,6 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { getGlobalSettings } from '../../settings-service.js';
4
+ import { parseCliJson } from '../parse-cli-json.js';
4
5
  import { deriveReadyToMerge } from '../types.js';
5
6
  const execFileAsync = promisify(execFile);
6
7
  function record(value) {
@@ -233,14 +234,14 @@ async function resolveTarget(repoPath) {
233
234
  if (bktEnv().BKT_TOKEN)
234
235
  return { ...target, context: null };
235
236
  const { stdout: contextOutput } = await execFileAsync('bkt', ['context', 'list', '--json'], bktOptions(repoPath));
236
- const contexts = record(JSON.parse(contextOutput)).contexts;
237
+ const contexts = record(parseCliJson(contextOutput, 'bkt context list')).contexts;
237
238
  const existing = (Array.isArray(contexts) ? contexts : [])
238
239
  .map(record)
239
240
  .find((context) => string(context.workspace) === target.workspace && string(context.default_repo) === target.repo);
240
241
  if (existing && string(existing.name))
241
242
  return { ...target, context: string(existing.name) };
242
243
  const { stdout: authOutput } = await execFileAsync('bkt', ['auth', 'status', '--json'], bktOptions(repoPath));
243
- const hosts = record(JSON.parse(authOutput)).hosts;
244
+ const hosts = record(parseCliJson(authOutput, 'bkt auth status')).hosts;
244
245
  const host = (Array.isArray(hosts) ? hosts : []).map(record).find((item) => string(item.kind) === 'cloud');
245
246
  const hostKey = string(host?.key);
246
247
  if (!hostKey)
@@ -255,7 +256,7 @@ function targetArgs(target) {
255
256
  async function fetchCi(repoPath, number, target) {
256
257
  try {
257
258
  const { stdout } = await execFileAsync('bkt', bktArgs(target, ['pr', 'checks', String(number), ...targetArgs(target), '--json']), bktOptions(repoPath));
258
- const statuses = record(JSON.parse(stdout)).statuses;
259
+ const statuses = record(parseCliJson(stdout, 'bkt pr checks')).statuses;
259
260
  const checks = (Array.isArray(statuses) ? statuses : []).map(mapCheck);
260
261
  const rollup = checks.length === 0
261
262
  ? null
@@ -286,7 +287,7 @@ export const bitbucketProvider = {
286
287
  async isAvailable(repoPath) {
287
288
  try {
288
289
  const { stdout } = await execFileAsync('bkt', ['auth', 'status', '--json'], bktOptions(repoPath));
289
- const status = record(JSON.parse(stdout));
290
+ const status = record(parseCliJson(stdout, 'bkt auth status'));
290
291
  const hosts = status.hosts;
291
292
  if ((Array.isArray(hosts) && hosts.length > 0) || bktEnv().BKT_TOKEN)
292
293
  return { available: true };
@@ -304,7 +305,7 @@ export const bitbucketProvider = {
304
305
  // branch deletion, so do not stop at OPEN requests.
305
306
  for (const state of ['OPEN', 'MERGED', 'DECLINED']) {
306
307
  const { stdout: listOutput } = await execFileAsync('bkt', bktArgs(target, ['pr', 'list', '--state', state, '--limit', '50', ...targetArgs(target), '--json']), bktOptions(repoPath));
307
- const pullRequests = record(JSON.parse(listOutput)).pull_requests;
308
+ const pullRequests = record(parseCliJson(listOutput, 'bkt pr list')).pull_requests;
308
309
  match = (Array.isArray(pullRequests) ? pullRequests : [])
309
310
  .map(record)
310
311
  .find((pullRequest) => branch(pullRequest, 'source', 'fromRef') === branchName);
@@ -314,7 +315,7 @@ export const bitbucketProvider = {
314
315
  if (!match)
315
316
  return null;
316
317
  const { stdout: viewOutput } = await execFileAsync('bkt', bktArgs(target, ['pr', 'view', String(number(match.id)), ...targetArgs(target), '--json']), bktOptions(repoPath));
317
- const raw = record(record(JSON.parse(viewOutput)).pull_request);
318
+ const raw = record(record(parseCliJson(viewOutput, 'bkt pr view')).pull_request);
318
319
  if (!number(raw.id))
319
320
  return null;
320
321
  const ci = await fetchCi(repoPath, number(raw.id), target);
@@ -340,7 +341,7 @@ export const bitbucketProvider = {
340
341
  ...targetArgs(target),
341
342
  '--json',
342
343
  ]), bktOptions(repoPath));
343
- const raw = record(JSON.parse(stdout));
344
+ const raw = record(parseCliJson(stdout, 'bkt pr create'));
344
345
  const prNumber = number(raw.id);
345
346
  const prUrl = string(raw.url);
346
347
  if (!prNumber || !prUrl)
@@ -363,7 +364,7 @@ export const bitbucketProvider = {
363
364
  if (opts.cursor)
364
365
  args.push('--page', opts.cursor);
365
366
  const { stdout } = await execFileAsync('bkt', bktArgs(target, args), bktOptions(repoPath));
366
- const page = mapBitbucketPage(JSON.parse(stdout || '{}'));
367
+ const page = mapBitbucketPage(parseCliJson(stdout || '{}', 'bkt pr list'));
367
368
  if (opts.filter === 'all' && !opts.search?.trim())
368
369
  return page;
369
370
  const currentUser = getGlobalSettings().bitbucketUsername || undefined;
@@ -1,6 +1,7 @@
1
1
  // src/server/services/forge/github/provider.ts
2
2
  import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
+ import { parseCliJson } from '../parse-cli-json.js';
4
5
  import { deriveReadyToMerge } from '../types.js';
5
6
  const execFileAsync = promisify(execFile);
6
7
  const CLI_TIMEOUT_MS = 30_000;
@@ -174,7 +175,7 @@ export const githubProvider = {
174
175
  const raw = stdout.trim();
175
176
  if (!raw)
176
177
  return null;
177
- return mapGhPrToSnapshot(JSON.parse(raw));
178
+ return mapGhPrToSnapshot(parseCliJson(raw, 'gh pr view'));
178
179
  }
179
180
  catch {
180
181
  return null;
@@ -198,7 +199,7 @@ export const githubProvider = {
198
199
  },
199
200
  async listPullRequests(repoPath, opts) {
200
201
  const { stdout: repoJson } = await execFileAsync('gh', ['repo', 'view', '--json', 'nameWithOwner'], cliOptions(repoPath));
201
- const nameWithOwner = String(JSON.parse(repoJson).nameWithOwner ?? '');
202
+ const nameWithOwner = String(parseCliJson(repoJson, 'gh repo view').nameWithOwner ?? '');
202
203
  if (!nameWithOwner)
203
204
  throw new Error('Could not determine the GitHub repository for this path');
204
205
  const args = [
@@ -214,6 +215,6 @@ export const githubProvider = {
214
215
  if (opts.cursor)
215
216
  args.push('-f', `after=${opts.cursor}`);
216
217
  const { stdout } = await execFileAsync('gh', args, cliOptions(repoPath));
217
- return mapGithubSearchPage(JSON.parse(stdout));
218
+ return mapGithubSearchPage(parseCliJson(stdout, 'gh api graphql (list PRs)'));
218
219
  },
219
220
  };
@@ -1,6 +1,7 @@
1
1
  // src/server/services/forge/gitlab/provider.ts
2
2
  import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
+ import { parseCliJson } from '../parse-cli-json.js';
4
5
  import { deriveReadyToMerge } from '../types.js';
5
6
  const execFileAsync = promisify(execFile);
6
7
  const CLI_TIMEOUT_MS = 30_000;
@@ -108,7 +109,7 @@ async function fetchGlabCi(repoPath, branch) {
108
109
  const raw = stdout.trim();
109
110
  if (!raw)
110
111
  return { rollup: null, checks: [] };
111
- const pipeline = JSON.parse(raw);
112
+ const pipeline = parseCliJson(raw, 'glab ci get');
112
113
  return {
113
114
  rollup: mapPipelineRollup(pipeline.status),
114
115
  checks: (pipeline.jobs ?? []).map(mapGlabJobToCheck),
@@ -192,7 +193,7 @@ export const gitlabProvider = {
192
193
  const raw = stdout.trim();
193
194
  if (!raw)
194
195
  return null;
195
- const snapshot = mapGlabMrToSnapshot(JSON.parse(raw));
196
+ const snapshot = mapGlabMrToSnapshot(parseCliJson(raw, 'glab mr view'));
196
197
  snapshot.ci = await fetchGlabCi(repoPath, branch);
197
198
  snapshot.readyToMerge = deriveReadyToMerge(snapshot);
198
199
  return snapshot;
@@ -231,6 +232,6 @@ export const gitlabProvider = {
231
232
  },
232
233
  async listPullRequests(repoPath, opts) {
233
234
  const { stdout } = await execFileAsync('glab', buildGitlabListArgs(opts), cliOptions(repoPath));
234
- return mapGitlabPage(JSON.parse(stdout || '[]'), opts);
235
+ return mapGitlabPage(parseCliJson(stdout || '[]', 'glab mr list'), opts);
235
236
  },
236
237
  };
@@ -0,0 +1,14 @@
1
+ /** JSON.parse with a message a user can act on: a truncated or non-JSON CLI
2
+ * response otherwise surfaces as a raw "Unexpected token…" 500. */
3
+ export function parseCliJson(raw, context) {
4
+ try {
5
+ return JSON.parse(raw);
6
+ }
7
+ catch (err) {
8
+ // Keep the diagnostic trail: the user-facing message stays terse, but the
9
+ // original parse error and a sample of the offending output land in the
10
+ // server log so "unparsable output" reports can actually be investigated.
11
+ console.error(`[forge] ${context} returned unparsable output:`, err, raw.slice(0, 200));
12
+ throw new Error(`${context} returned unparsable output`);
13
+ }
14
+ }
@@ -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
  }
@@ -0,0 +1,114 @@
1
+ import fs from 'node:fs';
2
+ import { getDb } from '../db/index.js';
3
+ import { slugifyProjectName } from '../utils/project-slug.js';
4
+ import { runScript } from '../utils/script-runner.js';
5
+ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
6
+ import { getEffectiveSettings, getGlobalSettings, getProjectSettings } from './settings-service.js';
7
+ /** Which effective-settings key holds the script for each event. */
8
+ const SCRIPT_KEY = {
9
+ 'session-ended': 'sessionEndedScript',
10
+ 'pr-merged': 'prMergedScript',
11
+ 'autoloop-disabled': 'autoLoopDisabledScript',
12
+ };
13
+ function getRow(workspaceId) {
14
+ const row = getDb()
15
+ .prepare(`SELECT id, name, project_path, working_branch, source_branch, worktree_path
16
+ FROM workspaces WHERE id = ?`)
17
+ .get(workspaceId);
18
+ return row ?? null;
19
+ }
20
+ /**
21
+ * Run the user's hook for `event`, if they configured one.
22
+ *
23
+ * Resolves to `null` when there is nothing to run — no script, unknown
24
+ * workspace, or a worktree that is no longer on disk — and to the script's exit
25
+ * code otherwise. Never rejects: the caller is always in the middle of a
26
+ * lifecycle transition that must complete regardless.
27
+ */
28
+ export async function runLifecycleHook(event, workspaceId, extraEnv) {
29
+ try {
30
+ const row = getRow(workspaceId);
31
+ if (!row)
32
+ return null;
33
+ const script = getEffectiveSettings(row.project_path)[SCRIPT_KEY[event]];
34
+ // Empty (or blank) means the user never wrote one. Hooks are opt-in: they
35
+ // run arbitrary shell, so absence of configuration is absence of a hook.
36
+ if (!script.trim())
37
+ return null;
38
+ const global = getGlobalSettings();
39
+ const projectSettings = getProjectSettings(row.project_path);
40
+ const projectSlug = global.worktreesPrefixByProject
41
+ ? slugifyProjectName(projectSettings?.displayName ?? '', row.project_path)
42
+ : undefined;
43
+ const worktreePath = row.worktree_path ??
44
+ resolveWorkspaceWorktreePath(row.project_path, row.working_branch, global.worktreesPath, projectSlug);
45
+ // A purged or manually deleted worktree has no cwd to spawn in. Routine
46
+ // for `pr-merged` once auto-purge has run; for the other events it means
47
+ // the user's script silently did not run, which deserves a line in the log.
48
+ if (!fs.existsSync(worktreePath)) {
49
+ if (event !== 'pr-merged') {
50
+ console.warn(`[lifecycle-hook] '${event}' hook skipped for workspace '${workspaceId}': worktree missing at ${worktreePath}`);
51
+ }
52
+ return null;
53
+ }
54
+ return await runScript({
55
+ workspaceId,
56
+ worktreePath,
57
+ script,
58
+ // Namespaced per event: three hooks streaming into one `hook:output` feed
59
+ // would be indistinguishable in the UI.
60
+ eventPrefix: `hook:${event}`,
61
+ tmpFileName: `.hook-${event}.tmp`,
62
+ env: {
63
+ workspaceName: row.name,
64
+ branchName: row.working_branch,
65
+ sourceBranch: row.source_branch,
66
+ projectPath: row.project_path,
67
+ },
68
+ extraEnv: { ...extraEnv, KOBO_HOOK_EVENT: event },
69
+ });
70
+ }
71
+ catch (err) {
72
+ console.error(`[lifecycle-hook] '${event}' hook failed for workspace '${workspaceId}':`, err);
73
+ return null;
74
+ }
75
+ }
76
+ /**
77
+ * An agent session ended. Fires for every real end — clean finish, error, user
78
+ * stop, watchdog kill — and lets the script tell them apart via
79
+ * `KOBO_SESSION_END_REASON`. Typical use: run the test suite, or notify.
80
+ */
81
+ export async function onSessionEnded(workspaceId, outcome) {
82
+ await runLifecycleHook('session-ended', workspaceId, {
83
+ KOBO_SESSION_ID: outcome.sessionId,
84
+ KOBO_SESSION_END_REASON: outcome.reason,
85
+ // A missing exit code is an empty variable, not the string 'null': a shell
86
+ // script testing `-z "$KOBO_SESSION_EXIT_CODE"` should see nothing there.
87
+ KOBO_SESSION_EXIT_CODE: outcome.exitCode === null ? '' : String(outcome.exitCode),
88
+ KOBO_SESSION_STOP_CAUSE: outcome.stopCause ?? '',
89
+ // A hook that runs the test suite should know the next iteration may
90
+ // already be editing the same files.
91
+ KOBO_AUTOLOOP_ACTIVE: outcome.autoLoopActive ? '1' : '0',
92
+ });
93
+ }
94
+ /**
95
+ * A PR was merged. Runs before the workspace is archived or purged, so the
96
+ * worktree is still on disk and the script can act on it (deploy, tag, notify).
97
+ */
98
+ export async function onPrMerged(workspaceId, pr) {
99
+ await runLifecycleHook('pr-merged', workspaceId, {
100
+ KOBO_PR_NUMBER: String(pr.prNumber),
101
+ KOBO_PR_URL: pr.prUrl,
102
+ });
103
+ }
104
+ /**
105
+ * Auto-loop turned itself off. `KOBO_AUTOLOOP_REASON` separates the loop
106
+ * finishing its work from it stalling or erroring out — the difference between
107
+ * a notification worth celebrating and one worth investigating.
108
+ */
109
+ export async function onAutoLoopDisabled(workspaceId, info) {
110
+ await runLifecycleHook('autoloop-disabled', workspaceId, {
111
+ KOBO_AUTOLOOP_REASON: info.reason,
112
+ KOBO_TASKS_PENDING: String(info.tasksPending),
113
+ });
114
+ }