@vibelet/cli 0.1.34 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/app.json +5 -0
  2. package/dist/advertised-hosts.d.ts +34 -0
  3. package/dist/advertised-hosts.d.ts.map +1 -0
  4. package/dist/advertised-hosts.js +176 -0
  5. package/dist/advertised-hosts.js.map +1 -0
  6. package/dist/advertised-hosts.test.d.ts +2 -0
  7. package/dist/advertised-hosts.test.d.ts.map +1 -0
  8. package/dist/advertised-hosts.test.js +96 -0
  9. package/dist/advertised-hosts.test.js.map +1 -0
  10. package/dist/audit.d.ts +30 -0
  11. package/dist/audit.d.ts.map +1 -0
  12. package/dist/audit.js +73 -0
  13. package/dist/audit.js.map +1 -0
  14. package/dist/audit.test.d.ts +2 -0
  15. package/dist/audit.test.d.ts.map +1 -0
  16. package/dist/audit.test.js +33 -0
  17. package/dist/audit.test.js.map +1 -0
  18. package/dist/auth.d.ts +6 -0
  19. package/dist/auth.d.ts.map +1 -0
  20. package/dist/auth.js +27 -0
  21. package/dist/auth.js.map +1 -0
  22. package/dist/claude-hooks.d.ts +58 -0
  23. package/dist/claude-hooks.d.ts.map +1 -0
  24. package/dist/claude-hooks.js +129 -0
  25. package/dist/claude-hooks.js.map +1 -0
  26. package/dist/cli-version.d.ts +3 -0
  27. package/dist/cli-version.d.ts.map +1 -0
  28. package/dist/cli-version.js +35 -0
  29. package/dist/cli-version.js.map +1 -0
  30. package/dist/cli-version.test.d.ts +2 -0
  31. package/dist/cli-version.test.d.ts.map +1 -0
  32. package/dist/cli-version.test.js +38 -0
  33. package/dist/cli-version.test.js.map +1 -0
  34. package/dist/config.d.ts +30 -0
  35. package/dist/config.d.ts.map +1 -0
  36. package/dist/config.js +327 -0
  37. package/dist/config.js.map +1 -0
  38. package/dist/config.test.d.ts +2 -0
  39. package/dist/config.test.d.ts.map +1 -0
  40. package/dist/config.test.js +184 -0
  41. package/dist/config.test.js.map +1 -0
  42. package/dist/dev-auth.test.d.ts +2 -0
  43. package/dist/dev-auth.test.d.ts.map +1 -0
  44. package/dist/dev-auth.test.js +154 -0
  45. package/dist/dev-auth.test.js.map +1 -0
  46. package/dist/dev-script.test.d.ts +2 -0
  47. package/dist/dev-script.test.d.ts.map +1 -0
  48. package/dist/dev-script.test.js +412 -0
  49. package/dist/dev-script.test.js.map +1 -0
  50. package/dist/drivers/claude.d.ts +34 -0
  51. package/dist/drivers/claude.d.ts.map +1 -0
  52. package/dist/drivers/claude.js +413 -0
  53. package/dist/drivers/claude.js.map +1 -0
  54. package/dist/drivers/claude.test.d.ts +2 -0
  55. package/dist/drivers/claude.test.d.ts.map +1 -0
  56. package/dist/drivers/claude.test.js +951 -0
  57. package/dist/drivers/claude.test.js.map +1 -0
  58. package/dist/drivers/codex.d.ts +38 -0
  59. package/dist/drivers/codex.d.ts.map +1 -0
  60. package/dist/drivers/codex.js +771 -0
  61. package/dist/drivers/codex.js.map +1 -0
  62. package/dist/drivers/codex.test.d.ts +2 -0
  63. package/dist/drivers/codex.test.d.ts.map +1 -0
  64. package/dist/drivers/codex.test.js +939 -0
  65. package/dist/drivers/codex.test.js.map +1 -0
  66. package/dist/drivers/types.d.ts +14 -0
  67. package/dist/drivers/types.d.ts.map +1 -0
  68. package/dist/drivers/types.js +2 -0
  69. package/dist/drivers/types.js.map +1 -0
  70. package/dist/e2e.test.d.ts +2 -0
  71. package/dist/e2e.test.d.ts.map +1 -0
  72. package/dist/e2e.test.js +111 -0
  73. package/dist/e2e.test.js.map +1 -0
  74. package/dist/identity.d.ts +10 -0
  75. package/dist/identity.d.ts.map +1 -0
  76. package/dist/identity.js +66 -0
  77. package/dist/identity.js.map +1 -0
  78. package/dist/identity.test.d.ts +2 -0
  79. package/dist/identity.test.d.ts.map +1 -0
  80. package/dist/identity.test.js +25 -0
  81. package/dist/identity.test.js.map +1 -0
  82. package/dist/index-entry.test.d.ts +2 -0
  83. package/dist/index-entry.test.d.ts.map +1 -0
  84. package/dist/index-entry.test.js +272 -0
  85. package/dist/index-entry.test.js.map +1 -0
  86. package/dist/index.d.ts +2 -0
  87. package/dist/index.d.ts.map +1 -0
  88. package/dist/index.js +707 -0
  89. package/dist/index.js.map +1 -0
  90. package/dist/logger.d.ts +31 -0
  91. package/dist/logger.d.ts.map +1 -0
  92. package/dist/logger.js +75 -0
  93. package/dist/logger.js.map +1 -0
  94. package/dist/metrics.d.ts +52 -0
  95. package/dist/metrics.d.ts.map +1 -0
  96. package/dist/metrics.js +89 -0
  97. package/dist/metrics.js.map +1 -0
  98. package/dist/pairing-store.d.ts +29 -0
  99. package/dist/pairing-store.d.ts.map +1 -0
  100. package/dist/pairing-store.js +131 -0
  101. package/dist/pairing-store.js.map +1 -0
  102. package/dist/pairing-store.test.d.ts +2 -0
  103. package/dist/pairing-store.test.d.ts.map +1 -0
  104. package/dist/pairing-store.test.js +47 -0
  105. package/dist/pairing-store.test.js.map +1 -0
  106. package/dist/paths.d.ts +16 -0
  107. package/dist/paths.d.ts.map +1 -0
  108. package/dist/paths.js +18 -0
  109. package/dist/paths.js.map +1 -0
  110. package/dist/perf-compare.d.ts +13 -0
  111. package/dist/perf-compare.d.ts.map +1 -0
  112. package/dist/perf-compare.js +125 -0
  113. package/dist/perf-compare.js.map +1 -0
  114. package/dist/port-conflict.d.ts +9 -0
  115. package/dist/port-conflict.d.ts.map +1 -0
  116. package/dist/port-conflict.js +33 -0
  117. package/dist/port-conflict.js.map +1 -0
  118. package/dist/port-conflict.test.d.ts +2 -0
  119. package/dist/port-conflict.test.d.ts.map +1 -0
  120. package/dist/port-conflict.test.js +38 -0
  121. package/dist/port-conflict.test.js.map +1 -0
  122. package/dist/process-scanner.d.ts +43 -0
  123. package/dist/process-scanner.d.ts.map +1 -0
  124. package/dist/process-scanner.js +453 -0
  125. package/dist/process-scanner.js.map +1 -0
  126. package/dist/process-scanner.perf.test.d.ts +2 -0
  127. package/dist/process-scanner.perf.test.d.ts.map +1 -0
  128. package/dist/process-scanner.perf.test.js +186 -0
  129. package/dist/process-scanner.perf.test.js.map +1 -0
  130. package/dist/process-scanner.test.d.ts +2 -0
  131. package/dist/process-scanner.test.d.ts.map +1 -0
  132. package/dist/process-scanner.test.js +399 -0
  133. package/dist/process-scanner.test.js.map +1 -0
  134. package/dist/push-protocol.d.ts +15 -0
  135. package/dist/push-protocol.d.ts.map +1 -0
  136. package/dist/push-protocol.js +23 -0
  137. package/dist/push-protocol.js.map +1 -0
  138. package/dist/push-protocol.test.d.ts +2 -0
  139. package/dist/push-protocol.test.d.ts.map +1 -0
  140. package/dist/push-protocol.test.js +57 -0
  141. package/dist/push-protocol.test.js.map +1 -0
  142. package/dist/push-store.d.ts +22 -0
  143. package/dist/push-store.d.ts.map +1 -0
  144. package/dist/push-store.js +103 -0
  145. package/dist/push-store.js.map +1 -0
  146. package/dist/push-store.test.d.ts +2 -0
  147. package/dist/push-store.test.d.ts.map +1 -0
  148. package/dist/push-store.test.js +79 -0
  149. package/dist/push-store.test.js.map +1 -0
  150. package/dist/push.d.ts +65 -0
  151. package/dist/push.d.ts.map +1 -0
  152. package/dist/push.js +202 -0
  153. package/dist/push.js.map +1 -0
  154. package/dist/push.test.d.ts +2 -0
  155. package/dist/push.test.d.ts.map +1 -0
  156. package/dist/push.test.js +199 -0
  157. package/dist/push.test.js.map +1 -0
  158. package/dist/safe-stdio.d.ts +3 -0
  159. package/dist/safe-stdio.d.ts.map +1 -0
  160. package/dist/safe-stdio.js +46 -0
  161. package/dist/safe-stdio.js.map +1 -0
  162. package/dist/scanner.d.ts +30 -0
  163. package/dist/scanner.d.ts.map +1 -0
  164. package/dist/scanner.js +859 -0
  165. package/dist/scanner.js.map +1 -0
  166. package/dist/scanner.perf.test.d.ts +2 -0
  167. package/dist/scanner.perf.test.d.ts.map +1 -0
  168. package/dist/scanner.perf.test.js +320 -0
  169. package/dist/scanner.perf.test.js.map +1 -0
  170. package/dist/scanner.test.d.ts +2 -0
  171. package/dist/scanner.test.d.ts.map +1 -0
  172. package/dist/scanner.test.js +948 -0
  173. package/dist/scanner.test.js.map +1 -0
  174. package/dist/session-inventory.d.ts +63 -0
  175. package/dist/session-inventory.d.ts.map +1 -0
  176. package/dist/session-inventory.js +525 -0
  177. package/dist/session-inventory.js.map +1 -0
  178. package/dist/session-inventory.perf.test.d.ts +2 -0
  179. package/dist/session-inventory.perf.test.d.ts.map +1 -0
  180. package/dist/session-inventory.perf.test.js +220 -0
  181. package/dist/session-inventory.perf.test.js.map +1 -0
  182. package/dist/session-inventory.test.d.ts +2 -0
  183. package/dist/session-inventory.test.d.ts.map +1 -0
  184. package/dist/session-inventory.test.js +712 -0
  185. package/dist/session-inventory.test.js.map +1 -0
  186. package/dist/session-manager.d.ts +75 -0
  187. package/dist/session-manager.d.ts.map +1 -0
  188. package/dist/session-manager.js +1515 -0
  189. package/dist/session-manager.js.map +1 -0
  190. package/dist/session-manager.test.d.ts +2 -0
  191. package/dist/session-manager.test.d.ts.map +1 -0
  192. package/dist/session-manager.test.js +2861 -0
  193. package/dist/session-manager.test.js.map +1 -0
  194. package/dist/session-store.d.ts +42 -0
  195. package/dist/session-store.d.ts.map +1 -0
  196. package/dist/session-store.js +163 -0
  197. package/dist/session-store.js.map +1 -0
  198. package/dist/session-store.test.d.ts +2 -0
  199. package/dist/session-store.test.d.ts.map +1 -0
  200. package/dist/session-store.test.js +236 -0
  201. package/dist/session-store.test.js.map +1 -0
  202. package/dist/session-title.d.ts +6 -0
  203. package/dist/session-title.d.ts.map +1 -0
  204. package/dist/session-title.js +105 -0
  205. package/dist/session-title.js.map +1 -0
  206. package/dist/session-title.perf.test.d.ts +2 -0
  207. package/dist/session-title.perf.test.d.ts.map +1 -0
  208. package/dist/session-title.perf.test.js +99 -0
  209. package/dist/session-title.perf.test.js.map +1 -0
  210. package/dist/session-title.test.d.ts +2 -0
  211. package/dist/session-title.test.d.ts.map +1 -0
  212. package/dist/session-title.test.js +199 -0
  213. package/dist/session-title.test.js.map +1 -0
  214. package/dist/shutdown-endpoint.test.d.ts +2 -0
  215. package/dist/shutdown-endpoint.test.d.ts.map +1 -0
  216. package/dist/shutdown-endpoint.test.js +93 -0
  217. package/dist/shutdown-endpoint.test.js.map +1 -0
  218. package/dist/storage-housekeeping.d.ts +28 -0
  219. package/dist/storage-housekeeping.d.ts.map +1 -0
  220. package/dist/storage-housekeeping.js +76 -0
  221. package/dist/storage-housekeeping.js.map +1 -0
  222. package/dist/storage-housekeeping.test.d.ts +2 -0
  223. package/dist/storage-housekeeping.test.d.ts.map +1 -0
  224. package/dist/storage-housekeeping.test.js +65 -0
  225. package/dist/storage-housekeeping.test.js.map +1 -0
  226. package/dist/test-daemon-harness.d.ts +31 -0
  227. package/dist/test-daemon-harness.d.ts.map +1 -0
  228. package/dist/test-daemon-harness.js +337 -0
  229. package/dist/test-daemon-harness.js.map +1 -0
  230. package/dist/token-auth.test.d.ts +2 -0
  231. package/dist/token-auth.test.d.ts.map +1 -0
  232. package/dist/token-auth.test.js +52 -0
  233. package/dist/token-auth.test.js.map +1 -0
  234. package/dist/utils.d.ts +4 -0
  235. package/dist/utils.d.ts.map +1 -0
  236. package/dist/utils.js +40 -0
  237. package/dist/utils.js.map +1 -0
  238. package/dist/utils.test.d.ts +2 -0
  239. package/dist/utils.test.d.ts.map +1 -0
  240. package/dist/utils.test.js +54 -0
  241. package/dist/utils.test.js.map +1 -0
  242. package/dist/ws-data.d.ts +4 -0
  243. package/dist/ws-data.d.ts.map +1 -0
  244. package/dist/ws-data.js +20 -0
  245. package/dist/ws-data.js.map +1 -0
  246. package/dist/ws-data.test.d.ts +2 -0
  247. package/dist/ws-data.test.d.ts.map +1 -0
  248. package/dist/ws-data.test.js +17 -0
  249. package/dist/ws-data.test.js.map +1 -0
  250. package/package.json +24 -27
  251. package/perf-reporter.mjs +138 -0
  252. package/scripts/build-release.mjs +41 -0
  253. package/scripts/dev.mjs +537 -0
  254. package/src/advertised-hosts.test.ts +125 -0
  255. package/src/advertised-hosts.ts +225 -0
  256. package/src/audit.test.ts +38 -0
  257. package/src/audit.ts +117 -0
  258. package/src/auth.ts +31 -0
  259. package/src/claude-hooks.ts +195 -0
  260. package/src/cli-version.test.ts +36 -0
  261. package/src/cli-version.ts +46 -0
  262. package/src/config.test.ts +254 -0
  263. package/src/config.ts +324 -0
  264. package/src/dev-auth.test.ts +183 -0
  265. package/src/dev-script.test.ts +511 -0
  266. package/src/drivers/claude.test.ts +1186 -0
  267. package/src/drivers/claude.ts +443 -0
  268. package/src/drivers/codex.test.ts +1096 -0
  269. package/src/drivers/codex.ts +879 -0
  270. package/src/drivers/types.ts +15 -0
  271. package/src/e2e.test.ts +139 -0
  272. package/src/identity.test.ts +26 -0
  273. package/src/identity.ts +82 -0
  274. package/src/index-entry.test.ts +336 -0
  275. package/src/index.ts +781 -0
  276. package/src/logger.ts +112 -0
  277. package/src/metrics.ts +117 -0
  278. package/src/pairing-store.test.ts +53 -0
  279. package/src/pairing-store.ts +154 -0
  280. package/src/paths.ts +19 -0
  281. package/src/perf-compare.ts +164 -0
  282. package/src/port-conflict.test.ts +45 -0
  283. package/src/port-conflict.ts +44 -0
  284. package/src/process-scanner.perf.test.ts +222 -0
  285. package/src/process-scanner.test.ts +575 -0
  286. package/src/process-scanner.ts +514 -0
  287. package/src/push-protocol.test.ts +74 -0
  288. package/src/push-protocol.ts +36 -0
  289. package/src/push-store.test.ts +89 -0
  290. package/src/push-store.ts +126 -0
  291. package/src/push.test.ts +234 -0
  292. package/src/push.ts +318 -0
  293. package/src/safe-stdio.ts +51 -0
  294. package/src/scanner.perf.test.ts +359 -0
  295. package/src/scanner.test.ts +1045 -0
  296. package/src/scanner.ts +924 -0
  297. package/src/session-inventory.perf.test.ts +250 -0
  298. package/src/session-inventory.test.ts +1002 -0
  299. package/src/session-inventory.ts +721 -0
  300. package/src/session-manager.test.ts +3430 -0
  301. package/src/session-manager.ts +1775 -0
  302. package/src/session-store.test.ts +276 -0
  303. package/src/session-store.ts +202 -0
  304. package/src/session-title.perf.test.ts +118 -0
  305. package/src/session-title.test.ts +286 -0
  306. package/src/session-title.ts +108 -0
  307. package/src/shutdown-endpoint.test.ts +95 -0
  308. package/src/storage-housekeeping.test.ts +78 -0
  309. package/src/storage-housekeeping.ts +111 -0
  310. package/src/test-daemon-harness.ts +410 -0
  311. package/src/token-auth.test.ts +67 -0
  312. package/src/utils.test.ts +65 -0
  313. package/src/utils.ts +47 -0
  314. package/src/ws-data.test.ts +20 -0
  315. package/src/ws-data.ts +26 -0
  316. package/tsconfig.json +12 -0
  317. package/README.md +0 -80
  318. package/bin/cloudflared-quick-tunnel.mjs +0 -11
  319. package/bin/cloudflared-resolver.mjs +0 -68
  320. package/bin/vibelet-runtime-policy.mjs +0 -36
  321. package/bin/vibelet.cjs +0 -12
  322. package/bin/vibelet.mjs +0 -1019
  323. package/dist/index.cjs +0 -123
@@ -0,0 +1,1002 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import type { SessionInfo } from '@vibelet/shared';
4
+ import {
5
+ __resetExternalInventoryStateForTests,
6
+ __waitForExternalInventoryRefreshForTests,
7
+ buildSessionInventoryPage,
8
+ getExternalInventoryHealth,
9
+ listSessionPage,
10
+ onExternalInventoryBackfill,
11
+ type InventorySources,
12
+ } from './session-inventory.js';
13
+ import type { RunningSessionCandidate } from './process-scanner.js';
14
+ import { metrics } from './metrics.js';
15
+
16
+ function createDeferred<T>() {
17
+ let resolve!: (value: T | PromiseLike<T>) => void;
18
+ let reject!: (reason?: unknown) => void;
19
+ const promise = new Promise<T>((res, rej) => {
20
+ resolve = res;
21
+ reject = rej;
22
+ });
23
+ return { promise, resolve, reject };
24
+ }
25
+
26
+ function idleSession(overrides: Partial<SessionInfo>): SessionInfo {
27
+ return {
28
+ sessionId: 'session-1',
29
+ agent: 'codex',
30
+ cwd: '/repo',
31
+ title: 'Session',
32
+ createdAt: '2026-03-01T00:00:00.000Z',
33
+ lastActivityAt: '2026-03-20T00:00:00.000Z',
34
+ sources: ['scanner'],
35
+ runtime: {
36
+ state: 'idle',
37
+ confidence: 'high',
38
+ resumeMode: 'resumeSession',
39
+ },
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ function sources(overrides: Partial<InventorySources>): InventorySources {
45
+ return {
46
+ activeSessions: [],
47
+ sessionRecords: [],
48
+ scannedSessions: [],
49
+ runningSessions: [],
50
+ scannedAt: '2026-03-20T12:00:00.000Z',
51
+ deletedSessionIds: new Set<string>(),
52
+ ...overrides,
53
+ };
54
+ }
55
+
56
+ test.beforeEach(() => {
57
+ __resetExternalInventoryStateForTests();
58
+ });
59
+
60
+ test('returns active sessions first and paginates only idle sessions', () => {
61
+ const page = buildSessionInventoryPage(
62
+ sources({
63
+ activeSessions: [
64
+ {
65
+ sessionId: 'active-1',
66
+ agent: 'codex',
67
+ cwd: '/repo',
68
+ title: 'Active',
69
+ createdAt: '2026-03-20T09:00:00.000Z',
70
+ lastActivityAt: '2026-03-20T12:00:00.000Z',
71
+ },
72
+ ],
73
+ scannedSessions: [
74
+ idleSession({
75
+ sessionId: 'idle-new',
76
+ title: 'Idle New',
77
+ lastActivityAt: '2026-03-20T11:00:00.000Z',
78
+ }),
79
+ idleSession({
80
+ sessionId: 'idle-old',
81
+ title: 'Idle Old',
82
+ lastActivityAt: '2026-03-19T11:00:00.000Z',
83
+ }),
84
+ ],
85
+ }),
86
+ 1,
87
+ );
88
+
89
+ assert.equal(page.sessions.length, 2);
90
+ assert.equal(page.sessions[0].sessionId, 'active-1');
91
+ assert.equal(page.sessions[0].runtime.state, 'daemonActive');
92
+ assert.equal(page.sessions[1].sessionId, 'idle-new');
93
+ assert.deepEqual(page.nextCursor, {
94
+ lastActivityAt: '2026-03-20T11:00:00.000Z',
95
+ sessionId: 'idle-new',
96
+ });
97
+ });
98
+
99
+ test('listSessionPage returns fast-path records before external backfill completes', async () => {
100
+ const scanned = createDeferred<SessionInfo[]>();
101
+ const running = createDeferred<RunningSessionCandidate[]>();
102
+
103
+ const page = await listSessionPage({
104
+ limit: 50,
105
+ activeSessions: [],
106
+ sessionRecords: [
107
+ {
108
+ sessionId: 'record-only',
109
+ agent: 'codex',
110
+ cwd: '/repo',
111
+ approvalMode: 'normal',
112
+ title: 'Record Only',
113
+ createdAt: '2026-03-01T00:00:00.000Z',
114
+ lastActivityAt: '2026-03-20T10:00:00.000Z',
115
+ },
116
+ ],
117
+ loaders: {
118
+ listScannedSessions: async () => scanned.promise,
119
+ scanRunningSessions: async () => running.promise,
120
+ },
121
+ });
122
+
123
+ assert.equal(page.sessions.length, 1);
124
+ assert.equal(page.sessions[0].sessionId, 'record-only');
125
+ assert.equal(page.sessions[0].runtime.state, 'idle');
126
+ assert.deepEqual(page.sessions[0].sources, ['record']);
127
+ assert.equal(getExternalInventoryHealth().backfillInFlight, true);
128
+
129
+ scanned.resolve([
130
+ idleSession({
131
+ sessionId: 'external-idle',
132
+ title: 'External Idle',
133
+ lastActivityAt: '2026-03-20T11:00:00.000Z',
134
+ }),
135
+ ]);
136
+ running.resolve([]);
137
+ await __waitForExternalInventoryRefreshForTests();
138
+ const health = getExternalInventoryHealth();
139
+ const metricsSnapshot = metrics.snapshot();
140
+
141
+ assert.equal(health.backfillInFlight, false);
142
+ assert.equal(health.cachedSessions, 1);
143
+ assert.equal(health.runningSessions, 0);
144
+ assert.equal(typeof health.lastBackfillDurationMs, 'number');
145
+ assert.equal(typeof health.lastBackfillCompletedAt, 'string');
146
+ assert.equal(typeof health.lastBackfillAppliedAt, 'string');
147
+ assert.ok(metricsSnapshot.timers['session.inventory.fast_path_latency_ms']);
148
+ assert.ok(metricsSnapshot.timers['session.inventory.backfill_latency_ms']);
149
+ assert.ok(metricsSnapshot.counters['session.inventory.backfill_applied']);
150
+
151
+ const refreshed = await listSessionPage({
152
+ limit: 50,
153
+ activeSessions: [],
154
+ sessionRecords: [
155
+ {
156
+ sessionId: 'record-only',
157
+ agent: 'codex',
158
+ cwd: '/repo',
159
+ approvalMode: 'normal',
160
+ title: 'Record Only',
161
+ createdAt: '2026-03-01T00:00:00.000Z',
162
+ lastActivityAt: '2026-03-20T10:00:00.000Z',
163
+ },
164
+ ],
165
+ });
166
+
167
+ assert.deepEqual(
168
+ refreshed.sessions.map((session) => session.sessionId),
169
+ ['external-idle', 'record-only'],
170
+ );
171
+ });
172
+
173
+ test('listSessionPage notifies listeners when external backfill changes inventory', async () => {
174
+ const scanned = createDeferred<SessionInfo[]>();
175
+ const running = createDeferred<RunningSessionCandidate[]>();
176
+ let notifications = 0;
177
+ const unsubscribe = onExternalInventoryBackfill(() => {
178
+ notifications += 1;
179
+ });
180
+
181
+ await listSessionPage({
182
+ limit: 50,
183
+ activeSessions: [],
184
+ sessionRecords: [],
185
+ loaders: {
186
+ listScannedSessions: async () => scanned.promise,
187
+ scanRunningSessions: async () => running.promise,
188
+ },
189
+ });
190
+
191
+ scanned.resolve([
192
+ idleSession({
193
+ sessionId: 'external-idle',
194
+ title: 'External Idle',
195
+ lastActivityAt: '2026-03-20T11:00:00.000Z',
196
+ }),
197
+ ]);
198
+ running.resolve([]);
199
+ await __waitForExternalInventoryRefreshForTests();
200
+ unsubscribe();
201
+
202
+ assert.equal(notifications, 1);
203
+ });
204
+
205
+ test('uses max lastActivityAt across sources and keeps later pages idle-only', () => {
206
+ const firstPage = buildSessionInventoryPage(
207
+ sources({
208
+ sessionRecords: [
209
+ {
210
+ sessionId: 'shared',
211
+ agent: 'codex',
212
+ cwd: '/repo',
213
+ approvalMode: 'normal',
214
+ title: 'Shared Record',
215
+ createdAt: '2026-03-01T00:00:00.000Z',
216
+ lastActivityAt: '2026-03-02T00:00:00.000Z',
217
+ },
218
+ ],
219
+ scannedSessions: [
220
+ idleSession({
221
+ sessionId: 'shared',
222
+ title: 'Shared Scanner',
223
+ lastActivityAt: '2026-03-20T10:00:00.000Z',
224
+ }),
225
+ idleSession({
226
+ sessionId: 'later-idle',
227
+ title: 'Later Idle',
228
+ lastActivityAt: '2026-03-19T10:00:00.000Z',
229
+ }),
230
+ ],
231
+ runningSessions: [
232
+ {
233
+ agent: 'claude',
234
+ pid: 9001,
235
+ cwd: '/repo',
236
+ command: 'claude --continue',
237
+ sessionId: 'claude-live',
238
+ confidence: 'medium',
239
+ isResponding: true,
240
+ },
241
+ ],
242
+ }),
243
+ 1,
244
+ );
245
+
246
+ assert.equal(firstPage.sessions[0].sessionId, 'claude-live');
247
+ assert.equal(firstPage.sessions[0].runtime.state, 'externalRunning');
248
+ assert.equal(firstPage.sessions[0].runtime.isResponding, true);
249
+ assert.equal(firstPage.sessions[1].sessionId, 'shared');
250
+ assert.equal(firstPage.sessions[1].lastActivityAt, '2026-03-20T10:00:00.000Z');
251
+ assert.deepEqual(firstPage.sessions[1].sources.sort(), ['record', 'scanner']);
252
+
253
+ const secondPage = buildSessionInventoryPage(
254
+ sources({
255
+ sessionRecords: [
256
+ {
257
+ sessionId: 'shared',
258
+ agent: 'codex',
259
+ cwd: '/repo',
260
+ approvalMode: 'normal',
261
+ title: 'Shared Record',
262
+ createdAt: '2026-03-01T00:00:00.000Z',
263
+ lastActivityAt: '2026-03-02T00:00:00.000Z',
264
+ },
265
+ ],
266
+ scannedSessions: [
267
+ idleSession({
268
+ sessionId: 'shared',
269
+ title: 'Shared Scanner',
270
+ lastActivityAt: '2026-03-20T10:00:00.000Z',
271
+ }),
272
+ idleSession({
273
+ sessionId: 'later-idle',
274
+ title: 'Later Idle',
275
+ lastActivityAt: '2026-03-19T10:00:00.000Z',
276
+ }),
277
+ ],
278
+ runningSessions: [
279
+ {
280
+ agent: 'claude',
281
+ pid: 9001,
282
+ cwd: '/repo',
283
+ command: 'claude --continue',
284
+ sessionId: 'claude-live',
285
+ confidence: 'medium',
286
+ },
287
+ ],
288
+ }),
289
+ 1,
290
+ firstPage.nextCursor,
291
+ );
292
+
293
+ assert.equal(secondPage.sessions.length, 1);
294
+ assert.equal(secondPage.sessions[0].sessionId, 'later-idle');
295
+ assert.equal(secondPage.sessions[0].runtime.state, 'idle');
296
+ });
297
+
298
+ test('externalRunning sessions preserve parsed approval mode', () => {
299
+ const page = buildSessionInventoryPage(
300
+ sources({
301
+ runningSessions: [
302
+ {
303
+ agent: 'claude',
304
+ pid: 9001,
305
+ cwd: '/repo',
306
+ command: 'claude --continue',
307
+ sessionId: 'claude-live',
308
+ confidence: 'medium',
309
+ approvalMode: 'acceptEdits',
310
+ },
311
+ ],
312
+ }),
313
+ 50,
314
+ );
315
+
316
+ assert.equal(page.sessions[0].runtime.state, 'externalRunning');
317
+ assert.equal(page.sessions[0].approvalMode, undefined);
318
+ assert.equal(page.sessions[0].observedApprovalMode, 'acceptEdits');
319
+ });
320
+
321
+ test('record approval override survives scanner and running observed modes', () => {
322
+ const page = buildSessionInventoryPage(
323
+ sources({
324
+ sessionRecords: [
325
+ {
326
+ sessionId: 'remote-shared',
327
+ agent: 'claude',
328
+ cwd: '/repo',
329
+ approvalMode: 'plan',
330
+ title: 'Remote Shared',
331
+ createdAt: '2026-03-01T00:00:00.000Z',
332
+ lastActivityAt: '2026-03-02T00:00:00.000Z',
333
+ },
334
+ ],
335
+ scannedSessions: [
336
+ idleSession({
337
+ sessionId: 'remote-shared',
338
+ agent: 'claude',
339
+ observedApprovalMode: 'acceptEdits',
340
+ title: 'Remote Shared',
341
+ }),
342
+ ],
343
+ runningSessions: [
344
+ {
345
+ agent: 'claude',
346
+ pid: 9002,
347
+ cwd: '/repo',
348
+ command: 'claude --resume remote-shared',
349
+ sessionId: 'remote-shared',
350
+ confidence: 'high',
351
+ approvalMode: 'autoApprove',
352
+ },
353
+ ],
354
+ }),
355
+ 50,
356
+ );
357
+
358
+ assert.equal(page.sessions[0].approvalMode, 'plan');
359
+ assert.equal(page.sessions[0].observedApprovalMode, 'autoApprove');
360
+ });
361
+
362
+ test('listSessionPage keeps search requests on the blocking path', async () => {
363
+ const scanned = createDeferred<SessionInfo[]>();
364
+ const contentMatches = createDeferred<Set<string>>();
365
+ let resolved = false;
366
+
367
+ const pending = listSessionPage({
368
+ limit: 50,
369
+ search: 'keyword',
370
+ activeSessions: [],
371
+ sessionRecords: [],
372
+ loaders: {
373
+ listScannedSessions: async () => scanned.promise,
374
+ scanRunningSessions: async () => [],
375
+ searchSessionContent: async () => contentMatches.promise,
376
+ },
377
+ }).then((page) => {
378
+ resolved = true;
379
+ return page;
380
+ });
381
+
382
+ await Promise.resolve();
383
+ assert.equal(resolved, false);
384
+
385
+ scanned.resolve([
386
+ idleSession({
387
+ sessionId: 'matched-search',
388
+ title: 'No metadata match here',
389
+ }),
390
+ ]);
391
+ contentMatches.resolve(new Set(['matched-search']));
392
+ const page = await pending;
393
+
394
+ assert.equal(resolved, true);
395
+ assert.equal(page.sessions.length, 1);
396
+ assert.equal(page.sessions[0].sessionId, 'matched-search');
397
+ });
398
+
399
+ // ===== Filtering =====
400
+
401
+ test('filters by agent type', () => {
402
+ const page = buildSessionInventoryPage(
403
+ sources({
404
+ scannedSessions: [
405
+ idleSession({ sessionId: 'codex-1', agent: 'codex', title: 'Codex' }),
406
+ idleSession({ sessionId: 'claude-1', agent: 'claude', title: 'Claude' }),
407
+ ],
408
+ }),
409
+ 10,
410
+ undefined,
411
+ 'claude',
412
+ );
413
+
414
+ assert.equal(page.sessions.length, 1);
415
+ assert.equal(page.sessions[0].agent, 'claude');
416
+ });
417
+
418
+ test('filters by cwd', () => {
419
+ const page = buildSessionInventoryPage(
420
+ sources({
421
+ scannedSessions: [
422
+ idleSession({ sessionId: 's1', cwd: '/repo-a', title: 'A' }),
423
+ idleSession({ sessionId: 's2', cwd: '/repo-b', title: 'B' }),
424
+ ],
425
+ }),
426
+ 10,
427
+ undefined,
428
+ undefined,
429
+ '/repo-b',
430
+ );
431
+
432
+ assert.equal(page.sessions.length, 1);
433
+ assert.equal(page.sessions[0].cwd, '/repo-b');
434
+ });
435
+
436
+ test('filters by search term matching title', () => {
437
+ const page = buildSessionInventoryPage(
438
+ sources({
439
+ scannedSessions: [
440
+ idleSession({ sessionId: 's1', title: 'Fix login bug' }),
441
+ idleSession({ sessionId: 's2', title: 'Add dashboard' }),
442
+ ],
443
+ }),
444
+ 10,
445
+ undefined,
446
+ undefined,
447
+ undefined,
448
+ 'login',
449
+ );
450
+
451
+ assert.equal(page.sessions.length, 1);
452
+ assert.equal(page.sessions[0].sessionId, 's1');
453
+ });
454
+
455
+ test('filters by search term matching cwd', () => {
456
+ const page = buildSessionInventoryPage(
457
+ sources({
458
+ scannedSessions: [
459
+ idleSession({ sessionId: 's1', cwd: '/home/user/frontend', title: 'A' }),
460
+ idleSession({ sessionId: 's2', cwd: '/home/user/backend', title: 'B' }),
461
+ ],
462
+ }),
463
+ 10,
464
+ undefined,
465
+ undefined,
466
+ undefined,
467
+ 'frontend',
468
+ );
469
+
470
+ assert.equal(page.sessions.length, 1);
471
+ assert.equal(page.sessions[0].sessionId, 's1');
472
+ });
473
+
474
+ test('search with contentMatchIds includes sessions matching content', () => {
475
+ const page = buildSessionInventoryPage(
476
+ sources({
477
+ scannedSessions: [
478
+ idleSession({ sessionId: 's1', title: 'No match' }),
479
+ idleSession({ sessionId: 's2', title: 'Also no match' }),
480
+ ],
481
+ }),
482
+ 10,
483
+ undefined,
484
+ undefined,
485
+ undefined,
486
+ 'special-query',
487
+ new Set(['s2']),
488
+ );
489
+
490
+ assert.equal(page.sessions.length, 1);
491
+ assert.equal(page.sessions[0].sessionId, 's2');
492
+ });
493
+
494
+ test('search disables pagination — returns all matching sessions', () => {
495
+ const sessions = Array.from({ length: 80 }, (_, i) =>
496
+ idleSession({
497
+ sessionId: `s${i}`,
498
+ title: `Session ${i} with keyword`,
499
+ lastActivityAt: `2026-03-${String(20 - Math.floor(i / 4)).padStart(2, '0')}T${String(i % 24).padStart(2, '0')}:00:00.000Z`,
500
+ }),
501
+ );
502
+ const page = buildSessionInventoryPage(
503
+ sources({ scannedSessions: sessions }),
504
+ 50,
505
+ undefined,
506
+ undefined,
507
+ undefined,
508
+ 'keyword',
509
+ );
510
+
511
+ assert.equal(page.sessions.length, 80);
512
+ assert.equal(page.nextCursor, undefined);
513
+ });
514
+
515
+ test('without search, pagination limits results to pageSize', () => {
516
+ const sessions = Array.from({ length: 80 }, (_, i) =>
517
+ idleSession({
518
+ sessionId: `s${i}`,
519
+ title: `Session ${i}`,
520
+ lastActivityAt: `2026-03-${String(20 - Math.floor(i / 4)).padStart(2, '0')}T${String(i % 24).padStart(2, '0')}:00:00.000Z`,
521
+ }),
522
+ );
523
+ const page = buildSessionInventoryPage(
524
+ sources({ scannedSessions: sessions }),
525
+ 50,
526
+ );
527
+
528
+ assert.equal(page.sessions.length, 50);
529
+ assert.ok(page.nextCursor);
530
+ });
531
+
532
+ test('search matches title OR content — union of both', () => {
533
+ const page = buildSessionInventoryPage(
534
+ sources({
535
+ scannedSessions: [
536
+ idleSession({ sessionId: 'title-match', title: 'Fix login bug' }),
537
+ idleSession({ sessionId: 'content-match', title: 'Unrelated title' }),
538
+ idleSession({ sessionId: 'no-match', title: 'Something else' }),
539
+ ],
540
+ }),
541
+ 50,
542
+ undefined,
543
+ undefined,
544
+ undefined,
545
+ 'login',
546
+ new Set(['content-match']),
547
+ );
548
+
549
+ assert.equal(page.sessions.length, 2);
550
+ const ids = page.sessions.map(s => s.sessionId).sort();
551
+ assert.deepEqual(ids, ['content-match', 'title-match']);
552
+ });
553
+
554
+ test('search is case insensitive for title and cwd', () => {
555
+ const page = buildSessionInventoryPage(
556
+ sources({
557
+ scannedSessions: [
558
+ idleSession({ sessionId: 's1', title: 'Fix Login Bug', cwd: '/home/User/Project' }),
559
+ idleSession({ sessionId: 's2', title: 'other', cwd: '/tmp' }),
560
+ ],
561
+ }),
562
+ 50,
563
+ undefined,
564
+ undefined,
565
+ undefined,
566
+ 'LOGIN',
567
+ );
568
+
569
+ assert.equal(page.sessions.length, 1);
570
+ assert.equal(page.sessions[0].sessionId, 's1');
571
+ });
572
+
573
+ test('search matches Chinese characters in title', () => {
574
+ const page = buildSessionInventoryPage(
575
+ sources({
576
+ scannedSessions: [
577
+ idleSession({ sessionId: 's1', title: '修复登录问题' }),
578
+ idleSession({ sessionId: 's2', title: '添加仪表盘功能' }),
579
+ ],
580
+ }),
581
+ 50,
582
+ undefined,
583
+ undefined,
584
+ undefined,
585
+ '登录',
586
+ );
587
+
588
+ assert.equal(page.sessions.length, 1);
589
+ assert.equal(page.sessions[0].sessionId, 's1');
590
+ });
591
+
592
+ test('search with no matches returns empty page', () => {
593
+ const page = buildSessionInventoryPage(
594
+ sources({
595
+ scannedSessions: [
596
+ idleSession({ sessionId: 's1', title: 'Fix bug' }),
597
+ idleSession({ sessionId: 's2', title: 'Add feature' }),
598
+ ],
599
+ }),
600
+ 50,
601
+ undefined,
602
+ undefined,
603
+ undefined,
604
+ 'nonexistent-term',
605
+ new Set(),
606
+ );
607
+
608
+ assert.equal(page.sessions.length, 0);
609
+ assert.equal(page.nextCursor, undefined);
610
+ });
611
+
612
+ test('search with contentMatchIds includes sessions not matched by meta', () => {
613
+ const contentIds = new Set(['s1', 's3']);
614
+ const page = buildSessionInventoryPage(
615
+ sources({
616
+ scannedSessions: [
617
+ idleSession({ sessionId: 's1', title: 'No meta match', cwd: '/a' }),
618
+ idleSession({ sessionId: 's2', title: 'No meta match', cwd: '/b' }),
619
+ idleSession({ sessionId: 's3', title: 'No meta match', cwd: '/c' }),
620
+ ],
621
+ }),
622
+ 50,
623
+ undefined,
624
+ undefined,
625
+ undefined,
626
+ 'xyz',
627
+ contentIds,
628
+ );
629
+
630
+ assert.equal(page.sessions.length, 2);
631
+ const ids = page.sessions.map(s => s.sessionId).sort();
632
+ assert.deepEqual(ids, ['s1', 's3']);
633
+ });
634
+
635
+ test('search combined with agent filter narrows results', () => {
636
+ const page = buildSessionInventoryPage(
637
+ sources({
638
+ scannedSessions: [
639
+ idleSession({ sessionId: 's1', agent: 'claude', title: 'Fix login' }),
640
+ idleSession({ sessionId: 's2', agent: 'codex', title: 'Fix login' }),
641
+ ],
642
+ }),
643
+ 50,
644
+ undefined,
645
+ 'claude',
646
+ undefined,
647
+ 'login',
648
+ );
649
+
650
+ assert.equal(page.sessions.length, 1);
651
+ assert.equal(page.sessions[0].agent, 'claude');
652
+ });
653
+
654
+ // ===== Additional coverage tests =====
655
+
656
+ test('active sessions are sorted by runtime state rank then by lastActivityAt', () => {
657
+ const page = buildSessionInventoryPage(
658
+ sources({
659
+ runningSessions: [
660
+ {
661
+ agent: 'codex', pid: 1, cwd: '/repo', command: 'codex',
662
+ sessionId: 'running-1', confidence: 'medium',
663
+ },
664
+ ],
665
+ activeSessions: [
666
+ {
667
+ sessionId: 'daemon-1', agent: 'codex', cwd: '/repo', title: 'Daemon',
668
+ createdAt: '2026-03-20T09:00:00.000Z', lastActivityAt: '2026-03-20T10:00:00.000Z',
669
+ },
670
+ ],
671
+ }),
672
+ 10,
673
+ );
674
+
675
+ // daemonActive (rank 3) should come before externalRunning (rank 2)
676
+ assert.equal(page.sessions[0].runtime.state, 'daemonActive');
677
+ assert.equal(page.sessions[1].runtime.state, 'externalRunning');
678
+ });
679
+
680
+ test('cursor that does not match any session resets to beginning', () => {
681
+ const page = buildSessionInventoryPage(
682
+ sources({
683
+ scannedSessions: [
684
+ idleSession({ sessionId: 's1', lastActivityAt: '2026-03-20T10:00:00.000Z' }),
685
+ idleSession({ sessionId: 's2', lastActivityAt: '2026-03-19T10:00:00.000Z' }),
686
+ ],
687
+ }),
688
+ 10,
689
+ { lastActivityAt: 'nonexistent', sessionId: 'nonexistent' },
690
+ );
691
+
692
+ // Should fall back to beginning
693
+ assert.equal(page.sessions.length, 2);
694
+ assert.equal(page.sessions[0].sessionId, 's1');
695
+ });
696
+
697
+ test('idle sessions are sorted by lastActivityAt descending', () => {
698
+ const page = buildSessionInventoryPage(
699
+ sources({
700
+ scannedSessions: [
701
+ idleSession({ sessionId: 'old', lastActivityAt: '2026-03-01T00:00:00.000Z' }),
702
+ idleSession({ sessionId: 'new', lastActivityAt: '2026-03-20T00:00:00.000Z' }),
703
+ idleSession({ sessionId: 'mid', lastActivityAt: '2026-03-10T00:00:00.000Z' }),
704
+ ],
705
+ }),
706
+ 10,
707
+ );
708
+
709
+ assert.equal(page.sessions[0].sessionId, 'new');
710
+ assert.equal(page.sessions[1].sessionId, 'mid');
711
+ assert.equal(page.sessions[2].sessionId, 'old');
712
+ });
713
+
714
+ test('runtime state: externalRunning beats idle in sort order', () => {
715
+ const page = buildSessionInventoryPage(
716
+ sources({
717
+ scannedSessions: [
718
+ idleSession({ sessionId: 'idle-1', lastActivityAt: '2026-03-20T12:00:00.000Z' }),
719
+ ],
720
+ runningSessions: [
721
+ {
722
+ agent: 'codex', pid: 1, cwd: '/repo', command: 'codex',
723
+ sessionId: 'running-1', confidence: 'low',
724
+ },
725
+ ],
726
+ }),
727
+ 10,
728
+ );
729
+
730
+ assert.equal(page.sessions[0].sessionId, 'running-1');
731
+ assert.equal(page.sessions[0].runtime.state, 'externalRunning');
732
+ assert.equal(page.sessions[1].sessionId, 'idle-1');
733
+ });
734
+
735
+ test('deleted sessions are filtered even when scanner finds them again', () => {
736
+ const page = buildSessionInventoryPage(
737
+ sources({
738
+ sessionRecords: [
739
+ {
740
+ sessionId: 'deleted-1',
741
+ agent: 'codex',
742
+ cwd: '/repo',
743
+ approvalMode: 'normal',
744
+ title: 'Deleted record',
745
+ createdAt: '2026-03-01T00:00:00.000Z',
746
+ lastActivityAt: '2026-03-02T00:00:00.000Z',
747
+ managed: true,
748
+ },
749
+ ],
750
+ scannedSessions: [
751
+ idleSession({
752
+ sessionId: 'deleted-1',
753
+ title: 'Deleted scanner',
754
+ lastActivityAt: '2026-03-20T10:00:00.000Z',
755
+ }),
756
+ idleSession({
757
+ sessionId: 'kept-1',
758
+ title: 'Kept session',
759
+ lastActivityAt: '2026-03-19T10:00:00.000Z',
760
+ }),
761
+ ],
762
+ deletedSessionIds: new Set(['deleted-1']),
763
+ }),
764
+ 10,
765
+ );
766
+
767
+ assert.equal(page.sessions.length, 1);
768
+ assert.equal(page.sessions[0].sessionId, 'kept-1');
769
+ });
770
+
771
+ test('pickRuntime chooses higher confidence when same state', () => {
772
+ const page = buildSessionInventoryPage(
773
+ sources({
774
+ runningSessions: [
775
+ {
776
+ agent: 'codex', pid: 1, cwd: '/repo', command: 'codex',
777
+ sessionId: 'shared', confidence: 'low',
778
+ },
779
+ {
780
+ agent: 'codex', pid: 2, cwd: '/repo', command: 'codex resume shared',
781
+ sessionId: 'shared', confidence: 'high',
782
+ },
783
+ ],
784
+ }),
785
+ 10,
786
+ );
787
+
788
+ assert.equal(page.sessions.length, 1);
789
+ assert.equal(page.sessions[0].runtime.confidence, 'high');
790
+ });
791
+
792
+ // ===== Merge edge cases =====
793
+
794
+ test('merges same session from record and scanner, picks higher runtime state', () => {
795
+ const page = buildSessionInventoryPage(
796
+ sources({
797
+ sessionRecords: [
798
+ {
799
+ sessionId: 'shared',
800
+ agent: 'codex',
801
+ cwd: '/repo',
802
+ approvalMode: 'normal',
803
+ title: 'Record title',
804
+ createdAt: '2026-03-01T00:00:00.000Z',
805
+ lastActivityAt: '2026-03-02T00:00:00.000Z',
806
+ },
807
+ ],
808
+ activeSessions: [
809
+ {
810
+ sessionId: 'shared',
811
+ agent: 'codex',
812
+ cwd: '/repo',
813
+ title: 'Active title',
814
+ createdAt: '2026-03-01T00:00:00.000Z',
815
+ lastActivityAt: '2026-03-20T12:00:00.000Z',
816
+ },
817
+ ],
818
+ }),
819
+ 10,
820
+ );
821
+
822
+ assert.equal(page.sessions.length, 1);
823
+ assert.equal(page.sessions[0].runtime.state, 'daemonActive');
824
+ assert.equal(page.sessions[0].title, 'Active title');
825
+ });
826
+
827
+ test('returns empty page when no sessions match filters', () => {
828
+ const page = buildSessionInventoryPage(
829
+ sources({
830
+ scannedSessions: [
831
+ idleSession({ sessionId: 's1', agent: 'codex' }),
832
+ ],
833
+ }),
834
+ 10,
835
+ undefined,
836
+ 'claude',
837
+ );
838
+
839
+ assert.equal(page.sessions.length, 0);
840
+ assert.equal(page.nextCursor, undefined);
841
+ });
842
+
843
+ test('uses min createdAt across merged sources', () => {
844
+ const page = buildSessionInventoryPage(
845
+ sources({
846
+ sessionRecords: [
847
+ {
848
+ sessionId: 'shared',
849
+ agent: 'codex',
850
+ cwd: '/repo',
851
+ title: 'Title',
852
+ createdAt: '2026-03-01T00:00:00.000Z',
853
+ lastActivityAt: '2026-03-10T00:00:00.000Z',
854
+ },
855
+ ],
856
+ scannedSessions: [
857
+ idleSession({
858
+ sessionId: 'shared',
859
+ createdAt: '2026-03-05T00:00:00.000Z',
860
+ lastActivityAt: '2026-03-20T00:00:00.000Z',
861
+ }),
862
+ ],
863
+ }),
864
+ 10,
865
+ );
866
+
867
+ assert.equal(page.sessions[0].createdAt, '2026-03-01T00:00:00.000Z');
868
+ assert.equal(page.sessions[0].lastActivityAt, '2026-03-20T00:00:00.000Z');
869
+ });
870
+
871
+ // ===== Title merging =====
872
+
873
+ test('mergeInto does not let process scanner override daemonActive lastActivityAt', () => {
874
+ const fiveMinsAgo = '2026-03-20T11:55:00.000Z';
875
+ const scannedAt = '2026-03-20T12:00:00.000Z'; // now
876
+
877
+ const page = buildSessionInventoryPage(
878
+ sources({
879
+ activeSessions: [
880
+ {
881
+ sessionId: 'shared-session',
882
+ agent: 'codex',
883
+ cwd: '/repo',
884
+ title: 'Daemon session',
885
+ createdAt: '2026-03-01T00:00:00.000Z',
886
+ lastActivityAt: fiveMinsAgo,
887
+ },
888
+ ],
889
+ runningSessions: [
890
+ {
891
+ agent: 'codex',
892
+ pid: 42,
893
+ cwd: '/repo',
894
+ command: 'codex --continue',
895
+ sessionId: 'shared-session',
896
+ confidence: 'medium',
897
+ },
898
+ ],
899
+ scannedAt,
900
+ }),
901
+ 10,
902
+ );
903
+
904
+ assert.equal(page.sessions.length, 1);
905
+ assert.equal(page.sessions[0].sessionId, 'shared-session');
906
+ // The daemon's lastActivityAt (5 mins ago) should be preserved,
907
+ // NOT overwritten by the process scanner's scannedAt (now).
908
+ assert.equal(
909
+ page.sessions[0].lastActivityAt,
910
+ fiveMinsAgo,
911
+ 'lastActivityAt should be the daemon timestamp, not the scanner scannedAt',
912
+ );
913
+ });
914
+
915
+ test('managed idle sessions are sorted with active sessions, not idle', () => {
916
+ const page = buildSessionInventoryPage(
917
+ sources({
918
+ sessionRecords: [
919
+ {
920
+ sessionId: 'managed-idle',
921
+ agent: 'claude',
922
+ cwd: '/repo',
923
+ title: 'Managed Session',
924
+ createdAt: '2026-03-01T00:00:00.000Z',
925
+ lastActivityAt: '2026-03-20T10:00:00.000Z',
926
+ managed: true,
927
+ },
928
+ ],
929
+ scannedSessions: [
930
+ idleSession({
931
+ sessionId: 'scanned-idle',
932
+ title: 'Scanned Session',
933
+ lastActivityAt: '2026-03-20T11:00:00.000Z',
934
+ }),
935
+ ],
936
+ }),
937
+ 1,
938
+ );
939
+
940
+ // managed idle should appear first (in active section), scanned idle in idle section
941
+ assert.equal(page.sessions[0].sessionId, 'managed-idle');
942
+ assert.equal(page.sessions[0].managed, true);
943
+ assert.equal(page.sessions[1].sessionId, 'scanned-idle');
944
+ });
945
+
946
+ test('managed flag is preserved when merging record and scanner sources', () => {
947
+ const page = buildSessionInventoryPage(
948
+ sources({
949
+ sessionRecords: [
950
+ {
951
+ sessionId: 'shared',
952
+ agent: 'codex',
953
+ cwd: '/repo',
954
+ title: 'Record',
955
+ createdAt: '2026-03-01T00:00:00.000Z',
956
+ lastActivityAt: '2026-03-02T00:00:00.000Z',
957
+ managed: true,
958
+ },
959
+ ],
960
+ scannedSessions: [
961
+ idleSession({
962
+ sessionId: 'shared',
963
+ title: 'Scanned',
964
+ lastActivityAt: '2026-03-20T10:00:00.000Z',
965
+ }),
966
+ ],
967
+ }),
968
+ 10,
969
+ );
970
+
971
+ assert.equal(page.sessions.length, 1);
972
+ assert.equal(page.sessions[0].managed, true);
973
+ });
974
+
975
+ test('replaces id-like fallback titles with a real scanned title', () => {
976
+ const sessionId = '019d03e3-2672-7011-9c6d-5ba083b71111';
977
+ const page = buildSessionInventoryPage(
978
+ sources({
979
+ sessionRecords: [
980
+ {
981
+ sessionId,
982
+ agent: 'codex',
983
+ cwd: '/repo',
984
+ approvalMode: 'normal',
985
+ title: sessionId,
986
+ createdAt: '2026-03-01T00:00:00.000Z',
987
+ lastActivityAt: '2026-03-02T00:00:00.000Z',
988
+ },
989
+ ],
990
+ scannedSessions: [
991
+ idleSession({
992
+ sessionId,
993
+ title: 'Recovered title',
994
+ lastActivityAt: '2026-03-20T10:00:00.000Z',
995
+ }),
996
+ ],
997
+ }),
998
+ 10,
999
+ );
1000
+
1001
+ assert.equal(page.sessions[0].title, 'Recovered title');
1002
+ });