@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
@@ -17,7 +17,7 @@ import { buildStopHookOutput } from './stop-hook.js';
17
17
  * post-result drain watchdog force-emits `session:ended` so the orchestrator
18
18
  * and auto-loop are not frozen forever.
19
19
  */
20
- const RESULT_DRAIN_TIMEOUT_MS = 15_000;
20
+ export const RESULT_DRAIN_TIMEOUT_MS = 15_000;
21
21
  /**
22
22
  * Deadline for a stream that stops reporting ANY activity. The former
23
23
  * text-only guard was re-armed solely on a message with text and no tool call
@@ -26,12 +26,23 @@ const RESULT_DRAIN_TIMEOUT_MS = 15_000;
26
26
  * forever with zero trace. Exported so tests never hard-code the value.
27
27
  */
28
28
  export const CLAUDE_STREAM_IDLE_TIMEOUT_MS = 120_000;
29
+ /**
30
+ * Idle ceiling while a FOREGROUND tool call is in flight (tool:call seen, no
31
+ * tool:result yet). A long Bash run — a test suite, a CI-poll sleep — emits no
32
+ * SDK message until its tool_result, so the short deadline would kill a
33
+ * healthy session mid-tool (the "random session stops" bug). A genuinely dead
34
+ * stream is still reaped once this longer ceiling elapses.
35
+ */
36
+ export const CLAUDE_TOOL_IDLE_TIMEOUT_MS = 30 * 60_000;
37
+ /** Ceiling on a context compaction: past this, assume it wedged and let the
38
+ * liveness deadline take over instead of pausing forever. */
39
+ export const COMPACTION_STALL_TIMEOUT_MS = 10 * 60_000;
29
40
  // Safety net for a subagent whose terminal `task_notification` carries a
30
41
  // status outside the mapper's known-terminal set (SDK schema drift) — it
31
42
  // never clears `activeSubagentToolCallIds`, which otherwise blocks
32
43
  // `inputStream.close()`/the result-drain watchdog forever. Generous window:
33
44
  // legitimate subagents can run for several minutes.
34
- const SUBAGENT_STALL_TIMEOUT_MS = 10 * 60_000;
45
+ export const SUBAGENT_STALL_TIMEOUT_MS = 10 * 60_000;
35
46
  const MAX_PENDING_USER_MESSAGES = 20;
36
47
  function toMcpServersMap(specs) {
37
48
  if (!specs || specs.length === 0)
@@ -106,6 +117,9 @@ export function createClaudeCodeEngine() {
106
117
  // UI/tool ids can differ from the SDK task id. Key by the stable UI id
107
118
  // so a terminal notification that omits task_id still clears the task.
108
119
  const activeSubagentTaskIds = new Map();
120
+ // Foreground tool calls currently awaiting their tool_result. Cleared on
121
+ // every `result` message: a turn cannot end with a tool still in flight.
122
+ const pendingToolCallIds = new Set();
109
123
  // Pending canUseTool callbacks, keyed by SDK ctx.toolUseID.
110
124
  const pendingResolvers = new Map();
111
125
  const isInteractive = options.agentPermissionMode === 'interactive';
@@ -141,6 +155,7 @@ export function createClaudeCodeEngine() {
141
155
  return;
142
156
  }
143
157
  ctx.signal.addEventListener('abort', onAbort, { once: true });
158
+ resolver.cleanup = () => ctx.signal.removeEventListener('abort', onAbort);
144
159
  onEvent({
145
160
  kind: 'session:user-input-requested',
146
161
  requestKind,
@@ -283,16 +298,74 @@ export function createClaudeCodeEngine() {
283
298
  const turnLiveness = createTurnLiveness({
284
299
  timeoutMs: CLAUDE_STREAM_IDLE_TIMEOUT_MS,
285
300
  onTimeout() {
286
- console.warn(`[claude-engine] SDK stream reported no activity for ${CLAUDE_STREAM_IDLE_TIMEOUT_MS}ms — forcing session:ended`);
301
+ // The armed deadline tracks whether a foreground tool call is in
302
+ // flight (see `turnLiveness.setTimeoutMs` below) — log the duration
303
+ // that actually elapsed, not always the shorter baseline, so the
304
+ // watchdog's own diagnostic message stays trustworthy.
305
+ const timeoutMs = pendingToolCallIds.size > 0 ? CLAUDE_TOOL_IDLE_TIMEOUT_MS : CLAUDE_STREAM_IDLE_TIMEOUT_MS;
306
+ console.warn(`[claude-engine] SDK stream reported no activity for ${timeoutMs}ms — forcing session:ended`);
287
307
  if (userInterrupted)
288
308
  emitSessionEnded('killed', null);
289
309
  else if (mapperState.sawErrorResult)
290
310
  emitSessionEnded('error', null);
291
- else
311
+ else {
312
+ safeEmit({
313
+ kind: 'error',
314
+ category: 'other',
315
+ message: 'Session force-ended by the liveness watchdog: no SDK activity within the deadline. If the agent was legitimately busy, this is a bug worth reporting.',
316
+ });
292
317
  emitSessionEnded('watchdog', null);
318
+ }
293
319
  abortController.abort();
294
320
  },
295
321
  });
322
+ // The single source of truth for "should the idle deadline be paused
323
+ // right now" — a background subagent, a pending permission/question
324
+ // card, or an in-progress compaction can each legitimately keep the
325
+ // stream quiet for minutes; their own dedicated watchdogs own those
326
+ // windows. Every call site that changes one of these three conditions
327
+ // must re-evaluate through this helper, not duplicate the check.
328
+ const reevaluateLivenessPause = () => {
329
+ if (activeSubagentTaskIds.size > 0 || pendingResolvers.size > 0 || isCompacting)
330
+ turnLiveness.pause();
331
+ else
332
+ turnLiveness.resume();
333
+ };
334
+ // Backstop for a compaction that never reports completion (stuck
335
+ // `session:compacting` with `active: true` forever): `isCompacting`
336
+ // pauses `turnLiveness` indefinitely, and a wedged-but-alive generator
337
+ // never trips `isAlive()`'s own sweep either. Past this ceiling, assume
338
+ // it wedged and resume the liveness deadline so the existing tool-aware
339
+ // / short deadline can reap it, mirroring `armSubagentStallWatchdog`.
340
+ let compactionStallTimer;
341
+ const clearCompactionStallTimer = () => {
342
+ if (!compactionStallTimer)
343
+ return;
344
+ clearTimeout(compactionStallTimer);
345
+ compactionStallTimer = undefined;
346
+ };
347
+ const armCompactionStallTimer = () => {
348
+ clearCompactionStallTimer();
349
+ compactionStallTimer = setTimeout(() => {
350
+ compactionStallTimer = undefined;
351
+ if (!isCompacting)
352
+ return;
353
+ console.warn(`[claude-engine] Compaction still reported active ${COMPACTION_STALL_TIMEOUT_MS}ms after it started — resuming the liveness deadline.`);
354
+ isCompacting = false;
355
+ reevaluateLivenessPause();
356
+ if (activeSubagentTaskIds.size > 0 && !subagentStallTimer) {
357
+ // Resume declined because a subagent is still tracked — but the
358
+ // subagent stall net is only armed from the `result` branch, which
359
+ // a fully wedged generator may never reach. Arm it here as a
360
+ // second stage so this combination can't hang forever. The
361
+ // `!subagentStallTimer` guard matters: arming re-sets the
362
+ // deadline, and a net already armed by a prior `result` must not
363
+ // be extended by this backstop (it is not new activity).
364
+ armSubagentStallWatchdog();
365
+ }
366
+ }, COMPACTION_STALL_TIMEOUT_MS);
367
+ compactionStallTimer.unref?.();
368
+ };
296
369
  const armResultDrainWatchdog = () => {
297
370
  if (resultDrainTimer)
298
371
  return;
@@ -302,8 +375,14 @@ export function createClaudeCodeEngine() {
302
375
  emitSessionEnded('killed', null);
303
376
  else if (mapperState.sawErrorResult)
304
377
  emitSessionEnded('error', null);
305
- else
378
+ else {
379
+ safeEmit({
380
+ kind: 'error',
381
+ category: 'other',
382
+ message: 'Session force-ended: the SDK generator stayed open after its final result (drain watchdog).',
383
+ });
306
384
  emitSessionEnded('watchdog', null);
385
+ }
307
386
  abortController.abort();
308
387
  }, RESULT_DRAIN_TIMEOUT_MS);
309
388
  resultDrainTimer.unref?.();
@@ -369,12 +448,25 @@ export function createClaudeCodeEngine() {
369
448
  activeSubagentTaskIds.delete(ev.toolCallId);
370
449
  }
371
450
  for (const ev of events) {
372
- if (ev.kind === 'session:compacting')
451
+ if (ev.kind === 'session:compacting') {
373
452
  isCompacting = ev.active;
453
+ if (ev.active)
454
+ armCompactionStallTimer();
455
+ else
456
+ clearCompactionStallTimer();
457
+ }
374
458
  // Older SDKs can emit only compact_boundary, without a trailing
375
459
  // status update. This boundary marks compaction as complete.
376
- else if (ev.kind === 'session:compacted')
460
+ else if (ev.kind === 'session:compacted') {
377
461
  isCompacting = false;
462
+ clearCompactionStallTimer();
463
+ }
464
+ }
465
+ for (const ev of events) {
466
+ if (ev.kind === 'tool:call')
467
+ pendingToolCallIds.add(ev.toolCallId);
468
+ else if (ev.kind === 'tool:result')
469
+ pendingToolCallIds.delete(ev.toolCallId);
378
470
  }
379
471
  // The stall watchdog is only armed while we're waiting purely on
380
472
  // background subagents (no further SDK turn expected unless one
@@ -398,11 +490,12 @@ export function createClaudeCodeEngine() {
398
490
  // A background subagent or a pending permission card can stay
399
491
  // legitimately quiet for minutes; their own dedicated watchdogs
400
492
  // own those windows, so suspend the turn deadline meanwhile.
401
- if (activeSubagentTaskIds.size > 0 || pendingResolvers.size > 0 || isCompacting)
402
- turnLiveness.pause();
403
- else
404
- turnLiveness.resume();
493
+ // A FOREGROUND tool call in flight keeps the deadline armed but
494
+ // stretched to the tool-aware ceiling.
495
+ turnLiveness.setTimeoutMs(pendingToolCallIds.size > 0 ? CLAUDE_TOOL_IDLE_TIMEOUT_MS : CLAUDE_STREAM_IDLE_TIMEOUT_MS);
496
+ reevaluateLivenessPause();
405
497
  if (msg.type === 'result') {
498
+ pendingToolCallIds.clear();
406
499
  completedResponses++;
407
500
  emitTurnCompletedIfSettled();
408
501
  // A queued forced message starts the next response on this same SDK stream.
@@ -457,11 +550,13 @@ export function createClaudeCodeEngine() {
457
550
  clearResultDrainWatchdog();
458
551
  turnLiveness.stop();
459
552
  clearSubagentStallWatchdog();
553
+ clearCompactionStallTimer();
460
554
  // Drain any callback still pending (SDK terminated while awaiting an
461
555
  // answer). canUseTool's abort path covers signalled stops; this
462
556
  // covers natural iterator completion.
463
557
  for (const resolver of pendingResolvers.values()) {
464
558
  try {
559
+ resolver.cleanup?.();
465
560
  resolver.resolve({ behavior: 'deny', message: 'session ended', interrupt: false });
466
561
  }
467
562
  catch {
@@ -541,10 +636,12 @@ export function createClaudeCodeEngine() {
541
636
  if (!resolver)
542
637
  return false;
543
638
  pendingResolvers.delete(toolCallId);
544
- // Only resume the deadline once every outstanding card is answered —
545
- // a sibling request may still be waiting on a human.
546
- if (pendingResolvers.size === 0)
547
- turnLiveness.resume();
639
+ resolver.cleanup?.();
640
+ // Re-evaluate rather than unconditionally resuming: a sibling
641
+ // request, a still-active subagent, or an in-progress compaction
642
+ // may each still have their own legitimate reason to keep the
643
+ // deadline paused.
644
+ reevaluateLivenessPause();
548
645
  if (response.kind === 'question') {
549
646
  // Echo the original questions array + answers so the SDK
550
647
  // reconstructs the AskUserQuestion tool input.
@@ -12,6 +12,8 @@ import { spawnAppServer } from './spawn.js';
12
12
  /** Long enough for normal tool work, short enough to recover a lost turn event. */
13
13
  export const CODEX_TURN_IDLE_TIMEOUT_MS = 120_000;
14
14
  export const CODEX_GRACEFUL_INTERRUPT_TIMEOUT_MS = 3_000;
15
+ /** Grace given to a SIGTERM before the child is killed outright. */
16
+ const CODEX_FORCE_KILL_TIMEOUT_MS = 3_000;
15
17
  // Safety net while `turnLiveness` is paused for background subagents: that
16
18
  // pause is deliberately unbounded (a legitimate subagent can run long), but
17
19
  // if a thread never reports a terminal status (dropped notification, or the
@@ -508,6 +510,22 @@ export function createCodexEngine() {
508
510
  catch {
509
511
  // swallow
510
512
  }
513
+ // Escalate. The SIGTERM in the `finally` below is a request, not a
514
+ // guarantee: a wedged `codex app-server` that ignores it outlives the
515
+ // stop, the archive, the delete and Kōbō's own shutdown, holding the
516
+ // worktree and its share of the model quota, with nothing left in
517
+ // this process tracking it.
518
+ if (child.exitCode === null && child.signalCode === null) {
519
+ child.kill('SIGTERM');
520
+ const exited = await Promise.race([
521
+ new Promise((resolve) => child.once('exit', () => resolve(true))),
522
+ wait(CODEX_FORCE_KILL_TIMEOUT_MS).then(() => false),
523
+ ]);
524
+ if (!exited) {
525
+ console.warn('[codex] app-server ignored SIGTERM — sending SIGKILL');
526
+ child.kill('SIGKILL');
527
+ }
528
+ }
511
529
  },
512
530
  resolvePendingUserInput(callId, response) {
513
531
  const pending = pendingByCallId.get(callId);
@@ -67,14 +67,11 @@ export function handleServerRequest(args) {
67
67
  return true;
68
68
  }
69
69
  if (method === 'item/permissions/requestApproval') {
70
- register(callId, { requestId, kind: 'permissions', payload: p });
71
- emit({
72
- kind: 'session:user-input-requested',
73
- requestKind: 'permission',
74
- toolCallId: callId,
75
- toolName: 'Permissions',
76
- payload: p,
77
- });
70
+ // Kōbō doesn't model granular permission grants: the only answer we can
71
+ // send is the empty grant (Codex then falls back to the turn policy), so
72
+ // an Allow/Deny card would be a lie — "Allow" and "Deny" produced the
73
+ // same decline. Answer immediately and keep the session moving.
74
+ respond?.(requestId, { permissions: {}, scope: 'turn' });
78
75
  return true;
79
76
  }
80
77
  return false; // unknown method
@@ -87,12 +84,6 @@ export function handleServerRequest(args) {
87
84
  * (and the matching `FileChangeApprovalDecision`): `'accept' | 'acceptForSession' | 'decline' | 'cancel'`.
88
85
  * NOT `'approve' / 'reject'` — those would be silently rejected as unknown
89
86
  * variants, which breaks the strict and interactive permission modes.
90
- *
91
- * `PermissionsRequestApprovalResponse` has a completely different shape:
92
- * `{ permissions, scope, strictAutoReview? }` — no `decision` field. Since
93
- * Kōbō doesn't yet model permission grants, we deny the request by sending
94
- * an empty permissions response. A future iteration could add a UI for
95
- * granular permission grants.
96
87
  */
97
88
  export function buildResponseForResolve(pending, response) {
98
89
  if (pending.kind === 'command' || pending.kind === 'file_change') {
@@ -100,12 +91,6 @@ export function buildResponseForResolve(pending, response) {
100
91
  return { decision: 'accept' };
101
92
  return { decision: 'decline' };
102
93
  }
103
- if (pending.kind === 'permissions') {
104
- // Codex's PermissionsRequestApprovalResponse shape — not { decision }.
105
- // We don't yet model granular permission grants, so deny by returning an
106
- // empty permissions object; Codex falls back to the existing turn policy.
107
- return { permissions: {}, scope: 'turn' };
108
- }
109
94
  if (pending.kind === 'user_input') {
110
95
  if (response.kind === 'question') {
111
96
  const answers = {};
@@ -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';
@@ -957,7 +1003,16 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
957
1003
  // `reason` is authoritative (with the SDK engine `exitCode` is often null,
958
1004
  // so reason='error'+exitCode=null would otherwise map wrongly to 'completed').
959
1005
  // `resumeFailed` is benign: stale id cleared, next iteration starts fresh.
960
- const isErrorOutcome = !resumeFailed && (reason === 'error' || (exitCode !== null && exitCode !== 0));
1006
+ // 'watchdog' is a forced kill, never a success. For an auto-loop workspace,
1007
+ // handleTransientAutoLoopFailure (called above, before onSessionEnded) runs
1008
+ // synchronously up to its first await and writes status 'quota' as its very
1009
+ // first statement — that write lands before `currentWorkspace` is read above
1010
+ // (line ~1144), so `preserveQuotaBackoff` is already true and this function
1011
+ // returns early at the `if (preserveQuotaBackoff) return true` branch above.
1012
+ // This computation is therefore never reached at all for that path, not
1013
+ // merely short-circuited afterwards — it only runs on session ends that
1014
+ // don't go through the auto-loop transient-failure path.
1015
+ const isErrorOutcome = !resumeFailed && (reason === 'error' || reason === 'watchdog' || (exitCode !== null && exitCode !== 0));
961
1016
  const targetStatus = isErrorOutcome ? 'error' : 'completed';
962
1017
  // Skip the transition when the workspace is already in a terminal state.
963
1018
  // This happens when stopAgent (or an equivalent caller) synchronously
@@ -1161,7 +1216,8 @@ export function interruptAgent(workspaceId, options = {}) {
1161
1216
  * is untracked by anyone in this process; only the OS or the user can end it.
1162
1217
  */
1163
1218
  export const STOP_AGENT_TIMEOUT_MS = 15_000;
1164
- async function stopController(workspaceId, ctrl) {
1219
+ async function stopController(workspaceId, ctrl, cause = 'user') {
1220
+ ctrl.stopCause = cause;
1165
1221
  wakeupService.cancel(workspaceId, 'stopped');
1166
1222
  // Normalize the state synchronously so callers (archive, delete, manual
1167
1223
  // stop) see a clean workspace immediately — without waiting for the async
@@ -1247,11 +1303,13 @@ async function stopWithTimeout(ctrl, timeoutMs) {
1247
1303
  * switch — MUST use this: the fire-and-forget `stopAgent` returns while the
1248
1304
  * engine may still be writing files.
1249
1305
  */
1250
- 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') {
1251
1309
  const ctrl = controllers.get(workspaceId);
1252
1310
  if (!ctrl)
1253
1311
  return 'not-running';
1254
- const finished = await raceWithTimeout(stopController(workspaceId, ctrl).then(() => true, (err) => {
1312
+ const finished = await raceWithTimeout(stopController(workspaceId, ctrl, cause).then(() => true, (err) => {
1255
1313
  console.error(`[orchestrator] controller.stop failed for '${workspaceId}':`, err);
1256
1314
  return true;
1257
1315
  }), timeoutMs, () => false);
@@ -1638,6 +1696,10 @@ export function getAgentStatus(workspaceId) {
1638
1696
  export function hasController(workspaceId) {
1639
1697
  return controllers.has(workspaceId);
1640
1698
  }
1699
+ /** How many agent sessions are live right now, across every workspace. */
1700
+ export function runningAgentCount() {
1701
+ return controllers.size;
1702
+ }
1641
1703
  /** The agent_session_id of the active controller for the workspace, if any. */
1642
1704
  export function getActiveSessionId(workspaceId) {
1643
1705
  return controllers.get(workspaceId)?.agentSessionId;
@@ -1782,25 +1844,63 @@ async function handleQuota(workspaceId, _agentSessionId) {
1782
1844
  // Never silent: a refused transition here loses the backoff two lines down.
1783
1845
  console.warn(`[orchestrator] Could not transition workspace '${workspaceId}' to quota:`, err);
1784
1846
  }
1785
- const retryCount = retryCounts.get(workspaceId) ?? 0;
1786
- const autoLoopEnabled = getWs(workspaceId)?.autoLoop === true;
1787
- const maxRetries = autoLoopEnabled ? (getGlobalSettings().autoLoopMaxRetries ?? 5) : 5;
1788
- if (autoLoopEnabled && retryCount >= maxRetries) {
1789
- autoLoopService.disable(workspaceId, 'error');
1790
- try {
1791
- updateWorkspaceStatus(workspaceId, 'error');
1792
- }
1793
- catch {
1794
- // 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;
1795
1865
  }
1796
- 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);
1797
1879
  }
1798
- const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount);
1799
- retryCounts.set(workspaceId, retryCount + 1);
1800
- // The quotaBackoffService owns the timer + the persistent row + the
1801
- // 'agent:quota-backoff' WS emit. Hand off everything to it.
1802
- quotaBackoffService.arm(workspaceId, delayMs, { resetsAt: resetsAt ?? null, source, reason: 'quota' });
1803
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.
1898
+ }
1899
+ }
1900
+ /** First retry after a transient failure (watchdog recovery, HTTP 500). The
1901
+ * quota ladder's 15-minute floor is tuned for real quota windows — a forced
1902
+ * kill or a server blip deserves a much faster first attempt. */
1903
+ const TRANSIENT_FIRST_RETRY_MS = 2 * 60_000;
1804
1904
  /**
1805
1905
  * Use the same persisted retry path for temporary upstream failures (HTTP 500
1806
1906
  * and overloads) while deliberately ignoring quota-reset data: a server error
@@ -1813,21 +1913,30 @@ async function handleTransientAutoLoopFailure(workspaceId) {
1813
1913
  catch (err) {
1814
1914
  console.warn(`[orchestrator] Could not transition workspace '${workspaceId}' to quota for a transient failure:`, err);
1815
1915
  }
1816
- const retryCount = retryCounts.get(workspaceId) ?? 0;
1817
- const maxRetries = getGlobalSettings().autoLoopMaxRetries ?? 5;
1818
- if (retryCount >= maxRetries) {
1819
- autoLoopService.disable(workspaceId, 'error');
1820
- try {
1821
- updateWorkspaceStatus(workspaceId, 'error');
1822
- }
1823
- catch {
1824
- // 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;
1825
1930
  }
1826
- 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);
1827
1939
  }
1828
- const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount, false);
1829
- retryCounts.set(workspaceId, retryCount + 1);
1830
- quotaBackoffService.arm(workspaceId, delayMs, { resetsAt: resetsAt ?? null, source, reason: 'transient' });
1831
1940
  }
1832
1941
  /** @internal test-only — re-export of `handleQuota` for direct testing. */
1833
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
@@ -19,6 +19,7 @@ export function createStreamingBatcher(emit, options = {}) {
19
19
  return;
20
20
  }
21
21
  const timer = setTimeout(() => flush(event.messageId), windowMs);
22
+ timer.unref?.();
22
23
  pending.set(event.messageId, { text: event.text, timer });
23
24
  return;
24
25
  }