github-issue-tower-defence-management 1.109.2 → 1.110.1

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 (201) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +26 -0
  3. package/bin/adapter/entry-points/console/ui-dist/assets/{index-CO-f_j4f.css → index-BPH6W1yn.css} +1 -1
  4. package/bin/adapter/entry-points/console/ui-dist/assets/index-CT3t4Yje.js +101 -0
  5. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +125 -2
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  8. package/bin/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.js +2 -0
  9. package/bin/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.js.map +1 -1
  10. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +2 -0
  11. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -1
  12. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +2 -0
  13. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -1
  14. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +62 -0
  15. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -0
  16. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +88 -3
  17. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -1
  18. package/bin/adapter/proxy/RateLimitCache.js +33 -1
  19. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  20. package/bin/adapter/proxy/proxyEntry.js +25 -0
  21. package/bin/adapter/proxy/proxyEntry.js.map +1 -1
  22. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +37 -0
  23. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -0
  24. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js +76 -0
  25. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js.map +1 -0
  26. package/bin/adapter/repositories/FileSystemSubAgentSilentSecondsResolver.js +67 -0
  27. package/bin/adapter/repositories/FileSystemSubAgentSilentSecondsResolver.js.map +1 -0
  28. package/bin/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.js +50 -0
  29. package/bin/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.js.map +1 -0
  30. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js +12 -0
  31. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js.map +1 -0
  32. package/bin/adapter/repositories/NodeSubAgentProcessLister.js +37 -0
  33. package/bin/adapter/repositories/NodeSubAgentProcessLister.js.map +1 -0
  34. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js +48 -0
  35. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js.map +1 -0
  36. package/bin/adapter/repositories/RealSleeper.js +17 -0
  37. package/bin/adapter/repositories/RealSleeper.js.map +1 -0
  38. package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js +56 -0
  39. package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js.map +1 -0
  40. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +150 -0
  41. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -0
  42. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +152 -0
  43. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -0
  44. package/bin/domain/entities/LiveSessionActivitySnapshot.js +3 -0
  45. package/bin/domain/entities/LiveSessionActivitySnapshot.js.map +1 -0
  46. package/bin/domain/entities/LiveSessionOutputActivity.js +3 -0
  47. package/bin/domain/entities/LiveSessionOutputActivity.js.map +1 -0
  48. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +35 -0
  49. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -0
  50. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +113 -0
  51. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -0
  52. package/bin/domain/usecases/OauthTokenSelectUseCase.js +8 -2
  53. package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -1
  54. package/bin/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.js +3 -0
  55. package/bin/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.js.map +1 -0
  56. package/bin/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.js +3 -0
  57. package/bin/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.js.map +1 -0
  58. package/bin/domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository.js +3 -0
  59. package/bin/domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository.js.map +1 -0
  60. package/bin/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.js +3 -0
  61. package/bin/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.js.map +1 -0
  62. package/bin/domain/usecases/adapter-interfaces/SilentSessionNotificationRepository.js +3 -0
  63. package/bin/domain/usecases/adapter-interfaces/SilentSessionNotificationRepository.js.map +1 -0
  64. package/bin/domain/usecases/adapter-interfaces/Sleeper.js +3 -0
  65. package/bin/domain/usecases/adapter-interfaces/Sleeper.js.map +1 -0
  66. package/bin/domain/usecases/adapter-interfaces/SubAgentProcessLister.js +3 -0
  67. package/bin/domain/usecases/adapter-interfaces/SubAgentProcessLister.js.map +1 -0
  68. package/bin/domain/usecases/adapter-interfaces/SubAgentSilentSecondsResolver.js +3 -0
  69. package/bin/domain/usecases/adapter-interfaces/SubAgentSilentSecondsResolver.js.map +1 -0
  70. package/bin/domain/usecases/adapter-interfaces/SubAgentTranscriptDirectoryResolver.js +3 -0
  71. package/bin/domain/usecases/adapter-interfaces/SubAgentTranscriptDirectoryResolver.js.map +1 -0
  72. package/package.json +1 -1
  73. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCopyUrlButton.stories.tsx +24 -0
  74. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCopyUrlButton.test.tsx +63 -0
  75. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCopyUrlButton.tsx +51 -0
  76. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.test.tsx +7 -0
  77. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +2 -0
  78. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.test.tsx +18 -0
  79. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.tsx +2 -0
  80. package/src/adapter/entry-points/console/ui/src/index.css +14 -0
  81. package/src/adapter/entry-points/console/ui-dist/assets/{index-CO-f_j4f.css → index-BPH6W1yn.css} +1 -1
  82. package/src/adapter/entry-points/console/ui-dist/assets/index-CT3t4Yje.js +101 -0
  83. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  84. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +122 -0
  85. package/src/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.ts +2 -0
  86. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +112 -0
  87. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +2 -0
  88. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +131 -0
  89. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +2 -0
  90. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +338 -0
  91. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +174 -0
  92. package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +79 -0
  93. package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +117 -3
  94. package/src/adapter/proxy/RateLimitCache.test.ts +152 -0
  95. package/src/adapter/proxy/RateLimitCache.ts +42 -0
  96. package/src/adapter/proxy/proxyEntry.test.ts +92 -0
  97. package/src/adapter/proxy/proxyEntry.ts +32 -0
  98. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +77 -0
  99. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +51 -0
  100. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.test.ts +92 -0
  101. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +48 -0
  102. package/src/adapter/repositories/FileSystemSubAgentSilentSecondsResolver.test.ts +56 -0
  103. package/src/adapter/repositories/FileSystemSubAgentSilentSecondsResolver.ts +32 -0
  104. package/src/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.test.ts +32 -0
  105. package/src/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.ts +17 -0
  106. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.test.ts +12 -0
  107. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.ts +9 -0
  108. package/src/adapter/repositories/NodeSubAgentProcessLister.test.ts +43 -0
  109. package/src/adapter/repositories/NodeSubAgentProcessLister.ts +40 -0
  110. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.test.ts +109 -0
  111. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.ts +56 -0
  112. package/src/adapter/repositories/RealSleeper.test.ts +19 -0
  113. package/src/adapter/repositories/RealSleeper.ts +12 -0
  114. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.test.ts +153 -0
  115. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.ts +83 -0
  116. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +220 -0
  117. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +139 -0
  118. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +217 -0
  119. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +145 -0
  120. package/src/domain/entities/LiveSessionActivitySnapshot.ts +12 -0
  121. package/src/domain/entities/LiveSessionOutputActivity.ts +4 -0
  122. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +52 -0
  123. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +39 -0
  124. package/src/domain/usecases/InTmuxByHumanSessionTokenCountUseCase.test.ts +2 -0
  125. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +40 -0
  126. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +612 -0
  127. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +223 -0
  128. package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +36 -0
  129. package/src/domain/usecases/OauthTokenSelectUseCase.ts +12 -0
  130. package/src/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.ts +5 -0
  131. package/src/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.ts +7 -0
  132. package/src/domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository.ts +7 -0
  133. package/src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts +6 -0
  134. package/src/domain/usecases/adapter-interfaces/SilentSessionNotificationRepository.ts +11 -0
  135. package/src/domain/usecases/adapter-interfaces/Sleeper.ts +3 -0
  136. package/src/domain/usecases/adapter-interfaces/SubAgentProcessLister.ts +8 -0
  137. package/src/domain/usecases/adapter-interfaces/SubAgentSilentSecondsResolver.ts +3 -0
  138. package/src/domain/usecases/adapter-interfaces/SubAgentTranscriptDirectoryResolver.ts +3 -0
  139. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  140. package/types/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.d.ts.map +1 -1
  141. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -1
  142. package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -1
  143. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +34 -0
  144. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -0
  145. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts +1 -0
  146. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
  147. package/types/adapter/proxy/RateLimitCache.d.ts +3 -0
  148. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  149. package/types/adapter/proxy/proxyEntry.d.ts.map +1 -1
  150. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +15 -0
  151. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -0
  152. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts +10 -0
  153. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts.map +1 -0
  154. package/types/adapter/repositories/FileSystemSubAgentSilentSecondsResolver.d.ts +9 -0
  155. package/types/adapter/repositories/FileSystemSubAgentSilentSecondsResolver.d.ts.map +1 -0
  156. package/types/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.d.ts +7 -0
  157. package/types/adapter/repositories/FileSystemSubAgentTranscriptDirectoryResolver.d.ts.map +1 -0
  158. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts +5 -0
  159. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts.map +1 -0
  160. package/types/adapter/repositories/NodeSubAgentProcessLister.d.ts +8 -0
  161. package/types/adapter/repositories/NodeSubAgentProcessLister.d.ts.map +1 -0
  162. package/types/adapter/repositories/ProcessListSessionSubAgentActivityRepository.d.ts +12 -0
  163. package/types/adapter/repositories/ProcessListSessionSubAgentActivityRepository.d.ts.map +1 -0
  164. package/types/adapter/repositories/RealSleeper.d.ts +5 -0
  165. package/types/adapter/repositories/RealSleeper.d.ts.map +1 -0
  166. package/types/adapter/repositories/TmuxSilentSessionNotificationRepository.d.ts +13 -0
  167. package/types/adapter/repositories/TmuxSilentSessionNotificationRepository.d.ts.map +1 -0
  168. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +10 -0
  169. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -0
  170. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts +12 -0
  171. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -0
  172. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts +12 -0
  173. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts.map +1 -0
  174. package/types/domain/entities/LiveSessionOutputActivity.d.ts +5 -0
  175. package/types/domain/entities/LiveSessionOutputActivity.d.ts.map +1 -0
  176. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +7 -0
  177. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -0
  178. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +41 -0
  179. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -0
  180. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +2 -0
  181. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -1
  182. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts +4 -0
  183. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts.map +1 -0
  184. package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts +5 -0
  185. package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts.map +1 -0
  186. package/types/domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository.d.ts +5 -0
  187. package/types/domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository.d.ts.map +1 -0
  188. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts +6 -0
  189. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts.map +1 -0
  190. package/types/domain/usecases/adapter-interfaces/SilentSessionNotificationRepository.d.ts +6 -0
  191. package/types/domain/usecases/adapter-interfaces/SilentSessionNotificationRepository.d.ts.map +1 -0
  192. package/types/domain/usecases/adapter-interfaces/Sleeper.d.ts +4 -0
  193. package/types/domain/usecases/adapter-interfaces/Sleeper.d.ts.map +1 -0
  194. package/types/domain/usecases/adapter-interfaces/SubAgentProcessLister.d.ts +8 -0
  195. package/types/domain/usecases/adapter-interfaces/SubAgentProcessLister.d.ts.map +1 -0
  196. package/types/domain/usecases/adapter-interfaces/SubAgentSilentSecondsResolver.d.ts +4 -0
  197. package/types/domain/usecases/adapter-interfaces/SubAgentSilentSecondsResolver.d.ts.map +1 -0
  198. package/types/domain/usecases/adapter-interfaces/SubAgentTranscriptDirectoryResolver.d.ts +4 -0
  199. package/types/domain/usecases/adapter-interfaces/SubAgentTranscriptDirectoryResolver.d.ts.map +1 -0
  200. package/bin/adapter/entry-points/console/ui-dist/assets/index-BpccigBS.js +0 -101
  201. package/src/adapter/entry-points/console/ui-dist/assets/index-BpccigBS.js +0 -101
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import type { Issue } from '../../../domain/entities/Issue';
4
+ import { IN_TMUX_STATUS_NAME } from '../../../domain/entities/WorkflowStatus';
4
5
  import {
5
6
  GenerateTokenStatusUseCase,
6
7
  TokenRateLimitSnapshot,
@@ -19,10 +20,13 @@ import { ProcTakeOwnershipSpawnRepository } from '../../repositories/ProcTakeOwn
19
20
  const SEVEN_DAY_SONNET_LIMIT_TYPE = 'seven_day_sonnet';
20
21
  const SEVEN_DAY_OPUS_LIMIT_TYPE = 'seven_day_opus';
21
22
 
23
+ const IN_TMUX_PROJECTS_DIR_NAME = 'token-status-in-tmux';
24
+
22
25
  export type TokenStatusWriterParams = {
23
26
  dashboardDataDir: string | null | undefined;
24
27
  tokenListJsonPath: string | null | undefined;
25
28
  issues: Issue[];
29
+ pjcode?: string | null | undefined;
26
30
  now?: Date;
27
31
  readSnapshot?: (token: string) => RateLimitSnapshot | null;
28
32
  interactiveSessionRepository?: ClaudeInteractiveSessionRepository;
@@ -34,6 +38,12 @@ export type TokenStatusFile = {
34
38
  capturedAt: string;
35
39
  };
36
40
 
41
+ type InTmuxByHumanProjectFile = {
42
+ pjcode: string;
43
+ urls: string[];
44
+ capturedAt: string;
45
+ };
46
+
37
47
  const writeJsonAtomic = (filePath: string, data: unknown): void => {
38
48
  const dir = path.dirname(filePath);
39
49
  fs.mkdirSync(dir, { recursive: true });
@@ -42,6 +52,64 @@ const writeJsonAtomic = (filePath: string, data: unknown): void => {
42
52
  fs.renameSync(tmpPath, filePath);
43
53
  };
44
54
 
55
+ const inTmuxByHumanUrlsFromIssues = (issues: Issue[]): string[] => {
56
+ const urls = new Set<string>();
57
+ for (const issue of issues) {
58
+ if (issue.status === IN_TMUX_STATUS_NAME && issue.isClosed === false) {
59
+ urls.add(issue.url);
60
+ }
61
+ }
62
+ return [...urls];
63
+ };
64
+
65
+ const persistProjectInTmuxByHumanUrls = (
66
+ inTmuxProjectsDir: string,
67
+ pjcode: string,
68
+ urls: string[],
69
+ capturedAt: string,
70
+ ): void => {
71
+ const file: InTmuxByHumanProjectFile = { pjcode, urls, capturedAt };
72
+ writeJsonAtomic(path.join(inTmuxProjectsDir, `${pjcode}.json`), file);
73
+ };
74
+
75
+ const readMachineWideInTmuxByHumanUrls = (
76
+ inTmuxProjectsDir: string,
77
+ ): Set<string> => {
78
+ const urls = new Set<string>();
79
+ let fileNames: string[];
80
+ try {
81
+ fileNames = fs.readdirSync(inTmuxProjectsDir);
82
+ } catch {
83
+ return urls;
84
+ }
85
+ for (const fileName of fileNames) {
86
+ if (!fileName.endsWith('.json')) {
87
+ continue;
88
+ }
89
+ try {
90
+ const parsed: unknown = JSON.parse(
91
+ fs.readFileSync(path.join(inTmuxProjectsDir, fileName), 'utf8'),
92
+ );
93
+ if (
94
+ parsed === null ||
95
+ typeof parsed !== 'object' ||
96
+ !('urls' in parsed) ||
97
+ !Array.isArray(parsed.urls)
98
+ ) {
99
+ continue;
100
+ }
101
+ for (const url of parsed.urls) {
102
+ if (typeof url === 'string') {
103
+ urls.add(url);
104
+ }
105
+ }
106
+ } catch {
107
+ continue;
108
+ }
109
+ }
110
+ return urls;
111
+ };
112
+
45
113
  export const toTokenRateLimitSnapshot = (
46
114
  snapshot: RateLimitSnapshot | null,
47
115
  ): TokenRateLimitSnapshot | null => {
@@ -73,7 +141,7 @@ export const toTokenRateLimitSnapshot = (
73
141
  };
74
142
 
75
143
  export const writeTokenStatus = (params: TokenStatusWriterParams): void => {
76
- const { dashboardDataDir, tokenListJsonPath, issues } = params;
144
+ const { dashboardDataDir, tokenListJsonPath, issues, pjcode } = params;
77
145
  if (!dashboardDataDir || !tokenListJsonPath) {
78
146
  return;
79
147
  }
@@ -83,6 +151,23 @@ export const writeTokenStatus = (params: TokenStatusWriterParams): void => {
83
151
  return;
84
152
  }
85
153
 
154
+ const now = params.now ?? new Date();
155
+ const inTmuxProjectsDir = path.join(
156
+ dashboardDataDir,
157
+ IN_TMUX_PROJECTS_DIR_NAME,
158
+ );
159
+ if (pjcode) {
160
+ persistProjectInTmuxByHumanUrls(
161
+ inTmuxProjectsDir,
162
+ pjcode,
163
+ inTmuxByHumanUrlsFromIssues(issues),
164
+ now.toISOString(),
165
+ );
166
+ }
167
+ const machineWideInTmuxByHumanUrls = pjcode
168
+ ? readMachineWideInTmuxByHumanUrls(inTmuxProjectsDir)
169
+ : new Set(inTmuxByHumanUrlsFromIssues(issues));
170
+
86
171
  const readSnapshot = params.readSnapshot ?? readRateLimit;
87
172
  const interactiveSessionRepository =
88
173
  params.interactiveSessionRepository ??
@@ -112,17 +197,46 @@ export const writeTokenStatus = (params: TokenStatusWriterParams): void => {
112
197
  name: entry.name,
113
198
  token: entry.token,
114
199
  snapshot: null,
200
+ subscriptionDisabled: false,
201
+ unifiedRejected: false,
202
+ }));
203
+ const machineWideInTmuxByHumanIssues: Issue[] = [
204
+ ...machineWideInTmuxByHumanUrls,
205
+ ].map((url) => ({
206
+ nameWithOwner: '',
207
+ number: 0,
208
+ title: '',
209
+ state: 'OPEN',
210
+ status: IN_TMUX_STATUS_NAME,
211
+ story: null,
212
+ nextActionDate: null,
213
+ nextActionHour: null,
214
+ estimationMinutes: null,
215
+ dependedIssueUrls: [],
216
+ completionDate50PercentConfidence: null,
217
+ url,
218
+ assignees: [],
219
+ labels: [],
220
+ org: '',
221
+ repo: '',
222
+ body: '',
223
+ itemId: '',
224
+ isPr: false,
225
+ isInProgress: false,
226
+ isClosed: false,
227
+ createdAt: now,
228
+ author: '',
229
+ closingIssueReferenceUrls: [],
115
230
  }));
116
231
  const humResult = new InTmuxByHumanSessionTokenCountUseCase().run(
117
232
  candidates,
118
233
  interactiveSessionRepository.listInteractiveSessions(),
119
- issues,
234
+ machineWideInTmuxByHumanIssues,
120
235
  );
121
236
  const humCountByToken = new Map<string, number>(
122
237
  humResult.counts.map((count) => [count.token, count.count]),
123
238
  );
124
239
 
125
- const now = params.now ?? new Date();
126
240
  const tokens = new GenerateTokenStatusUseCase().run({
127
241
  tokens: tokenInputs,
128
242
  prepCountByToken,
@@ -9,10 +9,28 @@ import {
9
9
  HEADERLESS_429_MAX_COOLDOWN_SECONDS,
10
10
  parseModelRateLimitsFromBody,
11
11
  parseModelRateLimitsFromHeaders,
12
+ PERMISSION_DISABLED_COOLDOWN_SECONDS,
12
13
  readRateLimit,
13
14
  writeModelRateLimit,
14
15
  writeRateLimit,
16
+ writeSubscriptionDisabled,
15
17
  } from './RateLimitCache';
18
+ import { OauthTokenSelectUseCase } from '../../domain/usecases/OauthTokenSelectUseCase';
19
+
20
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
21
+ v !== null && typeof v === 'object' && !Array.isArray(v);
22
+
23
+ const writeSubscriptionDisabledEpoch = (
24
+ token: string,
25
+ epochSeconds: number,
26
+ ): void => {
27
+ const filePath = cachePathForToken(token);
28
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
29
+ fs.writeFileSync(
30
+ filePath,
31
+ JSON.stringify({ subscriptionDisabledEpoch: epochSeconds }),
32
+ );
33
+ };
16
34
 
17
35
  describe('RateLimitCache', () => {
18
36
  let tempDir: string;
@@ -774,4 +792,138 @@ describe('RateLimitCache', () => {
774
792
  });
775
793
  });
776
794
  });
795
+
796
+ describe('writeSubscriptionDisabled', () => {
797
+ it('should persist subscriptionDisabledEpoch to the cache file', () => {
798
+ const token = 'sub-disabled-token';
799
+ writeSubscriptionDisabled(token);
800
+ const filePath = cachePathForToken(token);
801
+ const raw: unknown = JSON.parse(fs.readFileSync(filePath, 'utf8'));
802
+ const epoch = isRecord(raw) ? raw.subscriptionDisabledEpoch : undefined;
803
+ expect(typeof epoch).toBe('number');
804
+ });
805
+
806
+ it('should preserve existing cache fields when writing subscriptionDisabledEpoch', () => {
807
+ const token = 'sub-disabled-preserve-token';
808
+ writeRateLimit(token, {
809
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
810
+ 'anthropic-ratelimit-unified-5h-reset': '2000100',
811
+ 'anthropic-ratelimit-unified-5h-utilization': '0.1',
812
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
813
+ 'anthropic-ratelimit-unified-7d-reset': '2000200',
814
+ 'anthropic-ratelimit-unified-7d-utilization': '0.1',
815
+ });
816
+ writeSubscriptionDisabled(token);
817
+ const snapshot = readRateLimit(token);
818
+ expect(snapshot?.subscriptionDisabled).toBe(true);
819
+ expect(snapshot?.fiveHourReset).toBe(2000100);
820
+ });
821
+ });
822
+
823
+ describe('readRateLimit subscriptionDisabled', () => {
824
+ it('returns subscriptionDisabled: true after writeSubscriptionDisabled is called', () => {
825
+ const token = 'sub-disabled-read-token';
826
+ writeSubscriptionDisabled(token);
827
+ const snapshot = readRateLimit(token);
828
+ expect(snapshot?.subscriptionDisabled).toBe(true);
829
+ });
830
+
831
+ it('returns subscriptionDisabled: false when no subscriptionDisabledEpoch is in the cache', () => {
832
+ const token = 'sub-not-disabled-token';
833
+ writeRateLimit(token, {
834
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
835
+ 'anthropic-ratelimit-unified-5h-reset': '2000100',
836
+ 'anthropic-ratelimit-unified-5h-utilization': '0.1',
837
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
838
+ 'anthropic-ratelimit-unified-7d-reset': '2000200',
839
+ 'anthropic-ratelimit-unified-7d-utilization': '0.1',
840
+ });
841
+ const snapshot = readRateLimit(token);
842
+ expect(snapshot?.subscriptionDisabled).toBe(false);
843
+ });
844
+
845
+ it('returns subscriptionDisabled: true while within the cooldown window', () => {
846
+ const token = 'sub-disabled-within-cooldown-token';
847
+ const nowEpochSeconds = Date.now() / 1000;
848
+ writeSubscriptionDisabledEpoch(
849
+ token,
850
+ nowEpochSeconds - (PERMISSION_DISABLED_COOLDOWN_SECONDS - 60),
851
+ );
852
+ const snapshot = readRateLimit(token);
853
+ expect(snapshot?.subscriptionDisabled).toBe(true);
854
+ });
855
+
856
+ it('returns subscriptionDisabled: false once the cooldown window has elapsed', () => {
857
+ const token = 'sub-disabled-cooldown-elapsed-token';
858
+ const nowEpochSeconds = Date.now() / 1000;
859
+ writeSubscriptionDisabledEpoch(
860
+ token,
861
+ nowEpochSeconds - (PERMISSION_DISABLED_COOLDOWN_SECONDS + 60),
862
+ );
863
+ const snapshot = readRateLimit(token);
864
+ expect(snapshot?.subscriptionDisabled).toBe(false);
865
+ });
866
+ });
867
+
868
+ describe('writeRateLimit preserves subscriptionDisabledEpoch across header writes', () => {
869
+ const headerBearingResponse: Record<string, string> = {
870
+ 'anthropic-ratelimit-unified-status': 'allowed',
871
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
872
+ 'anthropic-ratelimit-unified-5h-reset': '2000100',
873
+ 'anthropic-ratelimit-unified-5h-utilization': '0.1',
874
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
875
+ 'anthropic-ratelimit-unified-7d-reset': '2000200',
876
+ 'anthropic-ratelimit-unified-7d-utilization': '0.1',
877
+ };
878
+
879
+ it('keeps subscriptionDisabled: true after a later header-bearing 200 response', () => {
880
+ const token = 'sub-disabled-then-200-token';
881
+ writeSubscriptionDisabled(token);
882
+ writeRateLimit(token, headerBearingResponse, 200);
883
+ const snapshot = readRateLimit(token);
884
+ expect(snapshot?.subscriptionDisabled).toBe(true);
885
+ expect(snapshot?.fiveHourReset).toBe(2000100);
886
+ });
887
+
888
+ it('keeps subscriptionDisabled: true after a later header-bearing 429 response', () => {
889
+ const token = 'sub-disabled-then-429-token';
890
+ writeSubscriptionDisabled(token);
891
+ writeRateLimit(token, headerBearingResponse, 429);
892
+ const snapshot = readRateLimit(token);
893
+ expect(snapshot?.subscriptionDisabled).toBe(true);
894
+ });
895
+
896
+ it('still excludes the token from selection after a header-bearing response', () => {
897
+ const token = 'sub-disabled-then-200-selection-token';
898
+ writeSubscriptionDisabled(token);
899
+ writeRateLimit(token, headerBearingResponse, 200);
900
+ const snapshot = readRateLimit(token);
901
+ const useCase = new OauthTokenSelectUseCase();
902
+ const result = useCase.run(
903
+ [
904
+ {
905
+ name: 'disabled-token',
906
+ token,
907
+ snapshot:
908
+ snapshot === null
909
+ ? null
910
+ : {
911
+ fiveHourUtilization: snapshot.fiveHourUtilization,
912
+ fiveHourReset: snapshot.fiveHourReset,
913
+ sevenDayUtilization: snapshot.sevenDayUtilization,
914
+ sevenDayReset: snapshot.sevenDayReset,
915
+ },
916
+ subscriptionDisabled: snapshot?.subscriptionDisabled ?? false,
917
+ unifiedRejected: snapshot?.unifiedRejected ?? false,
918
+ },
919
+ ],
920
+ Date.now() / 1000,
921
+ );
922
+ expect(result.selected).toBeNull();
923
+ expect(result.metrics[0]?.eligible).toBe(false);
924
+ expect(result.metrics[0]?.exclusionReason).toBe(
925
+ 'organization has disabled Claude subscription access for Claude Code',
926
+ );
927
+ });
928
+ });
777
929
  });
@@ -23,6 +23,7 @@ export interface RateLimitSnapshot {
23
23
  modelWeeklyLimits: Record<string, ModelWeeklyLimit>;
24
24
  lastUpdatedEpoch: number;
25
25
  blockedUntilEpoch: number;
26
+ subscriptionDisabled: boolean;
26
27
  }
27
28
 
28
29
  export const PROXY_PORT = 8787;
@@ -33,6 +34,8 @@ export const HEADERLESS_429_DEFAULT_COOLDOWN_SECONDS = 90;
33
34
 
34
35
  export const HEADERLESS_429_MAX_COOLDOWN_SECONDS = 600;
35
36
 
37
+ export const PERMISSION_DISABLED_COOLDOWN_SECONDS = 3600;
38
+
36
39
  export const cacheDir = (): string => {
37
40
  const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
38
41
  return path.join(base, 'tdpm', 'ratelimit');
@@ -77,6 +80,16 @@ const readModelWeeklyLimits = (
77
80
  return result;
78
81
  };
79
82
 
83
+ const readSubscriptionDisabledEpoch = (
84
+ payload: Record<string, unknown>,
85
+ ): { subscriptionDisabledEpoch: number } | Record<string, never> => {
86
+ const stored = payload.subscriptionDisabledEpoch;
87
+ if (typeof stored === 'number') {
88
+ return { subscriptionDisabledEpoch: stored };
89
+ }
90
+ return {};
91
+ };
92
+
80
93
  const cooldownEndFromRetryAfter = (
81
94
  retryAfterSeconds: number | null,
82
95
  nowEpochSeconds: number,
@@ -138,6 +151,7 @@ export const writeRateLimit = (
138
151
  }
139
152
  const existing = readPayload(filePath);
140
153
  const payload = {
154
+ ...readSubscriptionDisabledEpoch(existing),
141
155
  ts: Date.now() / 1000,
142
156
  headers: rateLimitHeaders,
143
157
  modelWeeklyLimits: readModelWeeklyLimits(existing),
@@ -168,6 +182,23 @@ export const writeModelRateLimit = (
168
182
  fs.writeFileSync(filePath, JSON.stringify(payload));
169
183
  };
170
184
 
185
+ export const writeSubscriptionDisabled = (
186
+ token: string,
187
+ baseDir: string = cacheDir(),
188
+ ): void => {
189
+ const dir = baseDir;
190
+ if (!fs.existsSync(dir)) {
191
+ fs.mkdirSync(dir, { recursive: true });
192
+ }
193
+ const filePath = path.join(dir, `${hashToken(token)}.json`);
194
+ const existing = readPayload(filePath);
195
+ const payload = {
196
+ ...existing,
197
+ subscriptionDisabledEpoch: Date.now() / 1000,
198
+ };
199
+ fs.writeFileSync(filePath, JSON.stringify(payload));
200
+ };
201
+
171
202
  export const parseModelRateLimitsFromBody = (
172
203
  body: string,
173
204
  ): Record<string, ModelWeeklyLimit> => {
@@ -264,6 +295,16 @@ export const readRateLimit = (
264
295
  const storedBlockedUntil = parsed.blockedUntilEpoch;
265
296
  const blockedUntilEpoch =
266
297
  typeof storedBlockedUntil === 'number' ? storedBlockedUntil : 0;
298
+ const storedSubscriptionDisabledEpoch = parsed.subscriptionDisabledEpoch;
299
+ const subscriptionDisabledEpoch =
300
+ typeof storedSubscriptionDisabledEpoch === 'number'
301
+ ? storedSubscriptionDisabledEpoch
302
+ : 0;
303
+ const nowEpochSeconds = Date.now() / 1000;
304
+ const subscriptionDisabled =
305
+ subscriptionDisabledEpoch > 0 &&
306
+ nowEpochSeconds - subscriptionDisabledEpoch <
307
+ PERMISSION_DISABLED_COOLDOWN_SECONDS;
267
308
  return {
268
309
  fiveHourUtilization: num('anthropic-ratelimit-unified-5h-utilization'),
269
310
  fiveHourReset: num('anthropic-ratelimit-unified-5h-reset'),
@@ -285,6 +326,7 @@ export const readRateLimit = (
285
326
  },
286
327
  lastUpdatedEpoch,
287
328
  blockedUntilEpoch,
329
+ subscriptionDisabled,
288
330
  };
289
331
  } catch {
290
332
  return null;
@@ -331,4 +331,96 @@ describe('startProxy', () => {
331
331
  expect(response.body).toBe('Upstream error');
332
332
  expect(writeModelRateLimitSpy).not.toHaveBeenCalled();
333
333
  });
334
+
335
+ it('should call writeSubscriptionDisabled when the response body contains the subscription-disabled message', async () => {
336
+ const writeSubscriptionDisabledSpy = jest
337
+ .spyOn(RateLimitCache, 'writeSubscriptionDisabled')
338
+ .mockImplementation(() => undefined);
339
+
340
+ upstreamHandler = (_request, response) => {
341
+ response.writeHead(403, { 'content-type': 'application/json' });
342
+ response.end(
343
+ JSON.stringify({
344
+ error: {
345
+ message:
346
+ 'Your organization has disabled Claude subscription access for Claude Code',
347
+ },
348
+ }),
349
+ );
350
+ };
351
+
352
+ await requestThroughProxy('POST', '/v1/messages', null);
353
+
354
+ expect(writeSubscriptionDisabledSpy).toHaveBeenCalledTimes(1);
355
+ expect(writeSubscriptionDisabledSpy).toHaveBeenCalledWith(TOKEN);
356
+ writeSubscriptionDisabledSpy.mockRestore();
357
+ });
358
+
359
+ it('should call writeSubscriptionDisabled when a 403 response body is a permission_error', async () => {
360
+ const writeSubscriptionDisabledSpy = jest
361
+ .spyOn(RateLimitCache, 'writeSubscriptionDisabled')
362
+ .mockImplementation(() => undefined);
363
+
364
+ upstreamHandler = (_request, response) => {
365
+ response.writeHead(403, { 'content-type': 'application/json' });
366
+ response.end(
367
+ JSON.stringify({
368
+ type: 'error',
369
+ error: {
370
+ type: 'permission_error',
371
+ message:
372
+ 'OAuth authentication is currently not allowed for this organization',
373
+ },
374
+ }),
375
+ );
376
+ };
377
+
378
+ await requestThroughProxy('POST', '/v1/messages', null);
379
+
380
+ expect(writeSubscriptionDisabledSpy).toHaveBeenCalledTimes(1);
381
+ expect(writeSubscriptionDisabledSpy).toHaveBeenCalledWith(TOKEN);
382
+ writeSubscriptionDisabledSpy.mockRestore();
383
+ });
384
+
385
+ it('should not call writeSubscriptionDisabled when a permission_error body has a non-403 status', async () => {
386
+ const writeSubscriptionDisabledSpy = jest
387
+ .spyOn(RateLimitCache, 'writeSubscriptionDisabled')
388
+ .mockImplementation(() => undefined);
389
+
390
+ upstreamHandler = (_request, response) => {
391
+ response.writeHead(200, { 'content-type': 'application/json' });
392
+ response.end(
393
+ JSON.stringify({
394
+ type: 'error',
395
+ error: { type: 'permission_error', message: 'noise' },
396
+ }),
397
+ );
398
+ };
399
+
400
+ await requestThroughProxy('POST', '/v1/messages', null);
401
+
402
+ expect(writeSubscriptionDisabledSpy).not.toHaveBeenCalled();
403
+ writeSubscriptionDisabledSpy.mockRestore();
404
+ });
405
+
406
+ it('should not call writeSubscriptionDisabled when a 403 body is not a permission_error', async () => {
407
+ const writeSubscriptionDisabledSpy = jest
408
+ .spyOn(RateLimitCache, 'writeSubscriptionDisabled')
409
+ .mockImplementation(() => undefined);
410
+
411
+ upstreamHandler = (_request, response) => {
412
+ response.writeHead(403, { 'content-type': 'application/json' });
413
+ response.end(
414
+ JSON.stringify({
415
+ type: 'error',
416
+ error: { type: 'authentication_error', message: 'bad token' },
417
+ }),
418
+ );
419
+ };
420
+
421
+ await requestThroughProxy('POST', '/v1/messages', null);
422
+
423
+ expect(writeSubscriptionDisabledSpy).not.toHaveBeenCalled();
424
+ writeSubscriptionDisabledSpy.mockRestore();
425
+ });
334
426
  });
@@ -6,6 +6,7 @@ import {
6
6
  parseModelRateLimitsFromBody,
7
7
  writeModelRateLimit,
8
8
  writeRateLimit,
9
+ writeSubscriptionDisabled,
9
10
  } from './RateLimitCache';
10
11
  import { ClaudeMessageResponseRepository } from '../../domain/usecases/adapter-interfaces/ClaudeMessageResponseRepository';
11
12
  import { parseClaudeMessageResponse } from './ClaudeMessageResponseParser';
@@ -29,6 +30,22 @@ const extractToken = (
29
30
  return token.length > 0 ? token : null;
30
31
  };
31
32
 
33
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
34
+ value !== null && typeof value === 'object' && !Array.isArray(value);
35
+
36
+ const isPermissionError = (body: string): boolean => {
37
+ let parsed: unknown;
38
+ try {
39
+ parsed = JSON.parse(body);
40
+ } catch {
41
+ return false;
42
+ }
43
+ if (!isRecord(parsed)) return false;
44
+ const error = parsed.error;
45
+ if (!isRecord(error)) return false;
46
+ return error.type === 'permission_error';
47
+ };
48
+
32
49
  const startProxy = (
33
50
  port: number,
34
51
  claudeMessageResponseRepository: ClaudeMessageResponseRepository | null = null,
@@ -73,6 +90,21 @@ const startProxy = (
73
90
  } catch (error) {
74
91
  console.error('Failed to write model rate limit cache:', error);
75
92
  }
93
+ if (
94
+ body.includes(
95
+ 'Your organization has disabled Claude subscription access for Claude Code',
96
+ ) ||
97
+ (upstreamResponse.statusCode === 403 && isPermissionError(body))
98
+ ) {
99
+ try {
100
+ writeSubscriptionDisabled(token);
101
+ } catch (error) {
102
+ console.error(
103
+ 'Failed to write subscription disabled cache:',
104
+ error,
105
+ );
106
+ }
107
+ }
76
108
  if (claudeMessageResponseRepository !== null) {
77
109
  try {
78
110
  const response = parseClaudeMessageResponse(
@@ -0,0 +1,77 @@
1
+ import { ConfigurableSilentSessionMessageComposer } from './ConfigurableSilentSessionMessageComposer';
2
+ import { SilentSessionMessageComposer } from '../../domain/usecases/adapter-interfaces/SilentSessionMessageComposer';
3
+
4
+ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
5
+
6
+ const createFallback = (): Mocked<SilentSessionMessageComposer> => ({
7
+ composeMainStalledSection: jest.fn().mockReturnValue('FALLBACK_MAIN'),
8
+ composeSubAgentSection: jest.fn().mockReturnValue('FALLBACK_SUB'),
9
+ });
10
+
11
+ describe('ConfigurableSilentSessionMessageComposer', () => {
12
+ it('uses the fallback main section when no main template is configured', () => {
13
+ const fallback = createFallback();
14
+ const composer = new ConfigurableSilentSessionMessageComposer(
15
+ {
16
+ mainStalledMessage: null,
17
+ subAgentMessageHeader: null,
18
+ subAgentMessageFooter: null,
19
+ },
20
+ fallback,
21
+ );
22
+ expect(composer.composeMainStalledSection(600)).toBe('FALLBACK_MAIN');
23
+ expect(fallback.composeMainStalledSection).toHaveBeenCalledWith(600);
24
+ });
25
+
26
+ it('uses the configured main template when provided', () => {
27
+ const fallback = createFallback();
28
+ const composer = new ConfigurableSilentSessionMessageComposer(
29
+ {
30
+ mainStalledMessage: 'CUSTOM_MAIN',
31
+ subAgentMessageHeader: null,
32
+ subAgentMessageFooter: null,
33
+ },
34
+ fallback,
35
+ );
36
+ expect(composer.composeMainStalledSection(600)).toBe('CUSTOM_MAIN');
37
+ expect(fallback.composeMainStalledSection).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it('uses the fallback sub-agent section when neither header nor footer is configured', () => {
41
+ const fallback = createFallback();
42
+ const composer = new ConfigurableSilentSessionMessageComposer(
43
+ {
44
+ mainStalledMessage: null,
45
+ subAgentMessageHeader: null,
46
+ subAgentMessageFooter: null,
47
+ },
48
+ fallback,
49
+ );
50
+ expect(
51
+ composer.composeSubAgentSection([
52
+ { label: 'task-a', silentSeconds: 360, runningSeconds: 1200 },
53
+ ]),
54
+ ).toBe('FALLBACK_SUB');
55
+ });
56
+
57
+ it('renders the configured sub-agent header, list, and footer', () => {
58
+ const fallback = createFallback();
59
+ const composer = new ConfigurableSilentSessionMessageComposer(
60
+ {
61
+ mainStalledMessage: null,
62
+ subAgentMessageHeader: 'HEADER',
63
+ subAgentMessageFooter: 'FOOTER',
64
+ },
65
+ fallback,
66
+ );
67
+ const section = composer.composeSubAgentSection([
68
+ { label: 'task-a', silentSeconds: 360, runningSeconds: 1200 },
69
+ ]);
70
+ expect(section).toContain('HEADER');
71
+ expect(section).toContain('task-a');
72
+ expect(section).toContain('silent for 6m');
73
+ expect(section).toContain('running for 20m');
74
+ expect(section).toContain('FOOTER');
75
+ expect(fallback.composeSubAgentSection).not.toHaveBeenCalled();
76
+ });
77
+ });