aicodeman 0.2.9 → 0.3.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 (347) hide show
  1. package/README.md +91 -0
  2. package/dist/ai-idle-checker.d.ts.map +1 -1
  3. package/dist/ai-idle-checker.js +3 -2
  4. package/dist/ai-idle-checker.js.map +1 -1
  5. package/dist/ai-plan-checker.d.ts.map +1 -1
  6. package/dist/ai-plan-checker.js +3 -2
  7. package/dist/ai-plan-checker.js.map +1 -1
  8. package/dist/bash-tool-parser.d.ts +2 -3
  9. package/dist/bash-tool-parser.d.ts.map +1 -1
  10. package/dist/bash-tool-parser.js +14 -31
  11. package/dist/bash-tool-parser.js.map +1 -1
  12. package/dist/config/ai-defaults.d.ts +16 -0
  13. package/dist/config/ai-defaults.d.ts.map +1 -0
  14. package/dist/config/ai-defaults.js +16 -0
  15. package/dist/config/ai-defaults.js.map +1 -0
  16. package/dist/config/auth-config.d.ts +19 -0
  17. package/dist/config/auth-config.d.ts.map +1 -0
  18. package/dist/config/auth-config.js +28 -0
  19. package/dist/config/auth-config.js.map +1 -0
  20. package/dist/config/exec-timeout.d.ts +10 -0
  21. package/dist/config/exec-timeout.d.ts.map +1 -0
  22. package/dist/config/exec-timeout.js +10 -0
  23. package/dist/config/exec-timeout.js.map +1 -0
  24. package/dist/config/map-limits.d.ts +4 -0
  25. package/dist/config/map-limits.d.ts.map +1 -1
  26. package/dist/config/map-limits.js +7 -0
  27. package/dist/config/map-limits.js.map +1 -1
  28. package/dist/config/server-timing.d.ts +36 -0
  29. package/dist/config/server-timing.d.ts.map +1 -0
  30. package/dist/config/server-timing.js +51 -0
  31. package/dist/config/server-timing.js.map +1 -0
  32. package/dist/config/team-config.d.ts +16 -0
  33. package/dist/config/team-config.d.ts.map +1 -0
  34. package/dist/config/team-config.js +16 -0
  35. package/dist/config/team-config.js.map +1 -0
  36. package/dist/config/terminal-limits.d.ts +18 -0
  37. package/dist/config/terminal-limits.d.ts.map +1 -0
  38. package/dist/config/terminal-limits.js +18 -0
  39. package/dist/config/terminal-limits.js.map +1 -0
  40. package/dist/config/tunnel-config.d.ts +27 -0
  41. package/dist/config/tunnel-config.d.ts.map +1 -0
  42. package/dist/config/tunnel-config.js +36 -0
  43. package/dist/config/tunnel-config.js.map +1 -0
  44. package/dist/hooks-config.d.ts.map +1 -1
  45. package/dist/hooks-config.js +7 -6
  46. package/dist/hooks-config.js.map +1 -1
  47. package/dist/image-watcher.d.ts +4 -4
  48. package/dist/image-watcher.d.ts.map +1 -1
  49. package/dist/image-watcher.js +17 -30
  50. package/dist/image-watcher.js.map +1 -1
  51. package/dist/index.js +1 -2
  52. package/dist/index.js.map +1 -1
  53. package/dist/plan-orchestrator.d.ts +2 -24
  54. package/dist/plan-orchestrator.d.ts.map +1 -1
  55. package/dist/plan-orchestrator.js.map +1 -1
  56. package/dist/push-store.d.ts +1 -1
  57. package/dist/push-store.d.ts.map +1 -1
  58. package/dist/push-store.js +4 -12
  59. package/dist/push-store.js.map +1 -1
  60. package/dist/ralph-fix-plan-watcher.d.ts +91 -0
  61. package/dist/ralph-fix-plan-watcher.d.ts.map +1 -0
  62. package/dist/ralph-fix-plan-watcher.js +326 -0
  63. package/dist/ralph-fix-plan-watcher.js.map +1 -0
  64. package/dist/ralph-plan-tracker.d.ts +201 -0
  65. package/dist/ralph-plan-tracker.d.ts.map +1 -0
  66. package/dist/ralph-plan-tracker.js +325 -0
  67. package/dist/ralph-plan-tracker.js.map +1 -0
  68. package/dist/ralph-stall-detector.d.ts +84 -0
  69. package/dist/ralph-stall-detector.d.ts.map +1 -0
  70. package/dist/ralph-stall-detector.js +139 -0
  71. package/dist/ralph-stall-detector.js.map +1 -0
  72. package/dist/ralph-status-parser.d.ts +141 -0
  73. package/dist/ralph-status-parser.d.ts.map +1 -0
  74. package/dist/ralph-status-parser.js +478 -0
  75. package/dist/ralph-status-parser.js.map +1 -0
  76. package/dist/ralph-tracker.d.ts +194 -685
  77. package/dist/ralph-tracker.d.ts.map +1 -1
  78. package/dist/ralph-tracker.js +349 -1713
  79. package/dist/ralph-tracker.js.map +1 -1
  80. package/dist/respawn-adaptive-timing.d.ts +61 -0
  81. package/dist/respawn-adaptive-timing.d.ts.map +1 -0
  82. package/dist/respawn-adaptive-timing.js +105 -0
  83. package/dist/respawn-adaptive-timing.js.map +1 -0
  84. package/dist/respawn-controller.d.ts +12 -101
  85. package/dist/respawn-controller.d.ts.map +1 -1
  86. package/dist/respawn-controller.js +144 -593
  87. package/dist/respawn-controller.js.map +1 -1
  88. package/dist/respawn-health.d.ts +54 -0
  89. package/dist/respawn-health.d.ts.map +1 -0
  90. package/dist/respawn-health.js +183 -0
  91. package/dist/respawn-health.js.map +1 -0
  92. package/dist/respawn-metrics.d.ts +81 -0
  93. package/dist/respawn-metrics.d.ts.map +1 -0
  94. package/dist/respawn-metrics.js +198 -0
  95. package/dist/respawn-metrics.js.map +1 -0
  96. package/dist/respawn-patterns.d.ts +45 -0
  97. package/dist/respawn-patterns.d.ts.map +1 -0
  98. package/dist/respawn-patterns.js +125 -0
  99. package/dist/respawn-patterns.js.map +1 -0
  100. package/dist/session-auto-ops.d.ts +89 -0
  101. package/dist/session-auto-ops.d.ts.map +1 -0
  102. package/dist/session-auto-ops.js +224 -0
  103. package/dist/session-auto-ops.js.map +1 -0
  104. package/dist/session-cli-builder.d.ts +62 -0
  105. package/dist/session-cli-builder.d.ts.map +1 -0
  106. package/dist/session-cli-builder.js +121 -0
  107. package/dist/session-cli-builder.js.map +1 -0
  108. package/dist/session-task-cache.d.ts +52 -0
  109. package/dist/session-task-cache.d.ts.map +1 -0
  110. package/dist/session-task-cache.js +90 -0
  111. package/dist/session-task-cache.js.map +1 -0
  112. package/dist/session.d.ts +2 -33
  113. package/dist/session.d.ts.map +1 -1
  114. package/dist/session.js +58 -309
  115. package/dist/session.js.map +1 -1
  116. package/dist/state-store.d.ts +2 -2
  117. package/dist/state-store.d.ts.map +1 -1
  118. package/dist/state-store.js +12 -23
  119. package/dist/state-store.js.map +1 -1
  120. package/dist/subagent-watcher.d.ts +3 -4
  121. package/dist/subagent-watcher.d.ts.map +1 -1
  122. package/dist/subagent-watcher.js +24 -61
  123. package/dist/subagent-watcher.js.map +1 -1
  124. package/dist/team-watcher.d.ts.map +1 -1
  125. package/dist/team-watcher.js +2 -5
  126. package/dist/team-watcher.js.map +1 -1
  127. package/dist/tmux-manager.d.ts.map +1 -1
  128. package/dist/tmux-manager.js +1 -2
  129. package/dist/tmux-manager.js.map +1 -1
  130. package/dist/tunnel-manager.d.ts +26 -0
  131. package/dist/tunnel-manager.d.ts.map +1 -1
  132. package/dist/tunnel-manager.js +127 -7
  133. package/dist/tunnel-manager.js.map +1 -1
  134. package/dist/types/api.d.ts +93 -0
  135. package/dist/types/api.d.ts.map +1 -0
  136. package/dist/types/api.js +83 -0
  137. package/dist/types/api.js.map +1 -0
  138. package/dist/types/app-state.d.ts +100 -0
  139. package/dist/types/app-state.d.ts.map +1 -0
  140. package/dist/types/app-state.js +59 -0
  141. package/dist/types/app-state.js.map +1 -0
  142. package/dist/types/common.d.ts +70 -0
  143. package/dist/types/common.d.ts.map +1 -0
  144. package/dist/types/common.js +8 -0
  145. package/dist/types/common.js.map +1 -0
  146. package/dist/types/index.d.ts +18 -0
  147. package/dist/types/index.d.ts.map +1 -0
  148. package/dist/types/index.js +18 -0
  149. package/dist/types/index.js.map +1 -0
  150. package/dist/types/lifecycle.d.ts +17 -0
  151. package/dist/types/lifecycle.d.ts.map +1 -0
  152. package/dist/types/lifecycle.js +5 -0
  153. package/dist/types/lifecycle.js.map +1 -0
  154. package/dist/types/plan.d.ts +32 -0
  155. package/dist/types/plan.d.ts.map +1 -0
  156. package/dist/types/plan.js +5 -0
  157. package/dist/types/plan.js.map +1 -0
  158. package/dist/types/push.d.ts +23 -0
  159. package/dist/types/push.d.ts.map +1 -0
  160. package/dist/types/push.js +5 -0
  161. package/dist/types/push.js.map +1 -0
  162. package/dist/types/ralph.d.ts +241 -0
  163. package/dist/types/ralph.d.ts.map +1 -0
  164. package/dist/types/ralph.js +49 -0
  165. package/dist/types/ralph.js.map +1 -0
  166. package/dist/types/respawn.d.ts +250 -0
  167. package/dist/types/respawn.d.ts.map +1 -0
  168. package/dist/types/respawn.js +5 -0
  169. package/dist/types/respawn.js.map +1 -0
  170. package/dist/types/run-summary.d.ts +81 -0
  171. package/dist/types/run-summary.d.ts.map +1 -0
  172. package/dist/types/run-summary.js +22 -0
  173. package/dist/types/run-summary.js.map +1 -0
  174. package/dist/types/session.d.ts +130 -0
  175. package/dist/types/session.d.ts.map +1 -0
  176. package/dist/types/session.js +5 -0
  177. package/dist/types/session.js.map +1 -0
  178. package/dist/types/task.d.ts +58 -0
  179. package/dist/types/task.d.ts.map +1 -0
  180. package/dist/types/task.js +5 -0
  181. package/dist/types/task.js.map +1 -0
  182. package/dist/types/teams.d.ts +55 -0
  183. package/dist/types/teams.d.ts.map +1 -0
  184. package/dist/types/teams.js +5 -0
  185. package/dist/types/teams.js.map +1 -0
  186. package/dist/types/tools.d.ts +46 -0
  187. package/dist/types/tools.d.ts.map +1 -0
  188. package/dist/types/tools.js +5 -0
  189. package/dist/types/tools.js.map +1 -0
  190. package/dist/types.d.ts +1 -1138
  191. package/dist/types.d.ts.map +1 -1
  192. package/dist/types.js +1 -214
  193. package/dist/types.js.map +1 -1
  194. package/dist/utils/claude-cli-resolver.d.ts.map +1 -1
  195. package/dist/utils/claude-cli-resolver.js +1 -2
  196. package/dist/utils/claude-cli-resolver.js.map +1 -1
  197. package/dist/utils/debouncer.d.ts +111 -0
  198. package/dist/utils/debouncer.d.ts.map +1 -0
  199. package/dist/utils/debouncer.js +162 -0
  200. package/dist/utils/debouncer.js.map +1 -0
  201. package/dist/utils/index.d.ts +3 -2
  202. package/dist/utils/index.d.ts.map +1 -1
  203. package/dist/utils/index.js +3 -2
  204. package/dist/utils/index.js.map +1 -1
  205. package/dist/utils/opencode-cli-resolver.d.ts.map +1 -1
  206. package/dist/utils/opencode-cli-resolver.js +1 -2
  207. package/dist/utils/opencode-cli-resolver.js.map +1 -1
  208. package/dist/utils/string-similarity.d.ts +0 -57
  209. package/dist/utils/string-similarity.d.ts.map +1 -1
  210. package/dist/utils/string-similarity.js +3 -18
  211. package/dist/utils/string-similarity.js.map +1 -1
  212. package/dist/web/middleware/auth.d.ts +31 -0
  213. package/dist/web/middleware/auth.d.ts.map +1 -0
  214. package/dist/web/middleware/auth.js +154 -0
  215. package/dist/web/middleware/auth.js.map +1 -0
  216. package/dist/web/ports/auth-port.d.ts +18 -0
  217. package/dist/web/ports/auth-port.d.ts.map +1 -0
  218. package/dist/web/ports/auth-port.js +6 -0
  219. package/dist/web/ports/auth-port.js.map +1 -0
  220. package/dist/web/ports/config-port.d.ts +28 -0
  221. package/dist/web/ports/config-port.d.ts.map +1 -0
  222. package/dist/web/ports/config-port.js +6 -0
  223. package/dist/web/ports/config-port.js.map +1 -0
  224. package/dist/web/ports/event-port.d.ts +13 -0
  225. package/dist/web/ports/event-port.d.ts.map +1 -0
  226. package/dist/web/ports/event-port.js +6 -0
  227. package/dist/web/ports/event-port.js.map +1 -0
  228. package/dist/web/ports/index.d.ts +14 -0
  229. package/dist/web/ports/index.d.ts.map +1 -0
  230. package/dist/web/ports/index.js +9 -0
  231. package/dist/web/ports/index.js.map +1 -0
  232. package/dist/web/ports/infra-port.d.ts +36 -0
  233. package/dist/web/ports/infra-port.d.ts.map +1 -0
  234. package/dist/web/ports/infra-port.js +6 -0
  235. package/dist/web/ports/infra-port.js.map +1 -0
  236. package/dist/web/ports/respawn-port.d.ts +20 -0
  237. package/dist/web/ports/respawn-port.d.ts.map +1 -0
  238. package/dist/web/ports/respawn-port.js +6 -0
  239. package/dist/web/ports/respawn-port.js.map +1 -0
  240. package/dist/web/ports/session-port.d.ts +15 -0
  241. package/dist/web/ports/session-port.d.ts.map +1 -0
  242. package/dist/web/ports/session-port.js +6 -0
  243. package/dist/web/ports/session-port.js.map +1 -0
  244. package/dist/web/public/api-client.js +70 -0
  245. package/dist/web/public/api-client.js.br +0 -0
  246. package/dist/web/public/api-client.js.gz +0 -0
  247. package/dist/web/public/app.js +151 -235
  248. package/dist/web/public/app.js.br +0 -0
  249. package/dist/web/public/app.js.gz +0 -0
  250. package/dist/web/public/constants.js +238 -0
  251. package/dist/web/public/constants.js.br +0 -0
  252. package/dist/web/public/constants.js.gz +0 -0
  253. package/dist/web/public/index.html +11 -3
  254. package/dist/web/public/index.html.br +0 -0
  255. package/dist/web/public/index.html.gz +0 -0
  256. package/dist/web/public/keyboard-accessory.js +279 -0
  257. package/dist/web/public/keyboard-accessory.js.br +0 -0
  258. package/dist/web/public/keyboard-accessory.js.gz +0 -0
  259. package/dist/web/public/mobile-handlers.js +467 -0
  260. package/dist/web/public/mobile-handlers.js.br +0 -0
  261. package/dist/web/public/mobile-handlers.js.gz +0 -0
  262. package/dist/web/public/mobile.css.gz +0 -0
  263. package/dist/web/public/notification-manager.js +445 -0
  264. package/dist/web/public/notification-manager.js.br +0 -0
  265. package/dist/web/public/notification-manager.js.gz +0 -0
  266. package/dist/web/public/ralph-wizard.js +3 -3
  267. package/dist/web/public/ralph-wizard.js.br +0 -0
  268. package/dist/web/public/ralph-wizard.js.gz +0 -0
  269. package/dist/web/public/styles.css.gz +0 -0
  270. package/dist/web/public/subagent-windows.js +1115 -0
  271. package/dist/web/public/subagent-windows.js.br +0 -0
  272. package/dist/web/public/subagent-windows.js.gz +0 -0
  273. package/dist/web/public/sw.js.gz +0 -0
  274. package/dist/web/public/upload.html.gz +0 -0
  275. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  276. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  277. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  278. package/dist/web/public/vendor/xterm.css.gz +0 -0
  279. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  280. package/dist/web/public/voice-input.js +858 -0
  281. package/dist/web/public/voice-input.js.br +0 -0
  282. package/dist/web/public/voice-input.js.gz +0 -0
  283. package/dist/web/route-helpers.d.ts +38 -0
  284. package/dist/web/route-helpers.d.ts.map +1 -0
  285. package/dist/web/route-helpers.js +143 -0
  286. package/dist/web/route-helpers.js.map +1 -0
  287. package/dist/web/routes/case-routes.d.ts +9 -0
  288. package/dist/web/routes/case-routes.d.ts.map +1 -0
  289. package/dist/web/routes/case-routes.js +419 -0
  290. package/dist/web/routes/case-routes.js.map +1 -0
  291. package/dist/web/routes/file-routes.d.ts +8 -0
  292. package/dist/web/routes/file-routes.d.ts.map +1 -0
  293. package/dist/web/routes/file-routes.js +337 -0
  294. package/dist/web/routes/file-routes.js.map +1 -0
  295. package/dist/web/routes/hook-event-routes.d.ts +9 -0
  296. package/dist/web/routes/hook-event-routes.d.ts.map +1 -0
  297. package/dist/web/routes/hook-event-routes.js +57 -0
  298. package/dist/web/routes/hook-event-routes.js.map +1 -0
  299. package/dist/web/routes/index.d.ts +16 -0
  300. package/dist/web/routes/index.d.ts.map +1 -0
  301. package/dist/web/routes/index.js +16 -0
  302. package/dist/web/routes/index.js.map +1 -0
  303. package/dist/web/routes/mux-routes.d.ts +8 -0
  304. package/dist/web/routes/mux-routes.d.ts.map +1 -0
  305. package/dist/web/routes/mux-routes.js +32 -0
  306. package/dist/web/routes/mux-routes.js.map +1 -0
  307. package/dist/web/routes/plan-routes.d.ts +9 -0
  308. package/dist/web/routes/plan-routes.d.ts.map +1 -0
  309. package/dist/web/routes/plan-routes.js +381 -0
  310. package/dist/web/routes/plan-routes.js.map +1 -0
  311. package/dist/web/routes/push-routes.d.ts +8 -0
  312. package/dist/web/routes/push-routes.d.ts.map +1 -0
  313. package/dist/web/routes/push-routes.js +49 -0
  314. package/dist/web/routes/push-routes.js.map +1 -0
  315. package/dist/web/routes/ralph-routes.d.ts +9 -0
  316. package/dist/web/routes/ralph-routes.d.ts.map +1 -0
  317. package/dist/web/routes/ralph-routes.js +475 -0
  318. package/dist/web/routes/ralph-routes.js.map +1 -0
  319. package/dist/web/routes/respawn-routes.d.ts +8 -0
  320. package/dist/web/routes/respawn-routes.d.ts.map +1 -0
  321. package/dist/web/routes/respawn-routes.js +260 -0
  322. package/dist/web/routes/respawn-routes.js.map +1 -0
  323. package/dist/web/routes/scheduled-routes.d.ts +8 -0
  324. package/dist/web/routes/scheduled-routes.d.ts.map +1 -0
  325. package/dist/web/routes/scheduled-routes.js +51 -0
  326. package/dist/web/routes/scheduled-routes.js.map +1 -0
  327. package/dist/web/routes/session-routes.d.ts +9 -0
  328. package/dist/web/routes/session-routes.d.ts.map +1 -0
  329. package/dist/web/routes/session-routes.js +729 -0
  330. package/dist/web/routes/session-routes.js.map +1 -0
  331. package/dist/web/routes/system-routes.d.ts +9 -0
  332. package/dist/web/routes/system-routes.d.ts.map +1 -0
  333. package/dist/web/routes/system-routes.js +678 -0
  334. package/dist/web/routes/system-routes.js.map +1 -0
  335. package/dist/web/routes/team-routes.d.ts +8 -0
  336. package/dist/web/routes/team-routes.d.ts.map +1 -0
  337. package/dist/web/routes/team-routes.js +14 -0
  338. package/dist/web/routes/team-routes.js.map +1 -0
  339. package/dist/web/schemas.d.ts +43 -3
  340. package/dist/web/schemas.d.ts.map +1 -1
  341. package/dist/web/schemas.js +6 -2
  342. package/dist/web/schemas.js.map +1 -1
  343. package/dist/web/server.d.ts +10 -9
  344. package/dist/web/server.d.ts.map +1 -1
  345. package/dist/web/server.js +335 -3824
  346. package/dist/web/server.js.map +1 -1
  347. package/package.json +1 -1
@@ -13,22 +13,19 @@ import Fastify from 'fastify';
13
13
  import fastifyCompress from '@fastify/compress';
14
14
  import fastifyCookie from '@fastify/cookie';
15
15
  import fastifyStatic from '@fastify/static';
16
- import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
16
+ import { join, dirname } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
- import { existsSync, statSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, chmodSync, realpathSync, } from 'node:fs';
18
+ import { existsSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
19
19
  import fs from 'node:fs/promises';
20
20
  import { execSync } from 'node:child_process';
21
- import { randomBytes, timingSafeEqual } from 'node:crypto';
22
- import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
21
+ import { homedir } from 'node:os';
23
22
  import { EventEmitter } from 'node:events';
24
23
  import { Session, } from '../session.js';
25
- import { fileStreamManager } from '../file-stream-manager.js';
26
24
  import { RespawnController } from '../respawn-controller.js';
27
25
  import { createMultiplexer } from '../mux-factory.js';
28
26
  import { getStore } from '../state-store.js';
29
- import { generateClaudeMd } from '../templates/claude-md.js';
30
- import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
31
- import { writeHooksConfig, updateCaseEnvVars } from '../hooks-config.js';
27
+ import { extractCompletionPhrase } from '../ralph-config.js';
28
+ import { fileStreamManager } from '../file-stream-manager.js';
32
29
  import { subagentWatcher, } from '../subagent-watcher.js';
33
30
  import { imageWatcher } from '../image-watcher.js';
34
31
  import { TranscriptWatcher } from '../transcript-watcher.js';
@@ -37,7 +34,6 @@ import { TunnelManager } from '../tunnel-manager.js';
37
34
  import { v4 as uuidv4 } from 'uuid';
38
35
  import { createRequire } from 'node:module';
39
36
  import { RunSummaryTracker } from '../run-summary.js';
40
- import { PlanOrchestrator } from '../plan-orchestrator.js';
41
37
  import { getLifecycleLog } from '../session-lifecycle-log.js';
42
38
  import { PushSubscriptionStore } from '../push-store.js';
43
39
  import webpush from 'web-push';
@@ -45,181 +41,18 @@ import webpush from 'web-push';
45
41
  const require = createRequire(import.meta.url);
46
42
  const { version: APP_VERSION } = require('../../package.json');
47
43
  import { getErrorMessage, ApiErrorCode, createErrorResponse, DEFAULT_NICE_CONFIG, } from '../types.js';
48
- import { CreateSessionSchema, RunPromptSchema, SessionInputWithLimitSchema, ResizeSchema, CreateCaseSchema, QuickStartSchema, HookEventSchema, ConfigUpdateSchema, RespawnConfigSchema, SessionNameSchema, SessionColorSchema, RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, AutoClearSchema, AutoCompactSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, ScheduledRunSchema, LinkCaseSchema, GeneratePlanSchema, GeneratePlanDetailedSchema, CancelPlanSchema, PlanTaskUpdateSchema, PlanTaskAddSchema, CpuLimitSchema, SettingsUpdateSchema, ModelConfigUpdateSchema, SubagentWindowStatesSchema, SubagentParentMapSchema, InteractiveRespawnSchema, RespawnEnableSchema, PushSubscribeSchema, PushPreferencesUpdateSchema, RalphLoopStartSchema, isValidWorkingDir, } from './schemas.js';
49
- import { StaleExpirationMap } from '../utils/index.js';
44
+ import { CleanupManager, KeyedDebouncer, StaleExpirationMap } from '../utils/index.js';
50
45
  import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
46
+ import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
47
+ import { registerPushRoutes, registerTeamRoutes, registerMuxRoutes, registerFileRoutes, registerScheduledRoutes, registerHookEventRoutes, registerSystemRoutes, registerCaseRoutes, registerSessionRoutes, registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, } from './routes/index.js';
51
48
  const __dirname = dirname(fileURLToPath(import.meta.url));
52
- // Batch terminal data for performance - collect for 16ms (60fps) before sending
53
- const TERMINAL_BATCH_INTERVAL = 16;
54
- // Batch task:updated events for 100ms
55
- const TASK_UPDATE_BATCH_INTERVAL = 100;
49
+ import { TERMINAL_BATCH_INTERVAL, TASK_UPDATE_BATCH_INTERVAL, STATE_UPDATE_DEBOUNCE_INTERVAL, SESSIONS_LIST_CACHE_TTL, SCHEDULED_CLEANUP_INTERVAL, SCHEDULED_RUN_MAX_AGE, SSE_HEALTH_CHECK_INTERVAL, SESSION_LIMIT_WAIT_MS, ITERATION_PAUSE_MS, BATCH_FLUSH_THRESHOLD, STATS_COLLECTION_INTERVAL_MS, } from '../config/server-timing.js';
56
50
  // DEC mode 2026 - Synchronized Output
57
51
  // When terminal supports this, it buffers all output between start/end markers
58
52
  // and renders atomically, eliminating partial-frame flicker from Ink redraws.
59
53
  // Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
60
54
  const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update
61
55
  const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update (flush to screen)
62
- // State update debounce interval (batch expensive toDetailedState() calls)
63
- const STATE_UPDATE_DEBOUNCE_INTERVAL = 500;
64
- // Cache TTL for getLightSessionsState() — avoids re-serializing all sessions on every SSE init / /api/sessions call
65
- const SESSIONS_LIST_CACHE_TTL = 1000;
66
- // Scheduled runs cleanup interval (check every 5 minutes)
67
- const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000;
68
- // Completed scheduled runs max age (1 hour)
69
- const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000;
70
- // SSE client health check interval (every 30 seconds)
71
- const SSE_HEALTH_CHECK_INTERVAL = 30 * 1000;
72
- // Maximum allowed input length for session write (64KB)
73
- const MAX_INPUT_LENGTH = 64 * 1024;
74
- // Maximum terminal resize dimensions
75
- const MAX_TERMINAL_COLS = 500;
76
- const MAX_TERMINAL_ROWS = 200;
77
- // Maximum session name length
78
- const MAX_SESSION_NAME_LENGTH = 128;
79
- // Maximum hook data size (prevents oversized SSE broadcasts)
80
- const MAX_HOOK_DATA_SIZE = 8 * 1024;
81
- // Maximum screenshot upload size (10MB)
82
- const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
83
- // Auth session cookie TTL (24h — matches autonomous run length)
84
- const AUTH_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
85
- // Auth session cookie name
86
- const AUTH_COOKIE_NAME = 'codeman_session';
87
- // Max concurrent auth sessions
88
- const MAX_AUTH_SESSIONS = 100;
89
- // Max failed auth attempts per IP before rate-limiting
90
- const AUTH_FAILURE_MAX = 10;
91
- // Failed auth attempt tracking window (15 minutes)
92
- const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000;
93
- // Screenshots directory
94
- const SCREENSHOTS_DIR = join(homedir(), '.codeman', 'screenshots');
95
- // Stats collection interval (2 seconds)
96
- const STATS_COLLECTION_INTERVAL_MS = 2000;
97
- // Session limit wait time before retrying (5 seconds)
98
- const SESSION_LIMIT_WAIT_MS = 5000;
99
- // Pause between scheduled run iterations (2 seconds)
100
- const ITERATION_PAUSE_MS = 2000;
101
- // Terminal batch flush threshold - flush immediately if batch exceeds this size
102
- // Set high (32KB) to allow effective batching; avg Ink events are ~14KB
103
- const BATCH_FLUSH_THRESHOLD = 32 * 1024;
104
- // Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
105
- // eslint-disable-next-line no-control-regex
106
- const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
107
- // eslint-disable-next-line no-control-regex
108
- const CTRL_L_PATTERN = /\x0c/g;
109
- const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
110
- /**
111
- * Formats uptime in seconds to a human-readable string.
112
- */
113
- function formatUptime(seconds) {
114
- const days = Math.floor(seconds / 86400);
115
- const hours = Math.floor((seconds % 86400) / 3600);
116
- const minutes = Math.floor((seconds % 3600) / 60);
117
- const secs = Math.floor(seconds % 60);
118
- const parts = [];
119
- if (days > 0)
120
- parts.push(`${days}d`);
121
- if (hours > 0)
122
- parts.push(`${hours}h`);
123
- if (minutes > 0)
124
- parts.push(`${minutes}m`);
125
- if (secs > 0 || parts.length === 0)
126
- parts.push(`${secs}s`);
127
- return parts.join(' ');
128
- }
129
- /**
130
- * Sanitizes hook event data before broadcasting via SSE.
131
- * Extracts only relevant fields and limits total size to prevent
132
- * oversized payloads from being broadcast to all connected clients.
133
- */
134
- function sanitizeHookData(data) {
135
- if (!data || typeof data !== 'object')
136
- return {};
137
- // Only forward known safe fields from Claude Code hook stdin
138
- const safeFields = {};
139
- const allowedKeys = [
140
- 'hook_event_name',
141
- 'tool_name',
142
- 'tool_input',
143
- 'session_id',
144
- 'cwd',
145
- 'permission_mode',
146
- 'stop_hook_active',
147
- 'transcript_path',
148
- ];
149
- for (const key of allowedKeys) {
150
- if (key in data && data[key] !== undefined) {
151
- safeFields[key] = data[key];
152
- }
153
- }
154
- // For tool_input, extract only summary fields (not full file content)
155
- if (safeFields.tool_input && typeof safeFields.tool_input === 'object') {
156
- const input = safeFields.tool_input;
157
- const summary = {};
158
- if (input.command)
159
- summary.command = String(input.command).slice(0, 500);
160
- if (input.file_path)
161
- summary.file_path = String(input.file_path).slice(0, 500);
162
- if (input.description)
163
- summary.description = String(input.description).slice(0, 200);
164
- if (input.query)
165
- summary.query = String(input.query).slice(0, 200);
166
- if (input.url)
167
- summary.url = String(input.url).slice(0, 500);
168
- if (input.pattern)
169
- summary.pattern = String(input.pattern).slice(0, 200);
170
- if (input.prompt)
171
- summary.prompt = String(input.prompt).slice(0, 200);
172
- safeFields.tool_input = summary;
173
- }
174
- // Final size check - drop if serialized data exceeds limit
175
- const serialized = JSON.stringify(safeFields);
176
- if (serialized.length > MAX_HOOK_DATA_SIZE) {
177
- return { tool_name: safeFields.tool_name, _truncated: true };
178
- }
179
- return safeFields;
180
- }
181
- /**
182
- * Auto-configure Ralph tracker for a session.
183
- *
184
- * Priority order:
185
- * 1. .claude/ralph-loop.local.md (official Ralph Wiggum plugin state)
186
- * 2. CLAUDE.md <promise> tags (fallback)
187
- *
188
- * The ralph-loop.local.md file has priority because it contains
189
- * the exact configuration from an active Ralph loop session.
190
- */
191
- function autoConfigureRalph(session, workingDir, broadcast) {
192
- // First, try to read the official Ralph Wiggum plugin state file
193
- const ralphConfig = parseRalphLoopConfig(workingDir);
194
- if (ralphConfig && ralphConfig.completionPromise) {
195
- session.ralphTracker.enable();
196
- session.ralphTracker.startLoop(ralphConfig.completionPromise, ralphConfig.maxIterations ?? undefined);
197
- // Restore iteration count if available
198
- if (ralphConfig.iteration > 0) {
199
- // The tracker's cycleCount will be updated when we detect iteration patterns
200
- // in the terminal output, but we can set maxIterations now
201
- console.log(`[auto-detect] Ralph loop at iteration ${ralphConfig.iteration}/${ralphConfig.maxIterations ?? '∞'}`);
202
- }
203
- console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from ralph-loop.local.md: ${ralphConfig.completionPromise}`);
204
- broadcast('session:ralphLoopUpdate', {
205
- sessionId: session.id,
206
- state: session.ralphTracker.loopState,
207
- });
208
- return;
209
- }
210
- // Fallback: try CLAUDE.md
211
- const claudeMdPath = join(workingDir, 'CLAUDE.md');
212
- const completionPhrase = extractCompletionPhrase(claudeMdPath);
213
- if (completionPhrase) {
214
- session.ralphTracker.enable();
215
- session.ralphTracker.startLoop(completionPhrase);
216
- console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from CLAUDE.md: ${completionPhrase}`);
217
- broadcast('session:ralphLoopUpdate', {
218
- sessionId: session.id,
219
- state: session.ralphTracker.loopState,
220
- });
221
- }
222
- }
223
56
  /**
224
57
  * Get or generate a self-signed TLS certificate for HTTPS.
225
58
  * Certs are stored in ~/.codeman/certs/ and reused across restarts.
@@ -248,8 +81,6 @@ function getOrCreateSelfSignedCert() {
248
81
  };
249
82
  }
250
83
  export class WebServer extends EventEmitter {
251
- /** Cached CPU count — doesn't change at runtime */
252
- static CPU_COUNT = cpus().length;
253
84
  app;
254
85
  sessions = new Map();
255
86
  respawnControllers = new Map();
@@ -277,16 +108,14 @@ export class WebServer extends EventEmitter {
277
108
  ttlMs: 5 * 60 * 1000, // 5 minutes - auto-expire stale session timing data
278
109
  refreshOnGet: false, // Don't refresh on reads, only on explicit sets
279
110
  });
280
- // Scheduled runs cleanup timer
281
- scheduledCleanupTimer = null;
111
+ // Centralized cleanup for standalone timers (intervals + resettable timeouts)
112
+ cleanup = new CleanupManager();
282
113
  // SSE event batching
283
114
  taskUpdateBatches = new Map();
284
- taskUpdateBatchTimer = null;
115
+ taskUpdateBatchTimerId = null;
285
116
  // State update batching (reduce expensive toDetailedState() serialization)
286
117
  stateUpdatePending = new Set();
287
- stateUpdateTimer = null;
288
- // SSE client health check timer
289
- sseHealthCheckTimer = null;
118
+ stateUpdateTimerId = null;
290
119
  // Flag to prevent new timers during shutdown
291
120
  _isStopping = false;
292
121
  // Cached light state for SSE init (avoids rebuilding on every reconnect)
@@ -296,14 +125,13 @@ export class WebServer extends EventEmitter {
296
125
  cachedSessionsList = null;
297
126
  // Token recording for daily stats (track what's been recorded to avoid double-counting)
298
127
  lastRecordedTokens = new Map();
299
- tokenRecordingTimer = null;
300
128
  // Server startup time for respawn grace period calculation
301
129
  serverStartTime = Date.now();
302
130
  // Pending respawn start timers (for cleanup on shutdown)
303
131
  pendingRespawnStarts = new Map();
304
132
  // Active plan orchestrators (for cancellation via API)
305
133
  activePlanOrchestrators = new Map();
306
- persistDebounceTimers = new Map();
134
+ persistDeb = new KeyedDebouncer(100);
307
135
  // Grace period before starting restored respawn controllers (2 minutes)
308
136
  static RESPAWN_RESTORE_GRACE_PERIOD_MS = 2 * 60 * 1000;
309
137
  // Stored listener handlers for cleanup
@@ -312,6 +140,7 @@ export class WebServer extends EventEmitter {
312
140
  tunnelManager = new TunnelManager();
313
141
  authSessions = null;
314
142
  authFailures = null;
143
+ qrAuthFailures = null;
315
144
  pushStore = new PushSubscriptionStore();
316
145
  teamWatcher = new TeamWatcher();
317
146
  teamWatcherHandlers = null;
@@ -324,3562 +153,320 @@ export class WebServer extends EventEmitter {
324
153
  if (https) {
325
154
  const { key, cert } = getOrCreateSelfSignedCert();
326
155
  this.app = Fastify({ logger: false, https: { key, cert } });
327
- }
328
- else {
329
- this.app = Fastify({ logger: false });
330
- }
331
- this.mux = createMultiplexer();
332
- // Set up mux event listeners
333
- this.mux.on('sessionCreated', (session) => {
334
- this.broadcast('mux:created', session);
335
- });
336
- this.mux.on('sessionKilled', (data) => {
337
- this.broadcast('mux:killed', data);
338
- });
339
- this.mux.on('sessionDied', (data) => {
340
- getLifecycleLog().log({
341
- event: 'mux_died',
342
- sessionId: data.sessionId || 'unknown',
343
- extra: data,
344
- });
345
- this.broadcast('mux:died', data);
346
- });
347
- this.mux.on('statsUpdated', (sessions) => {
348
- this.broadcast('mux:statsUpdated', sessions);
349
- });
350
- // Set up subagent watcher listeners
351
- this.setupSubagentWatcherListeners();
352
- // Set up image watcher listeners
353
- this.setupImageWatcherListeners();
354
- // Set up team watcher listeners
355
- this.setupTeamWatcherListeners();
356
- // Set up tunnel manager listeners
357
- this.tunnelManager.on('started', (data) => {
358
- this.broadcast('tunnel:started', data);
359
- });
360
- this.tunnelManager.on('stopped', () => {
361
- this.broadcast('tunnel:stopped', {});
362
- });
363
- this.tunnelManager.on('error', (message) => {
364
- this.broadcast('tunnel:error', { message });
365
- });
366
- this.tunnelManager.on('progress', (data) => {
367
- this.broadcast('tunnel:progress', data);
368
- });
369
- }
370
- /**
371
- * Set up event listeners for subagent watcher.
372
- * Broadcasts real-time subagent activity to SSE clients.
373
- *
374
- * The SubagentWatcher now extracts descriptions directly from the parent session's
375
- * transcript, which contains the exact Task tool call with the description parameter.
376
- * This is more reliable than the previous timing-based correlation approach.
377
- */
378
- setupSubagentWatcherListeners() {
379
- // Store handlers for cleanup on shutdown
380
- this.subagentWatcherHandlers = {
381
- discovered: (info) => this.broadcast('subagent:discovered', info),
382
- updated: (info) => this.broadcast('subagent:updated', info),
383
- toolCall: (data) => this.broadcast('subagent:tool_call', data),
384
- toolResult: (data) => this.broadcast('subagent:tool_result', data),
385
- progress: (data) => this.broadcast('subagent:progress', data),
386
- message: (data) => this.broadcast('subagent:message', data),
387
- completed: (info) => this.broadcast('subagent:completed', info),
388
- error: (error, agentId) => {
389
- console.error(`[SubagentWatcher] Error${agentId ? ` for ${agentId}` : ''}:`, error.message);
390
- },
391
- };
392
- subagentWatcher.on('subagent:discovered', this.subagentWatcherHandlers.discovered);
393
- subagentWatcher.on('subagent:updated', this.subagentWatcherHandlers.updated);
394
- subagentWatcher.on('subagent:tool_call', this.subagentWatcherHandlers.toolCall);
395
- subagentWatcher.on('subagent:tool_result', this.subagentWatcherHandlers.toolResult);
396
- subagentWatcher.on('subagent:progress', this.subagentWatcherHandlers.progress);
397
- subagentWatcher.on('subagent:message', this.subagentWatcherHandlers.message);
398
- subagentWatcher.on('subagent:completed', this.subagentWatcherHandlers.completed);
399
- subagentWatcher.on('subagent:error', this.subagentWatcherHandlers.error);
400
- }
401
- /**
402
- * Clean up subagent watcher listeners to prevent memory leaks.
403
- */
404
- cleanupSubagentWatcherListeners() {
405
- if (this.subagentWatcherHandlers) {
406
- subagentWatcher.off('subagent:discovered', this.subagentWatcherHandlers.discovered);
407
- subagentWatcher.off('subagent:updated', this.subagentWatcherHandlers.updated);
408
- subagentWatcher.off('subagent:tool_call', this.subagentWatcherHandlers.toolCall);
409
- subagentWatcher.off('subagent:tool_result', this.subagentWatcherHandlers.toolResult);
410
- subagentWatcher.off('subagent:progress', this.subagentWatcherHandlers.progress);
411
- subagentWatcher.off('subagent:message', this.subagentWatcherHandlers.message);
412
- subagentWatcher.off('subagent:completed', this.subagentWatcherHandlers.completed);
413
- subagentWatcher.off('subagent:error', this.subagentWatcherHandlers.error);
414
- this.subagentWatcherHandlers = null;
415
- }
416
- }
417
- /**
418
- * Set up event listeners for image watcher.
419
- * Broadcasts image detection events to SSE clients for auto-popup.
420
- */
421
- setupImageWatcherListeners() {
422
- // Store handlers for cleanup on shutdown
423
- this.imageWatcherHandlers = {
424
- detected: (event) => this.broadcast('image:detected', event),
425
- error: (error, sessionId) => {
426
- console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
427
- },
428
- };
429
- imageWatcher.on('image:detected', this.imageWatcherHandlers.detected);
430
- imageWatcher.on('image:error', this.imageWatcherHandlers.error);
431
- }
432
- /**
433
- * Clean up image watcher listeners to prevent memory leaks.
434
- */
435
- cleanupImageWatcherListeners() {
436
- if (this.imageWatcherHandlers) {
437
- imageWatcher.off('image:detected', this.imageWatcherHandlers.detected);
438
- imageWatcher.off('image:error', this.imageWatcherHandlers.error);
439
- this.imageWatcherHandlers = null;
440
- }
441
- }
442
- /**
443
- * Set up event listeners for team watcher.
444
- * Broadcasts team activity events to SSE clients.
445
- */
446
- setupTeamWatcherListeners() {
447
- this.teamWatcherHandlers = {
448
- teamCreated: (config) => this.broadcast('team:created', config),
449
- teamUpdated: (config) => this.broadcast('team:updated', config),
450
- teamRemoved: (config) => this.broadcast('team:removed', config),
451
- taskUpdated: (data) => this.broadcast('team:taskUpdated', data),
452
- };
453
- this.teamWatcher.on('teamCreated', this.teamWatcherHandlers.teamCreated);
454
- this.teamWatcher.on('teamUpdated', this.teamWatcherHandlers.teamUpdated);
455
- this.teamWatcher.on('teamRemoved', this.teamWatcherHandlers.teamRemoved);
456
- this.teamWatcher.on('taskUpdated', this.teamWatcherHandlers.taskUpdated);
457
- }
458
- /**
459
- * Clean up team watcher listeners to prevent memory leaks.
460
- */
461
- cleanupTeamWatcherListeners() {
462
- if (this.teamWatcherHandlers) {
463
- this.teamWatcher.off('teamCreated', this.teamWatcherHandlers.teamCreated);
464
- this.teamWatcher.off('teamUpdated', this.teamWatcherHandlers.teamUpdated);
465
- this.teamWatcher.off('teamRemoved', this.teamWatcherHandlers.teamRemoved);
466
- this.teamWatcher.off('taskUpdated', this.teamWatcherHandlers.taskUpdated);
467
- this.teamWatcherHandlers = null;
468
- }
469
- }
470
- async setupRoutes() {
471
- // Allow multipart/form-data for screenshot uploads — skip Fastify's body parser
472
- // so the route handler can read the raw stream directly.
473
- this.app.addContentTypeParser('multipart/form-data', (_req, _payload, done) => {
474
- done(null);
475
- });
476
- // Enable gzip/brotli compression for all responses.
477
- // Massive win: 793KB uncompressed → ~120KB compressed for static assets.
478
- // Threshold 1024 = don't compress tiny responses (headers > savings).
479
- await this.app.register(fastifyCompress, {
480
- threshold: 1024,
481
- });
482
- // Cookie plugin (needed for auth session tokens)
483
- await this.app.register(fastifyCookie);
484
- // Optional HTTP Basic Auth with session cookies and rate limiting
485
- const authPassword = process.env.CODEMAN_PASSWORD;
486
- if (authPassword) {
487
- const authUsername = process.env.CODEMAN_USERNAME || 'admin';
488
- const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
489
- // Session token store — active sessions extend TTL on access
490
- this.authSessions = new StaleExpirationMap({
491
- ttlMs: AUTH_SESSION_TTL_MS,
492
- refreshOnGet: true,
493
- });
494
- // Failure counter per IP — decay naturally after 15 minutes
495
- this.authFailures = new StaleExpirationMap({
496
- ttlMs: AUTH_FAILURE_WINDOW_MS,
497
- refreshOnGet: false,
498
- });
499
- this.app.addHook('onRequest', (req, reply, done) => {
500
- // Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available.
501
- // Safe: validated by HookEventSchema, only triggers broadcasts.
502
- // Security: restrict bypass to localhost only — prevents forged hook events via tunnel/LAN.
503
- if (req.url === '/api/hook-event' && req.method === 'POST') {
504
- const ip = req.ip;
505
- if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') {
506
- done();
507
- return;
508
- }
509
- // Non-localhost hook requests fall through to normal auth
510
- }
511
- const clientIp = req.ip;
512
- // Rate limit: reject if too many failed attempts from this IP
513
- const failures = this.authFailures.get(clientIp) ?? 0;
514
- if (failures >= AUTH_FAILURE_MAX) {
515
- reply.code(429).send('Too Many Requests — try again later');
516
- return;
517
- }
518
- // Check session cookie first (avoids re-sending credentials on every request)
519
- // Use get() instead of has() so refreshOnGet extends the TTL on active sessions
520
- const sessionToken = req.cookies[AUTH_COOKIE_NAME];
521
- if (sessionToken && this.authSessions.get(sessionToken) !== undefined) {
522
- done();
523
- return;
524
- }
525
- // Check Basic Auth header (timing-safe comparison to prevent side-channel attacks)
526
- const auth = req.headers.authorization;
527
- const authBuf = Buffer.from(auth ?? '');
528
- const expectedBuf = Buffer.from(expectedHeader);
529
- if (authBuf.length === expectedBuf.length && timingSafeEqual(authBuf, expectedBuf)) {
530
- // Issue session token cookie so browser doesn't need to re-send credentials
531
- const token = randomBytes(32).toString('hex');
532
- // Evict oldest if at capacity (prevent unbounded growth)
533
- if (this.authSessions.size >= MAX_AUTH_SESSIONS) {
534
- const oldestKey = this.authSessions.keys().next().value;
535
- if (oldestKey !== undefined)
536
- this.authSessions.delete(oldestKey);
537
- }
538
- this.authSessions.set(token, clientIp);
539
- // Reset failure count on successful auth
540
- this.authFailures.delete(clientIp);
541
- reply.setCookie(AUTH_COOKIE_NAME, token, {
542
- httpOnly: true,
543
- secure: this.https,
544
- sameSite: 'lax',
545
- maxAge: AUTH_SESSION_TTL_MS / 1000, // seconds
546
- path: '/',
547
- });
548
- done();
549
- return;
550
- }
551
- // Auth failed — track failure count
552
- this.authFailures.set(clientIp, failures + 1);
553
- reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
554
- reply.code(401).send('Unauthorized');
555
- });
556
- }
557
- // Security headers + CORS on every response
558
- this.app.addHook('onRequest', (req, reply, done) => {
559
- reply.header('X-Content-Type-Options', 'nosniff');
560
- reply.header('X-Frame-Options', 'SAMEORIGIN');
561
- reply.header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; connect-src 'self' wss://api.deepgram.com; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'");
562
- if (this.https) {
563
- reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
564
- }
565
- // CORS: restrict to same-origin (localhost) only
566
- const origin = req.headers.origin;
567
- if (origin) {
568
- try {
569
- const url = new URL(origin);
570
- if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') {
571
- reply.header('Access-Control-Allow-Origin', origin);
572
- reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
573
- reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
574
- reply.header('Access-Control-Max-Age', '86400');
575
- }
576
- }
577
- catch {
578
- // Invalid origin header — do not set CORS headers
579
- }
580
- }
581
- // Handle CORS preflight
582
- if (req.method === 'OPTIONS') {
583
- reply.code(204).send();
584
- done();
585
- return;
586
- }
587
- done();
588
- });
589
- // Service worker must never be cached — browsers check for SW updates on navigation
590
- this.app.get('/sw.js', async (_req, reply) => {
591
- return reply
592
- .header('Cache-Control', 'no-cache, no-store')
593
- .header('Service-Worker-Allowed', '/')
594
- .type('application/javascript')
595
- .sendFile('sw.js', join(__dirname, 'public'));
596
- });
597
- // Serve static files — versioned assets (?v=X) are immutable, cache aggressively
598
- // preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression
599
- await this.app.register(fastifyStatic, {
600
- root: join(__dirname, 'public'),
601
- prefix: '/',
602
- maxAge: '1y',
603
- immutable: true,
604
- preCompressed: true,
605
- });
606
- // SSE endpoint for real-time updates
607
- this.app.get('/api/events', (req, reply) => {
608
- // Enforce SSE client limit to prevent memory exhaustion from too many connections
609
- if (this.sseClients.size >= MAX_SSE_CLIENTS) {
610
- reply.code(503).send('Too many SSE connections');
611
- return;
612
- }
613
- reply.raw.writeHead(200, {
614
- 'Content-Type': 'text/event-stream',
615
- 'Cache-Control': 'no-cache',
616
- Connection: 'keep-alive',
617
- 'X-Accel-Buffering': 'no', // Disable nginx buffering
618
- });
619
- this.sseClients.add(reply);
620
- // Send initial state
621
- // Use light state for SSE init to avoid sending 2MB+ terminal buffers
622
- // Buffers are fetched on-demand when switching tabs
623
- this.sendSSE(reply, 'init', this.getLightState());
624
- req.raw.on('close', () => {
625
- this.sseClients.delete(reply);
626
- this.backpressuredClients.delete(reply);
627
- });
628
- });
629
- // API Routes
630
- // Logout: invalidate session cookie
631
- this.app.post('/api/logout', async (req, reply) => {
632
- const sessionToken = req.cookies[AUTH_COOKIE_NAME];
633
- if (sessionToken && this.authSessions) {
634
- this.authSessions.delete(sessionToken);
635
- }
636
- reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
637
- return { success: true };
638
- });
639
- this.app.get('/api/status', async () => this.getLightState());
640
- this.app.get('/api/tunnel/status', async () => this.tunnelManager.getStatus());
641
- this.app.get('/api/tunnel/qr', async (_req, reply) => {
642
- const url = this.tunnelManager.getUrl();
643
- if (!url) {
644
- return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running'));
645
- }
646
- try {
647
- const QRCode = require('qrcode');
648
- const svg = await QRCode.toString(url, { type: 'svg', margin: 2, width: 256 });
649
- // Return as data URI to avoid Fastify compress issues with SVG content-type
650
- return { svg };
651
- }
652
- catch (err) {
653
- return reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)));
654
- }
655
- });
656
- // OpenCode CLI availability check
657
- this.app.get('/api/opencode/status', async () => {
658
- const { isOpenCodeAvailable, resolveOpenCodeDir } = await import('../utils/opencode-cli-resolver.js');
659
- return {
660
- available: isOpenCodeAvailable(),
661
- path: resolveOpenCodeDir(),
662
- };
663
- });
664
- // Cleanup stale sessions from state file
665
- this.app.post('/api/cleanup-state', async () => {
666
- const cleaned = this.cleanupStaleSessions();
667
- return { success: true, cleanedSessions: cleaned };
668
- });
669
- // Session lifecycle audit log
670
- this.app.get('/api/session-lifecycle', async (req) => {
671
- const query = req.query;
672
- const lifecycleLog = getLifecycleLog();
673
- const entries = await lifecycleLog.query({
674
- sessionId: query.sessionId,
675
- event: query.event,
676
- since: query.since ? Number(query.since) : undefined,
677
- limit: query.limit ? Math.min(Number(query.limit), 1000) : 200,
678
- });
679
- return { success: true, entries };
680
- });
681
- // Global stats endpoint
682
- this.app.get('/api/stats', async () => {
683
- const activeSessionTokens = {};
684
- for (const [sessionId, session] of this.sessions) {
685
- activeSessionTokens[sessionId] = {
686
- inputTokens: session.inputTokens,
687
- outputTokens: session.outputTokens,
688
- totalCost: session.totalCost,
689
- };
690
- }
691
- return {
692
- success: true,
693
- stats: this.store.getAggregateStats(activeSessionTokens),
694
- raw: this.store.getGlobalStats(),
695
- };
696
- });
697
- // Token stats with daily history
698
- this.app.get('/api/token-stats', async () => {
699
- // Get aggregate totals (global + active sessions)
700
- const activeSessionTokens = {};
701
- for (const [sessionId, session] of this.sessions) {
702
- activeSessionTokens[sessionId] = {
703
- inputTokens: session.inputTokens,
704
- outputTokens: session.outputTokens,
705
- totalCost: session.totalCost,
706
- };
707
- }
708
- return {
709
- success: true,
710
- daily: this.store.getDailyStats(30),
711
- totals: this.store.getAggregateStats(activeSessionTokens),
712
- };
713
- });
714
- this.app.get('/api/config', async () => {
715
- return { success: true, config: this.store.getConfig() };
716
- });
717
- this.app.put('/api/config', async (req) => {
718
- // Validate request body against schema to prevent arbitrary config injection
719
- const parseResult = ConfigUpdateSchema.safeParse(req.body);
720
- if (!parseResult.success) {
721
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid config: ${parseResult.error.message}`);
722
- }
723
- this.store.setConfig(parseResult.data);
724
- return { success: true, config: this.store.getConfig() };
725
- });
726
- // Debug/monitoring endpoint - lightweight, only runs when called
727
- // Returns comprehensive memory metrics for debugging memory leaks
728
- this.app.get('/api/debug/memory', async () => {
729
- const mem = process.memoryUsage();
730
- const subagentStats = subagentWatcher.getStats();
731
- // Calculate total Map entries for memory estimation
732
- const serverMapSizes = {
733
- sessions: this.sessions.size,
734
- sseClients: this.sseClients.size,
735
- respawnControllers: this.respawnControllers.size,
736
- runSummaryTrackers: this.runSummaryTrackers.size,
737
- transcriptWatchers: this.transcriptWatchers.size,
738
- scheduledRuns: this.scheduledRuns.size,
739
- terminalBatches: this.terminalBatches.size,
740
- taskUpdateBatches: this.taskUpdateBatches.size,
741
- stateUpdatePending: this.stateUpdatePending.size,
742
- lastRecordedTokens: this.lastRecordedTokens.size,
743
- pendingRespawnStarts: this.pendingRespawnStarts.size,
744
- respawnTimers: this.respawnTimers.size,
745
- activePlanOrchestrators: this.activePlanOrchestrators.size,
746
- cleaningUp: this.cleaningUp.size,
747
- };
748
- const totalServerMapEntries = Object.values(serverMapSizes).reduce((a, b) => a + b, 0);
749
- const totalSubagentMapEntries = Object.values(subagentStats).reduce((a, b) => a + b, 0);
750
- return {
751
- memory: {
752
- rss: mem.rss,
753
- rssMB: Math.round((mem.rss / 1024 / 1024) * 10) / 10,
754
- heapUsed: mem.heapUsed,
755
- heapUsedMB: Math.round((mem.heapUsed / 1024 / 1024) * 10) / 10,
756
- heapTotal: mem.heapTotal,
757
- heapTotalMB: Math.round((mem.heapTotal / 1024 / 1024) * 10) / 10,
758
- external: mem.external,
759
- externalMB: Math.round((mem.external / 1024 / 1024) * 10) / 10,
760
- arrayBuffers: mem.arrayBuffers,
761
- arrayBuffersMB: Math.round((mem.arrayBuffers / 1024 / 1024) * 10) / 10,
762
- },
763
- mapSizes: {
764
- server: serverMapSizes,
765
- subagentWatcher: subagentStats,
766
- totals: {
767
- serverEntries: totalServerMapEntries,
768
- subagentEntries: totalSubagentMapEntries,
769
- allEntries: totalServerMapEntries + totalSubagentMapEntries,
770
- },
771
- },
772
- watchers: {
773
- fileDebouncers: subagentStats.fileDebouncerCount,
774
- dirWatchers: subagentStats.dirWatcherCount,
775
- transcriptWatchers: this.transcriptWatchers.size,
776
- total: subagentStats.fileDebouncerCount + subagentStats.dirWatcherCount + this.transcriptWatchers.size,
777
- },
778
- timers: {
779
- respawnTimers: this.respawnTimers.size,
780
- pendingRespawnStarts: this.pendingRespawnStarts.size,
781
- subagentIdleTimers: subagentStats.idleTimerCount,
782
- total: this.respawnTimers.size + this.pendingRespawnStarts.size + subagentStats.idleTimerCount,
783
- },
784
- uptime: {
785
- seconds: Math.round(process.uptime()),
786
- formatted: formatUptime(process.uptime()),
787
- },
788
- timestamp: Date.now(),
789
- };
790
- });
791
- // Session management
792
- this.app.get('/api/sessions', async () => this.getLightSessionsState());
793
- this.app.post('/api/sessions', async (req) => {
794
- // Prevent unbounded session creation
795
- if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
796
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`);
797
- }
798
- const result = CreateSessionSchema.safeParse(req.body);
799
- if (!result.success) {
800
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
801
- }
802
- const body = result.data;
803
- const workingDir = body.workingDir || process.cwd();
804
- // Validate workingDir exists and is a directory
805
- if (body.workingDir) {
806
- try {
807
- const stat = statSync(workingDir);
808
- if (!stat.isDirectory()) {
809
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
810
- }
811
- }
812
- catch {
813
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
814
- }
815
- }
816
- // Write env overrides to .claude/settings.local.json if provided
817
- if (body.envOverrides && Object.keys(body.envOverrides).length > 0) {
818
- await updateCaseEnvVars(workingDir, body.envOverrides);
819
- }
820
- // Check OpenCode availability if requested
821
- if (body.mode === 'opencode') {
822
- const { isOpenCodeAvailable } = await import('../utils/opencode-cli-resolver.js');
823
- if (!isOpenCodeAvailable()) {
824
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
825
- }
826
- }
827
- const globalNice = await this.getGlobalNiceConfig();
828
- const modelConfig = await this.getModelConfig();
829
- const mode = body.mode || 'claude';
830
- const model = mode === 'opencode' ? body.openCodeConfig?.model : mode !== 'shell' ? modelConfig?.defaultModel : undefined;
831
- const claudeModeConfig = await this.getClaudeModeConfig();
832
- const session = new Session({
833
- workingDir,
834
- mode,
835
- name: body.name || '',
836
- mux: this.mux,
837
- useMux: true,
838
- niceConfig: globalNice,
839
- model,
840
- claudeMode: claudeModeConfig.claudeMode,
841
- allowedTools: claudeModeConfig.allowedTools,
842
- openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
843
- });
844
- this.sessions.set(session.id, session);
845
- this.store.incrementSessionsCreated();
846
- this.persistSessionState(session);
847
- await this.setupSessionListeners(session);
848
- getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
849
- // Use light state for broadcast + response — buffers are fetched on-demand via /terminal.
850
- // Avoids serializing 2-3MB of terminal+text buffers per session creation.
851
- const lightState = this.getSessionStateWithRespawn(session);
852
- this.broadcast('session:created', lightState);
853
- return { success: true, session: lightState };
854
- });
855
- // Rename a session
856
- this.app.put('/api/sessions/:id/name', async (req) => {
857
- const { id } = req.params;
858
- const result = SessionNameSchema.safeParse(req.body);
859
- if (!result.success) {
860
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
861
- }
862
- const body = result.data;
863
- const session = this.sessions.get(id);
864
- if (!session) {
865
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
866
- }
867
- const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
868
- session.name = name;
869
- // Also update the mux session name if applicable
870
- this.mux.updateSessionName(id, session.name);
871
- this.persistSessionState(session);
872
- this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
873
- return { success: true, name: session.name };
874
- });
875
- // Set session color
876
- this.app.put('/api/sessions/:id/color', async (req) => {
877
- const { id } = req.params;
878
- const result = SessionColorSchema.safeParse(req.body);
879
- if (!result.success) {
880
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
881
- }
882
- const body = result.data;
883
- const session = this.sessions.get(id);
884
- if (!session) {
885
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
886
- }
887
- const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
888
- if (!validColors.includes(body.color)) {
889
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid color');
890
- }
891
- session.setColor(body.color);
892
- this.persistSessionState(session);
893
- this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
894
- return { success: true, color: session.color };
895
- });
896
- this.app.delete('/api/sessions/:id', async (req) => {
897
- const { id } = req.params;
898
- const query = req.query;
899
- const killMux = query.killMux !== 'false'; // Default to true
900
- if (!this.sessions.has(id)) {
901
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
902
- }
903
- await this.cleanupSession(id, killMux, 'user_delete');
904
- return { success: true };
905
- });
906
- // Kill all sessions at once
907
- this.app.delete('/api/sessions', async () => {
908
- const sessionIds = Array.from(this.sessions.keys());
909
- let killed = 0;
910
- for (const id of sessionIds) {
911
- if (this.sessions.has(id)) {
912
- await this.cleanupSession(id, true, 'user_bulk_delete');
913
- killed++;
914
- }
915
- }
916
- return { success: true, data: { killed } };
917
- });
918
- this.app.get('/api/sessions/:id', async (req) => {
919
- const { id } = req.params;
920
- const session = this.sessions.get(id);
921
- if (!session) {
922
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
923
- }
924
- // Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
925
- // Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
926
- return this.getSessionStateWithRespawn(session);
927
- });
928
- this.app.get('/api/sessions/:id/output', async (req) => {
929
- const { id } = req.params;
930
- const session = this.sessions.get(id);
931
- if (!session) {
932
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
933
- }
934
- return {
935
- success: true,
936
- data: {
937
- textOutput: session.textOutput,
938
- messages: session.messages,
939
- errorBuffer: session.errorBuffer,
940
- },
941
- };
942
- });
943
- // Get Ralph state (Ralph loop + todos) for a session
944
- this.app.get('/api/sessions/:id/ralph-state', async (req) => {
945
- const { id } = req.params;
946
- const session = this.sessions.get(id);
947
- if (!session) {
948
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
949
- }
950
- return {
951
- success: true,
952
- data: {
953
- loop: session.ralphLoopState,
954
- todos: session.ralphTodos,
955
- todoStats: session.ralphTodoStats,
956
- },
957
- };
958
- });
959
- // Get run summary for a session (what happened while you were away)
960
- this.app.get('/api/sessions/:id/run-summary', async (req) => {
961
- const { id } = req.params;
962
- const session = this.sessions.get(id);
963
- if (!session) {
964
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
965
- }
966
- const tracker = this.runSummaryTrackers.get(id);
967
- if (!tracker) {
968
- // Create a fresh tracker if one doesn't exist (shouldn't happen normally)
969
- const newTracker = new RunSummaryTracker(id, session.name);
970
- this.runSummaryTrackers.set(id, newTracker);
971
- return { success: true, summary: newTracker.getSummary() };
972
- }
973
- // Update session name in case it changed
974
- tracker.setSessionName(session.name);
975
- return { success: true, summary: tracker.getSummary() };
976
- });
977
- // Get active Bash tools for a session (file-viewing commands)
978
- this.app.get('/api/sessions/:id/active-tools', async (req) => {
979
- const { id } = req.params;
980
- const session = this.sessions.get(id);
981
- if (!session) {
982
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
983
- }
984
- return {
985
- success: true,
986
- data: {
987
- tools: session.activeTools,
988
- },
989
- };
990
- });
991
- // Get file tree for session's working directory (File Browser)
992
- this.app.get('/api/sessions/:id/files', async (req) => {
993
- const { id } = req.params;
994
- const { depth, showHidden } = req.query;
995
- const session = this.sessions.get(id);
996
- if (!session) {
997
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
998
- }
999
- const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
1000
- const includeHidden = showHidden === 'true';
1001
- const workingDir = session.workingDir;
1002
- // Default excludes - large/generated directories
1003
- const excludeDirs = new Set([
1004
- '.git',
1005
- 'node_modules',
1006
- 'dist',
1007
- 'build',
1008
- '__pycache__',
1009
- '.cache',
1010
- '.next',
1011
- '.nuxt',
1012
- 'coverage',
1013
- '.venv',
1014
- 'venv',
1015
- '.tox',
1016
- 'target',
1017
- 'vendor',
1018
- ]);
1019
- let totalFiles = 0;
1020
- let totalDirectories = 0;
1021
- let truncated = false;
1022
- const maxFiles = 5000;
1023
- const scanDirectory = async (dirPath, currentDepth) => {
1024
- if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) {
1025
- truncated = true;
1026
- return [];
1027
- }
1028
- try {
1029
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
1030
- const nodes = [];
1031
- // Sort: directories first, then alphabetically
1032
- entries.sort((a, b) => {
1033
- if (a.isDirectory() && !b.isDirectory())
1034
- return -1;
1035
- if (!a.isDirectory() && b.isDirectory())
1036
- return 1;
1037
- return a.name.localeCompare(b.name);
1038
- });
1039
- for (const entry of entries) {
1040
- if (totalFiles + totalDirectories > maxFiles) {
1041
- truncated = true;
1042
- break;
1043
- }
1044
- // Skip hidden files unless requested
1045
- if (!includeHidden && entry.name.startsWith('.'))
1046
- continue;
1047
- // Skip excluded directories
1048
- if (entry.isDirectory() && excludeDirs.has(entry.name))
1049
- continue;
1050
- const fullPath = join(dirPath, entry.name);
1051
- const relativePath = fullPath.slice(workingDir.length + 1);
1052
- if (entry.isDirectory()) {
1053
- totalDirectories++;
1054
- const children = await scanDirectory(fullPath, currentDepth + 1);
1055
- nodes.push({
1056
- name: entry.name,
1057
- path: relativePath,
1058
- type: 'directory',
1059
- children,
1060
- });
1061
- }
1062
- else {
1063
- totalFiles++;
1064
- const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined;
1065
- let size;
1066
- try {
1067
- const stat = await fs.stat(fullPath);
1068
- size = stat.size;
1069
- }
1070
- catch {
1071
- // Skip if can't stat
1072
- }
1073
- nodes.push({
1074
- name: entry.name,
1075
- path: relativePath,
1076
- type: 'file',
1077
- size,
1078
- extension: ext,
1079
- });
1080
- }
1081
- }
1082
- return nodes;
1083
- }
1084
- catch (err) {
1085
- // Can't read directory (permission denied, etc.)
1086
- return [];
1087
- }
1088
- };
1089
- const tree = await scanDirectory(workingDir, 1);
1090
- return {
1091
- success: true,
1092
- data: {
1093
- root: workingDir,
1094
- tree,
1095
- totalFiles,
1096
- totalDirectories,
1097
- truncated,
1098
- },
1099
- };
1100
- });
1101
- // Get file content for preview (File Browser)
1102
- this.app.get('/api/sessions/:id/file-content', async (req) => {
1103
- const { id } = req.params;
1104
- const { path: filePath, lines, raw } = req.query;
1105
- const session = this.sessions.get(id);
1106
- if (!session) {
1107
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1108
- }
1109
- if (!filePath) {
1110
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
1111
- }
1112
- // Validate path is within working directory (security: resolve symlinks to prevent traversal)
1113
- const fullPath = resolve(session.workingDir, filePath);
1114
- let resolvedPath;
1115
- try {
1116
- resolvedPath = realpathSync(fullPath);
1117
- }
1118
- catch {
1119
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
1120
- }
1121
- const relativePath = relative(session.workingDir, resolvedPath);
1122
- if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
1123
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory');
1124
- }
1125
- try {
1126
- const stat = await fs.stat(resolvedPath);
1127
- // Check if it's a binary/media file
1128
- const ext = filePath.split('.').pop()?.toLowerCase() || '';
1129
- const binaryExts = new Set([
1130
- 'png',
1131
- 'jpg',
1132
- 'jpeg',
1133
- 'gif',
1134
- 'webp',
1135
- 'ico',
1136
- 'svg',
1137
- 'bmp',
1138
- 'mp4',
1139
- 'webm',
1140
- 'mov',
1141
- 'avi',
1142
- 'mp3',
1143
- 'wav',
1144
- 'ogg',
1145
- 'pdf',
1146
- 'zip',
1147
- 'tar',
1148
- 'gz',
1149
- 'exe',
1150
- 'dll',
1151
- 'so',
1152
- 'woff',
1153
- 'woff2',
1154
- 'ttf',
1155
- 'eot',
1156
- ]);
1157
- const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
1158
- const videoExts = new Set(['mp4', 'webm', 'mov', 'avi']);
1159
- if (raw === 'true' || binaryExts.has(ext)) {
1160
- // Return metadata for binary files
1161
- return {
1162
- success: true,
1163
- data: {
1164
- path: filePath,
1165
- size: stat.size,
1166
- type: imageExts.has(ext) ? 'image' : videoExts.has(ext) ? 'video' : 'binary',
1167
- extension: ext,
1168
- url: `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`,
1169
- },
1170
- };
1171
- }
1172
- // Validate file size before reading (DoS protection - prevent memory exhaustion)
1173
- const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024; // 10MB
1174
- if (stat.size > MAX_TEXT_FILE_SIZE) {
1175
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit)`);
1176
- }
1177
- // Read text file with line limit (bounded to prevent DoS)
1178
- const MAX_LINES_LIMIT = 10000;
1179
- const maxLines = Math.min(parseInt(lines || '500', 10) || 500, MAX_LINES_LIMIT);
1180
- const content = await fs.readFile(resolvedPath, 'utf-8');
1181
- const allLines = content.split('\n');
1182
- const truncatedContent = allLines.length > maxLines;
1183
- const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content;
1184
- return {
1185
- success: true,
1186
- data: {
1187
- path: filePath,
1188
- content: displayContent,
1189
- size: stat.size,
1190
- totalLines: allLines.length,
1191
- truncated: truncatedContent,
1192
- extension: ext,
1193
- },
1194
- };
1195
- }
1196
- catch (err) {
1197
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`);
1198
- }
1199
- });
1200
- // Serve raw file content (for images/binary files)
1201
- this.app.get('/api/sessions/:id/file-raw', async (req, reply) => {
1202
- const { id } = req.params;
1203
- const { path: filePath } = req.query;
1204
- const session = this.sessions.get(id);
1205
- if (!session) {
1206
- reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'));
1207
- return;
1208
- }
1209
- if (!filePath) {
1210
- reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
1211
- return;
1212
- }
1213
- // Validate path is within working directory (security: resolve symlinks to prevent traversal)
1214
- const fullPath = resolve(session.workingDir, filePath);
1215
- let resolvedPath;
1216
- try {
1217
- resolvedPath = realpathSync(fullPath);
1218
- }
1219
- catch {
1220
- reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
1221
- return;
1222
- }
1223
- const relativePath = relative(session.workingDir, resolvedPath);
1224
- if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
1225
- reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory'));
1226
- return;
1227
- }
1228
- try {
1229
- // Validate file size before reading (DoS protection - prevent memory exhaustion)
1230
- const MAX_RAW_FILE_SIZE = 50 * 1024 * 1024; // 50MB for raw files
1231
- const stat = await fs.stat(resolvedPath);
1232
- if (stat.size > MAX_RAW_FILE_SIZE) {
1233
- reply
1234
- .code(400)
1235
- .send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_FILE_SIZE / 1024 / 1024}MB limit)`));
1236
- return;
1237
- }
1238
- const ext = filePath.split('.').pop()?.toLowerCase() || '';
1239
- const mimeTypes = {
1240
- png: 'image/png',
1241
- jpg: 'image/jpeg',
1242
- jpeg: 'image/jpeg',
1243
- gif: 'image/gif',
1244
- webp: 'image/webp',
1245
- svg: 'image/svg+xml',
1246
- ico: 'image/x-icon',
1247
- bmp: 'image/bmp',
1248
- mp4: 'video/mp4',
1249
- webm: 'video/webm',
1250
- mov: 'video/quicktime',
1251
- mp3: 'audio/mpeg',
1252
- wav: 'audio/wav',
1253
- ogg: 'audio/ogg',
1254
- pdf: 'application/pdf',
1255
- json: 'application/json',
1256
- };
1257
- const content = await fs.readFile(resolvedPath);
1258
- reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
1259
- reply.send(content);
1260
- }
1261
- catch (err) {
1262
- reply
1263
- .code(500)
1264
- .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
1265
- }
1266
- });
1267
- // Stream file content via tail -f (SSE endpoint)
1268
- this.app.get('/api/sessions/:id/tail-file', async (req, reply) => {
1269
- const { id } = req.params;
1270
- const { path: filePath, lines } = req.query;
1271
- const session = this.sessions.get(id);
1272
- if (!session) {
1273
- reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'));
1274
- return;
1275
- }
1276
- if (!filePath) {
1277
- reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
1278
- return;
1279
- }
1280
- // Set up SSE headers
1281
- reply.raw.writeHead(200, {
1282
- 'Content-Type': 'text/event-stream',
1283
- 'Cache-Control': 'no-cache',
1284
- Connection: 'keep-alive',
1285
- 'X-Accel-Buffering': 'no',
1286
- });
1287
- // Track stream for cleanup
1288
- const streamRef = {};
1289
- // Create the file stream
1290
- const result = await fileStreamManager.createStream({
1291
- sessionId: id,
1292
- filePath,
1293
- workingDir: session.workingDir,
1294
- lines: lines ? parseInt(lines, 10) : undefined,
1295
- onData: (data) => {
1296
- // Send data as SSE event
1297
- reply.raw.write(`data: ${JSON.stringify({ type: 'data', content: data })}\n\n`);
1298
- },
1299
- onEnd: () => {
1300
- reply.raw.write(`data: ${JSON.stringify({ type: 'end' })}\n\n`);
1301
- reply.raw.end();
1302
- },
1303
- onError: (error) => {
1304
- reply.raw.write(`data: ${JSON.stringify({ type: 'error', error })}\n\n`);
1305
- },
1306
- });
1307
- if (!result.success) {
1308
- reply.raw.write(`data: ${JSON.stringify({ type: 'error', error: result.error })}\n\n`);
1309
- reply.raw.end();
1310
- return;
1311
- }
1312
- streamRef.id = result.streamId;
1313
- // Notify client of successful connection
1314
- reply.raw.write(`data: ${JSON.stringify({ type: 'connected', streamId: result.streamId, filePath })}\n\n`);
1315
- // Handle client disconnect
1316
- req.raw.on('close', () => {
1317
- if (streamRef.id) {
1318
- fileStreamManager.closeStream(streamRef.id);
1319
- }
1320
- });
1321
- });
1322
- // Close a file stream
1323
- this.app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => {
1324
- const { id, streamId } = req.params;
1325
- const session = this.sessions.get(id);
1326
- if (!session) {
1327
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1328
- }
1329
- const closed = fileStreamManager.closeStream(streamId);
1330
- return { success: closed };
1331
- });
1332
- // Configure Ralph (Ralph Wiggum) settings
1333
- this.app.post('/api/sessions/:id/ralph-config', async (req) => {
1334
- const { id } = req.params;
1335
- const ralphResult = RalphConfigSchema.safeParse(req.body);
1336
- if (!ralphResult.success) {
1337
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
1338
- }
1339
- const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = ralphResult.data;
1340
- const session = this.sessions.get(id);
1341
- if (!session) {
1342
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1343
- }
1344
- // Ralph tracker is not supported for opencode sessions
1345
- if (session.mode === 'opencode') {
1346
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Ralph tracker is not supported for opencode sessions');
1347
- }
1348
- // Handle reset first (before other config)
1349
- if (reset) {
1350
- if (reset === 'full') {
1351
- session.ralphTracker.fullReset();
1352
- }
1353
- else {
1354
- session.ralphTracker.reset();
1355
- }
1356
- }
1357
- // Configure auto-enable behavior
1358
- if (disableAutoEnable !== undefined) {
1359
- if (disableAutoEnable) {
1360
- session.ralphTracker.disableAutoEnable();
1361
- }
1362
- else {
1363
- session.ralphTracker.enableAutoEnable();
1364
- }
1365
- }
1366
- // Enable/disable the tracker
1367
- if (enabled !== undefined) {
1368
- if (enabled) {
1369
- session.ralphTracker.enable();
1370
- // Allow re-enabling on restart if user explicitly enabled
1371
- session.ralphTracker.enableAutoEnable();
1372
- }
1373
- else {
1374
- session.ralphTracker.disable();
1375
- // Prevent re-enabling on restart when user explicitly disabled
1376
- session.ralphTracker.disableAutoEnable();
1377
- }
1378
- // Persist Ralph enabled state
1379
- this.mux.updateRalphEnabled(id, enabled);
1380
- }
1381
- // Configure the Ralph tracker
1382
- if (completionPhrase !== undefined) {
1383
- // Start loop with completion phrase to set it up for watching
1384
- if (completionPhrase) {
1385
- session.ralphTracker.startLoop(completionPhrase, maxIterations || undefined);
1386
- }
1387
- }
1388
- if (maxIterations !== undefined) {
1389
- session.ralphTracker.setMaxIterations(maxIterations || null);
1390
- }
1391
- // Persist and broadcast the update
1392
- this.persistSessionState(session);
1393
- this.broadcast('session:ralphLoopUpdate', {
1394
- sessionId: id,
1395
- state: session.ralphLoopState,
1396
- });
1397
- return { success: true };
1398
- });
1399
- // Reset circuit breaker for Ralph tracker
1400
- this.app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
1401
- const { id } = req.params;
1402
- const session = this.sessions.get(id);
1403
- if (!session) {
1404
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1405
- }
1406
- session.ralphTracker.resetCircuitBreaker();
1407
- return { success: true };
1408
- });
1409
- // Get Ralph status block and circuit breaker state
1410
- this.app.get('/api/sessions/:id/ralph-status', async (req) => {
1411
- const { id } = req.params;
1412
- const session = this.sessions.get(id);
1413
- if (!session) {
1414
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1415
- }
1416
- return {
1417
- success: true,
1418
- data: {
1419
- lastStatusBlock: session.ralphTracker.lastStatusBlock,
1420
- circuitBreaker: session.ralphTracker.circuitBreakerStatus,
1421
- cumulativeStats: session.ralphTracker.cumulativeStats,
1422
- exitGateMet: session.ralphTracker.exitGateMet,
1423
- },
1424
- };
1425
- });
1426
- // Generate @fix_plan.md content from todos
1427
- this.app.get('/api/sessions/:id/fix-plan', async (req) => {
1428
- const { id } = req.params;
1429
- const session = this.sessions.get(id);
1430
- if (!session) {
1431
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1432
- }
1433
- const content = session.ralphTracker.generateFixPlanMarkdown();
1434
- return {
1435
- success: true,
1436
- data: {
1437
- content,
1438
- todoCount: session.ralphTracker.todos.length,
1439
- },
1440
- };
1441
- });
1442
- // Import todos from @fix_plan.md content
1443
- this.app.post('/api/sessions/:id/fix-plan/import', async (req) => {
1444
- const { id } = req.params;
1445
- const importResult = FixPlanImportSchema.safeParse(req.body);
1446
- if (!importResult.success) {
1447
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
1448
- }
1449
- const { content } = importResult.data;
1450
- const session = this.sessions.get(id);
1451
- if (!session) {
1452
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1453
- }
1454
- const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
1455
- this.persistSessionState(session);
1456
- return {
1457
- success: true,
1458
- data: {
1459
- importedCount,
1460
- todos: session.ralphTracker.todos,
1461
- },
1462
- };
1463
- });
1464
- // Write @fix_plan.md to session's working directory
1465
- this.app.post('/api/sessions/:id/fix-plan/write', async (req) => {
1466
- const { id } = req.params;
1467
- const session = this.sessions.get(id);
1468
- if (!session) {
1469
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1470
- }
1471
- const workingDir = session.workingDir;
1472
- if (!workingDir) {
1473
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory');
1474
- }
1475
- const content = session.ralphTracker.generateFixPlanMarkdown();
1476
- const filePath = join(workingDir, '@fix_plan.md');
1477
- try {
1478
- await fs.writeFile(filePath, content, 'utf-8');
1479
- return {
1480
- success: true,
1481
- data: {
1482
- filePath,
1483
- todoCount: session.ralphTracker.todos.length,
1484
- },
1485
- };
1486
- }
1487
- catch (error) {
1488
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`);
1489
- }
1490
- });
1491
- // Read @fix_plan.md from session's working directory and import
1492
- this.app.post('/api/sessions/:id/fix-plan/read', async (req) => {
1493
- const { id } = req.params;
1494
- const session = this.sessions.get(id);
1495
- if (!session) {
1496
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1497
- }
1498
- const workingDir = session.workingDir;
1499
- if (!workingDir) {
1500
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory');
1501
- }
1502
- const filePath = join(workingDir, '@fix_plan.md');
1503
- try {
1504
- const content = await fs.readFile(filePath, 'utf-8');
1505
- const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
1506
- this.persistSessionState(session);
1507
- return {
1508
- success: true,
1509
- data: {
1510
- filePath,
1511
- importedCount,
1512
- todos: session.ralphTracker.todos,
1513
- },
1514
- };
1515
- }
1516
- catch (error) {
1517
- if (error.code === 'ENOENT') {
1518
- return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory');
1519
- }
1520
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${error}`);
1521
- }
1522
- });
1523
- // Write Ralph prompt to file in session's working directory
1524
- // This avoids mux input escaping issues with long multi-line prompts
1525
- this.app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
1526
- const { id } = req.params;
1527
- const promptResult = RalphPromptWriteSchema.safeParse(req.body);
1528
- if (!promptResult.success) {
1529
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
1530
- }
1531
- const { content } = promptResult.data;
1532
- const session = this.sessions.get(id);
1533
- if (!session) {
1534
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1535
- }
1536
- const workingDir = session.workingDir;
1537
- if (!workingDir) {
1538
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory');
1539
- }
1540
- const filePath = join(workingDir, '@ralph_prompt.md');
1541
- try {
1542
- await fs.writeFile(filePath, content, 'utf-8');
1543
- return {
1544
- success: true,
1545
- data: {
1546
- filePath,
1547
- contentLength: content.length,
1548
- },
1549
- };
1550
- }
1551
- catch (error) {
1552
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`);
1553
- }
1554
- });
1555
- // Run prompt in session
1556
- this.app.post('/api/sessions/:id/run', async (req) => {
1557
- const { id } = req.params;
1558
- const result = RunPromptSchema.safeParse(req.body);
1559
- if (!result.success) {
1560
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
1561
- }
1562
- const { prompt } = result.data;
1563
- const session = this.sessions.get(id);
1564
- if (!session) {
1565
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1566
- }
1567
- if (session.isBusy()) {
1568
- return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
1569
- }
1570
- // Run async, don't wait
1571
- session.runPrompt(prompt).catch((err) => {
1572
- this.broadcast('session:error', { id, error: err.message });
1573
- });
1574
- this.broadcast('session:running', { id, prompt });
1575
- return { success: true };
1576
- });
1577
- // Start interactive Claude session (persists even if browser disconnects)
1578
- this.app.post('/api/sessions/:id/interactive', async (req) => {
1579
- const { id } = req.params;
1580
- const session = this.sessions.get(id);
1581
- if (!session) {
1582
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1583
- }
1584
- if (session.isBusy()) {
1585
- return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
1586
- }
1587
- try {
1588
- // Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
1589
- // Ralph tracker is not supported for opencode sessions
1590
- if (session.mode !== 'opencode' &&
1591
- this.store.getConfig().ralphEnabled &&
1592
- !session.ralphTracker.autoEnableDisabled) {
1593
- autoConfigureRalph(session, session.workingDir, () => { });
1594
- if (!session.ralphTracker.enabled) {
1595
- session.ralphTracker.enable();
1596
- }
1597
- }
1598
- await session.startInteractive();
1599
- getLifecycleLog().log({
1600
- event: 'started',
1601
- sessionId: id,
1602
- name: session.name,
1603
- mode: session.mode,
1604
- });
1605
- this.broadcast('session:interactive', { id });
1606
- this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
1607
- return { success: true };
1608
- }
1609
- catch (err) {
1610
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
1611
- }
1612
- });
1613
- // Start a plain shell session (no Claude)
1614
- this.app.post('/api/sessions/:id/shell', async (req) => {
1615
- const { id } = req.params;
1616
- const session = this.sessions.get(id);
1617
- if (!session) {
1618
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1619
- }
1620
- if (session.isBusy()) {
1621
- return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
1622
- }
1623
- try {
1624
- await session.startShell();
1625
- getLifecycleLog().log({
1626
- event: 'started',
1627
- sessionId: id,
1628
- name: session.name,
1629
- mode: 'shell',
1630
- });
1631
- this.broadcast('session:interactive', { id, mode: 'shell' });
1632
- this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
1633
- return { success: true };
1634
- }
1635
- catch (err) {
1636
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
1637
- }
1638
- });
1639
- // Send input to interactive session
1640
- // useMux: true uses writeViaMux which is more reliable for programmatic input
1641
- this.app.post('/api/sessions/:id/input', async (req) => {
1642
- const { id } = req.params;
1643
- const result = SessionInputWithLimitSchema.safeParse(req.body);
1644
- if (!result.success) {
1645
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
1646
- }
1647
- const { input, useMux } = result.data;
1648
- const session = this.sessions.get(id);
1649
- if (!session) {
1650
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1651
- }
1652
- const inputStr = String(input);
1653
- if (inputStr.length > MAX_INPUT_LENGTH) {
1654
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`);
1655
- }
1656
- // Write input to PTY. Direct write is synchronous; writeViaMux
1657
- // (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
1658
- if (useMux) {
1659
- // Fire-and-forget: don't block HTTP response on tmux child process.
1660
- // Fallback to direct write on failure.
1661
- session
1662
- .writeViaMux(inputStr)
1663
- .then((ok) => {
1664
- if (!ok) {
1665
- console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
1666
- session.write(inputStr);
1667
- }
1668
- })
1669
- .catch(() => {
1670
- session.write(inputStr);
1671
- });
1672
- }
1673
- else {
1674
- session.write(inputStr);
1675
- }
1676
- return { success: true };
1677
- });
1678
- // Resize session terminal
1679
- this.app.post('/api/sessions/:id/resize', async (req) => {
1680
- const { id } = req.params;
1681
- const result = ResizeSchema.safeParse(req.body);
1682
- if (!result.success) {
1683
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
1684
- }
1685
- const { cols, rows } = result.data;
1686
- const session = this.sessions.get(id);
1687
- if (!session) {
1688
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1689
- }
1690
- // Note: Zod already validates that cols and rows are positive integers within bounds
1691
- if (cols > MAX_TERMINAL_COLS || rows > MAX_TERMINAL_ROWS) {
1692
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Terminal dimensions exceed maximum (${MAX_TERMINAL_COLS}x${MAX_TERMINAL_ROWS})`);
1693
- }
1694
- session.resize(cols, rows);
1695
- return { success: true };
1696
- });
1697
- // Get session terminal buffer (for reconnecting)
1698
- // Query params:
1699
- // tail=<bytes> - Only return last N bytes (faster initial load)
1700
- this.app.get('/api/sessions/:id/terminal', async (req) => {
1701
- const { id } = req.params;
1702
- const query = req.query;
1703
- const session = this.sessions.get(id);
1704
- if (!session) {
1705
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1706
- }
1707
- const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
1708
- const fullSize = session.terminalBufferLength;
1709
- let truncated = false;
1710
- let cleanBuffer;
1711
- if (tailBytes > 0 && fullSize > tailBytes) {
1712
- // Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
1713
- // Banner is near the top and gets discarded by tail anyway.
1714
- cleanBuffer = session.terminalBuffer.slice(-tailBytes);
1715
- truncated = true;
1716
- // Avoid starting mid-ANSI-escape: find first newline within the first 4KB
1717
- // and start from there. This prevents xterm.js from parsing a partial escape
1718
- // sequence which corrupts cursor position for all subsequent Ink redraws.
1719
- const firstNewline = cleanBuffer.indexOf('\n');
1720
- if (firstNewline > 0 && firstNewline < 4096) {
1721
- cleanBuffer = cleanBuffer.slice(firstNewline + 1);
1722
- }
1723
- }
1724
- else {
1725
- // Full buffer: clean junk before actual Claude content
1726
- cleanBuffer = session.terminalBuffer;
1727
- // Find where Claude banner starts (has color codes before "Claude")
1728
- const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN);
1729
- if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) {
1730
- let lineStart = claudeMatch.index;
1731
- while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') {
1732
- lineStart--;
1733
- }
1734
- cleanBuffer = cleanBuffer.slice(lineStart);
1735
- }
1736
- }
1737
- // Remove Ctrl+L and leading whitespace (cheap on tailed subset)
1738
- cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
1739
- return {
1740
- terminalBuffer: cleanBuffer,
1741
- status: session.status,
1742
- fullSize,
1743
- truncated,
1744
- };
1745
- });
1746
- // ============ Respawn Controller Endpoints ============
1747
- // Get respawn status for a session
1748
- this.app.get('/api/sessions/:id/respawn', async (req) => {
1749
- const { id } = req.params;
1750
- const controller = this.respawnControllers.get(id);
1751
- if (!controller) {
1752
- return { enabled: false, status: null };
1753
- }
1754
- return {
1755
- enabled: true,
1756
- ...controller.getStatus(),
1757
- };
1758
- });
1759
- // Get respawn config (from running controller or pre-saved)
1760
- this.app.get('/api/sessions/:id/respawn/config', async (req) => {
1761
- const { id } = req.params;
1762
- const controller = this.respawnControllers.get(id);
1763
- if (controller) {
1764
- return { success: true, config: controller.getConfig(), active: true };
1765
- }
1766
- // Return pre-saved config from mux-sessions.json
1767
- const preConfig = this.mux.getSession(id)?.respawnConfig;
1768
- if (preConfig) {
1769
- return { success: true, config: preConfig, active: false };
1770
- }
1771
- return { success: true, config: null, active: false };
1772
- });
1773
- // Start respawn controller for a session
1774
- this.app.post('/api/sessions/:id/respawn/start', async (req) => {
1775
- const { id } = req.params;
1776
- let body;
1777
- if (req.body) {
1778
- const result = RespawnConfigSchema.safeParse(req.body);
1779
- if (!result.success) {
1780
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid respawn config');
1781
- }
1782
- body = result.data;
1783
- }
1784
- const session = this.sessions.get(id);
1785
- if (!session) {
1786
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1787
- }
1788
- // Respawn is not supported for opencode sessions
1789
- if (session.mode === 'opencode') {
1790
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
1791
- }
1792
- // Create or get existing controller
1793
- let controller = this.respawnControllers.get(id);
1794
- if (!controller) {
1795
- // Merge request body with pre-saved config from mux-sessions.json
1796
- const preConfig = this.mux.getSession(id)?.respawnConfig;
1797
- const config = body || preConfig ? { ...preConfig, ...body } : undefined;
1798
- controller = new RespawnController(session, config);
1799
- this.respawnControllers.set(id, controller);
1800
- this.setupRespawnListeners(id, controller);
1801
- }
1802
- else if (body) {
1803
- controller.updateConfig(body);
1804
- }
1805
- controller.start();
1806
- // Persist respawn config to mux session and state.json
1807
- this.saveRespawnConfig(id, controller.getConfig());
1808
- this.persistSessionState(session);
1809
- this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
1810
- return { success: true, status: controller.getStatus() };
1811
- });
1812
- // Stop respawn controller for a session
1813
- this.app.post('/api/sessions/:id/respawn/stop', async (req) => {
1814
- const { id } = req.params;
1815
- const controller = this.respawnControllers.get(id);
1816
- if (!controller) {
1817
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Respawn controller not found');
1818
- }
1819
- controller.stop();
1820
- // Remove controller from map so persistSessionState doesn't save respawnEnabled: true
1821
- this.respawnControllers.delete(id);
1822
- // Clear any timed respawn
1823
- const timerInfo = this.respawnTimers.get(id);
1824
- if (timerInfo) {
1825
- clearTimeout(timerInfo.timer);
1826
- this.respawnTimers.delete(id);
1827
- }
1828
- // Clear persisted respawn config
1829
- this.mux.clearRespawnConfig(id);
1830
- // Update state.json (respawnConfig removed)
1831
- const session = this.sessions.get(id);
1832
- if (session) {
1833
- this.persistSessionState(session);
1834
- }
1835
- this.broadcast('respawn:stopped', { sessionId: id });
1836
- return { success: true };
1837
- });
1838
- // Update respawn configuration (works with or without running controller)
1839
- this.app.put('/api/sessions/:id/respawn/config', async (req) => {
1840
- const { id } = req.params;
1841
- // Validate respawn config to prevent arbitrary field injection
1842
- const parseResult = RespawnConfigSchema.safeParse(req.body);
1843
- if (!parseResult.success) {
1844
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid respawn config: ${parseResult.error.message}`);
1845
- }
1846
- const config = parseResult.data;
1847
- const session = this.sessions.get(id);
1848
- if (!session) {
1849
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1850
- }
1851
- const controller = this.respawnControllers.get(id);
1852
- if (controller) {
1853
- // Update running controller
1854
- controller.updateConfig(config);
1855
- this.saveRespawnConfig(id, controller.getConfig());
1856
- this.persistSessionState(session);
1857
- this.broadcast('respawn:configUpdated', { sessionId: id, config: controller.getConfig() });
1858
- return { success: true, config: controller.getConfig() };
1859
- }
1860
- // No controller running - save as pre-config for when respawn starts
1861
- const existing = this.mux.getSession(id);
1862
- const currentConfig = existing?.respawnConfig;
1863
- const merged = {
1864
- enabled: config.enabled ?? currentConfig?.enabled ?? false,
1865
- idleTimeoutMs: config.idleTimeoutMs ?? currentConfig?.idleTimeoutMs ?? 10000,
1866
- updatePrompt: config.updatePrompt ?? currentConfig?.updatePrompt ?? 'update all the docs and CLAUDE.md',
1867
- interStepDelayMs: config.interStepDelayMs ?? currentConfig?.interStepDelayMs ?? 1000,
1868
- sendClear: config.sendClear ?? currentConfig?.sendClear ?? true,
1869
- sendInit: config.sendInit ?? currentConfig?.sendInit ?? true,
1870
- kickstartPrompt: config.kickstartPrompt ?? currentConfig?.kickstartPrompt,
1871
- autoAcceptPrompts: config.autoAcceptPrompts ?? currentConfig?.autoAcceptPrompts ?? true,
1872
- autoAcceptDelayMs: config.autoAcceptDelayMs ?? currentConfig?.autoAcceptDelayMs ?? 8000,
1873
- aiIdleCheckEnabled: config.aiIdleCheckEnabled ?? currentConfig?.aiIdleCheckEnabled ?? true,
1874
- aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? 'claude-opus-4-5-20251101',
1875
- aiIdleCheckMaxContext: config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? 16000,
1876
- aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? 90000,
1877
- aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? 180000,
1878
- aiPlanCheckEnabled: config.aiPlanCheckEnabled ?? currentConfig?.aiPlanCheckEnabled ?? true,
1879
- aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? 'claude-opus-4-5-20251101',
1880
- aiPlanCheckMaxContext: config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? 8000,
1881
- aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? 60000,
1882
- aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000,
1883
- durationMinutes: currentConfig?.durationMinutes,
1884
- };
1885
- this.mux.updateRespawnConfig(id, merged);
1886
- this.persistSessionState(session);
1887
- this.broadcast('respawn:configUpdated', { sessionId: id, config: merged });
1888
- return { success: true, config: merged };
1889
- });
1890
- // Start interactive session WITH respawn enabled
1891
- this.app.post('/api/sessions/:id/interactive-respawn', async (req) => {
1892
- const { id } = req.params;
1893
- const irResult = req.body ? InteractiveRespawnSchema.safeParse(req.body) : { success: true, data: {} };
1894
- if (!irResult.success) {
1895
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
1896
- }
1897
- const body = irResult.data;
1898
- const session = this.sessions.get(id);
1899
- if (!session) {
1900
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1901
- }
1902
- if (session.isBusy()) {
1903
- return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
1904
- }
1905
- // Respawn is not supported for opencode sessions
1906
- if (session.mode === 'opencode') {
1907
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
1908
- }
1909
- try {
1910
- // Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
1911
- if (this.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled) {
1912
- autoConfigureRalph(session, session.workingDir, () => { });
1913
- if (!session.ralphTracker.enabled) {
1914
- session.ralphTracker.enable();
1915
- }
1916
- }
1917
- // Start interactive session
1918
- await session.startInteractive();
1919
- getLifecycleLog().log({
1920
- event: 'started',
1921
- sessionId: id,
1922
- name: session.name,
1923
- mode: session.mode,
1924
- reason: 'interactive_respawn',
1925
- });
1926
- this.broadcast('session:interactive', { id });
1927
- this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
1928
- // Create and start respawn controller
1929
- const controller = new RespawnController(session, body?.respawnConfig);
1930
- this.respawnControllers.set(id, controller);
1931
- this.setupRespawnListeners(id, controller);
1932
- controller.start();
1933
- // Set up timed stop if duration specified
1934
- if (body?.durationMinutes && body.durationMinutes > 0) {
1935
- this.setupTimedRespawn(id, body.durationMinutes);
1936
- }
1937
- // Persist full session state with respawn config
1938
- this.persistSessionState(session);
1939
- this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
1940
- return {
1941
- success: true,
1942
- data: {
1943
- message: 'Interactive session with respawn started',
1944
- respawnStatus: controller.getStatus(),
1945
- },
1946
- };
1947
- }
1948
- catch (err) {
1949
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
1950
- }
1951
- });
1952
- // Enable respawn on an EXISTING interactive session
1953
- this.app.post('/api/sessions/:id/respawn/enable', async (req) => {
1954
- const { id } = req.params;
1955
- const reResult = req.body ? RespawnEnableSchema.safeParse(req.body) : { success: true, data: {} };
1956
- if (!reResult.success) {
1957
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
1958
- }
1959
- const body = reResult.data;
1960
- const session = this.sessions.get(id);
1961
- if (!session) {
1962
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
1963
- }
1964
- // Respawn is not supported for opencode sessions
1965
- if (session.mode === 'opencode') {
1966
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
1967
- }
1968
- // Check if session is running (has a PID)
1969
- if (!session.pid) {
1970
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Session is not running. Start it first.');
1971
- }
1972
- // Stop existing controller if any
1973
- const existingController = this.respawnControllers.get(id);
1974
- if (existingController) {
1975
- existingController.stop();
1976
- }
1977
- // Create and start new respawn controller (merge with pre-saved config)
1978
- const preConfig = this.mux.getSession(id)?.respawnConfig;
1979
- const config = body?.config || preConfig ? { ...preConfig, ...body?.config } : undefined;
1980
- const controller = new RespawnController(session, config);
1981
- this.respawnControllers.set(id, controller);
1982
- this.setupRespawnListeners(id, controller);
1983
- controller.start();
1984
- // Set up timed stop if duration specified
1985
- if (body?.durationMinutes && body.durationMinutes > 0) {
1986
- this.setupTimedRespawn(id, body.durationMinutes);
1987
- }
1988
- // Persist respawn config to mux session and state.json
1989
- this.saveRespawnConfig(id, controller.getConfig(), body?.durationMinutes);
1990
- this.persistSessionState(session);
1991
- this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
1992
- return {
1993
- success: true,
1994
- message: 'Respawn enabled on existing session',
1995
- respawnStatus: controller.getStatus(),
1996
- };
1997
- });
1998
- // Set auto-clear on a session
1999
- this.app.post('/api/sessions/:id/auto-clear', async (req) => {
2000
- const { id } = req.params;
2001
- const acResult = AutoClearSchema.safeParse(req.body);
2002
- if (!acResult.success) {
2003
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
2004
- }
2005
- const body = acResult.data;
2006
- const session = this.sessions.get(id);
2007
- if (!session) {
2008
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
2009
- }
2010
- session.setAutoClear(body.enabled, body.threshold);
2011
- this.persistSessionState(session);
2012
- this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
2013
- return {
2014
- success: true,
2015
- data: {
2016
- autoClear: {
2017
- enabled: session.autoClearEnabled,
2018
- threshold: session.autoClearThreshold,
2019
- },
2020
- },
2021
- };
2022
- });
2023
- // Set auto-compact on a session
2024
- this.app.post('/api/sessions/:id/auto-compact', async (req) => {
2025
- const { id } = req.params;
2026
- const compactResult = AutoCompactSchema.safeParse(req.body);
2027
- if (!compactResult.success) {
2028
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
2029
- }
2030
- const body = compactResult.data;
2031
- const session = this.sessions.get(id);
2032
- if (!session) {
2033
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
2034
- }
2035
- session.setAutoCompact(body.enabled, body.threshold, body.prompt);
2036
- this.persistSessionState(session);
2037
- this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
2038
- return {
2039
- success: true,
2040
- data: {
2041
- autoCompact: {
2042
- enabled: session.autoCompactEnabled,
2043
- threshold: session.autoCompactThreshold,
2044
- prompt: session.autoCompactPrompt,
2045
- },
2046
- },
2047
- };
2048
- });
2049
- // Toggle image watcher for a session
2050
- this.app.post('/api/sessions/:id/image-watcher', async (req) => {
2051
- const { id } = req.params;
2052
- const iwResult = ImageWatcherSchema.safeParse(req.body);
2053
- if (!iwResult.success) {
2054
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
2055
- }
2056
- const body = iwResult.data;
2057
- const session = this.sessions.get(id);
2058
- if (!session) {
2059
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
2060
- }
2061
- if (body.enabled) {
2062
- imageWatcher.watchSession(session.id, session.workingDir);
2063
- }
2064
- else {
2065
- imageWatcher.unwatchSession(session.id);
2066
- }
2067
- // Store state on session for persistence
2068
- session.imageWatcherEnabled = body.enabled;
2069
- this.persistSessionState(session);
2070
- return {
2071
- success: true,
2072
- data: {
2073
- imageWatcherEnabled: body.enabled,
2074
- },
2075
- };
2076
- });
2077
- // Toggle flicker filter for a session
2078
- this.app.post('/api/sessions/:id/flicker-filter', async (req) => {
2079
- const { id } = req.params;
2080
- const ffResult = FlickerFilterSchema.safeParse(req.body);
2081
- if (!ffResult.success) {
2082
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
2083
- }
2084
- const body = ffResult.data;
2085
- const session = this.sessions.get(id);
2086
- if (!session) {
2087
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
2088
- }
2089
- session.flickerFilterEnabled = body.enabled;
2090
- this.persistSessionState(session);
2091
- this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
2092
- return {
2093
- success: true,
2094
- data: {
2095
- flickerFilterEnabled: body.enabled,
2096
- },
2097
- };
2098
- });
2099
- // Quick run (create session, run prompt, return result, then cleanup)
2100
- this.app.post('/api/run', async (req) => {
2101
- // Prevent unbounded session creation
2102
- if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
2103
- return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`);
2104
- }
2105
- const qrResult = QuickRunSchema.safeParse(req.body);
2106
- if (!qrResult.success) {
2107
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
2108
- }
2109
- const { prompt, workingDir } = qrResult.data;
2110
- if (!prompt.trim()) {
2111
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
2112
- }
2113
- const dir = workingDir || process.cwd();
2114
- // Validate workingDir exists and is a directory
2115
- if (workingDir) {
2116
- try {
2117
- const stat = statSync(dir);
2118
- if (!stat.isDirectory()) {
2119
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
2120
- }
2121
- }
2122
- catch {
2123
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
2124
- }
2125
- }
2126
- const session = new Session({ workingDir: dir });
2127
- this.sessions.set(session.id, session);
2128
- this.store.incrementSessionsCreated();
2129
- this.persistSessionState(session);
2130
- await this.setupSessionListeners(session);
2131
- getLifecycleLog().log({
2132
- event: 'created',
2133
- sessionId: session.id,
2134
- name: session.name,
2135
- reason: 'run_prompt',
2136
- });
2137
- this.broadcast('session:created', this.getSessionStateWithRespawn(session));
2138
- try {
2139
- const result = await session.runPrompt(prompt);
2140
- // Clean up session after completion to prevent memory leak
2141
- await this.cleanupSession(session.id, true, 'run_prompt_complete');
2142
- return { success: true, sessionId: session.id, ...result };
2143
- }
2144
- catch (err) {
2145
- // Clean up session on error too
2146
- await this.cleanupSession(session.id, true, 'run_prompt_error');
2147
- return { success: false, sessionId: session.id, error: getErrorMessage(err) };
2148
- }
2149
- });
2150
- // Scheduled runs
2151
- this.app.get('/api/scheduled', async () => {
2152
- return Array.from(this.scheduledRuns.values());
2153
- });
2154
- this.app.post('/api/scheduled', async (req) => {
2155
- const srResult = ScheduledRunSchema.safeParse(req.body);
2156
- if (!srResult.success) {
2157
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
2158
- }
2159
- const { prompt, workingDir, durationMinutes } = srResult.data;
2160
- // Validate workingDir exists and is a directory
2161
- if (workingDir) {
2162
- try {
2163
- const stat = statSync(workingDir);
2164
- if (!stat.isDirectory()) {
2165
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
2166
- }
2167
- }
2168
- catch {
2169
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
2170
- }
2171
- }
2172
- const run = await this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60);
2173
- return { success: true, run };
2174
- });
2175
- this.app.delete('/api/scheduled/:id', async (req) => {
2176
- const { id } = req.params;
2177
- const run = this.scheduledRuns.get(id);
2178
- if (!run) {
2179
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
2180
- }
2181
- await this.stopScheduledRun(id);
2182
- return { success: true };
2183
- });
2184
- this.app.get('/api/scheduled/:id', async (req) => {
2185
- const { id } = req.params;
2186
- const run = this.scheduledRuns.get(id);
2187
- if (!run) {
2188
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
2189
- }
2190
- return run;
2191
- });
2192
- // Case management
2193
- const casesDir = join(homedir(), 'codeman-cases');
2194
- this.app.get('/api/cases', async () => {
2195
- const cases = [];
2196
- // Get cases from casesDir
2197
- try {
2198
- const entries = await fs.readdir(casesDir, { withFileTypes: true });
2199
- for (const e of entries) {
2200
- if (e.isDirectory()) {
2201
- cases.push({
2202
- name: e.name,
2203
- path: join(casesDir, e.name),
2204
- hasClaudeMd: existsSync(join(casesDir, e.name, 'CLAUDE.md')),
2205
- });
2206
- }
2207
- }
2208
- }
2209
- catch {
2210
- // casesDir may not exist yet
2211
- }
2212
- // Get linked cases
2213
- const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
2214
- try {
2215
- const linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
2216
- for (const [name, path] of Object.entries(linkedCases)) {
2217
- // Only add if not already in cases (avoid duplicates) and path exists
2218
- if (!cases.some((c) => c.name === name) && existsSync(path)) {
2219
- cases.push({
2220
- name,
2221
- path,
2222
- hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
2223
- });
2224
- }
2225
- }
2226
- }
2227
- catch (err) {
2228
- if (err.code !== 'ENOENT') {
2229
- console.warn('[Server] Failed to read linked cases:', err);
2230
- }
2231
- }
2232
- return cases;
2233
- });
2234
- this.app.post('/api/cases', async (req) => {
2235
- const result = CreateCaseSchema.safeParse(req.body);
2236
- if (!result.success) {
2237
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
2238
- }
2239
- const { name, description } = result.data;
2240
- const casePath = join(casesDir, name);
2241
- // Security: Path traversal protection - use relative path check
2242
- const resolvedPath = resolve(casePath);
2243
- const resolvedBase = resolve(casesDir);
2244
- const relPath = relative(resolvedBase, resolvedPath);
2245
- if (relPath.startsWith('..') || isAbsolute(relPath)) {
2246
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
2247
- }
2248
- if (existsSync(casePath)) {
2249
- return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
2250
- }
2251
- try {
2252
- mkdirSync(casePath, { recursive: true });
2253
- mkdirSync(join(casePath, 'src'), { recursive: true });
2254
- // Read settings to get custom template path
2255
- const templatePath = await this.getDefaultClaudeMdPath();
2256
- const claudeMd = generateClaudeMd(name, description || '', templatePath);
2257
- writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
2258
- // Write .claude/settings.local.json with hooks for desktop notifications
2259
- await writeHooksConfig(casePath);
2260
- this.broadcast('case:created', { name, path: casePath });
2261
- return { success: true, data: { case: { name, path: casePath } } };
2262
- }
2263
- catch (err) {
2264
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
2265
- }
2266
- });
2267
- // Link an existing folder as a case
2268
- this.app.post('/api/cases/link', async (req) => {
2269
- const lcResult = LinkCaseSchema.safeParse(req.body);
2270
- if (!lcResult.success) {
2271
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
2272
- }
2273
- const { name, path: folderPath } = lcResult.data;
2274
- // Expand ~ to home directory
2275
- const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath;
2276
- // Validate the folder exists
2277
- if (!existsSync(expandedPath)) {
2278
- return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${expandedPath}`);
2279
- }
2280
- // Check if case name already exists in casesDir
2281
- const casePath = join(casesDir, name);
2282
- if (existsSync(casePath)) {
2283
- return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
2284
- }
2285
- // Load existing linked cases
2286
- const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
2287
- let linkedCases = {};
2288
- try {
2289
- linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
2290
- }
2291
- catch (err) {
2292
- if (err.code !== 'ENOENT') {
2293
- console.warn('[Server] Failed to read linked cases:', err);
2294
- }
2295
- }
2296
- // Check if name is already linked
2297
- if (linkedCases[name]) {
2298
- return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `Case "${name}" is already linked to ${linkedCases[name]}`);
2299
- }
2300
- // Save the linked case
2301
- linkedCases[name] = expandedPath;
2302
- try {
2303
- const codemanDir = join(homedir(), '.codeman');
2304
- if (!existsSync(codemanDir)) {
2305
- mkdirSync(codemanDir, { recursive: true });
2306
- }
2307
- await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2));
2308
- this.broadcast('case:linked', { name, path: expandedPath });
2309
- return { success: true, data: { case: { name, path: expandedPath } } };
2310
- }
2311
- catch (err) {
2312
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
2313
- }
2314
- });
2315
- this.app.get('/api/cases/:name', async (req) => {
2316
- const { name } = req.params;
2317
- // First check linked cases
2318
- const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
2319
- try {
2320
- const linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
2321
- if (linkedCases[name]) {
2322
- const linkedPath = linkedCases[name];
2323
- return {
2324
- name,
2325
- path: linkedPath,
2326
- hasClaudeMd: existsSync(join(linkedPath, 'CLAUDE.md')),
2327
- linked: true,
2328
- };
2329
- }
2330
- }
2331
- catch {
2332
- // ENOENT or parse errors - fall through to casesDir check
2333
- }
2334
- // Then check casesDir
2335
- const casePath = join(casesDir, name);
2336
- if (!existsSync(casePath)) {
2337
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
2338
- }
2339
- return {
2340
- name,
2341
- path: casePath,
2342
- hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
2343
- };
2344
- });
2345
- // Read @fix_plan.md from a case directory (for wizard to detect existing plans)
2346
- this.app.get('/api/cases/:name/fix-plan', async (req) => {
2347
- const { name } = req.params;
2348
- // Get case path (check linked cases first, then casesDir)
2349
- let casePath = null;
2350
- const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
2351
- try {
2352
- const linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
2353
- if (linkedCases[name]) {
2354
- casePath = linkedCases[name];
2355
- }
2356
- }
2357
- catch {
2358
- // ENOENT or parse errors - fall through to casesDir
2359
- }
2360
- if (!casePath) {
2361
- casePath = join(casesDir, name);
2362
- }
2363
- const fixPlanPath = join(casePath, '@fix_plan.md');
2364
- if (!existsSync(fixPlanPath)) {
2365
- return { success: true, exists: false, content: null, todos: [] };
2366
- }
2367
- try {
2368
- const content = await fs.readFile(fixPlanPath, 'utf-8');
2369
- // Parse todos from the content (similar to ralph-tracker's importFixPlanMarkdown)
2370
- const todos = [];
2371
- const todoPattern = /^-\s*\[([ xX-])\]\s*(.+)$/;
2372
- const p0HeaderPattern = /^##\s*(High Priority|Critical|P0|Critical Path)/i;
2373
- const p1HeaderPattern = /^##\s*(Standard|P1|Medium Priority)/i;
2374
- const p2HeaderPattern = /^##\s*(Nice to Have|P2|Low Priority)/i;
2375
- const completedHeaderPattern = /^##\s*Completed/i;
2376
- let currentPriority = null;
2377
- let inCompletedSection = false;
2378
- for (const line of content.split('\n')) {
2379
- const trimmed = line.trim();
2380
- if (p0HeaderPattern.test(trimmed)) {
2381
- currentPriority = 'P0';
2382
- inCompletedSection = false;
2383
- continue;
2384
- }
2385
- if (p1HeaderPattern.test(trimmed)) {
2386
- currentPriority = 'P1';
2387
- inCompletedSection = false;
2388
- continue;
2389
- }
2390
- if (p2HeaderPattern.test(trimmed)) {
2391
- currentPriority = 'P2';
2392
- inCompletedSection = false;
2393
- continue;
2394
- }
2395
- if (completedHeaderPattern.test(trimmed)) {
2396
- inCompletedSection = true;
2397
- continue;
2398
- }
2399
- const match = trimmed.match(todoPattern);
2400
- if (match) {
2401
- const [, checkboxState, taskContent] = match;
2402
- let status;
2403
- if (inCompletedSection || checkboxState === 'x' || checkboxState === 'X') {
2404
- status = 'completed';
2405
- }
2406
- else if (checkboxState === '-') {
2407
- status = 'in_progress';
2408
- }
2409
- else {
2410
- status = 'pending';
2411
- }
2412
- todos.push({
2413
- content: taskContent.trim(),
2414
- status,
2415
- priority: inCompletedSection ? null : currentPriority,
2416
- });
2417
- }
2418
- }
2419
- // Calculate stats in a single pass for better performance
2420
- let pending = 0, inProgress = 0, completed = 0;
2421
- for (const t of todos) {
2422
- if (t.status === 'pending')
2423
- pending++;
2424
- else if (t.status === 'in_progress')
2425
- inProgress++;
2426
- else if (t.status === 'completed')
2427
- completed++;
2428
- }
2429
- const stats = { total: todos.length, pending, inProgress, completed };
2430
- return {
2431
- success: true,
2432
- exists: true,
2433
- content,
2434
- todos,
2435
- stats,
2436
- };
2437
- }
2438
- catch (err) {
2439
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read @fix_plan.md: ${err}`);
2440
- }
2441
- });
2442
- // Quick Start: Create case (if needed) and start interactive session in one click
2443
- this.app.post('/api/quick-start', async (req) => {
2444
- // Prevent unbounded session creation
2445
- if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
2446
- return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`);
2447
- }
2448
- const result = QuickStartSchema.safeParse(req.body);
2449
- if (!result.success) {
2450
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
2451
- }
2452
- const { caseName = 'testcase', mode = 'claude', openCodeConfig } = result.data;
2453
- // Check OpenCode availability if requested
2454
- if (mode === 'opencode') {
2455
- const { isOpenCodeAvailable } = await import('../utils/opencode-cli-resolver.js');
2456
- if (!isOpenCodeAvailable()) {
2457
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
2458
- }
2459
- }
2460
- const casePath = join(casesDir, caseName);
2461
- // Security: Path traversal protection - use relative path check
2462
- const resolvedPath = resolve(casePath);
2463
- const resolvedBase = resolve(casesDir);
2464
- const relPath = relative(resolvedBase, resolvedPath);
2465
- if (relPath.startsWith('..') || isAbsolute(relPath)) {
2466
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
2467
- }
2468
- // Create case folder and CLAUDE.md if it doesn't exist
2469
- if (!existsSync(casePath)) {
2470
- try {
2471
- mkdirSync(casePath, { recursive: true });
2472
- mkdirSync(join(casePath, 'src'), { recursive: true });
2473
- // Read settings to get custom template path
2474
- const templatePath = await this.getDefaultClaudeMdPath();
2475
- const claudeMd = generateClaudeMd(caseName, '', templatePath);
2476
- writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
2477
- // Write .claude/settings.local.json with hooks for desktop notifications
2478
- // (Claude-specific — OpenCode uses its own plugin system)
2479
- if (mode !== 'opencode') {
2480
- await writeHooksConfig(casePath);
2481
- }
2482
- this.broadcast('case:created', { name: caseName, path: casePath });
2483
- }
2484
- catch (err) {
2485
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
2486
- }
2487
- }
2488
- // Create a new session with the case as working directory
2489
- // Apply global Nice priority config and model config from settings
2490
- const niceConfig = await this.getGlobalNiceConfig();
2491
- const qsModelConfig = await this.getModelConfig();
2492
- const qsModel = mode === 'opencode' ? openCodeConfig?.model : mode !== 'shell' ? qsModelConfig?.defaultModel : undefined;
2493
- const qsClaudeModeConfig = await this.getClaudeModeConfig();
2494
- const session = new Session({
2495
- workingDir: casePath,
2496
- mux: this.mux,
2497
- useMux: true,
2498
- mode: mode,
2499
- niceConfig: niceConfig,
2500
- model: qsModel,
2501
- claudeMode: qsClaudeModeConfig.claudeMode,
2502
- allowedTools: qsClaudeModeConfig.allowedTools,
2503
- openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
2504
- });
2505
- // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
2506
- // so the initial state already has the phrase configured (only if globally enabled)
2507
- if (mode === 'claude' && this.store.getConfig().ralphEnabled) {
2508
- autoConfigureRalph(session, casePath, () => { }); // no broadcast yet
2509
- if (!session.ralphTracker.enabled) {
2510
- session.ralphTracker.enable();
2511
- session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
2512
- }
2513
- }
2514
- this.sessions.set(session.id, session);
2515
- this.store.incrementSessionsCreated();
2516
- this.persistSessionState(session);
2517
- await this.setupSessionListeners(session);
2518
- getLifecycleLog().log({
2519
- event: 'created',
2520
- sessionId: session.id,
2521
- name: session.name,
2522
- reason: 'quick_start',
2523
- });
2524
- this.broadcast('session:created', this.getSessionStateWithRespawn(session));
2525
- // Start in the appropriate mode
2526
- try {
2527
- if (mode === 'shell') {
2528
- await session.startShell();
2529
- getLifecycleLog().log({
2530
- event: 'started',
2531
- sessionId: session.id,
2532
- name: session.name,
2533
- mode: 'shell',
2534
- });
2535
- this.broadcast('session:interactive', { id: session.id, mode: 'shell' });
2536
- }
2537
- else {
2538
- // Both 'claude' and 'opencode' modes use startInteractive()
2539
- await session.startInteractive();
2540
- getLifecycleLog().log({
2541
- event: 'started',
2542
- sessionId: session.id,
2543
- name: session.name,
2544
- mode,
2545
- });
2546
- this.broadcast('session:interactive', { id: session.id, mode });
2547
- }
2548
- this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
2549
- // Save lastUsedCase to settings for TUI/web sync
2550
- try {
2551
- const settingsFilePath = join(homedir(), '.codeman', 'settings.json');
2552
- let settings = {};
2553
- try {
2554
- settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
2555
- }
2556
- catch (err) {
2557
- if (err.code !== 'ENOENT')
2558
- throw err;
2559
- }
2560
- settings.lastUsedCase = caseName;
2561
- const dir = dirname(settingsFilePath);
2562
- if (!existsSync(dir)) {
2563
- mkdirSync(dir, { recursive: true });
2564
- }
2565
- // Use async write to avoid blocking event loop
2566
- fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
2567
- // Non-critical but log for debugging
2568
- console.warn('[Server] Failed to save settings (lastUsedCase):', err);
2569
- });
2570
- }
2571
- catch (err) {
2572
- // Non-critical but log for debugging
2573
- console.warn('[Server] Failed to prepare settings update:', err);
2574
- }
2575
- return {
2576
- success: true,
2577
- sessionId: session.id,
2578
- casePath,
2579
- caseName,
2580
- };
2581
- }
2582
- catch (err) {
2583
- // Clean up session on error to prevent orphaned resources
2584
- await this.cleanupSession(session.id, true, 'quick_start_error');
2585
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
2586
- }
2587
- });
2588
- // ========== Ralph Loop Start (replaces 6-8 serial API calls from frontend) ==========
2589
- this.app.post('/api/ralph-loop/start', async (req) => {
2590
- // Prevent unbounded session creation
2591
- if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) {
2592
- return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`);
2593
- }
2594
- const rlResult = RalphLoopStartSchema.safeParse(req.body);
2595
- if (!rlResult.success) {
2596
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, rlResult.error.issues[0]?.message ?? 'Validation failed');
2597
- }
2598
- const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = rlResult.data;
2599
- const casePath = join(casesDir, caseName);
2600
- // Security: Path traversal protection
2601
- const rlResolvedPath = resolve(casePath);
2602
- const rlResolvedBase = resolve(casesDir);
2603
- const rlRelPath = relative(rlResolvedBase, rlResolvedPath);
2604
- if (rlRelPath.startsWith('..') || isAbsolute(rlRelPath)) {
2605
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
2606
- }
2607
- // Create case folder if it doesn't exist (reuse quick-start logic)
2608
- if (!existsSync(casePath)) {
2609
- try {
2610
- mkdirSync(casePath, { recursive: true });
2611
- mkdirSync(join(casePath, 'src'), { recursive: true });
2612
- const templatePath = await this.getDefaultClaudeMdPath();
2613
- const claudeMd = generateClaudeMd(caseName, '', templatePath);
2614
- writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
2615
- await writeHooksConfig(casePath);
2616
- this.broadcast('case:created', { name: caseName, path: casePath });
2617
- }
2618
- catch (err) {
2619
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
2620
- }
2621
- }
2622
- // Create session
2623
- const niceConfig = await this.getGlobalNiceConfig();
2624
- const rlModelConfig = await this.getModelConfig();
2625
- const rlClaudeModeConfig = await this.getClaudeModeConfig();
2626
- const session = new Session({
2627
- workingDir: casePath,
2628
- mux: this.mux,
2629
- useMux: true,
2630
- mode: 'claude',
2631
- niceConfig,
2632
- model: rlModelConfig?.defaultModel,
2633
- claudeMode: rlClaudeModeConfig.claudeMode,
2634
- allowedTools: rlClaudeModeConfig.allowedTools,
2635
- });
2636
- // Configure Ralph tracker
2637
- autoConfigureRalph(session, casePath, () => { });
2638
- if (!session.ralphTracker.enabled) {
2639
- session.ralphTracker.enable();
2640
- session.ralphTracker.enableAutoEnable();
2641
- }
2642
- session.ralphTracker.startLoop(completionPhrase, maxIterations ?? undefined);
2643
- // Build fix_plan markdown from plan items if provided
2644
- const enabledItems = planItems?.filter((i) => i.enabled) ?? [];
2645
- let planContent = '';
2646
- if (enabledItems.length > 0) {
2647
- const p0 = enabledItems.filter((i) => i.priority === 'P0');
2648
- const p1 = enabledItems.filter((i) => i.priority === 'P1');
2649
- const p2 = enabledItems.filter((i) => i.priority === 'P2');
2650
- const noPri = enabledItems.filter((i) => !i.priority);
2651
- planContent = '# Implementation Plan\n\n';
2652
- planContent += `Generated: ${new Date().toISOString().slice(0, 10)}\n\n`;
2653
- if (p0.length > 0) {
2654
- planContent += '## Critical Path (P0)\n\n';
2655
- p0.forEach((i) => {
2656
- planContent += `- [ ] ${i.content}\n`;
2657
- });
2658
- planContent += '\n';
2659
- }
2660
- if (p1.length > 0) {
2661
- planContent += '## Standard (P1)\n\n';
2662
- p1.forEach((i) => {
2663
- planContent += `- [ ] ${i.content}\n`;
2664
- });
2665
- planContent += '\n';
2666
- }
2667
- if (p2.length > 0) {
2668
- planContent += '## Nice-to-Have (P2)\n\n';
2669
- p2.forEach((i) => {
2670
- planContent += `- [ ] ${i.content}\n`;
2671
- });
2672
- planContent += '\n';
2673
- }
2674
- if (noPri.length > 0) {
2675
- planContent += '## Tasks\n\n';
2676
- noPri.forEach((i) => {
2677
- planContent += `- [ ] ${i.content}\n`;
2678
- });
2679
- planContent += '\n';
2680
- }
2681
- // Import into tracker and write to disk
2682
- session.ralphTracker.importFixPlanMarkdown(planContent);
2683
- const fixPlanPath = join(casePath, '@fix_plan.md');
2684
- writeFileSync(fixPlanPath, planContent, 'utf-8');
2685
- }
2686
- // Build full prompt
2687
- const hasPlan = enabledItems.length > 0;
2688
- let fullPrompt = taskDescription + '\n\n---\n\n';
2689
- if (hasPlan) {
2690
- fullPrompt += '## Task Plan\n\n';
2691
- fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n';
2692
- fullPrompt += '- Reference the plan at the start of each iteration\n';
2693
- fullPrompt += '- Update task checkboxes as you complete items\n';
2694
- fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n';
2695
- }
2696
- fullPrompt += '## Iteration Protocol\n\n';
2697
- fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n';
2698
- fullPrompt += '1. Check what work has already been done\n';
2699
- fullPrompt += '2. Make incremental progress toward completion\n';
2700
- fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n';
2701
- fullPrompt += '## Verification\n\n';
2702
- fullPrompt += 'After each significant change:\n';
2703
- fullPrompt += '- Run tests to verify (npm test, pytest, etc.)\n';
2704
- fullPrompt += '- Check for type/lint errors if applicable\n';
2705
- fullPrompt += '- If tests fail, read the error, fix it, and retry\n\n';
2706
- fullPrompt += '## Completion Criteria\n\n';
2707
- fullPrompt += `Output \`<promise>${completionPhrase}</promise>\` when ALL of the following are true:\n`;
2708
- fullPrompt += '- All requirements from the task description are implemented\n';
2709
- fullPrompt += '- All tests pass\n';
2710
- fullPrompt += '- Changes are committed\n\n';
2711
- fullPrompt += '## If Stuck\n\n';
2712
- fullPrompt += 'If you encounter the same error for 3+ iterations:\n';
2713
- fullPrompt += "1. Document what you've tried\n";
2714
- fullPrompt += '2. Identify the specific blocker\n';
2715
- fullPrompt += '3. Try an alternative approach\n';
2716
- fullPrompt += '4. If truly blocked, output `<promise>BLOCKED</promise>` with an explanation\n';
2717
- // Write prompt to file
2718
- const promptPath = join(casePath, '@ralph_prompt.md');
2719
- writeFileSync(promptPath, fullPrompt, 'utf-8');
2720
- // Register session
2721
- this.sessions.set(session.id, session);
2722
- this.store.incrementSessionsCreated();
2723
- this.persistSessionState(session);
2724
- await this.setupSessionListeners(session);
2725
- getLifecycleLog().log({
2726
- event: 'created',
2727
- sessionId: session.id,
2728
- name: session.name,
2729
- reason: 'ralph_loop_start',
2730
- });
2731
- this.broadcast('session:created', this.getSessionStateWithRespawn(session));
2732
- // Start interactive mode
2733
- try {
2734
- await session.startInteractive();
2735
- getLifecycleLog().log({
2736
- event: 'started',
2737
- sessionId: session.id,
2738
- name: session.name,
2739
- mode: 'claude',
2740
- });
2741
- this.broadcast('session:interactive', { id: session.id, mode: 'claude' });
2742
- this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
2743
- }
2744
- catch (err) {
2745
- await this.cleanupSession(session.id, true, 'ralph_loop_start_error');
2746
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
2747
- }
2748
- // Enable respawn if requested
2749
- if (enableRespawn) {
2750
- const ralphUpdatePrompt = 'Before /clear: Update CLAUDE.md with discoveries and notes, mark completed tasks in @fix_plan.md, write a brief progress summary to a file so the next iteration can continue seamlessly.';
2751
- const ralphKickstartPrompt = `You are in a Ralph Wiggum loop. Read @fix_plan.md for task status, continue on the next uncompleted task, output <promise>${completionPhrase}</promise> when ALL tasks are complete.`;
2752
- const controller = new RespawnController(session, {
2753
- updatePrompt: ralphUpdatePrompt,
2754
- sendClear: true,
2755
- sendInit: true,
2756
- kickstartPrompt: ralphKickstartPrompt,
2757
- });
2758
- this.respawnControllers.set(session.id, controller);
2759
- this.setupRespawnListeners(session.id, controller);
2760
- controller.start();
2761
- this.saveRespawnConfig(session.id, controller.getConfig());
2762
- this.persistSessionState(session);
2763
- this.broadcast('respawn:started', {
2764
- sessionId: session.id,
2765
- status: controller.getStatus(),
2766
- });
2767
- }
2768
- // Save lastUsedCase
2769
- try {
2770
- const settingsFilePath = join(homedir(), '.codeman', 'settings.json');
2771
- let settings = {};
2772
- try {
2773
- settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
2774
- }
2775
- catch {
2776
- /* ignore */
2777
- }
2778
- settings.lastUsedCase = caseName;
2779
- const dir = dirname(settingsFilePath);
2780
- if (!existsSync(dir))
2781
- mkdirSync(dir, { recursive: true });
2782
- fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch(() => { });
2783
- }
2784
- catch {
2785
- /* non-critical */
2786
- }
2787
- const sessionId = session.id;
2788
- // Async: poll for CLI readiness, then send prompt
2789
- setImmediate(() => {
2790
- const pollReady = async () => {
2791
- for (let attempt = 0; attempt < 60; attempt++) {
2792
- await new Promise((r) => setTimeout(r, 500));
2793
- const s = this.sessions.get(sessionId);
2794
- if (!s)
2795
- return; // session was deleted
2796
- // Check terminal output for prompt indicator
2797
- const termBuf = s.getTerminalBuffer().slice(-2048);
2798
- if (termBuf.includes('❯') || termBuf.includes('tokens')) {
2799
- break;
2800
- }
2801
- }
2802
- // Small extra delay for CLI to settle
2803
- await new Promise((r) => setTimeout(r, 2000));
2804
- const s = this.sessions.get(sessionId);
2805
- if (!s)
2806
- return;
2807
- try {
2808
- await s.writeViaMux('Read @ralph_prompt.md and follow the instructions. Start working immediately.\r');
2809
- }
2810
- catch (err) {
2811
- console.warn(`[RalphLoop] Failed to send prompt to session ${sessionId}:`, getErrorMessage(err));
2812
- }
2813
- };
2814
- pollReady().catch((err) => console.error('[RalphLoop] pollReady error:', err));
2815
- });
2816
- return {
2817
- success: true,
2818
- data: { sessionId, caseName },
2819
- };
2820
- });
2821
- this.app.post('/api/generate-plan', async (req) => {
2822
- const gpResult = GeneratePlanSchema.safeParse(req.body);
2823
- if (!gpResult.success) {
2824
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
2825
- }
2826
- const { taskDescription, detailLevel = 'standard' } = gpResult.data;
2827
- // Build sophisticated prompt based on Ralph Wiggum methodology
2828
- const detailConfig = {
2829
- brief: { style: 'high-level milestones', testDepth: 'basic' },
2830
- standard: { style: 'balanced implementation steps', testDepth: 'thorough' },
2831
- detailed: {
2832
- style: 'granular sub-tasks with full TDD coverage',
2833
- testDepth: 'comprehensive',
2834
- },
2835
- };
2836
- const levelConfig = detailConfig[detailLevel] || detailConfig.standard;
2837
- const prompt = `You are an expert software architect breaking down a task into a thorough implementation plan.
2838
-
2839
- ## TASK TO IMPLEMENT
2840
- ${taskDescription}
2841
-
2842
- ## YOUR MISSION
2843
- Create a detailed, actionable implementation plan following Test-Driven Development (TDD) methodology.
2844
- Think deeply about:
2845
- - What are ALL the components, modules, and features needed?
2846
- - What could go wrong? Add defensive steps for error handling.
2847
- - How will we verify each part works? Tests before implementation.
2848
- - What edge cases need handling?
2849
- - What's the logical order of dependencies?
2850
-
2851
- ## DETAIL LEVEL: ${detailLevel.toUpperCase()}
2852
- Style: ${levelConfig.style}
2853
- Generate as many steps as needed to properly cover the task - don't artificially limit yourself.
2854
- For complex projects, this could be 30, 50, or even 100+ steps. Quality over brevity.
2855
-
2856
- ## PLAN STRUCTURE
2857
-
2858
- Your plan MUST include these phases in order:
2859
-
2860
- ### Phase 1: Foundation & Setup
2861
- - Project structure, dependencies, configuration
2862
- - Database schemas, type definitions, interfaces
2863
-
2864
- ### Phase 2: Core Implementation (TDD Cycle)
2865
- For EACH feature:
2866
- 1. Write failing tests first (unit tests)
2867
- 2. Implement the feature
2868
- 3. Run tests, debug until passing
2869
- 4. Refactor if needed
2870
-
2871
- ### Phase 3: Integration & Edge Cases
2872
- - Integration tests for feature interactions
2873
- - Edge case handling (errors, boundaries, invalid input)
2874
- - Error messages and user feedback
2875
-
2876
- ### Phase 4: Verification & Hardening
2877
- - Run full test suite
2878
- - Fix any failing tests
2879
- - Add missing test coverage
2880
- - Final verification that ALL requirements are met
2881
-
2882
- ## OUTPUT FORMAT
2883
- Return ONLY a JSON array. Each item MUST have:
2884
- - id: unique identifier (e.g., "P0-001", "P1-002")
2885
- - content: specific action (verb phrase, 15-120 chars, be descriptive!)
2886
- - priority: "P0" (critical/blocking), "P1" (required), "P2" (enhancement)
2887
- - verificationCriteria: HOW to verify this step is complete (required!)
2888
- - tddPhase: "setup" | "test" | "impl" | "verify"
2889
- - dependencies: array of task IDs this depends on (empty if none)
2890
-
2891
- ## EXAMPLE OUTPUT
2892
- [
2893
- {"id": "P0-001", "content": "Create project structure with src/, tests/, and config directories", "priority": "P0", "verificationCriteria": "Directories exist, package.json initialized", "tddPhase": "setup", "dependencies": []},
2894
- {"id": "P0-002", "content": "Define TypeScript interfaces for User, Session, and AuthToken types", "priority": "P0", "verificationCriteria": "Types compile without errors, exported from types.ts", "tddPhase": "setup", "dependencies": ["P0-001"]},
2895
- {"id": "P0-003", "content": "Write failing unit tests for password hashing (valid password, empty, too short)", "priority": "P0", "verificationCriteria": "Tests exist, fail with 'not implemented'", "tddPhase": "test", "dependencies": ["P0-002"]},
2896
- {"id": "P0-004", "content": "Implement password hashing with bcrypt, configurable salt rounds", "priority": "P0", "verificationCriteria": "npm test -- --grep='password' passes", "tddPhase": "impl", "dependencies": ["P0-003"]},
2897
- {"id": "P0-005", "content": "Write failing tests for JWT token generation and validation", "priority": "P0", "verificationCriteria": "Tests exist, fail with 'not implemented'", "tddPhase": "test", "dependencies": ["P0-004"]},
2898
- {"id": "P0-006", "content": "Implement JWT service with access/refresh token support", "priority": "P0", "verificationCriteria": "npm test -- --grep='JWT' passes", "tddPhase": "impl", "dependencies": ["P0-005"]},
2899
- {"id": "P1-001", "content": "Write integration tests for login flow (valid creds, invalid, locked account)", "priority": "P1", "verificationCriteria": "Integration tests exist, fail until endpoint implemented", "tddPhase": "test", "dependencies": ["P0-006"]},
2900
- {"id": "P1-002", "content": "Implement login endpoint with rate limiting and audit logging", "priority": "P1", "verificationCriteria": "All login tests pass, endpoint returns 200/401 correctly", "tddPhase": "impl", "dependencies": ["P1-001"]},
2901
- {"id": "P1-003", "content": "Run full test suite and verify all tests pass", "priority": "P1", "verificationCriteria": "npm test exits with code 0, coverage > 80%", "tddPhase": "verify", "dependencies": ["P1-002"]}
2902
- ]
2903
-
2904
- ## CRITICAL RULES
2905
- 1. EVERY task MUST have verificationCriteria - this is non-negotiable!
2906
- 2. EVERY implementation step should have a corresponding test step BEFORE it
2907
- 3. Use tddPhase: "test" for writing tests, "impl" for implementation
2908
- 4. Dependencies must form a valid DAG - no cycles
2909
- 5. Be SPECIFIC - not "Add tests" but "Write tests for X covering Y and Z"
2910
- 6. End with verification that ALL original requirements are met
2911
- 7. Use P0 for foundation and core features, P1 for required work, P2 for nice-to-have
2912
-
2913
- NOW: Generate the implementation plan for the task above. Think step by step.`;
2914
- // Create temporary session for the AI call using Opus 4.5 for deep reasoning
2915
- const session = new Session({
2916
- workingDir: process.cwd(),
2917
- mux: this.mux,
2918
- useMux: false, // No mux needed for one-shot
2919
- mode: 'claude',
2920
- });
2921
- // Use configured model for plan generation, falling back to opus
2922
- const planModelConfig = await this.getModelConfig();
2923
- const modelToUse = planModelConfig?.agentTypeOverrides?.implement || planModelConfig?.defaultModel || 'opus';
2924
- try {
2925
- const { result, cost } = await session.runPrompt(prompt, { model: modelToUse });
2926
- // Parse JSON from result
2927
- const jsonMatch = result.match(/\[[\s\S]*\]/);
2928
- if (!jsonMatch) {
2929
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan - no JSON array found');
2930
- }
2931
- let items;
2932
- try {
2933
- const parsed = JSON.parse(jsonMatch[0]);
2934
- if (!Array.isArray(parsed)) {
2935
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Invalid response - expected array');
2936
- }
2937
- // Validate and normalize items with enhanced fields
2938
- items = parsed.map((item, idx) => {
2939
- if (typeof item !== 'object' || item === null) {
2940
- return {
2941
- id: `task-${idx}`,
2942
- content: `Step ${idx + 1}`,
2943
- priority: null,
2944
- verificationCriteria: 'Task completed successfully',
2945
- status: 'pending',
2946
- attempts: 0,
2947
- version: 1,
2948
- };
2949
- }
2950
- const obj = item;
2951
- const content = typeof obj.content === 'string' ? obj.content.slice(0, 200) : `Step ${idx + 1}`;
2952
- let priority = null;
2953
- if (obj.priority === 'P0' || obj.priority === 'P1' || obj.priority === 'P2') {
2954
- priority = obj.priority;
2955
- }
2956
- // Parse tddPhase
2957
- let tddPhase;
2958
- if (obj.tddPhase === 'setup' ||
2959
- obj.tddPhase === 'test' ||
2960
- obj.tddPhase === 'impl' ||
2961
- obj.tddPhase === 'verify') {
2962
- tddPhase = obj.tddPhase;
2963
- }
2964
- return {
2965
- id: obj.id ? String(obj.id) : `task-${idx}`,
2966
- content,
2967
- priority,
2968
- verificationCriteria: typeof obj.verificationCriteria === 'string' ? obj.verificationCriteria : 'Task completed successfully',
2969
- tddPhase,
2970
- dependencies: Array.isArray(obj.dependencies) ? obj.dependencies.map(String) : [],
2971
- status: 'pending',
2972
- attempts: 0,
2973
- version: 1,
2974
- };
2975
- });
2976
- // No artificial limit - let Claude generate what's needed
2977
- }
2978
- catch (parseErr) {
2979
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan JSON: ' + getErrorMessage(parseErr));
2980
- }
2981
- return {
2982
- success: true,
2983
- data: { items, costUsd: cost },
2984
- };
2985
- }
2986
- catch (err) {
2987
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Plan generation failed: ' + getErrorMessage(err));
2988
- }
2989
- finally {
2990
- // Clean up the temporary session
2991
- try {
2992
- await session.stop();
2993
- }
2994
- catch {
2995
- // Ignore cleanup errors
2996
- }
2997
- }
2998
- });
2999
- // Generate detailed implementation plan using subagent orchestration
3000
- // This spawns multiple specialist subagents in parallel for thorough analysis
3001
- this.app.post('/api/generate-plan-detailed', async (req) => {
3002
- const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body);
3003
- if (!gpdResult.success) {
3004
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
3005
- }
3006
- const { taskDescription, caseName } = gpdResult.data;
3007
- // Determine output directory for saving wizard results
3008
- let outputDir;
3009
- if (caseName) {
3010
- const casesDir = join(homedir(), 'codeman-cases');
3011
- const casePath = join(casesDir, caseName);
3012
- // Security: Path traversal protection - use relative path check
3013
- const resolvedCase = resolve(casePath);
3014
- const resolvedBase = resolve(casesDir);
3015
- const relPath = relative(resolvedBase, resolvedCase);
3016
- if (!relPath.startsWith('..') && !isAbsolute(relPath) && existsSync(casePath)) {
3017
- outputDir = join(casePath, 'ralph-wizard');
3018
- // Clear old ralph-wizard directory to ensure fresh prompts for each generation
3019
- // This prevents stale prompts from previous runs being shown when clicking on agents
3020
- if (existsSync(outputDir)) {
3021
- try {
3022
- rmSync(outputDir, { recursive: true, force: true });
3023
- console.log(`[API] Cleared old ralph-wizard directory: ${outputDir}`);
3024
- }
3025
- catch (err) {
3026
- console.warn(`[API] Failed to clear ralph-wizard directory:`, err);
3027
- }
3028
- }
3029
- }
3030
- }
3031
- const detailedModelConfig = await this.getModelConfig();
3032
- const orchestrator = new PlanOrchestrator(this.mux, process.cwd(), outputDir, detailedModelConfig ?? undefined);
3033
- // Store orchestrator for potential cancellation via API (not on disconnect)
3034
- // Plan generation continues even if browser disconnects - only explicit cancel stops it
3035
- const orchestratorId = `plan-${Date.now()}`;
3036
- this.activePlanOrchestrators.set(orchestratorId, orchestrator);
3037
- // Broadcast the orchestrator ID so frontend can cancel if needed
3038
- this.broadcast('plan:started', { orchestratorId });
3039
- // Track progress for SSE updates
3040
- const progressUpdates = [];
3041
- const onProgress = (phase, detail) => {
3042
- const update = { phase, detail, timestamp: Date.now() };
3043
- progressUpdates.push(update);
3044
- // Broadcast progress to connected clients
3045
- this.broadcast('plan:progress', update);
3046
- };
3047
- // Broadcast plan subagent events for UI visibility
3048
- const onSubagent = (event) => {
3049
- this.broadcast('plan:subagent', event);
3050
- };
3051
- try {
3052
- const result = await orchestrator.generateDetailedPlan(taskDescription, onProgress, onSubagent);
3053
- // Clean up orchestrator from active map
3054
- this.activePlanOrchestrators.delete(orchestratorId);
3055
- this.broadcast('plan:completed', { orchestratorId, success: result.success });
3056
- if (!result.success) {
3057
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error || 'Plan generation failed');
3058
- }
3059
- return {
3060
- success: true,
3061
- data: {
3062
- items: result.items,
3063
- costUsd: result.costUsd,
3064
- metadata: result.metadata,
3065
- progressLog: progressUpdates,
3066
- orchestratorId,
3067
- },
3068
- };
3069
- }
3070
- catch (err) {
3071
- // Clean up on error too
3072
- this.activePlanOrchestrators.delete(orchestratorId);
3073
- this.broadcast('plan:completed', {
3074
- orchestratorId,
3075
- success: false,
3076
- error: getErrorMessage(err),
3077
- });
3078
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Detailed plan generation failed: ' + getErrorMessage(err));
3079
- }
3080
- });
3081
- // Cancel active plan generation
3082
- this.app.post('/api/cancel-plan-generation', async (req) => {
3083
- const cpResult = CancelPlanSchema.safeParse(req.body);
3084
- if (!cpResult.success) {
3085
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
3086
- }
3087
- const { orchestratorId } = cpResult.data;
3088
- // If specific orchestrator ID provided, cancel just that one
3089
- if (orchestratorId) {
3090
- const orchestrator = this.activePlanOrchestrators.get(orchestratorId);
3091
- if (!orchestrator) {
3092
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Plan generation not found or already completed');
3093
- }
3094
- console.log(`[API] Cancelling plan generation ${orchestratorId}`);
3095
- await orchestrator.cancel();
3096
- this.activePlanOrchestrators.delete(orchestratorId);
3097
- this.broadcast('plan:cancelled', { orchestratorId });
3098
- return { success: true, data: { cancelled: orchestratorId } };
3099
- }
3100
- // Otherwise cancel all active plan generations
3101
- const cancelled = [];
3102
- for (const [id, orchestrator] of this.activePlanOrchestrators) {
3103
- console.log(`[API] Cancelling plan generation ${id}`);
3104
- await orchestrator.cancel();
3105
- cancelled.push(id);
3106
- this.broadcast('plan:cancelled', { orchestratorId: id });
3107
- }
3108
- this.activePlanOrchestrators.clear();
3109
- return { success: true, data: { cancelled } };
3110
- });
3111
- // Get ralph-wizard files for a case (prompts and results)
3112
- this.app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
3113
- const { caseName } = req.params;
3114
- const casesDir = join(homedir(), 'codeman-cases');
3115
- let casePath = join(casesDir, caseName);
3116
- // Security: Path traversal protection - use relative path check
3117
- const resolvedCase = resolve(casePath);
3118
- const resolvedBase = resolve(casesDir);
3119
- const relPath = relative(resolvedBase, resolvedCase);
3120
- if (relPath.startsWith('..') || isAbsolute(relPath)) {
3121
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
3122
- }
3123
- // Check linked cases if path doesn't exist
3124
- if (!existsSync(casePath)) {
3125
- const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
3126
- try {
3127
- const linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
3128
- if (linkedCases[caseName]) {
3129
- casePath = linkedCases[caseName];
3130
- }
3131
- }
3132
- catch {
3133
- // No linked cases file
3134
- }
3135
- }
3136
- const wizardDir = join(casePath, 'ralph-wizard');
3137
- if (!existsSync(wizardDir)) {
3138
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Ralph wizard directory not found');
3139
- }
3140
- // List all subdirectories and their files
3141
- const files = [];
3142
- const entries = readdirSync(wizardDir, { withFileTypes: true });
3143
- for (const entry of entries) {
3144
- if (entry.isDirectory()) {
3145
- const agentDir = join(wizardDir, entry.name);
3146
- const agentFiles = {
3147
- agentType: entry.name,
3148
- };
3149
- if (existsSync(join(agentDir, 'prompt.md'))) {
3150
- agentFiles.promptFile = `${entry.name}/prompt.md`;
3151
- }
3152
- if (existsSync(join(agentDir, 'result.json'))) {
3153
- agentFiles.resultFile = `${entry.name}/result.json`;
3154
- }
3155
- if (agentFiles.promptFile || agentFiles.resultFile) {
3156
- files.push(agentFiles);
3157
- }
3158
- }
3159
- }
3160
- return { success: true, data: { files, caseName } };
3161
- });
3162
- // Read a specific ralph-wizard file
3163
- // Cache disabled to ensure fresh prompts when starting new plan generations
3164
- this.app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
3165
- const { caseName, filePath } = req.params;
3166
- const casesDir = join(homedir(), 'codeman-cases');
3167
- let casePath = join(casesDir, caseName);
3168
- // Prevent browser caching - prompts change between plan generations
3169
- reply.header('Cache-Control', 'no-store, no-cache, must-revalidate');
3170
- reply.header('Pragma', 'no-cache');
3171
- reply.header('Expires', '0');
3172
- // Security: Path traversal protection for case name - use relative path check
3173
- const resolvedCase = resolve(casePath);
3174
- const resolvedBase = resolve(casesDir);
3175
- const relPath = relative(resolvedBase, resolvedCase);
3176
- if (relPath.startsWith('..') || isAbsolute(relPath)) {
3177
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
3178
- }
3179
- // Check linked cases if path doesn't exist
3180
- if (!existsSync(casePath)) {
3181
- const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
3182
- try {
3183
- const linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
3184
- if (linkedCases[caseName]) {
3185
- casePath = linkedCases[caseName];
3186
- }
3187
- }
3188
- catch {
3189
- // No linked cases file
3190
- }
3191
- }
3192
- const wizardDir = join(casePath, 'ralph-wizard');
3193
- // Decode the file path (it may be URL encoded)
3194
- const decodedPath = decodeURIComponent(filePath);
3195
- const fullPath = join(wizardDir, decodedPath);
3196
- // Security: ensure path is within wizard directory
3197
- const resolvedPath = resolve(fullPath);
3198
- const resolvedWizard = resolve(wizardDir);
3199
- if (!resolvedPath.startsWith(resolvedWizard)) {
3200
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid file path');
3201
- }
3202
- let content;
3203
- try {
3204
- content = await fs.readFile(fullPath, 'utf-8');
3205
- }
3206
- catch (err) {
3207
- if (err.code === 'ENOENT') {
3208
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
3209
- }
3210
- throw err;
3211
- }
3212
- const isJson = filePath.endsWith('.json');
3213
- // Parse JSON content safely (may contain invalid JSON or unescaped control characters)
3214
- let parsed = null;
3215
- if (isJson) {
3216
- try {
3217
- parsed = JSON.parse(content);
3218
- }
3219
- catch {
3220
- // Try repairing common JSON issues (unescaped control characters, trailing commas)
3221
- try {
3222
- let repaired = content;
3223
- // Fix trailing commas before closing brackets
3224
- repaired = repaired.replace(/,(\s*[\]}])/g, '$1');
3225
- // Fix unescaped control characters within JSON strings
3226
- repaired = repaired.replace(/"([^"\\]|\\.)*"/g, (match) => {
3227
- return match
3228
- .replace(/\n/g, '\\n')
3229
- .replace(/\r/g, '\\r')
3230
- .replace(/\t/g, '\\t')
3231
- .replace(
3232
- // eslint-disable-next-line no-control-regex
3233
- /[\x00-\x1f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
3234
- });
3235
- parsed = JSON.parse(repaired);
3236
- }
3237
- catch {
3238
- // Still invalid - return null for parsed, content available as raw string
3239
- }
3240
- }
3241
- }
3242
- return {
3243
- success: true,
3244
- data: {
3245
- content,
3246
- filePath: decodedPath,
3247
- isJson,
3248
- parsed,
3249
- },
3250
- };
3251
- });
3252
- // ============ Plan Management Endpoints ============
3253
- // These endpoints support runtime plan adaptation with checkpoints, failure tracking, and versioning
3254
- // Update a specific plan task (status, attempts, errors)
3255
- this.app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => {
3256
- const { id, taskId } = req.params;
3257
- const session = this.sessions.get(id);
3258
- if (!session) {
3259
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
3260
- }
3261
- const tracker = session.ralphTracker;
3262
- if (!tracker) {
3263
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
3264
- }
3265
- const ptuResult = PlanTaskUpdateSchema.safeParse(req.body);
3266
- if (!ptuResult.success) {
3267
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
3268
- }
3269
- const update = ptuResult.data;
3270
- const result = tracker.updatePlanTask(taskId, update);
3271
- if (!result.success) {
3272
- return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Task not found');
3273
- }
3274
- this.broadcast('session:planTaskUpdate', { sessionId: id, taskId, update: result.task });
3275
- return { success: true, data: result.task };
3276
- });
3277
- // Trigger a checkpoint review (at iterations 5, 10, 20, etc.)
3278
- this.app.post('/api/sessions/:id/plan/checkpoint', async (req) => {
3279
- const { id } = req.params;
3280
- const session = this.sessions.get(id);
3281
- if (!session) {
3282
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
3283
- }
3284
- const tracker = session.ralphTracker;
3285
- if (!tracker) {
3286
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
3287
- }
3288
- const checkpoint = tracker.generateCheckpointReview();
3289
- this.broadcast('session:planCheckpoint', { sessionId: id, checkpoint });
3290
- return { success: true, data: checkpoint };
3291
- });
3292
- // Get plan version history
3293
- this.app.get('/api/sessions/:id/plan/history', async (req) => {
3294
- const { id } = req.params;
3295
- const session = this.sessions.get(id);
3296
- if (!session) {
3297
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
3298
- }
3299
- const tracker = session.ralphTracker;
3300
- if (!tracker) {
3301
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
3302
- }
3303
- return { success: true, data: tracker.getPlanHistory() };
3304
- });
3305
- // Rollback to a previous plan version
3306
- this.app.post('/api/sessions/:id/plan/rollback/:version', async (req) => {
3307
- const { id, version } = req.params;
3308
- const session = this.sessions.get(id);
3309
- if (!session) {
3310
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
3311
- }
3312
- const tracker = session.ralphTracker;
3313
- if (!tracker) {
3314
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
3315
- }
3316
- const result = tracker.rollbackToVersion(parseInt(version, 10));
3317
- if (!result.success) {
3318
- return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Version not found');
3319
- }
3320
- this.broadcast('session:planRollback', { sessionId: id, version: parseInt(version, 10) });
3321
- return { success: true, data: result.plan };
3322
- });
3323
- // Add a new task to the plan (for runtime adaptation)
3324
- this.app.post('/api/sessions/:id/plan/task', async (req) => {
3325
- const { id } = req.params;
3326
- const session = this.sessions.get(id);
3327
- if (!session) {
3328
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
3329
- }
3330
- const tracker = session.ralphTracker;
3331
- if (!tracker) {
3332
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
3333
- }
3334
- const ptaResult = PlanTaskAddSchema.safeParse(req.body);
3335
- if (!ptaResult.success) {
3336
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
3337
- }
3338
- const task = ptaResult.data;
3339
- const result = tracker.addPlanTask(task);
3340
- this.broadcast('session:planTaskAdded', { sessionId: id, task: result.task });
3341
- return { success: true, data: result.task };
3342
- });
3343
- // ============ App Settings Endpoints ============
3344
- const settingsPath = join(homedir(), '.codeman', 'settings.json');
3345
- this.app.get('/api/settings', async () => {
3346
- try {
3347
- const content = await fs.readFile(settingsPath, 'utf-8');
3348
- return JSON.parse(content);
3349
- }
3350
- catch (err) {
3351
- if (err.code !== 'ENOENT') {
3352
- console.error('Failed to read settings:', err);
3353
- }
3354
- }
3355
- return {};
3356
- });
3357
- this.app.put('/api/settings', async (req) => {
3358
- const settingsResult = SettingsUpdateSchema.safeParse(req.body);
3359
- if (!settingsResult.success) {
3360
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid settings');
3361
- }
3362
- const settings = settingsResult.data;
3363
- try {
3364
- const dir = dirname(settingsPath);
3365
- if (!existsSync(dir)) {
3366
- mkdirSync(dir, { recursive: true });
3367
- }
3368
- let existing = {};
3369
- try {
3370
- existing = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
3371
- }
3372
- catch {
3373
- /* ignore */
3374
- }
3375
- const merged = { ...existing, ...settings };
3376
- await fs.writeFile(settingsPath, JSON.stringify(merged, null, 2));
3377
- // Handle subagent tracking toggle dynamically
3378
- const subagentEnabled = settings.subagentTrackingEnabled ?? true;
3379
- if (subagentEnabled && !subagentWatcher.isRunning()) {
3380
- subagentWatcher.start();
3381
- console.log('Subagent watcher started via settings change');
3382
- }
3383
- else if (!subagentEnabled && subagentWatcher.isRunning()) {
3384
- subagentWatcher.stop();
3385
- console.log('Subagent watcher stopped via settings change');
3386
- }
3387
- // Handle image watcher toggle dynamically
3388
- const imageWatcherEnabled = settings.imageWatcherEnabled ?? false;
3389
- if (imageWatcherEnabled && !imageWatcher.isRunning()) {
3390
- imageWatcher.start();
3391
- // Re-watch all active sessions that have image watcher enabled
3392
- for (const session of this.sessions.values()) {
3393
- if (session.imageWatcherEnabled) {
3394
- imageWatcher.watchSession(session.id, session.workingDir);
3395
- }
3396
- }
3397
- console.log('Image watcher started via settings change');
3398
- }
3399
- else if (!imageWatcherEnabled && imageWatcher.isRunning()) {
3400
- imageWatcher.stop();
3401
- console.log('Image watcher stopped via settings change');
3402
- }
3403
- // Handle tunnel toggle dynamically
3404
- if ('tunnelEnabled' in settings) {
3405
- const tunnelEnabled = settings.tunnelEnabled;
3406
- if (tunnelEnabled && !this.tunnelManager.isRunning()) {
3407
- this.tunnelManager.start(this.port, this.https);
3408
- console.log('Tunnel started via settings change');
3409
- }
3410
- else if (tunnelEnabled && this.tunnelManager.isRunning() && this.tunnelManager.getUrl()) {
3411
- // Tunnel already running — re-emit so the client gets the URL
3412
- this.broadcast('tunnel:started', { url: this.tunnelManager.getUrl() });
3413
- console.log('Tunnel already running, re-broadcast URL to client');
3414
- }
3415
- else if (!tunnelEnabled && this.tunnelManager.isRunning()) {
3416
- this.tunnelManager.stop();
3417
- console.log('Tunnel stopped via settings change');
3418
- }
3419
- }
3420
- return { success: true };
3421
- }
3422
- catch (err) {
3423
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
3424
- }
3425
- });
3426
- // ============ Model Configuration Endpoints ============
3427
- this.app.get('/api/execution/model-config', async () => {
3428
- try {
3429
- const content = await fs.readFile(settingsPath, 'utf-8');
3430
- const settings = JSON.parse(content);
3431
- return { success: true, data: settings.modelConfig || {} };
3432
- }
3433
- catch (err) {
3434
- if (err.code !== 'ENOENT') {
3435
- console.error('Failed to read model config:', err);
3436
- }
3437
- return { success: true, data: {} };
3438
- }
3439
- });
3440
- this.app.put('/api/execution/model-config', async (req) => {
3441
- const mcResult = ModelConfigUpdateSchema.safeParse(req.body);
3442
- if (!mcResult.success) {
3443
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid model config');
3444
- }
3445
- const modelConfig = mcResult.data;
3446
- try {
3447
- let settings = {};
3448
- try {
3449
- const content = await fs.readFile(settingsPath, 'utf-8');
3450
- settings = JSON.parse(content);
3451
- }
3452
- catch {
3453
- // File doesn't exist yet, start fresh
3454
- }
3455
- settings.modelConfig = modelConfig;
3456
- const dir = dirname(settingsPath);
3457
- if (!existsSync(dir)) {
3458
- mkdirSync(dir, { recursive: true });
3459
- }
3460
- await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
3461
- return { success: true };
3462
- }
3463
- catch (err) {
3464
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
3465
- }
3466
- });
3467
- // ============ CPU Priority Endpoints ============
3468
- // Get Nice priority config for a session
3469
- this.app.get('/api/sessions/:id/cpu-limit', async (req) => {
3470
- const { id } = req.params;
3471
- const session = this.sessions.get(id);
3472
- if (!session) {
3473
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
3474
- }
3475
- return {
3476
- success: true,
3477
- nice: session.niceConfig,
3478
- };
3479
- });
3480
- // Update Nice priority config for a session
3481
- // Note: Changes only apply to NEW sessions, not running ones
3482
- this.app.post('/api/sessions/:id/cpu-limit', async (req) => {
3483
- const { id } = req.params;
3484
- const session = this.sessions.get(id);
3485
- if (!session) {
3486
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
3487
- }
3488
- const clResult = CpuLimitSchema.safeParse(req.body);
3489
- if (!clResult.success) {
3490
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
3491
- }
3492
- const body = clResult.data;
3493
- session.setNice(body);
3494
- this.persistSessionState(session);
3495
- this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) });
3496
- return {
3497
- success: true,
3498
- nice: session.niceConfig,
3499
- note: 'Nice priority only affects newly created mux sessions, not currently running ones.',
3500
- };
3501
- });
3502
- // ============ Subagent Window State Endpoints ============
3503
- // Persists minimized/open window states for cross-browser sync
3504
- const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json');
3505
- this.app.get('/api/subagent-window-states', async () => {
3506
- try {
3507
- const content = await fs.readFile(windowStatesPath, 'utf-8');
3508
- return JSON.parse(content);
3509
- }
3510
- catch (err) {
3511
- if (err.code !== 'ENOENT') {
3512
- console.error('Failed to read subagent window states:', err);
3513
- }
3514
- }
3515
- return { minimized: {}, open: [] };
3516
- });
3517
- this.app.put('/api/subagent-window-states', async (req) => {
3518
- const swResult = SubagentWindowStatesSchema.safeParse(req.body);
3519
- if (!swResult.success) {
3520
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid window states');
3521
- }
3522
- const states = swResult.data;
3523
- try {
3524
- const dir = dirname(windowStatesPath);
3525
- if (!existsSync(dir)) {
3526
- mkdirSync(dir, { recursive: true });
3527
- }
3528
- await fs.writeFile(windowStatesPath, JSON.stringify(states, null, 2));
3529
- return { success: true };
3530
- }
3531
- catch (err) {
3532
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
3533
- }
3534
- });
3535
- // ============ Subagent Parent Associations ============
3536
- // Persists which TAB each agent window connects to.
3537
- // This is the PERMANENT record of agent -> tab associations.
3538
- const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json');
3539
- this.app.get('/api/subagent-parents', async () => {
3540
- try {
3541
- const content = await fs.readFile(parentMapPath, 'utf-8');
3542
- return JSON.parse(content);
3543
- }
3544
- catch (err) {
3545
- if (err.code !== 'ENOENT') {
3546
- console.error('Failed to read subagent parent map:', err);
3547
- }
3548
- }
3549
- return {};
3550
- });
3551
- this.app.put('/api/subagent-parents', async (req) => {
3552
- const spResult = SubagentParentMapSchema.safeParse(req.body);
3553
- if (!spResult.success) {
3554
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid parent map');
3555
- }
3556
- const parentMap = spResult.data;
3557
- try {
3558
- const dir = dirname(parentMapPath);
3559
- if (!existsSync(dir)) {
3560
- mkdirSync(dir, { recursive: true });
3561
- }
3562
- await fs.writeFile(parentMapPath, JSON.stringify(parentMap, null, 2));
3563
- return { success: true };
3564
- }
3565
- catch (err) {
3566
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
3567
- }
3568
- });
3569
- // ============ Mux Session Management Endpoints ============
3570
- // Get all tracked mux sessions with stats
3571
- this.app.get('/api/mux-sessions', async () => {
3572
- const sessions = await this.mux.getSessionsWithStats();
3573
- return {
3574
- sessions,
3575
- muxAvailable: this.mux.isAvailable(),
3576
- };
3577
- });
3578
- // Kill a mux session
3579
- this.app.delete('/api/mux-sessions/:sessionId', async (req) => {
3580
- const { sessionId } = req.params;
3581
- const success = await this.mux.killSession(sessionId);
3582
- return { success };
3583
- });
3584
- // Reconcile mux sessions (find dead ones)
3585
- this.app.post('/api/mux-sessions/reconcile', async () => {
3586
- const result = await this.mux.reconcileSessions();
3587
- return result;
3588
- });
3589
- // Start stats collection
3590
- this.app.post('/api/mux-sessions/stats/start', async () => {
3591
- this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
3592
- return { success: true };
3593
- });
3594
- // Stop stats collection
3595
- this.app.post('/api/mux-sessions/stats/stop', async () => {
3596
- this.mux.stopStatsCollection();
3597
- return { success: true };
3598
- });
3599
- // System stats endpoint for frontend header display
3600
- this.app.get('/api/system/stats', async () => {
3601
- return this.getSystemStats();
3602
- });
3603
- // ========== Subagent Monitoring (Claude Code Background Agents) ==========
3604
- // List all known subagents
3605
- this.app.get('/api/subagents', async (req) => {
3606
- const { minutes } = req.query;
3607
- const subagents = minutes
3608
- ? subagentWatcher.getRecentSubagents(parseInt(minutes, 10))
3609
- : subagentWatcher.getSubagents();
3610
- return { success: true, data: subagents };
3611
- });
3612
- // Get subagents for a specific session (by working directory)
3613
- this.app.get('/api/sessions/:id/subagents', async (req) => {
3614
- const { id } = req.params;
3615
- const session = this.sessions.get(id);
3616
- if (!session) {
3617
- return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
3618
- }
3619
- const subagents = subagentWatcher.getSubagentsForSession(session.workingDir);
3620
- return { success: true, data: subagents };
156
+ }
157
+ else {
158
+ this.app = Fastify({ logger: false });
159
+ }
160
+ this.mux = createMultiplexer();
161
+ // Set up mux event listeners
162
+ this.mux.on('sessionCreated', (session) => {
163
+ this.broadcast('mux:created', session);
3621
164
  });
3622
- // Get a specific subagent's info
3623
- this.app.get('/api/subagents/:agentId', async (req) => {
3624
- const { agentId } = req.params;
3625
- const info = subagentWatcher.getSubagent(agentId);
3626
- if (!info) {
3627
- return createErrorResponse(ApiErrorCode.NOT_FOUND, `Subagent ${agentId} not found`);
3628
- }
3629
- return { success: true, data: info };
165
+ this.mux.on('sessionKilled', (data) => {
166
+ this.broadcast('mux:killed', data);
3630
167
  });
3631
- // Get a subagent's transcript
3632
- this.app.get('/api/subagents/:agentId/transcript', async (req) => {
3633
- const { agentId } = req.params;
3634
- const { limit, format } = req.query;
3635
- const limitNum = limit ? parseInt(limit, 10) : undefined;
3636
- const transcript = await subagentWatcher.getTranscript(agentId, limitNum);
3637
- if (format === 'formatted') {
3638
- const formatted = subagentWatcher.formatTranscript(transcript);
3639
- return { success: true, data: { formatted, entryCount: transcript.length } };
3640
- }
3641
- return { success: true, data: transcript };
168
+ this.mux.on('sessionDied', (data) => {
169
+ getLifecycleLog().log({
170
+ event: 'mux_died',
171
+ sessionId: data.sessionId || 'unknown',
172
+ extra: data,
173
+ });
174
+ this.broadcast('mux:died', data);
3642
175
  });
3643
- // Kill a subagent
3644
- this.app.delete('/api/subagents/:agentId', async (req) => {
3645
- const { agentId } = req.params;
3646
- const info = subagentWatcher.getSubagent(agentId);
3647
- if (!info) {
3648
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subagent not found');
3649
- }
3650
- const killed = await subagentWatcher.killSubagent(agentId);
3651
- if (killed) {
3652
- return { success: true, data: { agentId, status: 'killed' } };
3653
- }
3654
- return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Subagent not found or already completed');
176
+ this.mux.on('statsUpdated', (sessions) => {
177
+ this.broadcast('mux:statsUpdated', sessions);
3655
178
  });
3656
- // Trigger cleanup of stale subagents
3657
- this.app.post('/api/subagents/cleanup', async () => {
3658
- const removed = subagentWatcher.cleanupNow();
3659
- return { success: true, data: { removed, remaining: subagentWatcher.getSubagents().length } };
179
+ // Set up subagent watcher listeners
180
+ this.setupSubagentWatcherListeners();
181
+ // Set up image watcher listeners
182
+ this.setupImageWatcherListeners();
183
+ // Set up team watcher listeners
184
+ this.setupTeamWatcherListeners();
185
+ // Set up tunnel manager listeners
186
+ this.tunnelManager.on('started', (data) => {
187
+ this.broadcast('tunnel:started', data);
3660
188
  });
3661
- // Clear all tracked subagents (memory only - does not delete files)
3662
- this.app.delete('/api/subagents', async () => {
3663
- const cleared = subagentWatcher.clearAll();
3664
- return { success: true, data: { cleared } };
189
+ this.tunnelManager.on('stopped', () => {
190
+ this.broadcast('tunnel:stopped', {});
3665
191
  });
3666
- // ========== Agent Teams ==========
3667
- // List all discovered teams
3668
- this.app.get('/api/teams', async () => {
3669
- return { success: true, data: this.teamWatcher.getTeams() };
192
+ this.tunnelManager.on('error', (message) => {
193
+ this.broadcast('tunnel:error', { message });
3670
194
  });
3671
- // Get tasks for a specific team
3672
- this.app.get('/api/teams/:name/tasks', async (req) => {
3673
- const { name } = req.params;
3674
- return { success: true, data: this.teamWatcher.getTeamTasks(name) };
195
+ this.tunnelManager.on('progress', (data) => {
196
+ this.broadcast('tunnel:progress', data);
3675
197
  });
3676
- // ========== Hook Events ==========
3677
- this.app.post('/api/hook-event', async (req) => {
3678
- const result = HookEventSchema.safeParse(req.body);
3679
- if (!result.success) {
3680
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
3681
- }
3682
- const { event, sessionId, data } = result.data;
3683
- if (!this.sessions.has(sessionId)) {
3684
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
3685
- }
3686
- // Signal the respawn controller based on hook event type
3687
- const controller = this.respawnControllers.get(sessionId);
3688
- if (controller) {
3689
- if (event === 'elicitation_dialog') {
3690
- // Block auto-accept for question prompts
3691
- controller.signalElicitation();
3692
- }
3693
- else if (event === 'stop') {
3694
- // DEFINITIVE idle signal - Claude finished responding
3695
- controller.signalStopHook();
198
+ // QR token rotation — broadcast inline SVG for instant desktop refresh
199
+ this.tunnelManager.on('qrTokenRotated', async () => {
200
+ const url = this.tunnelManager.getUrl();
201
+ if (url && process.env.CODEMAN_PASSWORD) {
202
+ try {
203
+ const svg = await this.tunnelManager.getQrSvg(url);
204
+ this.broadcast('tunnel:qrRotated', { svg });
3696
205
  }
3697
- else if (event === 'idle_prompt') {
3698
- // DEFINITIVE idle signal - Claude has been idle for 60+ seconds
3699
- controller.signalIdlePrompt();
206
+ catch {
207
+ // QR generation failed skip this rotation
3700
208
  }
3701
209
  }
3702
- // Start transcript watching if transcript_path is provided and safe
3703
- if (data && 'transcript_path' in data) {
3704
- const transcriptPath = String(data.transcript_path);
3705
- if (transcriptPath && isValidWorkingDir(transcriptPath)) {
3706
- this.startTranscriptWatcher(sessionId, transcriptPath);
210
+ });
211
+ this.tunnelManager.on('qrTokenRegenerated', async () => {
212
+ const url = this.tunnelManager.getUrl();
213
+ if (url && process.env.CODEMAN_PASSWORD) {
214
+ try {
215
+ const svg = await this.tunnelManager.getQrSvg(url);
216
+ this.broadcast('tunnel:qrRegenerated', { svg });
217
+ }
218
+ catch {
219
+ // QR generation failed — skip
3707
220
  }
3708
221
  }
3709
- // Sanitize forwarded data: only include known safe fields, limit size
3710
- const safeData = sanitizeHookData(data);
3711
- this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });
3712
- // Send push notifications for hook events
3713
- const session = this.sessions.get(sessionId);
3714
- const sessionName = session?.name ?? sessionId.slice(0, 8);
3715
- this.sendPushNotifications(`hook:${event}`, { sessionId, sessionName, ...safeData });
3716
- // Track in run summary
3717
- const summaryTracker = this.runSummaryTrackers.get(sessionId);
3718
- if (summaryTracker) {
3719
- summaryTracker.recordHookEvent(event, safeData);
3720
- }
3721
- return { success: true };
3722
- });
3723
- // ========== Web Push ==========
3724
- this.app.get('/api/push/vapid-key', async () => {
3725
- return { success: true, data: { publicKey: this.pushStore.getPublicKey() } };
3726
222
  });
3727
- this.app.post('/api/push/subscribe', async (req) => {
3728
- const result = PushSubscribeSchema.safeParse(req.body);
3729
- if (!result.success) {
3730
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
3731
- }
3732
- const { endpoint, keys, userAgent, pushPreferences } = result.data;
3733
- const record = this.pushStore.addSubscription({
3734
- id: uuidv4(),
3735
- endpoint,
3736
- keys,
3737
- userAgent: userAgent ?? req.headers['user-agent'] ?? '',
3738
- createdAt: Date.now(),
3739
- pushPreferences: pushPreferences ?? {},
3740
- });
3741
- return { success: true, data: { id: record.id } };
223
+ }
224
+ /**
225
+ * Set up event listeners for subagent watcher.
226
+ * Broadcasts real-time subagent activity to SSE clients.
227
+ *
228
+ * The SubagentWatcher now extracts descriptions directly from the parent session's
229
+ * transcript, which contains the exact Task tool call with the description parameter.
230
+ * This is more reliable than the previous timing-based correlation approach.
231
+ */
232
+ setupSubagentWatcherListeners() {
233
+ // Store handlers for cleanup on shutdown
234
+ this.subagentWatcherHandlers = {
235
+ discovered: (info) => this.broadcast('subagent:discovered', info),
236
+ updated: (info) => this.broadcast('subagent:updated', info),
237
+ toolCall: (data) => this.broadcast('subagent:tool_call', data),
238
+ toolResult: (data) => this.broadcast('subagent:tool_result', data),
239
+ progress: (data) => this.broadcast('subagent:progress', data),
240
+ message: (data) => this.broadcast('subagent:message', data),
241
+ completed: (info) => this.broadcast('subagent:completed', info),
242
+ error: (error, agentId) => {
243
+ console.error(`[SubagentWatcher] Error${agentId ? ` for ${agentId}` : ''}:`, error.message);
244
+ },
245
+ };
246
+ subagentWatcher.on('subagent:discovered', this.subagentWatcherHandlers.discovered);
247
+ subagentWatcher.on('subagent:updated', this.subagentWatcherHandlers.updated);
248
+ subagentWatcher.on('subagent:tool_call', this.subagentWatcherHandlers.toolCall);
249
+ subagentWatcher.on('subagent:tool_result', this.subagentWatcherHandlers.toolResult);
250
+ subagentWatcher.on('subagent:progress', this.subagentWatcherHandlers.progress);
251
+ subagentWatcher.on('subagent:message', this.subagentWatcherHandlers.message);
252
+ subagentWatcher.on('subagent:completed', this.subagentWatcherHandlers.completed);
253
+ subagentWatcher.on('subagent:error', this.subagentWatcherHandlers.error);
254
+ }
255
+ /**
256
+ * Clean up subagent watcher listeners to prevent memory leaks.
257
+ */
258
+ cleanupSubagentWatcherListeners() {
259
+ if (this.subagentWatcherHandlers) {
260
+ subagentWatcher.off('subagent:discovered', this.subagentWatcherHandlers.discovered);
261
+ subagentWatcher.off('subagent:updated', this.subagentWatcherHandlers.updated);
262
+ subagentWatcher.off('subagent:tool_call', this.subagentWatcherHandlers.toolCall);
263
+ subagentWatcher.off('subagent:tool_result', this.subagentWatcherHandlers.toolResult);
264
+ subagentWatcher.off('subagent:progress', this.subagentWatcherHandlers.progress);
265
+ subagentWatcher.off('subagent:message', this.subagentWatcherHandlers.message);
266
+ subagentWatcher.off('subagent:completed', this.subagentWatcherHandlers.completed);
267
+ subagentWatcher.off('subagent:error', this.subagentWatcherHandlers.error);
268
+ this.subagentWatcherHandlers = null;
269
+ }
270
+ }
271
+ /**
272
+ * Set up event listeners for image watcher.
273
+ * Broadcasts image detection events to SSE clients for auto-popup.
274
+ */
275
+ setupImageWatcherListeners() {
276
+ // Store handlers for cleanup on shutdown
277
+ this.imageWatcherHandlers = {
278
+ detected: (event) => this.broadcast('image:detected', event),
279
+ error: (error, sessionId) => {
280
+ console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
281
+ },
282
+ };
283
+ imageWatcher.on('image:detected', this.imageWatcherHandlers.detected);
284
+ imageWatcher.on('image:error', this.imageWatcherHandlers.error);
285
+ }
286
+ /**
287
+ * Clean up image watcher listeners to prevent memory leaks.
288
+ */
289
+ cleanupImageWatcherListeners() {
290
+ if (this.imageWatcherHandlers) {
291
+ imageWatcher.off('image:detected', this.imageWatcherHandlers.detected);
292
+ imageWatcher.off('image:error', this.imageWatcherHandlers.error);
293
+ this.imageWatcherHandlers = null;
294
+ }
295
+ }
296
+ /**
297
+ * Set up event listeners for team watcher.
298
+ * Broadcasts team activity events to SSE clients.
299
+ */
300
+ setupTeamWatcherListeners() {
301
+ this.teamWatcherHandlers = {
302
+ teamCreated: (config) => this.broadcast('team:created', config),
303
+ teamUpdated: (config) => this.broadcast('team:updated', config),
304
+ teamRemoved: (config) => this.broadcast('team:removed', config),
305
+ taskUpdated: (data) => this.broadcast('team:taskUpdated', data),
306
+ };
307
+ this.teamWatcher.on('teamCreated', this.teamWatcherHandlers.teamCreated);
308
+ this.teamWatcher.on('teamUpdated', this.teamWatcherHandlers.teamUpdated);
309
+ this.teamWatcher.on('teamRemoved', this.teamWatcherHandlers.teamRemoved);
310
+ this.teamWatcher.on('taskUpdated', this.teamWatcherHandlers.taskUpdated);
311
+ }
312
+ /**
313
+ * Clean up team watcher listeners to prevent memory leaks.
314
+ */
315
+ cleanupTeamWatcherListeners() {
316
+ if (this.teamWatcherHandlers) {
317
+ this.teamWatcher.off('teamCreated', this.teamWatcherHandlers.teamCreated);
318
+ this.teamWatcher.off('teamUpdated', this.teamWatcherHandlers.teamUpdated);
319
+ this.teamWatcher.off('teamRemoved', this.teamWatcherHandlers.teamRemoved);
320
+ this.teamWatcher.off('taskUpdated', this.teamWatcherHandlers.taskUpdated);
321
+ this.teamWatcherHandlers = null;
322
+ }
323
+ }
324
+ /**
325
+ * Build a route context object satisfying all 5 port interfaces.
326
+ * Single object with zero runtime cost — ISP enforced at the type level.
327
+ */
328
+ createRouteContext() {
329
+ return {
330
+ // SessionPort
331
+ sessions: this.sessions,
332
+ addSession: (session) => {
333
+ this.sessions.set(session.id, session);
334
+ },
335
+ cleanupSession: this.cleanupSession.bind(this),
336
+ setupSessionListeners: this.setupSessionListeners.bind(this),
337
+ persistSessionState: this.persistSessionState.bind(this),
338
+ persistSessionStateNow: this._persistSessionStateNow.bind(this),
339
+ getSessionStateWithRespawn: this.getSessionStateWithRespawn.bind(this),
340
+ // EventPort
341
+ broadcast: this.broadcast.bind(this),
342
+ sendPushNotifications: this.sendPushNotifications.bind(this),
343
+ batchTerminalData: this.batchTerminalData.bind(this),
344
+ broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
345
+ batchTaskUpdate: this.batchTaskUpdate.bind(this),
346
+ // RespawnPort
347
+ respawnControllers: this.respawnControllers,
348
+ respawnTimers: this.respawnTimers,
349
+ setupRespawnListeners: this.setupRespawnListeners.bind(this),
350
+ setupTimedRespawn: this.setupTimedRespawn.bind(this),
351
+ restoreRespawnController: this.restoreRespawnController.bind(this),
352
+ saveRespawnConfig: this.saveRespawnConfig.bind(this),
353
+ // ConfigPort
354
+ store: this.store,
355
+ port: this.port,
356
+ https: this.https,
357
+ testMode: this.testMode,
358
+ serverStartTime: this.serverStartTime,
359
+ getGlobalNiceConfig: this.getGlobalNiceConfig.bind(this),
360
+ getModelConfig: this.getModelConfig.bind(this),
361
+ getClaudeModeConfig: this.getClaudeModeConfig.bind(this),
362
+ getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this),
363
+ getLightState: this.getLightState.bind(this),
364
+ getLightSessionsState: this.getLightSessionsState.bind(this),
365
+ startTranscriptWatcher: this.startTranscriptWatcher.bind(this),
366
+ stopTranscriptWatcher: this.stopTranscriptWatcher.bind(this),
367
+ // InfraPort
368
+ mux: this.mux,
369
+ runSummaryTrackers: this.runSummaryTrackers,
370
+ activePlanOrchestrators: this.activePlanOrchestrators,
371
+ scheduledRuns: this.scheduledRuns,
372
+ teamWatcher: this.teamWatcher,
373
+ tunnelManager: this.tunnelManager,
374
+ pushStore: this.pushStore,
375
+ startScheduledRun: this.startScheduledRun.bind(this),
376
+ stopScheduledRun: this.stopScheduledRun.bind(this),
377
+ // AuthPort
378
+ authSessions: this.authSessions,
379
+ qrAuthFailures: this.qrAuthFailures,
380
+ };
381
+ }
382
+ async setupRoutes() {
383
+ // Allow multipart/form-data for screenshot uploads — skip Fastify's body parser
384
+ // so the route handler can read the raw stream directly.
385
+ this.app.addContentTypeParser('multipart/form-data', (_req, _payload, done) => {
386
+ done(null);
3742
387
  });
3743
- this.app.put('/api/push/subscribe/:id', async (req) => {
3744
- const { id } = req.params;
3745
- const result = PushPreferencesUpdateSchema.safeParse(req.body);
3746
- if (!result.success) {
3747
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
3748
- }
3749
- const updated = this.pushStore.updatePreferences(id, result.data.pushPreferences);
3750
- if (!updated) {
3751
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
3752
- }
3753
- return { success: true };
388
+ // Enable gzip/brotli compression for all responses.
389
+ // Massive win: 793KB uncompressed → ~120KB compressed for static assets.
390
+ // Threshold 1024 = don't compress tiny responses (headers > savings).
391
+ await this.app.register(fastifyCompress, {
392
+ threshold: 1024,
3754
393
  });
3755
- this.app.delete('/api/push/subscribe/:id', async (req) => {
3756
- const { id } = req.params;
3757
- const removed = this.pushStore.removeSubscription(id);
3758
- if (!removed) {
3759
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found');
3760
- }
3761
- return { success: true };
394
+ // Cookie plugin (needed for auth session tokens)
395
+ await this.app.register(fastifyCookie);
396
+ // Auth middleware (Basic Auth + session cookies + rate limiting)
397
+ const authState = registerAuthMiddleware(this.app, this.https);
398
+ if (authState) {
399
+ this.authSessions = authState.authSessions;
400
+ this.authFailures = authState.authFailures;
401
+ this.qrAuthFailures = authState.qrAuthFailures;
402
+ }
403
+ // Security headers + CORS
404
+ registerSecurityHeaders(this.app, this.https);
405
+ // Service worker must never be cached — browsers check for SW updates on navigation
406
+ this.app.get('/sw.js', async (_req, reply) => {
407
+ return reply
408
+ .header('Cache-Control', 'no-cache, no-store')
409
+ .header('Service-Worker-Allowed', '/')
410
+ .type('application/javascript')
411
+ .sendFile('sw.js', join(__dirname, 'public'));
3762
412
  });
3763
- // Screenshot upload endpoint (accepts multipart/form-data)
3764
- // Upload form served as static file: /upload.html (src/web/public/upload.html)
3765
- this.app.post('/api/screenshots', async (req, reply) => {
3766
- const contentType = req.headers['content-type'] ?? '';
3767
- if (!contentType.includes('multipart/form-data')) {
3768
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
3769
- }
3770
- // Parse multipart boundary
3771
- const boundaryMatch = contentType.match(/boundary=(.+?)(?:;|$)/);
3772
- if (!boundaryMatch) {
3773
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing boundary');
3774
- }
3775
- // Collect raw body
3776
- const chunks = [];
3777
- let totalSize = 0;
3778
- for await (const chunk of req.raw) {
3779
- totalSize += chunk.length;
3780
- if (totalSize > MAX_SCREENSHOT_SIZE) {
3781
- reply.status(413);
3782
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)');
3783
- }
3784
- chunks.push(chunk);
3785
- }
3786
- const body = Buffer.concat(chunks);
3787
- // Extract file from multipart body
3788
- const boundary = '--' + boundaryMatch[1];
3789
- const boundaryBuf = Buffer.from(boundary);
3790
- const parts = [];
3791
- let pos = 0;
3792
- // Find each part between boundaries
3793
- while (pos < body.length) {
3794
- const start = body.indexOf(boundaryBuf, pos);
3795
- if (start === -1)
3796
- break;
3797
- const afterBoundary = start + boundaryBuf.length;
3798
- // Check for closing boundary (--)
3799
- if (body[afterBoundary] === 0x2d && body[afterBoundary + 1] === 0x2d)
3800
- break;
3801
- // Skip \r\n after boundary
3802
- const headerStart = afterBoundary + 2;
3803
- const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'), headerStart);
3804
- if (headerEnd === -1)
3805
- break;
3806
- const headers = body.subarray(headerStart, headerEnd).toString();
3807
- const dataStart = headerEnd + 4;
3808
- const nextBoundary = body.indexOf(boundaryBuf, dataStart);
3809
- // Data ends 2 bytes before next boundary (\r\n)
3810
- const dataEnd = nextBoundary === -1 ? body.length : nextBoundary - 2;
3811
- parts.push({ headers, data: body.subarray(dataStart, dataEnd) });
3812
- pos = nextBoundary === -1 ? body.length : nextBoundary;
3813
- }
3814
- const filePart = parts.find((p) => p.headers.includes('name="file"'));
3815
- if (!filePart || filePart.data.length === 0) {
3816
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No file uploaded');
3817
- }
3818
- // Determine extension from Content-Type or filename
3819
- let ext = '.png';
3820
- const filenameMatch = filePart.headers.match(/filename="(.+?)"/);
3821
- if (filenameMatch) {
3822
- const origExt = filenameMatch[1].match(/\.(png|jpg|jpeg|webp|gif)$/i);
3823
- if (origExt)
3824
- ext = origExt[0].toLowerCase();
3825
- }
3826
- const ctMatch = filePart.headers.match(/Content-Type:\s*image\/(png|jpeg|webp|gif)/i);
3827
- if (ctMatch) {
3828
- const map = {
3829
- png: '.png',
3830
- jpeg: '.jpg',
3831
- webp: '.webp',
3832
- gif: '.gif',
3833
- };
3834
- ext = map[ctMatch[1].toLowerCase()] ?? ext;
3835
- }
3836
- // Save to ~/.codeman/screenshots/
3837
- if (!existsSync(SCREENSHOTS_DIR)) {
3838
- mkdirSync(SCREENSHOTS_DIR, { recursive: true });
3839
- }
3840
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
3841
- const filename = `screenshot_${timestamp}${ext}`;
3842
- const filepath = join(SCREENSHOTS_DIR, filename);
3843
- await fs.writeFile(filepath, filePart.data);
3844
- return { success: true, path: filepath, filename };
413
+ // Serve static files — versioned assets (?v=X) are immutable, cache aggressively
414
+ // preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression
415
+ await this.app.register(fastifyStatic, {
416
+ root: join(__dirname, 'public'),
417
+ prefix: '/',
418
+ maxAge: '1y',
419
+ immutable: true,
420
+ preCompressed: true,
3845
421
  });
3846
- // List screenshots
3847
- this.app.get('/api/screenshots', async () => {
3848
- if (!existsSync(SCREENSHOTS_DIR)) {
3849
- return { files: [] };
422
+ // SSE endpoint for real-time updates
423
+ this.app.get('/api/events', (req, reply) => {
424
+ // Enforce SSE client limit to prevent memory exhaustion from too many connections
425
+ if (this.sseClients.size >= MAX_SSE_CLIENTS) {
426
+ reply.code(503).send('Too many SSE connections');
427
+ return;
3850
428
  }
3851
- const files = readdirSync(SCREENSHOTS_DIR)
3852
- .filter((f) => /\.(png|jpg|jpeg|webp|gif)$/i.test(f))
3853
- .sort()
3854
- .reverse()
3855
- .slice(0, 50)
3856
- .map((name) => ({ name, path: join(SCREENSHOTS_DIR, name) }));
3857
- return { files };
429
+ reply.raw.writeHead(200, {
430
+ 'Content-Type': 'text/event-stream',
431
+ 'Cache-Control': 'no-cache',
432
+ Connection: 'keep-alive',
433
+ 'X-Accel-Buffering': 'no', // Disable nginx buffering
434
+ });
435
+ this.sseClients.add(reply);
436
+ // Send initial state
437
+ // Use light state for SSE init to avoid sending 2MB+ terminal buffers
438
+ // Buffers are fetched on-demand when switching tabs
439
+ this.sendSSE(reply, 'init', this.getLightState());
440
+ req.raw.on('close', () => {
441
+ this.sseClients.delete(reply);
442
+ this.backpressuredClients.delete(reply);
443
+ });
3858
444
  });
3859
- // Serve individual screenshot
3860
- this.app.get('/api/screenshots/:name', async (req, reply) => {
3861
- const { name } = req.params;
3862
- // Prevent path traversal
3863
- if (name.includes('/') || name.includes('\\') || name.includes('..')) {
3864
- reply.status(400);
3865
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid filename');
445
+ // Global error handler for structured errors thrown by findSessionOrFail
446
+ this.app.setErrorHandler((error, _req, reply) => {
447
+ const statusCode = error.statusCode ?? 500;
448
+ const body = error.body;
449
+ if (body) {
450
+ reply.code(statusCode).send(body);
3866
451
  }
3867
- const filepath = join(SCREENSHOTS_DIR, name);
3868
- if (!existsSync(filepath)) {
3869
- reply.status(404);
3870
- return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Screenshot not found');
3871
- }
3872
- const ext = name.match(/\.(png|jpg|jpeg|webp|gif)$/i)?.[1]?.toLowerCase() ?? 'png';
3873
- const mimeMap = {
3874
- png: 'image/png',
3875
- jpg: 'image/jpeg',
3876
- jpeg: 'image/jpeg',
3877
- webp: 'image/webp',
3878
- gif: 'image/gif',
3879
- };
3880
- reply.type(mimeMap[ext] ?? 'image/png');
3881
- return fs.readFile(filepath);
3882
- });
452
+ else {
453
+ reply.code(statusCode).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(error)));
454
+ }
455
+ });
456
+ // Register all route modules
457
+ const ctx = this.createRouteContext();
458
+ registerPushRoutes(this.app, ctx);
459
+ registerTeamRoutes(this.app, ctx);
460
+ registerMuxRoutes(this.app, ctx);
461
+ registerFileRoutes(this.app, ctx);
462
+ registerScheduledRoutes(this.app, ctx);
463
+ registerHookEventRoutes(this.app, ctx);
464
+ registerSystemRoutes(this.app, ctx);
465
+ registerCaseRoutes(this.app, ctx);
466
+ registerSessionRoutes(this.app, ctx);
467
+ registerRespawnRoutes(this.app, ctx);
468
+ registerRalphRoutes(this.app, ctx);
469
+ registerPlanRoutes(this.app, ctx);
3883
470
  }
3884
471
  /**
3885
472
  * Start a transcript watcher for a session.
@@ -3936,16 +523,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
3936
523
  }
3937
524
  /** Debounced wrapper — coalesces rapid persistSessionState calls per session */
3938
525
  persistSessionState(session) {
3939
- const existing = this.persistDebounceTimers.get(session.id);
3940
- if (existing)
3941
- clearTimeout(existing);
3942
- this.persistDebounceTimers.set(session.id, setTimeout(() => {
3943
- this.persistDebounceTimers.delete(session.id);
526
+ this.persistDeb.schedule(session.id, () => {
3944
527
  // Session may have been removed during debounce
3945
528
  if (this.sessions.has(session.id)) {
3946
529
  this._persistSessionStateNow(session);
3947
530
  }
3948
- }, 100));
531
+ });
3949
532
  }
3950
533
  /** Persists full session state including respawn config to state.json */
3951
534
  _persistSessionStateNow(session) {
@@ -4002,49 +585,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
4002
585
  };
4003
586
  this.mux.updateRespawnConfig(sessionId, persistedConfig);
4004
587
  }
4005
- // Get system CPU and memory usage
4006
- getSystemStats() {
4007
- try {
4008
- const totalMem = totalmem();
4009
- // macOS: os.freemem() only returns truly free pages, not cached/purgeable memory.
4010
- // Use vm_stat to get accurate used memory (wired + active + compressed).
4011
- let usedMem;
4012
- if (process.platform === 'darwin') {
4013
- try {
4014
- const vmstat = execSync('vm_stat', { encoding: 'utf-8', timeout: 2000 });
4015
- const pageSize = parseInt(vmstat.match(/page size of (\d+)/)?.[1] || '4096', 10);
4016
- const wired = parseInt(vmstat.match(/Pages wired down:\s+(\d+)/)?.[1] || '0', 10);
4017
- const active = parseInt(vmstat.match(/Pages active:\s+(\d+)/)?.[1] || '0', 10);
4018
- const compressed = parseInt(vmstat.match(/Pages occupied by compressor:\s+(\d+)/)?.[1] || '0', 10);
4019
- usedMem = (wired + active + compressed) * pageSize;
4020
- }
4021
- catch {
4022
- usedMem = totalMem - freemem();
4023
- }
4024
- }
4025
- else {
4026
- usedMem = totalMem - freemem();
4027
- }
4028
- // CPU load average (1 min) as percentage (rough approximation)
4029
- const load = loadavg()[0];
4030
- const cpuCount = WebServer.CPU_COUNT;
4031
- const cpuPercent = Math.min(100, Math.round((load / cpuCount) * 100));
4032
- return {
4033
- cpu: cpuPercent,
4034
- memory: {
4035
- usedMB: Math.round(usedMem / (1024 * 1024)),
4036
- totalMB: Math.round(totalMem / (1024 * 1024)),
4037
- percent: Math.round((usedMem / totalMem) * 100),
4038
- },
4039
- };
4040
- }
4041
- catch {
4042
- return {
4043
- cpu: 0,
4044
- memory: { usedMB: 0, totalMB: 0, percent: 0 },
4045
- };
4046
- }
4047
- }
4048
588
  // Clean up all resources associated with a session
4049
589
  // Track sessions currently being cleaned up to prevent concurrent cleanup races
4050
590
  cleaningUp = new Set();
@@ -4119,11 +659,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
4119
659
  this.runSummaryTrackers.delete(sessionId);
4120
660
  }
4121
661
  // Clear pending persist-debounce timer (prevents stale closure holding session ref)
4122
- const pendingPersist = this.persistDebounceTimers.get(sessionId);
4123
- if (pendingPersist) {
4124
- clearTimeout(pendingPersist);
4125
- this.persistDebounceTimers.delete(sessionId);
4126
- }
662
+ this.persistDeb.cancelKey(sessionId);
4127
663
  // Clear batches, per-session timers, and pending state updates
4128
664
  this.terminalBatches.delete(sessionId);
4129
665
  this.terminalBatchSizes.delete(sessionId);
@@ -4332,11 +868,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
4332
868
  this.stateUpdatePending.delete(session.id);
4333
869
  this.lastTerminalEventTime.delete(session.id);
4334
870
  // Clear pending persist-debounce timer
4335
- const pendingPersist = this.persistDebounceTimers.get(session.id);
4336
- if (pendingPersist) {
4337
- clearTimeout(pendingPersist);
4338
- this.persistDebounceTimers.delete(session.id);
4339
- }
871
+ this.persistDeb.cancelKey(session.id);
4340
872
  // Close any active file streams
4341
873
  fileStreamManager.closeSessionStreams(session.id);
4342
874
  // Remove stored listener refs to break closure references (prevents memory leak).
@@ -5034,9 +1566,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
5034
1566
  // 1. The debounced session:updated follows within 500ms with the new state
5035
1567
  // 2. These caches serve /api/sessions and SSE init — neither is polled rapidly
5036
1568
  // 3. Invalidating on every working/idle transition makes the 1s TTL useless
5037
- if (event === 'session:created' ||
5038
- event === 'session:deleted' ||
5039
- event === 'session:updated') {
1569
+ if (event === 'session:created' || event === 'session:deleted' || event === 'session:updated') {
5040
1570
  this.cachedLightState = null;
5041
1571
  this.cachedSessionsList = null;
5042
1572
  }
@@ -5143,11 +1673,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
5143
1673
  // Use composite key to avoid losing updates when multiple tasks update in same batch window
5144
1674
  const key = `${sessionId}:${task.id}`;
5145
1675
  this.taskUpdateBatches.set(key, { sessionId, task });
5146
- if (!this.taskUpdateBatchTimer) {
5147
- this.taskUpdateBatchTimer = setTimeout(() => {
1676
+ if (!this.taskUpdateBatchTimerId) {
1677
+ this.taskUpdateBatchTimerId = this.cleanup.setTimeout(() => {
1678
+ this.taskUpdateBatchTimerId = null;
5148
1679
  this.flushTaskUpdateBatches();
5149
- this.taskUpdateBatchTimer = null;
5150
- }, TASK_UPDATE_BATCH_INTERVAL);
1680
+ }, TASK_UPDATE_BATCH_INTERVAL, { description: 'task update batch flush' });
5151
1681
  }
5152
1682
  }
5153
1683
  flushTaskUpdateBatches() {
@@ -5171,11 +1701,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
5171
1701
  if (this._isStopping)
5172
1702
  return;
5173
1703
  this.stateUpdatePending.add(sessionId);
5174
- if (!this.stateUpdateTimer) {
5175
- this.stateUpdateTimer = setTimeout(() => {
1704
+ if (!this.stateUpdateTimerId) {
1705
+ this.stateUpdateTimerId = this.cleanup.setTimeout(() => {
1706
+ this.stateUpdateTimerId = null;
5176
1707
  this.flushStateUpdates();
5177
- this.stateUpdateTimer = null;
5178
- }, STATE_UPDATE_DEBOUNCE_INTERVAL);
1708
+ }, STATE_UPDATE_DEBOUNCE_INTERVAL, { description: 'state update debounce flush' });
5179
1709
  }
5180
1710
  }
5181
1711
  flushStateUpdates() {
@@ -5345,17 +1875,17 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
5345
1875
  // Set API URL for child processes (MCP server, spawned sessions)
5346
1876
  process.env.CODEMAN_API_URL = `${protocol}://localhost:${this.port}`;
5347
1877
  // Start scheduled runs cleanup timer
5348
- this.scheduledCleanupTimer = setInterval(() => {
1878
+ this.cleanup.setInterval(() => {
5349
1879
  this.cleanupScheduledRuns();
5350
- }, SCHEDULED_CLEANUP_INTERVAL);
1880
+ }, SCHEDULED_CLEANUP_INTERVAL, { description: 'scheduled runs cleanup' });
5351
1881
  // Start SSE client health check timer (prevents memory leaks from dead connections)
5352
- this.sseHealthCheckTimer = setInterval(() => {
1882
+ this.cleanup.setInterval(() => {
5353
1883
  this.cleanupDeadSSEClients();
5354
- }, SSE_HEALTH_CHECK_INTERVAL);
1884
+ }, SSE_HEALTH_CHECK_INTERVAL, { description: 'SSE client health check' });
5355
1885
  // Start token recording timer (every 5 minutes for long-running sessions)
5356
- this.tokenRecordingTimer = setInterval(() => {
1886
+ this.cleanup.setInterval(() => {
5357
1887
  this.recordPeriodicTokenUsage();
5358
- }, 5 * 60 * 1000);
1888
+ }, 5 * 60 * 1000, { description: 'periodic token recording' });
5359
1889
  // Start subagent watcher for Claude Code background agent visibility (if enabled)
5360
1890
  if (await this.isSubagentTrackingEnabled()) {
5361
1891
  subagentWatcher.start();
@@ -5608,11 +2138,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
5608
2138
  getLifecycleLog().log({ event: 'server_stopped', sessionId: '*' });
5609
2139
  // Set stopping flag to prevent new timer creation during shutdown
5610
2140
  this._isStopping = true;
5611
- // Clear SSE health check timer
5612
- if (this.sseHealthCheckTimer) {
5613
- clearInterval(this.sseHealthCheckTimer);
5614
- this.sseHealthCheckTimer = null;
5615
- }
2141
+ // Dispose all managed timers (intervals + resettable timeouts)
2142
+ this.cleanup.dispose();
5616
2143
  // Gracefully close all SSE connections before clearing
5617
2144
  for (const client of this.sseClients) {
5618
2145
  try {
@@ -5633,38 +2160,18 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
5633
2160
  this.terminalBatchTimers.clear();
5634
2161
  this.terminalBatches.clear();
5635
2162
  this.terminalBatchSizes.clear();
5636
- if (this.taskUpdateBatchTimer) {
5637
- clearTimeout(this.taskUpdateBatchTimer);
5638
- this.taskUpdateBatchTimer = null;
5639
- }
5640
2163
  this.taskUpdateBatches.clear();
5641
- if (this.stateUpdateTimer) {
5642
- clearTimeout(this.stateUpdateTimer);
5643
- this.stateUpdateTimer = null;
5644
- }
5645
2164
  this.stateUpdatePending.clear();
5646
- // Clear token recording timer
5647
- if (this.tokenRecordingTimer) {
5648
- clearInterval(this.tokenRecordingTimer);
5649
- this.tokenRecordingTimer = null;
5650
- }
5651
2165
  this.lastRecordedTokens.clear();
5652
- // Clear scheduled cleanup timer
5653
- if (this.scheduledCleanupTimer) {
5654
- clearInterval(this.scheduledCleanupTimer);
5655
- this.scheduledCleanupTimer = null;
5656
- }
5657
2166
  // Stop multiplexer and flush pending saves
5658
2167
  this.mux.destroy();
5659
2168
  // Flush any pending persist-debounce timers and persist dirty sessions
5660
- for (const [sessionId, timer] of this.persistDebounceTimers) {
5661
- clearTimeout(timer);
2169
+ this.persistDeb.flushAll((sessionId) => {
5662
2170
  const session = this.sessions.get(sessionId);
5663
2171
  if (session) {
5664
2172
  this._persistSessionStateNow(session);
5665
2173
  }
5666
- }
5667
- this.persistDebounceTimers.clear();
2174
+ });
5668
2175
  // Clear cached state
5669
2176
  this.cachedLightState = null;
5670
2177
  this.cachedSessionsList = null;
@@ -5771,6 +2278,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
5771
2278
  this.authFailures.dispose();
5772
2279
  this.authFailures = null;
5773
2280
  }
2281
+ if (this.qrAuthFailures) {
2282
+ this.qrAuthFailures.dispose();
2283
+ this.qrAuthFailures = null;
2284
+ }
5774
2285
  this.activePlanOrchestrators.clear();
5775
2286
  this.cleaningUp.clear();
5776
2287
  // Dispose push store (flush pending saves)