aicodeman 1.46.0 → 1.48.0

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 (291) hide show
  1. package/README.md +3 -0
  2. package/dist/cli-notify.d.ts +34 -0
  3. package/dist/cli-notify.d.ts.map +1 -0
  4. package/dist/cli-notify.js +100 -0
  5. package/dist/cli-notify.js.map +1 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +16 -0
  8. package/dist/cli.js.map +1 -1
  9. package/dist/config/cli-registry/patterns.d.ts +1 -1
  10. package/dist/config/cli-registry/patterns.d.ts.map +1 -1
  11. package/dist/config/cli-registry/patterns.js +4 -0
  12. package/dist/config/cli-registry/patterns.js.map +1 -1
  13. package/dist/config/cli-registry/schema.d.ts +6 -0
  14. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  15. package/dist/config/cli-registry/schema.js +62 -2
  16. package/dist/config/cli-registry/schema.js.map +1 -1
  17. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  18. package/dist/config/cli-registry/stock.js +43 -26
  19. package/dist/config/cli-registry/stock.js.map +1 -1
  20. package/dist/config/cli-registry/types.d.ts +47 -17
  21. package/dist/config/cli-registry/types.d.ts.map +1 -1
  22. package/dist/config/path-probe.d.ts +6 -0
  23. package/dist/config/path-probe.d.ts.map +1 -1
  24. package/dist/config/path-probe.js +6 -0
  25. package/dist/config/path-probe.js.map +1 -1
  26. package/dist/config-backup.d.ts +1 -1
  27. package/dist/config-backup.d.ts.map +1 -1
  28. package/dist/config-backup.js +2 -0
  29. package/dist/config-backup.js.map +1 -1
  30. package/dist/custom-model-hosts.d.ts +32 -8
  31. package/dist/custom-model-hosts.d.ts.map +1 -1
  32. package/dist/custom-model-hosts.js +10 -0
  33. package/dist/custom-model-hosts.js.map +1 -1
  34. package/dist/custom-model-injection-apply.d.ts +19 -5
  35. package/dist/custom-model-injection-apply.d.ts.map +1 -1
  36. package/dist/custom-model-injection-apply.js +88 -8
  37. package/dist/custom-model-injection-apply.js.map +1 -1
  38. package/dist/custom-model-injection.d.ts +6 -0
  39. package/dist/custom-model-injection.d.ts.map +1 -1
  40. package/dist/custom-model-injection.js +92 -10
  41. package/dist/custom-model-injection.js.map +1 -1
  42. package/dist/discord-bridge.d.ts +338 -0
  43. package/dist/discord-bridge.d.ts.map +1 -0
  44. package/dist/discord-bridge.js +1256 -0
  45. package/dist/discord-bridge.js.map +1 -0
  46. package/dist/edit-history-local-source.d.ts +83 -0
  47. package/dist/edit-history-local-source.d.ts.map +1 -0
  48. package/dist/edit-history-local-source.js +198 -0
  49. package/dist/edit-history-local-source.js.map +1 -0
  50. package/dist/edit-history-parse.d.ts +114 -0
  51. package/dist/edit-history-parse.d.ts.map +1 -0
  52. package/dist/edit-history-parse.js +312 -0
  53. package/dist/edit-history-parse.js.map +1 -0
  54. package/dist/edit-history-store.d.ts +209 -0
  55. package/dist/edit-history-store.d.ts.map +1 -0
  56. package/dist/edit-history-store.js +599 -0
  57. package/dist/edit-history-store.js.map +1 -0
  58. package/dist/git-clone.d.ts +6 -1
  59. package/dist/git-clone.d.ts.map +1 -1
  60. package/dist/git-clone.js +79 -14
  61. package/dist/git-clone.js.map +1 -1
  62. package/dist/image-watcher.js +4 -4
  63. package/dist/image-watcher.js.map +1 -1
  64. package/dist/mux-interface.d.ts +10 -1
  65. package/dist/mux-interface.d.ts.map +1 -1
  66. package/dist/ralph-fix-plan-watcher.d.ts +13 -1
  67. package/dist/ralph-fix-plan-watcher.d.ts.map +1 -1
  68. package/dist/ralph-fix-plan-watcher.js +21 -3
  69. package/dist/ralph-fix-plan-watcher.js.map +1 -1
  70. package/dist/ralph-tracker.d.ts +6 -0
  71. package/dist/ralph-tracker.d.ts.map +1 -1
  72. package/dist/ralph-tracker.js +8 -0
  73. package/dist/ralph-tracker.js.map +1 -1
  74. package/dist/session-cli-registry-bridge.d.ts.map +1 -1
  75. package/dist/session-cli-registry-bridge.js +4 -0
  76. package/dist/session-cli-registry-bridge.js.map +1 -1
  77. package/dist/session.d.ts +70 -3
  78. package/dist/session.d.ts.map +1 -1
  79. package/dist/session.js +147 -4
  80. package/dist/session.js.map +1 -1
  81. package/dist/tmux-manager.d.ts +40 -0
  82. package/dist/tmux-manager.d.ts.map +1 -1
  83. package/dist/tmux-manager.js +62 -0
  84. package/dist/tmux-manager.js.map +1 -1
  85. package/dist/types/discord.d.ts +55 -0
  86. package/dist/types/discord.d.ts.map +1 -0
  87. package/dist/types/discord.js +18 -0
  88. package/dist/types/discord.js.map +1 -0
  89. package/dist/types/index.d.ts +2 -0
  90. package/dist/types/index.d.ts.map +1 -1
  91. package/dist/types/index.js +2 -0
  92. package/dist/types/index.js.map +1 -1
  93. package/dist/types/session.d.ts +13 -0
  94. package/dist/types/session.d.ts.map +1 -1
  95. package/dist/types/session.js.map +1 -1
  96. package/dist/web/case-path.d.ts +2 -0
  97. package/dist/web/case-path.d.ts.map +1 -1
  98. package/dist/web/case-path.js +3 -3
  99. package/dist/web/case-path.js.map +1 -1
  100. package/dist/web/edit-history-sync.d.ts +158 -0
  101. package/dist/web/edit-history-sync.d.ts.map +1 -0
  102. package/dist/web/edit-history-sync.js +445 -0
  103. package/dist/web/edit-history-sync.js.map +1 -0
  104. package/dist/web/paste-image-gc.d.ts +6 -0
  105. package/dist/web/paste-image-gc.d.ts.map +1 -1
  106. package/dist/web/paste-image-gc.js +6 -0
  107. package/dist/web/paste-image-gc.js.map +1 -1
  108. package/dist/web/ports/session-port.d.ts +13 -0
  109. package/dist/web/ports/session-port.d.ts.map +1 -1
  110. package/dist/web/public/admin-ui.js.gz +0 -0
  111. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  112. package/dist/web/public/{app.3093527f.js → app.963b5ba8.js} +18 -18
  113. package/dist/web/public/app.963b5ba8.js.br +0 -0
  114. package/dist/web/public/app.963b5ba8.js.gz +0 -0
  115. package/dist/web/public/approvals-ui.js.gz +0 -0
  116. package/dist/web/public/{constants.affcdde6.js → constants.782170e1.js} +114 -25
  117. package/dist/web/public/constants.782170e1.js.br +0 -0
  118. package/dist/web/public/constants.782170e1.js.gz +0 -0
  119. package/dist/web/public/cron-ui.js.gz +0 -0
  120. package/dist/web/public/edit-history-ui.js +630 -0
  121. package/dist/web/public/edit-history-ui.js.br +0 -0
  122. package/dist/web/public/edit-history-ui.js.gz +0 -0
  123. package/dist/web/public/entrance-animations.js.gz +0 -0
  124. package/dist/web/public/git-status-ui.js.gz +0 -0
  125. package/dist/web/public/header-divider.eb2d2463.js.gz +0 -0
  126. package/dist/web/public/home-sessions.js.gz +0 -0
  127. package/dist/web/public/host-wake-ui.js.gz +0 -0
  128. package/dist/web/public/i18n.33f930d7.js +2 -0
  129. package/dist/web/public/i18n.33f930d7.js.br +0 -0
  130. package/dist/web/public/i18n.33f930d7.js.gz +0 -0
  131. package/dist/web/public/{image-input.941386e7.js → image-input.a5d0f8f3.js} +18 -9
  132. package/dist/web/public/image-input.a5d0f8f3.js.br +0 -0
  133. package/dist/web/public/image-input.a5d0f8f3.js.gz +0 -0
  134. package/dist/web/public/index.html +162 -16
  135. package/dist/web/public/index.html.br +0 -0
  136. package/dist/web/public/index.html.gz +0 -0
  137. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  138. package/dist/web/public/{keyboard-accessory.4e8b20ac.js → keyboard-accessory.d4ec5957.js} +77 -2
  139. package/dist/web/public/keyboard-accessory.d4ec5957.js.br +0 -0
  140. package/dist/web/public/keyboard-accessory.d4ec5957.js.gz +0 -0
  141. package/dist/web/public/mobile-handlers.f7c3f692.js.gz +0 -0
  142. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  143. package/dist/web/public/mobile-overview.js.gz +0 -0
  144. package/dist/web/public/mobile.5ef2ce85.css +1 -0
  145. package/dist/web/public/mobile.5ef2ce85.css.br +0 -0
  146. package/dist/web/public/mobile.5ef2ce85.css.gz +0 -0
  147. package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
  148. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  149. package/dist/web/public/{panels-ui.4a56ee63.js → panels-ui.b100727d.js} +2 -2
  150. package/dist/web/public/panels-ui.b100727d.js.br +0 -0
  151. package/dist/web/public/{panels-ui.4a56ee63.js.gz → panels-ui.b100727d.js.gz} +0 -0
  152. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  153. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  154. package/dist/web/public/readmymind-ui.js.gz +0 -0
  155. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  156. package/dist/web/public/{respawn-ui.d7adcf6a.js → respawn-ui.304ba0d4.js} +1 -1
  157. package/dist/web/public/respawn-ui.304ba0d4.js.br +0 -0
  158. package/dist/web/public/respawn-ui.304ba0d4.js.gz +0 -0
  159. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  160. package/dist/web/public/security-notices.json +1 -21
  161. package/dist/web/public/session-lineage.js.gz +0 -0
  162. package/dist/web/public/{session-ui.186f9419.js → session-ui.5ac82f2c.js} +30 -30
  163. package/dist/web/public/session-ui.5ac82f2c.js.br +0 -0
  164. package/dist/web/public/session-ui.5ac82f2c.js.gz +0 -0
  165. package/dist/web/public/settings-ui.d1d4fb99.js +106 -0
  166. package/dist/web/public/settings-ui.d1d4fb99.js.br +0 -0
  167. package/dist/web/public/settings-ui.d1d4fb99.js.gz +0 -0
  168. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  169. package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
  170. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  171. package/dist/web/public/styles.77dc538f.css +1 -0
  172. package/dist/web/public/styles.77dc538f.css.br +0 -0
  173. package/dist/web/public/styles.77dc538f.css.gz +0 -0
  174. package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
  175. package/dist/web/public/sw.js +2 -2
  176. package/dist/web/public/sw.js.br +0 -0
  177. package/dist/web/public/sw.js.gz +0 -0
  178. package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
  179. package/dist/web/public/tab-rail-resize.b5cdceaf.js.gz +0 -0
  180. package/dist/web/public/terminal-keycode229-recovery.98843069.js +1 -0
  181. package/dist/web/public/terminal-keycode229-recovery.98843069.js.br +0 -0
  182. package/dist/web/public/terminal-keycode229-recovery.98843069.js.gz +0 -0
  183. package/dist/web/public/terminal-split.js.gz +0 -0
  184. package/dist/web/public/terminal-tile.js +8 -6
  185. package/dist/web/public/terminal-tile.js.br +0 -0
  186. package/dist/web/public/terminal-tile.js.gz +0 -0
  187. package/dist/web/public/{terminal-ui.7e787829.js → terminal-ui.d7c75ce8.js} +3 -3
  188. package/dist/web/public/terminal-ui.d7c75ce8.js.br +0 -0
  189. package/dist/web/public/terminal-ui.d7c75ce8.js.gz +0 -0
  190. package/dist/web/public/tile-grid.js.gz +0 -0
  191. package/dist/web/public/ultracode-panel.js.gz +0 -0
  192. package/dist/web/public/ultracode-windows.js.gz +0 -0
  193. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  194. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  195. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  196. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  197. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  198. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  199. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  200. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  201. package/dist/web/public/vendor/xterm-predictive-echo.276b11d9.js.gz +0 -0
  202. package/dist/web/public/vendor/xterm-zerolag-input.1be42307.js.gz +0 -0
  203. package/dist/web/public/vendor/xterm.css.gz +0 -0
  204. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  205. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  206. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  207. package/dist/web/public/webview-tabs.js.gz +0 -0
  208. package/dist/web/public/whats-new.json +9 -9
  209. package/dist/web/request-body-parser.d.ts +23 -0
  210. package/dist/web/request-body-parser.d.ts.map +1 -0
  211. package/dist/web/request-body-parser.js +57 -0
  212. package/dist/web/request-body-parser.js.map +1 -0
  213. package/dist/web/routes/case-routes.d.ts.map +1 -1
  214. package/dist/web/routes/case-routes.js +104 -8
  215. package/dist/web/routes/case-routes.js.map +1 -1
  216. package/dist/web/routes/config-backup-routes.d.ts +5 -1
  217. package/dist/web/routes/config-backup-routes.d.ts.map +1 -1
  218. package/dist/web/routes/config-backup-routes.js +4 -1
  219. package/dist/web/routes/config-backup-routes.js.map +1 -1
  220. package/dist/web/routes/custom-model-routes.d.ts +7 -2
  221. package/dist/web/routes/custom-model-routes.d.ts.map +1 -1
  222. package/dist/web/routes/custom-model-routes.js +51 -6
  223. package/dist/web/routes/custom-model-routes.js.map +1 -1
  224. package/dist/web/routes/discord-routes.d.ts +25 -0
  225. package/dist/web/routes/discord-routes.d.ts.map +1 -0
  226. package/dist/web/routes/discord-routes.js +206 -0
  227. package/dist/web/routes/discord-routes.js.map +1 -0
  228. package/dist/web/routes/edit-history-routes.d.ts +43 -0
  229. package/dist/web/routes/edit-history-routes.d.ts.map +1 -0
  230. package/dist/web/routes/edit-history-routes.js +106 -0
  231. package/dist/web/routes/edit-history-routes.js.map +1 -0
  232. package/dist/web/routes/file-routes.d.ts +5 -0
  233. package/dist/web/routes/file-routes.d.ts.map +1 -1
  234. package/dist/web/routes/file-routes.js +67 -3
  235. package/dist/web/routes/file-routes.js.map +1 -1
  236. package/dist/web/routes/index.d.ts +2 -0
  237. package/dist/web/routes/index.d.ts.map +1 -1
  238. package/dist/web/routes/index.js +2 -0
  239. package/dist/web/routes/index.js.map +1 -1
  240. package/dist/web/routes/ralph-routes.d.ts.map +1 -1
  241. package/dist/web/routes/ralph-routes.js +45 -2
  242. package/dist/web/routes/ralph-routes.js.map +1 -1
  243. package/dist/web/routes/session-routes.d.ts +17 -0
  244. package/dist/web/routes/session-routes.d.ts.map +1 -1
  245. package/dist/web/routes/session-routes.js +92 -37
  246. package/dist/web/routes/session-routes.js.map +1 -1
  247. package/dist/web/routes/system-routes.d.ts.map +1 -1
  248. package/dist/web/routes/system-routes.js +4 -2
  249. package/dist/web/routes/system-routes.js.map +1 -1
  250. package/dist/web/schemas.d.ts +68 -1
  251. package/dist/web/schemas.d.ts.map +1 -1
  252. package/dist/web/schemas.js +77 -1
  253. package/dist/web/schemas.js.map +1 -1
  254. package/dist/web/sensitive-path.d.ts.map +1 -1
  255. package/dist/web/sensitive-path.js +15 -0
  256. package/dist/web/sensitive-path.js.map +1 -1
  257. package/dist/web/server.d.ts +43 -0
  258. package/dist/web/server.d.ts.map +1 -1
  259. package/dist/web/server.js +237 -15
  260. package/dist/web/server.js.map +1 -1
  261. package/package.json +1 -1
  262. package/dist/web/public/app.3093527f.js.br +0 -0
  263. package/dist/web/public/app.3093527f.js.gz +0 -0
  264. package/dist/web/public/constants.affcdde6.js.br +0 -0
  265. package/dist/web/public/constants.affcdde6.js.gz +0 -0
  266. package/dist/web/public/i18n.70265257.js +0 -2
  267. package/dist/web/public/i18n.70265257.js.br +0 -0
  268. package/dist/web/public/i18n.70265257.js.gz +0 -0
  269. package/dist/web/public/image-input.941386e7.js.br +0 -0
  270. package/dist/web/public/image-input.941386e7.js.gz +0 -0
  271. package/dist/web/public/keyboard-accessory.4e8b20ac.js.br +0 -0
  272. package/dist/web/public/keyboard-accessory.4e8b20ac.js.gz +0 -0
  273. package/dist/web/public/mobile.1bbd2d67.css +0 -1
  274. package/dist/web/public/mobile.1bbd2d67.css.br +0 -0
  275. package/dist/web/public/mobile.1bbd2d67.css.gz +0 -0
  276. package/dist/web/public/panels-ui.4a56ee63.js.br +0 -0
  277. package/dist/web/public/respawn-ui.d7adcf6a.js.br +0 -0
  278. package/dist/web/public/respawn-ui.d7adcf6a.js.gz +0 -0
  279. package/dist/web/public/session-ui.186f9419.js.br +0 -0
  280. package/dist/web/public/session-ui.186f9419.js.gz +0 -0
  281. package/dist/web/public/settings-ui.a191154e.js +0 -106
  282. package/dist/web/public/settings-ui.a191154e.js.br +0 -0
  283. package/dist/web/public/settings-ui.a191154e.js.gz +0 -0
  284. package/dist/web/public/styles.7238eae8.css +0 -1
  285. package/dist/web/public/styles.7238eae8.css.br +0 -0
  286. package/dist/web/public/styles.7238eae8.css.gz +0 -0
  287. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js +0 -1
  288. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.br +0 -0
  289. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
  290. package/dist/web/public/terminal-ui.7e787829.js.br +0 -0
  291. package/dist/web/public/terminal-ui.7e787829.js.gz +0 -0
@@ -44,6 +44,8 @@ import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from
44
44
  import { rebootRestoreRegistry } from './reboot-restore-registry.js';
45
45
  import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
46
46
  import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js';
47
+ import { DiscordBridge, screenTail } from '../discord-bridge.js';
48
+ import { isExternalCliTranscriptMode } from './response-viewer-transcript.js';
47
49
  import { stopHostSensors } from '../host-sensors.js';
48
50
  import { webviewFetch } from './webview-egress.js';
49
51
  import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
@@ -66,8 +68,8 @@ import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment
66
68
  import { folderGrants } from '../folder-listing.js';
67
69
  import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js';
68
70
  import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
69
- import { readCustomModelHosts } from '../custom-model-hosts.js';
70
- import { applyCustomModelInjection, customModelConfigDir, removeConfigDir } from '../custom-model-injection-apply.js';
71
+ import { readCustomModelHosts, resolveCustomModelContextLength } from '../custom-model-hosts.js';
72
+ import { applyCustomModelInjection, customModelConfigDir, removeConfigDir, } from '../custom-model-injection-apply.js';
71
73
  import { registerGeneratedArtifactAttachment } from '../generated-artifact-attachments.js';
72
74
  import { buildDetectedAttachmentHistoryItem, buildExternalAttachmentHistoryItem, } from '../session-attachment-history.js';
73
75
  import { TranscriptWatcher } from '../transcript-watcher.js';
@@ -99,6 +101,9 @@ import { probePathKind } from '../utils/bounded-path-probe.js';
99
101
  import { defaultDockerWorkspaces } from './routes/git-status-routes.js';
100
102
  import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js';
101
103
  import { detectUsageLimitPause } from '../usage-limit-patterns.js';
104
+ import { EditHistorySync } from './edit-history-sync.js';
105
+ import { EditHistoryStore } from '../edit-history-store.js';
106
+ import { probeLocalPaths, readClaudeEditSources, resolveClaudeTranscript } from '../edit-history-local-source.js';
102
107
  import { stopDeepSeekWeb } from '../deepseek-web-server.js';
103
108
  import { wireRespawnListeners, setupTimedRespawn, restoreRespawnController, saveRespawnConfig, } from './respawn-event-wiring.js';
104
109
  import { reconcileUpdateOnBoot } from './self-update.js';
@@ -134,8 +139,9 @@ import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard, isL
134
139
  import { isMultiUserMode } from '../config/multiuser.js';
135
140
  import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
136
141
  import { installRouteErrorHandler } from './route-error-handler.js';
142
+ import { installEmptyApiPostParser } from './request-body-parser.js';
137
143
  import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy } from './network-auth-policy.js';
138
- import { registerPushRoutes, registerTeamRoutes, registerMuxRoutes, registerFileRoutes, registerHtmlViewRoutes, registerScheduledRoutes, registerHookEventRoutes, registerApprovalRoutes, registerInboxRoutes, registerRebootRestoreRoutes, registerReadMyMindRoutes, registerGitStatusRoutes, registerStatusTelemetryRoutes, registerSystemRoutes, registerCaseRoutes, registerWorktreeRoutes, registerSessionRoutes, registerAgentSessionRoutes, registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, registerClipboardRoutes, registerSearchRoutes, registerOrchestratorRoutes, registerCronRoutes, registerMeRoutes, registerAdminRoutes, registerWsRoutes, registerVoiceRoutes, registerWebviewRoutes, registerTabLayoutRoutes, registerMcpSyncRoutes, registerConfigBackupRoutes, runConfigBackup, registerWebhookRoutes, registerDoctorRoutes, registerCustomModelRoutes, refreshAllCustomModelHosts, readCustomModelEndpointsEnabled, closeAllLlamaSwapLogTails, detectCustomModelSwapDisplacements, pruneIdleLlamaSwapLogTails, tryWebviewRefererFallback, registerCliRegistryRoutes, } from './routes/index.js';
144
+ import { registerPushRoutes, registerTeamRoutes, registerMuxRoutes, registerFileRoutes, registerHtmlViewRoutes, registerScheduledRoutes, registerHookEventRoutes, registerApprovalRoutes, registerInboxRoutes, registerRebootRestoreRoutes, registerReadMyMindRoutes, registerGitStatusRoutes, registerEditHistoryRoutes, registerStatusTelemetryRoutes, registerSystemRoutes, registerCaseRoutes, registerWorktreeRoutes, registerSessionRoutes, registerAgentSessionRoutes, registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, registerClipboardRoutes, registerSearchRoutes, registerOrchestratorRoutes, registerCronRoutes, registerMeRoutes, registerAdminRoutes, registerWsRoutes, registerVoiceRoutes, registerWebviewRoutes, registerTabLayoutRoutes, registerMcpSyncRoutes, registerConfigBackupRoutes, runConfigBackup, registerWebhookRoutes, registerDiscordRoutes, registerDoctorRoutes, registerCustomModelRoutes, refreshAllCustomModelHosts, readCustomModelEndpointsEnabled, closeAllLlamaSwapLogTails, detectCustomModelSwapDisplacements, pruneIdleLlamaSwapLogTails, tryWebviewRefererFallback, registerCliRegistryRoutes, } from './routes/index.js';
139
145
  import { isLostWebviewFrameNavigation } from './webview-proxy.js';
140
146
  import { CronService } from '../cron/cron-service.js';
141
147
  import { caseSessionSettled } from '../case-rename.js';
@@ -204,6 +210,19 @@ function getOrCreateSelfSignedCert() {
204
210
  export class WebServer extends EventEmitter {
205
211
  app;
206
212
  sessions = new Map();
213
+ /**
214
+ * The edit history panel's reader (edit-history-sync.ts). Reads only when the panel asks;
215
+ * the main transcript is the one the transcript watcher follows (hook-reported path), or,
216
+ * before the first hook after a restart, a lookup under the session's claude config dir.
217
+ */
218
+ editHistorySync = new EditHistorySync({
219
+ transcriptPath: (session) => resolveClaudeTranscript(this.transcriptWatchers.get(session.id)?.getPath(), [session.claudeSessionId || session.id, session.id], session.claudeConfigDir),
220
+ readSources: readClaudeEditSources,
221
+ probe: probeLocalPaths,
222
+ loadStore: (sessionId) => EditHistoryStore.load(sessionId),
223
+ deleteStore: (sessionId) => EditHistoryStore.delete(sessionId),
224
+ sweepStores: (keep) => EditHistoryStore.sweep(keep),
225
+ });
207
226
  respawnControllers = new Map();
208
227
  respawnTimers = new Map();
209
228
  runSummaryTrackers = new Map();
@@ -350,6 +369,29 @@ export class WebServer extends EventEmitter {
350
369
  pushStore = new PushSubscriptionStore();
351
370
  /** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */
352
371
  webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch);
372
+ /** Two-way Discord bridge (thread per tab); config in discord.json (0600). Idle until enabled in Settings. */
373
+ /** Set by the session routes when they register (see SessionPort). */
374
+ lastResponseFor;
375
+ discordBridge = new DiscordBridge({
376
+ configDir: getDataDir(),
377
+ fetchImpl: webviewFetch,
378
+ sendInput: async (sessionId, text) => {
379
+ const session = this.sessions.get(sessionId);
380
+ if (!session)
381
+ return false;
382
+ // A Discord reply is a person's own input, so it names the tab like any other prompt.
383
+ if (await session.writeViaMux(text, { fromUser: true }).catch(() => false))
384
+ return true;
385
+ return session.write(text, { fromUser: true });
386
+ },
387
+ agentExited: (id) => !!this.sessions.get(id)?.paneExit,
388
+ awaitingChoice: (id) => approvalInbox.listPending().some((item) => item.sessionId === id && item.kind !== 'idle'),
389
+ // Remote (ssh) sessions keep their folder on another host, so received files have nowhere local to go.
390
+ sessionDir: (sessionId) => {
391
+ const session = this.sessions.get(sessionId);
392
+ return session && !session.remote ? session.workingDir : undefined;
393
+ },
394
+ });
353
395
  teamWatcher = new TeamWatcher();
354
396
  _orchestratorLoop = null;
355
397
  titleHostname;
@@ -681,6 +723,7 @@ export class WebServer extends EventEmitter {
681
723
  addSession: this.registerSessionWithLayout.bind(this),
682
724
  tabLayouts: this.tabLayouts,
683
725
  cleanupSession: this.cleanupSession.bind(this),
726
+ forgetEditHistory: this.forgetEditHistory.bind(this),
684
727
  setupSessionListeners: this.setupSessionListeners.bind(this),
685
728
  persistSessionState: this.persistSessionState.bind(this),
686
729
  persistSessionStateNow: this._persistSessionStateNow.bind(this),
@@ -817,6 +860,8 @@ export class WebServer extends EventEmitter {
817
860
  this.userFailures = authState.userFailures;
818
861
  this.userIpFailures = authState.userIpFailures;
819
862
  }
863
+ // Bodyless API POSTs may arrive as empty chunked streams through a proxy.
864
+ installEmptyApiPostParser(this.app);
820
865
  // WebSocket support (terminal I/O — low-latency bidirectional channel)
821
866
  await this.app.register(fastifyWebsocket);
822
867
  // Multipart parsing (used by paste-image). Replaces a hand-rolled
@@ -1083,6 +1128,7 @@ export class WebServer extends EventEmitter {
1083
1128
  registerRebootRestoreRoutes(this.app, ctx);
1084
1129
  registerReadMyMindRoutes(this.app, ctx);
1085
1130
  registerGitStatusRoutes(this.app, ctx);
1131
+ registerEditHistoryRoutes(this.app, ctx, this.editHistorySync);
1086
1132
  // Git fetch on an interval: this is only the wake-up, the scheduler itself decides what is due. Off in
1087
1133
  // tests for the same reason as the polls below: a test drives `tick()` directly instead of waiting on
1088
1134
  // a timer the harness would have to keep alive. The setting is read FRESH inside `tick()`, so turning
@@ -1101,6 +1147,8 @@ export class WebServer extends EventEmitter {
1101
1147
  // The registry's lifetime is the server's: it drops per-session wake state on every
1102
1148
  // cleanup path and resolves in-flight wakes on shutdown.
1103
1149
  this.remoteWake = registerSessionRoutes(this.app, ctx);
1150
+ // The routes' ctx is a separate object: take the last-response reader they registered on it (Discord alerts).
1151
+ this.lastResponseFor = ctx.lastResponseFor;
1104
1152
  registerAgentSessionRoutes(this.app, ctx);
1105
1153
  registerRespawnRoutes(this.app, ctx);
1106
1154
  registerRalphRoutes(this.app, ctx);
@@ -1113,12 +1161,23 @@ export class WebServer extends EventEmitter {
1113
1161
  registerWebviewRoutes(this.app, ctx, this.basePath);
1114
1162
  registerTabLayoutRoutes(this.app, ctx);
1115
1163
  registerMcpSyncRoutes(this.app);
1116
- registerConfigBackupRoutes(this.app);
1164
+ registerConfigBackupRoutes(this.app, { reloadDiscord: () => this.discordBridge.reload() });
1117
1165
  registerWebhookRoutes(this.app, {
1118
1166
  notifier: this.webhookNotifier,
1119
1167
  configDir: getDataDir(),
1120
1168
  hostTitle: () => this.windowTitle,
1121
1169
  });
1170
+ registerDiscordRoutes(this.app, {
1171
+ bridge: this.discordBridge,
1172
+ configDir: getDataDir(),
1173
+ ctx,
1174
+ hostTitle: () => this.windowTitle,
1175
+ });
1176
+ if (!this.testMode) {
1177
+ // The gateway waits for restoreMuxSessions(): a reply during restore would be told the tab is gone.
1178
+ this.discordBridge.hold();
1179
+ void this.discordBridge.ensureLoaded().catch(() => undefined);
1180
+ }
1122
1181
  registerDoctorRoutes(this.app);
1123
1182
  registerCustomModelRoutes(this.app);
1124
1183
  registerCliRegistryRoutes(this.app);
@@ -1237,13 +1296,46 @@ export class WebServer extends EventEmitter {
1237
1296
  return undefined;
1238
1297
  }
1239
1298
  try {
1240
- return applyCustomModelInjection(entry, endpoint, saved.modelId, session.id, endpoint.modelContextLengths?.[saved.modelId])?.envOverrides;
1299
+ // Boot recovery omits both `workingDir` (no reason to re-answer a trust dialog that
1300
+ // already fired once) and the session-env real-home lookup: the injected VALUES are
1301
+ // never persisted, so after a real restart the session can no longer carry its
1302
+ // allowlisted `DSH_HOME` either (`getEnvOverridesForPersist()` keeps only
1303
+ // `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`): the lookup could only ever hit on the first
1304
+ // apply in-process. Real-home resolution therefore falls back to the SERVER's
1305
+ // `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh).
1306
+ return applyCustomModelInjection(entry, endpoint, saved.modelId, session.id, resolveCustomModelContextLength(endpoint, saved.modelId));
1241
1307
  }
1242
1308
  catch (err) {
1243
1309
  console.warn('[WebServer] Failed to rebuild custom-model env on recovery:', err);
1244
1310
  return undefined;
1245
1311
  }
1246
1312
  }
1313
+ /**
1314
+ * Restore one persisted custom-model selection (both boot-recovery paths). The rebuilt
1315
+ * bookkeeping must come from the CURRENT recipe, not from the saved record: this is the
1316
+ * first feature where a shipped recipe changed KIND (deepseek: env redirect -> per-session
1317
+ * patch home), so a selection saved by an old build restores with e.g. no `configDir`
1318
+ * while the new recipe writes one, and clearing it later could then never delete the
1319
+ * generated dir. `envKeys` is the UNION of saved and applied keys: the tmux session may
1320
+ * still carry `setenv`s from both, and a clear has to unset every one.
1321
+ */
1322
+ async _restoreCustomModel(session, saved) {
1323
+ const applied = await this._rebuildCustomModelEnv(session, saved);
1324
+ if (!applied) {
1325
+ // Endpoint gone or CLI unregistered: restore the bookkeeping exactly as saved, which
1326
+ // is what a later clear needs.
1327
+ session.setCustomModel(saved);
1328
+ return;
1329
+ }
1330
+ session.setCustomModel({
1331
+ ...saved,
1332
+ // Prefer the current recipe's dir; keep the saved one when the recipe writes none,
1333
+ // so a clear can still delete a directory an older recipe created.
1334
+ configDir: applied.configDir ?? saved.configDir,
1335
+ launchModel: applied.launchModel,
1336
+ envKeys: [...new Set([...saved.envKeys, ...applied.envKeys])],
1337
+ }, applied.envOverrides);
1338
+ }
1247
1339
  /** Persists full session state including respawn config to state.json */
1248
1340
  _persistSessionStateNow(session) {
1249
1341
  // See session-manager.updateSessionState: __envOverrides is an internal disk-only
@@ -1493,7 +1585,9 @@ export class WebServer extends EventEmitter {
1493
1585
  // Only remove from state.json if we're also killing the mux session.
1494
1586
  // When killMux=false (server shutdown), preserve state for recovery.
1495
1587
  if (killMux) {
1496
- this.store.demoteOrRemoveSession(sessionId);
1588
+ // The edit history goes with the record; a pinned session's demoted record keeps it.
1589
+ if (this.store.demoteOrRemoveSession(sessionId) !== 'preserved')
1590
+ this.forgetEditHistory(sessionId);
1497
1591
  }
1498
1592
  }
1499
1593
  // Release anything blocked on this session, in the documented order: 'exit'
@@ -1507,8 +1601,11 @@ export class WebServer extends EventEmitter {
1507
1601
  // detach: mail posted before that is still there when the session is re-attached
1508
1602
  // (while detached, the routes answer 404, so nothing new arrives). Before
1509
1603
  // cancelAll, so a reader released by it reads an empty inbox.
1510
- if (killMux)
1604
+ if (killMux) {
1511
1605
  agentInbox.drop(sessionId);
1606
+ // A real delete only: a detach (killMux=false) leaves the session running and re-attachable.
1607
+ void this.discordBridge.sessionDeleted(sessionId).catch(() => undefined);
1608
+ }
1512
1609
  sessionWaits.notifySignal(sessionId, 'exit');
1513
1610
  sessionWaits.cancelAll(sessionId);
1514
1611
  approvalInbox.resolveForSession(sessionId, 'session_ended');
@@ -1777,12 +1874,23 @@ export class WebServer extends EventEmitter {
1777
1874
  const summaryTracker = new RunSummaryTracker(session.id, session.name);
1778
1875
  this.runSummaryTrackers.set(session.id, summaryTracker);
1779
1876
  summaryTracker.recordSessionStarted(session.mode, session.workingDir);
1780
- // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs)
1877
+ // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs).
1878
+ // A remote session's workingDir is a path on the remote host, and the watcher reads and
1879
+ // fs.watch()es it on the LOCAL disk, so a remote session only records the path: the fix plan stays
1880
+ // authoritative as for a local session (keeping the output-based completion guessers off), and its
1881
+ // file is read over ssh only on an explicit import (fix-plan/read).
1781
1882
  if (!isExternalCliMode(session.mode)) {
1782
- session.ralphTracker.setWorkingDir(session.workingDir);
1883
+ if (session.remote) {
1884
+ session.ralphTracker.setRemoteWorkingDir(session.workingDir);
1885
+ }
1886
+ else {
1887
+ session.ralphTracker.setWorkingDir(session.workingDir);
1888
+ }
1783
1889
  }
1784
- // Start watching for new images in this session's working directory (if enabled globally and per-session)
1785
- if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) {
1890
+ // Start watching for new images in this session's working directory (if enabled globally and per-session).
1891
+ // A remote session's workingDir is a path on the remote host, and the watcher watches it on the LOCAL
1892
+ // disk, so it is skipped there (the per-session and app-wide toggles skip it too).
1893
+ if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled && !session.remote) {
1786
1894
  imageWatcher.watchSession(session.id, session.workingDir);
1787
1895
  }
1788
1896
  // Create and attach all listener handlers via dependency injection
@@ -2223,9 +2331,25 @@ export class WebServer extends EventEmitter {
2223
2331
  const lifecycleLog = getLifecycleLog();
2224
2332
  for (const s of result.cleaned) {
2225
2333
  lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
2334
+ this.forgetEditHistory(s.id);
2226
2335
  }
2336
+ // History files no session owns (a crash between removing a record and deleting its file).
2337
+ const exists = (id) => this.sessions.has(id) || !!this.store.getSession(id);
2338
+ await this.editHistorySync.sweep(exists).catch((err) => {
2339
+ console.warn('[EditHistory] Sweeping orphaned histories failed:', getErrorMessage(err));
2340
+ });
2227
2341
  return result.count;
2228
2342
  }
2343
+ /**
2344
+ * Delete a session's edit history (diffs can hold secrets) once its record is gone: deleted
2345
+ * live (`cleanupSession`), deleted as a record only (DELETE route), or cleaned as stale
2346
+ * (startup, `POST /api/cleanup-state`).
2347
+ */
2348
+ forgetEditHistory(sessionId) {
2349
+ void this.editHistorySync.remove(sessionId).catch((err) => {
2350
+ console.warn(`[EditHistory] Removing history of ${sessionId} failed:`, getErrorMessage(err));
2351
+ });
2352
+ }
2229
2353
  /**
2230
2354
  * Get lightweight state for SSE init - excludes full terminal buffers
2231
2355
  * to prevent browser freezes. Terminal buffers are fetched on-demand.
@@ -2326,6 +2450,23 @@ export class WebServer extends EventEmitter {
2326
2450
  // Multi-user: derive an ownership routing hint so an event only reaches the
2327
2451
  // clients entitled to it (no-op in single-user — hint stays undefined).
2328
2452
  this.sse.broadcast(event, data, isMultiUserMode() ? this.deriveSseHint(event, data) : undefined);
2453
+ this.bridgeSessionEvent(event, data);
2454
+ }
2455
+ /** Keep the Discord thread in step with the tab: created, renamed, deleted. Never throws. */
2456
+ bridgeSessionEvent(event, data) {
2457
+ // Deletion is NOT handled here: `session:deleted` also fires for a detach (killMux=false), where the
2458
+ // session keeps running and can be re-attached. The real delete calls the bridge from the cleanup path.
2459
+ if (event !== SseEvent.SessionCreated && event !== SseEvent.SessionUpdated)
2460
+ return;
2461
+ const d = (data ?? {});
2462
+ const s = d.session ?? d;
2463
+ if (!s.id)
2464
+ return;
2465
+ const id = s.id;
2466
+ const run = event === SseEvent.SessionCreated
2467
+ ? this.discordBridge.sessionCreated(id, s.name).then(() => this.discordBridge.sessionRenamed(id, s.name))
2468
+ : this.discordBridge.sessionRenamed(id, s.name);
2469
+ void run.catch(() => undefined);
2329
2470
  }
2330
2471
  /**
2331
2472
  * Map an SSE event + payload to a routing hint (multi-user). Session-scoped
@@ -2433,15 +2574,26 @@ export class WebServer extends EventEmitter {
2433
2574
  const getPaneExit = this.mux.getPaneExit?.bind(this.mux);
2434
2575
  if (!getPaneExit)
2435
2576
  return;
2577
+ const getPaneCurrentPath = this.mux.getPaneCurrentPath?.bind(this.mux);
2436
2578
  for (const session of this.sessions.values()) {
2437
2579
  const muxName = session.muxName;
2438
2580
  // No pane, so nothing to report — and `setPaneExit()` would force UNKNOWN
2439
2581
  // for such a session anyway.
2440
2582
  if (!muxName)
2441
2583
  continue;
2442
- if (!session.setPaneExit(getPaneExit(muxName)))
2584
+ // The pane's live directory rides the same read: it is the base a relative
2585
+ // path printed in the terminal resolves against. The manager's answer is
2586
+ // applied as given, `undefined` included: it keeps its map across a failed
2587
+ // or empty read and carries a blank `#{pane_current_path}` forward, so an
2588
+ // `undefined` here means an authoritative read listed no single directory
2589
+ // for the session (split into several panes, or gone from the socket).
2590
+ const paneCwdChanged = getPaneCurrentPath ? session.setPaneCwd(getPaneCurrentPath(muxName)) : false;
2591
+ if (session.setPaneExit(getPaneExit(muxName))) {
2592
+ this.persistSessionState(session);
2593
+ }
2594
+ else if (!paneCwdChanged) {
2443
2595
  continue;
2444
- this.persistSessionState(session);
2596
+ }
2445
2597
  this.broadcastSessionStateDebounced(session.id);
2446
2598
  }
2447
2599
  this.closeCleanlyExitedSessions();
@@ -2552,6 +2704,26 @@ export class WebServer extends EventEmitter {
2552
2704
  host: this.windowTitle,
2553
2705
  })
2554
2706
  .catch(() => undefined);
2707
+ // Discord: same event, same independence from Web Push. It adds the tail of what the CLI printed,
2708
+ // so the person reading the thread can see what is being asked without opening the tab.
2709
+ const dcSession = sessionId ? this.sessions.get(sessionId) : undefined;
2710
+ // A question or permission dialog lives on the screen, so it keeps the screen tail (its options are
2711
+ // there); a finished turn posts the CLI's whole last message instead, from its transcript.
2712
+ const dialogEvent = event === SseEvent.HookPermissionPrompt || event === SseEvent.HookElicitationDialog;
2713
+ void this.discordBridge
2714
+ .notify({
2715
+ event,
2716
+ title: template.title,
2717
+ body,
2718
+ urgency: template.urgency,
2719
+ sessionId: sessionId || undefined,
2720
+ sessionName: sessionName || undefined,
2721
+ // A function, so the screen is only read (a synchronous tmux exec) once the bridge has decided to post.
2722
+ tail: dcSession ? () => this.discordTail(dcSession, !dialogEvent) : undefined,
2723
+ fullText: dcSession && !dialogEvent ? () => this.discordFullText(dcSession) : undefined,
2724
+ host: this.windowTitle,
2725
+ })
2726
+ .catch(() => undefined);
2555
2727
  const subscriptions = this.pushStore.getAll();
2556
2728
  if (subscriptions.length === 0)
2557
2729
  return;
@@ -2626,6 +2798,33 @@ export class WebServer extends EventEmitter {
2626
2798
  });
2627
2799
  }
2628
2800
  }
2801
+ /**
2802
+ * What the person would see on the tab right now, for a Discord alert: the rendered tmux screen
2803
+ * (`capture-pane -p`), never the raw output stream, which a full-screen UI repaints in place and
2804
+ * reads as garbage. A session with no pane falls back to its plain text output, else nothing.
2805
+ */
2806
+ discordTail(session, cutComposer) {
2807
+ const pane = session.muxName ? (this.mux.capturePaneText?.(session.muxName) ?? null) : null;
2808
+ // The composer row and the status lines under it are screen chrome, not what the CLI said.
2809
+ const glyph = cutComposer ? getCli(session.mode)?.capabilities.workDetect?.promptGlyph : undefined;
2810
+ return screenTail(pane ?? session.textOutput ?? '', 14, 900, glyph);
2811
+ }
2812
+ /**
2813
+ * The CLI's whole last message from the response viewer's reader (the CLI's own transcript, or the pane
2814
+ * segmented into turns for the CLIs that keep none); undefined for a shell, which has no turns, and the
2815
+ * screen tail stands in whenever the reader finds nothing.
2816
+ */
2817
+ async discordFullText(session) {
2818
+ if (!this.lastResponseFor)
2819
+ return undefined;
2820
+ // A CLI with a transcript reader, or whose pane the viewer segments into turns; a shell has neither.
2821
+ if (getCli(session.mode)?.capabilities.transcript === 'none' && !isExternalCliTranscriptMode(session.mode)) {
2822
+ return undefined;
2823
+ }
2824
+ const r = await this.lastResponseFor(session);
2825
+ // The turn it came from: the transcript's stamp, else when the person last submitted (a pane has no stamp).
2826
+ return { text: r.text, version: r.timestamp || String(session.lastSubmitAt ?? '') };
2827
+ }
2629
2828
  /** The notification body for an event (shared by Web Push and the webhook channel). */
2630
2829
  static pushBodyText(event, data, sessionName) {
2631
2830
  let body = sessionName ? `[${sessionName}]` : '';
@@ -2765,6 +2964,21 @@ export class WebServer extends EventEmitter {
2765
2964
  console.log(`[AgentInbox] restored ${restoredMail} pending message(s)`);
2766
2965
  const restored = await this.restoreMuxSessions();
2767
2966
  await this.finalizeRestoredState(restored);
2967
+ void this.discordBridge
2968
+ .release()
2969
+ .then(async () => {
2970
+ // Only after a successful restore, like the agent inbox prune and the tab layout reconciliation, and
2971
+ // with the same keep set: a reboot-restore rebuild reuses the old id.
2972
+ if (!restored)
2973
+ return;
2974
+ await this.discordBridge.prune([
2975
+ ...this.sessions.keys(),
2976
+ ...Object.keys(this.store.getSessions()),
2977
+ ...rebootRestoreRegistry.list(() => true).map((entry) => entry.sessionId),
2978
+ ]);
2979
+ })
2980
+ .then(() => this.discordBridge.startStaleSweep())
2981
+ .catch(() => undefined);
2768
2982
  // Instance-scoped reaper: after restore, `docker rm -f` managed containers of
2769
2983
  // THIS instance whose case is gone from docker-cases.json (best-effort, never
2770
2984
  // touches another instance's containers). Runs after restore so containers
@@ -3141,7 +3355,7 @@ export class WebServer extends EventEmitter {
3141
3355
  // bookkeeping survives a restart and the values are re-derived here.
3142
3356
  const savedCustomModel = saved.__customModel;
3143
3357
  if (savedCustomModel) {
3144
- session.setCustomModel(savedCustomModel, await this._rebuildCustomModelEnv(session, savedCustomModel));
3358
+ await this._restoreCustomModel(session, savedCustomModel);
3145
3359
  }
3146
3360
  if (saved.niceEnabled !== undefined || saved.niceValue !== undefined) {
3147
3361
  session.setNice({ enabled: saved.niceEnabled, niceValue: saved.niceValue });
@@ -3279,6 +3493,9 @@ export class WebServer extends EventEmitter {
3279
3493
  catch (err) {
3280
3494
  console.warn(`[Server] releasing the tab layout slot failed: ${getErrorMessage(err)}`);
3281
3495
  }
3496
+ // Per-tab bridge state only: a reboot-restore rebuild reuses the old id and stays on offer for a
3497
+ // retry, so its thread must survive the discard.
3498
+ this.discordBridge.forgetTab(sessionId);
3282
3499
  // Any `session:updated` the half-built session emitted before it failed left a
3283
3500
  // tab on every other open board, and the client's handler is an upsert.
3284
3501
  this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
@@ -3437,7 +3654,7 @@ export class WebServer extends EventEmitter {
3437
3654
  // the isolated config dir; an endpoint that has since been deleted still gets
3438
3655
  // the bookkeeping restored, which is what a later clear needs to unset.
3439
3656
  if (savedCustomModel) {
3440
- session.setCustomModel(savedCustomModel, await this._rebuildCustomModelEnv(session, savedCustomModel));
3657
+ await this._restoreCustomModel(session, savedCustomModel);
3441
3658
  }
3442
3659
  // Update session name if it was a "Restored:" placeholder or doesn't match saved name
3443
3660
  if (savedState?.name && muxSession.name !== savedState.name) {
@@ -3806,6 +4023,8 @@ export class WebServer extends EventEmitter {
3806
4023
  // Same teardown rule: the per-endpoint llama-swap log tails are otherwise closed
3807
4024
  // only by the periodic idle sweep, whose interval is disposed just below.
3808
4025
  closeAllLlamaSwapLogTails();
4026
+ // Close the Discord gateway socket and its heartbeat, and clear the bridge's per-tab maps.
4027
+ this.discordBridge.stop();
3809
4028
  // Dispose all managed timers (intervals + resettable timeouts)
3810
4029
  this.cleanup.dispose();
3811
4030
  // Gracefully close all SSE connections and clear batching state
@@ -3915,6 +4134,9 @@ export class WebServer extends EventEmitter {
3915
4134
  this.respawnTimers.clear();
3916
4135
  this.runSummaryTrackers.clear();
3917
4136
  this.transcriptWatchers.clear();
4137
+ await this.editHistorySync.flushAll().catch((err) => {
4138
+ console.warn('[EditHistory] Saving on shutdown failed:', getErrorMessage(err));
4139
+ });
3918
4140
  this.sessionListenerRefs.clear();
3919
4141
  this.scheduledRuns.clear();
3920
4142
  // Dispose StaleExpirationMaps (stops internal cleanup timers)