github-issue-tower-defence-management 1.109.1 → 1.110.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +29 -3
  3. package/bin/adapter/entry-points/cli/index.js +10 -10
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/dashboardComposeService.js +7 -6
  6. package/bin/adapter/entry-points/console/dashboardComposeService.js.map +1 -1
  7. package/bin/adapter/entry-points/console/ui-dist/assets/{index-CO-f_j4f.css → index-BPH6W1yn.css} +1 -1
  8. package/bin/adapter/entry-points/console/ui-dist/assets/index-CT3t4Yje.js +101 -0
  9. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  10. package/bin/adapter/entry-points/console/webServer.js +5 -4
  11. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  12. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +125 -2
  13. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  14. package/bin/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.js +2 -0
  15. package/bin/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.js.map +1 -1
  16. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +2 -0
  17. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -1
  18. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +2 -0
  19. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -1
  20. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +62 -0
  21. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -0
  22. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +88 -3
  23. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -1
  24. package/bin/adapter/proxy/RateLimitCache.js +25 -1
  25. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  26. package/bin/adapter/proxy/proxyEntry.js +25 -0
  27. package/bin/adapter/proxy/proxyEntry.js.map +1 -1
  28. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +37 -0
  29. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -0
  30. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js +76 -0
  31. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js.map +1 -0
  32. package/bin/adapter/repositories/FileSystemSubAgentSilentSecondsResolver.js +67 -0
  33. package/bin/adapter/repositories/FileSystemSubAgentSilentSecondsResolver.js.map +1 -0
  34. package/bin/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.js +50 -0
  35. package/bin/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.js.map +1 -0
  36. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js +12 -0
  37. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js.map +1 -0
  38. package/bin/adapter/repositories/NodeSubAgentProcessLister.js +37 -0
  39. package/bin/adapter/repositories/NodeSubAgentProcessLister.js.map +1 -0
  40. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js +48 -0
  41. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js.map +1 -0
  42. package/bin/adapter/repositories/RealSleeper.js +17 -0
  43. package/bin/adapter/repositories/RealSleeper.js.map +1 -0
  44. package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js +56 -0
  45. package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js.map +1 -0
  46. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +150 -0
  47. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -0
  48. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +152 -0
  49. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -0
  50. package/bin/domain/entities/LiveSessionActivitySnapshot.js +3 -0
  51. package/bin/domain/entities/LiveSessionActivitySnapshot.js.map +1 -0
  52. package/bin/domain/entities/LiveSessionOutputActivity.js +3 -0
  53. package/bin/domain/entities/LiveSessionOutputActivity.js.map +1 -0
  54. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +35 -0
  55. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -0
  56. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +113 -0
  57. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -0
  58. package/bin/domain/usecases/OauthTokenSelectUseCase.js +8 -2
  59. package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -1
  60. package/bin/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.js +3 -0
  61. package/bin/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.js.map +1 -0
  62. package/bin/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.js +3 -0
  63. package/bin/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.js.map +1 -0
  64. package/bin/domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository.js +3 -0
  65. package/bin/domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository.js.map +1 -0
  66. package/bin/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.js +3 -0
  67. package/bin/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.js.map +1 -0
  68. package/bin/domain/usecases/adapter-interfaces/SilentSessionNotificationRepository.js +3 -0
  69. package/bin/domain/usecases/adapter-interfaces/SilentSessionNotificationRepository.js.map +1 -0
  70. package/bin/domain/usecases/adapter-interfaces/Sleeper.js +3 -0
  71. package/bin/domain/usecases/adapter-interfaces/Sleeper.js.map +1 -0
  72. package/bin/domain/usecases/adapter-interfaces/SubAgentProcessLister.js +3 -0
  73. package/bin/domain/usecases/adapter-interfaces/SubAgentProcessLister.js.map +1 -0
  74. package/bin/domain/usecases/adapter-interfaces/SubAgentSilentSecondsResolver.js +3 -0
  75. package/bin/domain/usecases/adapter-interfaces/SubAgentSilentSecondsResolver.js.map +1 -0
  76. package/bin/domain/usecases/adapter-interfaces/SubAgentTranscriptDirectoryResolver.js +3 -0
  77. package/bin/domain/usecases/adapter-interfaces/SubAgentTranscriptDirectoryResolver.js.map +1 -0
  78. package/bin/domain/usecases/dashboard/DashboardProjectCode.js +19 -0
  79. package/bin/domain/usecases/dashboard/DashboardProjectCode.js.map +1 -0
  80. package/package.json +1 -1
  81. package/src/adapter/entry-points/cli/index.ts +14 -14
  82. package/src/adapter/entry-points/console/dashboardComposeService.test.ts +31 -28
  83. package/src/adapter/entry-points/console/dashboardComposeService.ts +13 -10
  84. package/src/adapter/entry-points/console/dashboardEmitterComposerKey.test.ts +175 -0
  85. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +1 -1
  86. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCopyUrlButton.stories.tsx +24 -0
  87. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCopyUrlButton.test.tsx +63 -0
  88. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCopyUrlButton.tsx +51 -0
  89. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.test.tsx +7 -0
  90. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +2 -0
  91. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.test.tsx +18 -0
  92. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.tsx +2 -0
  93. package/src/adapter/entry-points/console/ui/src/index.css +14 -0
  94. package/src/adapter/entry-points/console/ui-dist/assets/{index-CO-f_j4f.css → index-BPH6W1yn.css} +1 -1
  95. package/src/adapter/entry-points/console/ui-dist/assets/index-CT3t4Yje.js +101 -0
  96. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  97. package/src/adapter/entry-points/console/webServer.test.ts +23 -23
  98. package/src/adapter/entry-points/console/webServer.ts +5 -4
  99. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +122 -0
  100. package/src/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.ts +2 -0
  101. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +112 -0
  102. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +2 -0
  103. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +131 -0
  104. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +2 -0
  105. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +338 -0
  106. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +174 -0
  107. package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +79 -0
  108. package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +117 -3
  109. package/src/adapter/proxy/RateLimitCache.test.ts +89 -0
  110. package/src/adapter/proxy/RateLimitCache.ts +31 -0
  111. package/src/adapter/proxy/proxyEntry.test.ts +92 -0
  112. package/src/adapter/proxy/proxyEntry.ts +32 -0
  113. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +77 -0
  114. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +51 -0
  115. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.test.ts +92 -0
  116. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +48 -0
  117. package/src/adapter/repositories/FileSystemSubAgentSilentSecondsResolver.test.ts +56 -0
  118. package/src/adapter/repositories/FileSystemSubAgentSilentSecondsResolver.ts +32 -0
  119. package/src/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.test.ts +32 -0
  120. package/src/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.ts +17 -0
  121. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.test.ts +12 -0
  122. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.ts +9 -0
  123. package/src/adapter/repositories/NodeSubAgentProcessLister.test.ts +43 -0
  124. package/src/adapter/repositories/NodeSubAgentProcessLister.ts +40 -0
  125. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.test.ts +109 -0
  126. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.ts +56 -0
  127. package/src/adapter/repositories/RealSleeper.test.ts +19 -0
  128. package/src/adapter/repositories/RealSleeper.ts +12 -0
  129. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.test.ts +153 -0
  130. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.ts +83 -0
  131. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +220 -0
  132. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +139 -0
  133. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +217 -0
  134. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +145 -0
  135. package/src/domain/entities/LiveSessionActivitySnapshot.ts +12 -0
  136. package/src/domain/entities/LiveSessionOutputActivity.ts +4 -0
  137. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +52 -0
  138. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +39 -0
  139. package/src/domain/usecases/InTmuxByHumanSessionTokenCountUseCase.test.ts +2 -0
  140. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +40 -0
  141. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +612 -0
  142. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +223 -0
  143. package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +36 -0
  144. package/src/domain/usecases/OauthTokenSelectUseCase.ts +12 -0
  145. package/src/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.ts +5 -0
  146. package/src/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.ts +7 -0
  147. package/src/domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository.ts +7 -0
  148. package/src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts +6 -0
  149. package/src/domain/usecases/adapter-interfaces/SilentSessionNotificationRepository.ts +11 -0
  150. package/src/domain/usecases/adapter-interfaces/Sleeper.ts +3 -0
  151. package/src/domain/usecases/adapter-interfaces/SubAgentProcessLister.ts +8 -0
  152. package/src/domain/usecases/adapter-interfaces/SubAgentSilentSecondsResolver.ts +3 -0
  153. package/src/domain/usecases/adapter-interfaces/SubAgentTranscriptDirectoryResolver.ts +3 -0
  154. package/src/domain/usecases/dashboard/DashboardProjectCode.test.ts +32 -0
  155. package/src/domain/usecases/dashboard/DashboardProjectCode.ts +20 -0
  156. package/types/adapter/entry-points/console/dashboardComposeService.d.ts +1 -1
  157. package/types/adapter/entry-points/console/dashboardComposeService.d.ts.map +1 -1
  158. package/types/adapter/entry-points/console/webServer.d.ts +2 -2
  159. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  160. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  161. package/types/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.d.ts.map +1 -1
  162. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -1
  163. package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -1
  164. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +34 -0
  165. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -0
  166. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts +1 -0
  167. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
  168. package/types/adapter/proxy/RateLimitCache.d.ts +3 -0
  169. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  170. package/types/adapter/proxy/proxyEntry.d.ts.map +1 -1
  171. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +15 -0
  172. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -0
  173. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts +10 -0
  174. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts.map +1 -0
  175. package/types/adapter/repositories/FileSystemSubAgentSilentSecondsResolver.d.ts +9 -0
  176. package/types/adapter/repositories/FileSystemSubAgentSilentSecondsResolver.d.ts.map +1 -0
  177. package/types/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.d.ts +7 -0
  178. package/types/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.d.ts.map +1 -0
  179. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts +5 -0
  180. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts.map +1 -0
  181. package/types/adapter/repositories/NodeSubAgentProcessLister.d.ts +8 -0
  182. package/types/adapter/repositories/NodeSubAgentProcessLister.d.ts.map +1 -0
  183. package/types/adapter/repositories/ProcessListSessionSubAgentActivityRepository.d.ts +12 -0
  184. package/types/adapter/repositories/ProcessListSessionSubAgentActivityRepository.d.ts.map +1 -0
  185. package/types/adapter/repositories/RealSleeper.d.ts +5 -0
  186. package/types/adapter/repositories/RealSleeper.d.ts.map +1 -0
  187. package/types/adapter/repositories/TmuxSilentSessionNotificationRepository.d.ts +13 -0
  188. package/types/adapter/repositories/TmuxSilentSessionNotificationRepository.d.ts.map +1 -0
  189. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +10 -0
  190. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -0
  191. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts +12 -0
  192. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -0
  193. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts +12 -0
  194. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts.map +1 -0
  195. package/types/domain/entities/LiveSessionOutputActivity.d.ts +5 -0
  196. package/types/domain/entities/LiveSessionOutputActivity.d.ts.map +1 -0
  197. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +7 -0
  198. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -0
  199. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +41 -0
  200. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -0
  201. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +2 -0
  202. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -1
  203. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts +4 -0
  204. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts.map +1 -0
  205. package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts +5 -0
  206. package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts.map +1 -0
  207. package/types/domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository.d.ts +5 -0
  208. package/types/domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository.d.ts.map +1 -0
  209. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts +6 -0
  210. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts.map +1 -0
  211. package/types/domain/usecases/adapter-interfaces/SilentSessionNotificationRepository.d.ts +6 -0
  212. package/types/domain/usecases/adapter-interfaces/SilentSessionNotificationRepository.d.ts.map +1 -0
  213. package/types/domain/usecases/adapter-interfaces/Sleeper.d.ts +4 -0
  214. package/types/domain/usecases/adapter-interfaces/Sleeper.d.ts.map +1 -0
  215. package/types/domain/usecases/adapter-interfaces/SubAgentProcessLister.d.ts +8 -0
  216. package/types/domain/usecases/adapter-interfaces/SubAgentProcessLister.d.ts.map +1 -0
  217. package/types/domain/usecases/adapter-interfaces/SubAgentSilentSecondsResolver.d.ts +4 -0
  218. package/types/domain/usecases/adapter-interfaces/SubAgentSilentSecondsResolver.d.ts.map +1 -0
  219. package/types/domain/usecases/adapter-interfaces/SubAgentTranscriptDirectoryResolver.d.ts +4 -0
  220. package/types/domain/usecases/adapter-interfaces/SubAgentTranscriptDirectoryResolver.d.ts.map +1 -0
  221. package/types/domain/usecases/dashboard/DashboardProjectCode.d.ts +4 -0
  222. package/types/domain/usecases/dashboard/DashboardProjectCode.d.ts.map +1 -0
  223. package/bin/adapter/entry-points/console/ui-dist/assets/index-BpccigBS.js +0 -101
  224. package/src/adapter/entry-points/console/ui-dist/assets/index-BpccigBS.js +0 -101
@@ -0,0 +1,338 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { Issue } from '../../../domain/entities/Issue';
5
+ import { Project } from '../../../domain/entities/Project';
6
+ import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
7
+ import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
8
+ import { IN_TMUX_STATUS_NAME } from '../../../domain/entities/WorkflowStatus';
9
+ import { LocalStorageCacheRepository } from '../../repositories/LocalStorageCacheRepository';
10
+ import { LocalStorageRepository } from '../../repositories/LocalStorageRepository';
11
+ import { SilentSessionMessageTemplates } from '../../repositories/ConfigurableSilentSessionMessageComposer';
12
+ import {
13
+ notifySilentTmuxSessions,
14
+ DEFAULT_NOTIFY_SILENT_TMUX_SESSIONS_PARAMS,
15
+ } from './notifySilentTmuxSessions';
16
+
17
+ const NOW = new Date('2026-06-26T00:00:00.000Z');
18
+ const NOW_EPOCH_SECONDS = Math.floor(NOW.getTime() / 1000);
19
+ const ALLOW_CACHE_MINUTES = 10;
20
+ const SESSION_NAME = 'https_//github_com/demo/repo/issues/1';
21
+
22
+ const EMPTY_TEMPLATES: SilentSessionMessageTemplates = {
23
+ mainStalledMessage: null,
24
+ subAgentMessageHeader: null,
25
+ subAgentMessageFooter: null,
26
+ };
27
+
28
+ const makeProject = (): Project => ({
29
+ id: 'project-1',
30
+ url: 'https://github.com/users/user/projects/1',
31
+ databaseId: 1,
32
+ name: 'Test Project',
33
+ status: {
34
+ name: 'Status',
35
+ fieldId: 'field-1',
36
+ statuses: [],
37
+ },
38
+ nextActionDate: null,
39
+ nextActionHour: null,
40
+ story: null,
41
+ remainingEstimationMinutes: null,
42
+ dependedIssueUrlSeparatedByComma: null,
43
+ completionDate50PercentConfidence: null,
44
+ });
45
+
46
+ const makeIssue = (overrides: Partial<Issue> = {}): Issue => ({
47
+ nameWithOwner: 'demo/repo',
48
+ number: 1,
49
+ title: 'Issue 1',
50
+ state: 'OPEN',
51
+ status: IN_TMUX_STATUS_NAME,
52
+ story: null,
53
+ nextActionDate: null,
54
+ nextActionHour: null,
55
+ estimationMinutes: null,
56
+ dependedIssueUrls: [],
57
+ completionDate50PercentConfidence: null,
58
+ url: 'https://github.com/demo/repo/issues/1',
59
+ assignees: [],
60
+ labels: [],
61
+ org: 'demo',
62
+ repo: 'repo',
63
+ body: '',
64
+ itemId: 'item-1',
65
+ isPr: false,
66
+ isInProgress: false,
67
+ isClosed: false,
68
+ createdAt: new Date(),
69
+ author: '',
70
+ closingIssueReferenceUrls: [],
71
+ ...overrides,
72
+ });
73
+
74
+ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
75
+
76
+ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
77
+ runCommand: jest.fn().mockResolvedValue({
78
+ stdout: '',
79
+ stderr: '',
80
+ exitCode: 0,
81
+ }),
82
+ });
83
+
84
+ const createMockIssueRepository = (
85
+ issues: Issue[],
86
+ ): Pick<IssueRepository, 'getAllOpened'> => ({
87
+ getAllOpened: jest.fn().mockResolvedValue(issues),
88
+ });
89
+
90
+ describe('notifySilentTmuxSessions', () => {
91
+ let outputDirectory: string;
92
+ let cacheDirectory: string;
93
+
94
+ beforeEach(() => {
95
+ jest.spyOn(console, 'log').mockImplementation(() => undefined);
96
+ outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'silent-output-'));
97
+ cacheDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'silent-cache-'));
98
+ });
99
+
100
+ afterEach(() => {
101
+ jest.restoreAllMocks();
102
+ fs.rmSync(outputDirectory, { force: true, recursive: true });
103
+ fs.rmSync(cacheDirectory, { force: true, recursive: true });
104
+ });
105
+
106
+ const writeSilentOutputFile = (sessionName: string): void => {
107
+ const fileName = sessionName.replace(/\//g, '_');
108
+ const filePath = path.join(outputDirectory, fileName);
109
+ fs.writeFileSync(filePath, 'output', 'utf8');
110
+ const silentEpoch = NOW_EPOCH_SECONDS - 11 * 60;
111
+ fs.utimesSync(filePath, silentEpoch, silentEpoch);
112
+ };
113
+
114
+ const makeCacheRepository = (): LocalStorageCacheRepository =>
115
+ new LocalStorageCacheRepository(
116
+ new LocalStorageRepository(),
117
+ cacheDirectory,
118
+ );
119
+
120
+ const baseParams = (
121
+ runner: LocalCommandRunner,
122
+ ): Parameters<typeof notifySilentTmuxSessions>[0] => ({
123
+ project: makeProject(),
124
+ allowCacheMinutes: ALLOW_CACHE_MINUTES,
125
+ issueRepository: createMockIssueRepository([makeIssue()]),
126
+ localCommandRunner: runner,
127
+ cacheRepository: makeCacheRepository(),
128
+ sessionOutputRootDirectory: outputDirectory,
129
+ sessionTranscriptRootDirectory: null,
130
+ ownerCallMarker: null,
131
+ subAgentOutputRootDirectory: null,
132
+ subAgentProcessMatchPattern: null,
133
+ subAgentTranscriptRootDirectory: null,
134
+ messageTemplates: EMPTY_TEMPLATES,
135
+ now: NOW,
136
+ ...DEFAULT_NOTIFY_SILENT_TMUX_SESSIONS_PARAMS,
137
+ });
138
+
139
+ const listSessionsRunner = (): Mocked<LocalCommandRunner> => {
140
+ const runner = createMockRunner();
141
+ runner.runCommand.mockImplementation(async (program, args) => {
142
+ if (program === 'tmux' && args[0] === 'list-sessions') {
143
+ return { stdout: `${SESSION_NAME}\n`, stderr: '', exitCode: 0 };
144
+ }
145
+ return { stdout: '', stderr: '', exitCode: 0 };
146
+ });
147
+ return runner;
148
+ };
149
+
150
+ it('sends a main stalled notification to a silent monitored live session', async () => {
151
+ writeSilentOutputFile(SESSION_NAME);
152
+ const runner = listSessionsRunner();
153
+
154
+ await notifySilentTmuxSessions(baseParams(runner));
155
+
156
+ const sendCall = runner.runCommand.mock.calls.find(
157
+ (call) => call[0] === 'tmux' && call[1][0] === 'send-keys',
158
+ );
159
+ expect(sendCall?.[1][2]).toBe(SESSION_NAME);
160
+ expect(sendCall?.[1][4]).toContain('You have produced no output for');
161
+ });
162
+
163
+ it('does nothing when neither output root nor sub-agent pattern is configured', async () => {
164
+ writeSilentOutputFile(SESSION_NAME);
165
+ const runner = createMockRunner();
166
+
167
+ await notifySilentTmuxSessions({
168
+ ...baseParams(runner),
169
+ sessionOutputRootDirectory: null,
170
+ subAgentProcessMatchPattern: null,
171
+ });
172
+
173
+ expect(runner.runCommand.mock.calls).toHaveLength(0);
174
+ });
175
+
176
+ it('sends a sub-agent notification driven by the process match pattern even when the main session is not silent', async () => {
177
+ const runner = createMockRunner();
178
+ runner.runCommand.mockImplementation(async (program, args) => {
179
+ if (program === 'tmux' && args[0] === 'list-sessions') {
180
+ return { stdout: `${SESSION_NAME}\n`, stderr: '', exitCode: 0 };
181
+ }
182
+ if (program === 'ps') {
183
+ return {
184
+ stdout: ` 1200 worker session=${SESSION_NAME} label=task-a\n`,
185
+ stderr: '',
186
+ exitCode: 0,
187
+ };
188
+ }
189
+ return { stdout: '', stderr: '', exitCode: 0 };
190
+ });
191
+
192
+ await notifySilentTmuxSessions({
193
+ ...baseParams(runner),
194
+ sessionOutputRootDirectory: null,
195
+ subAgentProcessMatchPattern:
196
+ 'session=(?<session>[^ ]+) label=(?<label>[^ ]+)',
197
+ });
198
+
199
+ const sendCall = runner.runCommand.mock.calls.find(
200
+ (call) => call[0] === 'tmux' && call[1][0] === 'send-keys',
201
+ );
202
+ expect(sendCall?.[1][2]).toBe(SESSION_NAME);
203
+ expect(sendCall?.[1][4]).toContain('task-a');
204
+ });
205
+
206
+ it('uses the configured main stalled message template when provided', async () => {
207
+ writeSilentOutputFile(SESSION_NAME);
208
+ const runner = listSessionsRunner();
209
+
210
+ await notifySilentTmuxSessions({
211
+ ...baseParams(runner),
212
+ messageTemplates: {
213
+ mainStalledMessage: 'CUSTOM_MAIN_TEMPLATE',
214
+ subAgentMessageHeader: null,
215
+ subAgentMessageFooter: null,
216
+ },
217
+ });
218
+
219
+ const sendCall = runner.runCommand.mock.calls.find(
220
+ (call) => call[0] === 'tmux' && call[1][0] === 'send-keys',
221
+ );
222
+ expect(sendCall?.[1][4]).toBe('CUSTOM_MAIN_TEMPLATE');
223
+ });
224
+
225
+ it('suppresses the main stalled notification when the transcript shows an unanswered owner call', async () => {
226
+ writeSilentOutputFile(SESSION_NAME);
227
+ const transcriptDirectory = fs.mkdtempSync(
228
+ path.join(os.tmpdir(), 'silent-transcript-'),
229
+ );
230
+ const transcriptPath = path.join(
231
+ transcriptDirectory,
232
+ `${SESSION_NAME.replace(/\//g, '_')}.jsonl`,
233
+ );
234
+ fs.writeFileSync(
235
+ transcriptPath,
236
+ [
237
+ JSON.stringify({
238
+ type: 'user',
239
+ timestamp: '2026-06-25T23:00:00.000Z',
240
+ message: { role: 'user', content: 'go ahead' },
241
+ }),
242
+ JSON.stringify({
243
+ type: 'assistant',
244
+ timestamp: '2026-06-25T23:50:00.000Z',
245
+ message: {
246
+ role: 'assistant',
247
+ stop_reason: 'end_turn',
248
+ content: [
249
+ { type: 'text', text: 'waiting <<OWNER_CALL>> please decide' },
250
+ ],
251
+ },
252
+ }),
253
+ ].join('\n'),
254
+ 'utf8',
255
+ );
256
+ const runner = listSessionsRunner();
257
+
258
+ await notifySilentTmuxSessions({
259
+ ...baseParams(runner),
260
+ sessionTranscriptRootDirectory: transcriptDirectory,
261
+ ownerCallMarker: '<<OWNER_CALL>>',
262
+ });
263
+
264
+ const sendCall = runner.runCommand.mock.calls.find(
265
+ (call) => call[0] === 'tmux' && call[1][0] === 'send-keys',
266
+ );
267
+ expect(sendCall).toBeUndefined();
268
+ fs.rmSync(transcriptDirectory, { force: true, recursive: true });
269
+ });
270
+
271
+ it('sends a sub-agent notification driven by a running transcript even when the main session is not silent', async () => {
272
+ const transcriptRoot = fs.mkdtempSync(
273
+ path.join(os.tmpdir(), 'silent-subagent-tx-'),
274
+ );
275
+ const subAgentsDir = path.join(
276
+ transcriptRoot,
277
+ SESSION_NAME.replace(/\//g, '_'),
278
+ 'subagents',
279
+ );
280
+ fs.mkdirSync(subAgentsDir, { recursive: true });
281
+ const agentPath = path.join(subAgentsDir, 'agent-running1.jsonl');
282
+ fs.writeFileSync(
283
+ agentPath,
284
+ [
285
+ JSON.stringify({
286
+ type: 'user',
287
+ timestamp: '2026-06-25T23:30:00.000Z',
288
+ message: { role: 'user' },
289
+ }),
290
+ JSON.stringify({
291
+ type: 'assistant',
292
+ timestamp: '2026-06-25T23:40:00.000Z',
293
+ message: { role: 'assistant', stop_reason: 'tool_use' },
294
+ }),
295
+ ].join('\n'),
296
+ 'utf8',
297
+ );
298
+ const silentEpoch = NOW_EPOCH_SECONDS - 6 * 60;
299
+ fs.utimesSync(agentPath, silentEpoch, silentEpoch);
300
+ const runner = listSessionsRunner();
301
+
302
+ await notifySilentTmuxSessions({
303
+ ...baseParams(runner),
304
+ sessionOutputRootDirectory: null,
305
+ subAgentTranscriptRootDirectory: transcriptRoot,
306
+ });
307
+
308
+ const sendCall = runner.runCommand.mock.calls.find(
309
+ (call) => call[0] === 'tmux' && call[1][0] === 'send-keys',
310
+ );
311
+ expect(sendCall?.[1][2]).toBe(SESSION_NAME);
312
+ expect(sendCall?.[1][4]).toContain('agent-running1');
313
+ fs.rmSync(transcriptRoot, { force: true, recursive: true });
314
+ });
315
+
316
+ it('does not re-notify the same silent session on the next cycle within cooldown', async () => {
317
+ writeSilentOutputFile(SESSION_NAME);
318
+ const cacheRepository = makeCacheRepository();
319
+ const firstRunner = listSessionsRunner();
320
+
321
+ await notifySilentTmuxSessions({
322
+ ...baseParams(firstRunner),
323
+ cacheRepository,
324
+ });
325
+
326
+ const secondRunner = listSessionsRunner();
327
+ await notifySilentTmuxSessions({
328
+ ...baseParams(secondRunner),
329
+ cacheRepository,
330
+ now: new Date(NOW.getTime() + 60 * 1000),
331
+ });
332
+
333
+ const secondSendCall = secondRunner.runCommand.mock.calls.find(
334
+ (call) => call[0] === 'tmux' && call[1][0] === 'send-keys',
335
+ );
336
+ expect(secondSendCall).toBeUndefined();
337
+ });
338
+ });
@@ -0,0 +1,174 @@
1
+ import { Project } from '../../../domain/entities/Project';
2
+ import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
3
+ import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
4
+ import { SessionSubAgentActivityRepository } from '../../../domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository';
5
+ import { OwnerCallStatusProvider } from '../../../domain/usecases/adapter-interfaces/OwnerCallStatusProvider';
6
+ import {
7
+ NotifySilentLiveSessionsUseCase,
8
+ DEFAULT_MONITORED_STATUS,
9
+ DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
10
+ DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
11
+ DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
12
+ DEFAULT_NOTIFICATION_COOLDOWN_SECONDS,
13
+ DEFAULT_NOTIFICATION_STAGGER_SECONDS,
14
+ } from '../../../domain/usecases/NotifySilentLiveSessionsUseCase';
15
+ import { DefaultSilentSessionMessageComposer } from '../../../domain/usecases/DefaultSilentSessionMessageComposer';
16
+ import { NodeTmuxSessionRepository } from '../../repositories/NodeTmuxSessionRepository';
17
+ import { FileSystemSessionOutputActivityRepository } from '../../repositories/FileSystemSessionOutputActivityRepository';
18
+ import { TmuxSilentSessionNotificationRepository } from '../../repositories/TmuxSilentSessionNotificationRepository';
19
+ import { LocalStorageCacheRepository } from '../../repositories/LocalStorageCacheRepository';
20
+ import { NoUnansweredOwnerCallStatusProvider } from '../../repositories/NoUnansweredOwnerCallStatusProvider';
21
+ import { TranscriptOwnerCallStatusProvider } from '../../repositories/TranscriptOwnerCallStatusProvider';
22
+ import { ProcessListSessionSubAgentActivityRepository } from '../../repositories/ProcessListSessionSubAgentActivityRepository';
23
+ import { TranscriptSessionSubAgentActivityRepository } from '../../repositories/TranscriptSessionSubAgentActivityRepository';
24
+ import { FileSystemSubAgentTranscriptDirectoryResolver } from '../../repositories/FileSystemSubAgentTranscriptDirectoryResolver';
25
+ import { NodeSubAgentProcessLister } from '../../repositories/NodeSubAgentProcessLister';
26
+ import { FileSystemSubAgentSilentSecondsResolver } from '../../repositories/FileSystemSubAgentSilentSecondsResolver';
27
+ import {
28
+ ConfigurableSilentSessionMessageComposer,
29
+ SilentSessionMessageTemplates,
30
+ } from '../../repositories/ConfigurableSilentSessionMessageComposer';
31
+ import { RealSleeper } from '../../repositories/RealSleeper';
32
+
33
+ export type NotifySilentTmuxSessionsParams = {
34
+ project: Project;
35
+ allowCacheMinutes: number;
36
+ issueRepository: Pick<IssueRepository, 'getAllOpened'>;
37
+ localCommandRunner: LocalCommandRunner;
38
+ cacheRepository: Pick<LocalStorageCacheRepository, 'getLatest' | 'set'>;
39
+ sessionOutputRootDirectory: string | null;
40
+ sessionTranscriptRootDirectory: string | null;
41
+ ownerCallMarker: string | null;
42
+ subAgentOutputRootDirectory: string | null;
43
+ subAgentProcessMatchPattern: string | null;
44
+ subAgentTranscriptRootDirectory: string | null;
45
+ mainSilentThresholdSeconds: number;
46
+ subAgentSilentThresholdSeconds: number;
47
+ subAgentRunningThresholdSeconds: number;
48
+ cooldownSeconds: number;
49
+ staggerSeconds: number;
50
+ messageTemplates: SilentSessionMessageTemplates;
51
+ now: Date;
52
+ };
53
+
54
+ const createOwnerCallStatusProvider = (
55
+ sessionTranscriptRootDirectory: string | null,
56
+ ownerCallMarker: string | null,
57
+ ): OwnerCallStatusProvider => {
58
+ if (
59
+ sessionTranscriptRootDirectory !== null &&
60
+ ownerCallMarker !== null &&
61
+ ownerCallMarker.length > 0
62
+ ) {
63
+ return new TranscriptOwnerCallStatusProvider(
64
+ sessionTranscriptRootDirectory,
65
+ ownerCallMarker,
66
+ );
67
+ }
68
+ return new NoUnansweredOwnerCallStatusProvider();
69
+ };
70
+
71
+ const createSubAgentActivityRepository = (
72
+ subAgentTranscriptRootDirectory: string | null,
73
+ subAgentProcessMatchPattern: string | null,
74
+ subAgentOutputRootDirectory: string | null,
75
+ localCommandRunner: LocalCommandRunner,
76
+ now: Date,
77
+ ): SessionSubAgentActivityRepository => {
78
+ if (subAgentTranscriptRootDirectory !== null) {
79
+ return new TranscriptSessionSubAgentActivityRepository(
80
+ new FileSystemSubAgentTranscriptDirectoryResolver(
81
+ subAgentTranscriptRootDirectory,
82
+ ),
83
+ now,
84
+ );
85
+ }
86
+ return new ProcessListSessionSubAgentActivityRepository(
87
+ subAgentProcessMatchPattern,
88
+ new NodeSubAgentProcessLister(localCommandRunner),
89
+ new FileSystemSubAgentSilentSecondsResolver(
90
+ subAgentOutputRootDirectory,
91
+ now,
92
+ ),
93
+ );
94
+ };
95
+
96
+ export const notifySilentTmuxSessions = async (
97
+ params: NotifySilentTmuxSessionsParams,
98
+ ): Promise<void> => {
99
+ const {
100
+ project,
101
+ allowCacheMinutes,
102
+ issueRepository,
103
+ localCommandRunner,
104
+ cacheRepository,
105
+ sessionOutputRootDirectory,
106
+ sessionTranscriptRootDirectory,
107
+ ownerCallMarker,
108
+ subAgentOutputRootDirectory,
109
+ subAgentProcessMatchPattern,
110
+ subAgentTranscriptRootDirectory,
111
+ mainSilentThresholdSeconds,
112
+ subAgentSilentThresholdSeconds,
113
+ subAgentRunningThresholdSeconds,
114
+ cooldownSeconds,
115
+ staggerSeconds,
116
+ messageTemplates,
117
+ now,
118
+ } = params;
119
+ if (
120
+ sessionOutputRootDirectory === null &&
121
+ subAgentProcessMatchPattern === null &&
122
+ subAgentTranscriptRootDirectory === null
123
+ ) {
124
+ console.log(
125
+ 'Silent live session notification skipped: no session output root directory, sub-agent process match pattern, or sub-agent transcript root directory is configured.',
126
+ );
127
+ return;
128
+ }
129
+ const messageComposer = new ConfigurableSilentSessionMessageComposer(
130
+ messageTemplates,
131
+ new DefaultSilentSessionMessageComposer(),
132
+ );
133
+ const useCase = new NotifySilentLiveSessionsUseCase(
134
+ issueRepository,
135
+ new NodeTmuxSessionRepository(localCommandRunner),
136
+ new FileSystemSessionOutputActivityRepository(sessionOutputRootDirectory),
137
+ createSubAgentActivityRepository(
138
+ subAgentTranscriptRootDirectory,
139
+ subAgentProcessMatchPattern,
140
+ subAgentOutputRootDirectory,
141
+ localCommandRunner,
142
+ now,
143
+ ),
144
+ createOwnerCallStatusProvider(
145
+ sessionTranscriptRootDirectory,
146
+ ownerCallMarker,
147
+ ),
148
+ new TmuxSilentSessionNotificationRepository(
149
+ localCommandRunner,
150
+ cacheRepository,
151
+ ),
152
+ messageComposer,
153
+ new RealSleeper(),
154
+ );
155
+ await useCase.run({
156
+ project,
157
+ allowCacheMinutes,
158
+ monitoredStatus: DEFAULT_MONITORED_STATUS,
159
+ mainSilentThresholdSeconds,
160
+ subAgentSilentThresholdSeconds,
161
+ subAgentRunningThresholdSeconds,
162
+ cooldownSeconds,
163
+ staggerSeconds,
164
+ now,
165
+ });
166
+ };
167
+
168
+ export const DEFAULT_NOTIFY_SILENT_TMUX_SESSIONS_PARAMS = {
169
+ mainSilentThresholdSeconds: DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
170
+ subAgentSilentThresholdSeconds: DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
171
+ subAgentRunningThresholdSeconds: DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
172
+ cooldownSeconds: DEFAULT_NOTIFICATION_COOLDOWN_SECONDS,
173
+ staggerSeconds: DEFAULT_NOTIFICATION_STAGGER_SECONDS,
174
+ } as const;
@@ -29,6 +29,7 @@ const baseSnapshot = (
29
29
  modelWeeklyLimits: {},
30
30
  lastUpdatedEpoch: 0,
31
31
  blockedUntilEpoch: 0,
32
+ subscriptionDisabled: false,
32
33
  ...overrides,
33
34
  });
34
35
 
@@ -150,6 +151,84 @@ describe('writeTokenStatus', () => {
150
151
  expect(written).toEqual(expected);
151
152
  });
152
153
 
154
+ it('aggregates In-Tmux-by-human sessions across multiple projects, not just the last project written', () => {
155
+ const interactiveSessions: ClaudeInteractiveSession[] = [
156
+ {
157
+ token: 'token-a',
158
+ sessionId: 'session-project-a',
159
+ issueUrl: 'https://github.com/demo/repo/issues/100',
160
+ },
161
+ {
162
+ token: 'token-a',
163
+ sessionId: 'session-project-b',
164
+ issueUrl: 'https://github.com/demo/repo/issues/200',
165
+ },
166
+ ];
167
+ const sharedRepositories = {
168
+ readSnapshot: () => null,
169
+ interactiveSessionRepository: {
170
+ listInteractiveSessions: () => interactiveSessions,
171
+ },
172
+ spawnRepository: { listSpawns: () => [] },
173
+ };
174
+
175
+ writeTokenStatus({
176
+ dashboardDataDir: dir,
177
+ tokenListJsonPath: tokenListPath,
178
+ pjcode: 'projectA',
179
+ issues: [
180
+ makeIssue({
181
+ status: 'In Tmux by human',
182
+ url: 'https://github.com/demo/repo/issues/100',
183
+ }),
184
+ ],
185
+ now,
186
+ ...sharedRepositories,
187
+ });
188
+
189
+ writeTokenStatus({
190
+ dashboardDataDir: dir,
191
+ tokenListJsonPath: tokenListPath,
192
+ pjcode: 'projectB',
193
+ issues: [
194
+ makeIssue({
195
+ status: 'In Tmux by human',
196
+ url: 'https://github.com/demo/repo/issues/200',
197
+ }),
198
+ ],
199
+ now,
200
+ ...sharedRepositories,
201
+ });
202
+
203
+ const written = readJson(path.join(dir, 'token-status.json'));
204
+ const expectedAlice: TokenStatusFile['tokens'][number] = {
205
+ name: 'alice',
206
+ fiveHourUtilizationPercent: null,
207
+ fiveHourResetSeconds: null,
208
+ sevenDayUtilizationPercent: null,
209
+ sevenDayResetSeconds: null,
210
+ color: 'Y',
211
+ prep: 0,
212
+ hum: 2,
213
+ };
214
+ expect(written).toEqual({
215
+ capturedAt: '2026-06-26T12:00:00.000Z',
216
+ tokens: [
217
+ expectedAlice,
218
+ {
219
+ name: 'bob',
220
+ fiveHourUtilizationPercent: null,
221
+ fiveHourResetSeconds: null,
222
+ sevenDayUtilizationPercent: null,
223
+ sevenDayResetSeconds: null,
224
+ color: 'Y',
225
+ prep: 0,
226
+ hum: 0,
227
+ },
228
+ ],
229
+ });
230
+ });
231
+
153
232
  it('is a no-op when dashboardDataDir is unset', () => {
154
233
  writeTokenStatus({
155
234
  dashboardDataDir: null,