@loicngr/kobo 2.1.2 → 2.1.5

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 (200) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/CONFIGURATION.md +11 -3
  3. package/dist/server/index.js +24 -91
  4. package/dist/server/routes/git.js +6 -7
  5. package/dist/server/routes/settings.js +21 -11
  6. package/dist/server/routes/workspaces.js +82 -41
  7. package/dist/server/services/agent/engines/claude-code/engine.js +87 -20
  8. package/dist/server/services/agent/engines/claude-code/event-mapper.js +88 -22
  9. package/dist/server/services/agent/engines/claude-code/options-builder.js +1 -0
  10. package/dist/server/services/agent/engines/codex/engine.js +27 -5
  11. package/dist/server/services/agent/engines/codex/options-builder.js +1 -0
  12. package/dist/server/services/agent/integration-mcp.js +4 -3
  13. package/dist/server/services/agent/orchestrator.js +36 -1
  14. package/dist/server/services/agent/session-controller.js +12 -0
  15. package/dist/server/services/engine-handoff-service.js +120 -24
  16. package/dist/server/services/pr-checkout-service.js +90 -53
  17. package/dist/server/services/pr-watcher-service.js +13 -7
  18. package/dist/server/services/retention/worker.js +40 -0
  19. package/dist/server/services/review-service.js +8 -4
  20. package/dist/server/services/session-event-metrics.js +43 -0
  21. package/dist/server/services/session-handoff-service.js +6 -4
  22. package/dist/server/services/templates-service.js +67 -49
  23. package/dist/server/services/terminal-service.js +45 -14
  24. package/dist/server/services/terminal-websocket-service.js +143 -0
  25. package/dist/server/services/wakeup-service.js +23 -21
  26. package/dist/server/services/workspace-service.js +3 -44
  27. package/dist/server/services/workspace-template-service.js +16 -18
  28. package/dist/server/services/worktree-purge-service.js +1 -4
  29. package/dist/server/services/ws-events-retention-service.js +197 -104
  30. package/dist/server/services/ws-events-retention-worker-service.js +112 -0
  31. package/dist/server/utils/atomic-json-file.js +23 -0
  32. package/dist/server/utils/git-ops.js +262 -60
  33. package/dist/shared/codex-models.js +20 -3
  34. package/dist/shared/models.js +12 -0
  35. package/package.json +8 -8
  36. package/src/client/dist/pwa/assets/{ActivityFeed-DCmiJP-z.css → ActivityFeed-B4lTKMVE.css} +1 -1
  37. package/src/client/dist/pwa/assets/ActivityFeed-DXWXioW9.js +8 -0
  38. package/src/client/dist/pwa/assets/ChangelogPage-BL5Jh8-v.js +1 -0
  39. package/src/client/dist/pwa/assets/ChangelogPage-DVyx9ZjV.css +1 -0
  40. package/src/client/dist/pwa/assets/{ClosePopup-BikuzYle.js → ClosePopup-C1a82pL9.js} +1 -1
  41. package/src/client/dist/pwa/assets/ComparisonPanel-Bv2SZrVZ.js +2 -0
  42. package/src/client/dist/pwa/assets/ComparisonPanel-ByVbqYCs.css +1 -0
  43. package/src/client/dist/pwa/assets/CreatePage-CMyl2j8Z.css +1 -0
  44. package/src/client/dist/pwa/assets/CreatePage-d43ITOC1.js +2 -0
  45. package/src/client/dist/pwa/assets/DashboardPage-BEXXRguE.js +1 -0
  46. package/src/client/dist/pwa/assets/DashboardPage-CtaHaVdf.css +1 -0
  47. package/src/client/dist/pwa/assets/DiffViewer-B92w_jd7.js +8 -0
  48. package/src/client/dist/pwa/assets/DocumentsPanel-C9JXyqHg.js +1 -0
  49. package/src/client/dist/pwa/assets/{DrawerToggleButton-Dfvkaro0.js → DrawerToggleButton-Btx8QwHo.js} +1 -1
  50. package/src/client/dist/pwa/assets/{GitPanel-C4wOcZV2.css → GitPanel-C3blk_Pd.css} +1 -1
  51. package/src/client/dist/pwa/assets/GitPanel-Cgz9crLN.js +3 -0
  52. package/src/client/dist/pwa/assets/HealthPage-CDhgJQkL.js +1 -0
  53. package/src/client/dist/pwa/assets/HealthPage-DT8FGrKA.css +1 -0
  54. package/src/client/dist/pwa/assets/MainLayout-DTTtj9sq.js +2 -0
  55. package/src/client/dist/pwa/assets/{MainLayout-gHENJEFQ.css → MainLayout-knNNtkre.css} +1 -1
  56. package/src/client/dist/pwa/assets/QBadge-rGILsUYl.js +1 -0
  57. package/src/client/dist/pwa/assets/QBanner-7EWRdc4U.js +1 -0
  58. package/src/client/dist/pwa/assets/QBtn-b_baQtAt.js +1 -0
  59. package/src/client/dist/pwa/assets/QBtnDropdown-vjat4GZl.js +1 -0
  60. package/src/client/dist/pwa/assets/QBtnGroup-CUmAgBV8.js +1 -0
  61. package/src/client/dist/pwa/assets/QBtnToggle-COL46j6W.js +1 -0
  62. package/src/client/dist/pwa/assets/QChip-CG1kWhFm.js +1 -0
  63. package/src/client/dist/pwa/assets/QExpansionItem-BQuy89vv.js +1 -0
  64. package/src/client/dist/pwa/assets/QItemSection-BrVklYfM.js +1 -0
  65. package/src/client/dist/pwa/assets/QList-BVX-b8nc.js +1 -0
  66. package/src/client/dist/pwa/assets/QMarkupTable-B8_FKLvo.js +1 -0
  67. package/src/client/dist/pwa/assets/QPage-BCDXjfVj.js +1 -0
  68. package/src/client/dist/pwa/assets/QSelect-DBW_TZri.js +1 -0
  69. package/src/client/dist/pwa/assets/QSpinnerDots-BsKmccpX.js +1 -0
  70. package/src/client/dist/pwa/assets/QTooltip-DzYbguTR.js +1 -0
  71. package/src/client/dist/pwa/assets/SchedulePanel-BX6iKh6S.js +1 -0
  72. package/src/client/dist/pwa/assets/SchedulePanel-CBM_GpS3.css +1 -0
  73. package/src/client/dist/pwa/assets/SearchPage-_In2gH2P.js +1 -0
  74. package/src/client/dist/pwa/assets/SettingsPage-BqkNYzC5.js +9 -0
  75. package/src/client/dist/pwa/assets/{SettingsPage-BYdLXJEJ.css → SettingsPage-CpykRKq2.css} +1 -1
  76. package/src/client/dist/pwa/assets/SplitWorkspacePage-DRoXbSRP.js +1 -0
  77. package/src/client/dist/pwa/assets/{TerminalPanel-B9LDe7uG.js → TerminalPanel-CfqiuSuk.js} +2 -2
  78. package/src/client/dist/pwa/assets/{TerminalPanel-DbVZk0vD.css → TerminalPanel-Dz9EE-Ph.css} +1 -1
  79. package/src/client/dist/pwa/assets/TouchPan-DcOCRf7x.js +1 -0
  80. package/src/client/dist/pwa/assets/{TourReplayButton-Dtv_UDaY.js → TourReplayButton-gp7m6tSV.js} +1 -1
  81. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DSw8Upcp.css +1 -0
  82. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-IcoMGkJh.js +1 -0
  83. package/src/client/dist/pwa/assets/{WorkspacePage--5HsnMQ1.css → WorkspacePage-BnW-gp3u.css} +1 -1
  84. package/src/client/dist/pwa/assets/WorkspacePage-CR2xAzW_.js +4 -0
  85. package/src/client/dist/pwa/assets/{browser-B15GGwtW.js → browser-BveHkbe-.js} +1 -1
  86. package/src/client/dist/pwa/assets/build-path-tree-Dtp7rlxS.js +1 -0
  87. package/src/client/dist/pwa/assets/custom-sounds-CBoWzJ85.js +1 -0
  88. package/src/client/dist/pwa/assets/de-BJsWkZro.js +17 -0
  89. package/src/client/dist/pwa/assets/defineProperty-BbfpZ9Tg.js +1 -0
  90. package/src/client/dist/pwa/assets/documents-sb2sQ4JC.js +1 -0
  91. package/src/client/dist/pwa/assets/{editor.api-DhaLhNl-.js → editor.api-CbkPxoJf.js} +1 -1
  92. package/src/client/dist/pwa/assets/efforts-BCli1Unj.js +9 -0
  93. package/src/client/dist/pwa/assets/es-tNYw7itB.js +17 -0
  94. package/src/client/dist/pwa/assets/expand-template-CfaEhb2f.js +1 -0
  95. package/src/client/dist/pwa/assets/{format-2JisC3s1.js → format-BRl2DUlm.js} +7 -7
  96. package/src/client/dist/pwa/assets/formatters-C5TjPSR_.js +1 -0
  97. package/src/client/dist/pwa/assets/fr-B5sFski4.js +17 -0
  98. package/src/client/dist/pwa/assets/{freemarker2-PYrDqU04.js → freemarker2-CDmvOQyn.js} +1 -1
  99. package/src/client/dist/pwa/assets/{handlebars-BuiY0fFf.js → handlebars-Bx_w8mx1.js} +1 -1
  100. package/src/client/dist/pwa/assets/{html-Du3cjdug.js → html-DVRfiRkS.js} +1 -1
  101. package/src/client/dist/pwa/assets/i18n-Bm-L4nW3.js +1 -0
  102. package/src/client/dist/pwa/assets/i18n-D21JKsVt.js +19 -0
  103. package/src/client/dist/pwa/assets/index-C7vFf6p2.js +2 -0
  104. package/src/client/dist/pwa/assets/index-C8zAZ-k-.css +1 -0
  105. package/src/client/dist/pwa/assets/it-Bc2FncM9.js +17 -0
  106. package/src/client/dist/pwa/assets/{javascript-Bb48hPli.js → javascript-CPvttU5J.js} +1 -1
  107. package/src/client/dist/pwa/assets/latest-request-CN4-B22z.js +1 -0
  108. package/src/client/dist/pwa/assets/{liquid-Be77iQe3.js → liquid-CI0gQddb.js} +1 -1
  109. package/src/client/dist/pwa/assets/{mdx-CfohNJja.js → mdx-BveBwYJH.js} +1 -1
  110. package/src/client/dist/pwa/assets/{monaco-contributions-CyM4wsIg.js → monaco-contributions-CHXFh4P2.js} +1 -1
  111. package/src/client/dist/pwa/assets/monaco.contribution-DOK8t_9B.js +2 -0
  112. package/src/client/dist/pwa/assets/network-auth-CItnNZDh.js +1 -0
  113. package/src/client/dist/pwa/assets/{notify-theme-DvvaSyg1.js → notify-theme-BO6r1Rd7.js} +1 -1
  114. package/src/client/dist/pwa/assets/pending-diff-open-DpS4iyDM.js +1 -0
  115. package/src/client/dist/pwa/assets/{permissionModes-B-ciuUbz.js → permissionModes-Cc5VaOdC.js} +1 -1
  116. package/src/client/dist/pwa/assets/preload-helper-BZ1Pz5am.js +1 -0
  117. package/src/client/dist/pwa/assets/{private.use-form-Bk8ti264.js → private.use-form-vxvbh_VN.js} +1 -1
  118. package/src/client/dist/pwa/assets/{project-color-Be0t12-n.js → project-color-Bnv02P9D.js} +1 -1
  119. package/src/client/dist/pwa/assets/{python-BNemmtx8.js → python-BesLCxpi.js} +1 -1
  120. package/src/client/dist/pwa/assets/{razor-3NM8iFVf.js → razor-Dv1PMxuY.js} +1 -1
  121. package/src/client/dist/pwa/assets/{reactivity.esm-bundler-Bo9anJjg.js → reactivity.esm-bundler-tuEvQ47f.js} +1 -1
  122. package/src/client/dist/pwa/assets/{render-chat-markdown-BWJNIDv3.js → render-chat-markdown-CafzCcmu.js} +1 -1
  123. package/src/client/dist/pwa/assets/runtime-core.esm-bundler-DOiZcasU.js +1 -0
  124. package/src/client/dist/pwa/assets/{typescript-DwyZ4CF7.js → typescript-26L2Xsu9.js} +1 -1
  125. package/src/client/dist/pwa/assets/use-checkbox-BLeif5-t.js +1 -0
  126. package/src/client/dist/pwa/assets/use-id-b4A2I8LB.js +1 -0
  127. package/src/client/dist/pwa/assets/use-is-mobile-DLIZxEo9.js +1 -0
  128. package/src/client/dist/pwa/assets/use-search-index-status-Mv3pIO6p.js +1 -0
  129. package/src/client/dist/pwa/assets/{use-slide-transition-CiaDm-tR.js → use-slide-transition-C5eDGOmu.js} +1 -1
  130. package/src/client/dist/pwa/assets/{use-tours-CjASQcG5.js → use-tours-B5uIqxRS.js} +2 -2
  131. package/src/client/dist/pwa/assets/use-worktree-restore-BBChALUM.js +1 -0
  132. package/src/client/dist/pwa/assets/workspace-templates-CDY-MiH2.js +1 -0
  133. package/src/client/dist/pwa/assets/{xml-B1UujXdS.js → xml-CkRojTcc.js} +1 -1
  134. package/src/client/dist/pwa/assets/{yaml-mHN8kNgU.js → yaml-DKkQHwew.js} +1 -1
  135. package/src/client/dist/pwa/index.html +12 -9
  136. package/src/client/dist/pwa/sw.js +1 -1
  137. package/src/client/dist/pwa/assets/ActivityFeed-CO5rOaa5.js +0 -8
  138. package/src/client/dist/pwa/assets/ChangelogPage-DKJkFiU_.css +0 -1
  139. package/src/client/dist/pwa/assets/ChangelogPage-DNRcPRtW.js +0 -1
  140. package/src/client/dist/pwa/assets/ComparisonPanel-CRRaDs2D.css +0 -1
  141. package/src/client/dist/pwa/assets/ComparisonPanel-DZoDqgJI.js +0 -2
  142. package/src/client/dist/pwa/assets/CreatePage-B9C9J9Pw.css +0 -1
  143. package/src/client/dist/pwa/assets/CreatePage-wNH35uTG.js +0 -2
  144. package/src/client/dist/pwa/assets/DashboardPage-DUyy4SzS.js +0 -1
  145. package/src/client/dist/pwa/assets/DashboardPage-XiCj24i2.css +0 -1
  146. package/src/client/dist/pwa/assets/DiffViewer-CZ5QwXqi.js +0 -8
  147. package/src/client/dist/pwa/assets/DocumentsPanel-sBatgKA-.js +0 -1
  148. package/src/client/dist/pwa/assets/GitPanel-B8e1olfm.js +0 -3
  149. package/src/client/dist/pwa/assets/HealthPage-BwXYpczh.js +0 -1
  150. package/src/client/dist/pwa/assets/HealthPage-CFZ-tdKK.css +0 -1
  151. package/src/client/dist/pwa/assets/MainLayout-DFXIS_wv.js +0 -2
  152. package/src/client/dist/pwa/assets/QBadge-CQF1smL-.js +0 -1
  153. package/src/client/dist/pwa/assets/QBanner-bdDA-A5g.js +0 -1
  154. package/src/client/dist/pwa/assets/QBtn-CcQQrYA_.js +0 -1
  155. package/src/client/dist/pwa/assets/QBtnDropdown-C-1EhO9J.js +0 -1
  156. package/src/client/dist/pwa/assets/QBtnGroup-wd5lKRXu.js +0 -1
  157. package/src/client/dist/pwa/assets/QBtnToggle-BnC8rTql.js +0 -1
  158. package/src/client/dist/pwa/assets/QChip-D75nKi-L.js +0 -1
  159. package/src/client/dist/pwa/assets/QExpansionItem-RpMPvfxX.js +0 -1
  160. package/src/client/dist/pwa/assets/QItemSection-Bt9H35JJ.js +0 -1
  161. package/src/client/dist/pwa/assets/QList-D5msNbSZ.js +0 -1
  162. package/src/client/dist/pwa/assets/QMarkupTable-D3REW6RC.js +0 -1
  163. package/src/client/dist/pwa/assets/QPage-HAaeQ-cv.js +0 -1
  164. package/src/client/dist/pwa/assets/QSelect-Dpru_T81.js +0 -1
  165. package/src/client/dist/pwa/assets/QSpinnerDots-DDsPDUH9.js +0 -1
  166. package/src/client/dist/pwa/assets/QTooltip-C31vXtse.js +0 -1
  167. package/src/client/dist/pwa/assets/SchedulePanel-BYKSFoFj.css +0 -1
  168. package/src/client/dist/pwa/assets/SchedulePanel-D6LTiRFg.js +0 -1
  169. package/src/client/dist/pwa/assets/SearchPage-Dj487BdP.js +0 -1
  170. package/src/client/dist/pwa/assets/SettingsPage-CJmgFiAa.js +0 -9
  171. package/src/client/dist/pwa/assets/SplitWorkspacePage-BGSDgGIO.js +0 -1
  172. package/src/client/dist/pwa/assets/TouchPan-DJqqxDBF.js +0 -1
  173. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +0 -1
  174. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-VlJCIkwU.js +0 -1
  175. package/src/client/dist/pwa/assets/WorkspacePage-B_SlX8YB.js +0 -4
  176. package/src/client/dist/pwa/assets/build-path-tree-CU6uXunR.js +0 -1
  177. package/src/client/dist/pwa/assets/custom-sounds-CDJsgChY.js +0 -1
  178. package/src/client/dist/pwa/assets/de-B_Y4EArT.js +0 -17
  179. package/src/client/dist/pwa/assets/documents-Cf6nmu8Z.js +0 -1
  180. package/src/client/dist/pwa/assets/efforts-DoYZi0AZ.js +0 -9
  181. package/src/client/dist/pwa/assets/engineFeatures-C9XiCfvv.js +0 -1
  182. package/src/client/dist/pwa/assets/es-DtsTTShP.js +0 -17
  183. package/src/client/dist/pwa/assets/expand-template-LDLM-6iN.js +0 -1
  184. package/src/client/dist/pwa/assets/formatters-ZVNs2VOl.js +0 -1
  185. package/src/client/dist/pwa/assets/fr-C82MIVrA.js +0 -17
  186. package/src/client/dist/pwa/assets/i18n-BumeGGx_.js +0 -1
  187. package/src/client/dist/pwa/assets/index-DolbyIM4.js +0 -20
  188. package/src/client/dist/pwa/assets/index-VMFLfvp2.css +0 -1
  189. package/src/client/dist/pwa/assets/it-BJNuTcj2.js +0 -17
  190. package/src/client/dist/pwa/assets/monaco.contribution-BcIfN6Mi.js +0 -2
  191. package/src/client/dist/pwa/assets/network-auth-B1xAaHmx.js +0 -1
  192. package/src/client/dist/pwa/assets/pending-diff-open-B6N0vI5n.js +0 -1
  193. package/src/client/dist/pwa/assets/pinia-CTJ3OfP3.js +0 -1
  194. package/src/client/dist/pwa/assets/runtime-core.esm-bundler-CNFKm4ij.js +0 -1
  195. package/src/client/dist/pwa/assets/use-checkbox-2xzX4yYj.js +0 -1
  196. package/src/client/dist/pwa/assets/use-id-vOr8PRTG.js +0 -1
  197. package/src/client/dist/pwa/assets/use-is-mobile-CJfSpTHx.js +0 -1
  198. package/src/client/dist/pwa/assets/use-search-index-status-Bluf9L99.js +0 -1
  199. package/src/client/dist/pwa/assets/workspace-templates-BxYxJt3S.js +0 -1
  200. /package/src/client/dist/pwa/assets/{engineFeatures-B-9jHocY.css → use-worktree-restore-B-9jHocY.css} +0 -0
@@ -1,6 +1,6 @@
1
- import { createHash } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
- import path from 'node:path';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants, copyFileSync, existsSync, readFileSync } from 'node:fs';
3
+ import { writeJsonFileAtomically } from '../utils/atomic-json-file.js';
4
4
  import { getTemplatesPath } from '../utils/paths.js';
5
5
  const LEGACY_DEFAULT_CONTENT_HASHES = {
6
6
  'kobo-context': 'ee29ebb56186ce9537e682c25d9d7737ff8d824ab6f29c095c925b27eed70552',
@@ -13,42 +13,35 @@ const MAX_CONTENT_LENGTH = 4096;
13
13
  const MAX_DESCRIPTION_LENGTH = 120;
14
14
  /**
15
15
  * Read the templates list from disk. Seeds with defaults if the file does
16
- * not exist yet. Returns an empty array (with a logged error) on corruption.
16
+ * not exist yet. Refuses damaged data so ordinary mutations cannot erase it.
17
17
  */
18
18
  export function listTemplates() {
19
19
  const filePath = getTemplatesPath();
20
20
  if (!existsSync(filePath)) {
21
21
  seedTemplates();
22
22
  }
23
- try {
24
- const raw = readFileSync(filePath, 'utf-8');
25
- const parsed = JSON.parse(raw);
26
- if (parsed.version !== CURRENT_FILE_VERSION) {
27
- console.warn(`[templates-service] templates.json has version ${parsed.version}, expected ${CURRENT_FILE_VERSION}. Reading best-effort.`);
28
- }
29
- const templates = Array.isArray(parsed.templates) ? parsed.templates : [];
30
- let changed = false;
31
- for (const template of templates) {
32
- const previousHash = LEGACY_DEFAULT_CONTENT_HASHES[template.slug];
33
- if (!previousHash || typeof template.content !== 'string')
34
- continue;
35
- if (createHash('sha256').update(template.content).digest('hex') !== previousHash)
36
- continue;
37
- const next = DEFAULT_TEMPLATES.find((value) => value.slug === template.slug);
38
- if (!next || next.content === template.content)
39
- continue;
40
- template.content = next.content;
41
- template.updatedAt = new Date().toISOString();
42
- changed = true;
43
- }
44
- if (changed)
45
- writeTemplates(templates, parsed.seededDefaultSlugs);
46
- return templates;
23
+ const parsed = readTemplatesFile();
24
+ if (parsed.version !== CURRENT_FILE_VERSION) {
25
+ console.warn(`[templates-service] templates.json has version ${parsed.version}, expected ${CURRENT_FILE_VERSION}. Reading best-effort.`);
47
26
  }
48
- catch (err) {
49
- console.error('[templates-service] Failed to read templates.json:', err);
50
- return [];
27
+ const templates = parsed.templates;
28
+ let changed = false;
29
+ for (const template of templates) {
30
+ const previousHash = LEGACY_DEFAULT_CONTENT_HASHES[template.slug];
31
+ if (!previousHash)
32
+ continue;
33
+ if (createHash('sha256').update(template.content).digest('hex') !== previousHash)
34
+ continue;
35
+ const next = DEFAULT_TEMPLATES.find((value) => value.slug === template.slug);
36
+ if (!next || next.content === template.content)
37
+ continue;
38
+ template.content = next.content;
39
+ template.updatedAt = new Date().toISOString();
40
+ changed = true;
51
41
  }
42
+ if (changed)
43
+ writeTemplates(templates, parsed.seededDefaultSlugs);
44
+ return templates;
52
45
  }
53
46
  /** Create a new template. Throws on invalid input or duplicate slug. */
54
47
  export function createTemplate(input) {
@@ -140,31 +133,41 @@ function validateTemplateInput(input) {
140
133
  throw new Error(`Invalid content: must be 1..${MAX_CONTENT_LENGTH} chars`);
141
134
  }
142
135
  }
143
- function readSeededSlugs() {
144
- const filePath = getTemplatesPath();
145
- if (!existsSync(filePath))
146
- return undefined;
136
+ function readTemplatesFile() {
147
137
  try {
148
- const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
149
- return Array.isArray(parsed.seededDefaultSlugs) ? parsed.seededDefaultSlugs : undefined;
138
+ const parsed = JSON.parse(readFileSync(getTemplatesPath(), 'utf-8'));
139
+ if (!parsed || !Array.isArray(parsed.templates))
140
+ throw new Error('Expected a templates array');
141
+ if (parsed.templates.some((template) => !template ||
142
+ typeof template.slug !== 'string' ||
143
+ typeof template.description !== 'string' ||
144
+ typeof template.content !== 'string' ||
145
+ typeof template.createdAt !== 'string' ||
146
+ typeof template.updatedAt !== 'string')) {
147
+ throw new Error('A template has missing or malformed fields');
148
+ }
149
+ if (parsed.seededDefaultSlugs !== undefined &&
150
+ (!Array.isArray(parsed.seededDefaultSlugs) || parsed.seededDefaultSlugs.some((slug) => typeof slug !== 'string'))) {
151
+ throw new Error('Expected seededDefaultSlugs to be an array of strings');
152
+ }
153
+ return parsed;
150
154
  }
151
- catch {
152
- return undefined;
155
+ catch (error) {
156
+ throw new Error('Failed to read templates.json. Existing data was preserved; restore a valid file or import templates.', {
157
+ cause: error,
158
+ });
153
159
  }
154
160
  }
161
+ function readSeededSlugs() {
162
+ return existsSync(getTemplatesPath()) ? readTemplatesFile().seededDefaultSlugs : undefined;
163
+ }
155
164
  function writeTemplates(templates, seededDefaultSlugs) {
156
- const filePath = getTemplatesPath();
157
- mkdirSync(path.dirname(filePath), { recursive: true });
158
165
  const seeded = seededDefaultSlugs ?? readSeededSlugs() ?? [...LEGACY_DEFAULT_SLUGS];
159
166
  const file = { version: CURRENT_FILE_VERSION, templates, seededDefaultSlugs: seeded };
160
- writeFileSync(filePath, JSON.stringify(file, null, 2), 'utf-8');
167
+ writeJsonFileAtomically(getTemplatesPath(), file);
161
168
  }
162
- /**
163
- * Replace the entire templates list atomically. Validates each entry and
164
- * rejects the whole write on any invalid row — do not partially accept.
165
- * Used by config import.
166
- */
167
- export function replaceAllTemplates(templates) {
169
+ /** Validate a whole import before any file in the configuration bundle changes. */
170
+ export function validateTemplatesForReplacement(templates) {
168
171
  if (!Array.isArray(templates)) {
169
172
  throw new Error('Invalid templates payload: expected an array');
170
173
  }
@@ -188,7 +191,22 @@ export function replaceAllTemplates(templates) {
188
191
  const updatedAt = typeof t.updatedAt === 'string' ? t.updatedAt : now;
189
192
  validated.push({ slug, description, content, createdAt, updatedAt });
190
193
  }
191
- writeTemplates(validated);
194
+ return validated;
195
+ }
196
+ /** Replace the list atomically, preserving damaged existing data before recovery. */
197
+ export function replaceAllTemplates(templates) {
198
+ const validated = validateTemplatesForReplacement(templates);
199
+ let seededDefaultSlugs;
200
+ try {
201
+ seededDefaultSlugs = readSeededSlugs();
202
+ }
203
+ catch {
204
+ // Import is an explicit replacement. Retain damaged bytes for recovery,
205
+ // and abort the replacement if the backup cannot be written.
206
+ const filePath = getTemplatesPath();
207
+ copyFileSync(filePath, `${filePath}.recovery-${randomUUID()}.bak`, constants.COPYFILE_EXCL);
208
+ }
209
+ writeTemplates(validated, seededDefaultSlugs ?? [...LEGACY_DEFAULT_SLUGS]);
192
210
  }
193
211
  /**
194
212
  * The default slugs that existed BEFORE the seededDefaultSlugs watermark shipped.
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import * as pty from 'node-pty';
3
3
  import { ensureSpawnHelperExecutable } from '../utils/node-pty-spawn-helper.js';
4
+ export class TerminalStopError extends Error {
5
+ }
4
6
  const terminals = new Map();
5
7
  let spawnHelperChecked = false;
6
8
  function prepareSpawnHelper() {
@@ -17,8 +19,11 @@ function prepareSpawnHelper() {
17
19
  }
18
20
  }
19
21
  export function createTerminal(workspaceId, cwd) {
20
- if (terminals.has(workspaceId)) {
21
- return terminals.get(workspaceId).pty;
22
+ const existing = terminals.get(workspaceId);
23
+ if (existing) {
24
+ if (existing.closing)
25
+ throw new TerminalStopError('Terminal is closing; wait for confirmed exit before reopening it');
26
+ return existing.pty;
22
27
  }
23
28
  if (!fs.existsSync(cwd)) {
24
29
  throw new Error(`Worktree directory does not exist: ${cwd}`);
@@ -32,29 +37,55 @@ export function createTerminal(workspaceId, cwd) {
32
37
  cwd,
33
38
  env: process.env,
34
39
  });
35
- terminals.set(workspaceId, { pty: term });
40
+ let resolveClosed;
41
+ const closed = new Promise((resolve) => {
42
+ resolveClosed = resolve;
43
+ });
44
+ const instance = { pty: term, closing: false, closed, resolveClosed };
45
+ terminals.set(workspaceId, instance);
36
46
  term.onExit(() => {
37
- terminals.delete(workspaceId);
47
+ if (terminals.get(workspaceId) === instance)
48
+ terminals.delete(workspaceId);
49
+ instance.resolveClosed();
38
50
  });
39
51
  return term;
40
52
  }
41
53
  export function getTerminal(workspaceId) {
42
- return terminals.get(workspaceId)?.pty ?? null;
54
+ const instance = terminals.get(workspaceId);
55
+ return instance && !instance.closing ? instance.pty : null;
43
56
  }
44
- export function destroyTerminal(workspaceId) {
57
+ export function destroyTerminal(workspaceId, timeoutMs = 2000) {
45
58
  const instance = terminals.get(workspaceId);
46
- if (instance) {
59
+ if (!instance)
60
+ return Promise.resolve();
61
+ if (instance.stop)
62
+ return instance.stop;
63
+ instance.closing = true;
64
+ const stopped = new Promise((resolve, reject) => {
65
+ const timer = setTimeout(() => {
66
+ reject(new TerminalStopError(`Terminal exit could not be confirmed for workspace ${workspaceId}`));
67
+ }, timeoutMs);
68
+ instance.closed.then(() => {
69
+ clearTimeout(timer);
70
+ resolve();
71
+ });
47
72
  try {
48
73
  instance.pty.kill();
49
74
  }
50
75
  catch (err) {
51
- console.error(`[terminal] Failed to kill PTY for workspace ${workspaceId}:`, err);
76
+ clearTimeout(timer);
77
+ reject(new TerminalStopError(`Failed to stop terminal: ${err instanceof Error ? err.message : String(err)}`));
52
78
  }
53
- terminals.delete(workspaceId);
54
- }
79
+ });
80
+ instance.stop = stopped.catch((error) => {
81
+ instance.stop = undefined;
82
+ throw error;
83
+ });
84
+ return instance.stop;
55
85
  }
56
- export function destroyAllTerminals() {
57
- for (const [id] of terminals) {
58
- destroyTerminal(id);
59
- }
86
+ export async function destroyAllTerminals() {
87
+ const results = await Promise.allSettled([...terminals.keys()].map((id) => destroyTerminal(id)));
88
+ const failures = results.filter((result) => result.status === 'rejected');
89
+ if (failures.length)
90
+ throw new AggregateError(failures.map((result) => result.reason), 'Some terminals did not stop');
60
91
  }
@@ -0,0 +1,143 @@
1
+ import { assertWorkspaceLifecycleAvailable, isWorkspaceLifecycleBusy } from '../utils/workspace-lifecycle-guard.js';
2
+ import { createTerminal, getTerminal } from './terminal-service.js';
3
+ import { getWorkspace } from './workspace-service.js';
4
+ const MAX_BUFFERED_BYTES = 1024 * 1024;
5
+ const HEARTBEAT_INTERVAL_MS = 30_000;
6
+ /** Each client owns its subscriptions and output budget, never the shared PTY. */
7
+ export function handleTerminalConnection(ws, workspaceId, isShuttingDown = () => false) {
8
+ let currentPty = null;
9
+ let dataDisposable = null;
10
+ let exitDisposable = null;
11
+ let disposed = false;
12
+ let alive = true;
13
+ function detachTerminal() {
14
+ dataDisposable?.dispose();
15
+ exitDisposable?.dispose();
16
+ dataDisposable = null;
17
+ exitDisposable = null;
18
+ currentPty = null;
19
+ }
20
+ function dispose() {
21
+ if (disposed)
22
+ return;
23
+ disposed = true;
24
+ clearInterval(heartbeat);
25
+ detachTerminal();
26
+ }
27
+ function disconnect() {
28
+ if (disposed)
29
+ return;
30
+ dispose();
31
+ ws.terminate();
32
+ }
33
+ function send(data) {
34
+ if (disposed || ws.readyState !== 1)
35
+ return false;
36
+ if (ws.bufferedAmount + Buffer.byteLength(data) > MAX_BUFFERED_BYTES) {
37
+ disconnect();
38
+ return false;
39
+ }
40
+ try {
41
+ ws.send(data, { binary: Buffer.isBuffer(data) }, (error) => {
42
+ if (error)
43
+ disconnect();
44
+ });
45
+ return true;
46
+ }
47
+ catch {
48
+ disconnect();
49
+ return false;
50
+ }
51
+ }
52
+ function sendError(error) {
53
+ send(JSON.stringify({ type: 'error', message: error instanceof Error ? error.message : String(error) }));
54
+ }
55
+ const heartbeat = setInterval(() => {
56
+ if (ws.readyState !== 1 || !alive) {
57
+ disconnect();
58
+ return;
59
+ }
60
+ alive = false;
61
+ try {
62
+ ws.ping();
63
+ }
64
+ catch {
65
+ disconnect();
66
+ }
67
+ }, HEARTBEAT_INTERVAL_MS);
68
+ heartbeat.unref?.();
69
+ ws.on('pong', () => {
70
+ alive = true;
71
+ });
72
+ ws.on('close', dispose);
73
+ ws.on('error', disconnect);
74
+ ws.on('message', (data, isBinary) => {
75
+ if (disposed || ws.readyState !== 1 || isShuttingDown())
76
+ return;
77
+ if (isBinary) {
78
+ if (currentPty && getTerminal(workspaceId) === currentPty && !isWorkspaceLifecycleBusy(workspaceId)) {
79
+ try {
80
+ currentPty.write(data.toString());
81
+ }
82
+ catch (error) {
83
+ sendError(error);
84
+ }
85
+ }
86
+ return;
87
+ }
88
+ let message;
89
+ try {
90
+ message = JSON.parse(data.toString());
91
+ }
92
+ catch {
93
+ return;
94
+ }
95
+ if (!message || typeof message !== 'object' || !('type' in message))
96
+ return;
97
+ if (message.type === 'create') {
98
+ try {
99
+ assertWorkspaceLifecycleAvailable(workspaceId);
100
+ const workspace = getWorkspace(workspaceId);
101
+ if (!workspace)
102
+ throw new Error('Workspace not found');
103
+ if (workspace.archivedAt)
104
+ throw new Error('Workspace is archived');
105
+ if (workspace.worktreePurgedAt)
106
+ throw new Error('Workspace worktree is purged');
107
+ detachTerminal();
108
+ const terminal = createTerminal(workspaceId, workspace.worktreePath);
109
+ currentPty = terminal;
110
+ dataDisposable = terminal.onData((output) => {
111
+ send(Buffer.from(output));
112
+ });
113
+ exitDisposable = terminal.onExit(({ exitCode }) => {
114
+ if (send(JSON.stringify({ type: 'exited', code: exitCode })))
115
+ ws.close();
116
+ dispose();
117
+ });
118
+ send(JSON.stringify({ type: 'ready' }));
119
+ }
120
+ catch (error) {
121
+ sendError(error);
122
+ }
123
+ return;
124
+ }
125
+ if (message.type === 'resize' &&
126
+ 'cols' in message &&
127
+ 'rows' in message &&
128
+ typeof message.cols === 'number' &&
129
+ typeof message.rows === 'number' &&
130
+ Number.isFinite(message.cols) &&
131
+ Number.isFinite(message.rows) &&
132
+ currentPty &&
133
+ getTerminal(workspaceId) === currentPty &&
134
+ !isWorkspaceLifecycleBusy(workspaceId)) {
135
+ try {
136
+ currentPty.resize(Math.max(1, Math.min(1000, Math.floor(message.cols))), Math.max(1, Math.min(1000, Math.floor(message.rows))));
137
+ }
138
+ catch (error) {
139
+ sendError(error);
140
+ }
141
+ }
142
+ });
143
+ }
@@ -53,28 +53,30 @@ function defer(workspaceId, row) {
53
53
  }
54
54
  /** Schedule a wakeup for the given workspace. Replaces any existing pending wakeup. */
55
55
  export function schedule(workspaceId, delaySeconds, prompt, reason, agentSessionId) {
56
- try {
57
- const clampedSeconds = clamp(Math.floor(delaySeconds), MIN_DELAY_SECONDS, MAX_DELAY_SECONDS);
58
- const effectivePrompt = prompt === AUTONOMOUS_LOOP_SENTINEL ? AUTONOMOUS_LOOP_FALLBACK_PROMPT : prompt;
59
- const targetAtIso = new Date(Date.now() + clampedSeconds * 1000).toISOString();
60
- const existing = timers.get(workspaceId);
61
- if (existing)
62
- clearTimeout(existing);
63
- const db = getDb();
64
- db.prepare(`INSERT OR REPLACE INTO pending_wakeups
65
- (workspace_id, target_at, prompt, reason, created_at, agent_session_id)
66
- VALUES (?, ?, ?, ?, ?, ?)`).run(workspaceId, targetAtIso, effectivePrompt, reason ?? null, new Date().toISOString(), agentSessionId ?? null);
67
- timers.delete(workspaceId);
68
- if (!suspended) {
69
- const timeout = setTimeout(() => fire(workspaceId), clampedSeconds * 1000);
70
- timeout.unref?.();
71
- timers.set(workspaceId, timeout);
72
- }
73
- emitEphemeral(workspaceId, 'wakeup:scheduled', { targetAt: targetAtIso, reason });
74
- }
75
- catch (err) {
76
- console.error('[wakeup-service] schedule failed:', err);
56
+ const clampedSeconds = clamp(Math.floor(delaySeconds), MIN_DELAY_SECONDS, MAX_DELAY_SECONDS);
57
+ const effectivePrompt = prompt === AUTONOMOUS_LOOP_SENTINEL ? AUTONOMOUS_LOOP_FALLBACK_PROMPT : prompt;
58
+ const targetAtIso = new Date(Date.now() + clampedSeconds * 1000).toISOString();
59
+ // Commit before changing the live timer. A failed replacement must leave
60
+ // the original durable wakeup and its timer intact, and fail the MCP receipt.
61
+ const row = getDb()
62
+ .prepare(`INSERT OR REPLACE INTO pending_wakeups
63
+ (workspace_id, target_at, prompt, reason, created_at, agent_session_id)
64
+ VALUES (?, ?, ?, ?, ?, ?)
65
+ RETURNING target_at, reason`)
66
+ .get(workspaceId, targetAtIso, effectivePrompt, reason ?? null, new Date().toISOString(), agentSessionId ?? null);
67
+ const pending = { targetAt: row.target_at, reason: row.reason ?? undefined };
68
+ const existing = timers.get(workspaceId);
69
+ if (existing)
70
+ clearTimeout(existing);
71
+ timers.delete(workspaceId);
72
+ failedRetries.delete(workspaceId);
73
+ if (!suspended) {
74
+ const timeout = setTimeout(() => fire(workspaceId), clampedSeconds * 1000);
75
+ timeout.unref?.();
76
+ timers.set(workspaceId, timeout);
77
77
  }
78
+ emitEphemeral(workspaceId, 'wakeup:scheduled', pending);
79
+ return pending;
78
80
  }
79
81
  /** Move a source conversation's pending check only once its handoff succeeds. */
80
82
  export function transferSession(workspaceId, sourceSessionId, targetSessionId) {
@@ -9,6 +9,7 @@ import * as autoLoopService from './auto-loop-service.js';
9
9
  import * as cronService from './cron-service.js';
10
10
  import * as quotaBackoffService from './quota-backoff-service.js';
11
11
  import { SESSION_RECENCY_ORDER } from './session-activity-service.js';
12
+ import { recomputeSessionMetricsOn } from './session-event-metrics.js';
12
13
  import { createTaskRecord, deleteTaskRecord, updateTaskRecord } from './task-mutations.js';
13
14
  import * as wakeupService from './wakeup-service.js';
14
15
  import { emitEphemeral } from './websocket-service.js';
@@ -153,7 +154,7 @@ export function createWorkspace(data) {
153
154
  notion_url, notion_page_id, sentry_url, pr_url, worktree_path, worktree_owned,
154
155
  model, brainstorm_model, reasoning_effort, permission_mode, permission_profile, agent_permission_mode, engine, comparison_id, created_at, updated_at, workflow_policy
155
156
  ) VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
156
- `).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.sentryUrl ?? null, data.prUrl ?? null, computedWorktreePath, owned ? 1 : 0, data.model ?? 'claude-opus-4-8', data.brainstormModel ?? null, data.reasoningEffort ?? 'auto', legacyMode, legacyProfile, unifiedMode, data.engine ?? 'claude-code',
157
+ `).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.sentryUrl ?? null, data.prUrl ?? null, computedWorktreePath, owned ? 1 : 0, data.model ?? 'auto', data.brainstormModel ?? null, data.reasoningEffort ?? 'auto', legacyMode, legacyProfile, unifiedMode, data.engine ?? 'claude-code',
157
158
  // Empty string is not a group: it would silently gather every workspace
158
159
  // created with a blank id into one comparison.
159
160
  data.comparisonId?.trim() ? data.comparisonId : null, now, now, JSON.stringify(workflowPolicy));
@@ -899,49 +900,7 @@ export function deleteSession(sessionId, workspaceId) {
899
900
  transaction();
900
901
  return true;
901
902
  }
902
- /**
903
- * Recompute one session's persisted metrics from the events that remain, on an
904
- * explicit database handle.
905
- *
906
- * Until v36 an `AFTER DELETE ... FOR EACH ROW` trigger did this per deleted
907
- * row, re-aggregating the whole session every time — 79.7 s for 20 000 events,
908
- * with the SQLite write lock held throughout. The trigger is gone; every code
909
- * path that deletes `agent:event` rows while KEEPING the session calls this
910
- * once, at the end of its transaction.
911
- */
912
- export function recomputeSessionMetricsOn(db, workspaceId, sessionId) {
913
- db.prepare('DELETE FROM session_event_metrics WHERE workspace_id = ? AND session_id = ?').run(workspaceId, sessionId);
914
- db.prepare(`INSERT INTO session_event_metrics (
915
- workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
916
- )
917
- SELECT
918
- e.workspace_id,
919
- e.session_id,
920
- SUM(CASE WHEN json_extract(e.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END),
921
- SUM(CASE
922
- WHEN json_extract(e.payload, '$.kind') = 'error'
923
- OR (json_extract(e.payload, '$.kind') = 'tool:result'
924
- AND json_extract(e.payload, '$.isError') = 1)
925
- THEN 1 ELSE 0
926
- END),
927
- MAX(CASE
928
- WHEN json_extract(e.payload, '$.kind') = 'usage'
929
- AND json_type(e.payload, '$.inputTokens') IN ('integer', 'real')
930
- THEN CAST(json_extract(e.payload, '$.inputTokens') AS INTEGER) ELSE 0
931
- END),
932
- MAX(CASE
933
- WHEN json_extract(e.payload, '$.kind') = 'usage'
934
- AND json_type(e.payload, '$.outputTokens') IN ('integer', 'real')
935
- THEN CAST(json_extract(e.payload, '$.outputTokens') AS INTEGER) ELSE 0
936
- END)
937
- FROM ws_events e
938
- JOIN agent_sessions s ON s.id = e.session_id AND s.workspace_id = e.workspace_id
939
- WHERE e.workspace_id = ?
940
- AND e.session_id = ?
941
- AND e.type = 'agent:event'
942
- AND json_valid(e.payload)
943
- GROUP BY e.workspace_id, e.session_id`).run(workspaceId, sessionId);
944
- }
903
+ export { recomputeSessionMetricsOn } from './session-event-metrics.js';
945
904
  /** Same, on the singleton connection. */
946
905
  export function recomputeSessionMetrics(workspaceId, sessionId) {
947
906
  recomputeSessionMetricsOn(getDb(), workspaceId, sessionId);
@@ -1,7 +1,7 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import path from 'node:path';
1
+ import { existsSync, readFileSync } from 'node:fs';
3
2
  import { nanoid } from 'nanoid';
4
3
  import { getDb } from '../db/index.js';
4
+ import { writeJsonFileAtomically } from '../utils/atomic-json-file.js';
5
5
  import { getWorkspaceTemplatesPath } from '../utils/paths.js';
6
6
  const FILE_VERSION = 1;
7
7
  export const MAX_WORKSPACE_TEMPLATES = 100;
@@ -71,32 +71,30 @@ function readFile() {
71
71
  return [];
72
72
  try {
73
73
  const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
74
+ if (!parsed || !Array.isArray(parsed.templates))
75
+ throw new Error('Expected a templates array');
74
76
  if (parsed.version !== FILE_VERSION) {
75
77
  console.warn(`[workspace-template-service] workspace-templates.json has version ${parsed.version}, expected ${FILE_VERSION}. Reading best-effort.`);
76
78
  }
77
- if (!Array.isArray(parsed.templates))
78
- return [];
79
+ if (parsed.templates.some((t) => !t ||
80
+ typeof t.id !== 'string' ||
81
+ typeof t.name !== 'string' ||
82
+ typeof t.createdAt !== 'string' ||
83
+ typeof t.updatedAt !== 'string')) {
84
+ throw new Error('A workspace template has missing or malformed fields');
85
+ }
79
86
  // Re-sanitise on read: the file may have been edited by hand.
80
- return parsed.templates
81
- .filter((t) => t &&
82
- typeof t.id === 'string' &&
83
- typeof t.name === 'string' &&
84
- typeof t.createdAt === 'string' &&
85
- typeof t.updatedAt === 'string')
86
- .map((t) => ({ ...t, preset: sanitizePreset(t.preset) }));
87
+ return parsed.templates.map((t) => ({ ...t, preset: sanitizePreset(t.preset) }));
87
88
  }
88
89
  catch (err) {
89
- // Treated as empty, never overwritten here: the next successful write is
90
- // the user's, after they have seen the log line.
91
- console.error('[workspace-template-service] Failed to read workspace-templates.json:', err);
92
- return [];
90
+ throw new Error('Failed to read workspace-templates.json. Existing data was preserved; restore a valid file.', {
91
+ cause: err,
92
+ });
93
93
  }
94
94
  }
95
95
  function writeFile(templates) {
96
- const filePath = getWorkspaceTemplatesPath();
97
- mkdirSync(path.dirname(filePath), { recursive: true });
98
96
  const file = { version: FILE_VERSION, templates };
99
- writeFileSync(filePath, JSON.stringify(file, null, 2), 'utf-8');
97
+ writeJsonFileAtomically(getWorkspaceTemplatesPath(), file);
100
98
  }
101
99
  export function listWorkspaceTemplates() {
102
100
  return readFile();
@@ -39,10 +39,7 @@ async function purgeWorktreeGuarded(workspaceId, expectedArchivedAt) {
39
39
  logError('purge', `stopDevServer failed for '${workspace.name}'`, { workspaceId: workspace.id, error: msg });
40
40
  throw err;
41
41
  }
42
- try {
43
- destroyTerminal(workspaceId);
44
- }
45
- catch { }
42
+ await destroyTerminal(workspaceId);
46
43
  // Snapshot the forge BEFORE removing the worktree — the PR lookup uses
47
44
  // `worktreePath` as cwd.
48
45
  const restoreData = await captureRestoreData(workspace);