@vibelet/cli 0.1.37 → 1.0.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 (323) hide show
  1. package/README.md +80 -0
  2. package/bin/cloudflared-quick-tunnel.mjs +11 -0
  3. package/bin/cloudflared-resolver.mjs +171 -0
  4. package/bin/vibelet-runtime-policy.mjs +36 -0
  5. package/bin/vibelet.cjs +12 -0
  6. package/bin/vibelet.mjs +1062 -0
  7. package/dist/index.cjs +126 -0
  8. package/package.json +25 -24
  9. package/app.json +0 -5
  10. package/dist/advertised-hosts.d.ts +0 -34
  11. package/dist/advertised-hosts.d.ts.map +0 -1
  12. package/dist/advertised-hosts.js +0 -176
  13. package/dist/advertised-hosts.js.map +0 -1
  14. package/dist/advertised-hosts.test.d.ts +0 -2
  15. package/dist/advertised-hosts.test.d.ts.map +0 -1
  16. package/dist/advertised-hosts.test.js +0 -96
  17. package/dist/advertised-hosts.test.js.map +0 -1
  18. package/dist/audit.d.ts +0 -30
  19. package/dist/audit.d.ts.map +0 -1
  20. package/dist/audit.js +0 -73
  21. package/dist/audit.js.map +0 -1
  22. package/dist/audit.test.d.ts +0 -2
  23. package/dist/audit.test.d.ts.map +0 -1
  24. package/dist/audit.test.js +0 -33
  25. package/dist/audit.test.js.map +0 -1
  26. package/dist/auth.d.ts +0 -6
  27. package/dist/auth.d.ts.map +0 -1
  28. package/dist/auth.js +0 -27
  29. package/dist/auth.js.map +0 -1
  30. package/dist/claude-hooks.d.ts +0 -58
  31. package/dist/claude-hooks.d.ts.map +0 -1
  32. package/dist/claude-hooks.js +0 -129
  33. package/dist/claude-hooks.js.map +0 -1
  34. package/dist/cli-version.d.ts +0 -3
  35. package/dist/cli-version.d.ts.map +0 -1
  36. package/dist/cli-version.js +0 -35
  37. package/dist/cli-version.js.map +0 -1
  38. package/dist/cli-version.test.d.ts +0 -2
  39. package/dist/cli-version.test.d.ts.map +0 -1
  40. package/dist/cli-version.test.js +0 -38
  41. package/dist/cli-version.test.js.map +0 -1
  42. package/dist/config.d.ts +0 -30
  43. package/dist/config.d.ts.map +0 -1
  44. package/dist/config.js +0 -327
  45. package/dist/config.js.map +0 -1
  46. package/dist/config.test.d.ts +0 -2
  47. package/dist/config.test.d.ts.map +0 -1
  48. package/dist/config.test.js +0 -184
  49. package/dist/config.test.js.map +0 -1
  50. package/dist/dev-auth.test.d.ts +0 -2
  51. package/dist/dev-auth.test.d.ts.map +0 -1
  52. package/dist/dev-auth.test.js +0 -154
  53. package/dist/dev-auth.test.js.map +0 -1
  54. package/dist/dev-script.test.d.ts +0 -2
  55. package/dist/dev-script.test.d.ts.map +0 -1
  56. package/dist/dev-script.test.js +0 -412
  57. package/dist/dev-script.test.js.map +0 -1
  58. package/dist/drivers/claude.d.ts +0 -34
  59. package/dist/drivers/claude.d.ts.map +0 -1
  60. package/dist/drivers/claude.js +0 -413
  61. package/dist/drivers/claude.js.map +0 -1
  62. package/dist/drivers/claude.test.d.ts +0 -2
  63. package/dist/drivers/claude.test.d.ts.map +0 -1
  64. package/dist/drivers/claude.test.js +0 -951
  65. package/dist/drivers/claude.test.js.map +0 -1
  66. package/dist/drivers/codex.d.ts +0 -38
  67. package/dist/drivers/codex.d.ts.map +0 -1
  68. package/dist/drivers/codex.js +0 -771
  69. package/dist/drivers/codex.js.map +0 -1
  70. package/dist/drivers/codex.test.d.ts +0 -2
  71. package/dist/drivers/codex.test.d.ts.map +0 -1
  72. package/dist/drivers/codex.test.js +0 -939
  73. package/dist/drivers/codex.test.js.map +0 -1
  74. package/dist/drivers/types.d.ts +0 -14
  75. package/dist/drivers/types.d.ts.map +0 -1
  76. package/dist/drivers/types.js +0 -2
  77. package/dist/drivers/types.js.map +0 -1
  78. package/dist/e2e.test.d.ts +0 -2
  79. package/dist/e2e.test.d.ts.map +0 -1
  80. package/dist/e2e.test.js +0 -111
  81. package/dist/e2e.test.js.map +0 -1
  82. package/dist/identity.d.ts +0 -10
  83. package/dist/identity.d.ts.map +0 -1
  84. package/dist/identity.js +0 -66
  85. package/dist/identity.js.map +0 -1
  86. package/dist/identity.test.d.ts +0 -2
  87. package/dist/identity.test.d.ts.map +0 -1
  88. package/dist/identity.test.js +0 -25
  89. package/dist/identity.test.js.map +0 -1
  90. package/dist/index-entry.test.d.ts +0 -2
  91. package/dist/index-entry.test.d.ts.map +0 -1
  92. package/dist/index-entry.test.js +0 -272
  93. package/dist/index-entry.test.js.map +0 -1
  94. package/dist/index.d.ts +0 -2
  95. package/dist/index.d.ts.map +0 -1
  96. package/dist/index.js +0 -707
  97. package/dist/index.js.map +0 -1
  98. package/dist/logger.d.ts +0 -31
  99. package/dist/logger.d.ts.map +0 -1
  100. package/dist/logger.js +0 -75
  101. package/dist/logger.js.map +0 -1
  102. package/dist/metrics.d.ts +0 -52
  103. package/dist/metrics.d.ts.map +0 -1
  104. package/dist/metrics.js +0 -89
  105. package/dist/metrics.js.map +0 -1
  106. package/dist/pairing-store.d.ts +0 -29
  107. package/dist/pairing-store.d.ts.map +0 -1
  108. package/dist/pairing-store.js +0 -131
  109. package/dist/pairing-store.js.map +0 -1
  110. package/dist/pairing-store.test.d.ts +0 -2
  111. package/dist/pairing-store.test.d.ts.map +0 -1
  112. package/dist/pairing-store.test.js +0 -47
  113. package/dist/pairing-store.test.js.map +0 -1
  114. package/dist/paths.d.ts +0 -16
  115. package/dist/paths.d.ts.map +0 -1
  116. package/dist/paths.js +0 -18
  117. package/dist/paths.js.map +0 -1
  118. package/dist/perf-compare.d.ts +0 -13
  119. package/dist/perf-compare.d.ts.map +0 -1
  120. package/dist/perf-compare.js +0 -125
  121. package/dist/perf-compare.js.map +0 -1
  122. package/dist/port-conflict.d.ts +0 -9
  123. package/dist/port-conflict.d.ts.map +0 -1
  124. package/dist/port-conflict.js +0 -33
  125. package/dist/port-conflict.js.map +0 -1
  126. package/dist/port-conflict.test.d.ts +0 -2
  127. package/dist/port-conflict.test.d.ts.map +0 -1
  128. package/dist/port-conflict.test.js +0 -38
  129. package/dist/port-conflict.test.js.map +0 -1
  130. package/dist/process-scanner.d.ts +0 -43
  131. package/dist/process-scanner.d.ts.map +0 -1
  132. package/dist/process-scanner.js +0 -453
  133. package/dist/process-scanner.js.map +0 -1
  134. package/dist/process-scanner.perf.test.d.ts +0 -2
  135. package/dist/process-scanner.perf.test.d.ts.map +0 -1
  136. package/dist/process-scanner.perf.test.js +0 -186
  137. package/dist/process-scanner.perf.test.js.map +0 -1
  138. package/dist/process-scanner.test.d.ts +0 -2
  139. package/dist/process-scanner.test.d.ts.map +0 -1
  140. package/dist/process-scanner.test.js +0 -399
  141. package/dist/process-scanner.test.js.map +0 -1
  142. package/dist/push-protocol.d.ts +0 -15
  143. package/dist/push-protocol.d.ts.map +0 -1
  144. package/dist/push-protocol.js +0 -23
  145. package/dist/push-protocol.js.map +0 -1
  146. package/dist/push-protocol.test.d.ts +0 -2
  147. package/dist/push-protocol.test.d.ts.map +0 -1
  148. package/dist/push-protocol.test.js +0 -57
  149. package/dist/push-protocol.test.js.map +0 -1
  150. package/dist/push-store.d.ts +0 -22
  151. package/dist/push-store.d.ts.map +0 -1
  152. package/dist/push-store.js +0 -103
  153. package/dist/push-store.js.map +0 -1
  154. package/dist/push-store.test.d.ts +0 -2
  155. package/dist/push-store.test.d.ts.map +0 -1
  156. package/dist/push-store.test.js +0 -79
  157. package/dist/push-store.test.js.map +0 -1
  158. package/dist/push.d.ts +0 -65
  159. package/dist/push.d.ts.map +0 -1
  160. package/dist/push.js +0 -202
  161. package/dist/push.js.map +0 -1
  162. package/dist/push.test.d.ts +0 -2
  163. package/dist/push.test.d.ts.map +0 -1
  164. package/dist/push.test.js +0 -199
  165. package/dist/push.test.js.map +0 -1
  166. package/dist/safe-stdio.d.ts +0 -3
  167. package/dist/safe-stdio.d.ts.map +0 -1
  168. package/dist/safe-stdio.js +0 -46
  169. package/dist/safe-stdio.js.map +0 -1
  170. package/dist/scanner.d.ts +0 -30
  171. package/dist/scanner.d.ts.map +0 -1
  172. package/dist/scanner.js +0 -859
  173. package/dist/scanner.js.map +0 -1
  174. package/dist/scanner.perf.test.d.ts +0 -2
  175. package/dist/scanner.perf.test.d.ts.map +0 -1
  176. package/dist/scanner.perf.test.js +0 -320
  177. package/dist/scanner.perf.test.js.map +0 -1
  178. package/dist/scanner.test.d.ts +0 -2
  179. package/dist/scanner.test.d.ts.map +0 -1
  180. package/dist/scanner.test.js +0 -948
  181. package/dist/scanner.test.js.map +0 -1
  182. package/dist/session-inventory.d.ts +0 -63
  183. package/dist/session-inventory.d.ts.map +0 -1
  184. package/dist/session-inventory.js +0 -525
  185. package/dist/session-inventory.js.map +0 -1
  186. package/dist/session-inventory.perf.test.d.ts +0 -2
  187. package/dist/session-inventory.perf.test.d.ts.map +0 -1
  188. package/dist/session-inventory.perf.test.js +0 -220
  189. package/dist/session-inventory.perf.test.js.map +0 -1
  190. package/dist/session-inventory.test.d.ts +0 -2
  191. package/dist/session-inventory.test.d.ts.map +0 -1
  192. package/dist/session-inventory.test.js +0 -712
  193. package/dist/session-inventory.test.js.map +0 -1
  194. package/dist/session-manager.d.ts +0 -75
  195. package/dist/session-manager.d.ts.map +0 -1
  196. package/dist/session-manager.js +0 -1515
  197. package/dist/session-manager.js.map +0 -1
  198. package/dist/session-manager.test.d.ts +0 -2
  199. package/dist/session-manager.test.d.ts.map +0 -1
  200. package/dist/session-manager.test.js +0 -2861
  201. package/dist/session-manager.test.js.map +0 -1
  202. package/dist/session-store.d.ts +0 -42
  203. package/dist/session-store.d.ts.map +0 -1
  204. package/dist/session-store.js +0 -163
  205. package/dist/session-store.js.map +0 -1
  206. package/dist/session-store.test.d.ts +0 -2
  207. package/dist/session-store.test.d.ts.map +0 -1
  208. package/dist/session-store.test.js +0 -236
  209. package/dist/session-store.test.js.map +0 -1
  210. package/dist/session-title.d.ts +0 -6
  211. package/dist/session-title.d.ts.map +0 -1
  212. package/dist/session-title.js +0 -105
  213. package/dist/session-title.js.map +0 -1
  214. package/dist/session-title.perf.test.d.ts +0 -2
  215. package/dist/session-title.perf.test.d.ts.map +0 -1
  216. package/dist/session-title.perf.test.js +0 -99
  217. package/dist/session-title.perf.test.js.map +0 -1
  218. package/dist/session-title.test.d.ts +0 -2
  219. package/dist/session-title.test.d.ts.map +0 -1
  220. package/dist/session-title.test.js +0 -199
  221. package/dist/session-title.test.js.map +0 -1
  222. package/dist/shutdown-endpoint.test.d.ts +0 -2
  223. package/dist/shutdown-endpoint.test.d.ts.map +0 -1
  224. package/dist/shutdown-endpoint.test.js +0 -93
  225. package/dist/shutdown-endpoint.test.js.map +0 -1
  226. package/dist/storage-housekeeping.d.ts +0 -28
  227. package/dist/storage-housekeeping.d.ts.map +0 -1
  228. package/dist/storage-housekeeping.js +0 -76
  229. package/dist/storage-housekeeping.js.map +0 -1
  230. package/dist/storage-housekeeping.test.d.ts +0 -2
  231. package/dist/storage-housekeeping.test.d.ts.map +0 -1
  232. package/dist/storage-housekeeping.test.js +0 -65
  233. package/dist/storage-housekeeping.test.js.map +0 -1
  234. package/dist/test-daemon-harness.d.ts +0 -31
  235. package/dist/test-daemon-harness.d.ts.map +0 -1
  236. package/dist/test-daemon-harness.js +0 -337
  237. package/dist/test-daemon-harness.js.map +0 -1
  238. package/dist/token-auth.test.d.ts +0 -2
  239. package/dist/token-auth.test.d.ts.map +0 -1
  240. package/dist/token-auth.test.js +0 -52
  241. package/dist/token-auth.test.js.map +0 -1
  242. package/dist/utils.d.ts +0 -4
  243. package/dist/utils.d.ts.map +0 -1
  244. package/dist/utils.js +0 -40
  245. package/dist/utils.js.map +0 -1
  246. package/dist/utils.test.d.ts +0 -2
  247. package/dist/utils.test.d.ts.map +0 -1
  248. package/dist/utils.test.js +0 -54
  249. package/dist/utils.test.js.map +0 -1
  250. package/dist/ws-data.d.ts +0 -4
  251. package/dist/ws-data.d.ts.map +0 -1
  252. package/dist/ws-data.js +0 -20
  253. package/dist/ws-data.js.map +0 -1
  254. package/dist/ws-data.test.d.ts +0 -2
  255. package/dist/ws-data.test.d.ts.map +0 -1
  256. package/dist/ws-data.test.js +0 -17
  257. package/dist/ws-data.test.js.map +0 -1
  258. package/perf-reporter.mjs +0 -138
  259. package/scripts/build-release.mjs +0 -41
  260. package/scripts/dev.mjs +0 -537
  261. package/src/advertised-hosts.test.ts +0 -125
  262. package/src/advertised-hosts.ts +0 -225
  263. package/src/audit.test.ts +0 -38
  264. package/src/audit.ts +0 -117
  265. package/src/auth.ts +0 -31
  266. package/src/claude-hooks.ts +0 -195
  267. package/src/cli-version.test.ts +0 -36
  268. package/src/cli-version.ts +0 -46
  269. package/src/config.test.ts +0 -254
  270. package/src/config.ts +0 -324
  271. package/src/dev-auth.test.ts +0 -183
  272. package/src/dev-script.test.ts +0 -511
  273. package/src/drivers/claude.test.ts +0 -1186
  274. package/src/drivers/claude.ts +0 -443
  275. package/src/drivers/codex.test.ts +0 -1096
  276. package/src/drivers/codex.ts +0 -879
  277. package/src/drivers/types.ts +0 -15
  278. package/src/e2e.test.ts +0 -139
  279. package/src/identity.test.ts +0 -26
  280. package/src/identity.ts +0 -82
  281. package/src/index-entry.test.ts +0 -336
  282. package/src/index.ts +0 -781
  283. package/src/logger.ts +0 -112
  284. package/src/metrics.ts +0 -117
  285. package/src/pairing-store.test.ts +0 -53
  286. package/src/pairing-store.ts +0 -154
  287. package/src/paths.ts +0 -19
  288. package/src/perf-compare.ts +0 -164
  289. package/src/port-conflict.test.ts +0 -45
  290. package/src/port-conflict.ts +0 -44
  291. package/src/process-scanner.perf.test.ts +0 -222
  292. package/src/process-scanner.test.ts +0 -575
  293. package/src/process-scanner.ts +0 -514
  294. package/src/push-protocol.test.ts +0 -74
  295. package/src/push-protocol.ts +0 -36
  296. package/src/push-store.test.ts +0 -89
  297. package/src/push-store.ts +0 -126
  298. package/src/push.test.ts +0 -234
  299. package/src/push.ts +0 -318
  300. package/src/safe-stdio.ts +0 -51
  301. package/src/scanner.perf.test.ts +0 -359
  302. package/src/scanner.test.ts +0 -1045
  303. package/src/scanner.ts +0 -924
  304. package/src/session-inventory.perf.test.ts +0 -250
  305. package/src/session-inventory.test.ts +0 -1002
  306. package/src/session-inventory.ts +0 -721
  307. package/src/session-manager.test.ts +0 -3430
  308. package/src/session-manager.ts +0 -1775
  309. package/src/session-store.test.ts +0 -276
  310. package/src/session-store.ts +0 -202
  311. package/src/session-title.perf.test.ts +0 -118
  312. package/src/session-title.test.ts +0 -286
  313. package/src/session-title.ts +0 -108
  314. package/src/shutdown-endpoint.test.ts +0 -95
  315. package/src/storage-housekeeping.test.ts +0 -78
  316. package/src/storage-housekeeping.ts +0 -111
  317. package/src/test-daemon-harness.ts +0 -410
  318. package/src/token-auth.test.ts +0 -67
  319. package/src/utils.test.ts +0 -65
  320. package/src/utils.ts +0 -47
  321. package/src/ws-data.test.ts +0 -20
  322. package/src/ws-data.ts +0 -26
  323. package/tsconfig.json +0 -12
@@ -1,1775 +0,0 @@
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
- }