@vibelet/cli 0.1.35 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/app.json +5 -0
  2. package/dist/advertised-hosts.d.ts +34 -0
  3. package/dist/advertised-hosts.d.ts.map +1 -0
  4. package/dist/advertised-hosts.js +176 -0
  5. package/dist/advertised-hosts.js.map +1 -0
  6. package/dist/advertised-hosts.test.d.ts +2 -0
  7. package/dist/advertised-hosts.test.d.ts.map +1 -0
  8. package/dist/advertised-hosts.test.js +96 -0
  9. package/dist/advertised-hosts.test.js.map +1 -0
  10. package/dist/audit.d.ts +30 -0
  11. package/dist/audit.d.ts.map +1 -0
  12. package/dist/audit.js +73 -0
  13. package/dist/audit.js.map +1 -0
  14. package/dist/audit.test.d.ts +2 -0
  15. package/dist/audit.test.d.ts.map +1 -0
  16. package/dist/audit.test.js +33 -0
  17. package/dist/audit.test.js.map +1 -0
  18. package/dist/auth.d.ts +6 -0
  19. package/dist/auth.d.ts.map +1 -0
  20. package/dist/auth.js +27 -0
  21. package/dist/auth.js.map +1 -0
  22. package/dist/claude-hooks.d.ts +58 -0
  23. package/dist/claude-hooks.d.ts.map +1 -0
  24. package/dist/claude-hooks.js +129 -0
  25. package/dist/claude-hooks.js.map +1 -0
  26. package/dist/cli-version.d.ts +3 -0
  27. package/dist/cli-version.d.ts.map +1 -0
  28. package/dist/cli-version.js +35 -0
  29. package/dist/cli-version.js.map +1 -0
  30. package/dist/cli-version.test.d.ts +2 -0
  31. package/dist/cli-version.test.d.ts.map +1 -0
  32. package/dist/cli-version.test.js +38 -0
  33. package/dist/cli-version.test.js.map +1 -0
  34. package/dist/config.d.ts +30 -0
  35. package/dist/config.d.ts.map +1 -0
  36. package/dist/config.js +327 -0
  37. package/dist/config.js.map +1 -0
  38. package/dist/config.test.d.ts +2 -0
  39. package/dist/config.test.d.ts.map +1 -0
  40. package/dist/config.test.js +184 -0
  41. package/dist/config.test.js.map +1 -0
  42. package/dist/dev-auth.test.d.ts +2 -0
  43. package/dist/dev-auth.test.d.ts.map +1 -0
  44. package/dist/dev-auth.test.js +154 -0
  45. package/dist/dev-auth.test.js.map +1 -0
  46. package/dist/dev-script.test.d.ts +2 -0
  47. package/dist/dev-script.test.d.ts.map +1 -0
  48. package/dist/dev-script.test.js +412 -0
  49. package/dist/dev-script.test.js.map +1 -0
  50. package/dist/drivers/claude.d.ts +34 -0
  51. package/dist/drivers/claude.d.ts.map +1 -0
  52. package/dist/drivers/claude.js +413 -0
  53. package/dist/drivers/claude.js.map +1 -0
  54. package/dist/drivers/claude.test.d.ts +2 -0
  55. package/dist/drivers/claude.test.d.ts.map +1 -0
  56. package/dist/drivers/claude.test.js +951 -0
  57. package/dist/drivers/claude.test.js.map +1 -0
  58. package/dist/drivers/codex.d.ts +38 -0
  59. package/dist/drivers/codex.d.ts.map +1 -0
  60. package/dist/drivers/codex.js +771 -0
  61. package/dist/drivers/codex.js.map +1 -0
  62. package/dist/drivers/codex.test.d.ts +2 -0
  63. package/dist/drivers/codex.test.d.ts.map +1 -0
  64. package/dist/drivers/codex.test.js +939 -0
  65. package/dist/drivers/codex.test.js.map +1 -0
  66. package/dist/drivers/types.d.ts +14 -0
  67. package/dist/drivers/types.d.ts.map +1 -0
  68. package/dist/drivers/types.js +2 -0
  69. package/dist/drivers/types.js.map +1 -0
  70. package/dist/e2e.test.d.ts +2 -0
  71. package/dist/e2e.test.d.ts.map +1 -0
  72. package/dist/e2e.test.js +111 -0
  73. package/dist/e2e.test.js.map +1 -0
  74. package/dist/identity.d.ts +10 -0
  75. package/dist/identity.d.ts.map +1 -0
  76. package/dist/identity.js +66 -0
  77. package/dist/identity.js.map +1 -0
  78. package/dist/identity.test.d.ts +2 -0
  79. package/dist/identity.test.d.ts.map +1 -0
  80. package/dist/identity.test.js +25 -0
  81. package/dist/identity.test.js.map +1 -0
  82. package/dist/index-entry.test.d.ts +2 -0
  83. package/dist/index-entry.test.d.ts.map +1 -0
  84. package/dist/index-entry.test.js +272 -0
  85. package/dist/index-entry.test.js.map +1 -0
  86. package/dist/index.d.ts +2 -0
  87. package/dist/index.d.ts.map +1 -0
  88. package/dist/index.js +707 -0
  89. package/dist/index.js.map +1 -0
  90. package/dist/logger.d.ts +31 -0
  91. package/dist/logger.d.ts.map +1 -0
  92. package/dist/logger.js +75 -0
  93. package/dist/logger.js.map +1 -0
  94. package/dist/metrics.d.ts +52 -0
  95. package/dist/metrics.d.ts.map +1 -0
  96. package/dist/metrics.js +89 -0
  97. package/dist/metrics.js.map +1 -0
  98. package/dist/pairing-store.d.ts +29 -0
  99. package/dist/pairing-store.d.ts.map +1 -0
  100. package/dist/pairing-store.js +131 -0
  101. package/dist/pairing-store.js.map +1 -0
  102. package/dist/pairing-store.test.d.ts +2 -0
  103. package/dist/pairing-store.test.d.ts.map +1 -0
  104. package/dist/pairing-store.test.js +47 -0
  105. package/dist/pairing-store.test.js.map +1 -0
  106. package/dist/paths.d.ts +16 -0
  107. package/dist/paths.d.ts.map +1 -0
  108. package/dist/paths.js +18 -0
  109. package/dist/paths.js.map +1 -0
  110. package/dist/perf-compare.d.ts +13 -0
  111. package/dist/perf-compare.d.ts.map +1 -0
  112. package/dist/perf-compare.js +125 -0
  113. package/dist/perf-compare.js.map +1 -0
  114. package/dist/port-conflict.d.ts +9 -0
  115. package/dist/port-conflict.d.ts.map +1 -0
  116. package/dist/port-conflict.js +33 -0
  117. package/dist/port-conflict.js.map +1 -0
  118. package/dist/port-conflict.test.d.ts +2 -0
  119. package/dist/port-conflict.test.d.ts.map +1 -0
  120. package/dist/port-conflict.test.js +38 -0
  121. package/dist/port-conflict.test.js.map +1 -0
  122. package/dist/process-scanner.d.ts +43 -0
  123. package/dist/process-scanner.d.ts.map +1 -0
  124. package/dist/process-scanner.js +453 -0
  125. package/dist/process-scanner.js.map +1 -0
  126. package/dist/process-scanner.perf.test.d.ts +2 -0
  127. package/dist/process-scanner.perf.test.d.ts.map +1 -0
  128. package/dist/process-scanner.perf.test.js +186 -0
  129. package/dist/process-scanner.perf.test.js.map +1 -0
  130. package/dist/process-scanner.test.d.ts +2 -0
  131. package/dist/process-scanner.test.d.ts.map +1 -0
  132. package/dist/process-scanner.test.js +399 -0
  133. package/dist/process-scanner.test.js.map +1 -0
  134. package/dist/push-protocol.d.ts +15 -0
  135. package/dist/push-protocol.d.ts.map +1 -0
  136. package/dist/push-protocol.js +23 -0
  137. package/dist/push-protocol.js.map +1 -0
  138. package/dist/push-protocol.test.d.ts +2 -0
  139. package/dist/push-protocol.test.d.ts.map +1 -0
  140. package/dist/push-protocol.test.js +57 -0
  141. package/dist/push-protocol.test.js.map +1 -0
  142. package/dist/push-store.d.ts +22 -0
  143. package/dist/push-store.d.ts.map +1 -0
  144. package/dist/push-store.js +103 -0
  145. package/dist/push-store.js.map +1 -0
  146. package/dist/push-store.test.d.ts +2 -0
  147. package/dist/push-store.test.d.ts.map +1 -0
  148. package/dist/push-store.test.js +79 -0
  149. package/dist/push-store.test.js.map +1 -0
  150. package/dist/push.d.ts +65 -0
  151. package/dist/push.d.ts.map +1 -0
  152. package/dist/push.js +202 -0
  153. package/dist/push.js.map +1 -0
  154. package/dist/push.test.d.ts +2 -0
  155. package/dist/push.test.d.ts.map +1 -0
  156. package/dist/push.test.js +199 -0
  157. package/dist/push.test.js.map +1 -0
  158. package/dist/safe-stdio.d.ts +3 -0
  159. package/dist/safe-stdio.d.ts.map +1 -0
  160. package/dist/safe-stdio.js +46 -0
  161. package/dist/safe-stdio.js.map +1 -0
  162. package/dist/scanner.d.ts +30 -0
  163. package/dist/scanner.d.ts.map +1 -0
  164. package/dist/scanner.js +859 -0
  165. package/dist/scanner.js.map +1 -0
  166. package/dist/scanner.perf.test.d.ts +2 -0
  167. package/dist/scanner.perf.test.d.ts.map +1 -0
  168. package/dist/scanner.perf.test.js +320 -0
  169. package/dist/scanner.perf.test.js.map +1 -0
  170. package/dist/scanner.test.d.ts +2 -0
  171. package/dist/scanner.test.d.ts.map +1 -0
  172. package/dist/scanner.test.js +948 -0
  173. package/dist/scanner.test.js.map +1 -0
  174. package/dist/session-inventory.d.ts +63 -0
  175. package/dist/session-inventory.d.ts.map +1 -0
  176. package/dist/session-inventory.js +525 -0
  177. package/dist/session-inventory.js.map +1 -0
  178. package/dist/session-inventory.perf.test.d.ts +2 -0
  179. package/dist/session-inventory.perf.test.d.ts.map +1 -0
  180. package/dist/session-inventory.perf.test.js +220 -0
  181. package/dist/session-inventory.perf.test.js.map +1 -0
  182. package/dist/session-inventory.test.d.ts +2 -0
  183. package/dist/session-inventory.test.d.ts.map +1 -0
  184. package/dist/session-inventory.test.js +712 -0
  185. package/dist/session-inventory.test.js.map +1 -0
  186. package/dist/session-manager.d.ts +75 -0
  187. package/dist/session-manager.d.ts.map +1 -0
  188. package/dist/session-manager.js +1515 -0
  189. package/dist/session-manager.js.map +1 -0
  190. package/dist/session-manager.test.d.ts +2 -0
  191. package/dist/session-manager.test.d.ts.map +1 -0
  192. package/dist/session-manager.test.js +2861 -0
  193. package/dist/session-manager.test.js.map +1 -0
  194. package/dist/session-store.d.ts +42 -0
  195. package/dist/session-store.d.ts.map +1 -0
  196. package/dist/session-store.js +163 -0
  197. package/dist/session-store.js.map +1 -0
  198. package/dist/session-store.test.d.ts +2 -0
  199. package/dist/session-store.test.d.ts.map +1 -0
  200. package/dist/session-store.test.js +236 -0
  201. package/dist/session-store.test.js.map +1 -0
  202. package/dist/session-title.d.ts +6 -0
  203. package/dist/session-title.d.ts.map +1 -0
  204. package/dist/session-title.js +105 -0
  205. package/dist/session-title.js.map +1 -0
  206. package/dist/session-title.perf.test.d.ts +2 -0
  207. package/dist/session-title.perf.test.d.ts.map +1 -0
  208. package/dist/session-title.perf.test.js +99 -0
  209. package/dist/session-title.perf.test.js.map +1 -0
  210. package/dist/session-title.test.d.ts +2 -0
  211. package/dist/session-title.test.d.ts.map +1 -0
  212. package/dist/session-title.test.js +199 -0
  213. package/dist/session-title.test.js.map +1 -0
  214. package/dist/shutdown-endpoint.test.d.ts +2 -0
  215. package/dist/shutdown-endpoint.test.d.ts.map +1 -0
  216. package/dist/shutdown-endpoint.test.js +93 -0
  217. package/dist/shutdown-endpoint.test.js.map +1 -0
  218. package/dist/storage-housekeeping.d.ts +28 -0
  219. package/dist/storage-housekeeping.d.ts.map +1 -0
  220. package/dist/storage-housekeeping.js +76 -0
  221. package/dist/storage-housekeeping.js.map +1 -0
  222. package/dist/storage-housekeeping.test.d.ts +2 -0
  223. package/dist/storage-housekeeping.test.d.ts.map +1 -0
  224. package/dist/storage-housekeeping.test.js +65 -0
  225. package/dist/storage-housekeeping.test.js.map +1 -0
  226. package/dist/test-daemon-harness.d.ts +31 -0
  227. package/dist/test-daemon-harness.d.ts.map +1 -0
  228. package/dist/test-daemon-harness.js +337 -0
  229. package/dist/test-daemon-harness.js.map +1 -0
  230. package/dist/token-auth.test.d.ts +2 -0
  231. package/dist/token-auth.test.d.ts.map +1 -0
  232. package/dist/token-auth.test.js +52 -0
  233. package/dist/token-auth.test.js.map +1 -0
  234. package/dist/utils.d.ts +4 -0
  235. package/dist/utils.d.ts.map +1 -0
  236. package/dist/utils.js +40 -0
  237. package/dist/utils.js.map +1 -0
  238. package/dist/utils.test.d.ts +2 -0
  239. package/dist/utils.test.d.ts.map +1 -0
  240. package/dist/utils.test.js +54 -0
  241. package/dist/utils.test.js.map +1 -0
  242. package/dist/ws-data.d.ts +4 -0
  243. package/dist/ws-data.d.ts.map +1 -0
  244. package/dist/ws-data.js +20 -0
  245. package/dist/ws-data.js.map +1 -0
  246. package/dist/ws-data.test.d.ts +2 -0
  247. package/dist/ws-data.test.d.ts.map +1 -0
  248. package/dist/ws-data.test.js +17 -0
  249. package/dist/ws-data.test.js.map +1 -0
  250. package/package.json +24 -24
  251. package/perf-reporter.mjs +138 -0
  252. package/scripts/build-release.mjs +41 -0
  253. package/scripts/dev.mjs +537 -0
  254. package/src/advertised-hosts.test.ts +125 -0
  255. package/src/advertised-hosts.ts +225 -0
  256. package/src/audit.test.ts +38 -0
  257. package/src/audit.ts +117 -0
  258. package/src/auth.ts +31 -0
  259. package/src/claude-hooks.ts +195 -0
  260. package/src/cli-version.test.ts +36 -0
  261. package/src/cli-version.ts +46 -0
  262. package/src/config.test.ts +254 -0
  263. package/src/config.ts +324 -0
  264. package/src/dev-auth.test.ts +183 -0
  265. package/src/dev-script.test.ts +511 -0
  266. package/src/drivers/claude.test.ts +1186 -0
  267. package/src/drivers/claude.ts +443 -0
  268. package/src/drivers/codex.test.ts +1096 -0
  269. package/src/drivers/codex.ts +879 -0
  270. package/src/drivers/types.ts +15 -0
  271. package/src/e2e.test.ts +139 -0
  272. package/src/identity.test.ts +26 -0
  273. package/src/identity.ts +82 -0
  274. package/src/index-entry.test.ts +336 -0
  275. package/src/index.ts +781 -0
  276. package/src/logger.ts +112 -0
  277. package/src/metrics.ts +117 -0
  278. package/src/pairing-store.test.ts +53 -0
  279. package/src/pairing-store.ts +154 -0
  280. package/src/paths.ts +19 -0
  281. package/src/perf-compare.ts +164 -0
  282. package/src/port-conflict.test.ts +45 -0
  283. package/src/port-conflict.ts +44 -0
  284. package/src/process-scanner.perf.test.ts +222 -0
  285. package/src/process-scanner.test.ts +575 -0
  286. package/src/process-scanner.ts +514 -0
  287. package/src/push-protocol.test.ts +74 -0
  288. package/src/push-protocol.ts +36 -0
  289. package/src/push-store.test.ts +89 -0
  290. package/src/push-store.ts +126 -0
  291. package/src/push.test.ts +234 -0
  292. package/src/push.ts +318 -0
  293. package/src/safe-stdio.ts +51 -0
  294. package/src/scanner.perf.test.ts +359 -0
  295. package/src/scanner.test.ts +1045 -0
  296. package/src/scanner.ts +924 -0
  297. package/src/session-inventory.perf.test.ts +250 -0
  298. package/src/session-inventory.test.ts +1002 -0
  299. package/src/session-inventory.ts +721 -0
  300. package/src/session-manager.test.ts +3430 -0
  301. package/src/session-manager.ts +1775 -0
  302. package/src/session-store.test.ts +276 -0
  303. package/src/session-store.ts +202 -0
  304. package/src/session-title.perf.test.ts +118 -0
  305. package/src/session-title.test.ts +286 -0
  306. package/src/session-title.ts +108 -0
  307. package/src/shutdown-endpoint.test.ts +95 -0
  308. package/src/storage-housekeeping.test.ts +78 -0
  309. package/src/storage-housekeeping.ts +111 -0
  310. package/src/test-daemon-harness.ts +410 -0
  311. package/src/token-auth.test.ts +67 -0
  312. package/src/utils.test.ts +65 -0
  313. package/src/utils.ts +47 -0
  314. package/src/ws-data.test.ts +20 -0
  315. package/src/ws-data.ts +26 -0
  316. package/tsconfig.json +12 -0
  317. package/README.md +0 -80
  318. package/bin/cloudflared-quick-tunnel.mjs +0 -11
  319. package/bin/cloudflared-resolver.mjs +0 -68
  320. package/bin/vibelet-runtime-policy.mjs +0 -36
  321. package/bin/vibelet.cjs +0 -12
  322. package/bin/vibelet.mjs +0 -1035
  323. package/dist/index.cjs +0 -125
package/src/scanner.ts ADDED
@@ -0,0 +1,924 @@
1
+ import { readdir, stat, readFile, open } from 'fs/promises';
2
+ import { createReadStream } from 'fs';
3
+ import { createInterface } from 'readline';
4
+ import { join, basename } from 'path';
5
+ import { homedir } from 'os';
6
+ import type {
7
+ AgentType,
8
+ ApprovalMode,
9
+ SessionInfo,
10
+ SessionSource,
11
+ } from '@vibelet/shared';
12
+ import { preferSessionTitle } from './session-title.js';
13
+ import { createIdleRuntime } from './session-inventory.js';
14
+ import { logger as rootLogger } from './logger.js';
15
+
16
+ const log = rootLogger.child({ module: 'scanner' });
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // TTL cache helper
20
+ // ---------------------------------------------------------------------------
21
+ interface CacheEntry<T> { value: T; expiresAt: number }
22
+
23
+ function createTtlCache<T>(ttlMs: number) {
24
+ const store = new Map<string, CacheEntry<T>>();
25
+ return {
26
+ get(key: string): T | undefined {
27
+ const entry = store.get(key);
28
+ if (!entry) return undefined;
29
+ if (Date.now() > entry.expiresAt) { store.delete(key); return undefined; }
30
+ return entry.value;
31
+ },
32
+ set(key: string, value: T): void {
33
+ store.set(key, { value, expiresAt: Date.now() + ttlMs });
34
+ },
35
+ clear(): void { store.clear(); },
36
+ };
37
+ }
38
+
39
+ const SCAN_CACHE_TTL = 30_000; // 30s — explicit invalidation on mutations keeps it fresh
40
+ const scannerCache = createTtlCache<SessionInfo[]>(SCAN_CACHE_TTL);
41
+ const searchCache = createTtlCache<Set<string>>(SCAN_CACHE_TTL);
42
+ const scannerInflight = new Map<string, Promise<SessionInfo[]>>();
43
+ const searchInflight = new Map<string, Promise<Set<string>>>();
44
+ const FILE_READ_CONCURRENCY = 24;
45
+
46
+ /** Invalidate all scanner caches. Call after session creation/deletion. */
47
+ export function invalidateScannerCache(): void {
48
+ scannerCache.clear();
49
+ searchCache.clear();
50
+ }
51
+
52
+ export interface SessionFileMeta {
53
+ sessionId: string;
54
+ agent: AgentType;
55
+ cwd: string;
56
+ title?: string;
57
+ approvalMode?: ApprovalMode;
58
+ createdAt: string;
59
+ lastActivityAt: string;
60
+ filePath: string;
61
+ }
62
+
63
+ export interface SessionRuntimeHints {
64
+ isResponding?: boolean;
65
+ partialReplyText?: string;
66
+ }
67
+
68
+ function encodeCwd(cwd: string): string {
69
+ return cwd.replace(/[^a-zA-Z0-9]/g, '-');
70
+ }
71
+
72
+ async function mapWithConcurrency<T, R>(
73
+ items: T[],
74
+ concurrency: number,
75
+ worker: (item: T, index: number) => Promise<R>,
76
+ ): Promise<R[]> {
77
+ if (items.length === 0) return [];
78
+
79
+ const limit = Math.max(1, Math.min(concurrency, items.length));
80
+ const results = new Array<R>(items.length);
81
+ let nextIndex = 0;
82
+
83
+ await Promise.all(Array.from({ length: limit }, async () => {
84
+ while (true) {
85
+ const index = nextIndex;
86
+ nextIndex += 1;
87
+ if (index >= items.length) return;
88
+ results[index] = await worker(items[index], index);
89
+ }
90
+ }));
91
+
92
+ return results;
93
+ }
94
+
95
+ /** Yield up to `maxLines` non-empty lines from text without splitting the entire string. */
96
+ function* iterateFirstLines(text: string, maxLines: number): Generator<string> {
97
+ let start = 0;
98
+ let yielded = 0;
99
+ while (start < text.length && yielded < maxLines) {
100
+ let end = text.indexOf('\n', start);
101
+ if (end === -1) end = text.length;
102
+ const line = text.substring(start, end);
103
+ start = end + 1;
104
+ if (line.length === 0 || line.trim().length === 0) continue;
105
+ yield line;
106
+ yielded += 1;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Read the head of a file (up to `maxBytes`) instead of the entire file.
112
+ * For files smaller than `maxBytes`, this is equivalent to readFile.
113
+ * For large files (100MB+), this avoids loading the entire content into memory.
114
+ */
115
+ const HEAD_READ_MAX_BYTES = 512 * 1024; // 512 KB — enough for 64-96 JSONL lines
116
+ const TAIL_READ_MAX_BYTES = 256 * 1024; // 256 KB — enough to capture recent mode changes
117
+
118
+ async function readFileHead(filePath: string, fileSize: number): Promise<string> {
119
+ if (fileSize <= HEAD_READ_MAX_BYTES) {
120
+ return readFile(filePath, 'utf-8');
121
+ }
122
+ const fd = await open(filePath, 'r');
123
+ try {
124
+ const buf = Buffer.alloc(HEAD_READ_MAX_BYTES);
125
+ const { bytesRead } = await fd.read(buf, 0, HEAD_READ_MAX_BYTES, 0);
126
+ return buf.toString('utf-8', 0, bytesRead);
127
+ } finally {
128
+ await fd.close();
129
+ }
130
+ }
131
+
132
+ async function readFileTail(filePath: string, fileSize: number): Promise<string> {
133
+ if (fileSize <= TAIL_READ_MAX_BYTES) {
134
+ return readFile(filePath, 'utf-8');
135
+ }
136
+ const fd = await open(filePath, 'r');
137
+ try {
138
+ const start = Math.max(0, fileSize - TAIL_READ_MAX_BYTES);
139
+ const buf = Buffer.alloc(fileSize - start);
140
+ const { bytesRead } = await fd.read(buf, 0, buf.length, start);
141
+ return buf.toString('utf-8', 0, bytesRead);
142
+ } finally {
143
+ await fd.close();
144
+ }
145
+ }
146
+
147
+ function mapClaudePermissionMode(permissionMode: unknown): ApprovalMode | undefined {
148
+ switch (permissionMode) {
149
+ case 'plan':
150
+ return 'plan';
151
+ case 'acceptEdits':
152
+ return 'acceptEdits';
153
+ case 'bypassPermissions':
154
+ return 'autoApprove';
155
+ case 'default':
156
+ return 'normal';
157
+ default:
158
+ return undefined;
159
+ }
160
+ }
161
+
162
+ function mapCodexApprovalModeFromTurnContext(payload: any): ApprovalMode | undefined {
163
+ const approvalPolicy = typeof payload?.approval_policy === 'string' ? payload.approval_policy : '';
164
+ const sandboxPolicy = payload?.sandbox_policy;
165
+ const sandboxType = typeof sandboxPolicy?.type === 'string' ? sandboxPolicy.type : '';
166
+ const writableRoots = Array.isArray(sandboxPolicy?.writable_roots) ? sandboxPolicy.writable_roots : null;
167
+
168
+ if (approvalPolicy === 'never' && sandboxType === 'danger-full-access') {
169
+ return 'autoApprove';
170
+ }
171
+ if (approvalPolicy === 'on-request' && sandboxType === 'workspace-write') {
172
+ return writableRoots?.length ? 'normal' : 'plan';
173
+ }
174
+ return undefined;
175
+ }
176
+
177
+ function readClaudeApprovalModeFromEntry(entry: any): ApprovalMode | undefined {
178
+ if (entry?.type === 'permission-mode') {
179
+ return mapClaudePermissionMode(entry.permissionMode);
180
+ }
181
+ return mapClaudePermissionMode(entry?.permissionMode);
182
+ }
183
+
184
+ function readCodexApprovalModeFromEntry(entry: any): ApprovalMode | undefined {
185
+ if (entry?.type !== 'turn_context') {
186
+ return undefined;
187
+ }
188
+ return mapCodexApprovalModeFromTurnContext(entry.payload);
189
+ }
190
+
191
+ async function readSessionApprovalModeFromFile(
192
+ filePath: string,
193
+ agent: AgentType,
194
+ fileSize: number,
195
+ ): Promise<ApprovalMode | undefined> {
196
+ let latestApprovalMode: ApprovalMode | undefined;
197
+ const content = await readFileTail(filePath, fileSize);
198
+ for (const line of content.split('\n')) {
199
+ if (!line.trim()) continue;
200
+ let entry: any;
201
+ try {
202
+ entry = JSON.parse(line);
203
+ } catch {
204
+ continue;
205
+ }
206
+ const approvalMode = agent === 'claude'
207
+ ? readClaudeApprovalModeFromEntry(entry)
208
+ : readCodexApprovalModeFromEntry(entry);
209
+ if (approvalMode) {
210
+ latestApprovalMode = approvalMode;
211
+ }
212
+ }
213
+ return latestApprovalMode;
214
+ }
215
+
216
+ function extractCodexText(content: unknown): string {
217
+ if (typeof content === 'string') return content;
218
+ if (!Array.isArray(content)) return '';
219
+ return content
220
+ .map((item) => {
221
+ if (!item || typeof item !== 'object') return '';
222
+ if (typeof (item as any).text === 'string') return (item as any).text;
223
+ if (typeof (item as any).content === 'string') return (item as any).content;
224
+ return '';
225
+ })
226
+ .join('');
227
+ }
228
+
229
+ function extractCodexUserMessageText(entry: any): string {
230
+ if (entry?.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'user') {
231
+ return extractCodexText(entry.payload.content);
232
+ }
233
+ if (entry?.type === 'event_msg' && entry.payload?.type === 'user_message' && typeof entry.payload.message === 'string') {
234
+ return entry.payload.message;
235
+ }
236
+ return '';
237
+ }
238
+
239
+ function extractClaudeText(content: unknown): string {
240
+ if (typeof content === 'string') return content;
241
+ if (!Array.isArray(content)) return '';
242
+ return content
243
+ .map((item) => {
244
+ if (!item || typeof item !== 'object') return '';
245
+ if ((item as any).type !== 'text') return '';
246
+ return typeof (item as any).text === 'string' ? (item as any).text : '';
247
+ })
248
+ .join('');
249
+ }
250
+
251
+ const CLAUDE_INTERNAL_USER_MESSAGE_PREFIXES = [
252
+ '<task-notification>',
253
+ '<local-command-caveat>',
254
+ '<local-command-stdout>',
255
+ '<local-command-stderr>',
256
+ '<command-name>',
257
+ '<command-message>',
258
+ '<command-args>',
259
+ '<system-reminder>',
260
+ '<environment_context>',
261
+ '# AGENTS.md instructions for ',
262
+ ];
263
+
264
+ function isClaudeInternalUserMessage(entry: any, text: string): boolean {
265
+ if (entry?.isMeta === true) return true;
266
+ const trimmed = text.trim();
267
+ if (!trimmed) return false;
268
+ return CLAUDE_INTERNAL_USER_MESSAGE_PREFIXES.some(prefix => trimmed.startsWith(prefix));
269
+ }
270
+
271
+ function extractClaudeUserMessageText(entry: any): string {
272
+ if (entry?.type !== 'user' || entry.message?.role !== 'user') return '';
273
+ const text = extractClaudeText(entry.message.content);
274
+ return isClaudeInternalUserMessage(entry, text) ? '' : text;
275
+ }
276
+
277
+ function hasClaudeToolResult(content: unknown): boolean {
278
+ return Array.isArray(content) && content.some((item) => item && typeof item === 'object' && (item as any).type === 'tool_result');
279
+ }
280
+
281
+ function isCodexSetupMessage(text: string): boolean {
282
+ const trimmed = text.trim();
283
+ return trimmed.startsWith('# AGENTS.md instructions for ') || trimmed.startsWith('<environment_context>');
284
+ }
285
+
286
+ function toIdleSessionInfo(meta: SessionFileMeta, source: SessionSource = 'scanner'): SessionInfo {
287
+ return {
288
+ sessionId: meta.sessionId,
289
+ agent: meta.agent,
290
+ cwd: meta.cwd,
291
+ title: meta.title,
292
+ observedApprovalMode: meta.approvalMode,
293
+ createdAt: meta.createdAt,
294
+ lastActivityAt: meta.lastActivityAt,
295
+ sources: [source],
296
+ runtime: createIdleRuntime(),
297
+ };
298
+ }
299
+
300
+ export function extractSessionIdFromSessionFile(agent: AgentType, filePath: string): string | null {
301
+ const name = basename(filePath, '.jsonl');
302
+ if (agent === 'claude') return name || null;
303
+ const match = name.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
304
+ return match?.[1] ?? null;
305
+ }
306
+
307
+ async function readClaudeSessionFileMeta(filePath: string, fileSize: number): Promise<Pick<SessionFileMeta, 'sessionId' | 'cwd' | 'title' | 'approvalMode'> | null> {
308
+ const content = await readFileHead(filePath, fileSize);
309
+ const lines = iterateFirstLines(content, 64);
310
+
311
+ let cwd = '';
312
+ let sessionId = '';
313
+ let title: string | undefined;
314
+ let firstUserText: string | undefined;
315
+ let derivedTitle: string | undefined;
316
+
317
+ for (const line of lines) {
318
+ let entry: any;
319
+ try {
320
+ entry = JSON.parse(line);
321
+ } catch {
322
+ continue;
323
+ }
324
+
325
+ if (!cwd && typeof entry.cwd === 'string') cwd = entry.cwd;
326
+ if (!sessionId && typeof entry.sessionId === 'string') sessionId = entry.sessionId;
327
+ if (!title) {
328
+ title = preferSessionTitle([entry.customTitle, entry.summary]);
329
+ }
330
+ if (!derivedTitle) {
331
+ const userText = extractClaudeUserMessageText(entry);
332
+ const nextDerivedTitle = preferSessionTitle([], userText);
333
+ if (nextDerivedTitle) {
334
+ firstUserText = userText;
335
+ derivedTitle = nextDerivedTitle;
336
+ }
337
+ }
338
+
339
+ if (cwd && (title || derivedTitle)) break;
340
+ }
341
+
342
+ if (!cwd) return null;
343
+
344
+ return {
345
+ sessionId: sessionId || extractSessionIdFromSessionFile('claude', filePath) || basename(filePath, '.jsonl'),
346
+ cwd,
347
+ title: title ?? derivedTitle ?? preferSessionTitle([], firstUserText),
348
+ approvalMode: await readSessionApprovalModeFromFile(filePath, 'claude', fileSize),
349
+ };
350
+ }
351
+
352
+ async function readCodexSessionFileMeta(filePath: string, fileSize: number): Promise<Pick<SessionFileMeta, 'sessionId' | 'cwd' | 'title' | 'approvalMode'> | null> {
353
+ const content = await readFileHead(filePath, fileSize);
354
+ const lines = iterateFirstLines(content, 96);
355
+
356
+ let cwd = '';
357
+ let sessionId = '';
358
+ let title: string | undefined;
359
+ let firstUserText: string | undefined;
360
+ let derivedTitle: string | undefined;
361
+
362
+ for (const line of lines) {
363
+ let entry: any;
364
+ try {
365
+ entry = JSON.parse(line);
366
+ } catch {
367
+ continue;
368
+ }
369
+
370
+ if (!cwd) {
371
+ cwd = entry.payload?.cwd ?? entry.cwd ?? '';
372
+ }
373
+ if (!sessionId) {
374
+ sessionId = entry.payload?.id ?? entry.id ?? '';
375
+ }
376
+ if (!title) {
377
+ title = preferSessionTitle([entry.payload?.title, entry.title]);
378
+ }
379
+ if (!derivedTitle) {
380
+ const userText = extractCodexUserMessageText(entry);
381
+ const nextDerivedTitle = userText && !isCodexSetupMessage(userText)
382
+ ? preferSessionTitle([], userText)
383
+ : undefined;
384
+ if (nextDerivedTitle) {
385
+ firstUserText = userText;
386
+ derivedTitle = nextDerivedTitle;
387
+ }
388
+ }
389
+
390
+ if (cwd && (title || derivedTitle)) break;
391
+ }
392
+
393
+ if (!cwd) return null;
394
+
395
+ return {
396
+ sessionId: sessionId || extractSessionIdFromSessionFile('codex', filePath) || basename(filePath, '.jsonl'),
397
+ cwd,
398
+ title: title ?? derivedTitle ?? preferSessionTitle([], firstUserText),
399
+ approvalMode: await readSessionApprovalModeFromFile(filePath, 'codex', fileSize),
400
+ };
401
+ }
402
+
403
+ export async function readSessionFileMeta(filePath: string, agent: AgentType): Promise<SessionFileMeta | null> {
404
+ const fileStat = await stat(filePath).catch(() => null);
405
+ if (!fileStat) return null;
406
+
407
+ try {
408
+ const fileSize = fileStat.size;
409
+ if (agent === 'claude') {
410
+ const meta = await readClaudeSessionFileMeta(filePath, fileSize);
411
+ if (!meta) return null;
412
+ return {
413
+ sessionId: meta.sessionId,
414
+ agent,
415
+ cwd: meta.cwd,
416
+ title: meta.title,
417
+ approvalMode: meta.approvalMode,
418
+ createdAt: new Date(fileStat.birthtimeMs).toISOString(),
419
+ lastActivityAt: new Date(fileStat.mtimeMs).toISOString(),
420
+ filePath,
421
+ };
422
+ }
423
+
424
+ const meta = await readCodexSessionFileMeta(filePath, fileSize);
425
+ if (!meta) return null;
426
+
427
+ return {
428
+ sessionId: meta.sessionId,
429
+ agent,
430
+ cwd: meta.cwd,
431
+ title: meta.title,
432
+ approvalMode: meta.approvalMode,
433
+ createdAt: new Date(fileStat.birthtimeMs).toISOString(),
434
+ lastActivityAt: new Date(fileStat.mtimeMs).toISOString(),
435
+ filePath,
436
+ };
437
+ } catch {
438
+ return null;
439
+ }
440
+ }
441
+
442
+ export async function listClaudeSessions(cwd?: string, partial?: SessionInfo[]): Promise<SessionInfo[]> {
443
+ const projectsDir = join(homedir(), '.claude', 'projects');
444
+
445
+ try {
446
+ let dirNames: string[];
447
+ if (cwd) {
448
+ dirNames = [encodeCwd(cwd)];
449
+ } else {
450
+ const entries = await readdir(projectsDir, { withFileTypes: true });
451
+ dirNames = entries.filter(e => e.isDirectory()).map(e => e.name);
452
+ }
453
+
454
+ // Collect all file paths, then read in parallel
455
+ const filePaths: string[] = [];
456
+ for (const dir of dirNames) {
457
+ const dirPath = join(projectsDir, dir);
458
+ const files = await readdir(dirPath).catch(() => [] as string[]);
459
+ for (const file of files) {
460
+ if (file.endsWith('.jsonl')) filePaths.push(join(dirPath, file));
461
+ }
462
+ }
463
+
464
+ // Stat files to get mtime, then sort by recency so active sessions are scanned first.
465
+ // Even if the inventory timeout fires mid-scan, recently active sessions are already in `partial`.
466
+ const fileStats = await mapWithConcurrency(filePaths, FILE_READ_CONCURRENCY, async (fp) => {
467
+ const s = await stat(fp).catch(() => null);
468
+ return { path: fp, mtimeMs: s?.mtimeMs ?? 0 };
469
+ });
470
+ fileStats.sort((a, b) => b.mtimeMs - a.mtimeMs);
471
+
472
+ const metas = await mapWithConcurrency(fileStats, FILE_READ_CONCURRENCY, async (entry) => {
473
+ const meta = await readSessionFileMeta(entry.path, 'claude');
474
+ if (meta && partial) partial.push(toIdleSessionInfo(meta));
475
+ return meta;
476
+ });
477
+ const sessions = metas
478
+ .filter((meta): meta is SessionFileMeta => meta !== null)
479
+ .map(meta => toIdleSessionInfo(meta));
480
+
481
+ return sessions.sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt));
482
+ } catch (e) {
483
+ // .claude/projects might not exist
484
+ log.warn({ error: String(e) }, 'failed to list claude sessions');
485
+ return [];
486
+ }
487
+ }
488
+
489
+ export async function listCodexSessions(cwd?: string, partial?: SessionInfo[]): Promise<SessionInfo[]> {
490
+ const sessionsDir = join(homedir(), '.codex', 'sessions');
491
+
492
+ try {
493
+ const years = await readdir(sessionsDir).catch(() => []);
494
+
495
+ // Collect all file paths first via directory traversal
496
+ const filePaths: string[] = [];
497
+ for (const year of years) {
498
+ const yearPath = join(sessionsDir, year);
499
+ const months = await readdir(yearPath).catch(() => []);
500
+
501
+ for (const month of months) {
502
+ const monthPath = join(yearPath, month);
503
+ const days = await readdir(monthPath).catch(() => []);
504
+
505
+ for (const day of days) {
506
+ const dayPath = join(monthPath, day);
507
+ const files = await readdir(dayPath).catch(() => []);
508
+
509
+ for (const file of files) {
510
+ if (file.endsWith('.jsonl')) filePaths.push(join(dayPath, file));
511
+ }
512
+ }
513
+ }
514
+ }
515
+
516
+ // Stat files to get mtime, then sort by recency so active sessions are scanned first
517
+ const fileStats = await mapWithConcurrency(filePaths, FILE_READ_CONCURRENCY, async (fp) => {
518
+ const s = await stat(fp).catch(() => null);
519
+ return { path: fp, mtimeMs: s?.mtimeMs ?? 0 };
520
+ });
521
+ fileStats.sort((a, b) => b.mtimeMs - a.mtimeMs);
522
+
523
+ const metas = await mapWithConcurrency(fileStats, FILE_READ_CONCURRENCY, async (entry) => {
524
+ const meta = await readSessionFileMeta(entry.path, 'codex');
525
+ if (meta && partial && (!cwd || meta.cwd === cwd)) partial.push(toIdleSessionInfo(meta));
526
+ return meta;
527
+ });
528
+ const sessions = metas
529
+ .filter((meta): meta is SessionFileMeta => meta !== null)
530
+ .filter(meta => !cwd || meta.cwd === cwd)
531
+ .map(meta => toIdleSessionInfo(meta));
532
+
533
+ return sessions.sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt));
534
+ } catch (e) {
535
+ // .codex/sessions might not exist
536
+ log.warn({ error: String(e) }, 'failed to list codex sessions');
537
+ return [];
538
+ }
539
+ }
540
+
541
+ export async function listSessions(agent?: AgentType, cwd?: string, partial?: SessionInfo[]): Promise<SessionInfo[]> {
542
+ const home = homedir();
543
+ const cacheKey = `list:${home}:${agent ?? 'all'}:${cwd ?? ''}`;
544
+ const cached = scannerCache.get(cacheKey);
545
+ if (cached) return cached;
546
+
547
+ // Coalesce concurrent callers into a single in-flight scan
548
+ const inflight = scannerInflight.get(cacheKey);
549
+ if (inflight) return inflight;
550
+
551
+ const promise = (async () => {
552
+ let result: SessionInfo[];
553
+ if (agent === 'claude') {
554
+ result = await listClaudeSessions(cwd, partial);
555
+ } else if (agent === 'codex') {
556
+ result = await listCodexSessions(cwd, partial);
557
+ } else {
558
+ const [claude, codex] = await Promise.all([
559
+ listClaudeSessions(cwd, partial),
560
+ listCodexSessions(cwd, partial),
561
+ ]);
562
+ result = [...claude, ...codex].sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt));
563
+ }
564
+
565
+ scannerCache.set(cacheKey, result);
566
+ return result;
567
+ })();
568
+
569
+ scannerInflight.set(cacheKey, promise);
570
+ promise.finally(() => scannerInflight.delete(cacheKey));
571
+ return promise;
572
+ }
573
+
574
+ export async function searchSessionContent(query: string, agent?: AgentType): Promise<Set<string>> {
575
+ const home = homedir();
576
+ const cacheKey = `search:${home}:${query}:${agent ?? 'all'}`;
577
+ const cached = searchCache.get(cacheKey);
578
+ if (cached) return cached;
579
+
580
+ // Coalesce concurrent callers into a single in-flight search
581
+ const inflight = searchInflight.get(cacheKey);
582
+ if (inflight) return inflight;
583
+
584
+ const promise = (async () => {
585
+ const matched = new Set<string>();
586
+ const q = query.toLowerCase();
587
+
588
+ async function searchClaudeFiles(): Promise<void> {
589
+ const projectsDir = join(homedir(), '.claude', 'projects');
590
+ try {
591
+ const entries = await readdir(projectsDir, { withFileTypes: true });
592
+ const filePaths: string[] = [];
593
+ for (const entry of entries) {
594
+ if (!entry.isDirectory()) continue;
595
+ const dirPath = join(projectsDir, entry.name);
596
+ const files = await readdir(dirPath).catch(() => [] as string[]);
597
+ for (const file of files) {
598
+ if (file.endsWith('.jsonl')) filePaths.push(join(dirPath, file));
599
+ }
600
+ }
601
+ const results = await mapWithConcurrency(filePaths, FILE_READ_CONCURRENCY, (filePath) => searchFileContent(filePath, q, 'claude'));
602
+ for (const result of results) {
603
+ if (result) matched.add(result);
604
+ }
605
+ } catch (e) {
606
+ log.warn({ error: String(e) }, 'failed to search claude files');
607
+ }
608
+ }
609
+
610
+ async function searchCodexFiles(): Promise<void> {
611
+ const sessionsDir = join(homedir(), '.codex', 'sessions');
612
+ try {
613
+ const filePaths: string[] = [];
614
+ async function collectFiles(dir: string): Promise<void> {
615
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
616
+ for (const entry of entries) {
617
+ const fullPath = join(dir, entry.name);
618
+ if (entry.isDirectory()) {
619
+ await collectFiles(fullPath);
620
+ } else if (entry.name.endsWith('.jsonl')) {
621
+ filePaths.push(fullPath);
622
+ }
623
+ }
624
+ }
625
+ await collectFiles(sessionsDir);
626
+ const results = await mapWithConcurrency(filePaths, FILE_READ_CONCURRENCY, (filePath) => searchFileContent(filePath, q, 'codex'));
627
+ for (const result of results) {
628
+ if (result) matched.add(result);
629
+ }
630
+ } catch (e) {
631
+ log.warn({ error: String(e) }, 'failed to search codex files');
632
+ }
633
+ }
634
+
635
+ if (agent === 'claude') {
636
+ await searchClaudeFiles();
637
+ } else if (agent === 'codex') {
638
+ await searchCodexFiles();
639
+ } else {
640
+ await Promise.all([searchClaudeFiles(), searchCodexFiles()]);
641
+ }
642
+
643
+ searchCache.set(cacheKey, matched);
644
+ return matched;
645
+ })();
646
+
647
+ searchInflight.set(cacheKey, promise);
648
+ promise.finally(() => searchInflight.delete(cacheKey));
649
+ return promise;
650
+ }
651
+
652
+ /** Returns the sessionId if the file contains matching text, null otherwise. */
653
+ async function searchFileContent(filePath: string, query: string, agent: AgentType): Promise<string | null> {
654
+ const input = createReadStream(filePath, { encoding: 'utf-8' });
655
+ const reader = createInterface({ input, crlfDelay: Infinity });
656
+ const extractText = agent === 'claude' ? extractClaudeMessageText : extractCodexMessageText;
657
+ let sessionId = '';
658
+ let found = false;
659
+
660
+ try {
661
+ for await (const line of reader) {
662
+ if (!line.trim()) continue;
663
+ let entry: any;
664
+ try {
665
+ entry = JSON.parse(line);
666
+ } catch {
667
+ continue;
668
+ }
669
+ if (!sessionId) {
670
+ if (agent === 'claude') {
671
+ if (typeof entry.sessionId === 'string') sessionId = entry.sessionId;
672
+ } else {
673
+ sessionId = entry.payload?.id ?? entry.id ?? '';
674
+ }
675
+ }
676
+ if (!found) {
677
+ const text = extractText(entry);
678
+ if (text && text.toLowerCase().includes(query)) {
679
+ found = true;
680
+ }
681
+ }
682
+ if (sessionId && found) break;
683
+ }
684
+ } finally {
685
+ reader.close();
686
+ input.destroy();
687
+ }
688
+
689
+ if (!found) return null;
690
+ return sessionId || extractSessionIdFromSessionFile(agent, filePath);
691
+ }
692
+
693
+ function extractClaudeMessageText(entry: any): string {
694
+ if (entry?.type === 'user' && entry.message?.role === 'user') {
695
+ return extractClaudeUserMessageText(entry);
696
+ }
697
+ if (entry?.type === 'assistant' && entry.message?.role === 'assistant') {
698
+ return extractClaudeText(entry.message.content);
699
+ }
700
+ return '';
701
+ }
702
+
703
+ function extractCodexMessageText(entry: any): string {
704
+ if (entry?.type === 'response_item' && entry.payload?.type === 'message') {
705
+ const role = entry.payload.role;
706
+ if (role === 'user' || role === 'assistant') {
707
+ return extractCodexText(entry.payload.content);
708
+ }
709
+ }
710
+ const userText = extractCodexUserMessageText(entry);
711
+ if (userText) return userText;
712
+ return '';
713
+ }
714
+
715
+ async function readJsonlFile(filePath: string, onEntry: (entry: any) => boolean | void): Promise<void> {
716
+ const input = createReadStream(filePath, { encoding: 'utf-8' });
717
+ const reader = createInterface({ input, crlfDelay: Infinity });
718
+
719
+ try {
720
+ for await (const line of reader) {
721
+ if (!line.trim()) continue;
722
+ let entry: any;
723
+ try {
724
+ entry = JSON.parse(line);
725
+ } catch {
726
+ continue;
727
+ }
728
+ if (onEntry(entry)) break;
729
+ }
730
+ } finally {
731
+ reader.close();
732
+ input.destroy();
733
+ }
734
+ }
735
+
736
+ async function resolveClaudeSessionFilePath(sessionId: string, cwd?: string): Promise<string | null> {
737
+ const projectsDir = join(homedir(), '.claude', 'projects');
738
+ const primaryDir = cwd ? encodeCwd(cwd) : null;
739
+ const allDirs = await readdir(projectsDir).catch(() => [] as string[]);
740
+ const dirs = primaryDir
741
+ ? [primaryDir, ...allDirs.filter((dir) => dir !== primaryDir)]
742
+ : allDirs;
743
+
744
+ for (const dir of dirs) {
745
+ const filePath = join(projectsDir, dir, `${sessionId}.jsonl`);
746
+ const fileStat = await stat(filePath).catch(() => null);
747
+ if (fileStat?.isFile()) {
748
+ return filePath;
749
+ }
750
+ }
751
+
752
+ return null;
753
+ }
754
+
755
+ async function resolveSessionFilePath(sessionId: string, agent: AgentType, cwd?: string): Promise<string | null> {
756
+ if (agent === 'claude') {
757
+ return resolveClaudeSessionFilePath(sessionId, cwd);
758
+ }
759
+
760
+ const sessionsDir = join(homedir(), '.codex', 'sessions');
761
+ return findCodexSessionFile(sessionsDir, sessionId);
762
+ }
763
+
764
+ type ClaudeRuntimeRelevantEntry =
765
+ | { kind: 'userPrompt' }
766
+ | { kind: 'toolResult' }
767
+ | { kind: 'assistant'; stopReason?: string | null; text?: string }
768
+ | { kind: 'stopHook' };
769
+
770
+ function classifyClaudeRuntimeEntry(entry: any): ClaudeRuntimeRelevantEntry | null {
771
+ if (entry?.type === 'user' && entry.message?.role === 'user') {
772
+ if (hasClaudeToolResult(entry.message.content)) {
773
+ return { kind: 'toolResult' };
774
+ }
775
+
776
+ const text = extractClaudeUserMessageText(entry);
777
+ if (text) {
778
+ return { kind: 'userPrompt' };
779
+ }
780
+ return null;
781
+ }
782
+
783
+ if (entry?.type === 'assistant' && entry.message?.role === 'assistant') {
784
+ const text = extractClaudeText(entry.message.content);
785
+ const stopReason = entry.message?.stop_reason;
786
+ return {
787
+ kind: 'assistant',
788
+ stopReason: typeof stopReason === 'string' ? stopReason : stopReason ?? null,
789
+ ...(text ? { text } : {}),
790
+ };
791
+ }
792
+
793
+ if (entry?.type === 'system' && entry.subtype === 'stop_hook_summary') {
794
+ return { kind: 'stopHook' };
795
+ }
796
+
797
+ return null;
798
+ }
799
+
800
+ function runtimeHintsFromClaudeEntry(entry: ClaudeRuntimeRelevantEntry | null): SessionRuntimeHints {
801
+ if (!entry) return {};
802
+
803
+ if (entry.kind === 'stopHook') {
804
+ return {};
805
+ }
806
+
807
+ if (entry.kind === 'assistant') {
808
+ const isResponding = entry.stopReason == null || entry.stopReason === 'tool_use';
809
+ return {
810
+ ...(isResponding ? { isResponding: true } : {}),
811
+ ...(isResponding && entry.text ? { partialReplyText: entry.text } : {}),
812
+ };
813
+ }
814
+
815
+ return { isResponding: true };
816
+ }
817
+
818
+ export async function readSessionRuntimeHintsFromFile(filePath: string, agent: AgentType): Promise<SessionRuntimeHints> {
819
+ if (agent !== 'claude') {
820
+ return {};
821
+ }
822
+
823
+ let lastRelevant: ClaudeRuntimeRelevantEntry | null = null;
824
+ try {
825
+ await readJsonlFile(filePath, (entry) => {
826
+ const relevant = classifyClaudeRuntimeEntry(entry);
827
+ if (relevant) {
828
+ lastRelevant = relevant;
829
+ }
830
+ });
831
+ } catch (error) {
832
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
833
+ log.warn({ filePath, error: String(error) }, 'failed to read session runtime hints');
834
+ }
835
+ return {};
836
+ }
837
+
838
+ return runtimeHintsFromClaudeEntry(lastRelevant);
839
+ }
840
+
841
+ export async function readSessionRuntimeHints(sessionId: string, agent: AgentType, cwd?: string): Promise<SessionRuntimeHints> {
842
+ const filePath = await resolveSessionFilePath(sessionId, agent, cwd);
843
+ if (!filePath) {
844
+ return {};
845
+ }
846
+
847
+ return readSessionRuntimeHintsFromFile(filePath, agent);
848
+ }
849
+
850
+ export async function readSessionHistory(sessionId: string, agent: AgentType, cwd?: string): Promise<Array<{ role: 'user' | 'assistant'; content: string }>> {
851
+ const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
852
+ const appendHistoryMessage = (
853
+ message: { role: 'user' | 'assistant'; content: string },
854
+ options?: { coalesceConsecutiveAssistant?: boolean },
855
+ ) => {
856
+ if (options?.coalesceConsecutiveAssistant && message.role === 'assistant') {
857
+ const lastMessage = messages[messages.length - 1];
858
+ if (lastMessage?.role === 'assistant') {
859
+ messages[messages.length - 1] = message;
860
+ return;
861
+ }
862
+ }
863
+ messages.push(message);
864
+ };
865
+
866
+ if (agent === 'claude') {
867
+ try {
868
+ const filePath = await resolveClaudeSessionFilePath(sessionId, cwd);
869
+ if (filePath) {
870
+ await readJsonlFile(filePath, (msg) => {
871
+ if (msg.type === 'user' && msg.message?.role === 'user') {
872
+ const text = extractClaudeUserMessageText(msg);
873
+ if (text) appendHistoryMessage({ role: 'user', content: text });
874
+ } else if (msg.type === 'assistant' && msg.message?.role === 'assistant') {
875
+ const text = extractClaudeText(msg.message.content);
876
+ if (text) appendHistoryMessage({ role: 'assistant', content: text });
877
+ }
878
+ });
879
+ }
880
+ } catch (e) {
881
+ log.warn({ error: String(e) }, 'failed to read claude session history');
882
+ }
883
+ } else if (agent === 'codex') {
884
+ try {
885
+ const filePath = await resolveSessionFilePath(sessionId, agent, cwd);
886
+ if (filePath) {
887
+ await readJsonlFile(filePath, (msg) => {
888
+ if (msg.type !== 'response_item' || msg.payload?.type !== 'message') return;
889
+ const role = msg.payload.role;
890
+ if (role !== 'user' && role !== 'assistant') return;
891
+ const text = extractCodexText(msg.payload.content);
892
+ if (!text) return;
893
+ if (role === 'user' && isCodexSetupMessage(text)) return;
894
+ appendHistoryMessage(
895
+ { role, content: text },
896
+ { coalesceConsecutiveAssistant: true },
897
+ );
898
+ });
899
+ }
900
+ } catch (e) {
901
+ log.warn({ error: String(e) }, 'failed to read codex session history');
902
+ }
903
+ }
904
+
905
+ return messages;
906
+ }
907
+
908
+ async function findCodexSessionFile(dir: string, sessionId: string): Promise<string | null> {
909
+ try {
910
+ const entries = await readdir(dir, { withFileTypes: true });
911
+ for (const entry of entries) {
912
+ const fullPath = join(dir, entry.name);
913
+ if (entry.isDirectory()) {
914
+ const found = await findCodexSessionFile(fullPath, sessionId);
915
+ if (found) return found;
916
+ } else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) {
917
+ return fullPath;
918
+ }
919
+ }
920
+ } catch (e) {
921
+ log.warn({ error: String(e) }, 'failed to walk codex sessions dir');
922
+ }
923
+ return null;
924
+ }