@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
@@ -44,6 +44,7 @@ import * as gitOps from '../utils/git-ops.js';
44
44
  import { logError } from '../utils/logger.js';
45
45
  import { slugifyProjectName } from '../utils/project-slug.js';
46
46
  import * as safePath from '../utils/safe-path.js';
47
+ import { resolveExtractedName } from '../utils/workspace-name.js';
47
48
  import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
48
49
  /** Hono sub-router for workspace CRUD, tasks, agent lifecycle, git operations, and PR creation. */
49
50
  const app = new Hono();
@@ -231,26 +232,6 @@ function parsePrNumberFromUrl(prUrl) {
231
232
  }
232
233
  return number;
233
234
  }
234
- // Mirrors the private `WORKSPACE_NAME_MAX_LENGTH` in workspace-service.ts —
235
- // duplicated rather than exported to avoid coupling this route to that
236
- // module's internals (and the many `vi.mock(workspace-service.js, factory)`
237
- // test files that would otherwise need updating for an export nothing else
238
- // consumes).
239
- const WORKSPACE_NAME_MAX_LENGTH = 200;
240
- /**
241
- * Cap an auto-derived title (Notion page title, Sentry issue title, PR/MR
242
- * title) before handing it to `updateWorkspaceName`. That function throws on
243
- * anything longer — the right behaviour for a name the user typed
244
- * themselves, but titles pulled from an external source (a long exception
245
- * message, a verbose PR title) are not user input: truncating them is the
246
- * sane default, matching the existing `slugifyBranchSegment` precedent for
247
- * auto-derived branch names.
248
- */
249
- function truncateWorkspaceName(name) {
250
- if (name.length <= WORKSPACE_NAME_MAX_LENGTH)
251
- return name;
252
- return `${name.slice(0, WORKSPACE_NAME_MAX_LENGTH - 1)}…`;
253
- }
254
235
  app.get('/', (c) => {
255
236
  try {
256
237
  const workspaces = workspaceService.listWorkspaces();
@@ -265,7 +246,7 @@ app.get('/', (c) => {
265
246
  app.post('/:id/engine-handoff-preview', migrationGuard, async (c) => {
266
247
  try {
267
248
  const id = c.req.param('id');
268
- const body = await c.req.json();
249
+ const body = await c.req.json().catch(() => ({}));
269
250
  const workspace = workspaceService.getWorkspaceWithTasks(id);
270
251
  if (!workspace)
271
252
  return c.json({ error: `Workspace '${id}' not found` }, 404);
@@ -282,7 +263,9 @@ app.post('/:id/engine-handoff-preview', migrationGuard, async (c) => {
282
263
  app.post('/:id/switch-engine', migrationGuard, async (c) => {
283
264
  try {
284
265
  const id = c.req.param('id');
285
- const body = await c.req.json();
266
+ const body = await c.req
267
+ .json()
268
+ .catch(() => ({}));
286
269
  const workspace = workspaceService.getWorkspaceWithTasks(id);
287
270
  if (!workspace)
288
271
  return c.json({ error: `Workspace '${id}' not found` }, 404);
@@ -340,7 +323,9 @@ app.post('/', migrationGuard, async (c) => {
340
323
  // can read it to decide whether the setup-script caveat applies.
341
324
  let setupScriptConfigured = false;
342
325
  try {
343
- const body = await c.req.json();
326
+ const body = await c.req
327
+ .json()
328
+ .catch(() => ({}));
344
329
  // workingBranch is derived from git when worktreePath is provided, so
345
330
  // it's not required in that flow. The other 3 fields stay mandatory.
346
331
  if (!body.name || !body.projectPath || !body.sourceBranch) {
@@ -357,6 +342,14 @@ app.post('/', migrationGuard, async (c) => {
357
342
  if (body.workingBranch && !gitOps.isValidBranchName(body.workingBranch)) {
358
343
  return c.json({ error: `Invalid working branch name: ${body.workingBranch}` }, 400);
359
344
  }
345
+ // Same reasoning for the source branch, which is always caller-supplied and
346
+ // reaches `git fetch <remote> <sourceBranch>` as a bare argument.
347
+ if (!gitOps.isValidBranchName(body.sourceBranch)) {
348
+ return c.json({ error: `Invalid source branch name: ${body.sourceBranch}` }, 400);
349
+ }
350
+ if (body.comparisonId !== undefined && typeof body.comparisonId !== 'string') {
351
+ return c.json({ error: 'comparisonId must be a string' }, 400);
352
+ }
360
353
  creationId = typeof body.creationId === 'string' && body.creationId.length > 0 ? body.creationId : undefined;
361
354
  currentStep = 'validate';
362
355
  emitCreateProgress(creationId, currentStep);
@@ -397,7 +390,7 @@ app.post('/', migrationGuard, async (c) => {
397
390
  let baseRef = `origin/${body.sourceBranch}`;
398
391
  let usedLocalFallback = false;
399
392
  try {
400
- gitOps.fetchSourceBranch(body.projectPath, body.sourceBranch);
393
+ await gitOps.fetchSourceBranchOrThrowAsync(body.projectPath, body.sourceBranch);
401
394
  }
402
395
  catch (err) {
403
396
  const message = err instanceof Error ? err.message : String(err);
@@ -603,7 +596,13 @@ app.post('/', migrationGuard, async (c) => {
603
596
  .replace(/^-|-$/g, '')
604
597
  .substring(0, 50);
605
598
  const safeSlug = titleSlug || 'task';
606
- workingBranch = ticketPrefix ? `${typePrefix}${ticketPrefix}--${safeSlug}` : `${typePrefix}${safeSlug}`;
599
+ // An engine comparison posts here once per engine with the same
600
+ // ticket: without the suffix both halves collapse onto one branch and
601
+ // the second gets a random hash instead of its engine.
602
+ const engineSuffix = body.comparisonId && body.engine ? `-${body.engine}` : '';
603
+ workingBranch = ticketPrefix
604
+ ? `${typePrefix}${ticketPrefix}--${safeSlug}${engineSuffix}`
605
+ : `${typePrefix}${safeSlug}${engineSuffix}`;
607
606
  }
608
607
  }
609
608
  // Compute the project slug once and reuse it for both the prospective
@@ -661,6 +660,9 @@ app.post('/', migrationGuard, async (c) => {
661
660
  reasoningEffort: body.reasoningEffort,
662
661
  agentPermissionMode: resolveCreateAgentPermissionMode(body.agentPermissionMode, body.projectPath, globalSettings, body.engine ?? 'claude-code'),
663
662
  engine: body.engine,
663
+ // Set by the engine-comparison flow, which posts here once per engine
664
+ // with the same id so the resulting workspaces can find each other.
665
+ comparisonId: body.comparisonId,
664
666
  ...(useReusedWorktree ? {} : { worktreesPath: globalSettings.worktreesPath }),
665
667
  });
666
668
  createdWorkspace = workspace;
@@ -706,9 +708,11 @@ app.post('/', migrationGuard, async (c) => {
706
708
  // a custom name and Notion hasn't already filled it. Prefix with the Sentry
707
709
  // short-id (e.g. "SEKUR-IOS-9 | TypeError: …") so the workspace stays
708
710
  // identifiable in the sidebar without opening the panel.
709
- if (sentryContent?.title && !notionContent?.title && workspace.name === 'workspace') {
711
+ if (sentryContent?.title && !notionContent?.title) {
710
712
  const prefix = sentryContent.issueId ? `${sentryContent.issueId} | ` : '';
711
- workspace = workspaceService.updateWorkspaceName(workspace.id, truncateWorkspaceName(`${prefix}${sentryContent.title}`));
713
+ const renamed = resolveExtractedName(workspace.name, `${prefix}${sentryContent.title}`);
714
+ if (renamed)
715
+ workspace = workspaceService.updateWorkspaceName(workspace.id, renamed);
712
716
  }
713
717
  currentStep = 'create-tasks';
714
718
  emitCreateProgress(creationId, currentStep);
@@ -732,16 +736,20 @@ app.post('/', migrationGuard, async (c) => {
732
736
  // Update workspace name with Notion page title only if user didn't
733
737
  // provide a custom name. Prefix with the Notion unique-id (e.g.
734
738
  // Use the Notion page title when the user left the generic placeholder.
735
- if (notionContent.title && workspace.name === 'workspace') {
736
- workspace = workspaceService.updateWorkspaceName(workspace.id, truncateWorkspaceName(notionContent.title));
739
+ if (notionContent.title) {
740
+ const renamed = resolveExtractedName(workspace.name, notionContent.title);
741
+ if (renamed)
742
+ workspace = workspaceService.updateWorkspaceName(workspace.id, renamed);
737
743
  }
738
744
  }
739
745
  // Update workspace name with the PR/MR title if the user did not provide
740
746
  // a custom name and neither Notion nor Sentry already claimed it. This
741
747
  // makes the PR a THIRD context source, lowest priority of the three
742
748
  // (Notion > Sentry > PR), consistent with the checks above.
743
- if (prContent && !notionContent?.title && !sentryContent?.title && workspace.name === 'workspace') {
744
- workspace = workspaceService.updateWorkspaceName(workspace.id, truncateWorkspaceName(prContent.title));
749
+ if (prContent && !notionContent?.title && !sentryContent?.title) {
750
+ const renamed = resolveExtractedName(workspace.name, prContent.title);
751
+ if (renamed)
752
+ workspace = workspaceService.updateWorkspaceName(workspace.id, renamed);
745
753
  }
746
754
  // Create manual tasks/criteria if no Notion content was extracted
747
755
  if (!notionContent && (Array.isArray(body.tasks) || Array.isArray(body.acceptanceCriteria))) {
@@ -814,6 +822,11 @@ app.post('/', migrationGuard, async (c) => {
814
822
  ['.ai/thoughts/', 'thoughts/'],
815
823
  ['.ai/images/', 'images/'],
816
824
  ['.ai/.setup-script.tmp', '.setup-script.tmp'],
825
+ ['.ai/.cleanup-script.tmp', '.cleanup-script.tmp'],
826
+ ['.ai/.archive-script.tmp', '.archive-script.tmp'],
827
+ // A crash mid-hook leaves the temp script behind; the next agent
828
+ // would otherwise happily commit it.
829
+ ['.ai/.hook-*.tmp', '.hook-*.tmp'],
817
830
  ['.mcp.json', ''],
818
831
  ];
819
832
  const toAdd = [];
@@ -1401,6 +1414,31 @@ app.get('/info', (c) => {
1401
1414
  return c.json({ error: message }, 500);
1402
1415
  }
1403
1416
  });
1417
+ // GET /api/workspaces/:id/comparison — the workspaces running the same task on
1418
+ // another engine, with the diff each one produced. Reads the git-stats cache the
1419
+ // pr-watcher already maintains rather than shelling out to git twice per request.
1420
+ app.get('/:id/comparison', (c) => {
1421
+ const id = c.req.param('id');
1422
+ try {
1423
+ const workspace = workspaceService.getWorkspace(id);
1424
+ if (!workspace)
1425
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1426
+ if (!workspace.comparisonId)
1427
+ return c.json({ comparisonId: null, members: [] });
1428
+ const stats = getAllGitStats();
1429
+ const members = workspaceService.listComparisonMembers(workspace.comparisonId).map((member) => ({
1430
+ workspace: member,
1431
+ // Null, not zeros: "not measured yet" and "changed nothing" are
1432
+ // different answers, and only one of them is true here.
1433
+ gitStats: stats[member.id] ?? null,
1434
+ }));
1435
+ return c.json({ comparisonId: workspace.comparisonId, members });
1436
+ }
1437
+ catch (err) {
1438
+ const message = err instanceof Error ? err.message : String(err);
1439
+ return c.json({ error: message }, 500);
1440
+ }
1441
+ });
1404
1442
  // POST /api/workspaces/pr-snapshot/refresh/:id — on-demand refresh of a single
1405
1443
  // workspace's PR snapshot, driven by the Git tab refresh button. Static prefix
1406
1444
  // keeps it ahead of `/:id` in the Hono matcher.
@@ -1458,7 +1496,13 @@ app.get('/auto-loop-states', (c) => {
1458
1496
  // GET /api/workspaces/:id/auto-loop — current auto-loop status for one workspace.
1459
1497
  app.get('/:id/auto-loop', (c) => {
1460
1498
  try {
1461
- return c.json(autoLoopService.getStatus(c.req.param('id')));
1499
+ const id = c.req.param('id');
1500
+ // getStatus returns a default row for an unknown id, so without this the
1501
+ // caller gets a cheerful `auto_loop: false` for a workspace that does not
1502
+ // exist — the neighbouring handlers all 404.
1503
+ if (!workspaceService.getWorkspace(id))
1504
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1505
+ return c.json(autoLoopService.getStatus(id));
1462
1506
  }
1463
1507
  catch (err) {
1464
1508
  const message = err instanceof Error ? err.message : String(err);
@@ -1848,7 +1892,9 @@ app.post('/:id/tasks', async (c) => {
1848
1892
  if (!workspace) {
1849
1893
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1850
1894
  }
1851
- const body = await c.req.json();
1895
+ const body = await c.req
1896
+ .json()
1897
+ .catch(() => ({}));
1852
1898
  if (!body.title?.trim()) {
1853
1899
  return c.json({ error: 'Title is required' }, 400);
1854
1900
  }
@@ -1879,7 +1925,9 @@ app.patch('/:id/tasks/:taskId', async (c) => {
1879
1925
  if (!task) {
1880
1926
  return c.json({ error: `Task '${taskId}' not found in workspace '${id}'` }, 404);
1881
1927
  }
1882
- const body = await c.req.json();
1928
+ const body = await c.req
1929
+ .json()
1930
+ .catch(() => ({}));
1883
1931
  if (body.status === undefined && body.title === undefined) {
1884
1932
  return c.json({ error: 'At least one of status or title is required' }, 400);
1885
1933
  }
@@ -2075,6 +2123,59 @@ function readableHistorySearchResult(type, rawPayload, query) {
2075
2123
  snippet: `${start > 0 ? '…' : ''}${text.slice(start, end).replace(/\s+/g, ' ').trim()}${end < text.length ? '…' : ''}`,
2076
2124
  };
2077
2125
  }
2126
+ /**
2127
+ * GET /api/workspaces/:id/sessions/:sessionId/summary — why a session ended.
2128
+ *
2129
+ * A session can stop for many reasons (quota, transient failure, drain
2130
+ * watchdog, auto-loop stall, failed resume, manual stop, natural end) and none
2131
+ * of them were legible after the fact: you had to read the event feed to work
2132
+ * out what happened overnight. Everything here is already collected —
2133
+ * `agent_sessions` and `session_event_metrics` — it was just never put in one
2134
+ * place. Note that `end_reason` itself only records four values (completed /
2135
+ * error / killed / watchdog); quota, stall and failed resume are visible
2136
+ * through the workspace status and the auto-loop events, not through it.
2137
+ */
2138
+ app.get('/:id/sessions/:sessionId/summary', (c) => {
2139
+ try {
2140
+ const id = c.req.param('id');
2141
+ const sessionId = c.req.param('sessionId');
2142
+ if (!workspaceService.getWorkspace(id))
2143
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
2144
+ const db = getDb();
2145
+ const session = db
2146
+ .prepare(`SELECT id, engine, model, status, end_reason, started_at, ended_at
2147
+ FROM agent_sessions WHERE id = ? AND workspace_id = ?`)
2148
+ .get(sessionId, id);
2149
+ if (!session)
2150
+ return c.json({ error: `Session '${sessionId}' not found` }, 404);
2151
+ const metrics = db
2152
+ .prepare(`SELECT tool_calls, errors, input_tokens, output_tokens
2153
+ FROM session_event_metrics WHERE workspace_id = ? AND session_id = ?`)
2154
+ .get(id, sessionId);
2155
+ const startedAt = Date.parse(session.started_at);
2156
+ const endedAt = session.ended_at ? Date.parse(session.ended_at) : null;
2157
+ return c.json({
2158
+ sessionId: session.id,
2159
+ engine: session.engine,
2160
+ model: session.model,
2161
+ status: session.status,
2162
+ // Null for sessions that ended before this was recorded, and for one
2163
+ // still running. The client says "unknown" rather than inventing a cause.
2164
+ endReason: session.end_reason,
2165
+ startedAt: session.started_at,
2166
+ endedAt: session.ended_at,
2167
+ durationMs: endedAt !== null && !Number.isNaN(startedAt) ? endedAt - startedAt : null,
2168
+ toolCalls: metrics?.tool_calls ?? 0,
2169
+ errors: metrics?.errors ?? 0,
2170
+ inputTokens: metrics?.input_tokens ?? 0,
2171
+ outputTokens: metrics?.output_tokens ?? 0,
2172
+ });
2173
+ }
2174
+ catch (err) {
2175
+ const message = err instanceof Error ? err.message : String(err);
2176
+ return c.json({ error: message }, 500);
2177
+ }
2178
+ });
2078
2179
  // GET /api/workspaces/:id/session-metrics — compact observability data for the timeline.
2079
2180
  app.get('/:id/session-metrics', (c) => {
2080
2181
  try {
@@ -2118,15 +2219,22 @@ app.get('/:id/diagnostic.json', (c) => {
2118
2219
  const sessions = db
2119
2220
  .prepare('SELECT id, engine_session_id, status, started_at, ended_at, model, name FROM agent_sessions WHERE workspace_id = ? ORDER BY started_at ASC')
2120
2221
  .all(id);
2222
+ // Bounded on purpose: better-sqlite3 is synchronous, so an unbounded read
2223
+ // materialises every event the workspace ever produced and blocks the
2224
+ // event loop while `c.json` serialises them. A diagnostic bundle wants a
2225
+ // representative trace, not the entire history.
2226
+ const DIAGNOSTIC_EVENT_LIMIT = 50_000;
2121
2227
  const events = db
2122
- .prepare('SELECT id, session_id, type, created_at FROM ws_events WHERE workspace_id = ? ORDER BY rowid ASC')
2123
- .all(id);
2228
+ .prepare('SELECT id, session_id, type, created_at FROM ws_events WHERE workspace_id = ? ORDER BY rowid ASC LIMIT ?')
2229
+ .all(id, DIAGNOSTIC_EVENT_LIMIT);
2230
+ const eventsTruncated = events.length === DIAGNOSTIC_EVENT_LIMIT;
2124
2231
  c.header('Content-Disposition', `attachment; filename="${workspace.name.replace(/[^a-z0-9]+/gi, '-').toLowerCase() || id}-diagnostic.json"`);
2125
2232
  return c.json({
2126
2233
  generatedAt: new Date().toISOString(),
2127
2234
  workspace: { id, name: workspace.name, engine: workspace.engine },
2128
2235
  sessions,
2129
2236
  events,
2237
+ eventsTruncated,
2130
2238
  });
2131
2239
  }
2132
2240
  catch (err) {
@@ -2218,21 +2326,24 @@ app.get('/:id/events', (c) => {
2218
2326
  if (firstRow) {
2219
2327
  const older = session
2220
2328
  ? db
2221
- .prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL) AND rowid < ?')
2329
+ .prepare('SELECT 1 as c FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL) AND rowid < ? LIMIT 1')
2222
2330
  .get(id, session, firstRow.rowid)
2223
2331
  : db
2224
- .prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND rowid < ?')
2332
+ .prepare('SELECT 1 as c FROM ws_events WHERE workspace_id = ? AND rowid < ? LIMIT 1')
2225
2333
  .get(id, firstRow.rowid);
2226
- hasMore = older.c > 0;
2334
+ hasMore = older !== undefined;
2227
2335
  }
2228
2336
  }
2229
2337
  else {
2230
- const total = session
2338
+ // `LIMIT 1 OFFSET <page size>` answers "is there anything past this
2339
+ // page?" by reading one row, where COUNT(*) walked every event the
2340
+ // workspace ever produced — on each page, synchronously.
2341
+ const beyond = session
2231
2342
  ? db
2232
- .prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL)')
2233
- .get(id, session)
2234
- : db.prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ?').get(id);
2235
- hasMore = total.c > rows.length;
2343
+ .prepare('SELECT 1 as c FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL) LIMIT 1 OFFSET ?')
2344
+ .get(id, session, rows.length)
2345
+ : db.prepare('SELECT 1 as c FROM ws_events WHERE workspace_id = ? LIMIT 1 OFFSET ?').get(id, rows.length);
2346
+ hasMore = beyond !== undefined;
2236
2347
  }
2237
2348
  }
2238
2349
  return c.json({ events, hasMore });
@@ -2427,7 +2538,7 @@ app.delete('/:id/pr-watch-disabled', async (c) => {
2427
2538
  app.put('/:id/tags', async (c) => {
2428
2539
  const { id } = c.req.param();
2429
2540
  try {
2430
- const body = await c.req.json();
2541
+ const body = await c.req.json().catch(() => ({}));
2431
2542
  if (!Array.isArray(body.tags)) {
2432
2543
  return c.json({ error: 'tags must be an array of strings' }, 400);
2433
2544
  }
@@ -2664,26 +2775,31 @@ app.post('/:id/run-setup-script', async (c) => {
2664
2775
  if (setupScriptRunning.has(id)) {
2665
2776
  return c.json({ error: 'Setup script is already running for this workspace' }, 409);
2666
2777
  }
2667
- // Stop the running agent before re-running the setup script — the script
2668
- // rewrites the worktree the agent may still be writing to.
2669
- try {
2670
- if (agentManager.getAgentStatus(id)) {
2671
- await agentManager.stopAgentAndWait(id);
2672
- }
2673
- }
2674
- catch (err) {
2675
- console.error(`[workspaces] stopAgentAndWait before setup script failed for '${id}':`, err);
2676
- }
2677
- const effectiveSettings = settingsService.getEffectiveSettings(workspace.projectPath);
2678
- if (!effectiveSettings.setupScript) {
2679
- return c.json({ error: 'No setup script configured' }, 400);
2680
- }
2681
- const worktreePath = workspace.worktreePath;
2682
- if (!fs.existsSync(worktreePath)) {
2683
- return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
2684
- }
2778
+ // Claim before the first await. Checking here and claiming after the agent
2779
+ // stop below let two concurrent calls both pass the check and then run the
2780
+ // user's script twice, concurrently, in the same worktree.
2685
2781
  setupScriptRunning.add(id);
2782
+ // Everything below releases the claim, including the validation failures:
2783
+ // a workspace left marked as running would refuse every later attempt.
2686
2784
  try {
2785
+ // Stop the running agent before re-running the setup script — the script
2786
+ // rewrites the worktree the agent may still be writing to.
2787
+ try {
2788
+ if (agentManager.getAgentStatus(id)) {
2789
+ await agentManager.stopAgentAndWait(id);
2790
+ }
2791
+ }
2792
+ catch (err) {
2793
+ console.error(`[workspaces] stopAgentAndWait before setup script failed for '${id}':`, err);
2794
+ }
2795
+ const effectiveSettings = settingsService.getEffectiveSettings(workspace.projectPath);
2796
+ if (!effectiveSettings.setupScript) {
2797
+ return c.json({ error: 'No setup script configured' }, 400);
2798
+ }
2799
+ const worktreePath = workspace.worktreePath;
2800
+ if (!fs.existsSync(worktreePath)) {
2801
+ return c.json({ error: `Worktree path does not exist: ${worktreePath}` }, 400);
2802
+ }
2687
2803
  const result = await runSetupScript(workspace.id, worktreePath, effectiveSettings.setupScript, {
2688
2804
  workspaceName: workspace.name,
2689
2805
  branchName: workspace.workingBranch,
@@ -2802,7 +2918,7 @@ async function deleteWorkspaceWithSideEffects(workspace, opts) {
2802
2918
  // a few lines below: firing and forgetting here pulled the directory from
2803
2919
  // under an agent still writing to it, destroying uncommitted work.
2804
2920
  try {
2805
- await agentManager.stopAgentAndWait(workspace.id);
2921
+ await agentManager.stopAgentAndWait(workspace.id, undefined, 'delete');
2806
2922
  }
2807
2923
  catch (err) {
2808
2924
  console.error(`[workspaces] stopAgentAndWait during delete failed for '${workspace.name}':`, err);
@@ -3185,7 +3301,7 @@ app.post('/:id/rollback-file', async (c) => {
3185
3301
  if (!workspace) {
3186
3302
  return c.json({ error: `Workspace '${id}' not found` }, 404);
3187
3303
  }
3188
- const body = await c.req.json();
3304
+ const body = await c.req.json().catch(() => ({}));
3189
3305
  const filePath = typeof body?.path === 'string' ? body.path.trim() : '';
3190
3306
  if (!filePath) {
3191
3307
  return c.json({ error: 'Missing or invalid `path` field' }, 400);
@@ -3427,10 +3543,10 @@ app.post('/:id/push', async (c) => {
3427
3543
  // no-options call shape identical to before for callers/tests that
3428
3544
  // assert on argument count.
3429
3545
  if (force) {
3430
- gitOps.pushBranch(worktreePath, workspace.workingBranch, { force: true });
3546
+ await gitOps.pushBranchAsync(worktreePath, workspace.workingBranch, { force: true });
3431
3547
  }
3432
3548
  else {
3433
- gitOps.pushBranch(worktreePath, workspace.workingBranch);
3549
+ await gitOps.pushBranchAsync(worktreePath, workspace.workingBranch);
3434
3550
  }
3435
3551
  }
3436
3552
  catch (err) {
@@ -3449,14 +3565,14 @@ app.post('/:id/push', async (c) => {
3449
3565
  });
3450
3566
  // POST /api/workspaces/:id/fetch — git fetch the workspace repo (all branches of origin).
3451
3567
  // Read-only: updates remote-tracking refs, never touches the working tree.
3452
- app.post('/:id/fetch', (c) => {
3568
+ app.post('/:id/fetch', async (c) => {
3453
3569
  try {
3454
3570
  const id = c.req.param('id');
3455
3571
  const workspace = workspaceService.getWorkspace(id);
3456
3572
  if (!workspace) {
3457
3573
  return c.json({ error: `Workspace '${id}' not found` }, 404);
3458
3574
  }
3459
- gitOps.fetchAllBranches(workspace.worktreePath);
3575
+ await gitOps.fetchAllBranchesAsync(workspace.worktreePath);
3460
3576
  return c.json({ ok: true });
3461
3577
  }
3462
3578
  catch (err) {
@@ -3465,7 +3581,7 @@ app.post('/:id/fetch', (c) => {
3465
3581
  }
3466
3582
  });
3467
3583
  // POST /api/workspaces/:id/pull — pull working branch from origin (fast-forward only)
3468
- app.post('/:id/pull', (c) => {
3584
+ app.post('/:id/pull', async (c) => {
3469
3585
  try {
3470
3586
  const id = c.req.param('id');
3471
3587
  const workspace = workspaceService.getWorkspace(id);
@@ -3475,7 +3591,7 @@ app.post('/:id/pull', (c) => {
3475
3591
  const worktreePath = workspace.worktreePath;
3476
3592
  const autostash = c.req.query('autostash') === '1';
3477
3593
  try {
3478
- gitOps.pullBranch(worktreePath, workspace.workingBranch, 'origin', { autostash });
3594
+ await gitOps.pullBranchAsync(worktreePath, workspace.workingBranch, 'origin', { autostash });
3479
3595
  }
3480
3596
  catch (err) {
3481
3597
  if (err instanceof gitOps.DirtyWorktreeError) {
@@ -3495,7 +3611,7 @@ app.post('/:id/pull', (c) => {
3495
3611
  }
3496
3612
  });
3497
3613
  /** Rebase the workspace branch onto its source branch. */
3498
- app.post('/:id/rebase', (c) => {
3614
+ app.post('/:id/rebase', async (c) => {
3499
3615
  try {
3500
3616
  const id = c.req.param('id');
3501
3617
  const workspace = workspaceService.getWorkspace(id);
@@ -3503,7 +3619,7 @@ app.post('/:id/rebase', (c) => {
3503
3619
  return c.json({ error: `Workspace '${id}' not found` }, 404);
3504
3620
  const autostash = c.req.query('autostash') === '1';
3505
3621
  const worktreePath = workspace.worktreePath;
3506
- gitOps.rebaseBranch(worktreePath, workspace.sourceBranch, { autostash });
3622
+ await gitOps.rebaseBranchAsync(worktreePath, workspace.sourceBranch, { autostash });
3507
3623
  return c.json({ success: true });
3508
3624
  }
3509
3625
  catch (err) {
@@ -3518,7 +3634,7 @@ app.post('/:id/rebase', (c) => {
3518
3634
  }
3519
3635
  });
3520
3636
  /** Merge the source branch into the workspace branch (non-fast-forward). */
3521
- app.post('/:id/merge', (c) => {
3637
+ app.post('/:id/merge', async (c) => {
3522
3638
  try {
3523
3639
  const id = c.req.param('id');
3524
3640
  const workspace = workspaceService.getWorkspace(id);
@@ -3526,7 +3642,7 @@ app.post('/:id/merge', (c) => {
3526
3642
  return c.json({ error: `Workspace '${id}' not found` }, 404);
3527
3643
  const autostash = c.req.query('autostash') === '1';
3528
3644
  const worktreePath = workspace.worktreePath;
3529
- gitOps.mergeBranch(worktreePath, workspace.sourceBranch, { autostash });
3645
+ await gitOps.mergeBranchAsync(worktreePath, workspace.sourceBranch, { autostash });
3530
3646
  return c.json({ success: true });
3531
3647
  }
3532
3648
  catch (err) {
@@ -3719,9 +3835,14 @@ Start now.`;
3719
3835
  app.post('/:id/change-pr-base', async (c) => {
3720
3836
  try {
3721
3837
  const id = c.req.param('id');
3722
- const body = await c.req.json();
3838
+ const body = await c.req.json().catch(() => ({}));
3723
3839
  if (!body.base)
3724
3840
  return c.json({ error: 'Missing base parameter' }, 400);
3841
+ // Forwarded to `gh pr edit --base <base>` / the GitLab equivalent, so a
3842
+ // name starting with `-` would read as a CLI option rather than a branch.
3843
+ if (!gitOps.isValidBranchName(body.base)) {
3844
+ return c.json({ error: `Invalid base branch name: ${body.base}` }, 400);
3845
+ }
3725
3846
  const workspace = workspaceService.getWorkspace(id);
3726
3847
  if (!workspace)
3727
3848
  return c.json({ error: `Workspace '${id}' not found` }, 404);
@@ -3814,9 +3935,14 @@ app.post('/:id/change-source-branch', async (c) => {
3814
3935
  const id = c.req.param('id');
3815
3936
  const workspace = workspaceService.getWorkspace(id);
3816
3937
  try {
3817
- const body = await c.req.json();
3938
+ const body = await c.req.json().catch(() => ({}));
3818
3939
  if (!body.newBase)
3819
3940
  return c.json({ error: 'Missing newBase parameter' }, 400);
3941
+ // Reaches git (fetch/reset/cherry-pick) and the forge CLI as a bare
3942
+ // argument, and is exported to the custom script as KOBO_NEW_BASE.
3943
+ if (!gitOps.isValidBranchName(body.newBase)) {
3944
+ return c.json({ error: `Invalid source branch name: ${body.newBase}` }, 400);
3945
+ }
3820
3946
  if (!workspace)
3821
3947
  return c.json({ error: `Workspace '${id}' not found` }, 404);
3822
3948
  const result = await changeSourceBranch(id, body.newBase);
@@ -3847,9 +3973,15 @@ app.post('/:id/change-source-branch', async (c) => {
3847
3973
  app.post('/:id/cancel-source-change', async (c) => {
3848
3974
  try {
3849
3975
  const id = c.req.param('id');
3850
- const body = await c.req.json();
3976
+ const body = await c.req.json().catch(() => ({}));
3851
3977
  if (!body.previousBase)
3852
3978
  return c.json({ error: 'Missing previousBase parameter' }, 400);
3979
+ // Persisted as the workspace source branch, and from there it reaches
3980
+ // `git fetch origin <sourceBranch>` as a bare argument — including from a
3981
+ // plain visit to the Diff tab, with no further user action.
3982
+ if (!gitOps.isValidBranchName(body.previousBase)) {
3983
+ return c.json({ error: `Invalid source branch name: ${body.previousBase}` }, 400);
3984
+ }
3853
3985
  const workspace = workspaceService.getWorkspace(id);
3854
3986
  if (!workspace)
3855
3987
  return c.json({ error: `Workspace '${id}' not found` }, 404);
@@ -3877,13 +4009,13 @@ app.post('/:id/cancel-source-change', async (c) => {
3877
4009
  }
3878
4010
  });
3879
4011
  /** Force-push the working branch with --force-with-lease (after a history rewrite). */
3880
- app.post('/:id/force-push', (c) => {
4012
+ app.post('/:id/force-push', async (c) => {
3881
4013
  try {
3882
4014
  const id = c.req.param('id');
3883
4015
  const workspace = workspaceService.getWorkspace(id);
3884
4016
  if (!workspace)
3885
4017
  return c.json({ error: `Workspace '${id}' not found` }, 404);
3886
- gitOps.pushBranch(workspace.worktreePath, workspace.workingBranch, { force: true });
4018
+ await gitOps.pushBranchAsync(workspace.worktreePath, workspace.workingBranch, { force: true });
3887
4019
  return c.json({ success: true });
3888
4020
  }
3889
4021
  catch (err) {
@@ -155,6 +155,7 @@ export function createClaudeCodeEngine() {
155
155
  return;
156
156
  }
157
157
  ctx.signal.addEventListener('abort', onAbort, { once: true });
158
+ resolver.cleanup = () => ctx.signal.removeEventListener('abort', onAbort);
158
159
  onEvent({
159
160
  kind: 'session:user-input-requested',
160
161
  requestKind,
@@ -555,6 +556,7 @@ export function createClaudeCodeEngine() {
555
556
  // covers natural iterator completion.
556
557
  for (const resolver of pendingResolvers.values()) {
557
558
  try {
559
+ resolver.cleanup?.();
558
560
  resolver.resolve({ behavior: 'deny', message: 'session ended', interrupt: false });
559
561
  }
560
562
  catch {
@@ -634,6 +636,7 @@ export function createClaudeCodeEngine() {
634
636
  if (!resolver)
635
637
  return false;
636
638
  pendingResolvers.delete(toolCallId);
639
+ resolver.cleanup?.();
637
640
  // Re-evaluate rather than unconditionally resuming: a sibling
638
641
  // request, a still-active subagent, or an in-progress compaction
639
642
  // may each still have their own legitimate reason to keep the
@@ -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);