@vibelet/cli 0.1.35 → 0.1.37

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 (323) hide show
  1. package/app.json +5 -0
  2. package/dist/advertised-hosts.d.ts +34 -0
  3. package/dist/advertised-hosts.d.ts.map +1 -0
  4. package/dist/advertised-hosts.js +176 -0
  5. package/dist/advertised-hosts.js.map +1 -0
  6. package/dist/advertised-hosts.test.d.ts +2 -0
  7. package/dist/advertised-hosts.test.d.ts.map +1 -0
  8. package/dist/advertised-hosts.test.js +96 -0
  9. package/dist/advertised-hosts.test.js.map +1 -0
  10. package/dist/audit.d.ts +30 -0
  11. package/dist/audit.d.ts.map +1 -0
  12. package/dist/audit.js +73 -0
  13. package/dist/audit.js.map +1 -0
  14. package/dist/audit.test.d.ts +2 -0
  15. package/dist/audit.test.d.ts.map +1 -0
  16. package/dist/audit.test.js +33 -0
  17. package/dist/audit.test.js.map +1 -0
  18. package/dist/auth.d.ts +6 -0
  19. package/dist/auth.d.ts.map +1 -0
  20. package/dist/auth.js +27 -0
  21. package/dist/auth.js.map +1 -0
  22. package/dist/claude-hooks.d.ts +58 -0
  23. package/dist/claude-hooks.d.ts.map +1 -0
  24. package/dist/claude-hooks.js +129 -0
  25. package/dist/claude-hooks.js.map +1 -0
  26. package/dist/cli-version.d.ts +3 -0
  27. package/dist/cli-version.d.ts.map +1 -0
  28. package/dist/cli-version.js +35 -0
  29. package/dist/cli-version.js.map +1 -0
  30. package/dist/cli-version.test.d.ts +2 -0
  31. package/dist/cli-version.test.d.ts.map +1 -0
  32. package/dist/cli-version.test.js +38 -0
  33. package/dist/cli-version.test.js.map +1 -0
  34. package/dist/config.d.ts +30 -0
  35. package/dist/config.d.ts.map +1 -0
  36. package/dist/config.js +327 -0
  37. package/dist/config.js.map +1 -0
  38. package/dist/config.test.d.ts +2 -0
  39. package/dist/config.test.d.ts.map +1 -0
  40. package/dist/config.test.js +184 -0
  41. package/dist/config.test.js.map +1 -0
  42. package/dist/dev-auth.test.d.ts +2 -0
  43. package/dist/dev-auth.test.d.ts.map +1 -0
  44. package/dist/dev-auth.test.js +154 -0
  45. package/dist/dev-auth.test.js.map +1 -0
  46. package/dist/dev-script.test.d.ts +2 -0
  47. package/dist/dev-script.test.d.ts.map +1 -0
  48. package/dist/dev-script.test.js +412 -0
  49. package/dist/dev-script.test.js.map +1 -0
  50. package/dist/drivers/claude.d.ts +34 -0
  51. package/dist/drivers/claude.d.ts.map +1 -0
  52. package/dist/drivers/claude.js +413 -0
  53. package/dist/drivers/claude.js.map +1 -0
  54. package/dist/drivers/claude.test.d.ts +2 -0
  55. package/dist/drivers/claude.test.d.ts.map +1 -0
  56. package/dist/drivers/claude.test.js +951 -0
  57. package/dist/drivers/claude.test.js.map +1 -0
  58. package/dist/drivers/codex.d.ts +38 -0
  59. package/dist/drivers/codex.d.ts.map +1 -0
  60. package/dist/drivers/codex.js +771 -0
  61. package/dist/drivers/codex.js.map +1 -0
  62. package/dist/drivers/codex.test.d.ts +2 -0
  63. package/dist/drivers/codex.test.d.ts.map +1 -0
  64. package/dist/drivers/codex.test.js +939 -0
  65. package/dist/drivers/codex.test.js.map +1 -0
  66. package/dist/drivers/types.d.ts +14 -0
  67. package/dist/drivers/types.d.ts.map +1 -0
  68. package/dist/drivers/types.js +2 -0
  69. package/dist/drivers/types.js.map +1 -0
  70. package/dist/e2e.test.d.ts +2 -0
  71. package/dist/e2e.test.d.ts.map +1 -0
  72. package/dist/e2e.test.js +111 -0
  73. package/dist/e2e.test.js.map +1 -0
  74. package/dist/identity.d.ts +10 -0
  75. package/dist/identity.d.ts.map +1 -0
  76. package/dist/identity.js +66 -0
  77. package/dist/identity.js.map +1 -0
  78. package/dist/identity.test.d.ts +2 -0
  79. package/dist/identity.test.d.ts.map +1 -0
  80. package/dist/identity.test.js +25 -0
  81. package/dist/identity.test.js.map +1 -0
  82. package/dist/index-entry.test.d.ts +2 -0
  83. package/dist/index-entry.test.d.ts.map +1 -0
  84. package/dist/index-entry.test.js +272 -0
  85. package/dist/index-entry.test.js.map +1 -0
  86. package/dist/index.d.ts +2 -0
  87. package/dist/index.d.ts.map +1 -0
  88. package/dist/index.js +707 -0
  89. package/dist/index.js.map +1 -0
  90. package/dist/logger.d.ts +31 -0
  91. package/dist/logger.d.ts.map +1 -0
  92. package/dist/logger.js +75 -0
  93. package/dist/logger.js.map +1 -0
  94. package/dist/metrics.d.ts +52 -0
  95. package/dist/metrics.d.ts.map +1 -0
  96. package/dist/metrics.js +89 -0
  97. package/dist/metrics.js.map +1 -0
  98. package/dist/pairing-store.d.ts +29 -0
  99. package/dist/pairing-store.d.ts.map +1 -0
  100. package/dist/pairing-store.js +131 -0
  101. package/dist/pairing-store.js.map +1 -0
  102. package/dist/pairing-store.test.d.ts +2 -0
  103. package/dist/pairing-store.test.d.ts.map +1 -0
  104. package/dist/pairing-store.test.js +47 -0
  105. package/dist/pairing-store.test.js.map +1 -0
  106. package/dist/paths.d.ts +16 -0
  107. package/dist/paths.d.ts.map +1 -0
  108. package/dist/paths.js +18 -0
  109. package/dist/paths.js.map +1 -0
  110. package/dist/perf-compare.d.ts +13 -0
  111. package/dist/perf-compare.d.ts.map +1 -0
  112. package/dist/perf-compare.js +125 -0
  113. package/dist/perf-compare.js.map +1 -0
  114. package/dist/port-conflict.d.ts +9 -0
  115. package/dist/port-conflict.d.ts.map +1 -0
  116. package/dist/port-conflict.js +33 -0
  117. package/dist/port-conflict.js.map +1 -0
  118. package/dist/port-conflict.test.d.ts +2 -0
  119. package/dist/port-conflict.test.d.ts.map +1 -0
  120. package/dist/port-conflict.test.js +38 -0
  121. package/dist/port-conflict.test.js.map +1 -0
  122. package/dist/process-scanner.d.ts +43 -0
  123. package/dist/process-scanner.d.ts.map +1 -0
  124. package/dist/process-scanner.js +453 -0
  125. package/dist/process-scanner.js.map +1 -0
  126. package/dist/process-scanner.perf.test.d.ts +2 -0
  127. package/dist/process-scanner.perf.test.d.ts.map +1 -0
  128. package/dist/process-scanner.perf.test.js +186 -0
  129. package/dist/process-scanner.perf.test.js.map +1 -0
  130. package/dist/process-scanner.test.d.ts +2 -0
  131. package/dist/process-scanner.test.d.ts.map +1 -0
  132. package/dist/process-scanner.test.js +399 -0
  133. package/dist/process-scanner.test.js.map +1 -0
  134. package/dist/push-protocol.d.ts +15 -0
  135. package/dist/push-protocol.d.ts.map +1 -0
  136. package/dist/push-protocol.js +23 -0
  137. package/dist/push-protocol.js.map +1 -0
  138. package/dist/push-protocol.test.d.ts +2 -0
  139. package/dist/push-protocol.test.d.ts.map +1 -0
  140. package/dist/push-protocol.test.js +57 -0
  141. package/dist/push-protocol.test.js.map +1 -0
  142. package/dist/push-store.d.ts +22 -0
  143. package/dist/push-store.d.ts.map +1 -0
  144. package/dist/push-store.js +103 -0
  145. package/dist/push-store.js.map +1 -0
  146. package/dist/push-store.test.d.ts +2 -0
  147. package/dist/push-store.test.d.ts.map +1 -0
  148. package/dist/push-store.test.js +79 -0
  149. package/dist/push-store.test.js.map +1 -0
  150. package/dist/push.d.ts +65 -0
  151. package/dist/push.d.ts.map +1 -0
  152. package/dist/push.js +202 -0
  153. package/dist/push.js.map +1 -0
  154. package/dist/push.test.d.ts +2 -0
  155. package/dist/push.test.d.ts.map +1 -0
  156. package/dist/push.test.js +199 -0
  157. package/dist/push.test.js.map +1 -0
  158. package/dist/safe-stdio.d.ts +3 -0
  159. package/dist/safe-stdio.d.ts.map +1 -0
  160. package/dist/safe-stdio.js +46 -0
  161. package/dist/safe-stdio.js.map +1 -0
  162. package/dist/scanner.d.ts +30 -0
  163. package/dist/scanner.d.ts.map +1 -0
  164. package/dist/scanner.js +859 -0
  165. package/dist/scanner.js.map +1 -0
  166. package/dist/scanner.perf.test.d.ts +2 -0
  167. package/dist/scanner.perf.test.d.ts.map +1 -0
  168. package/dist/scanner.perf.test.js +320 -0
  169. package/dist/scanner.perf.test.js.map +1 -0
  170. package/dist/scanner.test.d.ts +2 -0
  171. package/dist/scanner.test.d.ts.map +1 -0
  172. package/dist/scanner.test.js +948 -0
  173. package/dist/scanner.test.js.map +1 -0
  174. package/dist/session-inventory.d.ts +63 -0
  175. package/dist/session-inventory.d.ts.map +1 -0
  176. package/dist/session-inventory.js +525 -0
  177. package/dist/session-inventory.js.map +1 -0
  178. package/dist/session-inventory.perf.test.d.ts +2 -0
  179. package/dist/session-inventory.perf.test.d.ts.map +1 -0
  180. package/dist/session-inventory.perf.test.js +220 -0
  181. package/dist/session-inventory.perf.test.js.map +1 -0
  182. package/dist/session-inventory.test.d.ts +2 -0
  183. package/dist/session-inventory.test.d.ts.map +1 -0
  184. package/dist/session-inventory.test.js +712 -0
  185. package/dist/session-inventory.test.js.map +1 -0
  186. package/dist/session-manager.d.ts +75 -0
  187. package/dist/session-manager.d.ts.map +1 -0
  188. package/dist/session-manager.js +1515 -0
  189. package/dist/session-manager.js.map +1 -0
  190. package/dist/session-manager.test.d.ts +2 -0
  191. package/dist/session-manager.test.d.ts.map +1 -0
  192. package/dist/session-manager.test.js +2861 -0
  193. package/dist/session-manager.test.js.map +1 -0
  194. package/dist/session-store.d.ts +42 -0
  195. package/dist/session-store.d.ts.map +1 -0
  196. package/dist/session-store.js +163 -0
  197. package/dist/session-store.js.map +1 -0
  198. package/dist/session-store.test.d.ts +2 -0
  199. package/dist/session-store.test.d.ts.map +1 -0
  200. package/dist/session-store.test.js +236 -0
  201. package/dist/session-store.test.js.map +1 -0
  202. package/dist/session-title.d.ts +6 -0
  203. package/dist/session-title.d.ts.map +1 -0
  204. package/dist/session-title.js +105 -0
  205. package/dist/session-title.js.map +1 -0
  206. package/dist/session-title.perf.test.d.ts +2 -0
  207. package/dist/session-title.perf.test.d.ts.map +1 -0
  208. package/dist/session-title.perf.test.js +99 -0
  209. package/dist/session-title.perf.test.js.map +1 -0
  210. package/dist/session-title.test.d.ts +2 -0
  211. package/dist/session-title.test.d.ts.map +1 -0
  212. package/dist/session-title.test.js +199 -0
  213. package/dist/session-title.test.js.map +1 -0
  214. package/dist/shutdown-endpoint.test.d.ts +2 -0
  215. package/dist/shutdown-endpoint.test.d.ts.map +1 -0
  216. package/dist/shutdown-endpoint.test.js +93 -0
  217. package/dist/shutdown-endpoint.test.js.map +1 -0
  218. package/dist/storage-housekeeping.d.ts +28 -0
  219. package/dist/storage-housekeeping.d.ts.map +1 -0
  220. package/dist/storage-housekeeping.js +76 -0
  221. package/dist/storage-housekeeping.js.map +1 -0
  222. package/dist/storage-housekeeping.test.d.ts +2 -0
  223. package/dist/storage-housekeeping.test.d.ts.map +1 -0
  224. package/dist/storage-housekeeping.test.js +65 -0
  225. package/dist/storage-housekeeping.test.js.map +1 -0
  226. package/dist/test-daemon-harness.d.ts +31 -0
  227. package/dist/test-daemon-harness.d.ts.map +1 -0
  228. package/dist/test-daemon-harness.js +337 -0
  229. package/dist/test-daemon-harness.js.map +1 -0
  230. package/dist/token-auth.test.d.ts +2 -0
  231. package/dist/token-auth.test.d.ts.map +1 -0
  232. package/dist/token-auth.test.js +52 -0
  233. package/dist/token-auth.test.js.map +1 -0
  234. package/dist/utils.d.ts +4 -0
  235. package/dist/utils.d.ts.map +1 -0
  236. package/dist/utils.js +40 -0
  237. package/dist/utils.js.map +1 -0
  238. package/dist/utils.test.d.ts +2 -0
  239. package/dist/utils.test.d.ts.map +1 -0
  240. package/dist/utils.test.js +54 -0
  241. package/dist/utils.test.js.map +1 -0
  242. package/dist/ws-data.d.ts +4 -0
  243. package/dist/ws-data.d.ts.map +1 -0
  244. package/dist/ws-data.js +20 -0
  245. package/dist/ws-data.js.map +1 -0
  246. package/dist/ws-data.test.d.ts +2 -0
  247. package/dist/ws-data.test.d.ts.map +1 -0
  248. package/dist/ws-data.test.js +17 -0
  249. package/dist/ws-data.test.js.map +1 -0
  250. package/package.json +24 -24
  251. package/perf-reporter.mjs +138 -0
  252. package/scripts/build-release.mjs +41 -0
  253. package/scripts/dev.mjs +537 -0
  254. package/src/advertised-hosts.test.ts +125 -0
  255. package/src/advertised-hosts.ts +225 -0
  256. package/src/audit.test.ts +38 -0
  257. package/src/audit.ts +117 -0
  258. package/src/auth.ts +31 -0
  259. package/src/claude-hooks.ts +195 -0
  260. package/src/cli-version.test.ts +36 -0
  261. package/src/cli-version.ts +46 -0
  262. package/src/config.test.ts +254 -0
  263. package/src/config.ts +324 -0
  264. package/src/dev-auth.test.ts +183 -0
  265. package/src/dev-script.test.ts +511 -0
  266. package/src/drivers/claude.test.ts +1186 -0
  267. package/src/drivers/claude.ts +443 -0
  268. package/src/drivers/codex.test.ts +1096 -0
  269. package/src/drivers/codex.ts +879 -0
  270. package/src/drivers/types.ts +15 -0
  271. package/src/e2e.test.ts +139 -0
  272. package/src/identity.test.ts +26 -0
  273. package/src/identity.ts +82 -0
  274. package/src/index-entry.test.ts +336 -0
  275. package/src/index.ts +781 -0
  276. package/src/logger.ts +112 -0
  277. package/src/metrics.ts +117 -0
  278. package/src/pairing-store.test.ts +53 -0
  279. package/src/pairing-store.ts +154 -0
  280. package/src/paths.ts +19 -0
  281. package/src/perf-compare.ts +164 -0
  282. package/src/port-conflict.test.ts +45 -0
  283. package/src/port-conflict.ts +44 -0
  284. package/src/process-scanner.perf.test.ts +222 -0
  285. package/src/process-scanner.test.ts +575 -0
  286. package/src/process-scanner.ts +514 -0
  287. package/src/push-protocol.test.ts +74 -0
  288. package/src/push-protocol.ts +36 -0
  289. package/src/push-store.test.ts +89 -0
  290. package/src/push-store.ts +126 -0
  291. package/src/push.test.ts +234 -0
  292. package/src/push.ts +318 -0
  293. package/src/safe-stdio.ts +51 -0
  294. package/src/scanner.perf.test.ts +359 -0
  295. package/src/scanner.test.ts +1045 -0
  296. package/src/scanner.ts +924 -0
  297. package/src/session-inventory.perf.test.ts +250 -0
  298. package/src/session-inventory.test.ts +1002 -0
  299. package/src/session-inventory.ts +721 -0
  300. package/src/session-manager.test.ts +3430 -0
  301. package/src/session-manager.ts +1775 -0
  302. package/src/session-store.test.ts +276 -0
  303. package/src/session-store.ts +202 -0
  304. package/src/session-title.perf.test.ts +118 -0
  305. package/src/session-title.test.ts +286 -0
  306. package/src/session-title.ts +108 -0
  307. package/src/shutdown-endpoint.test.ts +95 -0
  308. package/src/storage-housekeeping.test.ts +78 -0
  309. package/src/storage-housekeeping.ts +111 -0
  310. package/src/test-daemon-harness.ts +410 -0
  311. package/src/token-auth.test.ts +67 -0
  312. package/src/utils.test.ts +65 -0
  313. package/src/utils.ts +47 -0
  314. package/src/ws-data.test.ts +20 -0
  315. package/src/ws-data.ts +26 -0
  316. package/tsconfig.json +12 -0
  317. package/README.md +0 -80
  318. package/bin/cloudflared-quick-tunnel.mjs +0 -11
  319. package/bin/cloudflared-resolver.mjs +0 -68
  320. package/bin/vibelet-runtime-policy.mjs +0 -36
  321. package/bin/vibelet.cjs +0 -12
  322. package/bin/vibelet.mjs +0 -1035
  323. package/dist/index.cjs +0 -125
@@ -0,0 +1,1775 @@
1
+ import type { WebSocket } from 'ws';
2
+ import { randomUUID } from 'crypto';
3
+ import { stat } from 'fs/promises';
4
+ import type {
5
+ AgentType,
6
+ ApprovalRequestPayload,
7
+ ApprovalMode,
8
+ ApprovalModeOverride,
9
+ ClientMessage,
10
+ PendingApprovalSnapshot,
11
+ ReconnectSnapshotSession,
12
+ ServerMessage,
13
+ SessionListChangeReason,
14
+ SessionListPage,
15
+ SessionListCursor,
16
+ } from '@vibelet/shared';
17
+ import type { Driver } from './drivers/types.js';
18
+ import { ClaudeDriver, isClaudeSyntheticApprovalRequestId } from './drivers/claude.js';
19
+ import { CodexDriver } from './drivers/codex.js';
20
+ import {
21
+ listSessions,
22
+ readSessionHistory,
23
+ readSessionRuntimeHints,
24
+ invalidateScannerCache,
25
+ } from './scanner.js';
26
+ import { invalidateProcessScanCache } from './process-scanner.js';
27
+ import {
28
+ listSessionPage,
29
+ onExternalInventoryBackfill,
30
+ type ActiveSessionSnapshot,
31
+ } from './session-inventory.js';
32
+ import { SessionStore, type SessionRecord } from './session-store.js';
33
+ import { expandPath } from './utils.js';
34
+ import { isFallbackSessionTitle, titleFromFirstSentence } from './session-title.js';
35
+ import { logger as rootLogger } from './logger.js';
36
+ import { metrics } from './metrics.js';
37
+ import { audit } from './audit.js';
38
+ import { sendPush } from './push.js';
39
+ import { config } from './config.js';
40
+ import {
41
+ CLAUDE_HOOK_APPROVAL_PREFIX,
42
+ DEFAULT_CLAUDE_PERMISSION_HOOK_RESPONSE,
43
+ type ClaudePermissionHookData,
44
+ type ClaudePermissionHookResponse,
45
+ type ClaudeSessionHookData,
46
+ } from './claude-hooks.js';
47
+
48
+ const log = rootLogger.child({ module: 'manager' });
49
+ const DEFAULT_PUSH_BODY = 'Done.';
50
+ const APPROVAL_PUSH_TITLE = 'Approval required';
51
+ const MAX_PUSH_BODY_LENGTH = 180;
52
+ const MAX_ACCEPTED_CLIENT_MESSAGE_IDS = 200;
53
+
54
+ type PushSender = (title: string, body: string, data?: Record<string, unknown>) => Promise<void> | void;
55
+ type SyntheticApprovalRetry = {
56
+ message: string;
57
+ toolName: string;
58
+ };
59
+
60
+ type PendingApproval = ApprovalRequestPayload;
61
+
62
+ type PendingClaudeHookApproval = {
63
+ requestId: string;
64
+ promise: Promise<ClaudePermissionHookResponse>;
65
+ resolve: (response: ClaudePermissionHookResponse) => void;
66
+ };
67
+
68
+ interface ActiveSession {
69
+ sessionId: string;
70
+ agent: AgentType;
71
+ cwd: string;
72
+ approvalMode?: ApprovalMode;
73
+ driver: Driver | null;
74
+ clients: Set<WebSocket>;
75
+ title: string;
76
+ createdAt: string;
77
+ lastActivityAt: string;
78
+ active: boolean;
79
+ /** Monotonic timestamp (Date.now()) of last activity, for idle timeout checks. */
80
+ lastActivityTs: number;
81
+ /** True while the driver is processing a prompt (between sendPrompt and session.done/exit). */
82
+ isResponding: boolean;
83
+ /** Accumulates the current assistant turn so push notifications can include a reply preview. */
84
+ currentReplyText: string;
85
+ /** Recently accepted client message ids so reconnect replays stay idempotent. */
86
+ acceptedClientMessageIds: string[];
87
+ /** Last prompt sent to the agent, used to recover from Claude's post-hoc permission denials. */
88
+ lastUserMessage?: string;
89
+ /** Synthetic approval requests awaiting a one-shot retry. */
90
+ syntheticApprovalRetries: Record<string, SyntheticApprovalRetry>;
91
+ /** Current pending approval request, re-sent to reconnecting clients. */
92
+ pendingApproval?: PendingApproval;
93
+ /** True while a newly created session is still completing async startup. */
94
+ startupInProgress: boolean;
95
+ /** Prompts accepted before the driver is ready to receive them. */
96
+ bufferedPrompts: string[];
97
+ /** Monotonic token used to ignore stale async startup completion. */
98
+ startupToken: number;
99
+ /** True for sessions created from the Vibelet app. */
100
+ managed?: boolean;
101
+ /** Shared secret for Claude hook bridge requests targeting this session. */
102
+ claudeHookSecret?: string;
103
+ /** Pending Claude hook approval requests waiting for user action. */
104
+ pendingClaudeHookApprovals: Map<string, PendingClaudeHookApproval>;
105
+ }
106
+
107
+ interface ResolvedReconnectSession {
108
+ agent: AgentType;
109
+ cwd: string;
110
+ approvalMode?: ApprovalMode;
111
+ pendingApproval?: PendingApproval;
112
+ title: string;
113
+ createdAt: string;
114
+ lastActivityAt: string;
115
+ acceptedClientMessageIds: string[];
116
+ source: 'memory' | 'record' | 'scanner' | 'client';
117
+ managed?: boolean;
118
+ isResponding?: boolean;
119
+ }
120
+
121
+ function mergeAcceptedClientMessageIds(...groups: Array<string[] | undefined>): string[] {
122
+ const merged: string[] = [];
123
+ for (const group of groups) {
124
+ if (!group?.length) continue;
125
+ for (const value of group) {
126
+ if (!value || merged.includes(value)) continue;
127
+ merged.push(value);
128
+ if (merged.length > MAX_ACCEPTED_CLIENT_MESSAGE_IDS) {
129
+ merged.splice(0, merged.length - MAX_ACCEPTED_CLIENT_MESSAGE_IDS);
130
+ }
131
+ }
132
+ }
133
+ return merged;
134
+ }
135
+
136
+ function acceptedClientMessageIdsPayload(acceptedClientMessageIds?: string[]): {
137
+ acceptedClientMessageIds?: string[];
138
+ } {
139
+ return acceptedClientMessageIds?.length
140
+ ? { acceptedClientMessageIds: mergeAcceptedClientMessageIds(acceptedClientMessageIds) }
141
+ : {};
142
+ }
143
+
144
+ function buildRecord(session: ActiveSession): SessionRecord {
145
+ return {
146
+ sessionId: session.sessionId,
147
+ agent: session.agent,
148
+ cwd: session.cwd,
149
+ approvalMode: session.approvalMode,
150
+ acceptedClientMessageIds: mergeAcceptedClientMessageIds(session.acceptedClientMessageIds),
151
+ pendingApproval: session.agent === 'codex' ? session.pendingApproval : undefined,
152
+ title: session.title,
153
+ createdAt: session.createdAt,
154
+ lastActivityAt: session.lastActivityAt,
155
+ managed: session.managed,
156
+ isResponding: session.isResponding || undefined,
157
+ };
158
+ }
159
+
160
+ function hasSyntheticPendingApproval(session: Pick<ActiveSession, 'pendingApproval'>): boolean {
161
+ const requestId = session.pendingApproval?.requestId;
162
+ return typeof requestId === 'string' && isClaudeSyntheticApprovalRequestId(requestId);
163
+ }
164
+
165
+ function buildPendingApprovalSnapshot(
166
+ sessionId: string,
167
+ agent: AgentType,
168
+ pendingApproval: PendingApproval,
169
+ ): PendingApprovalSnapshot {
170
+ return {
171
+ sessionId,
172
+ agent,
173
+ ...pendingApproval,
174
+ };
175
+ }
176
+
177
+ function isClaudeHookApprovalRequestId(requestId: string): boolean {
178
+ return requestId.startsWith(CLAUDE_HOOK_APPROVAL_PREFIX);
179
+ }
180
+
181
+ function buildClaudeHookDecisionResponse(approved: boolean): ClaudePermissionHookResponse {
182
+ return approved
183
+ ? {
184
+ continue: true,
185
+ suppressOutput: true,
186
+ hookSpecificOutput: {
187
+ hookEventName: 'PreToolUse',
188
+ permissionDecision: 'allow',
189
+ },
190
+ }
191
+ : {
192
+ continue: true,
193
+ suppressOutput: true,
194
+ hookSpecificOutput: {
195
+ hookEventName: 'PreToolUse',
196
+ permissionDecision: 'deny',
197
+ permissionDecisionReason: 'Denied from Vibelet',
198
+ },
199
+ };
200
+ }
201
+
202
+ export class SessionManager {
203
+ private sessions = new Map<string, ActiveSession>();
204
+ private claudeHookSessions = new Map<string, ActiveSession>();
205
+ private store = new SessionStore();
206
+ private idleSweepInterval: ReturnType<typeof setInterval> | null = null;
207
+ /** All authenticated WebSocket clients, used for global broadcasts (e.g. approval requests). */
208
+ private globalClients = new Set<WebSocket>();
209
+ private inventoryVersion = 0;
210
+ private readonly removeInventoryBackfillListener: () => void;
211
+
212
+ constructor(private readonly pushSender: PushSender = sendPush) {
213
+ this.removeInventoryBackfillListener = onExternalInventoryBackfill(() => {
214
+ this.noteInventoryChanged('inventory_backfilled');
215
+ });
216
+ this.startIdleSweep();
217
+ }
218
+
219
+ /** Start the periodic idle-session sweep (every 60s). */
220
+ private startIdleSweep(): void {
221
+ if (config.idleTimeoutMs <= 0 && config.turnStallTimeoutMs <= 0) return;
222
+ this.idleSweepInterval = setInterval(() => this.sweepIdleSessions(), 60_000);
223
+ this.idleSweepInterval.unref();
224
+ }
225
+
226
+ /** Stop the idle sweep interval. */
227
+ private stopIdleSweep(): void {
228
+ if (this.idleSweepInterval) {
229
+ clearInterval(this.idleSweepInterval);
230
+ this.idleSweepInterval = null;
231
+ }
232
+ }
233
+
234
+ /** Check all active sessions and stop drivers that have been idle too long. */
235
+ private sweepIdleSessions(): void {
236
+ const now = Date.now();
237
+ const idleTimeoutMs = config.idleTimeoutMs;
238
+ const turnStallTimeoutMs = config.turnStallTimeoutMs;
239
+ if (idleTimeoutMs <= 0 && turnStallTimeoutMs <= 0) return;
240
+
241
+ const INACTIVE_CLEANUP_MS = 60 * 60 * 1000; // 1 hour
242
+
243
+ for (const [id, session] of this.sessions) {
244
+ // Clean up inactive sessions (no driver, no clients) after 1 hour
245
+ if (!session.active && !session.driver) {
246
+ if (session.clients.size === 0) {
247
+ const inactiveMs = now - session.lastActivityTs;
248
+ if (inactiveMs >= INACTIVE_CLEANUP_MS) {
249
+ log.info(
250
+ { sessionId: session.sessionId, inactiveMs },
251
+ 'removing inactive session from memory',
252
+ );
253
+ metrics.increment('session.cleanup', { reason: 'inactive' });
254
+ this.unregisterClaudeHookSession(session);
255
+ this.sessions.delete(id);
256
+ }
257
+ }
258
+ continue;
259
+ }
260
+
261
+ if (!session.active || !session.driver) continue;
262
+
263
+ const inactiveMs = now - session.lastActivityTs;
264
+ if (session.isResponding) {
265
+ if (turnStallTimeoutMs <= 0 || session.pendingApproval) continue;
266
+ if (inactiveMs < turnStallTimeoutMs) continue;
267
+
268
+ log.warn(
269
+ {
270
+ sessionId: session.sessionId,
271
+ agent: session.agent,
272
+ inactiveMs,
273
+ turnStallTimeoutMs,
274
+ },
275
+ 'stopping stalled driver',
276
+ );
277
+ metrics.increment('driver.stall_timeout', { agent: session.agent });
278
+ audit.emit('driver.stall_timeout', {
279
+ sessionId: session.sessionId,
280
+ agent: session.agent,
281
+ idleMs: inactiveMs,
282
+ });
283
+
284
+ // Kill the stalled driver; user's next message will auto-reconnect
285
+ this.resolvePendingClaudeHookApprovals(session);
286
+ session.driver.stop();
287
+ session.active = false;
288
+ session.driver = null;
289
+ session.isResponding = false;
290
+ session.currentReplyText = '';
291
+ this.updateGauges();
292
+ this.broadcast(session.sessionId, {
293
+ type: 'error',
294
+ sessionId: session.sessionId,
295
+ message: `Agent stopped responding for over ${Math.ceil(turnStallTimeoutMs / 1000)}s. Send a new message to continue.`,
296
+ });
297
+ this.touchSession(session.sessionId);
298
+ this.noteInventoryChanged('session_updated');
299
+ continue;
300
+ }
301
+
302
+ if (session.pendingApproval) continue;
303
+
304
+ if (idleTimeoutMs <= 0 || inactiveMs < idleTimeoutMs) continue;
305
+
306
+ log.info(
307
+ { sessionId: session.sessionId, agent: session.agent, idleMs: inactiveMs, timeoutMs: idleTimeoutMs },
308
+ 'stopping idle driver',
309
+ );
310
+ metrics.increment('driver.idle_timeout', { agent: session.agent });
311
+ audit.emit('driver.idle_timeout', {
312
+ sessionId: session.sessionId,
313
+ agent: session.agent,
314
+ idleMs: inactiveMs,
315
+ });
316
+
317
+ session.driver.stop();
318
+ session.active = false;
319
+ session.driver = null;
320
+ this.updateGauges();
321
+ this.touchSession(session.sessionId);
322
+ this.noteInventoryChanged('session_updated');
323
+ }
324
+ }
325
+
326
+ private bindDriverLifecycle(session: ActiveSession, agent: AgentType, context: string, ws?: WebSocket): void {
327
+ session.driver?.onMessage((msg) => {
328
+ log.debug({ agent, context, msgType: msg.type, sessionId: session.sessionId }, 'driver message received');
329
+ if (
330
+ msg.type !== 'response'
331
+ && 'sessionId' in msg
332
+ && msg.sessionId
333
+ && msg.sessionId !== session.sessionId
334
+ && !this.sessions.has(msg.sessionId)
335
+ ) {
336
+ this.remapSessionId(session, msg.sessionId, ws);
337
+ }
338
+ if (msg.type === 'session.done' || msg.type === 'session.interrupted') {
339
+ session.isResponding = false;
340
+ if (msg.type === 'session.interrupted' || !hasSyntheticPendingApproval(session)) {
341
+ session.pendingApproval = undefined;
342
+ }
343
+ }
344
+ if (msg.type === 'approval.request') {
345
+ if (agent === 'codex' && session.approvalMode === 'autoApprove') {
346
+ const sent = session.driver?.respondApproval(msg.requestId, true) ?? false;
347
+ if (sent) {
348
+ audit.emit('approval.response', { sessionId: session.sessionId, requestId: msg.requestId, approved: true });
349
+ session.pendingApproval = undefined;
350
+ session.isResponding = true;
351
+ this.touchSession(session.sessionId);
352
+ this.noteInventoryChanged('session_updated');
353
+ return;
354
+ }
355
+ log.warn({ sessionId: session.sessionId, requestId: msg.requestId }, 'failed to auto-approve codex request; falling back to pending approval flow');
356
+ }
357
+ session.isResponding = false;
358
+ session.pendingApproval = {
359
+ requestId: msg.requestId,
360
+ toolName: msg.toolName,
361
+ input: msg.input,
362
+ description: msg.description,
363
+ ...(msg.approvalContext ? { approvalContext: msg.approvalContext } : {}),
364
+ };
365
+ void this.pushSender(
366
+ APPROVAL_PUSH_TITLE,
367
+ this.buildPushBody(`${session.title || session.sessionId}: ${msg.description || msg.toolName}`),
368
+ {
369
+ sessionId: session.sessionId,
370
+ agent: session.agent,
371
+ requestId: msg.requestId,
372
+ eventType: 'approval_request',
373
+ },
374
+ );
375
+ }
376
+ if (agent === 'claude' && msg.type === 'approval.request' && isClaudeSyntheticApprovalRequestId(msg.requestId)) {
377
+ if (session.lastUserMessage) {
378
+ session.syntheticApprovalRetries[msg.requestId] = {
379
+ message: session.lastUserMessage,
380
+ toolName: msg.toolName,
381
+ };
382
+ } else {
383
+ log.warn({ sessionId: session.sessionId, requestId: msg.requestId }, 'missing lastUserMessage for synthetic approval retry');
384
+ }
385
+ }
386
+ this.touchSession(session.sessionId, msg.type !== 'text.delta');
387
+ if (msg.type === 'approval.request' || msg.type === 'session.done' || msg.type === 'session.interrupted') {
388
+ this.noteInventoryChanged('session_updated');
389
+ }
390
+ this.broadcast(session.sessionId, msg);
391
+ if ((msg.type === 'session.done' || msg.type === 'session.interrupted') && session.bufferedPrompts.length > 0) {
392
+ this.flushBufferedPrompt(session);
393
+ }
394
+ });
395
+
396
+ session.driver?.onExit?.((code) => {
397
+ log.info({ agent, context, exitCode: code, sessionId: session.sessionId }, 'driver exited');
398
+ audit.emit('driver.exit', { sessionId: session.sessionId, agent, exitCode: code });
399
+ metrics.increment('driver.exit', { agent, abnormal: code && code !== 0 ? 'true' : 'false' });
400
+ const preservePendingApproval = Boolean(session.pendingApproval && (
401
+ session.agent === 'codex' || (code === 0 && hasSyntheticPendingApproval(session))
402
+ ));
403
+ this.resolvePendingClaudeHookApprovals(session);
404
+ session.active = false;
405
+ session.driver = null;
406
+ session.isResponding = false;
407
+ session.currentReplyText = '';
408
+ if (!preservePendingApproval) {
409
+ session.pendingApproval = undefined;
410
+ }
411
+ this.updateGauges();
412
+ this.touchSession(session.sessionId);
413
+ this.noteInventoryChanged('session_updated');
414
+ });
415
+ }
416
+
417
+ private remapSessionId(session: ActiveSession, newSessionId: string, ws?: WebSocket): void {
418
+ if (!newSessionId || newSessionId === session.sessionId) return;
419
+ const existing = this.sessions.get(newSessionId);
420
+ if (existing && existing !== session) {
421
+ log.warn({ oldSessionId: session.sessionId, newSessionId }, 'skipping session ID remap because target already exists');
422
+ return;
423
+ }
424
+ const oldId = session.sessionId;
425
+ log.info({ oldSessionId: oldId, newSessionId }, 'session ID updated');
426
+ this.store.remove(oldId);
427
+ this.sessions.delete(oldId);
428
+ session.sessionId = newSessionId;
429
+ this.sessions.set(newSessionId, session);
430
+ this.store.upsert(buildRecord(session));
431
+ this.noteInventoryChanged('session_remapped');
432
+ if (ws) {
433
+ this.reply(ws, `id_update_${Date.now()}`, true, { sessionId: newSessionId, oldSessionId: oldId });
434
+ }
435
+ }
436
+
437
+ private flushBufferedPrompt(session: ActiveSession, force = false): boolean {
438
+ if (!session.driver || !session.active || session.startupInProgress || session.pendingApproval) {
439
+ return false;
440
+ }
441
+ if (!force && session.isResponding) {
442
+ return false;
443
+ }
444
+ const nextPrompt = session.bufferedPrompts.shift();
445
+ if (!nextPrompt) {
446
+ return false;
447
+ }
448
+ session.isResponding = true;
449
+ session.currentReplyText = '';
450
+ session.lastUserMessage = nextPrompt;
451
+ this.touchSession(session.sessionId);
452
+ this.noteInventoryChanged('session_updated');
453
+ session.driver.sendPrompt(nextPrompt);
454
+ return true;
455
+ }
456
+
457
+ private startPendingCodexSession(session: ActiveSession, ws: WebSocket): void {
458
+ if (session.agent !== 'codex' || !session.driver) {
459
+ return;
460
+ }
461
+ const driver = session.driver;
462
+ const pendingSessionId = session.sessionId;
463
+ const startToken = session.startupToken;
464
+ const endTimer = metrics.startTimer('driver.spawn');
465
+ void (async () => {
466
+ try {
467
+ const actualSessionId = await driver.start(session.cwd, undefined, session.approvalMode);
468
+ const spawnMs = endTimer();
469
+ const current = this.sessions.get(pendingSessionId);
470
+ if (current !== session || session.startupToken !== startToken || session.driver !== driver) {
471
+ log.info({ pendingSessionId, actualSessionId }, 'discarding stale codex startup result');
472
+ return;
473
+ }
474
+ session.startupInProgress = false;
475
+ log.info({ pendingSessionId, sessionId: actualSessionId, spawnMs }, 'codex session ready');
476
+ if (actualSessionId && actualSessionId !== pendingSessionId) {
477
+ this.remapSessionId(session, actualSessionId, ws);
478
+ }
479
+ this.flushBufferedPrompt(session, true);
480
+ } catch (e) {
481
+ endTimer();
482
+ const current = this.sessions.get(pendingSessionId);
483
+ if (current !== session || session.startupToken !== startToken) {
484
+ log.info({ pendingSessionId, error: String(e) }, 'ignoring stale codex startup failure');
485
+ return;
486
+ }
487
+ session.startupInProgress = false;
488
+ session.bufferedPrompts = [];
489
+ session.active = false;
490
+ session.driver = null;
491
+ session.isResponding = false;
492
+ session.currentReplyText = '';
493
+ this.updateGauges();
494
+ this.touchSession(session.sessionId);
495
+ this.noteInventoryChanged('session_updated');
496
+ log.error({ sessionId: pendingSessionId, cwd: session.cwd, error: String(e) }, 'async codex createSession error');
497
+ this.broadcast(session.sessionId, {
498
+ type: 'error',
499
+ sessionId: session.sessionId,
500
+ message: `Failed to start Codex session: ${String(e)}`,
501
+ });
502
+ }
503
+ })();
504
+ }
505
+
506
+ private configureDriverBeforeStart(agent: AgentType, driver: Driver, existingSecret?: string): string | undefined {
507
+ if (agent !== 'claude' || !(driver instanceof ClaudeDriver)) return undefined;
508
+ const secret = existingSecret ?? randomUUID().replace(/-/g, '');
509
+ driver.configureHookBridge(config.port, secret);
510
+ return secret;
511
+ }
512
+
513
+ private registerClaudeHookSession(session: ActiveSession): void {
514
+ if (session.agent !== 'claude' || !session.claudeHookSecret) return;
515
+ this.claudeHookSessions.set(session.claudeHookSecret, session);
516
+ }
517
+
518
+ private unregisterClaudeHookSession(session: ActiveSession): void {
519
+ if (session.agent !== 'claude' || !session.claudeHookSecret) return;
520
+ const current = this.claudeHookSessions.get(session.claudeHookSecret);
521
+ if (current === session) {
522
+ this.claudeHookSessions.delete(session.claudeHookSecret);
523
+ }
524
+ }
525
+
526
+ private resolvePendingClaudeHookApprovals(
527
+ session: ActiveSession,
528
+ response: ClaudePermissionHookResponse = DEFAULT_CLAUDE_PERMISSION_HOOK_RESPONSE,
529
+ ): void {
530
+ for (const pending of session.pendingClaudeHookApprovals.values()) {
531
+ pending.resolve(response);
532
+ }
533
+ session.pendingClaudeHookApprovals.clear();
534
+ if (session.pendingApproval && isClaudeHookApprovalRequestId(session.pendingApproval.requestId)) {
535
+ session.pendingApproval = undefined;
536
+ }
537
+ }
538
+
539
+ private resolveClaudeHookSession(secret?: string | null): ActiveSession | undefined {
540
+ if (!secret) return undefined;
541
+ const normalized = secret.trim();
542
+ if (!normalized) return undefined;
543
+ return this.claudeHookSessions.get(normalized);
544
+ }
545
+
546
+ handleClaudeSessionStartHook(secret: string | null | undefined, _data: ClaudeSessionHookData): boolean {
547
+ const session = this.resolveClaudeHookSession(secret);
548
+ if (!session) {
549
+ log.warn({ secretPresent: Boolean(secret) }, 'received Claude session-start hook for unknown session');
550
+ return false;
551
+ }
552
+ this.touchSession(session.sessionId);
553
+ return true;
554
+ }
555
+
556
+ async handleClaudePermissionHook(
557
+ secret: string | null | undefined,
558
+ data: ClaudePermissionHookData,
559
+ ): Promise<ClaudePermissionHookResponse> {
560
+ const session = this.resolveClaudeHookSession(secret);
561
+ if (!session || session.agent !== 'claude' || !session.active) {
562
+ log.warn({ secretPresent: Boolean(secret) }, 'received Claude permission hook for unknown or inactive session');
563
+ return DEFAULT_CLAUDE_PERMISSION_HOOK_RESPONSE;
564
+ }
565
+
566
+ if (session.approvalMode === 'autoApprove') {
567
+ return buildClaudeHookDecisionResponse(true);
568
+ }
569
+
570
+ const rawToolName = typeof data.tool_name === 'string'
571
+ ? data.tool_name
572
+ : typeof data.toolName === 'string'
573
+ ? data.toolName
574
+ : '';
575
+ const toolName = rawToolName.trim();
576
+ if (!toolName) {
577
+ return DEFAULT_CLAUDE_PERMISSION_HOOK_RESPONSE;
578
+ }
579
+
580
+ const toolInput = data.tool_input ?? data.toolInput;
581
+ const input = toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput)
582
+ ? toolInput as Record<string, unknown>
583
+ : {};
584
+ const rawToolUseId = typeof data.tool_use_id === 'string'
585
+ ? data.tool_use_id
586
+ : typeof data.toolUseId === 'string'
587
+ ? data.toolUseId
588
+ : '';
589
+ const requestId = `${CLAUDE_HOOK_APPROVAL_PREFIX}${rawToolUseId.trim() || randomUUID()}`;
590
+ const existing = session.pendingClaudeHookApprovals.get(requestId);
591
+ if (existing) {
592
+ return existing.promise;
593
+ }
594
+
595
+ const description = `Claude requested permissions to use ${toolName}.`;
596
+ let resolvePending!: (response: ClaudePermissionHookResponse) => void;
597
+ const promise = new Promise<ClaudePermissionHookResponse>((resolve) => {
598
+ resolvePending = resolve;
599
+ });
600
+ session.pendingClaudeHookApprovals.set(requestId, {
601
+ requestId,
602
+ promise,
603
+ resolve: (response) => {
604
+ session.pendingClaudeHookApprovals.delete(requestId);
605
+ resolvePending(response);
606
+ },
607
+ });
608
+
609
+ session.pendingApproval = {
610
+ requestId,
611
+ toolName,
612
+ input,
613
+ description,
614
+ };
615
+ audit.emit('approval.request', { agent: 'claude', sessionId: session.sessionId, toolName });
616
+ this.touchSession(session.sessionId);
617
+ this.noteInventoryChanged('session_updated');
618
+ this.broadcast(session.sessionId, {
619
+ type: 'approval.request',
620
+ sessionId: session.sessionId,
621
+ requestId,
622
+ toolName,
623
+ input,
624
+ description,
625
+ });
626
+
627
+ return promise;
628
+ }
629
+
630
+ private async resolveReconnectSession(
631
+ sessionId: string,
632
+ fallbackAgent?: AgentType,
633
+ existingSession?: ActiveSession,
634
+ ): Promise<ResolvedReconnectSession | undefined> {
635
+ if (this.isDeletedSession(sessionId)) {
636
+ return undefined;
637
+ }
638
+
639
+ const now = new Date().toISOString();
640
+ let resolvedAgent = existingSession?.agent;
641
+ let resolvedCwd = existingSession?.cwd ?? '';
642
+ let resolvedApprovalMode = existingSession?.approvalMode;
643
+ let resolvedPendingApproval = existingSession?.pendingApproval;
644
+ let resolvedTitle = existingSession?.title ?? '';
645
+ let resolvedCreatedAt = existingSession?.createdAt ?? now;
646
+ let resolvedLastActivityAt = existingSession?.lastActivityAt ?? now;
647
+ let resolvedAcceptedClientMessageIds = mergeAcceptedClientMessageIds(existingSession?.acceptedClientMessageIds);
648
+ let resolvedIsResponding = existingSession?.isResponding;
649
+ let source: ResolvedReconnectSession['source'] = existingSession ? 'memory' : 'client';
650
+
651
+ const record = this.store.find(sessionId);
652
+ if (record) {
653
+ resolvedAgent = record.agent;
654
+ resolvedCwd = record.cwd || resolvedCwd;
655
+ resolvedApprovalMode = record.approvalMode ?? resolvedApprovalMode;
656
+ resolvedPendingApproval = record.pendingApproval ?? resolvedPendingApproval;
657
+ resolvedTitle = record.title || resolvedTitle;
658
+ resolvedCreatedAt = record.createdAt || resolvedCreatedAt;
659
+ resolvedLastActivityAt = record.lastActivityAt || resolvedLastActivityAt;
660
+ resolvedAcceptedClientMessageIds = mergeAcceptedClientMessageIds(
661
+ record.acceptedClientMessageIds,
662
+ resolvedAcceptedClientMessageIds,
663
+ );
664
+ source = 'record';
665
+ } else if (!existingSession) {
666
+ const scanned = await listSessions(fallbackAgent ?? resolvedAgent);
667
+ const found = scanned.find((candidate) => candidate.sessionId === sessionId);
668
+ if (found) {
669
+ resolvedAgent = found.agent;
670
+ resolvedCwd = found.cwd || resolvedCwd;
671
+ resolvedTitle = found.title ?? resolvedTitle;
672
+ resolvedCreatedAt = found.createdAt;
673
+ resolvedLastActivityAt = found.lastActivityAt;
674
+ resolvedIsResponding = found.runtime.isResponding ?? resolvedIsResponding;
675
+ source = 'scanner';
676
+ }
677
+ }
678
+
679
+ if (!resolvedAgent && fallbackAgent) {
680
+ resolvedAgent = fallbackAgent;
681
+ source = existingSession ? 'memory' : 'client';
682
+ }
683
+
684
+ if (!resolvedAgent) {
685
+ return undefined;
686
+ }
687
+
688
+ return {
689
+ agent: resolvedAgent,
690
+ cwd: resolvedCwd,
691
+ approvalMode: resolvedApprovalMode,
692
+ pendingApproval: resolvedPendingApproval,
693
+ title: resolvedTitle,
694
+ createdAt: resolvedCreatedAt,
695
+ lastActivityAt: resolvedLastActivityAt,
696
+ acceptedClientMessageIds: resolvedAcceptedClientMessageIds,
697
+ source,
698
+ managed: existingSession?.managed ?? record?.managed,
699
+ isResponding: resolvedIsResponding || undefined,
700
+ };
701
+ }
702
+
703
+ private collectReconnectPendingApprovals(): PendingApprovalSnapshot[] {
704
+ const approvals: PendingApprovalSnapshot[] = [];
705
+ const seen = new Set<string>();
706
+
707
+ for (const session of this.sessions.values()) {
708
+ if (!session.pendingApproval) continue;
709
+ const key = `${session.agent}:${session.sessionId}:${session.pendingApproval.requestId}`;
710
+ if (seen.has(key)) continue;
711
+ seen.add(key);
712
+ approvals.push(buildPendingApprovalSnapshot(session.sessionId, session.agent, session.pendingApproval));
713
+ }
714
+
715
+ for (const record of this.store.getAll()) {
716
+ if (!record.pendingApproval) continue;
717
+ const key = `${record.agent}:${record.sessionId}:${record.pendingApproval.requestId}`;
718
+ if (seen.has(key)) continue;
719
+ seen.add(key);
720
+ approvals.push(buildPendingApprovalSnapshot(record.sessionId, record.agent, record.pendingApproval));
721
+ }
722
+
723
+ return approvals;
724
+ }
725
+
726
+ private restoreDriverPendingApproval(driver: Driver, pendingApproval: PendingApproval | undefined): void {
727
+ if (!pendingApproval) return;
728
+ driver.restorePendingApproval?.(pendingApproval);
729
+ }
730
+
731
+ private async reviveSessionForApproval(session: ActiveSession): Promise<boolean> {
732
+ if (session.driver) return true;
733
+ if (!session.pendingApproval || session.agent !== 'codex') return false;
734
+
735
+ const driver = this.createDriver(session.agent);
736
+ const claudeHookSecret = this.configureDriverBeforeStart(session.agent, driver, session.claudeHookSecret);
737
+ await driver.start(session.cwd, session.sessionId, session.approvalMode);
738
+ this.restoreDriverPendingApproval(driver, session.pendingApproval);
739
+
740
+ session.driver = driver;
741
+ session.active = true;
742
+ session.isResponding = false;
743
+ session.currentReplyText = '';
744
+ session.startupInProgress = false;
745
+ session.bufferedPrompts = session.bufferedPrompts ?? [];
746
+ session.startupToken = session.startupToken ?? 0;
747
+ session.claudeHookSecret = claudeHookSecret ?? session.claudeHookSecret;
748
+ session.pendingClaudeHookApprovals = session.pendingClaudeHookApprovals ?? new Map();
749
+
750
+ this.registerClaudeHookSession(session);
751
+ this.bindDriverLifecycle(session, session.agent, ' (approval resumed)');
752
+ this.store.upsert(buildRecord(session));
753
+ this.updateGauges();
754
+ this.touchSession(session.sessionId);
755
+ this.noteInventoryChanged('session_updated');
756
+ return true;
757
+ }
758
+
759
+ private buildPushBody(replyText: string): string {
760
+ const collapsed = replyText.replace(/\s+/g, ' ').trim();
761
+ if (!collapsed) return DEFAULT_PUSH_BODY;
762
+ if (collapsed.length <= MAX_PUSH_BODY_LENGTH) return collapsed;
763
+ return `${collapsed.slice(0, Math.max(1, MAX_PUSH_BODY_LENGTH - 3)).trimEnd()}...`;
764
+ }
765
+
766
+ private currentPartialReplyText(session?: Pick<ActiveSession, 'isResponding' | 'currentReplyText'>): string | undefined {
767
+ if (!session?.isResponding) return undefined;
768
+ const partial = session.currentReplyText.trim();
769
+ return partial ? session.currentReplyText : undefined;
770
+ }
771
+
772
+ private touchSession(sessionId: string, persist = true): void {
773
+ const now = new Date().toISOString();
774
+ const session = this.sessions.get(sessionId);
775
+ if (session) {
776
+ session.lastActivityAt = now;
777
+ session.lastActivityTs = Date.now();
778
+ if (persist) {
779
+ this.store.upsert(buildRecord(session));
780
+ }
781
+ return;
782
+ }
783
+
784
+ const record = this.store.find(sessionId);
785
+ if (record) {
786
+ this.store.upsert({ ...record, lastActivityAt: now });
787
+ }
788
+ }
789
+
790
+ private updateGauges(): void {
791
+ let active = 0;
792
+ for (const s of this.sessions.values()) {
793
+ if (s.active) active++;
794
+ }
795
+ metrics.gauge('session.active', active);
796
+ }
797
+
798
+ private activeSessionSnapshots(): ActiveSessionSnapshot[] {
799
+ const snapshots: ActiveSessionSnapshot[] = [];
800
+ for (const session of this.sessions.values()) {
801
+ if (session.sessionId.startsWith('pending_')) continue;
802
+ if (!session.active) continue;
803
+ snapshots.push({
804
+ sessionId: session.sessionId,
805
+ agent: session.agent,
806
+ cwd: session.cwd,
807
+ approvalMode: session.approvalMode,
808
+ title: session.title,
809
+ createdAt: session.createdAt,
810
+ lastActivityAt: session.lastActivityAt,
811
+ ...(session.pendingApproval ? { needsAttention: true } : {}),
812
+ ...(session.isResponding ? { isResponding: true } : {}),
813
+ managed: session.managed,
814
+ });
815
+ }
816
+ return snapshots;
817
+ }
818
+
819
+ private getDeletedSessionIds(): Set<string> {
820
+ return new Set(this.store.getDeletedSessionIds());
821
+ }
822
+
823
+ private isDeletedSession(sessionId: string): boolean {
824
+ return this.store.isDeleted(sessionId);
825
+ }
826
+
827
+ private async listRecentSessionsForContinue(agent: AgentType, cwd: string) {
828
+ return listSessions(agent, cwd);
829
+ }
830
+
831
+ async handle(ws: WebSocket, msg: ClientMessage): Promise<void> {
832
+ log.debug({ action: msg.action }, 'handling client message');
833
+ switch (msg.action) {
834
+ case 'session.create':
835
+ await this.createSession(ws, msg.id, msg.agent, msg.cwd, msg.approvalMode, msg.continueSession);
836
+ break;
837
+ case 'session.resume':
838
+ await this.resumeSession(ws, msg.id, msg.sessionId, msg.agent);
839
+ break;
840
+ case 'session.send':
841
+ await this.sendMessage(ws, msg.id, msg.sessionId, msg.message, msg.agent, msg.clientMessageId, msg.images);
842
+ break;
843
+ case 'session.approve':
844
+ await this.approve(ws, msg.id, msg.sessionId, msg.requestId, msg.approved);
845
+ break;
846
+ case 'session.setApprovalMode':
847
+ await this.setApprovalMode(ws, msg.id, msg.sessionId, msg.approvalMode);
848
+ break;
849
+ case 'session.interrupt':
850
+ this.interrupt(ws, msg.id, msg.sessionId);
851
+ break;
852
+ case 'session.stop':
853
+ this.stopSession(ws, msg.id, msg.sessionId);
854
+ break;
855
+ case 'session.delete':
856
+ this.deleteSession(ws, msg.id, msg.sessionId);
857
+ break;
858
+ case 'session.history':
859
+ await this.sendHistory(ws, msg.id, msg.sessionId, msg.agent);
860
+ break;
861
+ case 'reconnect.snapshot':
862
+ await this.sendReconnectSnapshot(ws, msg.id, msg.agent, msg.cwd, msg.search, msg.activeSessionId, msg.activeAgent);
863
+ break;
864
+ case 'sessions.list':
865
+ await this.listSessions(ws, msg.id, msg.agent, msg.cwd, msg.search, msg.limit, msg.cursor);
866
+ break;
867
+ }
868
+ }
869
+
870
+ addGlobalClient(ws: WebSocket): void {
871
+ this.globalClients.add(ws);
872
+ }
873
+
874
+ removeClient(ws: WebSocket): void {
875
+ this.globalClients.delete(ws);
876
+ for (const session of this.sessions.values()) {
877
+ session.clients.delete(ws);
878
+ }
879
+ }
880
+
881
+ shutdown(): void {
882
+ this.stopIdleSweep();
883
+ this.removeInventoryBackfillListener();
884
+ this.globalClients.clear();
885
+ for (const session of this.sessions.values()) {
886
+ try {
887
+ this.resolvePendingClaudeHookApprovals(session);
888
+ session.driver?.stop();
889
+ } catch (e) {
890
+ log.error({ sessionId: session.sessionId, agent: session.agent, error: String(e) }, 'failed to stop session on shutdown');
891
+ }
892
+ session.active = false;
893
+ session.driver = null;
894
+ session.clients.clear();
895
+ this.unregisterClaudeHookSession(session);
896
+ }
897
+ this.claudeHookSessions.clear();
898
+ this.store.flushSync();
899
+ }
900
+
901
+ /** Expose active session count for health endpoint. */
902
+ getActiveSessionCount(): number {
903
+ let count = 0;
904
+ for (const s of this.sessions.values()) {
905
+ if (s.active) count++;
906
+ }
907
+ return count;
908
+ }
909
+
910
+ /** Fire-and-forget cache warm so the next reconnect.snapshot is fast. */
911
+ prewarmCaches(): void {
912
+ void listSessionPage({
913
+ limit: 50,
914
+ activeSessions: this.activeSessionSnapshots(),
915
+ sessionRecords: this.store.getAll(),
916
+ deletedSessionIds: this.getDeletedSessionIds(),
917
+ }).catch(() => {});
918
+ }
919
+
920
+ /** Expose driver breakdown for health endpoint. */
921
+ getDriverCounts(): Record<string, number> {
922
+ const counts: Record<string, number> = {};
923
+ for (const s of this.sessions.values()) {
924
+ if (s.active) {
925
+ counts[s.agent] = (counts[s.agent] ?? 0) + 1;
926
+ }
927
+ }
928
+ return counts;
929
+ }
930
+
931
+ private noteInventoryChanged(reason: SessionListChangeReason): void {
932
+ this.inventoryVersion += 1;
933
+ if (this.globalClients.size === 0) {
934
+ return;
935
+ }
936
+
937
+ const msg: ServerMessage = {
938
+ type: 'sessions.changed',
939
+ version: this.inventoryVersion,
940
+ reason,
941
+ };
942
+ const data = JSON.stringify(msg);
943
+ for (const client of this.globalClients) {
944
+ if (client.readyState === 1) {
945
+ client.send(data);
946
+ }
947
+ }
948
+ }
949
+
950
+ private async createSession(ws: WebSocket, reqId: string, agent: AgentType, cwd: string, approvalMode?: ApprovalMode, continueSession?: boolean): Promise<void> {
951
+ cwd = expandPath(cwd);
952
+ log.info({ agent, cwd, approvalMode, continueSession }, 'creating session');
953
+
954
+ // Validate that the working directory exists and is a directory
955
+ try {
956
+ const cwdStat = await stat(cwd);
957
+ if (!cwdStat.isDirectory()) {
958
+ this.reply(ws, reqId, false, undefined, `Path is not a directory: ${cwd}`);
959
+ return;
960
+ }
961
+ } catch {
962
+ this.reply(ws, reqId, false, undefined, `Directory does not exist: ${cwd}`);
963
+ return;
964
+ }
965
+
966
+ // For "continue last" mode, find the most recent session and resume it
967
+ if (continueSession) {
968
+ try {
969
+ const recentSessions = await this.listRecentSessionsForContinue(agent, cwd);
970
+ const lastSession = recentSessions.find((session) => !this.isDeletedSession(session.sessionId));
971
+ if (lastSession) {
972
+ log.info({ sessionId: lastSession.sessionId, cwd }, 'continue mode: resuming last session');
973
+ return this.resumeSession(ws, reqId, lastSession.sessionId, agent, cwd, approvalMode);
974
+ }
975
+ log.info('continue mode: no previous sessions found, creating new');
976
+ } catch (e) {
977
+ log.warn({ error: String(e) }, 'continue mode: error finding sessions, creating new');
978
+ }
979
+ }
980
+
981
+ try {
982
+ const driver = this.createDriver(agent);
983
+ const claudeHookSecret = this.configureDriverBeforeStart(agent, driver);
984
+ if (agent === 'codex') {
985
+ const sessionId = `pending_${Date.now()}`;
986
+ log.info({ sessionId, agent, cwd }, 'session created (pending codex startup)');
987
+ metrics.increment('session.create', { agent });
988
+ audit.emit('session.create', { sessionId, agent, cwd, approvalMode });
989
+
990
+ const session: ActiveSession = {
991
+ sessionId,
992
+ agent,
993
+ cwd,
994
+ approvalMode,
995
+ driver,
996
+ clients: new Set([ws]),
997
+ title: 'New session',
998
+ createdAt: new Date().toISOString(),
999
+ lastActivityAt: new Date().toISOString(),
1000
+ active: true,
1001
+ lastActivityTs: Date.now(),
1002
+ isResponding: false,
1003
+ currentReplyText: '',
1004
+ acceptedClientMessageIds: [],
1005
+ syntheticApprovalRetries: {},
1006
+ startupInProgress: true,
1007
+ bufferedPrompts: [],
1008
+ startupToken: 1,
1009
+ managed: true,
1010
+ claudeHookSecret,
1011
+ pendingClaudeHookApprovals: new Map(),
1012
+ };
1013
+ this.sessions.set(sessionId, session);
1014
+ this.registerClaudeHookSession(session);
1015
+ this.store.upsert(buildRecord(session));
1016
+ this.updateGauges();
1017
+ invalidateScannerCache();
1018
+ invalidateProcessScanCache();
1019
+ this.noteInventoryChanged('session_created');
1020
+
1021
+ this.bindDriverLifecycle(session, agent, '', ws);
1022
+
1023
+ this.reply(ws, reqId, true, { sessionId });
1024
+ this.startPendingCodexSession(session, ws);
1025
+ return;
1026
+ }
1027
+
1028
+ const endTimer = metrics.startTimer('driver.spawn');
1029
+ const sessionId = await driver.start(cwd, undefined, approvalMode);
1030
+ const spawnMs = endTimer();
1031
+ log.info({ sessionId, agent, spawnMs }, 'session created');
1032
+
1033
+ metrics.increment('session.create', { agent });
1034
+ audit.emit('session.create', { sessionId, agent, cwd, approvalMode });
1035
+
1036
+ const session: ActiveSession = {
1037
+ sessionId,
1038
+ agent,
1039
+ cwd,
1040
+ approvalMode,
1041
+ driver,
1042
+ clients: new Set([ws]),
1043
+ title: 'New session',
1044
+ createdAt: new Date().toISOString(),
1045
+ lastActivityAt: new Date().toISOString(),
1046
+ active: true,
1047
+ lastActivityTs: Date.now(),
1048
+ isResponding: false,
1049
+ currentReplyText: '',
1050
+ acceptedClientMessageIds: [],
1051
+ syntheticApprovalRetries: {},
1052
+ startupInProgress: false,
1053
+ bufferedPrompts: [],
1054
+ startupToken: 0,
1055
+ managed: true,
1056
+ claudeHookSecret,
1057
+ pendingClaudeHookApprovals: new Map(),
1058
+ };
1059
+ this.sessions.set(sessionId, session);
1060
+ this.registerClaudeHookSession(session);
1061
+ this.store.upsert(buildRecord(session));
1062
+ this.updateGauges();
1063
+ invalidateScannerCache();
1064
+ invalidateProcessScanCache();
1065
+ this.noteInventoryChanged('session_created');
1066
+
1067
+ this.bindDriverLifecycle(session, agent, '', ws);
1068
+
1069
+ this.reply(ws, reqId, true, { sessionId });
1070
+ } catch (e) {
1071
+ log.error({ agent, cwd, error: String(e) }, 'createSession error');
1072
+ this.reply(ws, reqId, false, undefined, String(e));
1073
+ }
1074
+ }
1075
+
1076
+ private async resumeSession(
1077
+ ws: WebSocket,
1078
+ reqId: string,
1079
+ sessionId: string,
1080
+ agent: AgentType,
1081
+ cwdOverride?: string,
1082
+ approvalMode?: ApprovalMode,
1083
+ ): Promise<void> {
1084
+ if (this.isDeletedSession(sessionId)) {
1085
+ this.reply(ws, reqId, false, undefined, 'Session not found');
1086
+ return;
1087
+ }
1088
+
1089
+ // If already active, just attach client
1090
+ const existing = this.sessions.get(sessionId);
1091
+ if (existing && existing.active) {
1092
+ existing.clients.add(ws);
1093
+ this.touchSession(existing.sessionId);
1094
+ this.noteInventoryChanged('session_updated');
1095
+ // Send history
1096
+ const history = await readSessionHistory(sessionId, agent, existing.cwd);
1097
+ const partialReplyText = this.currentPartialReplyText(existing);
1098
+ if (history.length > 0 || partialReplyText || existing.approvalMode || existing.pendingApproval) {
1099
+ const historyMsg: ServerMessage = {
1100
+ type: 'session.history',
1101
+ sessionId,
1102
+ messages: history,
1103
+ ...acceptedClientMessageIdsPayload(existing.acceptedClientMessageIds),
1104
+ isResponding: existing.isResponding || undefined,
1105
+ partialReplyText,
1106
+ approvalMode: existing.approvalMode,
1107
+ pendingApproval: existing.pendingApproval,
1108
+ };
1109
+ if (ws.readyState === 1) {
1110
+ ws.send(JSON.stringify(historyMsg));
1111
+ }
1112
+ }
1113
+ this.reply(ws, reqId, true, { sessionId });
1114
+ return;
1115
+ }
1116
+
1117
+ // Find persisted record for cwd/title
1118
+ const record = this.store.find(sessionId);
1119
+ const cwd = cwdOverride || record?.cwd || '';
1120
+ const sessionApprovalMode = approvalMode ?? record?.approvalMode;
1121
+ const title = record?.title ?? 'Resumed session';
1122
+
1123
+ try {
1124
+ const endTimer = metrics.startTimer('driver.spawn');
1125
+ const driver = this.createDriver(agent);
1126
+ const claudeHookSecret = this.configureDriverBeforeStart(agent, driver);
1127
+ const actualSessionId = await driver.start(cwd, sessionId, sessionApprovalMode);
1128
+ const spawnMs = endTimer();
1129
+ log.info({ sessionId: actualSessionId, agent, spawnMs }, 'session resumed');
1130
+
1131
+ metrics.increment('session.resume', { agent });
1132
+ audit.emit('session.resume', { sessionId: actualSessionId, agent, cwd });
1133
+
1134
+ const session: ActiveSession = {
1135
+ sessionId: actualSessionId, agent, cwd, approvalMode: sessionApprovalMode, driver,
1136
+ clients: new Set([ws]),
1137
+ title,
1138
+ createdAt: record?.createdAt ?? new Date().toISOString(),
1139
+ lastActivityAt: new Date().toISOString(),
1140
+ active: true,
1141
+ lastActivityTs: Date.now(),
1142
+ isResponding: false,
1143
+ currentReplyText: '',
1144
+ acceptedClientMessageIds: record?.acceptedClientMessageIds ?? [],
1145
+ syntheticApprovalRetries: {},
1146
+ startupInProgress: false,
1147
+ bufferedPrompts: [],
1148
+ startupToken: 0,
1149
+ managed: record?.managed,
1150
+ pendingApproval: record?.pendingApproval,
1151
+ claudeHookSecret,
1152
+ pendingClaudeHookApprovals: new Map(),
1153
+ };
1154
+ this.sessions.set(actualSessionId, session);
1155
+ this.registerClaudeHookSession(session);
1156
+ this.store.upsert(buildRecord(session));
1157
+ this.updateGauges();
1158
+ this.noteInventoryChanged('session_updated');
1159
+
1160
+ this.bindDriverLifecycle(session, agent, ' (resumed)', ws);
1161
+ this.restoreDriverPendingApproval(driver, session.pendingApproval);
1162
+
1163
+ // Send history from session file
1164
+ const history = await readSessionHistory(sessionId, agent, cwd);
1165
+ if (history.length > 0 || session.pendingApproval) {
1166
+ log.info({ sessionId, historyCount: history.length }, 'sending history messages');
1167
+ const historyMsg: ServerMessage = {
1168
+ type: 'session.history',
1169
+ sessionId: actualSessionId,
1170
+ messages: history,
1171
+ ...acceptedClientMessageIdsPayload(session.acceptedClientMessageIds),
1172
+ approvalMode: sessionApprovalMode,
1173
+ pendingApproval: session.pendingApproval,
1174
+ };
1175
+ if (ws.readyState === 1) {
1176
+ ws.send(JSON.stringify(historyMsg));
1177
+ }
1178
+ }
1179
+
1180
+ this.reply(ws, reqId, true, { sessionId: actualSessionId });
1181
+ } catch (e) {
1182
+ this.reply(ws, reqId, false, undefined, String(e));
1183
+ }
1184
+ }
1185
+
1186
+ private hasAcceptedClientMessage(session: ActiveSession, clientMessageId: string): boolean {
1187
+ return session.acceptedClientMessageIds.includes(clientMessageId);
1188
+ }
1189
+
1190
+ private rememberAcceptedClientMessage(session: ActiveSession, clientMessageId: string): void {
1191
+ if (this.hasAcceptedClientMessage(session, clientMessageId)) return;
1192
+ session.acceptedClientMessageIds.push(clientMessageId);
1193
+ if (session.acceptedClientMessageIds.length > MAX_ACCEPTED_CLIENT_MESSAGE_IDS) {
1194
+ session.acceptedClientMessageIds.splice(
1195
+ 0,
1196
+ session.acceptedClientMessageIds.length - MAX_ACCEPTED_CLIENT_MESSAGE_IDS,
1197
+ );
1198
+ }
1199
+ }
1200
+
1201
+ private async sendMessage(
1202
+ ws: WebSocket,
1203
+ reqId: string,
1204
+ sessionId: string,
1205
+ message: string,
1206
+ agent?: AgentType,
1207
+ clientMessageId?: string,
1208
+ images?: string[],
1209
+ ): Promise<void> {
1210
+ if (this.isDeletedSession(sessionId)) {
1211
+ this.reply(ws, reqId, false, undefined, 'Session not found');
1212
+ return;
1213
+ }
1214
+
1215
+ let session = this.sessions.get(sessionId);
1216
+
1217
+ // Auto-reconnect: if session not found or inactive, try to resume it
1218
+ if (!session || !session.driver) {
1219
+ const reconnect = await this.resolveReconnectSession(sessionId, agent, session);
1220
+ if (!reconnect) {
1221
+ log.warn({ sessionId }, 'session not found in records or scanner');
1222
+ this.reply(ws, reqId, false, undefined, 'Session not found');
1223
+ return;
1224
+ }
1225
+
1226
+ log.info({ sessionId, agent: reconnect.agent, source: reconnect.source }, 'auto-reconnecting session');
1227
+ metrics.increment('session.reconnect', { agent: reconnect.agent, source: reconnect.source });
1228
+ audit.emit('session.reconnect', { sessionId, agent: reconnect.agent, source: reconnect.source });
1229
+
1230
+ try {
1231
+ const driver = this.createDriver(reconnect.agent);
1232
+ const claudeHookSecret = this.configureDriverBeforeStart(reconnect.agent, driver, session?.claudeHookSecret);
1233
+ await driver.start(reconnect.cwd, sessionId, reconnect.approvalMode);
1234
+ this.restoreDriverPendingApproval(driver, reconnect.pendingApproval);
1235
+
1236
+ if (session) {
1237
+ session.approvalMode = reconnect.approvalMode;
1238
+ session.pendingApproval = reconnect.pendingApproval;
1239
+ session.driver = driver;
1240
+ session.active = true;
1241
+ session.clients.add(ws);
1242
+ session.lastActivityAt = reconnect.lastActivityAt;
1243
+ session.lastActivityTs = Date.now();
1244
+ session.isResponding = false;
1245
+ session.currentReplyText = '';
1246
+ session.acceptedClientMessageIds = mergeAcceptedClientMessageIds(
1247
+ session.acceptedClientMessageIds,
1248
+ reconnect.acceptedClientMessageIds,
1249
+ );
1250
+ session.syntheticApprovalRetries = session.syntheticApprovalRetries ?? {};
1251
+ session.startupInProgress = false;
1252
+ session.bufferedPrompts = session.bufferedPrompts ?? [];
1253
+ session.startupToken = session.startupToken ?? 0;
1254
+ session.claudeHookSecret = claudeHookSecret ?? session.claudeHookSecret;
1255
+ session.pendingClaudeHookApprovals = session.pendingClaudeHookApprovals ?? new Map();
1256
+ } else {
1257
+ session = {
1258
+ sessionId,
1259
+ agent: reconnect.agent,
1260
+ cwd: reconnect.cwd,
1261
+ approvalMode: reconnect.approvalMode,
1262
+ driver,
1263
+ clients: new Set([ws]),
1264
+ title: reconnect.title,
1265
+ createdAt: reconnect.createdAt,
1266
+ lastActivityAt: reconnect.lastActivityAt,
1267
+ active: true,
1268
+ lastActivityTs: Date.now(),
1269
+ isResponding: false,
1270
+ currentReplyText: '',
1271
+ acceptedClientMessageIds: reconnect.acceptedClientMessageIds,
1272
+ syntheticApprovalRetries: {},
1273
+ startupInProgress: false,
1274
+ bufferedPrompts: [],
1275
+ startupToken: 0,
1276
+ managed: reconnect.managed,
1277
+ pendingApproval: reconnect.pendingApproval,
1278
+ claudeHookSecret,
1279
+ pendingClaudeHookApprovals: new Map(),
1280
+ };
1281
+ this.sessions.set(sessionId, session);
1282
+ }
1283
+
1284
+ this.registerClaudeHookSession(session);
1285
+ this.bindDriverLifecycle(session, reconnect.agent, ' (reconnected)');
1286
+ this.store.upsert(buildRecord(session));
1287
+ this.updateGauges();
1288
+ this.noteInventoryChanged('session_updated');
1289
+ } catch (e) {
1290
+ this.reply(ws, reqId, false, undefined, `Failed to reconnect: ${e}`);
1291
+ return;
1292
+ }
1293
+ }
1294
+
1295
+ session.clients.add(ws);
1296
+ if (session.pendingApproval) {
1297
+ this.reply(ws, reqId, false, undefined, 'Resolve the pending approval before sending another message.');
1298
+ return;
1299
+ }
1300
+ if (clientMessageId && this.hasAcceptedClientMessage(session, clientMessageId)) {
1301
+ this.reply(ws, reqId, true, { sessionId, clientMessageId, duplicate: true });
1302
+ return;
1303
+ }
1304
+ log.info({
1305
+ sessionId,
1306
+ clients: session.clients.size,
1307
+ hasDriver: !!session.driver,
1308
+ active: session.active,
1309
+ imageCount: images?.length ?? 0,
1310
+ }, 'sending message');
1311
+ audit.emit('session.send', {
1312
+ sessionId,
1313
+ agent: session.agent,
1314
+ messagePreview: message.slice(0, 100),
1315
+ imageCount: images?.length ?? 0,
1316
+ });
1317
+
1318
+ if (isFallbackSessionTitle(session.title)) {
1319
+ const derivedTitle = titleFromFirstSentence(message, 50);
1320
+ if (derivedTitle && derivedTitle !== session.title) {
1321
+ session.title = derivedTitle;
1322
+ }
1323
+ this.store.upsert(buildRecord(session));
1324
+ }
1325
+ if (clientMessageId) {
1326
+ this.rememberAcceptedClientMessage(session, clientMessageId);
1327
+ }
1328
+ this.touchSession(session.sessionId);
1329
+ const attachmentBlock = images?.length
1330
+ ? images.map((path) => `[Attached image: ${path}]`).join('\n')
1331
+ : '';
1332
+ const prompt = attachmentBlock
1333
+ ? message
1334
+ ? `${message}\n\n${attachmentBlock}`
1335
+ : attachmentBlock
1336
+ : message;
1337
+ if (session.startupInProgress) {
1338
+ session.isResponding = true;
1339
+ session.currentReplyText = '';
1340
+ session.lastUserMessage = message;
1341
+ session.bufferedPrompts.push(prompt);
1342
+ this.noteInventoryChanged('session_updated');
1343
+ this.reply(ws, reqId, true, clientMessageId ? { sessionId, clientMessageId } : undefined);
1344
+ return;
1345
+ }
1346
+ if (session.isResponding) {
1347
+ session.bufferedPrompts.push(prompt);
1348
+ this.reply(ws, reqId, true, clientMessageId ? { sessionId, clientMessageId } : undefined);
1349
+ return;
1350
+ }
1351
+ session.isResponding = true;
1352
+ session.currentReplyText = '';
1353
+ session.lastUserMessage = message;
1354
+ this.noteInventoryChanged('session_updated');
1355
+ session.driver!.sendPrompt(prompt);
1356
+ this.reply(ws, reqId, true, clientMessageId ? { sessionId, clientMessageId } : undefined);
1357
+ }
1358
+
1359
+ private async sendReconnectSnapshot(
1360
+ ws: WebSocket,
1361
+ reqId: string,
1362
+ agent?: AgentType,
1363
+ cwd?: string,
1364
+ search?: string,
1365
+ activeSessionId?: string,
1366
+ activeAgent?: AgentType,
1367
+ ): Promise<void> {
1368
+ try {
1369
+ const deletedSessionIds = this.getDeletedSessionIds();
1370
+ let page: SessionListPage;
1371
+ try {
1372
+ page = await listSessionPage({
1373
+ agent,
1374
+ cwd,
1375
+ search,
1376
+ limit: 50,
1377
+ activeSessions: this.activeSessionSnapshots(),
1378
+ sessionRecords: this.store.getAll(),
1379
+ deletedSessionIds,
1380
+ });
1381
+ } catch (error) {
1382
+ log.warn({ error: String(error) }, 'failed to build reconnect snapshot session list');
1383
+ page = { sessions: [], nextCursor: undefined };
1384
+ }
1385
+ page = { ...page, inventoryVersion: this.inventoryVersion };
1386
+ let activeSession: ReconnectSnapshotSession | undefined;
1387
+ if (activeSessionId && !deletedSessionIds.has(activeSessionId)) {
1388
+ const inferredAgent = activeAgent
1389
+ ?? page.sessions.find((session) => session.sessionId === activeSessionId)?.agent;
1390
+ const liveSession = this.sessions.get(activeSessionId);
1391
+ // Subscribe this ws to session broadcasts so streaming updates reach reconnected clients
1392
+ if (liveSession) {
1393
+ liveSession.clients.add(ws);
1394
+ }
1395
+ const resolved = await this.resolveReconnectSession(activeSessionId, inferredAgent, liveSession);
1396
+ if (resolved) {
1397
+ const history = await readSessionHistory(activeSessionId, resolved.agent, liveSession?.cwd ?? resolved.cwd);
1398
+ const runtimeHints = liveSession
1399
+ ? {}
1400
+ : await readSessionRuntimeHints(activeSessionId, resolved.agent, resolved.cwd);
1401
+ activeSession = {
1402
+ sessionId: activeSessionId,
1403
+ agent: resolved.agent,
1404
+ messages: history,
1405
+ ...acceptedClientMessageIdsPayload(
1406
+ liveSession?.acceptedClientMessageIds ?? resolved.acceptedClientMessageIds,
1407
+ ),
1408
+ isResponding: liveSession?.isResponding ?? runtimeHints.isResponding ?? resolved.isResponding,
1409
+ partialReplyText: liveSession
1410
+ ? this.currentPartialReplyText(liveSession)
1411
+ : runtimeHints.partialReplyText,
1412
+ approvalMode: liveSession?.approvalMode ?? resolved.approvalMode,
1413
+ ...(liveSession?.pendingApproval ? { pendingApproval: liveSession.pendingApproval } : {}),
1414
+ };
1415
+ }
1416
+ }
1417
+ const pendingApprovals = this.collectReconnectPendingApprovals();
1418
+
1419
+ metrics.increment('reconnect.snapshot', { activeSession: activeSession ? 'true' : 'false' });
1420
+ this.reply(ws, reqId, true, {
1421
+ snapshot: {
1422
+ sessions: page.sessions,
1423
+ nextCursor: page.nextCursor,
1424
+ inventoryVersion: page.inventoryVersion,
1425
+ ...(activeSession ? { activeSession } : {}),
1426
+ ...(pendingApprovals.length > 0 ? { pendingApprovals } : {}),
1427
+ },
1428
+ });
1429
+ } catch (error) {
1430
+ this.reply(ws, reqId, false, undefined, `Failed to build reconnect snapshot: ${String(error)}`);
1431
+ }
1432
+ }
1433
+
1434
+ private async approve(ws: WebSocket, reqId: string, sessionId: string, requestId: string, approved: boolean): Promise<void> {
1435
+ const session = this.sessions.get(sessionId);
1436
+ const pendingApproval = session?.pendingApproval;
1437
+ const pendingClaudeHookApproval = session?.pendingClaudeHookApprovals.get(requestId);
1438
+ if (session && pendingClaudeHookApproval) {
1439
+ session.pendingApproval = undefined;
1440
+ audit.emit('approval.response', { sessionId, requestId, approved });
1441
+ this.touchSession(session.sessionId);
1442
+ this.noteInventoryChanged('session_updated');
1443
+ pendingClaudeHookApproval.resolve(buildClaudeHookDecisionResponse(approved));
1444
+ session.isResponding = true;
1445
+ this.reply(ws, reqId, true);
1446
+ return;
1447
+ }
1448
+
1449
+ const syntheticRetry = session?.syntheticApprovalRetries[requestId];
1450
+ if (session && syntheticRetry && isClaudeSyntheticApprovalRequestId(requestId)) {
1451
+ delete session.syntheticApprovalRetries[requestId];
1452
+ session.pendingApproval = undefined;
1453
+ audit.emit('approval.response', { sessionId, requestId, approved });
1454
+ this.touchSession(session.sessionId);
1455
+ this.noteInventoryChanged('session_updated');
1456
+ this.reply(ws, reqId, true);
1457
+ if (approved) {
1458
+ await this.retrySyntheticClaudeApproval(session, syntheticRetry);
1459
+ }
1460
+ return;
1461
+ }
1462
+ if (session && !session.driver && pendingApproval) {
1463
+ try {
1464
+ await this.reviveSessionForApproval(session);
1465
+ } catch (error) {
1466
+ this.reply(ws, reqId, false, undefined, `Failed to restore approval session: ${String(error)}`);
1467
+ return;
1468
+ }
1469
+ }
1470
+ if (!session?.driver) {
1471
+ this.reply(ws, reqId, false, undefined, 'Session not found or inactive');
1472
+ return;
1473
+ }
1474
+ audit.emit('approval.response', { sessionId, requestId, approved });
1475
+ const sent = session.driver.respondApproval(requestId, approved);
1476
+ if (!sent) {
1477
+ this.reply(ws, reqId, false, undefined, 'Agent process is not running. Send a new message to continue.');
1478
+ return;
1479
+ }
1480
+ session.pendingApproval = undefined;
1481
+ this.touchSession(session.sessionId);
1482
+ session.isResponding = true;
1483
+ this.noteInventoryChanged('session_updated');
1484
+ this.reply(ws, reqId, true);
1485
+ }
1486
+
1487
+ private async retrySyntheticClaudeApproval(session: ActiveSession, retry: SyntheticApprovalRetry): Promise<void> {
1488
+ if (session.agent !== 'claude') return;
1489
+
1490
+ try {
1491
+ if (session.driver) {
1492
+ session.driver.stop();
1493
+ }
1494
+
1495
+ const retryApprovalMode: ApprovalMode = ['Write', 'Edit', 'NotebookEdit'].includes(retry.toolName)
1496
+ ? 'acceptEdits'
1497
+ : 'autoApprove';
1498
+ const driver = this.createDriver(session.agent);
1499
+ const claudeHookSecret = this.configureDriverBeforeStart(session.agent, driver, session.claudeHookSecret);
1500
+ await driver.start(session.cwd, session.sessionId, retryApprovalMode);
1501
+
1502
+ session.driver = driver;
1503
+ session.active = true;
1504
+ session.isResponding = true;
1505
+ session.currentReplyText = '';
1506
+ session.lastUserMessage = retry.message;
1507
+ session.startupInProgress = false;
1508
+ session.bufferedPrompts = [];
1509
+ session.startupToken = 0;
1510
+ session.claudeHookSecret = claudeHookSecret ?? session.claudeHookSecret;
1511
+ session.pendingClaudeHookApprovals = session.pendingClaudeHookApprovals ?? new Map();
1512
+
1513
+ this.registerClaudeHookSession(session);
1514
+ this.bindDriverLifecycle(session, session.agent, ' (approval retry)');
1515
+ this.store.upsert(buildRecord(session));
1516
+ this.updateGauges();
1517
+ this.touchSession(session.sessionId);
1518
+ this.noteInventoryChanged('session_updated');
1519
+
1520
+ log.info({ sessionId: session.sessionId, toolName: retry.toolName, retryApprovalMode }, 'retrying Claude turn after synthetic approval');
1521
+ driver.sendPrompt(retry.message);
1522
+ } catch (error) {
1523
+ session.driver = null;
1524
+ session.active = false;
1525
+ session.isResponding = false;
1526
+ session.startupInProgress = false;
1527
+ session.bufferedPrompts = [];
1528
+ session.startupToken += 1;
1529
+ this.updateGauges();
1530
+ this.touchSession(session.sessionId);
1531
+ this.noteInventoryChanged('session_updated');
1532
+ log.error({ sessionId: session.sessionId, error: String(error) }, 'failed to retry Claude turn after approval');
1533
+ this.broadcast(session.sessionId, {
1534
+ type: 'error',
1535
+ sessionId: session.sessionId,
1536
+ message: `Failed to continue after approval: ${String(error)}`,
1537
+ });
1538
+ }
1539
+ }
1540
+
1541
+ private async setApprovalMode(ws: WebSocket, reqId: string, sessionId: string, approvalMode: ApprovalModeOverride): Promise<void> {
1542
+ const session = this.sessions.get(sessionId);
1543
+ if (!session) {
1544
+ // Session not active in memory — update persisted record or create one
1545
+ const record = this.store.find(sessionId);
1546
+ if (record) {
1547
+ if (approvalMode === null) {
1548
+ delete record.approvalMode;
1549
+ } else {
1550
+ record.approvalMode = approvalMode;
1551
+ }
1552
+ this.store.upsert(record);
1553
+ this.noteInventoryChanged('session_updated');
1554
+ log.info({ sessionId, agent: record.agent, newMode: approvalMode }, 'approval mode changed (inactive session)');
1555
+ } else {
1556
+ log.info({ sessionId, newMode: approvalMode }, 'approval mode changed (untracked session)');
1557
+ }
1558
+ this.reply(ws, reqId, true, { approvalMode });
1559
+ return;
1560
+ }
1561
+ if (approvalMode === null) {
1562
+ this.reply(ws, reqId, false, undefined, 'Follow Remote is only available for external sessions');
1563
+ return;
1564
+ }
1565
+ const oldMode = session.approvalMode;
1566
+ session.approvalMode = approvalMode;
1567
+ this.store.upsert(buildRecord(session));
1568
+ this.noteInventoryChanged('session_updated');
1569
+ log.info({ sessionId, agent: session.agent, oldMode, newMode: approvalMode }, 'approval mode changed');
1570
+
1571
+ if (session.driver) {
1572
+ session.driver.setApprovalMode(approvalMode);
1573
+ }
1574
+
1575
+ this.reply(ws, reqId, true, { approvalMode });
1576
+ }
1577
+
1578
+ private interrupt(ws: WebSocket, reqId: string, sessionId: string): void {
1579
+ const session = this.sessions.get(sessionId);
1580
+ if (!session?.driver) {
1581
+ this.reply(ws, reqId, false, undefined, 'Session not found or inactive');
1582
+ return;
1583
+ }
1584
+ audit.emit('session.interrupt', { sessionId, agent: session.agent });
1585
+ if (session.startupInProgress) {
1586
+ const hadBufferedTurn = session.isResponding || session.bufferedPrompts.length > 0;
1587
+ log.info({ sessionId, agent: session.agent, hadBufferedTurn }, 'interrupting pending startup session');
1588
+ session.bufferedPrompts = [];
1589
+ session.isResponding = false;
1590
+ session.currentReplyText = '';
1591
+ session.lastUserMessage = undefined;
1592
+ session.pendingApproval = undefined;
1593
+ this.touchSession(session.sessionId);
1594
+ this.noteInventoryChanged('session_updated');
1595
+ this.reply(ws, reqId, true);
1596
+ if (hadBufferedTurn) {
1597
+ this.broadcast(session.sessionId, { type: 'session.interrupted', sessionId: session.sessionId });
1598
+ }
1599
+ return;
1600
+ }
1601
+ session.driver.interrupt();
1602
+ this.reply(ws, reqId, true);
1603
+ }
1604
+
1605
+ private stopSession(ws: WebSocket, reqId: string, sessionId: string): void {
1606
+ const session = this.sessions.get(sessionId);
1607
+ if (!session) {
1608
+ this.reply(ws, reqId, false, undefined, 'Session not found');
1609
+ return;
1610
+ }
1611
+ log.info({ sessionId, agent: session.agent }, 'stopping session');
1612
+ audit.emit('session.stop', { sessionId, agent: session.agent });
1613
+ this.resolvePendingClaudeHookApprovals(session);
1614
+ session.driver?.stop();
1615
+ session.active = false;
1616
+ session.driver = null;
1617
+ session.pendingApproval = undefined;
1618
+ session.startupInProgress = false;
1619
+ session.bufferedPrompts = [];
1620
+ session.startupToken += 1;
1621
+ this.updateGauges();
1622
+ this.touchSession(session.sessionId);
1623
+ this.noteInventoryChanged('session_updated');
1624
+ this.reply(ws, reqId, true);
1625
+ }
1626
+
1627
+ private deleteSession(ws: WebSocket, reqId: string, sessionId: string): void {
1628
+ const session = this.sessions.get(sessionId);
1629
+ if (session) {
1630
+ log.info({ sessionId, agent: session.agent }, 'deleting session');
1631
+ this.resolvePendingClaudeHookApprovals(session);
1632
+ session.driver?.stop();
1633
+ session.startupInProgress = false;
1634
+ session.bufferedPrompts = [];
1635
+ session.startupToken += 1;
1636
+ this.unregisterClaudeHookSession(session);
1637
+ this.sessions.delete(sessionId);
1638
+ }
1639
+ audit.emit('session.delete', { sessionId });
1640
+ this.store.remove(sessionId);
1641
+ this.updateGauges();
1642
+ invalidateScannerCache();
1643
+ invalidateProcessScanCache();
1644
+ this.noteInventoryChanged('session_deleted');
1645
+ this.reply(ws, reqId, true);
1646
+ }
1647
+
1648
+ private async sendHistory(ws: WebSocket, reqId: string, sessionId: string, agent: AgentType): Promise<void> {
1649
+ const session = this.sessions.get(sessionId);
1650
+ if (!session && this.isDeletedSession(sessionId)) {
1651
+ this.reply(ws, reqId, false, undefined, 'Session not found');
1652
+ return;
1653
+ }
1654
+ // Subscribe this ws to session broadcasts so streaming updates reach reconnected clients
1655
+ if (session) {
1656
+ session.clients.add(ws);
1657
+ }
1658
+ const cwd = session?.cwd ?? this.store.find(sessionId)?.cwd ?? '';
1659
+ const record = this.store.find(sessionId);
1660
+ const history = await readSessionHistory(sessionId, agent, cwd);
1661
+ const runtimeHints = session ? {} : await readSessionRuntimeHints(sessionId, agent, cwd);
1662
+ const isResponding = session?.isResponding ?? runtimeHints.isResponding;
1663
+ const partialReplyText = session
1664
+ ? this.currentPartialReplyText(session)
1665
+ : runtimeHints.partialReplyText;
1666
+ log.info({ sessionId, historyCount: history.length, isResponding, approvalMode: session?.approvalMode ?? record?.approvalMode }, 'sending history');
1667
+ const historyMsg: ServerMessage = {
1668
+ type: 'session.history',
1669
+ sessionId,
1670
+ messages: history,
1671
+ ...acceptedClientMessageIdsPayload(session?.acceptedClientMessageIds ?? record?.acceptedClientMessageIds),
1672
+ isResponding,
1673
+ partialReplyText,
1674
+ approvalMode: session?.approvalMode ?? record?.approvalMode,
1675
+ pendingApproval: session?.pendingApproval ?? record?.pendingApproval,
1676
+ };
1677
+ if (ws.readyState === 1) {
1678
+ ws.send(JSON.stringify(historyMsg));
1679
+ }
1680
+ this.reply(ws, reqId, true);
1681
+ }
1682
+
1683
+ private async listSessions(
1684
+ ws: WebSocket,
1685
+ reqId: string,
1686
+ agent?: AgentType,
1687
+ cwd?: string,
1688
+ search?: string,
1689
+ limit = 50,
1690
+ cursor?: SessionListCursor,
1691
+ ): Promise<void> {
1692
+ try {
1693
+ const page = await listSessionPage({
1694
+ agent,
1695
+ cwd,
1696
+ search,
1697
+ limit,
1698
+ cursor,
1699
+ activeSessions: this.activeSessionSnapshots(),
1700
+ sessionRecords: this.store.getAll(),
1701
+ deletedSessionIds: this.getDeletedSessionIds(),
1702
+ });
1703
+ this.reply(ws, reqId, true, { ...page, inventoryVersion: this.inventoryVersion });
1704
+ } catch (e) {
1705
+ this.reply(ws, reqId, false, undefined, String(e));
1706
+ }
1707
+ }
1708
+
1709
+ private createDriver(agent: AgentType): Driver {
1710
+ switch (agent) {
1711
+ case 'claude': return new ClaudeDriver();
1712
+ case 'codex': return new CodexDriver();
1713
+ default: throw new Error(`Unknown agent: ${agent}`);
1714
+ }
1715
+ }
1716
+
1717
+ private broadcast(sessionId: string, msg: ServerMessage): void {
1718
+ const session = this.sessions.get(sessionId);
1719
+ if (!session) {
1720
+ log.warn({ sessionId, msgType: msg.type }, 'broadcast target session not found');
1721
+ return;
1722
+ }
1723
+ const data = JSON.stringify(msg);
1724
+
1725
+ // Approval requests must reach all connected clients, not just those
1726
+ // subscribed to this session, so the app can show the approval sheet
1727
+ // even when the user is viewing a different session.
1728
+ const targets = msg.type === 'approval.request' ? this.globalClients : session.clients;
1729
+
1730
+ const total = targets.size;
1731
+ let sent = 0;
1732
+ let failed = 0;
1733
+ for (const client of targets) {
1734
+ if (client.readyState === 1) { // WebSocket.OPEN
1735
+ client.send(data);
1736
+ sent++;
1737
+ } else {
1738
+ failed++;
1739
+ }
1740
+ }
1741
+ if (failed > 0) {
1742
+ metrics.increment('broadcast.fail');
1743
+ }
1744
+ if (msg.type !== 'text.delta') {
1745
+ log.debug({ sessionId, msgType: msg.type, sent, total }, 'broadcast');
1746
+ }
1747
+
1748
+ if (msg.type === 'text.delta') {
1749
+ session.currentReplyText += msg.content;
1750
+ return;
1751
+ }
1752
+
1753
+ if (msg.type === 'session.done') {
1754
+ const title = session.title || sessionId;
1755
+ const body = this.buildPushBody(session.currentReplyText);
1756
+ session.currentReplyText = '';
1757
+ void this.pushSender(title, body, {
1758
+ sessionId,
1759
+ agent: session.agent,
1760
+ eventType: 'reply_ready',
1761
+ });
1762
+ return;
1763
+ }
1764
+
1765
+ if (msg.type === 'session.interrupted') {
1766
+ session.currentReplyText = '';
1767
+ }
1768
+ }
1769
+
1770
+ private reply(ws: WebSocket, id: string, ok: boolean, data?: unknown, error?: string): void {
1771
+ if (ws.readyState !== 1) return;
1772
+ const msg: ServerMessage = { type: 'response', id, ok, data, error };
1773
+ ws.send(JSON.stringify(msg));
1774
+ }
1775
+ }