@loicngr/kobo 1.11.36 → 1.11.38

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 (112) hide show
  1. package/AGENTS.md +18 -1
  2. package/CHANGELOG.md +8 -0
  3. package/dist/server/index.js +20 -24
  4. package/dist/server/routes/dev-server.js +2 -1
  5. package/dist/server/routes/pull-requests.js +5 -4
  6. package/dist/server/routes/workspaces.js +161 -109
  7. package/dist/server/services/agent/engines/claude-code/engine.js +11 -0
  8. package/dist/server/services/agent/engines/codex/client.js +1 -0
  9. package/dist/server/services/agent/engines/codex/engine.js +47 -10
  10. package/dist/server/services/agent/engines/codex/event-mapper.js +8 -2
  11. package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +21 -8
  12. package/dist/server/services/agent/engines/codex/jsonrpc/transport.js +30 -3
  13. package/dist/server/services/agent/event-router.js +1 -1
  14. package/dist/server/services/agent/orchestrator.js +219 -78
  15. package/dist/server/services/agent/session-controller.js +12 -2
  16. package/dist/server/services/auto-loop-service.js +60 -6
  17. package/dist/server/services/change-source-branch-service.js +36 -4
  18. package/dist/server/services/cron-service.js +19 -0
  19. package/dist/server/services/db-backup-service.js +30 -0
  20. package/dist/server/services/dev-server-service.js +167 -38
  21. package/dist/server/services/image-service.js +25 -28
  22. package/dist/server/services/pr-checkout-service.js +34 -5
  23. package/dist/server/services/pr-watcher-service.js +5 -3
  24. package/dist/server/services/quota-backoff-service.js +25 -8
  25. package/dist/server/services/wakeup-service.js +23 -3
  26. package/dist/server/services/websocket-service.js +10 -2
  27. package/dist/server/services/workspace-service.js +8 -4
  28. package/dist/server/services/worktree-purge-service.js +3 -7
  29. package/dist/server/utils/agent-stop-result.js +12 -0
  30. package/dist/server/utils/git-ops.js +8 -1
  31. package/dist/server/utils/git-worktree-fingerprint.js +86 -0
  32. package/dist/server/utils/safe-path.js +21 -0
  33. package/dist/server/utils/script-runner.js +32 -9
  34. package/dist/server/utils/workspace-lifecycle-guard.js +27 -0
  35. package/package.json +1 -1
  36. package/src/client/dist/pwa/assets/ActivityFeed-BzzvcOmq.js +8 -0
  37. package/src/client/dist/pwa/assets/{ActivityFeed-Bakgvay6.css → ActivityFeed-Chsw1Mh7.css} +1 -1
  38. package/src/client/dist/pwa/assets/{ChangelogPage-BFGYQ6eY.js → ChangelogPage-D8563008.js} +1 -1
  39. package/src/client/dist/pwa/assets/ComparisonPanel-Bd0X08sg.js +2 -0
  40. package/src/client/dist/pwa/assets/ComparisonPanel-CRRaDs2D.css +1 -0
  41. package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +1 -0
  42. package/src/client/dist/pwa/assets/CreatePage-Bon8qIka.js +2 -0
  43. package/src/client/dist/pwa/assets/DashboardPage-D8g7_r4S.css +1 -0
  44. package/src/client/dist/pwa/assets/DashboardPage-DYxdxEul.js +1 -0
  45. package/src/client/dist/pwa/assets/{DiffViewer-DSlSAIiW.js → DiffViewer-BpsLNxnj.js} +3 -3
  46. package/src/client/dist/pwa/assets/{DocumentsPanel-C1eXTw_Q.js → DocumentsPanel-BJsJ0fNs.js} +1 -1
  47. package/src/client/dist/pwa/assets/{DrawerToggleButton-BxYpUb4y.js → DrawerToggleButton-BVLO5K5d.js} +1 -1
  48. package/src/client/dist/pwa/assets/{GitPanel-DnBWSdVp.js → GitPanel-Cal_wzC0.js} +3 -3
  49. package/src/client/dist/pwa/assets/{HealthPage-rX1TiaP9.js → HealthPage-RFZS7Lao.js} +1 -1
  50. package/src/client/dist/pwa/assets/{MainLayout-Ceuh5uJj.js → MainLayout-vvyiRcql.js} +2 -2
  51. package/src/client/dist/pwa/assets/{QBtnDropdown-K7syiyyH.js → QBtnDropdown-X8oNU5Mb.js} +1 -1
  52. package/src/client/dist/pwa/assets/{QDrawer-DapzE8DH.js → QDrawer-Cij17skX.js} +1 -1
  53. package/src/client/dist/pwa/assets/{QSelect-Bodml89k.js → QSelect-xd-AlGo_.js} +1 -1
  54. package/src/client/dist/pwa/assets/{QTooltip-K-9OdDfq.js → QTooltip-CwcCGSWq.js} +1 -1
  55. package/src/client/dist/pwa/assets/{SchedulePanel-CfumTzAa.js → SchedulePanel-CXRUsz6G.js} +1 -1
  56. package/src/client/dist/pwa/assets/{SearchPage-AhZYS-dS.js → SearchPage-DopbgLxh.js} +1 -1
  57. package/src/client/dist/pwa/assets/{SettingsPage-BoHXWvKn.js → SettingsPage-Jx6TnOzR.js} +1 -1
  58. package/src/client/dist/pwa/assets/{SplitWorkspacePage-3wK11mcu.js → SplitWorkspacePage-D7lGFRKs.js} +1 -1
  59. package/src/client/dist/pwa/assets/{TerminalPanel-CKg0BeNK.js → TerminalPanel-DI8DHb9e.js} +1 -1
  60. package/src/client/dist/pwa/assets/{TouchPan-D6ctyPYf.js → TouchPan-B3nrkCY8.js} +1 -1
  61. package/src/client/dist/pwa/assets/{TourReplayButton-Dn4Dy_kH.js → TourReplayButton-DU1UShMY.js} +1 -1
  62. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-DnoabsfK.js → WorkspaceAttentionLabels-C-yN5IX8.js} +1 -1
  63. package/src/client/dist/pwa/assets/{WorkspacePage-DYQrfeYw.css → WorkspacePage-BAC6u9OP.css} +1 -1
  64. package/src/client/dist/pwa/assets/WorkspacePage-DKRpdpPg.js +4 -0
  65. package/src/client/dist/pwa/assets/{browser-DHpSUK8-.js → browser-f9MHQjfi.js} +1 -1
  66. package/src/client/dist/pwa/assets/{build-path-tree-DtEjsZlh.js → build-path-tree-BWBzP8os.js} +1 -1
  67. package/src/client/dist/pwa/assets/{de-Dt_m-H83.js → de-QkDLrcGq.js} +1 -1
  68. package/src/client/dist/pwa/assets/{documents-B2dg1wRf.js → documents-C2cLnEBQ.js} +1 -1
  69. package/src/client/dist/pwa/assets/{editor.api-Cd1D35oj.js → editor.api-sw146MWN.js} +1 -1
  70. package/src/client/dist/pwa/assets/{efforts-IB1vPjJQ.js → efforts-C5ZZgCH4.js} +1 -1
  71. package/src/client/dist/pwa/assets/{engineFeatures-DYSyDEJe.js → engineFeatures-CAVMDawq.js} +1 -1
  72. package/src/client/dist/pwa/assets/{es-DFg25DCS.js → es-BgQFKlo8.js} +1 -1
  73. package/src/client/dist/pwa/assets/{expand-template-CNAHiesL.js → expand-template-B6xAlAaM.js} +1 -1
  74. package/src/client/dist/pwa/assets/{format-DidwgbbX.js → format-BwWP6Aay.js} +1 -1
  75. package/src/client/dist/pwa/assets/{formatters-BjzyUHlu.js → formatters-DyWtO-nR.js} +1 -1
  76. package/src/client/dist/pwa/assets/{fr-CQ1slpol.js → fr-DAdLWjNM.js} +1 -1
  77. package/src/client/dist/pwa/assets/{freemarker2-jNGWawVw.js → freemarker2-DZ5Hm8UH.js} +1 -1
  78. package/src/client/dist/pwa/assets/{handlebars-DfSqClrW.js → handlebars-BRUGpTA7.js} +1 -1
  79. package/src/client/dist/pwa/assets/{html-ByLX-n8T.js → html-DNOayNNs.js} +1 -1
  80. package/src/client/dist/pwa/assets/i18n-CBez_vip.js +1 -0
  81. package/src/client/dist/pwa/assets/index-VL3fI9a3.js +20 -0
  82. package/src/client/dist/pwa/assets/{it-w09rfaP7.js → it-Bumaewp1.js} +1 -1
  83. package/src/client/dist/pwa/assets/{javascript-Dja1fHfv.js → javascript-C0tDQU3z.js} +1 -1
  84. package/src/client/dist/pwa/assets/{liquid-D315hW6C.js → liquid-CN46O6OF.js} +1 -1
  85. package/src/client/dist/pwa/assets/{mdx-B5yUwpzv.js → mdx-DM-lrr8Q.js} +1 -1
  86. package/src/client/dist/pwa/assets/{monaco-contributions-NfxrU0r3.js → monaco-contributions-CkN7y-8c.js} +1 -1
  87. package/src/client/dist/pwa/assets/{monaco.contribution-D1hXPkDZ.js → monaco.contribution-2BcAOLjc.js} +2 -2
  88. package/src/client/dist/pwa/assets/{network-auth-CARVtbNe.js → network-auth-KYS4snQH.js} +1 -1
  89. package/src/client/dist/pwa/assets/{notify-theme-CKC3-EAZ.js → notify-theme-a91Tcr6o.js} +1 -1
  90. package/src/client/dist/pwa/assets/{pending-diff-open-BlLrQaW7.js → pending-diff-open-DOtELZfB.js} +1 -1
  91. package/src/client/dist/pwa/assets/{permissionModes-DMzFEQhr.js → permissionModes-C-9Crj-c.js} +1 -1
  92. package/src/client/dist/pwa/assets/{project-color-CFjdIj6W.js → project-color-BRe4oyNx.js} +1 -1
  93. package/src/client/dist/pwa/assets/{python-Dc6mnEip.js → python-BRpJG6sZ.js} +1 -1
  94. package/src/client/dist/pwa/assets/{razor-DArVFBGO.js → razor-CehCmvHl.js} +1 -1
  95. package/src/client/dist/pwa/assets/{render-chat-markdown-BTPAjU2g.js → render-chat-markdown-DlbQLZ02.js} +1 -1
  96. package/src/client/dist/pwa/assets/{typescript-COE1vIyB.js → typescript-Dt_8mVuj.js} +1 -1
  97. package/src/client/dist/pwa/assets/{use-tours-C8x2V1w5.js → use-tours-CSVM_54K.js} +1 -1
  98. package/src/client/dist/pwa/assets/{workspace-templates-DHyA_GEW.js → workspace-templates-GCObcjO6.js} +1 -1
  99. package/src/client/dist/pwa/assets/{xml-eYjr4cGR.js → xml-_gOwdzB4.js} +1 -1
  100. package/src/client/dist/pwa/assets/{yaml-UooOcssF.js → yaml-BxeBqpGf.js} +1 -1
  101. package/src/client/dist/pwa/index.html +1 -1
  102. package/src/client/dist/pwa/sw.js +1 -1
  103. package/src/client/dist/pwa/assets/ActivityFeed-BPx-xhFY.js +0 -8
  104. package/src/client/dist/pwa/assets/ComparisonPanel-C6DfjpZZ.js +0 -2
  105. package/src/client/dist/pwa/assets/ComparisonPanel-x1iuICgd.css +0 -1
  106. package/src/client/dist/pwa/assets/CreatePage-DxOyY65u.css +0 -1
  107. package/src/client/dist/pwa/assets/CreatePage-ypEzkAKv.js +0 -2
  108. package/src/client/dist/pwa/assets/DashboardPage-DsVRjzzy.js +0 -1
  109. package/src/client/dist/pwa/assets/DashboardPage-DsWn5cWl.css +0 -1
  110. package/src/client/dist/pwa/assets/WorkspacePage-BI7CW2yT.js +0 -4
  111. package/src/client/dist/pwa/assets/i18n-CnjXQjxy.js +0 -1
  112. package/src/client/dist/pwa/assets/index-DcwcFDrZ.js +0 -20
@@ -1,5 +1,7 @@
1
1
  import { execFile as execFileCb, execFileSync, spawn } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import { AgentStopError, assertAgentStopped } from '../utils/agent-stop-result.js';
4
+ import { withGitRepoLock } from '../utils/git-repo-lock.js';
3
5
  const execFileAsync = promisify(execFileCb);
4
6
  import fs from 'node:fs';
5
7
  import path from 'node:path';
@@ -50,8 +52,46 @@ import * as safePath from '../utils/safe-path.js';
50
52
  import { WorkspaceLifecycleBusyError, withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
51
53
  import { resolveExtractedName } from '../utils/workspace-name.js';
52
54
  import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
53
- /** Hono sub-router for workspace CRUD, tasks, agent lifecycle, git operations, and PR creation. */
55
+ /** Lifecycle conflicts can be retried once the current owner has stopped. */
56
+ function workspaceErrorStatus(err) {
57
+ return err instanceof AgentStopError || err instanceof WorkspaceLifecycleBusyError ? 409 : 500;
58
+ }
59
+ /** Hono sub-router for workspace CRUD, tasks, agents, Git and PR creation. */
54
60
  const app = new Hono();
61
+ // These handlers own teardown across awaits; purge/delete/restore own their guard in the service.
62
+ for (const route of ['/:id/archive', '/:id/run-setup-script', '/:id/cancel-source-change']) {
63
+ app.use(route, async (c, next) => {
64
+ try {
65
+ await withWorkspaceLifecycleGuard(c.req.param('id'), next);
66
+ }
67
+ catch (err) {
68
+ if (err instanceof WorkspaceLifecycleBusyError)
69
+ return c.json({ error: err.message, code: err.code }, 409);
70
+ throw err;
71
+ }
72
+ });
73
+ }
74
+ // User prompt actions must reject before cancelling timers, stopping engines,
75
+ // creating a PR or persisting input. The orchestrator also checks at delivery.
76
+ for (const route of [
77
+ '/:id/start',
78
+ '/:id/switch-engine',
79
+ '/:id/git/commit-with-agent',
80
+ '/:id/git/resolve-with-agent',
81
+ '/:id/open-pr',
82
+ '/:id/start-review',
83
+ '/:id/start-ci-fix',
84
+ ]) {
85
+ app.use(route, async (c, next) => {
86
+ if (c.req.method === 'POST' && workspaceService.getWorkspace(c.req.param('id'))?.status === 'compacting') {
87
+ return c.json({
88
+ code: 'compacting',
89
+ error: 'Workspace is compacting its context; wait until compaction finishes before sending a message',
90
+ }, 409);
91
+ }
92
+ await next();
93
+ });
94
+ }
55
95
  /** Tracks workspaces currently running a setup script to prevent concurrent executions. */
56
96
  const setupScriptRunning = new Set();
57
97
  /**
@@ -243,7 +283,7 @@ app.get('/', (c) => {
243
283
  }
244
284
  catch (err) {
245
285
  const message = err instanceof Error ? err.message : String(err);
246
- return c.json({ error: message }, 500);
286
+ return c.json({ error: message }, workspaceErrorStatus(err));
247
287
  }
248
288
  });
249
289
  // POST /api/workspaces/:id/engine-handoff-preview — build an editable, deterministic handoff.
@@ -291,10 +331,16 @@ app.post('/:id/switch-engine', migrationGuard, async (c) => {
291
331
  const handoff = typeof body.handoff === 'string' && body.handoff.trim()
292
332
  ? body.handoff.trim().slice(0, 30_000)
293
333
  : buildEngineHandoff(workspace, workspace.engine, body.engine);
334
+ if (workspaceService.getWorkspace(workspace.id)?.status === 'compacting') {
335
+ return c.json({
336
+ code: 'compacting',
337
+ error: 'Workspace is compacting its context; wait until compaction finishes before sending a message',
338
+ }, 409);
339
+ }
294
340
  // A new engine is started three lines below: the previous one must be dead
295
341
  // first, or two agents share the worktree.
296
342
  if (agentManager.hasController(id))
297
- await agentManager.stopAgentAndWait(id);
343
+ assertAgentStopped(await agentManager.stopAgentAndWait(id, undefined, 'replacement'));
298
344
  const updated = workspaceService.updateWorkspaceEngineConfiguration(id, body.engine, model, effort, body.agentPermissionMode);
299
345
  const session = agentManager.startAgent(id, updated.worktreePath, handoff, updated.model, false, updated.agentPermissionMode, undefined, updated.reasoningEffort);
300
346
  workspaceService.updateWorkspaceStatus(id, 'executing');
@@ -1366,7 +1412,7 @@ app.post('/:id/sessions', migrationGuard, (c) => {
1366
1412
  }
1367
1413
  catch (err) {
1368
1414
  const message = err instanceof Error ? err.message : String(err);
1369
- return c.json({ error: message }, 500);
1415
+ return c.json({ error: message }, workspaceErrorStatus(err));
1370
1416
  }
1371
1417
  });
1372
1418
  // GET /api/workspaces/:id/sessions — list sessions for a workspace
@@ -1381,7 +1427,7 @@ app.get('/:id/sessions', (c) => {
1381
1427
  }
1382
1428
  catch (err) {
1383
1429
  const message = err instanceof Error ? err.message : String(err);
1384
- return c.json({ error: message }, 500);
1430
+ return c.json({ error: message }, workspaceErrorStatus(err));
1385
1431
  }
1386
1432
  });
1387
1433
  // GET /api/workspaces/pr-states — batch snapshot of PR states known to the
@@ -1395,7 +1441,7 @@ app.get('/pr-states', (c) => {
1395
1441
  }
1396
1442
  catch (err) {
1397
1443
  const message = err instanceof Error ? err.message : String(err);
1398
- return c.json({ error: message }, 500);
1444
+ return c.json({ error: message }, workspaceErrorStatus(err));
1399
1445
  }
1400
1446
  });
1401
1447
  // GET /api/workspaces/info — bulk snapshot for the 30s client refresh:
@@ -1415,7 +1461,7 @@ app.get('/info', (c) => {
1415
1461
  }
1416
1462
  catch (err) {
1417
1463
  const message = err instanceof Error ? err.message : String(err);
1418
- return c.json({ error: message }, 500);
1464
+ return c.json({ error: message }, workspaceErrorStatus(err));
1419
1465
  }
1420
1466
  });
1421
1467
  // GET /api/workspaces/:id/comparison — the workspaces running the same task on
@@ -1449,7 +1495,7 @@ app.get('/:id/comparison', (c) => {
1449
1495
  }
1450
1496
  catch (err) {
1451
1497
  const message = err instanceof Error ? err.message : String(err);
1452
- return c.json({ error: message }, 500);
1498
+ return c.json({ error: message }, workspaceErrorStatus(err));
1453
1499
  }
1454
1500
  });
1455
1501
  // GET /api/workspaces/:id/preset — the create-form preset this workspace would
@@ -1464,7 +1510,7 @@ app.get('/:id/preset', (c) => {
1464
1510
  }
1465
1511
  catch (err) {
1466
1512
  const message = err instanceof Error ? err.message : String(err);
1467
- return c.json({ error: message }, 500);
1513
+ return c.json({ error: message }, workspaceErrorStatus(err));
1468
1514
  }
1469
1515
  });
1470
1516
  // POST /api/workspaces/pr-snapshot/refresh/:id — on-demand refresh of a single
@@ -1483,7 +1529,7 @@ app.post('/pr-snapshot/refresh/:id', async (c) => {
1483
1529
  const message = err instanceof Error ? err.message : String(err);
1484
1530
  if (/not found/i.test(message))
1485
1531
  return c.json({ error: message }, 404);
1486
- return c.json({ error: message }, 500);
1532
+ return c.json({ error: message }, workspaceErrorStatus(err));
1487
1533
  }
1488
1534
  });
1489
1535
  // GET /api/workspaces/auto-loop-states — batch snapshot keyed by workspace id.
@@ -1518,7 +1564,7 @@ app.get('/auto-loop-states', (c) => {
1518
1564
  }
1519
1565
  catch (err) {
1520
1566
  const message = err instanceof Error ? err.message : String(err);
1521
- return c.json({ error: message }, 500);
1567
+ return c.json({ error: message }, workspaceErrorStatus(err));
1522
1568
  }
1523
1569
  });
1524
1570
  // GET /api/workspaces/:id/auto-loop — current auto-loop status for one workspace.
@@ -1534,7 +1580,7 @@ app.get('/:id/auto-loop', (c) => {
1534
1580
  }
1535
1581
  catch (err) {
1536
1582
  const message = err instanceof Error ? err.message : String(err);
1537
- return c.json({ error: message }, 500);
1583
+ return c.json({ error: message }, workspaceErrorStatus(err));
1538
1584
  }
1539
1585
  });
1540
1586
  // POST /api/workspaces/:id/auto-loop — enable the loop (user toggle ON).
@@ -1556,7 +1602,7 @@ app.delete('/:id/auto-loop', (c) => {
1556
1602
  }
1557
1603
  catch (err) {
1558
1604
  const message = err instanceof Error ? err.message : String(err);
1559
- return c.json({ error: message }, 500);
1605
+ return c.json({ error: message }, workspaceErrorStatus(err));
1560
1606
  }
1561
1607
  });
1562
1608
  // POST /api/workspaces/:id/auto-loop-ready — force auto_loop_ready=true.
@@ -1576,7 +1622,7 @@ app.post('/:id/auto-loop-ready', (c) => {
1576
1622
  }
1577
1623
  catch (err) {
1578
1624
  const message = err instanceof Error ? err.message : String(err);
1579
- return c.json({ error: message }, 500);
1625
+ return c.json({ error: message }, workspaceErrorStatus(err));
1580
1626
  }
1581
1627
  });
1582
1628
  // GET /api/workspaces/:id/crons — list pending crons for a workspace.
@@ -1590,7 +1636,7 @@ app.get('/:id/crons', (c) => {
1590
1636
  }
1591
1637
  catch (err) {
1592
1638
  const message = err instanceof Error ? err.message : String(err);
1593
- return c.json({ error: message }, 500);
1639
+ return c.json({ error: message }, workspaceErrorStatus(err));
1594
1640
  }
1595
1641
  });
1596
1642
  // POST /api/workspaces/:id/crons — arm a new cron. Validates the expression
@@ -1638,7 +1684,7 @@ app.post('/:id/crons', async (c) => {
1638
1684
  }
1639
1685
  catch (err) {
1640
1686
  const message = err instanceof Error ? err.message : String(err);
1641
- return c.json({ error: message }, 500);
1687
+ return c.json({ error: message }, workspaceErrorStatus(err));
1642
1688
  }
1643
1689
  });
1644
1690
  // DELETE /api/workspaces/:id/crons/:cronId — cancel a single cron. Idempotent:
@@ -1655,7 +1701,7 @@ app.delete('/:id/crons/:cronId', (c) => {
1655
1701
  }
1656
1702
  catch (err) {
1657
1703
  const message = err instanceof Error ? err.message : String(err);
1658
- return c.json({ error: message }, 500);
1704
+ return c.json({ error: message }, workspaceErrorStatus(err));
1659
1705
  }
1660
1706
  });
1661
1707
  // GET /api/workspaces/:id/pending-wakeup — returns the pending wakeup or null.
@@ -1667,7 +1713,7 @@ app.get('/:id/pending-wakeup', (c) => {
1667
1713
  }
1668
1714
  catch (err) {
1669
1715
  const message = err instanceof Error ? err.message : String(err);
1670
- return c.json({ error: message }, 500);
1716
+ return c.json({ error: message }, workspaceErrorStatus(err));
1671
1717
  }
1672
1718
  });
1673
1719
  // DELETE /api/workspaces/:id/pending-wakeup — user-initiated cancel ("×" button)
@@ -1680,7 +1726,7 @@ app.delete('/:id/pending-wakeup', (c) => {
1680
1726
  }
1681
1727
  catch (err) {
1682
1728
  const message = err instanceof Error ? err.message : String(err);
1683
- return c.json({ error: message }, 500);
1729
+ return c.json({ error: message }, workspaceErrorStatus(err));
1684
1730
  }
1685
1731
  });
1686
1732
  // GET /api/workspaces/:id/quota-backoff — returns the pending quota backoff or null.
@@ -1696,7 +1742,7 @@ app.get('/:id/quota-backoff', (c) => {
1696
1742
  }
1697
1743
  catch (err) {
1698
1744
  const message = err instanceof Error ? err.message : String(err);
1699
- return c.json({ error: message }, 500);
1745
+ return c.json({ error: message }, workspaceErrorStatus(err));
1700
1746
  }
1701
1747
  });
1702
1748
  // DELETE /api/workspaces/:id/quota-backoff — user-initiated cancel ("×" button).
@@ -1711,7 +1757,7 @@ app.delete('/:id/quota-backoff', (c) => {
1711
1757
  }
1712
1758
  catch (err) {
1713
1759
  const message = err instanceof Error ? err.message : String(err);
1714
- return c.json({ error: message }, 500);
1760
+ return c.json({ error: message }, workspaceErrorStatus(err));
1715
1761
  }
1716
1762
  });
1717
1763
  // POST /api/workspaces/:id/pending-wakeup — schedule a wakeup, either from
@@ -1748,7 +1794,7 @@ app.post('/:id/pending-wakeup', async (c) => {
1748
1794
  }
1749
1795
  catch (err) {
1750
1796
  const message = err instanceof Error ? err.message : String(err);
1751
- return c.json({ error: message }, 500);
1797
+ return c.json({ error: message }, workspaceErrorStatus(err));
1752
1798
  }
1753
1799
  });
1754
1800
  // PATCH /api/workspaces/:id/sessions/:sessionId — rename a session
@@ -1772,7 +1818,7 @@ app.patch('/:id/sessions/:sessionId', async (c) => {
1772
1818
  }
1773
1819
  catch (err) {
1774
1820
  const message = err instanceof Error ? err.message : String(err);
1775
- return c.json({ error: message }, 500);
1821
+ return c.json({ error: message }, workspaceErrorStatus(err));
1776
1822
  }
1777
1823
  });
1778
1824
  // DELETE /api/workspaces/:id/sessions/:sessionId — remove a stopped session and its history.
@@ -1909,7 +1955,7 @@ app.delete('/:id/events/:eventId', (c) => {
1909
1955
  }
1910
1956
  catch (err) {
1911
1957
  const message = err instanceof Error ? err.message : 'unknown';
1912
- return c.json({ error: message }, 500);
1958
+ return c.json({ error: message }, workspaceErrorStatus(err));
1913
1959
  }
1914
1960
  });
1915
1961
  // POST /api/workspaces/:id/tasks — create a new task
@@ -1937,7 +1983,7 @@ app.post('/:id/tasks', async (c) => {
1937
1983
  }
1938
1984
  catch (err) {
1939
1985
  const message = err instanceof Error ? err.message : String(err);
1940
- return c.json({ error: message }, 500);
1986
+ return c.json({ error: message }, workspaceErrorStatus(err));
1941
1987
  }
1942
1988
  });
1943
1989
  // PATCH /api/workspaces/:id/tasks/:taskId — update task status and/or title
@@ -1976,7 +2022,7 @@ app.patch('/:id/tasks/:taskId', async (c) => {
1976
2022
  }
1977
2023
  catch (err) {
1978
2024
  const message = err instanceof Error ? err.message : String(err);
1979
- return c.json({ error: message }, 500);
2025
+ return c.json({ error: message }, workspaceErrorStatus(err));
1980
2026
  }
1981
2027
  });
1982
2028
  // DELETE /api/workspaces/:id/tasks/:taskId — delete a task
@@ -1997,7 +2043,7 @@ app.delete('/:id/tasks/:taskId', (c) => {
1997
2043
  }
1998
2044
  catch (err) {
1999
2045
  const message = err instanceof Error ? err.message : String(err);
2000
- return c.json({ error: message }, 500);
2046
+ return c.json({ error: message }, workspaceErrorStatus(err));
2001
2047
  }
2002
2048
  });
2003
2049
  // POST /api/workspaces/:id/tasks/notify-updated — broadcast generic task list change
@@ -2015,7 +2061,7 @@ app.post('/:id/tasks/notify-updated', (c) => {
2015
2061
  }
2016
2062
  catch (err) {
2017
2063
  const message = err instanceof Error ? err.message : String(err);
2018
- return c.json({ error: message }, 500);
2064
+ return c.json({ error: message }, workspaceErrorStatus(err));
2019
2065
  }
2020
2066
  });
2021
2067
  // POST /api/workspaces/:id/agent-description/notify-updated — broadcast
@@ -2037,7 +2083,7 @@ app.post('/:id/agent-description/notify-updated', (c) => {
2037
2083
  }
2038
2084
  catch (err) {
2039
2085
  const message = err instanceof Error ? err.message : String(err);
2040
- return c.json({ error: message }, 500);
2086
+ return c.json({ error: message }, workspaceErrorStatus(err));
2041
2087
  }
2042
2088
  });
2043
2089
  // POST /api/workspaces/:id/crons/notify-updated — broadcast cron list changed
@@ -2053,7 +2099,7 @@ app.post('/:id/crons/notify-updated', (c) => {
2053
2099
  }
2054
2100
  catch (err) {
2055
2101
  const message = err instanceof Error ? err.message : String(err);
2056
- return c.json({ error: message }, 500);
2102
+ return c.json({ error: message }, workspaceErrorStatus(err));
2057
2103
  }
2058
2104
  });
2059
2105
  // POST /api/workspaces/:id/tasks/:taskId/notify-done — broadcast task:updated event
@@ -2070,7 +2116,7 @@ app.post('/:id/tasks/:taskId/notify-done', (c) => {
2070
2116
  }
2071
2117
  catch (err) {
2072
2118
  const message = err instanceof Error ? err.message : String(err);
2073
- return c.json({ error: message }, 500);
2119
+ return c.json({ error: message }, workspaceErrorStatus(err));
2074
2120
  }
2075
2121
  });
2076
2122
  // Cap how many recent rows one search may scan: the LIKE below cannot use an
@@ -2201,7 +2247,7 @@ app.get('/:id/sessions/:sessionId/summary', (c) => {
2201
2247
  }
2202
2248
  catch (err) {
2203
2249
  const message = err instanceof Error ? err.message : String(err);
2204
- return c.json({ error: message }, 500);
2250
+ return c.json({ error: message }, workspaceErrorStatus(err));
2205
2251
  }
2206
2252
  });
2207
2253
  // GET /api/workspaces/:id/session-metrics — compact observability data for the timeline.
@@ -2378,7 +2424,7 @@ app.get('/:id/events', (c) => {
2378
2424
  }
2379
2425
  catch (err) {
2380
2426
  const message = err instanceof Error ? err.message : String(err);
2381
- return c.json({ error: message }, 500);
2427
+ return c.json({ error: message }, workspaceErrorStatus(err));
2382
2428
  }
2383
2429
  });
2384
2430
  // GET /api/workspaces/archived — list archived workspaces (must be before GET /:id)
@@ -2388,7 +2434,7 @@ app.get('/archived', (c) => {
2388
2434
  }
2389
2435
  catch (err) {
2390
2436
  const message = err instanceof Error ? err.message : String(err);
2391
- return c.json({ error: message }, 500);
2437
+ return c.json({ error: message }, workspaceErrorStatus(err));
2392
2438
  }
2393
2439
  });
2394
2440
  // GET /:id/prep-autoloop-prompt — compose the project-aware grooming
@@ -2415,7 +2461,7 @@ ${AUTO_LOOP_HARD_RULES}`;
2415
2461
  }
2416
2462
  catch (err) {
2417
2463
  const message = err instanceof Error ? err.message : String(err);
2418
- return c.json({ error: message }, 500);
2464
+ return c.json({ error: message }, workspaceErrorStatus(err));
2419
2465
  }
2420
2466
  });
2421
2467
  // GET /:id/chat-history — list up to 200 most recent chat-input messages
@@ -2431,7 +2477,7 @@ app.get('/:id/chat-history', (c) => {
2431
2477
  }
2432
2478
  catch (err) {
2433
2479
  const message = err instanceof Error ? err.message : String(err);
2434
- return c.json({ error: message }, 500);
2480
+ return c.json({ error: message }, workspaceErrorStatus(err));
2435
2481
  }
2436
2482
  });
2437
2483
  // POST /:id/chat-history — append a message to this workspace's history.
@@ -2454,7 +2500,7 @@ app.post('/:id/chat-history', async (c) => {
2454
2500
  }
2455
2501
  catch (err) {
2456
2502
  const message = err instanceof Error ? err.message : String(err);
2457
- return c.json({ error: message }, 500);
2503
+ return c.json({ error: message }, workspaceErrorStatus(err));
2458
2504
  }
2459
2505
  });
2460
2506
  // POST /:id/save-file — persist edited file content from the diff viewer.
@@ -2490,7 +2536,7 @@ app.post('/:id/save-file', async (c) => {
2490
2536
  }
2491
2537
  catch (err) {
2492
2538
  const message = err instanceof Error ? err.message : String(err);
2493
- return c.json({ error: message }, 500);
2539
+ return c.json({ error: message }, workspaceErrorStatus(err));
2494
2540
  }
2495
2541
  });
2496
2542
  // GET /api/workspaces/:id — get workspace details with tasks
@@ -2505,7 +2551,7 @@ app.get('/:id', (c) => {
2505
2551
  }
2506
2552
  catch (err) {
2507
2553
  const message = err instanceof Error ? err.message : String(err);
2508
- return c.json({ error: message }, 500);
2554
+ return c.json({ error: message }, workspaceErrorStatus(err));
2509
2555
  }
2510
2556
  });
2511
2557
  // POST /api/workspaces/:id/favorite — mark workspace as favorite
@@ -2660,7 +2706,7 @@ app.patch('/:id', migrationGuard, async (c) => {
2660
2706
  message.includes('Description must be')) {
2661
2707
  return c.json({ error: message }, 400);
2662
2708
  }
2663
- return c.json({ error: message }, 500);
2709
+ return c.json({ error: message }, workspaceErrorStatus(err));
2664
2710
  }
2665
2711
  });
2666
2712
  /** Open the workspace worktree in the user's configured editor. */
@@ -2696,7 +2742,7 @@ app.post('/:id/open-editor', (c) => {
2696
2742
  }
2697
2743
  catch (err) {
2698
2744
  const message = err instanceof Error ? err.message : String(err);
2699
- return c.json({ error: message }, 500);
2745
+ return c.json({ error: message }, workspaceErrorStatus(err));
2700
2746
  }
2701
2747
  });
2702
2748
  /** Open the workspace worktree in the user's configured terminal emulator. */
@@ -2736,7 +2782,7 @@ app.post('/:id/open-terminal', async (c) => {
2736
2782
  }
2737
2783
  catch (err) {
2738
2784
  const message = err instanceof Error ? err.message : String(err);
2739
- return c.json({ error: message }, 500);
2785
+ return c.json({ error: message }, workspaceErrorStatus(err));
2740
2786
  }
2741
2787
  });
2742
2788
  /** Open the workspace worktree in the user's configured file manager. */
@@ -2769,7 +2815,7 @@ app.post('/:id/open-file-manager', async (c) => {
2769
2815
  }
2770
2816
  catch (err) {
2771
2817
  const message = err instanceof Error ? err.message : String(err);
2772
- return c.json({ error: message }, 500);
2818
+ return c.json({ error: message }, workspaceErrorStatus(err));
2773
2819
  }
2774
2820
  });
2775
2821
  /** Spawn a detached process and resolve as soon as it has started (or reject
@@ -2814,11 +2860,12 @@ app.post('/:id/run-setup-script', async (c) => {
2814
2860
  // rewrites the worktree the agent may still be writing to.
2815
2861
  try {
2816
2862
  if (agentManager.getAgentStatus(id)) {
2817
- await agentManager.stopAgentAndWait(id);
2863
+ assertAgentStopped(await agentManager.stopAgentAndWait(id, undefined, 'setup'));
2818
2864
  }
2819
2865
  }
2820
2866
  catch (err) {
2821
2867
  console.error(`[workspaces] stopAgentAndWait before setup script failed for '${id}':`, err);
2868
+ throw err;
2822
2869
  }
2823
2870
  const effectiveSettings = settingsService.getEffectiveSettings(workspace.projectPath);
2824
2871
  if (!effectiveSettings.setupScript) {
@@ -2845,7 +2892,7 @@ app.post('/:id/run-setup-script', async (c) => {
2845
2892
  }
2846
2893
  catch (err) {
2847
2894
  const message = err instanceof Error ? err.message : String(err);
2848
- return c.json({ error: message }, 500);
2895
+ return c.json({ error: message }, workspaceErrorStatus(err));
2849
2896
  }
2850
2897
  });
2851
2898
  // POST /api/workspaces/:id/archive — mark workspace as archived (soft-delete)
@@ -2860,10 +2907,11 @@ app.post('/:id/archive', migrationGuard, async (c) => {
2860
2907
  return c.json({ error: 'Already archived' }, 400);
2861
2908
  }
2862
2909
  try {
2863
- await agentManager.stopAgentAndWait(id);
2910
+ assertAgentStopped(await agentManager.stopAgentAndWait(id, undefined, 'archive'));
2864
2911
  }
2865
2912
  catch (err) {
2866
2913
  console.error(`[workspaces] stopAgentAndWait during archive failed for '${id}':`, err);
2914
+ throw err;
2867
2915
  }
2868
2916
  try {
2869
2917
  await devServerService.stopDevServer(id);
@@ -2871,6 +2919,7 @@ app.post('/:id/archive', migrationGuard, async (c) => {
2871
2919
  catch (err) {
2872
2920
  const message = err instanceof Error ? err.message : String(err);
2873
2921
  console.error(`[workspaces] stopDevServer during archive failed: ${message}`);
2922
+ throw err;
2874
2923
  }
2875
2924
  try {
2876
2925
  terminalService.destroyTerminal(id);
@@ -2886,7 +2935,7 @@ app.post('/:id/archive', migrationGuard, async (c) => {
2886
2935
  }
2887
2936
  catch (err) {
2888
2937
  const message = err instanceof Error ? err.message : String(err);
2889
- return c.json({ error: message }, 500);
2938
+ return c.json({ error: message }, workspaceErrorStatus(err));
2890
2939
  }
2891
2940
  });
2892
2941
  // POST /api/workspaces/:id/purge-worktree
@@ -2909,7 +2958,7 @@ app.post('/:id/purge-worktree', migrationGuard, async (c) => {
2909
2958
  if (err instanceof WorkspaceLifecycleBusyError)
2910
2959
  return c.json({ code: err.code, error: err.message }, 409);
2911
2960
  const message = err instanceof Error ? err.message : String(err);
2912
- return c.json({ error: message }, 500);
2961
+ return c.json({ error: message }, workspaceErrorStatus(err));
2913
2962
  }
2914
2963
  });
2915
2964
  // Recreate the checkout before unarchiving; never starts agents or setup scripts.
@@ -2953,7 +3002,7 @@ app.post('/:id/unarchive', migrationGuard, (c) => {
2953
3002
  }
2954
3003
  catch (err) {
2955
3004
  const message = err instanceof Error ? err.message : String(err);
2956
- return c.json({ error: message }, 500);
3005
+ return c.json({ error: message }, workspaceErrorStatus(err));
2957
3006
  }
2958
3007
  });
2959
3008
  // Shared teardown for a single workspace: stops the agent, destroys the
@@ -2979,10 +3028,11 @@ async function deleteWorkspaceWithSideEffectsUnlocked(workspace, opts) {
2979
3028
  // a few lines below: firing and forgetting here pulled the directory from
2980
3029
  // under an agent still writing to it, destroying uncommitted work.
2981
3030
  try {
2982
- await agentManager.stopAgentAndWait(workspace.id, undefined, 'delete');
3031
+ assertAgentStopped(await agentManager.stopAgentAndWait(workspace.id, undefined, 'delete'));
2983
3032
  }
2984
3033
  catch (err) {
2985
3034
  console.error(`[workspaces] stopAgentAndWait during delete failed for '${workspace.name}':`, err);
3035
+ throw err;
2986
3036
  }
2987
3037
  // Stop dev server if it was running. The processSpawn would otherwise
2988
3038
  // outlive the workspace (and keep its port + docker containers alive).
@@ -2991,6 +3041,7 @@ async function deleteWorkspaceWithSideEffectsUnlocked(workspace, opts) {
2991
3041
  }
2992
3042
  catch (err) {
2993
3043
  console.error(`[workspaces] stopDevServer during delete failed for '${workspace.name}':`, err);
3044
+ throw err;
2994
3045
  }
2995
3046
  try {
2996
3047
  terminalService.destroyTerminal(workspace.id);
@@ -3089,7 +3140,7 @@ app.delete('/archived', migrationGuard, async (c) => {
3089
3140
  }
3090
3141
  catch (err) {
3091
3142
  const message = err instanceof Error ? err.message : String(err);
3092
- return c.json({ error: message }, 500);
3143
+ return c.json({ error: message }, workspaceErrorStatus(err));
3093
3144
  }
3094
3145
  });
3095
3146
  // DELETE /api/workspaces/:id — delete workspace
@@ -3117,7 +3168,7 @@ app.delete('/:id', migrationGuard, async (c) => {
3117
3168
  if (err instanceof WorkspaceLifecycleBusyError)
3118
3169
  return c.json({ code: err.code, error: err.message }, 409);
3119
3170
  const message = err instanceof Error ? err.message : String(err);
3120
- return c.json({ error: message }, 500);
3171
+ return c.json({ error: message }, workspaceErrorStatus(err));
3121
3172
  }
3122
3173
  });
3123
3174
  // POST /api/workspaces/:id/start — start/restart agent
@@ -3156,16 +3207,23 @@ app.post('/:id/start', migrationGuard, async (c) => {
3156
3207
  const prompt = body.prompt ?? pendingInitialPrompt ?? 'Continue the previous task where you left off.';
3157
3208
  const agentSessionId = body.agentSessionId;
3158
3209
  const resume = body.resume === true;
3210
+ if (workspaceService.getWorkspace(id)?.status === 'compacting') {
3211
+ return c.json({
3212
+ code: 'compacting',
3213
+ error: 'Workspace is compacting its context; wait until compaction finishes before sending a message',
3214
+ }, 409);
3215
+ }
3159
3216
  // A manual start supersedes any scheduled retry (quota or transient
3160
3217
  // watchdog recovery). Leaving its timer armed could spawn a second agent
3161
3218
  // after the user has already resumed the workspace.
3162
3219
  quotaBackoffService.cancel(id, 'user');
3163
3220
  // Stop the existing agent and wait: a fresh one starts right below.
3164
3221
  try {
3165
- await agentManager.stopAgentAndWait(id);
3222
+ assertAgentStopped(await agentManager.stopAgentAndWait(id, undefined, 'replacement'));
3166
3223
  }
3167
3224
  catch (err) {
3168
3225
  console.error(`[workspaces] stopAgentAndWait before start failed for '${id}':`, err);
3226
+ throw err;
3169
3227
  }
3170
3228
  const worktreePath = workspace.worktreePath;
3171
3229
  const agent = agentManager.startAgent(id, worktreePath, prompt, workspace.model, resume, workspace.agentPermissionMode, agentSessionId, workspace.reasoningEffort);
@@ -3195,7 +3253,7 @@ app.post('/:id/start', migrationGuard, async (c) => {
3195
3253
  }
3196
3254
  catch (err) {
3197
3255
  const message = err instanceof Error ? err.message : String(err);
3198
- return c.json({ error: message }, 500);
3256
+ return c.json({ error: message }, workspaceErrorStatus(err));
3199
3257
  }
3200
3258
  });
3201
3259
  // GET /api/workspaces/:id/git-stats — commit count and diff stats for the branch
@@ -3221,7 +3279,7 @@ app.get('/:id/git-stats', async (c) => {
3221
3279
  }
3222
3280
  catch (err) {
3223
3281
  const message = err instanceof Error ? err.message : String(err);
3224
- return c.json({ error: message }, 500);
3282
+ return c.json({ error: message }, workspaceErrorStatus(err));
3225
3283
  }
3226
3284
  });
3227
3285
  // GET /api/workspaces/:id/diff?mode=branch|unpushed — list changed files
@@ -3283,7 +3341,7 @@ app.get('/:id/diff', async (c) => {
3283
3341
  }
3284
3342
  catch (err) {
3285
3343
  const message = err instanceof Error ? err.message : String(err);
3286
- return c.json({ error: message }, 500);
3344
+ return c.json({ error: message }, workspaceErrorStatus(err));
3287
3345
  }
3288
3346
  });
3289
3347
  // GET /api/workspaces/:id/diff-file?path=...&mode=branch|unpushed
@@ -3349,7 +3407,7 @@ app.get('/:id/diff-file', (c) => {
3349
3407
  }
3350
3408
  catch (err) {
3351
3409
  const message = err instanceof Error ? err.message : String(err);
3352
- return c.json({ error: message }, 500);
3410
+ return c.json({ error: message }, workspaceErrorStatus(err));
3353
3411
  }
3354
3412
  });
3355
3413
  // POST /api/workspaces/:id/rollback-file { path }
@@ -3388,7 +3446,7 @@ app.post('/:id/rollback-file', async (c) => {
3388
3446
  }
3389
3447
  catch (err) {
3390
3448
  const message = err instanceof Error ? err.message : String(err);
3391
- return c.json({ error: message }, 500);
3449
+ return c.json({ error: message }, workspaceErrorStatus(err));
3392
3450
  }
3393
3451
  });
3394
3452
  // GET /api/workspaces/:id/branch-divergence?limit=50
@@ -3420,7 +3478,7 @@ app.get('/:id/branch-divergence', async (c) => {
3420
3478
  }
3421
3479
  catch (err) {
3422
3480
  const message = err instanceof Error ? err.message : String(err);
3423
- return c.json({ error: message }, 500);
3481
+ return c.json({ error: message }, workspaceErrorStatus(err));
3424
3482
  }
3425
3483
  });
3426
3484
  // GET /api/workspaces/:id/commits?limit=50 — list commits between
@@ -3444,7 +3502,7 @@ app.get('/:id/commits', async (c) => {
3444
3502
  }
3445
3503
  catch (err) {
3446
3504
  const message = err instanceof Error ? err.message : String(err);
3447
- return c.json({ error: message }, 500);
3505
+ return c.json({ error: message }, workspaceErrorStatus(err));
3448
3506
  }
3449
3507
  });
3450
3508
  // GET /:id/working-tree-files — list uncommitted working-tree files (read-only)
@@ -3459,7 +3517,7 @@ app.get('/:id/working-tree-files', (c) => {
3459
3517
  }
3460
3518
  catch (err) {
3461
3519
  const message = err instanceof Error ? err.message : String(err);
3462
- return c.json({ error: message }, 500);
3520
+ return c.json({ error: message }, workspaceErrorStatus(err));
3463
3521
  }
3464
3522
  });
3465
3523
  // POST /api/workspaces/:id/rename-branch { newName }
@@ -3531,7 +3589,7 @@ app.post('/:id/rename-branch', async (c) => {
3531
3589
  }
3532
3590
  catch (err) {
3533
3591
  const message = err instanceof Error ? err.message : String(err);
3534
- return c.json({ error: message }, 500);
3592
+ return c.json({ error: message }, workspaceErrorStatus(err));
3535
3593
  }
3536
3594
  });
3537
3595
  // POST /api/workspaces/:id/resync-branch
@@ -3587,7 +3645,7 @@ app.post('/:id/resync-branch', (c) => {
3587
3645
  }
3588
3646
  catch (err) {
3589
3647
  const message = err instanceof Error ? err.message : String(err);
3590
- return c.json({ error: message }, 500);
3648
+ return c.json({ error: message }, workspaceErrorStatus(err));
3591
3649
  }
3592
3650
  });
3593
3651
  // POST /api/workspaces/:id/push — push working branch to origin
@@ -3624,7 +3682,7 @@ app.post('/:id/push', async (c) => {
3624
3682
  }
3625
3683
  catch (err) {
3626
3684
  const message = err instanceof Error ? err.message : String(err);
3627
- return c.json({ error: message }, 500);
3685
+ return c.json({ error: message }, workspaceErrorStatus(err));
3628
3686
  }
3629
3687
  // Emit a trace into the chat feed so the user sees the action
3630
3688
  const session = workspaceService.getActiveSession(id);
@@ -3638,7 +3696,7 @@ app.post('/:id/push', async (c) => {
3638
3696
  }
3639
3697
  catch (err) {
3640
3698
  const message = err instanceof Error ? err.message : String(err);
3641
- return c.json({ error: message }, 500);
3699
+ return c.json({ error: message }, workspaceErrorStatus(err));
3642
3700
  }
3643
3701
  });
3644
3702
  // POST /api/workspaces/:id/fetch — git fetch the workspace repo (all branches of origin).
@@ -3655,7 +3713,7 @@ app.post('/:id/fetch', async (c) => {
3655
3713
  }
3656
3714
  catch (err) {
3657
3715
  const message = err instanceof Error ? err.message : String(err);
3658
- return c.json({ error: message }, 500);
3716
+ return c.json({ error: message }, workspaceErrorStatus(err));
3659
3717
  }
3660
3718
  });
3661
3719
  // POST /api/workspaces/:id/pull — pull working branch from origin (fast-forward only)
@@ -3676,7 +3734,7 @@ app.post('/:id/pull', async (c) => {
3676
3734
  return c.json({ error: err.message, code: 'dirty_worktree', operation: err.operation, status: err.status }, 409);
3677
3735
  }
3678
3736
  const message = err instanceof Error ? err.message : String(err);
3679
- return c.json({ error: message }, 500);
3737
+ return c.json({ error: message }, workspaceErrorStatus(err));
3680
3738
  }
3681
3739
  // Emit a trace into the chat feed so the user sees the action
3682
3740
  const session = workspaceService.getActiveSession(id);
@@ -3685,7 +3743,7 @@ app.post('/:id/pull', async (c) => {
3685
3743
  }
3686
3744
  catch (err) {
3687
3745
  const message = err instanceof Error ? err.message : String(err);
3688
- return c.json({ error: message }, 500);
3746
+ return c.json({ error: message }, workspaceErrorStatus(err));
3689
3747
  }
3690
3748
  });
3691
3749
  /** Rebase the workspace branch onto its source branch. */
@@ -3708,7 +3766,7 @@ app.post('/:id/rebase', async (c) => {
3708
3766
  return c.json({ error: err.message, code: 'dirty_worktree', operation: err.operation, status: err.status }, 409);
3709
3767
  }
3710
3768
  const message = err instanceof Error ? err.message : String(err);
3711
- return c.json({ error: message }, 500);
3769
+ return c.json({ error: message }, workspaceErrorStatus(err));
3712
3770
  }
3713
3771
  });
3714
3772
  /** Merge the source branch into the workspace branch (non-fast-forward). */
@@ -3731,7 +3789,7 @@ app.post('/:id/merge', async (c) => {
3731
3789
  return c.json({ error: err.message, code: 'dirty_worktree', operation: err.operation, status: err.status }, 409);
3732
3790
  }
3733
3791
  const message = err instanceof Error ? err.message : String(err);
3734
- return c.json({ error: message }, 500);
3792
+ return c.json({ error: message }, workspaceErrorStatus(err));
3735
3793
  }
3736
3794
  });
3737
3795
  /** Abort any in-progress merge or rebase in the worktree. */
@@ -3747,7 +3805,7 @@ app.post('/:id/git/abort', (c) => {
3747
3805
  }
3748
3806
  catch (err) {
3749
3807
  const message = err instanceof Error ? err.message : String(err);
3750
- return c.json({ error: message }, 500);
3808
+ return c.json({ error: message }, workspaceErrorStatus(err));
3751
3809
  }
3752
3810
  });
3753
3811
  /** Continue an in-progress merge, rebase or cherry-pick after resolution. */
@@ -3764,7 +3822,7 @@ app.post('/:id/git/continue', (c) => {
3764
3822
  }
3765
3823
  catch (err) {
3766
3824
  const message = err instanceof Error ? err.message : String(err);
3767
- return c.json({ error: message }, 500);
3825
+ return c.json({ error: message }, workspaceErrorStatus(err));
3768
3826
  }
3769
3827
  });
3770
3828
  /** Stage + commit all working-tree changes (recovery action for a dirty rebase/merge). */
@@ -3783,7 +3841,7 @@ app.post('/:id/git/commit-all', async (c) => {
3783
3841
  }
3784
3842
  catch (err) {
3785
3843
  const message = err instanceof Error ? err.message : String(err);
3786
- return c.json({ error: message }, 500);
3844
+ return c.json({ error: message }, workspaceErrorStatus(err));
3787
3845
  }
3788
3846
  });
3789
3847
  /** Ask the workspace agent to inspect and commit the current working tree. */
@@ -3806,7 +3864,6 @@ app.post('/:id/git/commit-with-agent', migrationGuard, async (c) => {
3806
3864
  5. Do NOT push the branch.
3807
3865
 
3808
3866
  When finished, report the commit SHA, its message, the files included, and the checks you ran.`;
3809
- wakeupService.cancel(workspace.id, 'user-message');
3810
3867
  let messageSent = false;
3811
3868
  try {
3812
3869
  const { agentSessionId } = await deliverAgentPrompt(workspace, workspace.worktreePath, prompt);
@@ -3835,7 +3892,7 @@ app.post('/:id/git/discard', (c) => {
3835
3892
  }
3836
3893
  catch (err) {
3837
3894
  const message = err instanceof Error ? err.message : String(err);
3838
- return c.json({ error: message }, 500);
3895
+ return c.json({ error: message }, workspaceErrorStatus(err));
3839
3896
  }
3840
3897
  });
3841
3898
  /** Hand off merge/rebase conflicts to the workspace agent with an intelligent-resolution prompt. */
@@ -3889,9 +3946,6 @@ ${fileList}
3889
3946
  6. Report the summary: which files you touched, the key decisions you made, and the final test result.${cherryPickNote}
3890
3947
 
3891
3948
  Start now.`;
3892
- // Cancel any pending wakeup: the user is driving this turn, the
3893
- // scheduler should not also wake the agent a few minutes later.
3894
- wakeupService.cancel(workspace.id, 'user-message');
3895
3949
  let messageSent = false;
3896
3950
  try {
3897
3951
  const { agentSessionId } = await deliverAgentPrompt(workspace, worktreePath, prompt);
@@ -3906,7 +3960,7 @@ Start now.`;
3906
3960
  }
3907
3961
  catch (err) {
3908
3962
  const message = err instanceof Error ? err.message : String(err);
3909
- return c.json({ error: message }, 500);
3963
+ return c.json({ error: message }, workspaceErrorStatus(err));
3910
3964
  }
3911
3965
  });
3912
3966
  /** Change the base branch of an existing PR via the resolved forge provider. */
@@ -3943,7 +3997,7 @@ app.post('/:id/change-pr-base', async (c) => {
3943
3997
  return c.json({ error: err.message, code: 'forge_unavailable' }, 409);
3944
3998
  }
3945
3999
  const message = err instanceof Error ? err.message : String(err);
3946
- return c.json({ error: message }, 500);
4000
+ return c.json({ error: message }, workspaceErrorStatus(err));
3947
4001
  }
3948
4002
  });
3949
4003
  /** Merge a ready, open PR/MR through its resolved forge provider. */
@@ -3980,7 +4034,7 @@ app.post('/:id/merge-pr', async (c) => {
3980
4034
  return c.json({ error: err.message, code: 'forge_unavailable' }, 409);
3981
4035
  }
3982
4036
  const message = err instanceof Error ? err.message : String(err);
3983
- return c.json({ error: message }, 500);
4037
+ return c.json({ error: message }, workspaceErrorStatus(err));
3984
4038
  }
3985
4039
  });
3986
4040
  /** Delete the workspace branch from the remote after its PR/MR was merged. */
@@ -4005,7 +4059,7 @@ app.post('/:id/delete-remote-branch', async (c) => {
4005
4059
  }
4006
4060
  catch (err) {
4007
4061
  const message = err instanceof Error ? err.message : String(err);
4008
- return c.json({ error: message }, 500);
4062
+ return c.json({ error: message }, workspaceErrorStatus(err));
4009
4063
  }
4010
4064
  });
4011
4065
  /** Re-target a workspace onto a new source branch (metadata + worktree + PR base). */
@@ -4077,13 +4131,17 @@ app.post('/:id/cancel-source-change', async (c) => {
4077
4131
  if (backups.length === 0) {
4078
4132
  return c.json({ error: 'No backup branch found — cannot auto-restore', code: 'no_backup' }, 409);
4079
4133
  }
4080
- gitOps.restoreBranchFromBackup(workspace.worktreePath, workspace.workingBranch, backups[0]);
4134
+ if (agentManager.hasController(id))
4135
+ return c.json({ error: 'Stop the agent before restoring the branch' }, 409);
4136
+ await withGitRepoLock(workspace.worktreePath, async () => {
4137
+ gitOps.restoreBranchFromBackup(workspace.worktreePath, workspace.workingBranch, backups[0]);
4138
+ });
4081
4139
  workspaceService.updateWorkspaceSourceBranch(id, body.previousBase);
4082
4140
  return c.json({ success: true, restoredFrom: backups[0] });
4083
4141
  }
4084
4142
  catch (err) {
4085
4143
  const message = err instanceof Error ? err.message : String(err);
4086
- return c.json({ error: message }, 500);
4144
+ return c.json({ error: message }, workspaceErrorStatus(err));
4087
4145
  }
4088
4146
  });
4089
4147
  /** Force-push the working branch with --force-with-lease (after a history rewrite). */
@@ -4119,7 +4177,7 @@ app.post('/:id/force-push', async (c) => {
4119
4177
  }
4120
4178
  catch (err) {
4121
4179
  const message = err instanceof Error ? err.message : String(err);
4122
- return c.json({ error: message }, 500);
4180
+ return c.json({ error: message }, workspaceErrorStatus(err));
4123
4181
  }
4124
4182
  });
4125
4183
  // POST /api/workspaces/:id/open-pr — create a GitHub PR and send a templated prompt to the agent
@@ -4222,8 +4280,6 @@ app.post('/:id/open-pr', async (c) => {
4222
4280
  diffStats,
4223
4281
  tasks,
4224
4282
  });
4225
- // Cancel any pending wakeup: the user is driving this turn.
4226
- wakeupService.cancel(workspace.id, 'user-message');
4227
4283
  // Send to the running agent, or resume the agent with the PR prompt
4228
4284
  let messageSent = false;
4229
4285
  try {
@@ -4239,7 +4295,7 @@ app.post('/:id/open-pr', async (c) => {
4239
4295
  }
4240
4296
  catch (err) {
4241
4297
  const message = err instanceof Error ? err.message : String(err);
4242
- return c.json({ error: message }, 500);
4298
+ return c.json({ error: message }, workspaceErrorStatus(err));
4243
4299
  }
4244
4300
  });
4245
4301
  // POST /api/workspaces/:id/start-review — ask the agent to review committed + uncommitted changes
@@ -4291,22 +4347,23 @@ app.post('/:id/start-review', async (c) => {
4291
4347
  baseCommit,
4292
4348
  additionalInstructions,
4293
4349
  });
4294
- try {
4295
- wakeupService.cancel(workspace.id, 'user-message');
4296
- }
4297
- catch {
4298
- /* swallow */
4299
- }
4300
4350
  let messageSent = false;
4301
4351
  let emitSessionId;
4352
+ if (workspaceService.getWorkspace(workspace.id)?.status === 'compacting') {
4353
+ return c.json({
4354
+ code: 'compacting',
4355
+ error: 'Workspace is compacting its context; wait until compaction finishes before sending a message',
4356
+ }, 409);
4357
+ }
4302
4358
  if (newSession) {
4303
4359
  // Stop current agent, wait for it, then start fresh.
4304
4360
  try {
4305
- await agentManager.stopAgentAndWait(workspace.id);
4361
+ assertAgentStopped(await agentManager.stopAgentAndWait(workspace.id, undefined, 'replacement'));
4306
4362
  }
4307
4363
  catch (err) {
4308
4364
  const msg = err instanceof Error ? err.message : String(err);
4309
4365
  console.error(`[start-review] stopAgentAndWait failed (continuing): ${msg}`);
4366
+ throw err;
4310
4367
  }
4311
4368
  try {
4312
4369
  const agent = agentManager.startAgent(workspace.id, worktreePath, rendered, workspace.model, false /* resume */, workspace.agentPermissionMode, undefined, workspace.reasoningEffort);
@@ -4339,7 +4396,7 @@ app.post('/:id/start-review', async (c) => {
4339
4396
  }
4340
4397
  catch (err) {
4341
4398
  const message = err instanceof Error ? err.message : String(err);
4342
- return c.json({ error: message }, 500);
4399
+ return c.json({ error: message }, workspaceErrorStatus(err));
4343
4400
  }
4344
4401
  });
4345
4402
  // POST /api/workspaces/:id/start-ci-fix — dispatch the configured CI-fix
@@ -4384,12 +4441,6 @@ app.post('/:id/start-ci-fix', migrationGuard, async (c) => {
4384
4441
  failedChecks,
4385
4442
  ciRunUrl,
4386
4443
  });
4387
- try {
4388
- wakeupService.cancel(workspace.id, 'user-message');
4389
- }
4390
- catch {
4391
- /* swallow */
4392
- }
4393
4444
  const session = workspaceService.getActiveSession(workspace.id);
4394
4445
  let emitSessionId = session?.id;
4395
4446
  try {
@@ -4405,7 +4456,7 @@ app.post('/:id/start-ci-fix', migrationGuard, async (c) => {
4405
4456
  }
4406
4457
  catch (err) {
4407
4458
  const message = err instanceof Error ? err.message : String(err);
4408
- return c.json({ error: message }, 500);
4459
+ return c.json({ error: message }, workspaceErrorStatus(err));
4409
4460
  }
4410
4461
  });
4411
4462
  /**
@@ -4435,7 +4486,7 @@ app.post('/:id/dismiss-pr-attention', async (c) => {
4435
4486
  }
4436
4487
  catch (err) {
4437
4488
  const message = err instanceof Error ? err.message : String(err);
4438
- return c.json({ error: message }, 500);
4489
+ return c.json({ error: message }, workspaceErrorStatus(err));
4439
4490
  }
4440
4491
  });
4441
4492
  /**
@@ -4461,7 +4512,7 @@ app.post('/:id/restore-pr-attention', async (c) => {
4461
4512
  }
4462
4513
  catch (err) {
4463
4514
  const message = err instanceof Error ? err.message : String(err);
4464
- return c.json({ error: message }, 500);
4515
+ return c.json({ error: message }, workspaceErrorStatus(err));
4465
4516
  }
4466
4517
  });
4467
4518
  /** POST /api/workspaces/:id/mark-read — mark workspace as read (clear unread indicator). */
@@ -4478,7 +4529,7 @@ app.post('/:id/mark-read', (c) => {
4478
4529
  }
4479
4530
  catch (err) {
4480
4531
  const message = err instanceof Error ? err.message : String(err);
4481
- return c.json({ error: message }, 500);
4532
+ return c.json({ error: message }, workspaceErrorStatus(err));
4482
4533
  }
4483
4534
  });
4484
4535
  // POST /api/workspaces/:id/stop — stop agent
@@ -4491,11 +4542,12 @@ app.post('/:id/stop', migrationGuard, async (c) => {
4491
4542
  }
4492
4543
  try {
4493
4544
  // The response says "stopped": make that true before returning.
4494
- await agentManager.stopAgentAndWait(id);
4545
+ assertAgentStopped(await agentManager.stopAgentAndWait(id));
4495
4546
  }
4496
4547
  catch (err) {
4497
4548
  // Agent may not be tracked (e.g. server restarted) — just update status
4498
4549
  console.error(`[workspaces] stopAgentAndWait on manual stop failed for '${id}':`, err);
4550
+ throw err;
4499
4551
  }
4500
4552
  // Always transition to idle so the UI reflects the stopped state
4501
4553
  try {
@@ -4508,7 +4560,7 @@ app.post('/:id/stop', migrationGuard, async (c) => {
4508
4560
  }
4509
4561
  catch (err) {
4510
4562
  const message = err instanceof Error ? err.message : String(err);
4511
- return c.json({ error: message }, 500);
4563
+ return c.json({ error: message }, workspaceErrorStatus(err));
4512
4564
  }
4513
4565
  });
4514
4566
  // POST /api/workspaces/:id/interrupt — soft-interrupt agent (SIGINT, like Escape in Claude Code)
@@ -4555,7 +4607,7 @@ app.post('/:id/interrupt', migrationGuard, async (c) => {
4555
4607
  const status = err.code === 'interrupt_failed' ? 500 : 409;
4556
4608
  return c.json({ error: message, code: err.code }, status);
4557
4609
  }
4558
- return c.json({ error: message }, 500);
4610
+ return c.json({ error: message }, workspaceErrorStatus(err));
4559
4611
  }
4560
4612
  });
4561
4613
  export default app;