@vibelet/cli 0.1.35 → 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 -1035
  323. package/dist/index.cjs +0 -125
@@ -0,0 +1,1045 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
4
+ import { join } from 'path';
5
+ import { tmpdir } from 'os';
6
+ import {
7
+ extractSessionIdFromSessionFile,
8
+ readSessionFileMeta,
9
+ readSessionHistory,
10
+ readSessionRuntimeHints,
11
+ searchSessionContent,
12
+ } from './scanner.js';
13
+ import { homedir } from 'os';
14
+
15
+ async function withTempJsonl(
16
+ lines: unknown[],
17
+ run: (filePath: string) => Promise<void>,
18
+ ): Promise<void> {
19
+ const dir = await mkdtemp(join(tmpdir(), 'vibelet-scanner-'));
20
+ const filePath = join(dir, 'session.jsonl');
21
+ await writeFile(filePath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8');
22
+ try {
23
+ await run(filePath);
24
+ } finally {
25
+ await rm(dir, { recursive: true, force: true });
26
+ }
27
+ }
28
+
29
+ test('derives a Claude title from the first user sentence when the file starts with progress events', async () => {
30
+ await withTempJsonl([
31
+ {
32
+ type: 'progress',
33
+ cwd: '/repo',
34
+ sessionId: 'claude-session-1',
35
+ data: { type: 'hook_progress' },
36
+ },
37
+ {
38
+ type: 'file-history-snapshot',
39
+ messageId: 'msg-1',
40
+ snapshot: { timestamp: '2026-03-20T00:00:00.000Z' },
41
+ isSnapshotUpdate: false,
42
+ },
43
+ {
44
+ type: 'user',
45
+ cwd: '/repo',
46
+ sessionId: 'claude-session-1',
47
+ message: {
48
+ role: 'user',
49
+ content: 'Investigate this regression. It started after the inventory merge.',
50
+ },
51
+ },
52
+ ], async (filePath) => {
53
+ const meta = await readSessionFileMeta(filePath, 'claude');
54
+ assert.equal(meta?.cwd, '/repo');
55
+ assert.equal(meta?.sessionId, 'claude-session-1');
56
+ assert.equal(meta?.title, 'Investigate this regression');
57
+ });
58
+ });
59
+
60
+ test('reads the latest Claude approval mode from session entries', async () => {
61
+ await withTempJsonl([
62
+ {
63
+ type: 'permission-mode',
64
+ permissionMode: 'bypassPermissions',
65
+ sessionId: 'claude-session-mode',
66
+ },
67
+ {
68
+ type: 'user',
69
+ cwd: '/repo',
70
+ sessionId: 'claude-session-mode',
71
+ permissionMode: 'default',
72
+ message: {
73
+ role: 'user',
74
+ content: 'First prompt',
75
+ },
76
+ },
77
+ {
78
+ type: 'user',
79
+ cwd: '/repo',
80
+ sessionId: 'claude-session-mode',
81
+ permissionMode: 'acceptEdits',
82
+ message: {
83
+ role: 'user',
84
+ content: 'Second prompt',
85
+ },
86
+ },
87
+ ], async (filePath) => {
88
+ const meta = await readSessionFileMeta(filePath, 'claude');
89
+ assert.equal(meta?.approvalMode, 'acceptEdits');
90
+ });
91
+ });
92
+
93
+ test('skips Claude local-command meta messages when deriving a title', async () => {
94
+ await withTempJsonl([
95
+ {
96
+ type: 'file-history-snapshot',
97
+ messageId: 'msg-1',
98
+ snapshot: { timestamp: '2026-03-20T00:00:00.000Z' },
99
+ isSnapshotUpdate: false,
100
+ },
101
+ {
102
+ type: 'user',
103
+ cwd: '/repo',
104
+ sessionId: 'claude-session-2',
105
+ isMeta: true,
106
+ message: {
107
+ role: 'user',
108
+ content: '<local-command-caveat>Caveat: generated by local commands</local-command-caveat>',
109
+ },
110
+ },
111
+ {
112
+ type: 'user',
113
+ cwd: '/repo',
114
+ sessionId: 'claude-session-2',
115
+ message: {
116
+ role: 'user',
117
+ content: '<command-name>/model</command-name>\n<command-message>model</command-message>',
118
+ },
119
+ },
120
+ {
121
+ type: 'user',
122
+ cwd: '/repo',
123
+ sessionId: 'claude-session-2',
124
+ message: {
125
+ role: 'user',
126
+ content: '为什么我的 statusline 全没了?',
127
+ },
128
+ },
129
+ ], async (filePath) => {
130
+ const meta = await readSessionFileMeta(filePath, 'claude');
131
+ assert.equal(meta?.title, '为什么我的 statusline 全没了');
132
+ });
133
+ });
134
+
135
+ test('skips Claude task notifications when deriving a title', async () => {
136
+ await withTempJsonl([
137
+ {
138
+ type: 'user',
139
+ cwd: '/repo',
140
+ sessionId: 'claude-session-3',
141
+ message: {
142
+ role: 'user',
143
+ content: '<task-notification>\n<task-id>abc123</task-id>\n<status>failed</status>\n</task-notification>',
144
+ },
145
+ },
146
+ {
147
+ type: 'user',
148
+ cwd: '/repo',
149
+ sessionId: 'claude-session-3',
150
+ message: {
151
+ role: 'user',
152
+ content: '打开浏览器查看今天的杭州天气。',
153
+ },
154
+ },
155
+ ], async (filePath) => {
156
+ const meta = await readSessionFileMeta(filePath, 'claude');
157
+ assert.equal(meta?.title, '打开浏览器查看今天的杭州天气');
158
+ });
159
+ });
160
+
161
+ test('derives a Codex title from the first meaningful user message instead of session metadata', async () => {
162
+ await withTempJsonl([
163
+ {
164
+ type: 'session_meta',
165
+ payload: {
166
+ id: '0199a7f0-83df-7440-a5df-815f4771e93c',
167
+ cwd: '/repo',
168
+ },
169
+ },
170
+ {
171
+ type: 'response_item',
172
+ payload: {
173
+ type: 'message',
174
+ role: 'user',
175
+ content: [
176
+ {
177
+ type: 'input_text',
178
+ text: '<environment_context>\n <cwd>/repo</cwd>\n</environment_context>',
179
+ },
180
+ ],
181
+ },
182
+ },
183
+ {
184
+ type: 'response_item',
185
+ payload: {
186
+ type: 'message',
187
+ role: 'user',
188
+ content: [
189
+ {
190
+ type: 'input_text',
191
+ text: "'/repo/src/file.ts'\n这个指示器的状态不对了, 即使远程任务存在, 也不会显示 CloudOutlined",
192
+ },
193
+ ],
194
+ },
195
+ },
196
+ ], async (filePath) => {
197
+ const meta = await readSessionFileMeta(filePath, 'codex');
198
+ assert.equal(meta?.cwd, '/repo');
199
+ assert.equal(meta?.sessionId, '0199a7f0-83df-7440-a5df-815f4771e93c');
200
+ assert.equal(meta?.title, '这个指示器的状态不对了, 即使远程任务存在, 也不会显示 CloudOutlined');
201
+ });
202
+ });
203
+
204
+ test('reads the latest Codex approval mode from turn_context entries', async () => {
205
+ await withTempJsonl([
206
+ {
207
+ type: 'session_meta',
208
+ payload: {
209
+ id: '0199a7f0-83df-7440-a5df-815f4771e93c',
210
+ cwd: '/repo',
211
+ },
212
+ },
213
+ {
214
+ type: 'turn_context',
215
+ payload: {
216
+ approval_policy: 'never',
217
+ sandbox_policy: { type: 'danger-full-access' },
218
+ },
219
+ },
220
+ {
221
+ type: 'turn_context',
222
+ payload: {
223
+ approval_policy: 'on-request',
224
+ sandbox_policy: { type: 'workspace-write', writable_roots: [] },
225
+ },
226
+ },
227
+ ], async (filePath) => {
228
+ const meta = await readSessionFileMeta(filePath, 'codex');
229
+ assert.equal(meta?.approvalMode, 'plan');
230
+ });
231
+ });
232
+
233
+ test('ignores Claude jsonl files that do not contain a real session cwd', async () => {
234
+ await withTempJsonl([
235
+ {
236
+ type: 'queue-operation',
237
+ operation: 'enqueue',
238
+ timestamp: '2026-03-20T00:00:00.000Z',
239
+ sessionId: 'queue-only',
240
+ content: 'not a transcript',
241
+ },
242
+ ], async (filePath) => {
243
+ const meta = await readSessionFileMeta(filePath, 'claude');
244
+ assert.equal(meta, null);
245
+ });
246
+ });
247
+
248
+ // ===== extractSessionIdFromSessionFile =====
249
+
250
+ test('extracts claude session id from filename (basename without .jsonl)', () => {
251
+ assert.equal(
252
+ extractSessionIdFromSessionFile('claude', '/Users/test/.claude/projects/-Users-test-repo/my-session-id.jsonl'),
253
+ 'my-session-id',
254
+ );
255
+ });
256
+
257
+ test('extracts claude session id even for dot-prefixed filenames', () => {
258
+ // basename('/some/path/.jsonl', '.jsonl') returns '.jsonl' which is truthy
259
+ assert.equal(
260
+ extractSessionIdFromSessionFile('claude', '/some/path/.jsonl'),
261
+ '.jsonl',
262
+ );
263
+ });
264
+
265
+ test('extracts codex session id (UUID) from rollout filename', () => {
266
+ assert.equal(
267
+ extractSessionIdFromSessionFile('codex', '/Users/test/.codex/sessions/2026/03/20/rollout-2026-03-20T08-37-12-019d08ac-c265-7270-9497-d14125228a50.jsonl'),
268
+ '019d08ac-c265-7270-9497-d14125228a50',
269
+ );
270
+ });
271
+
272
+ test('returns null for codex when filename has no UUID', () => {
273
+ assert.equal(
274
+ extractSessionIdFromSessionFile('codex', '/Users/test/.codex/sessions/2026/03/20/no-uuid-here.jsonl'),
275
+ null,
276
+ );
277
+ });
278
+
279
+ test('extracts codex UUID from simple filename', () => {
280
+ assert.equal(
281
+ extractSessionIdFromSessionFile('codex', '/path/019d03e3-2672-7011-9c6d-5ba083b71111.jsonl'),
282
+ '019d03e3-2672-7011-9c6d-5ba083b71111',
283
+ );
284
+ });
285
+
286
+ // ===== searchSessionContent (with temp files) =====
287
+
288
+ async function withTempClaudeProject(
289
+ sessions: Array<{ id: string; lines: unknown[] }>,
290
+ run: (projectsDir: string) => Promise<void>,
291
+ ): Promise<void> {
292
+ const dir = await mkdtemp(join(tmpdir(), 'vibelet-search-'));
293
+ const projectDir = join(dir, '-test-project');
294
+ const { mkdir } = await import('fs/promises');
295
+ await mkdir(projectDir, { recursive: true });
296
+ for (const session of sessions) {
297
+ const filePath = join(projectDir, `${session.id}.jsonl`);
298
+ await writeFile(filePath, `${session.lines.map((l) => JSON.stringify(l)).join('\n')}\n`, 'utf-8');
299
+ }
300
+ try {
301
+ await run(dir);
302
+ } finally {
303
+ await rm(dir, { recursive: true, force: true });
304
+ }
305
+ }
306
+
307
+ test('searchSessionContent finds Claude user message text', async () => {
308
+ await withTempClaudeProject([
309
+ {
310
+ id: 'session-match',
311
+ lines: [
312
+ { type: 'progress', cwd: '/repo', sessionId: 'session-match' },
313
+ { type: 'user', message: { role: 'user', content: '请帮我修复登录问题' }, cwd: '/repo', sessionId: 'session-match' },
314
+ ],
315
+ },
316
+ {
317
+ id: 'session-nomatch',
318
+ lines: [
319
+ { type: 'progress', cwd: '/repo', sessionId: 'session-nomatch' },
320
+ { type: 'user', message: { role: 'user', content: 'Add a new feature' }, cwd: '/repo', sessionId: 'session-nomatch' },
321
+ ],
322
+ },
323
+ ], async (projectsDir) => {
324
+ // Monkey-patch homedir for this test
325
+ const originalHome = process.env.HOME;
326
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-home-'));
327
+ const { mkdir, symlink } = await import('fs/promises');
328
+ await mkdir(join(tempHome, '.claude'), { recursive: true });
329
+ await symlink(projectsDir, join(tempHome, '.claude', 'projects'));
330
+ process.env.HOME = tempHome;
331
+ try {
332
+ const results = await searchSessionContent('登录', 'claude');
333
+ assert.equal(results.size, 1);
334
+ assert.ok(results.has('session-match'));
335
+ } finally {
336
+ process.env.HOME = originalHome;
337
+ await rm(tempHome, { recursive: true, force: true });
338
+ }
339
+ });
340
+ });
341
+
342
+ test('searchSessionContent finds Claude assistant message text', async () => {
343
+ await withTempClaudeProject([
344
+ {
345
+ id: 'asst-session',
346
+ lines: [
347
+ { type: 'progress', cwd: '/repo', sessionId: 'asst-session' },
348
+ { type: 'user', message: { role: 'user', content: 'help me' }, cwd: '/repo', sessionId: 'asst-session' },
349
+ { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: '我来帮你解决这个问题' }] }, sessionId: 'asst-session' },
350
+ ],
351
+ },
352
+ ], async (projectsDir) => {
353
+ const originalHome = process.env.HOME;
354
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-home-'));
355
+ const { mkdir, symlink } = await import('fs/promises');
356
+ await mkdir(join(tempHome, '.claude'), { recursive: true });
357
+ await symlink(projectsDir, join(tempHome, '.claude', 'projects'));
358
+ process.env.HOME = tempHome;
359
+ try {
360
+ const results = await searchSessionContent('解决', 'claude');
361
+ assert.equal(results.size, 1);
362
+ assert.ok(results.has('asst-session'));
363
+ } finally {
364
+ process.env.HOME = originalHome;
365
+ await rm(tempHome, { recursive: true, force: true });
366
+ }
367
+ });
368
+ });
369
+
370
+ test('searchSessionContent uses sessionId from JSONL content, not filename', async () => {
371
+ await withTempClaudeProject([
372
+ {
373
+ id: 'filename-id',
374
+ lines: [
375
+ { type: 'progress', cwd: '/repo', sessionId: 'internal-id' },
376
+ { type: 'user', message: { role: 'user', content: 'unique-search-term' }, cwd: '/repo', sessionId: 'internal-id' },
377
+ ],
378
+ },
379
+ ], async (projectsDir) => {
380
+ const originalHome = process.env.HOME;
381
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-home-'));
382
+ const { mkdir, symlink } = await import('fs/promises');
383
+ await mkdir(join(tempHome, '.claude'), { recursive: true });
384
+ await symlink(projectsDir, join(tempHome, '.claude', 'projects'));
385
+ process.env.HOME = tempHome;
386
+ try {
387
+ const results = await searchSessionContent('unique-search-term', 'claude');
388
+ assert.equal(results.size, 1);
389
+ assert.ok(results.has('internal-id'), 'should use sessionId from JSONL content, not filename');
390
+ assert.ok(!results.has('filename-id'), 'should NOT use filename as sessionId');
391
+ } finally {
392
+ process.env.HOME = originalHome;
393
+ await rm(tempHome, { recursive: true, force: true });
394
+ }
395
+ });
396
+ });
397
+
398
+ // ===== readSessionHistory =====
399
+
400
+ test('readSessionHistory returns empty array for non-existent claude session', async () => {
401
+ const result = await readSessionHistory('nonexistent-session-id-12345', 'claude', '/tmp/nonexistent-cwd-xyz');
402
+ assert.deepEqual(result, []);
403
+ });
404
+
405
+ test('readSessionHistory returns empty array for non-existent codex session', async () => {
406
+ const result = await readSessionHistory('nonexistent-session-id-12345', 'codex');
407
+ assert.deepEqual(result, []);
408
+ });
409
+
410
+ test('readSessionHistory returns empty array when cwd is not provided for claude', async () => {
411
+ const result = await readSessionHistory('nonexistent-no-cwd', 'claude');
412
+ assert.deepEqual(result, []);
413
+ });
414
+
415
+ // ===== readSessionFileMeta edge cases =====
416
+
417
+ test('readSessionFileMeta returns null for non-existent file path', async () => {
418
+ const result = await readSessionFileMeta('/tmp/this-file-does-not-exist-at-all.jsonl', 'claude');
419
+ assert.equal(result, null);
420
+ });
421
+
422
+ test('readSessionFileMeta returns null for non-existent codex file path', async () => {
423
+ const result = await readSessionFileMeta('/tmp/this-file-does-not-exist-at-all.jsonl', 'codex');
424
+ assert.equal(result, null);
425
+ });
426
+
427
+ test('readSessionFileMeta handles file with only empty lines', async () => {
428
+ await withTempJsonl([
429
+ ], async (filePath) => {
430
+ // Overwrite with just whitespace lines
431
+ await writeFile(filePath, '\n\n \n', 'utf-8');
432
+ const meta = await readSessionFileMeta(filePath, 'claude');
433
+ assert.equal(meta, null);
434
+ });
435
+ });
436
+
437
+ test('readSessionFileMeta handles file with invalid JSON lines', async () => {
438
+ const dir = await mkdtemp(join(tmpdir(), 'vibelet-scanner-'));
439
+ const filePath = join(dir, 'broken.jsonl');
440
+ await writeFile(filePath, 'not json at all\n{broken json\n', 'utf-8');
441
+ try {
442
+ const meta = await readSessionFileMeta(filePath, 'claude');
443
+ assert.equal(meta, null);
444
+ } finally {
445
+ await rm(dir, { recursive: true, force: true });
446
+ }
447
+ });
448
+
449
+ test('readSessionFileMeta for codex returns null when no cwd in entries', async () => {
450
+ await withTempJsonl([
451
+ { type: 'session_meta', payload: { id: 'codex-no-cwd-123' } },
452
+ ], async (filePath) => {
453
+ const meta = await readSessionFileMeta(filePath, 'codex');
454
+ assert.equal(meta, null);
455
+ });
456
+ });
457
+
458
+ test('readSessionFileMeta for codex with session_meta but no user message derives no title', async () => {
459
+ await withTempJsonl([
460
+ {
461
+ type: 'session_meta',
462
+ payload: {
463
+ id: '0199a7f0-0000-0000-0000-000000000000',
464
+ cwd: '/some/project',
465
+ },
466
+ },
467
+ ], async (filePath) => {
468
+ const meta = await readSessionFileMeta(filePath, 'codex');
469
+ assert.equal(meta?.cwd, '/some/project');
470
+ assert.equal(meta?.sessionId, '0199a7f0-0000-0000-0000-000000000000');
471
+ assert.equal(meta?.title, undefined);
472
+ });
473
+ });
474
+
475
+ test('readSessionFileMeta for claude extracts sessionId from filename when not in content', async () => {
476
+ await withTempJsonl([
477
+ { type: 'user', cwd: '/repo', message: { role: 'user', content: 'Hello world' } },
478
+ ], async (filePath) => {
479
+ const meta = await readSessionFileMeta(filePath, 'claude');
480
+ assert.equal(meta?.cwd, '/repo');
481
+ // sessionId should fall back to filename (basename without .jsonl)
482
+ assert.equal(meta?.sessionId, 'session');
483
+ });
484
+ });
485
+
486
+ test('readSessionFileMeta for claude uses customTitle when available', async () => {
487
+ await withTempJsonl([
488
+ {
489
+ type: 'user',
490
+ cwd: '/repo',
491
+ sessionId: 'titled-session',
492
+ customTitle: 'My Custom Title',
493
+ message: { role: 'user', content: 'Some long message that would otherwise be the title' },
494
+ },
495
+ ], async (filePath) => {
496
+ const meta = await readSessionFileMeta(filePath, 'claude');
497
+ assert.equal(meta?.title, 'My Custom Title');
498
+ });
499
+ });
500
+
501
+ test('readSessionFileMeta for codex skips environment_context setup messages for title', async () => {
502
+ await withTempJsonl([
503
+ {
504
+ type: 'session_meta',
505
+ payload: { id: 'codex-setup-skip', cwd: '/repo' },
506
+ },
507
+ {
508
+ type: 'response_item',
509
+ payload: {
510
+ type: 'message',
511
+ role: 'user',
512
+ content: [{ type: 'input_text', text: '<environment_context>\n <cwd>/repo</cwd>\n</environment_context>' }],
513
+ },
514
+ },
515
+ {
516
+ type: 'response_item',
517
+ payload: {
518
+ type: 'message',
519
+ role: 'user',
520
+ content: [{ type: 'input_text', text: 'Fix the broken tests in scanner module' }],
521
+ },
522
+ },
523
+ ], async (filePath) => {
524
+ const meta = await readSessionFileMeta(filePath, 'codex');
525
+ assert.equal(meta?.sessionId, 'codex-setup-skip');
526
+ assert.ok(meta?.title);
527
+ assert.ok(!meta?.title?.includes('environment_context'));
528
+ });
529
+ });
530
+
531
+ test('readSessionFileMeta for codex with event_msg user_message type', async () => {
532
+ await withTempJsonl([
533
+ {
534
+ type: 'session_meta',
535
+ payload: { id: 'codex-event-msg', cwd: '/project' },
536
+ },
537
+ {
538
+ type: 'event_msg',
539
+ payload: {
540
+ type: 'user_message',
541
+ message: 'Refactor the database layer for better performance',
542
+ },
543
+ },
544
+ ], async (filePath) => {
545
+ const meta = await readSessionFileMeta(filePath, 'codex');
546
+ assert.equal(meta?.cwd, '/project');
547
+ assert.equal(meta?.sessionId, 'codex-event-msg');
548
+ assert.ok(meta?.title);
549
+ assert.ok(meta?.title?.includes('Refactor'));
550
+ });
551
+ });
552
+
553
+ test('readSessionFileMeta includes createdAt and lastActivityAt from file stats', async () => {
554
+ await withTempJsonl([
555
+ { type: 'user', cwd: '/repo', sessionId: 'stat-test', message: { role: 'user', content: 'test message' } },
556
+ ], async (filePath) => {
557
+ const meta = await readSessionFileMeta(filePath, 'claude');
558
+ assert.ok(meta);
559
+ assert.ok(meta.createdAt);
560
+ assert.ok(meta.lastActivityAt);
561
+ // Should be valid ISO date strings
562
+ assert.ok(!isNaN(new Date(meta.createdAt).getTime()));
563
+ assert.ok(!isNaN(new Date(meta.lastActivityAt).getTime()));
564
+ assert.equal(meta.filePath, filePath);
565
+ assert.equal(meta.agent, 'claude');
566
+ });
567
+ });
568
+
569
+ // ===== listClaudeSessions / listCodexSessions / listSessions =====
570
+
571
+ test('listClaudeSessions returns sessions from temp claude projects dir', async () => {
572
+ const { listClaudeSessions } = await import('./scanner.js');
573
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-home-'));
574
+ const projectDir = join(tempHome, '.claude', 'projects', '-test-repo');
575
+ await mkdir(projectDir, { recursive: true });
576
+
577
+ // Create a session file
578
+ await writeFile(join(projectDir, 'session-abc.jsonl'), JSON.stringify({
579
+ type: 'user',
580
+ cwd: '/test-repo',
581
+ sessionId: 'session-abc',
582
+ message: { role: 'user', content: 'Hello world' },
583
+ }) + '\n', 'utf-8');
584
+
585
+ const originalHome = process.env.HOME;
586
+ process.env.HOME = tempHome;
587
+ try {
588
+ const sessions = await listClaudeSessions();
589
+ assert.ok(sessions.length >= 1);
590
+ const found = sessions.find(s => s.sessionId === 'session-abc');
591
+ assert.ok(found, 'Should find session-abc');
592
+ assert.equal(found!.agent, 'claude');
593
+ assert.equal(found!.cwd, '/test-repo');
594
+ } finally {
595
+ process.env.HOME = originalHome;
596
+ await rm(tempHome, { recursive: true, force: true });
597
+ }
598
+ });
599
+
600
+ test('listClaudeSessions filters by cwd when provided', async () => {
601
+ const { listClaudeSessions } = await import('./scanner.js');
602
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-home-'));
603
+
604
+ // encodeCwd replaces non-alphanumeric with -
605
+ const projectDir = join(tempHome, '.claude', 'projects', '-test-repo');
606
+ await mkdir(projectDir, { recursive: true });
607
+ await writeFile(join(projectDir, 'sess1.jsonl'), JSON.stringify({
608
+ type: 'user', cwd: '/test-repo', sessionId: 'sess1',
609
+ message: { role: 'user', content: 'Hello' },
610
+ }) + '\n', 'utf-8');
611
+
612
+ const originalHome = process.env.HOME;
613
+ process.env.HOME = tempHome;
614
+ try {
615
+ // Search with matching cwd - encodeCwd('/test-repo') = '-test-repo'
616
+ const sessions = await listClaudeSessions('/test-repo');
617
+ assert.ok(sessions.length >= 1);
618
+
619
+ // Search with non-matching cwd
620
+ const empty = await listClaudeSessions('/other-repo');
621
+ assert.equal(empty.length, 0);
622
+ } finally {
623
+ process.env.HOME = originalHome;
624
+ await rm(tempHome, { recursive: true, force: true });
625
+ }
626
+ });
627
+
628
+ test('listCodexSessions returns sessions from temp codex sessions dir', async () => {
629
+ const { listCodexSessions } = await import('./scanner.js');
630
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-home-'));
631
+ const dayDir = join(tempHome, '.codex', 'sessions', '2026', '03', '20');
632
+ await mkdir(dayDir, { recursive: true });
633
+
634
+ const sessionId = '019d08ac-c265-7270-9497-d14125228a50';
635
+ await writeFile(join(dayDir, `rollout-2026-03-20T08-37-12-${sessionId}.jsonl`), [
636
+ JSON.stringify({ type: 'session_meta', payload: { id: sessionId, cwd: '/my-project' } }),
637
+ JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Fix the bug' }] } }),
638
+ ].join('\n') + '\n', 'utf-8');
639
+
640
+ const originalHome = process.env.HOME;
641
+ process.env.HOME = tempHome;
642
+ try {
643
+ const sessions = await listCodexSessions();
644
+ assert.ok(sessions.length >= 1);
645
+ const found = sessions.find(s => s.sessionId === sessionId);
646
+ assert.ok(found, `Should find session ${sessionId}`);
647
+ assert.equal(found!.agent, 'codex');
648
+ assert.equal(found!.cwd, '/my-project');
649
+ } finally {
650
+ process.env.HOME = originalHome;
651
+ await rm(tempHome, { recursive: true, force: true });
652
+ }
653
+ });
654
+
655
+ test('listCodexSessions filters by cwd when provided', async () => {
656
+ const { listCodexSessions } = await import('./scanner.js');
657
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-home-'));
658
+ const dayDir = join(tempHome, '.codex', 'sessions', '2026', '03', '20');
659
+ await mkdir(dayDir, { recursive: true });
660
+
661
+ const sessionId = '019d08ac-c265-7270-9497-aaaaaaaaaaaa';
662
+ await writeFile(join(dayDir, `rollout-${sessionId}.jsonl`), [
663
+ JSON.stringify({ type: 'session_meta', payload: { id: sessionId, cwd: '/specific-project' } }),
664
+ JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Do something' }] } }),
665
+ ].join('\n') + '\n', 'utf-8');
666
+
667
+ const originalHome = process.env.HOME;
668
+ process.env.HOME = tempHome;
669
+ try {
670
+ const matching = await listCodexSessions('/specific-project');
671
+ assert.ok(matching.length >= 1);
672
+
673
+ const nonMatching = await listCodexSessions('/other-project');
674
+ assert.equal(nonMatching.length, 0);
675
+ } finally {
676
+ process.env.HOME = originalHome;
677
+ await rm(tempHome, { recursive: true, force: true });
678
+ }
679
+ });
680
+
681
+ test('listSessions returns both claude and codex sessions when no agent filter', async () => {
682
+ const { listSessions: listAllSessions } = await import('./scanner.js');
683
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-home-'));
684
+
685
+ // Create claude session
686
+ const claudeDir = join(tempHome, '.claude', 'projects', '-repo');
687
+ await mkdir(claudeDir, { recursive: true });
688
+ await writeFile(join(claudeDir, 'claude-sess.jsonl'), JSON.stringify({
689
+ type: 'user', cwd: '/repo', sessionId: 'claude-sess',
690
+ message: { role: 'user', content: 'Hello' },
691
+ }) + '\n', 'utf-8');
692
+
693
+ // Create codex session
694
+ const codexDir = join(tempHome, '.codex', 'sessions', '2026', '03', '20');
695
+ await mkdir(codexDir, { recursive: true });
696
+ await writeFile(join(codexDir, 'rollout-019d0000-0000-0000-0000-000000000000.jsonl'), [
697
+ JSON.stringify({ type: 'session_meta', payload: { id: '019d0000-0000-0000-0000-000000000000', cwd: '/repo' } }),
698
+ JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Hi' }] } }),
699
+ ].join('\n') + '\n', 'utf-8');
700
+
701
+ const originalHome = process.env.HOME;
702
+ process.env.HOME = tempHome;
703
+ try {
704
+ const all = await listAllSessions();
705
+ const agents = new Set(all.map(s => s.agent));
706
+ assert.ok(agents.has('claude'));
707
+ assert.ok(agents.has('codex'));
708
+ assert.ok(all.length >= 2);
709
+ } finally {
710
+ process.env.HOME = originalHome;
711
+ await rm(tempHome, { recursive: true, force: true });
712
+ }
713
+ });
714
+
715
+ test('listClaudeSessions returns empty when .claude/projects does not exist', async () => {
716
+ const { listClaudeSessions } = await import('./scanner.js');
717
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-home-'));
718
+ // Don't create .claude/projects
719
+
720
+ const originalHome = process.env.HOME;
721
+ process.env.HOME = tempHome;
722
+ try {
723
+ const sessions = await listClaudeSessions();
724
+ assert.deepEqual(sessions, []);
725
+ } finally {
726
+ process.env.HOME = originalHome;
727
+ await rm(tempHome, { recursive: true, force: true });
728
+ }
729
+ });
730
+
731
+ test('listCodexSessions returns empty when .codex/sessions does not exist', async () => {
732
+ const { listCodexSessions } = await import('./scanner.js');
733
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-home-'));
734
+
735
+ const originalHome = process.env.HOME;
736
+ process.env.HOME = tempHome;
737
+ try {
738
+ const sessions = await listCodexSessions();
739
+ assert.deepEqual(sessions, []);
740
+ } finally {
741
+ process.env.HOME = originalHome;
742
+ await rm(tempHome, { recursive: true, force: true });
743
+ }
744
+ });
745
+
746
+ // ===== readSessionHistory with temp files =====
747
+
748
+ test('readSessionHistory reads claude history from temp project dir', async () => {
749
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-hist-'));
750
+ // encodeCwd('/test') = '-test'
751
+ const projectDir = join(tempHome, '.claude', 'projects', '-test');
752
+ await mkdir(projectDir, { recursive: true });
753
+
754
+ await writeFile(join(projectDir, 'sess-hist-1.jsonl'), [
755
+ JSON.stringify({ type: 'user', cwd: '/test', sessionId: 'sess-hist-1', message: { role: 'user', content: 'Hello' } }),
756
+ JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Hi there' }] } }),
757
+ ].join('\n') + '\n', 'utf-8');
758
+
759
+ const originalHome = process.env.HOME;
760
+ process.env.HOME = tempHome;
761
+ try {
762
+ const history = await readSessionHistory('sess-hist-1', 'claude', '/test');
763
+ assert.equal(history.length, 2);
764
+ assert.equal(history[0].role, 'user');
765
+ assert.equal(history[0].content, 'Hello');
766
+ assert.equal(history[1].role, 'assistant');
767
+ assert.equal(history[1].content, 'Hi there');
768
+ } finally {
769
+ process.env.HOME = originalHome;
770
+ await rm(tempHome, { recursive: true, force: true });
771
+ }
772
+ });
773
+
774
+ test('readSessionHistory filters Claude internal task notifications from history', async () => {
775
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-hist-'));
776
+ const projectDir = join(tempHome, '.claude', 'projects', '-test');
777
+ await mkdir(projectDir, { recursive: true });
778
+
779
+ await writeFile(join(projectDir, 'sess-hist-2.jsonl'), [
780
+ JSON.stringify({
781
+ type: 'user',
782
+ cwd: '/test',
783
+ sessionId: 'sess-hist-2',
784
+ message: {
785
+ role: 'user',
786
+ content: '<task-notification>\n<task-id>abc123</task-id>\n<status>failed</status>\n</task-notification>',
787
+ },
788
+ }),
789
+ JSON.stringify({ type: 'user', cwd: '/test', sessionId: 'sess-hist-2', message: { role: 'user', content: '继续' } }),
790
+ JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: '好的,我继续。' }] } }),
791
+ ].join('\n') + '\n', 'utf-8');
792
+
793
+ const originalHome = process.env.HOME;
794
+ process.env.HOME = tempHome;
795
+ try {
796
+ const history = await readSessionHistory('sess-hist-2', 'claude', '/test');
797
+ assert.deepEqual(history, [
798
+ { role: 'user', content: '继续' },
799
+ { role: 'assistant', content: '好的,我继续。' },
800
+ ]);
801
+ } finally {
802
+ process.env.HOME = originalHome;
803
+ await rm(tempHome, { recursive: true, force: true });
804
+ }
805
+ });
806
+
807
+ test('readSessionHistory reads codex history from temp sessions dir', async () => {
808
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-hist-'));
809
+ const dayDir = join(tempHome, '.codex', 'sessions', '2026', '03', '20');
810
+ await mkdir(dayDir, { recursive: true });
811
+
812
+ const sessionId = '019d0000-1111-2222-3333-444444444444';
813
+ await writeFile(join(dayDir, `rollout-${sessionId}.jsonl`), [
814
+ JSON.stringify({ type: 'session_meta', payload: { id: sessionId, cwd: '/repo' } }),
815
+ JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: '# AGENTS.md instructions for /repo' }] } }),
816
+ JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Fix bug' }] } }),
817
+ JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Inspecting the repo' }] } }),
818
+ JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Done' }] } }),
819
+ JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Ship it' }] } }),
820
+ JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Running tests' }] } }),
821
+ JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Shipped' }] } }),
822
+ ].join('\n') + '\n', 'utf-8');
823
+
824
+ const originalHome = process.env.HOME;
825
+ process.env.HOME = tempHome;
826
+ try {
827
+ const history = await readSessionHistory(sessionId, 'codex');
828
+ assert.deepEqual(history, [
829
+ { role: 'user', content: 'Fix bug' },
830
+ { role: 'assistant', content: 'Done' },
831
+ { role: 'user', content: 'Ship it' },
832
+ { role: 'assistant', content: 'Shipped' },
833
+ ]);
834
+ } finally {
835
+ process.env.HOME = originalHome;
836
+ await rm(tempHome, { recursive: true, force: true });
837
+ }
838
+ });
839
+
840
+ test('readSessionHistory falls back to other dirs when cwd dir has no file', async () => {
841
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-hist-'));
842
+ // Session file exists under a different project dir, not the one matching cwd
843
+ const otherDir = join(tempHome, '.claude', 'projects', '-other-project');
844
+ await mkdir(otherDir, { recursive: true });
845
+
846
+ await writeFile(join(otherDir, 'sess-fallback.jsonl'), [
847
+ JSON.stringify({ type: 'user', cwd: '/other-project', sessionId: 'sess-fallback', message: { role: 'user', content: 'Found me' } }),
848
+ JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Yes' }] } }),
849
+ ].join('\n') + '\n', 'utf-8');
850
+
851
+ const originalHome = process.env.HOME;
852
+ process.env.HOME = tempHome;
853
+ try {
854
+ // Pass cwd='/wrong' which encodes to '-wrong' — no file there, should fallback
855
+ const history = await readSessionHistory('sess-fallback', 'claude', '/wrong');
856
+ assert.equal(history.length, 2);
857
+ assert.equal(history[0].role, 'user');
858
+ assert.equal(history[0].content, 'Found me');
859
+ assert.equal(history[1].role, 'assistant');
860
+ assert.equal(history[1].content, 'Yes');
861
+ } finally {
862
+ process.env.HOME = originalHome;
863
+ await rm(tempHome, { recursive: true, force: true });
864
+ }
865
+ });
866
+
867
+ test('readSessionHistory prefers cwd dir over fallback', async () => {
868
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-hist-'));
869
+ // Create files in both the cwd dir and another dir
870
+ const cwdDir = join(tempHome, '.claude', 'projects', '-correct');
871
+ const otherDir = join(tempHome, '.claude', 'projects', '-other');
872
+ await mkdir(cwdDir, { recursive: true });
873
+ await mkdir(otherDir, { recursive: true });
874
+
875
+ await writeFile(join(cwdDir, 'sess-prefer.jsonl'), [
876
+ JSON.stringify({ type: 'user', cwd: '/correct', sessionId: 'sess-prefer', message: { role: 'user', content: 'From correct dir' } }),
877
+ ].join('\n') + '\n', 'utf-8');
878
+
879
+ await writeFile(join(otherDir, 'sess-prefer.jsonl'), [
880
+ JSON.stringify({ type: 'user', cwd: '/other', sessionId: 'sess-prefer', message: { role: 'user', content: 'From other dir' } }),
881
+ ].join('\n') + '\n', 'utf-8');
882
+
883
+ const originalHome = process.env.HOME;
884
+ process.env.HOME = tempHome;
885
+ try {
886
+ const history = await readSessionHistory('sess-prefer', 'claude', '/correct');
887
+ assert.equal(history.length, 1);
888
+ assert.equal(history[0].content, 'From correct dir');
889
+ } finally {
890
+ process.env.HOME = originalHome;
891
+ await rm(tempHome, { recursive: true, force: true });
892
+ }
893
+ });
894
+
895
+ test('readSessionRuntimeHints marks Claude sessions as responding while assistant text is still in-flight', async () => {
896
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-runtime-'));
897
+ const projectDir = join(tempHome, '.claude', 'projects', '-test');
898
+ await mkdir(projectDir, { recursive: true });
899
+
900
+ await writeFile(join(projectDir, 'sess-runtime-active.jsonl'), [
901
+ JSON.stringify({ type: 'user', cwd: '/test', sessionId: 'sess-runtime-active', message: { role: 'user', content: 'Check this file' } }),
902
+ JSON.stringify({
903
+ type: 'assistant',
904
+ message: {
905
+ role: 'assistant',
906
+ stop_reason: null,
907
+ content: [{ type: 'text', text: 'Still checking the file' }],
908
+ },
909
+ }),
910
+ ].join('\n') + '\n', 'utf-8');
911
+
912
+ const originalHome = process.env.HOME;
913
+ process.env.HOME = tempHome;
914
+ try {
915
+ const hints = await readSessionRuntimeHints('sess-runtime-active', 'claude', '/test');
916
+ assert.deepEqual(hints, {
917
+ isResponding: true,
918
+ partialReplyText: 'Still checking the file',
919
+ });
920
+ } finally {
921
+ process.env.HOME = originalHome;
922
+ await rm(tempHome, { recursive: true, force: true });
923
+ }
924
+ });
925
+
926
+ test('readSessionRuntimeHints keeps Claude sessions responding after a tool result', async () => {
927
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-runtime-'));
928
+ const projectDir = join(tempHome, '.claude', 'projects', '-test');
929
+ await mkdir(projectDir, { recursive: true });
930
+
931
+ await writeFile(join(projectDir, 'sess-runtime-tool.jsonl'), [
932
+ JSON.stringify({ type: 'user', cwd: '/test', sessionId: 'sess-runtime-tool', message: { role: 'user', content: 'Run the checks' } }),
933
+ JSON.stringify({
934
+ type: 'assistant',
935
+ message: {
936
+ role: 'assistant',
937
+ stop_reason: 'tool_use',
938
+ content: [{ type: 'text', text: 'Running checks now' }, { type: 'tool_use', id: 'tool-1', name: 'Bash', input: { cmd: 'npm test' } }],
939
+ },
940
+ }),
941
+ JSON.stringify({
942
+ type: 'user',
943
+ cwd: '/test',
944
+ sessionId: 'sess-runtime-tool',
945
+ message: {
946
+ role: 'user',
947
+ content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' }],
948
+ },
949
+ }),
950
+ ].join('\n') + '\n', 'utf-8');
951
+
952
+ const originalHome = process.env.HOME;
953
+ process.env.HOME = tempHome;
954
+ try {
955
+ const hints = await readSessionRuntimeHints('sess-runtime-tool', 'claude', '/test');
956
+ assert.deepEqual(hints, { isResponding: true });
957
+ } finally {
958
+ process.env.HOME = originalHome;
959
+ await rm(tempHome, { recursive: true, force: true });
960
+ }
961
+ });
962
+
963
+ test('readSessionRuntimeHints ignores trailing system events after a completed Claude turn', async () => {
964
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-runtime-'));
965
+ const projectDir = join(tempHome, '.claude', 'projects', '-test');
966
+ await mkdir(projectDir, { recursive: true });
967
+
968
+ await writeFile(join(projectDir, 'sess-runtime-done.jsonl'), [
969
+ JSON.stringify({ type: 'user', cwd: '/test', sessionId: 'sess-runtime-done', message: { role: 'user', content: 'Summarize the diff' } }),
970
+ JSON.stringify({
971
+ type: 'assistant',
972
+ message: {
973
+ role: 'assistant',
974
+ stop_reason: 'end_turn',
975
+ content: [{ type: 'text', text: 'Summary complete' }],
976
+ },
977
+ }),
978
+ JSON.stringify({ type: 'system', subtype: 'permission', message: 'post-turn metadata' }),
979
+ ].join('\n') + '\n', 'utf-8');
980
+
981
+ const originalHome = process.env.HOME;
982
+ process.env.HOME = tempHome;
983
+ try {
984
+ const hints = await readSessionRuntimeHints('sess-runtime-done', 'claude', '/test');
985
+ assert.deepEqual(hints, {});
986
+ } finally {
987
+ process.env.HOME = originalHome;
988
+ await rm(tempHome, { recursive: true, force: true });
989
+ }
990
+ });
991
+
992
+ // ===== searchSessionContent with codex =====
993
+
994
+ test('searchSessionContent searches codex session files', async () => {
995
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-search-'));
996
+ const dayDir = join(tempHome, '.codex', 'sessions', '2026', '03', '20');
997
+ await mkdir(dayDir, { recursive: true });
998
+
999
+ const sessionId = '019d0000-aaaa-bbbb-cccc-dddddddddddd';
1000
+ await writeFile(join(dayDir, `rollout-${sessionId}.jsonl`), [
1001
+ JSON.stringify({ type: 'session_meta', payload: { id: sessionId, cwd: '/repo' } }),
1002
+ JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'unique_codex_search_term_xyz' }] } }),
1003
+ ].join('\n') + '\n', 'utf-8');
1004
+
1005
+ const originalHome = process.env.HOME;
1006
+ process.env.HOME = tempHome;
1007
+ try {
1008
+ const results = await searchSessionContent('unique_codex_search_term_xyz', 'codex');
1009
+ assert.ok(results.has(sessionId));
1010
+ } finally {
1011
+ process.env.HOME = originalHome;
1012
+ await rm(tempHome, { recursive: true, force: true });
1013
+ }
1014
+ });
1015
+
1016
+ test('searchSessionContent searches both agents when no agent filter', async () => {
1017
+ const tempHome = await mkdtemp(join(tmpdir(), 'vibe-search-'));
1018
+
1019
+ // Claude session
1020
+ const claudeDir = join(tempHome, '.claude', 'projects', '-repo');
1021
+ await mkdir(claudeDir, { recursive: true });
1022
+ await writeFile(join(claudeDir, 'claude-sess.jsonl'), [
1023
+ JSON.stringify({ type: 'user', cwd: '/repo', sessionId: 'claude-sess', message: { role: 'user', content: 'shared_search_keyword_abc' } }),
1024
+ ].join('\n') + '\n', 'utf-8');
1025
+
1026
+ // Codex session
1027
+ const codexDir = join(tempHome, '.codex', 'sessions', '2026', '03', '20');
1028
+ await mkdir(codexDir, { recursive: true });
1029
+ const codexId = '019d0000-0000-0000-0000-eeeeeeeeeeee';
1030
+ await writeFile(join(codexDir, `rollout-${codexId}.jsonl`), [
1031
+ JSON.stringify({ type: 'session_meta', payload: { id: codexId, cwd: '/repo' } }),
1032
+ JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'shared_search_keyword_abc' }] } }),
1033
+ ].join('\n') + '\n', 'utf-8');
1034
+
1035
+ const originalHome = process.env.HOME;
1036
+ process.env.HOME = tempHome;
1037
+ try {
1038
+ const results = await searchSessionContent('shared_search_keyword_abc');
1039
+ assert.ok(results.has('claude-sess'));
1040
+ assert.ok(results.has(codexId));
1041
+ } finally {
1042
+ process.env.HOME = originalHome;
1043
+ await rm(tempHome, { recursive: true, force: true });
1044
+ }
1045
+ });