@vibelet/cli 0.1.34 → 0.1.36

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