github-issue-tower-defence-management 1.95.0 → 1.97.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 (69) hide show
  1. package/.github/workflows/console-ui.yml +17 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +20 -0
  4. package/bin/adapter/entry-points/cli/index.js +21 -0
  5. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  6. package/bin/adapter/entry-points/console/ui-dist/assets/{index-BeJzGnfH.js → index-B3F4E8LD.js} +33 -33
  7. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +18 -2
  9. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  10. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +77 -0
  11. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -0
  12. package/bin/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.js +19 -0
  13. package/bin/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.js.map +1 -0
  14. package/bin/adapter/repositories/NodeTmuxSessionRepository.js +44 -0
  15. package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -0
  16. package/bin/adapter/repositories/ProcClaudeLiveSessionRepository.js +139 -0
  17. package/bin/adapter/repositories/ProcClaudeLiveSessionRepository.js.map +1 -0
  18. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +59 -0
  19. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -0
  20. package/bin/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.js +3 -0
  21. package/bin/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.js.map +1 -0
  22. package/bin/domain/usecases/adapter-interfaces/TmuxSessionRepository.js +3 -0
  23. package/bin/domain/usecases/adapter-interfaces/TmuxSessionRepository.js.map +1 -0
  24. package/bin/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.js +41 -0
  25. package/bin/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.js.map +1 -0
  26. package/package.json +4 -1
  27. package/src/adapter/entry-points/cli/index.ts +38 -0
  28. package/src/adapter/entry-points/console/ui/biome.json +7 -1
  29. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +93 -0
  30. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +380 -0
  31. package/src/adapter/entry-points/console/ui/e2e/playwright.config.ts +30 -0
  32. package/src/adapter/entry-points/console/ui/e2e/tsconfig.json +18 -0
  33. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +2 -4
  34. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +5 -6
  35. package/src/adapter/entry-points/console/ui-dist/assets/{index-BeJzGnfH.js → index-B3F4E8LD.js} +33 -33
  36. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  37. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +17 -0
  38. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +268 -0
  39. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +116 -0
  40. package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.test.ts +123 -0
  41. package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.ts +29 -0
  42. package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +100 -0
  43. package/src/adapter/repositories/NodeTmuxSessionRepository.ts +53 -0
  44. package/src/adapter/repositories/ProcClaudeLiveSessionRepository.test.ts +146 -0
  45. package/src/adapter/repositories/ProcClaudeLiveSessionRepository.ts +119 -0
  46. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +147 -0
  47. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +100 -0
  48. package/src/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.ts +8 -0
  49. package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +9 -0
  50. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +186 -0
  51. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.ts +75 -0
  52. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  53. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  54. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts +20 -0
  55. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -0
  56. package/types/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.d.ts +10 -0
  57. package/types/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.d.ts.map +1 -0
  58. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +10 -0
  59. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -0
  60. package/types/adapter/repositories/ProcClaudeLiveSessionRepository.d.ts +11 -0
  61. package/types/adapter/repositories/ProcClaudeLiveSessionRepository.d.ts.map +1 -0
  62. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +23 -0
  63. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -0
  64. package/types/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.d.ts +8 -0
  65. package/types/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.d.ts.map +1 -0
  66. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +6 -0
  67. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -0
  68. package/types/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.d.ts +19 -0
  69. package/types/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.d.ts.map +1 -0
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="/assets/index-BeJzGnfH.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-B3F4E8LD.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-CO3Vvzqr.css">
9
9
  </head>
10
10
  <body>
@@ -4,6 +4,7 @@ import fs from 'fs';
4
4
  import { writeSituationFile } from './situationFileWriter';
5
5
  import { writeConsoleLists } from './consoleListsWriter';
6
6
  import { writeInTmuxByHumanData } from './inTmuxByHumanDataWriter';
7
+ import { reconcileInTmuxByHumanSessions } from './inTmuxByHumanSessionReconciler';
7
8
  import { writeRotationOrderFile } from './rotationOrderFileWriter';
8
9
  import {
9
10
  fetchProjectReadme,
@@ -73,6 +74,7 @@ export class HandleScheduledEventUseCaseHandler {
73
74
  inTmuxConsoleBaseUrl?: string;
74
75
  inTmuxConsoleToken?: string;
75
76
  inTmuxProjectOrder?: string[];
77
+ inTmuxLauncherCommand?: string;
76
78
  credentials: {
77
79
  manager: {
78
80
  github: {
@@ -409,6 +411,21 @@ export class HandleScheduledEventUseCaseHandler {
409
411
  }`,
410
412
  );
411
413
  }
414
+
415
+ try {
416
+ await reconcileInTmuxByHumanSessions({
417
+ inTmuxLauncherCommand: mergedInput.inTmuxLauncherCommand ?? null,
418
+ assigneeLogin: input.manager,
419
+ issues: result.issues,
420
+ localCommandRunner: nodeLocalCommandRunner,
421
+ });
422
+ } catch (error) {
423
+ console.error(
424
+ `Failed to reconcile in-tmux-by-human sessions: ${
425
+ error instanceof Error ? error.message : String(error)
426
+ }`,
427
+ );
428
+ }
412
429
  }
413
430
  return result;
414
431
  };
@@ -0,0 +1,268 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import {
5
+ ClaudeLiveSession,
6
+ ClaudeLiveSessionRepository,
7
+ } from '../../../domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository';
8
+ import { LiveSessionOauthTokenSelectUseCase } from '../../../domain/usecases/LiveSessionOauthTokenSelectUseCase';
9
+ import { hashToken } from '../../proxy/RateLimitCache';
10
+ import { LiveSessionOauthTokenSelectHandler } from './LiveSessionOauthTokenSelectHandler';
11
+
12
+ const NOW = 2_000_000;
13
+ const HOUR = 3600;
14
+ const DAY = 86400;
15
+
16
+ type FakeHeaders = {
17
+ fiveHourUtilization: number;
18
+ fiveHourReset: number;
19
+ sevenDayUtilization: number;
20
+ sevenDayReset: number;
21
+ };
22
+
23
+ class FakeClaudeLiveSessionRepository implements ClaudeLiveSessionRepository {
24
+ constructor(private readonly sessions: ClaudeLiveSession[]) {}
25
+
26
+ listLiveSessions = (): ClaudeLiveSession[] => this.sessions;
27
+ }
28
+
29
+ describe('LiveSessionOauthTokenSelectHandler', () => {
30
+ let tempDir: string;
31
+ let cacheDirectory: string;
32
+ let tokenListPath: string;
33
+ const originalTokenListEnv =
34
+ process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH;
35
+ const originalCacheEnv = process.env.TDPM_RATELIMIT_CACHE_DIR;
36
+
37
+ beforeEach(() => {
38
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'select-live-token-'));
39
+ cacheDirectory = path.join(tempDir, 'cache');
40
+ fs.mkdirSync(cacheDirectory, { recursive: true });
41
+ tokenListPath = path.join(tempDir, 'tokens.json');
42
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH;
43
+ delete process.env.TDPM_RATELIMIT_CACHE_DIR;
44
+ });
45
+
46
+ afterEach(() => {
47
+ if (originalTokenListEnv === undefined) {
48
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH;
49
+ } else {
50
+ process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH = originalTokenListEnv;
51
+ }
52
+ if (originalCacheEnv === undefined) {
53
+ delete process.env.TDPM_RATELIMIT_CACHE_DIR;
54
+ } else {
55
+ process.env.TDPM_RATELIMIT_CACHE_DIR = originalCacheEnv;
56
+ }
57
+ fs.rmSync(tempDir, { recursive: true, force: true });
58
+ });
59
+
60
+ const writeTokenList = (entries: { name: string; token: string }[]): void => {
61
+ fs.writeFileSync(tokenListPath, JSON.stringify(entries));
62
+ };
63
+
64
+ const writeCache = (token: string, headers: FakeHeaders): void => {
65
+ const payload = {
66
+ ts: NOW,
67
+ headers: {
68
+ 'anthropic-ratelimit-unified-status': 'allowed',
69
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
70
+ 'anthropic-ratelimit-unified-5h-reset': String(headers.fiveHourReset),
71
+ 'anthropic-ratelimit-unified-5h-utilization': String(
72
+ headers.fiveHourUtilization,
73
+ ),
74
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
75
+ 'anthropic-ratelimit-unified-7d-reset': String(headers.sevenDayReset),
76
+ 'anthropic-ratelimit-unified-7d-utilization': String(
77
+ headers.sevenDayUtilization,
78
+ ),
79
+ },
80
+ modelWeeklyLimits: {},
81
+ };
82
+ fs.writeFileSync(
83
+ path.join(cacheDirectory, `${hashToken(token)}.json`),
84
+ JSON.stringify(payload),
85
+ );
86
+ };
87
+
88
+ const buildHandler = (
89
+ sessions: ClaudeLiveSession[],
90
+ ): LiveSessionOauthTokenSelectHandler =>
91
+ new LiveSessionOauthTokenSelectHandler(
92
+ new LiveSessionOauthTokenSelectUseCase(),
93
+ new FakeClaudeLiveSessionRepository(sessions),
94
+ );
95
+
96
+ it('selects the eligible token with the fewest live sessions', () => {
97
+ writeTokenList([
98
+ { name: 'busy', token: 'fake-busy' },
99
+ { name: 'idle', token: 'fake-idle' },
100
+ ]);
101
+ writeCache('fake-busy', {
102
+ fiveHourUtilization: 0.1,
103
+ fiveHourReset: NOW + HOUR,
104
+ sevenDayUtilization: 0.1,
105
+ sevenDayReset: NOW + 2 * DAY,
106
+ });
107
+ writeCache('fake-idle', {
108
+ fiveHourUtilization: 0.1,
109
+ fiveHourReset: NOW + HOUR,
110
+ sevenDayUtilization: 0.1,
111
+ sevenDayReset: NOW + 6 * DAY,
112
+ });
113
+
114
+ const handler = buildHandler([
115
+ { token: 'fake-busy', sessionId: 'session-a' },
116
+ ]);
117
+ const output = handler.handle({
118
+ tokenListJsonPath: tokenListPath,
119
+ cacheDirectory,
120
+ nowEpochSeconds: NOW,
121
+ });
122
+
123
+ expect(output.selectedName).toBe('idle');
124
+ expect(output.selectedToken).toBe('fake-idle');
125
+ });
126
+
127
+ it('breaks an occupancy tie by the soonest 7d reset', () => {
128
+ writeTokenList([
129
+ { name: 'far', token: 'fake-far' },
130
+ { name: 'soon', token: 'fake-soon' },
131
+ ]);
132
+ writeCache('fake-far', {
133
+ fiveHourUtilization: 0.1,
134
+ fiveHourReset: NOW + HOUR,
135
+ sevenDayUtilization: 0.1,
136
+ sevenDayReset: NOW + 6 * DAY,
137
+ });
138
+ writeCache('fake-soon', {
139
+ fiveHourUtilization: 0.1,
140
+ fiveHourReset: NOW + HOUR,
141
+ sevenDayUtilization: 0.1,
142
+ sevenDayReset: NOW + 2 * DAY,
143
+ });
144
+
145
+ const handler = buildHandler([
146
+ { token: 'fake-far', sessionId: 'session-a' },
147
+ { token: 'fake-soon', sessionId: 'session-b' },
148
+ ]);
149
+ const output = handler.handle({
150
+ tokenListJsonPath: tokenListPath,
151
+ cacheDirectory,
152
+ nowEpochSeconds: NOW,
153
+ });
154
+
155
+ expect(output.selectedName).toBe('soon');
156
+ });
157
+
158
+ it('dedupes child processes that share a session id when counting occupancy', () => {
159
+ writeTokenList([
160
+ { name: 'oneSession', token: 'fake-one' },
161
+ { name: 'twoSessions', token: 'fake-two' },
162
+ ]);
163
+ writeCache('fake-one', {
164
+ fiveHourUtilization: 0.1,
165
+ fiveHourReset: NOW + HOUR,
166
+ sevenDayUtilization: 0.1,
167
+ sevenDayReset: NOW + 2 * DAY,
168
+ });
169
+ writeCache('fake-two', {
170
+ fiveHourUtilization: 0.1,
171
+ fiveHourReset: NOW + HOUR,
172
+ sevenDayUtilization: 0.1,
173
+ sevenDayReset: NOW + 6 * DAY,
174
+ });
175
+
176
+ const handler = buildHandler([
177
+ { token: 'fake-one', sessionId: 'session-a' },
178
+ { token: 'fake-one', sessionId: 'session-a' },
179
+ { token: 'fake-two', sessionId: 'session-b' },
180
+ { token: 'fake-two', sessionId: 'session-c' },
181
+ ]);
182
+ const output = handler.handle({
183
+ tokenListJsonPath: tokenListPath,
184
+ cacheDirectory,
185
+ nowEpochSeconds: NOW,
186
+ });
187
+
188
+ expect(output.selectedName).toBe('oneSession');
189
+ });
190
+
191
+ it('excludes a rate-limit-ineligible token even when it is unoccupied', () => {
192
+ writeTokenList([
193
+ { name: 'blocked', token: 'fake-blocked' },
194
+ { name: 'free', token: 'fake-free' },
195
+ ]);
196
+ writeCache('fake-blocked', {
197
+ fiveHourUtilization: 0.9,
198
+ fiveHourReset: NOW + HOUR,
199
+ sevenDayUtilization: 0.1,
200
+ sevenDayReset: NOW + DAY,
201
+ });
202
+ writeCache('fake-free', {
203
+ fiveHourUtilization: 0.1,
204
+ fiveHourReset: NOW + HOUR,
205
+ sevenDayUtilization: 0.1,
206
+ sevenDayReset: NOW + DAY,
207
+ });
208
+
209
+ const handler = buildHandler([
210
+ { token: 'fake-free', sessionId: 'session-a' },
211
+ ]);
212
+ const output = handler.handle({
213
+ tokenListJsonPath: tokenListPath,
214
+ cacheDirectory,
215
+ nowEpochSeconds: NOW,
216
+ });
217
+
218
+ expect(output.selectedName).toBe('free');
219
+ });
220
+
221
+ it('returns null and a diagnostic when no token passes the filter', () => {
222
+ writeTokenList([{ name: 'busy', token: 'fake-busy' }]);
223
+ writeCache('fake-busy', {
224
+ fiveHourUtilization: 0.9,
225
+ fiveHourReset: NOW + HOUR,
226
+ sevenDayUtilization: 0.1,
227
+ sevenDayReset: NOW + DAY,
228
+ });
229
+
230
+ const handler = buildHandler([]);
231
+ const output = handler.handle({
232
+ tokenListJsonPath: tokenListPath,
233
+ cacheDirectory,
234
+ nowEpochSeconds: NOW,
235
+ });
236
+
237
+ expect(output.selectedToken).toBeNull();
238
+ expect(output.diagnostics.join('\n')).toContain(
239
+ 'No eligible token passed the rate-limit filter.',
240
+ );
241
+ });
242
+
243
+ it('returns a diagnostic when no token list path is resolvable', () => {
244
+ const handler = buildHandler([]);
245
+ const output = handler.handle({
246
+ tokenListJsonPath: null,
247
+ cacheDirectory,
248
+ nowEpochSeconds: NOW,
249
+ });
250
+
251
+ expect(output.selectedToken).toBeNull();
252
+ expect(output.diagnostics.join('\n')).toContain('No token list path');
253
+ });
254
+
255
+ it('returns a diagnostic when the token list file has no usable entries', () => {
256
+ fs.writeFileSync(tokenListPath, JSON.stringify([]));
257
+
258
+ const handler = buildHandler([]);
259
+ const output = handler.handle({
260
+ tokenListJsonPath: tokenListPath,
261
+ cacheDirectory,
262
+ nowEpochSeconds: NOW,
263
+ });
264
+
265
+ expect(output.selectedToken).toBeNull();
266
+ expect(output.diagnostics.join('\n')).toContain('No usable token entries');
267
+ });
268
+ });
@@ -0,0 +1,116 @@
1
+ import { ClaudeLiveSessionRepository } from '../../../domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository';
2
+ import {
3
+ LiveSessionOauthTokenSelectResult,
4
+ LiveSessionOauthTokenSelectUseCase,
5
+ } from '../../../domain/usecases/LiveSessionOauthTokenSelectUseCase';
6
+ import { OauthTokenCandidate } from '../../../domain/usecases/OauthTokenSelectUseCase';
7
+ import { ProcClaudeLiveSessionRepository } from '../../repositories/ProcClaudeLiveSessionRepository';
8
+ import { readRateLimit } from '../../proxy/RateLimitCache';
9
+ import { loadTokenEntries } from '../../proxy/TokenListLoader';
10
+ import {
11
+ resolveCacheDirectory,
12
+ resolveTokenListJsonPath,
13
+ } from './OauthTokenSelectHandler';
14
+
15
+ export type LiveSessionOauthTokenSelectHandlerInput = {
16
+ tokenListJsonPath: string | null;
17
+ cacheDirectory: string | null;
18
+ nowEpochSeconds: number;
19
+ };
20
+
21
+ export type LiveSessionOauthTokenSelectHandlerOutput = {
22
+ selectedToken: string | null;
23
+ selectedName: string | null;
24
+ diagnostics: string[];
25
+ };
26
+
27
+ export class LiveSessionOauthTokenSelectHandler {
28
+ constructor(
29
+ private readonly useCase: LiveSessionOauthTokenSelectUseCase = new LiveSessionOauthTokenSelectUseCase(),
30
+ private readonly liveSessionRepository: ClaudeLiveSessionRepository = new ProcClaudeLiveSessionRepository(),
31
+ ) {}
32
+
33
+ handle = (
34
+ input: LiveSessionOauthTokenSelectHandlerInput,
35
+ ): LiveSessionOauthTokenSelectHandlerOutput => {
36
+ const tokenListJsonPath = resolveTokenListJsonPath(input.tokenListJsonPath);
37
+ if (tokenListJsonPath === null) {
38
+ return {
39
+ selectedToken: null,
40
+ selectedName: null,
41
+ diagnostics: [
42
+ 'No token list path provided. Pass --tokenListJsonPath or set CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH.',
43
+ ],
44
+ };
45
+ }
46
+
47
+ const entries = loadTokenEntries(tokenListJsonPath);
48
+ if (entries === null) {
49
+ return {
50
+ selectedToken: null,
51
+ selectedName: null,
52
+ diagnostics: [
53
+ `No usable token entries loaded from ${tokenListJsonPath}.`,
54
+ ],
55
+ };
56
+ }
57
+
58
+ const cacheDirectory = resolveCacheDirectory(input.cacheDirectory);
59
+
60
+ const candidates: OauthTokenCandidate[] = entries.map(({ name, token }) => {
61
+ const snapshot = readRateLimit(token, cacheDirectory);
62
+ return {
63
+ name,
64
+ token,
65
+ snapshot:
66
+ snapshot === null
67
+ ? null
68
+ : {
69
+ fiveHourUtilization: snapshot.fiveHourUtilization,
70
+ fiveHourReset: snapshot.fiveHourReset,
71
+ sevenDayUtilization: snapshot.sevenDayUtilization,
72
+ sevenDayReset: snapshot.sevenDayReset,
73
+ },
74
+ };
75
+ });
76
+
77
+ const liveSessions = this.liveSessionRepository.listLiveSessions();
78
+
79
+ const result = this.useCase.run(
80
+ candidates,
81
+ liveSessions,
82
+ input.nowEpochSeconds,
83
+ );
84
+
85
+ return {
86
+ selectedToken: result.selected?.token ?? null,
87
+ selectedName: result.selected?.name ?? null,
88
+ diagnostics: this.formatDiagnostics(result, input.nowEpochSeconds),
89
+ };
90
+ };
91
+
92
+ private formatDiagnostics = (
93
+ result: LiveSessionOauthTokenSelectResult,
94
+ nowEpochSeconds: number,
95
+ ): string[] => {
96
+ const lines = result.metrics.map((metric) => {
97
+ const secondsUntilSevenDayEnd = Math.round(
98
+ metric.sevenDayEndEpoch - nowEpochSeconds,
99
+ );
100
+ const status = metric.eligible
101
+ ? 'eligible'
102
+ : `excluded (${metric.exclusionReason})`;
103
+ return `${metric.name}: ${metric.liveSessionCount} live session(s), 5h ${Math.round(metric.fiveHourFreeRatio * 100)}% free, 7d ${Math.round(metric.sevenDayFreeRatio * 100)}% free, 7d-end in ${secondsUntilSevenDayEnd}s -> ${status}`;
104
+ });
105
+
106
+ if (result.selected === null) {
107
+ lines.push('No eligible token passed the rate-limit filter.');
108
+ } else {
109
+ lines.push(
110
+ `Selected ${result.selected.name} (fewest live sessions, then soonest 7d reset among eligible tokens).`,
111
+ );
112
+ }
113
+
114
+ return lines;
115
+ };
116
+ }
@@ -0,0 +1,123 @@
1
+ import { Issue } from '../../../domain/entities/Issue';
2
+ import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
3
+ import { reconcileInTmuxByHumanSessions } from './inTmuxByHumanSessionReconciler';
4
+
5
+ const ASSIGNEE = 'owner-login';
6
+
7
+ const makeIssue = (overrides: Partial<Issue> = {}): Issue => ({
8
+ nameWithOwner: 'demo/repo',
9
+ number: 1,
10
+ title: 'Issue 1',
11
+ state: 'OPEN',
12
+ status: 'In Tmux by human',
13
+ story: null,
14
+ nextActionDate: null,
15
+ nextActionHour: null,
16
+ estimationMinutes: null,
17
+ dependedIssueUrls: [],
18
+ completionDate50PercentConfidence: null,
19
+ url: 'https://github.com/demo/repo/issues/1',
20
+ assignees: [ASSIGNEE],
21
+ labels: [],
22
+ org: 'demo',
23
+ repo: 'repo',
24
+ body: '',
25
+ itemId: 'item-1',
26
+ isPr: false,
27
+ isInProgress: false,
28
+ isClosed: false,
29
+ createdAt: new Date(),
30
+ author: '',
31
+ ...overrides,
32
+ });
33
+
34
+ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
35
+
36
+ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
37
+ runCommand: jest.fn(),
38
+ });
39
+
40
+ describe('reconcileInTmuxByHumanSessions', () => {
41
+ it('does nothing when no launcher command is configured', async () => {
42
+ const runner = createMockRunner();
43
+
44
+ await reconcileInTmuxByHumanSessions({
45
+ inTmuxLauncherCommand: null,
46
+ assigneeLogin: ASSIGNEE,
47
+ issues: [makeIssue()],
48
+ localCommandRunner: runner,
49
+ });
50
+
51
+ expect(runner.runCommand.mock.calls).toHaveLength(0);
52
+ });
53
+
54
+ it('launches a detached tmux session when an In Tmux by human session is missing', async () => {
55
+ const runner = createMockRunner();
56
+ runner.runCommand.mockImplementation(async (program, args) => {
57
+ if (program === 'tmux' && args[0] === 'list-sessions') {
58
+ return { stdout: '', stderr: '', exitCode: 0 };
59
+ }
60
+ if (program === 'ps') {
61
+ return { stdout: '', stderr: '', exitCode: 0 };
62
+ }
63
+ return { stdout: '', stderr: '', exitCode: 0 };
64
+ });
65
+
66
+ await reconcileInTmuxByHumanSessions({
67
+ inTmuxLauncherCommand: 'cl',
68
+ assigneeLogin: ASSIGNEE,
69
+ issues: [makeIssue()],
70
+ localCommandRunner: runner,
71
+ });
72
+
73
+ const newSessionCall = runner.runCommand.mock.calls.find(
74
+ (call) => call[0] === 'tmux' && call[1][0] === 'new-session',
75
+ );
76
+ expect(newSessionCall).toBeDefined();
77
+ expect(newSessionCall?.[1]).toEqual([
78
+ 'new-session',
79
+ '-d',
80
+ '-s',
81
+ 'https___github_com_demo_repo_issues_1',
82
+ 'sh',
83
+ '-lc',
84
+ 'exec "$1" "$2"',
85
+ 'sh',
86
+ 'cl',
87
+ 'https://github.com/demo/repo/issues/1',
88
+ ]);
89
+ });
90
+
91
+ it('does not launch when a live session already exists', async () => {
92
+ const runner = createMockRunner();
93
+ runner.runCommand.mockImplementation(async (program, args) => {
94
+ if (program === 'tmux' && args[0] === 'list-sessions') {
95
+ return {
96
+ stdout: 'https___github_com_demo_repo_issues_1\n',
97
+ stderr: '',
98
+ exitCode: 0,
99
+ };
100
+ }
101
+ if (program === 'ps') {
102
+ return {
103
+ stdout: 'claude --name https://github.com/demo/repo/issues/1\n',
104
+ stderr: '',
105
+ exitCode: 0,
106
+ };
107
+ }
108
+ return { stdout: '', stderr: '', exitCode: 0 };
109
+ });
110
+
111
+ await reconcileInTmuxByHumanSessions({
112
+ inTmuxLauncherCommand: 'cl',
113
+ assigneeLogin: ASSIGNEE,
114
+ issues: [makeIssue()],
115
+ localCommandRunner: runner,
116
+ });
117
+
118
+ const newSessionCalls = runner.runCommand.mock.calls.filter(
119
+ (call) => call[0] === 'tmux' && call[1][0] === 'new-session',
120
+ );
121
+ expect(newSessionCalls).toHaveLength(0);
122
+ });
123
+ });
@@ -0,0 +1,29 @@
1
+ import { Issue } from '../../../domain/entities/Issue';
2
+ import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
3
+ import { InTmuxByHumanSessionReconcileUseCase } from '../../../domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase';
4
+ import { NodeTmuxSessionRepository } from '../../repositories/NodeTmuxSessionRepository';
5
+
6
+ export type ReconcileInTmuxByHumanSessionsParams = {
7
+ inTmuxLauncherCommand: string | null;
8
+ assigneeLogin: string;
9
+ issues: Issue[];
10
+ localCommandRunner: LocalCommandRunner;
11
+ };
12
+
13
+ export const reconcileInTmuxByHumanSessions = async (
14
+ params: ReconcileInTmuxByHumanSessionsParams,
15
+ ): Promise<void> => {
16
+ const { inTmuxLauncherCommand, assigneeLogin, issues, localCommandRunner } =
17
+ params;
18
+ if (!inTmuxLauncherCommand || !assigneeLogin) {
19
+ return;
20
+ }
21
+ const useCase = new InTmuxByHumanSessionReconcileUseCase(
22
+ new NodeTmuxSessionRepository(localCommandRunner),
23
+ );
24
+ await useCase.run({
25
+ issues,
26
+ assigneeLogin,
27
+ launcherCommand: inTmuxLauncherCommand,
28
+ });
29
+ };
@@ -0,0 +1,100 @@
1
+ import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
2
+ import { NodeTmuxSessionRepository } from './NodeTmuxSessionRepository';
3
+
4
+ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
5
+
6
+ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
7
+ runCommand: jest.fn(),
8
+ });
9
+
10
+ describe('NodeTmuxSessionRepository', () => {
11
+ describe('listLiveSessionNames', () => {
12
+ it('parses tmux session names and drops blank lines', async () => {
13
+ const runner = createMockRunner();
14
+ runner.runCommand.mockResolvedValue({
15
+ stdout: 'session-a\nsession-b\n\n',
16
+ stderr: '',
17
+ exitCode: 0,
18
+ });
19
+ const repository = new NodeTmuxSessionRepository(runner);
20
+
21
+ const result = await repository.listLiveSessionNames();
22
+
23
+ expect(result).toEqual(['session-a', 'session-b']);
24
+ expect(runner.runCommand.mock.calls[0][0]).toBe('tmux');
25
+ expect(runner.runCommand.mock.calls[0][1]).toEqual([
26
+ 'list-sessions',
27
+ '-F',
28
+ '#{session_name}',
29
+ ]);
30
+ });
31
+
32
+ it('returns an empty list when tmux exits non-zero', async () => {
33
+ const runner = createMockRunner();
34
+ runner.runCommand.mockResolvedValue({
35
+ stdout: '',
36
+ stderr: 'no server running',
37
+ exitCode: 1,
38
+ });
39
+ const repository = new NodeTmuxSessionRepository(runner);
40
+
41
+ const result = await repository.listLiveSessionNames();
42
+
43
+ expect(result).toEqual([]);
44
+ });
45
+ });
46
+
47
+ describe('listInteractiveProcessCommandLines', () => {
48
+ it('parses process command lines from ps output', async () => {
49
+ const runner = createMockRunner();
50
+ runner.runCommand.mockResolvedValue({
51
+ stdout:
52
+ 'claude --name https://github.com/demo/repo/issues/1\n/usr/bin/tmux\n',
53
+ stderr: '',
54
+ exitCode: 0,
55
+ });
56
+ const repository = new NodeTmuxSessionRepository(runner);
57
+
58
+ const result = await repository.listInteractiveProcessCommandLines();
59
+
60
+ expect(result).toEqual([
61
+ 'claude --name https://github.com/demo/repo/issues/1',
62
+ '/usr/bin/tmux',
63
+ ]);
64
+ expect(runner.runCommand.mock.calls[0][0]).toBe('ps');
65
+ expect(runner.runCommand.mock.calls[0][1]).toEqual(['-eo', 'args=']);
66
+ });
67
+ });
68
+
69
+ describe('launchDetachedSession', () => {
70
+ it('creates a detached tmux session running the launcher command with the issue url', async () => {
71
+ const runner = createMockRunner();
72
+ runner.runCommand.mockResolvedValue({
73
+ stdout: '',
74
+ stderr: '',
75
+ exitCode: 0,
76
+ });
77
+ const repository = new NodeTmuxSessionRepository(runner);
78
+
79
+ await repository.launchDetachedSession(
80
+ 'https___github_com_demo_repo_issues_1',
81
+ 'cl',
82
+ 'https://github.com/demo/repo/issues/1',
83
+ );
84
+
85
+ expect(runner.runCommand.mock.calls[0][0]).toBe('tmux');
86
+ expect(runner.runCommand.mock.calls[0][1]).toEqual([
87
+ 'new-session',
88
+ '-d',
89
+ '-s',
90
+ 'https___github_com_demo_repo_issues_1',
91
+ 'sh',
92
+ '-lc',
93
+ 'exec "$1" "$2"',
94
+ 'sh',
95
+ 'cl',
96
+ 'https://github.com/demo/repo/issues/1',
97
+ ]);
98
+ });
99
+ });
100
+ });