github-issue-tower-defence-management 1.146.7 → 1.146.8

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 (25) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +5 -4
  3. package/bin/adapter/entry-points/cli/fleetConfig.js +101 -0
  4. package/bin/adapter/entry-points/cli/fleetConfig.js.map +1 -0
  5. package/bin/adapter/entry-points/cli/index.js +5 -2
  6. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +4 -5
  8. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -1
  9. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +24 -9
  10. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/adapter/entry-points/cli/fleetConfig.test.ts +177 -0
  13. package/src/adapter/entry-points/cli/fleetConfig.ts +109 -0
  14. package/src/adapter/entry-points/cli/index.ts +14 -2
  15. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +25 -10
  16. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +5 -5
  17. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +325 -156
  18. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +50 -22
  19. package/types/adapter/entry-points/cli/fleetConfig.d.ts +6 -0
  20. package/types/adapter/entry-points/cli/fleetConfig.d.ts.map +1 -0
  21. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  22. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts +3 -4
  23. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -1
  24. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +10 -2
  25. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -1
@@ -0,0 +1,177 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS } from '../../../domain/usecases/LiveSessionOauthTokenSelectUseCase';
5
+ import {
6
+ FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE,
7
+ loadLiveSessionOauthTokenSelectionSettings,
8
+ resolveFleetConfigFilePath,
9
+ } from './fleetConfig';
10
+
11
+ describe('resolveFleetConfigFilePath', () => {
12
+ const originalEnvironmentValue =
13
+ process.env[FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE];
14
+
15
+ afterEach(() => {
16
+ if (originalEnvironmentValue === undefined) {
17
+ delete process.env[FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE];
18
+ return;
19
+ }
20
+ process.env[FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE] =
21
+ originalEnvironmentValue;
22
+ });
23
+
24
+ it('prefers the explicit value over the environment variable', () => {
25
+ process.env[FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE] =
26
+ '/from/environment.yaml';
27
+
28
+ expect(resolveFleetConfigFilePath('/from/option.yaml')).toBe(
29
+ '/from/option.yaml',
30
+ );
31
+ });
32
+
33
+ it('falls back to the environment variable when no explicit value is given', () => {
34
+ process.env[FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE] =
35
+ '/from/environment.yaml';
36
+
37
+ expect(resolveFleetConfigFilePath(null)).toBe('/from/environment.yaml');
38
+ });
39
+
40
+ it('returns null when neither an explicit value nor the environment variable is set', () => {
41
+ delete process.env[FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE];
42
+
43
+ expect(resolveFleetConfigFilePath(null)).toBeNull();
44
+ });
45
+ });
46
+
47
+ describe('loadLiveSessionOauthTokenSelectionSettings', () => {
48
+ let tempDir: string;
49
+
50
+ const writeFleetConfig = (content: string): string => {
51
+ const fleetConfigFilePath = path.join(tempDir, 'fleet.config.yaml');
52
+ fs.writeFileSync(fleetConfigFilePath, content);
53
+ return fleetConfigFilePath;
54
+ };
55
+
56
+ beforeEach(() => {
57
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fleet-config-'));
58
+ });
59
+
60
+ afterEach(() => {
61
+ fs.rmSync(tempDir, { recursive: true, force: true });
62
+ });
63
+
64
+ it('returns the built-in settings when no fleet config path is given', () => {
65
+ expect(loadLiveSessionOauthTokenSelectionSettings(null)).toEqual(
66
+ DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
67
+ );
68
+ });
69
+
70
+ it('reads both tuning numbers from the fleet config file', () => {
71
+ const fleetConfigFilePath = writeFleetConfig(
72
+ [
73
+ 'inTmuxLauncherCommand: cl',
74
+ 'liveSessionOauthTokenSelection:',
75
+ ' maxConcurrentSessionCount: 16',
76
+ ' fullSpeedFiveHourFreeRatio: 0.4',
77
+ ].join('\n'),
78
+ );
79
+
80
+ expect(
81
+ loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
82
+ ).toEqual({
83
+ maxConcurrentSessionCount: 16,
84
+ fullSpeedFiveHourFreeRatio: 0.4,
85
+ });
86
+ });
87
+
88
+ it('keeps the built-in value for a tuning number the fleet config omits', () => {
89
+ const fleetConfigFilePath = writeFleetConfig(
90
+ [
91
+ 'liveSessionOauthTokenSelection:',
92
+ ' maxConcurrentSessionCount: 16',
93
+ ].join('\n'),
94
+ );
95
+
96
+ expect(
97
+ loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
98
+ ).toEqual({
99
+ maxConcurrentSessionCount: 16,
100
+ fullSpeedFiveHourFreeRatio:
101
+ DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS.fullSpeedFiveHourFreeRatio,
102
+ });
103
+ });
104
+
105
+ it('returns the built-in settings when the fleet config carries no live session section', () => {
106
+ const fleetConfigFilePath = writeFleetConfig('inTmuxLauncherCommand: cl\n');
107
+
108
+ expect(
109
+ loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
110
+ ).toEqual(DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS);
111
+ });
112
+
113
+ it('returns the built-in settings for an empty fleet config file', () => {
114
+ const fleetConfigFilePath = writeFleetConfig('');
115
+
116
+ expect(
117
+ loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
118
+ ).toEqual(DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS);
119
+ });
120
+
121
+ it('throws when the fleet config file does not exist', () => {
122
+ expect(() =>
123
+ loadLiveSessionOauthTokenSelectionSettings(
124
+ path.join(tempDir, 'missing.yaml'),
125
+ ),
126
+ ).toThrow();
127
+ });
128
+
129
+ it('throws when the maximum concurrent session count is not a positive integer', () => {
130
+ const fleetConfigFilePath = writeFleetConfig(
131
+ [
132
+ 'liveSessionOauthTokenSelection:',
133
+ ' maxConcurrentSessionCount: 0',
134
+ ].join('\n'),
135
+ );
136
+
137
+ expect(() =>
138
+ loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
139
+ ).toThrow('maxConcurrentSessionCount');
140
+ });
141
+
142
+ it('throws when the five hour free ratio is above one', () => {
143
+ const fleetConfigFilePath = writeFleetConfig(
144
+ [
145
+ 'liveSessionOauthTokenSelection:',
146
+ ' fullSpeedFiveHourFreeRatio: 1.5',
147
+ ].join('\n'),
148
+ );
149
+
150
+ expect(() =>
151
+ loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
152
+ ).toThrow('fullSpeedFiveHourFreeRatio');
153
+ });
154
+
155
+ it('throws when a tuning number is written as a string', () => {
156
+ const fleetConfigFilePath = writeFleetConfig(
157
+ [
158
+ 'liveSessionOauthTokenSelection:',
159
+ " maxConcurrentSessionCount: '16'",
160
+ ].join('\n'),
161
+ );
162
+
163
+ expect(() =>
164
+ loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
165
+ ).toThrow('must be a number');
166
+ });
167
+
168
+ it('throws when the live session section is not a mapping', () => {
169
+ const fleetConfigFilePath = writeFleetConfig(
170
+ 'liveSessionOauthTokenSelection: 10\n',
171
+ );
172
+
173
+ expect(() =>
174
+ loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
175
+ ).toThrow('must be a mapping');
176
+ });
177
+ });
@@ -0,0 +1,109 @@
1
+ import YAML from 'yaml';
2
+ import * as fs from 'fs';
3
+ import {
4
+ DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
5
+ LiveSessionOauthTokenSelectionSettings,
6
+ } from '../../../domain/usecases/LiveSessionOauthTokenSelectUseCase';
7
+
8
+ export const FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE = 'TDPM_FLEET_CONFIG';
9
+
10
+ export const LIVE_SESSION_OAUTH_TOKEN_SELECTION_SECTION_KEY =
11
+ 'liveSessionOauthTokenSelection';
12
+
13
+ export const resolveFleetConfigFilePath = (
14
+ cliValue: string | null,
15
+ ): string | null => {
16
+ if (cliValue !== null && cliValue !== '') {
17
+ return cliValue;
18
+ }
19
+ const fromEnvironment =
20
+ process.env[FLEET_CONFIG_FILE_PATH_ENVIRONMENT_VARIABLE];
21
+ if (fromEnvironment !== undefined && fromEnvironment !== '') {
22
+ return fromEnvironment;
23
+ }
24
+ return null;
25
+ };
26
+
27
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
28
+ typeof value === 'object' && value !== null && !Array.isArray(value);
29
+
30
+ const readSection = (
31
+ fleetConfigFilePath: string,
32
+ ): Record<string, unknown> | null => {
33
+ const parsed: unknown = YAML.parse(
34
+ fs.readFileSync(fleetConfigFilePath, 'utf8'),
35
+ );
36
+ if (parsed === null || parsed === undefined) {
37
+ return null;
38
+ }
39
+ if (!isRecord(parsed)) {
40
+ throw new Error(
41
+ `${fleetConfigFilePath} does not hold a mapping at its top level.`,
42
+ );
43
+ }
44
+ const section = parsed[LIVE_SESSION_OAUTH_TOKEN_SELECTION_SECTION_KEY];
45
+ if (section === undefined || section === null) {
46
+ return null;
47
+ }
48
+ if (!isRecord(section)) {
49
+ throw new Error(
50
+ `${LIVE_SESSION_OAUTH_TOKEN_SELECTION_SECTION_KEY} in ${fleetConfigFilePath} must be a mapping.`,
51
+ );
52
+ }
53
+ return section;
54
+ };
55
+
56
+ const readBoundedNumber = (
57
+ section: Record<string, unknown>,
58
+ key: string,
59
+ fleetConfigFilePath: string,
60
+ fallback: number,
61
+ isAccepted: (value: number) => boolean,
62
+ requirement: string,
63
+ ): number => {
64
+ const value = section[key];
65
+ if (value === undefined || value === null) {
66
+ return fallback;
67
+ }
68
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
69
+ throw new Error(
70
+ `${LIVE_SESSION_OAUTH_TOKEN_SELECTION_SECTION_KEY}.${key} in ${fleetConfigFilePath} must be a number ${requirement}.`,
71
+ );
72
+ }
73
+ if (!isAccepted(value)) {
74
+ throw new Error(
75
+ `${LIVE_SESSION_OAUTH_TOKEN_SELECTION_SECTION_KEY}.${key} in ${fleetConfigFilePath} must be a number ${requirement}, but it is ${value}.`,
76
+ );
77
+ }
78
+ return value;
79
+ };
80
+
81
+ export const loadLiveSessionOauthTokenSelectionSettings = (
82
+ fleetConfigFilePath: string | null,
83
+ ): LiveSessionOauthTokenSelectionSettings => {
84
+ if (fleetConfigFilePath === null) {
85
+ return DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS;
86
+ }
87
+ const section = readSection(fleetConfigFilePath);
88
+ if (section === null) {
89
+ return DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS;
90
+ }
91
+ return {
92
+ maxConcurrentSessionCount: readBoundedNumber(
93
+ section,
94
+ 'maxConcurrentSessionCount',
95
+ fleetConfigFilePath,
96
+ DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS.maxConcurrentSessionCount,
97
+ (value) => Number.isInteger(value) && value >= 1,
98
+ 'integer of at least 1',
99
+ ),
100
+ fullSpeedFiveHourFreeRatio: readBoundedNumber(
101
+ section,
102
+ 'fullSpeedFiveHourFreeRatio',
103
+ fleetConfigFilePath,
104
+ DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS.fullSpeedFiveHourFreeRatio,
105
+ (value) => value > 0 && value <= 1,
106
+ 'above 0 and at most 1',
107
+ ),
108
+ };
109
+ };
@@ -59,6 +59,10 @@ import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/Iss
59
59
  import { OauthTokenSelectHandler } from '../handlers/OauthTokenSelectHandler';
60
60
  import { LiveSessionOauthTokenSelectHandler } from '../handlers/LiveSessionOauthTokenSelectHandler';
61
61
  import { InTmuxByHumanSessionTokenCountHandler } from '../handlers/InTmuxByHumanSessionTokenCountHandler';
62
+ import {
63
+ loadLiveSessionOauthTokenSelectionSettings,
64
+ resolveFleetConfigFilePath,
65
+ } from './fleetConfig';
62
66
 
63
67
  type StartDaemonOptions = {
64
68
  projectUrl?: string;
@@ -127,6 +131,7 @@ type SelectOauthTokenOptions = {
127
131
  type SelectLiveSessionOauthTokenOptions = {
128
132
  tokenListJsonPath?: string;
129
133
  cacheDir?: string;
134
+ fleetConfigFilePath?: string;
130
135
  };
131
136
 
132
137
  type CountInTmuxByHumanSessionsPerTokenOptions = {
@@ -951,22 +956,29 @@ program
951
956
  program
952
957
  .command('selectLiveSessionOauthToken')
953
958
  .description(
954
- 'Print exactly one Claude Code OAuth token chosen for a new live interactive session. Among rate-limit-eligible tokens the choice is weighted-random, with each token weighted by its per-token selectionWeight (default 1), multiplied by how urgent its 7d window is (the free share of that window times 168 divided by the hours left before it resets, with those hours floored at 1), and divided by one plus its current live session count (by distinct CLAUDE_CODE_SESSION_ID found in running Claude Code processes). A token whose 7d window still holds unused allowance and resets soon is therefore chosen more often, while occupancy keeps sessions spread across tokens. When every eligible weight is identical, the choice stays deterministic and no random draw is made. The token string is written to stdout (pipeable); the per-candidate decision trace is written to stderr. Exits non-zero when no token passes the filter.',
959
+ 'Print exactly one Claude Code OAuth token chosen for a new live interactive session. The choice is deterministic. Each rate-limit-eligible token gets a concurrent session limit of maxConcurrentSessionCount (default 10), scaled by its per-token selectionWeight (default 1). That limit is held at full value while the free share of the 5h window is at or above fullSpeedFiveHourFreeRatio (default 0.5) and is tapered in proportion below it, never dropping under 1; the 7d window never lowers the limit, so a weekly allowance that is about to expire is drained at full speed instead of being discarded unused. Among eligible tokens still under that limit the token whose 7d window resets soonest wins; ties go to the token carrying fewer live sessions (by distinct CLAUDE_CODE_SESSION_ID found in running Claude Code processes). When every eligible token is at its limit the soonest-resetting one is still returned. The token string is written to stdout (pipeable); the per-candidate decision trace is written to stderr. Exits non-zero when no token passes the filter.',
955
960
  )
956
961
  .option(
957
962
  '--tokenListJsonPath <path>',
958
- 'Path to the JSON array of { name, token, selectionWeight? } records. selectionWeight is an optional positive number (default 1) that biases how often this token is chosen among rate-limit-eligible candidates; a smaller weight is chosen proportionally less often and never bypasses eligibility filtering or starves a sole eligible token. Falls back to the CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH environment variable.',
963
+ 'Path to the JSON array of { name, token, selectionWeight? } records. selectionWeight is an optional positive number (default 1) that scales this token concurrent live session limit; a smaller weight allows fewer simultaneous sessions and never bypasses eligibility filtering or starves a sole eligible token. Falls back to the CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH environment variable.',
959
964
  )
960
965
  .option(
961
966
  '--cacheDir <path>',
962
967
  'Directory holding per-token rate-limit cache files. Falls back to the TDPM_RATELIMIT_CACHE_DIR environment variable, then to ${XDG_CACHE_HOME:-~/.cache}/tdpm/ratelimit.',
963
968
  )
969
+ .option(
970
+ '--fleetConfigFilePath <path>',
971
+ 'Path to the fleet-wide YAML config file holding the liveSessionOauthTokenSelection mapping (maxConcurrentSessionCount, fullSpeedFiveHourFreeRatio). Falls back to the TDPM_FLEET_CONFIG environment variable; when neither is set the built-in values are used. A key the file omits keeps its built-in value, and an unreadable file or an out-of-range value is reported as an error instead of being ignored.',
972
+ )
964
973
  .action((options: SelectLiveSessionOauthTokenOptions) => {
965
974
  const handler = new LiveSessionOauthTokenSelectHandler();
966
975
  const output = handler.handle({
967
976
  tokenListJsonPath: options.tokenListJsonPath ?? null,
968
977
  cacheDirectory: options.cacheDir ?? null,
969
978
  nowEpochSeconds: Date.now() / 1000,
979
+ selectionSettings: loadLiveSessionOauthTokenSelectionSettings(
980
+ resolveFleetConfigFilePath(options.fleetConfigFilePath ?? null),
981
+ ),
970
982
  });
971
983
 
972
984
  for (const line of output.diagnostics) {
@@ -5,7 +5,10 @@ import {
5
5
  ClaudeLiveSession,
6
6
  ClaudeLiveSessionRepository,
7
7
  } from '../../../domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository';
8
- import { LiveSessionOauthTokenSelectUseCase } from '../../../domain/usecases/LiveSessionOauthTokenSelectUseCase';
8
+ import {
9
+ DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
10
+ LiveSessionOauthTokenSelectUseCase,
11
+ } from '../../../domain/usecases/LiveSessionOauthTokenSelectUseCase';
9
12
  import { FABLE_LIMIT_TYPE, hashToken } from '../../proxy/RateLimitCache';
10
13
  import { LiveSessionOauthTokenSelectHandler } from './LiveSessionOauthTokenSelectHandler';
11
14
 
@@ -109,12 +112,10 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
109
112
 
110
113
  const buildHandler = (
111
114
  sessions: ClaudeLiveSession[],
112
- random: () => number = () => 0.5,
113
115
  ): LiveSessionOauthTokenSelectHandler =>
114
116
  new LiveSessionOauthTokenSelectHandler(
115
117
  new LiveSessionOauthTokenSelectUseCase(),
116
118
  new FakeClaudeLiveSessionRepository(sessions),
117
- random,
118
119
  );
119
120
 
120
121
  const writeFableRejectionCache = (token: string, resetsAt: number): void => {
@@ -157,6 +158,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
157
158
  tokenListJsonPath: tokenListPath,
158
159
  cacheDirectory,
159
160
  nowEpochSeconds: NOW,
161
+ selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
160
162
  });
161
163
 
162
164
  expect(output.selectedName).toBe('active');
@@ -165,7 +167,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
165
167
  );
166
168
  });
167
169
 
168
- it('selects the eligible token with fewer live sessions for a draw inside its weight slice', () => {
170
+ it('selects the soonest resetting eligible token even when it already carries a live session', () => {
169
171
  writeTokenList([
170
172
  { name: 'busy', token: 'fake-busy' },
171
173
  { name: 'idle', token: 'fake-idle' },
@@ -183,18 +185,21 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
183
185
  sevenDayReset: NOW + 6 * DAY,
184
186
  });
185
187
 
186
- const handler = buildHandler(
187
- [{ token: 'fake-busy', sessionKey: 'session-a' }],
188
- () => 0.9,
189
- );
188
+ const handler = buildHandler([
189
+ { token: 'fake-busy', sessionKey: 'session-a' },
190
+ ]);
190
191
  const output = handler.handle({
191
192
  tokenListJsonPath: tokenListPath,
192
193
  cacheDirectory,
193
194
  nowEpochSeconds: NOW,
195
+ selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
194
196
  });
195
197
 
196
- expect(output.selectedName).toBe('idle');
197
- expect(output.selectedToken).toBe('fake-idle');
198
+ expect(output.selectedName).toBe('busy');
199
+ expect(output.selectedToken).toBe('fake-busy');
200
+ expect(output.diagnostics.join('\n')).toContain(
201
+ 'busy: 1/10 live session(s)',
202
+ );
198
203
  });
199
204
 
200
205
  it('breaks an occupancy tie by the soonest 7d reset', () => {
@@ -223,6 +228,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
223
228
  tokenListJsonPath: tokenListPath,
224
229
  cacheDirectory,
225
230
  nowEpochSeconds: NOW,
231
+ selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
226
232
  });
227
233
 
228
234
  expect(output.selectedName).toBe('soon');
@@ -256,6 +262,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
256
262
  tokenListJsonPath: tokenListPath,
257
263
  cacheDirectory,
258
264
  nowEpochSeconds: NOW,
265
+ selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
259
266
  });
260
267
 
261
268
  expect(output.selectedName).toBe('oneSession');
@@ -286,6 +293,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
286
293
  tokenListJsonPath: tokenListPath,
287
294
  cacheDirectory,
288
295
  nowEpochSeconds: NOW,
296
+ selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
289
297
  });
290
298
 
291
299
  expect(output.selectedName).toBe('free');
@@ -305,6 +313,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
305
313
  tokenListJsonPath: tokenListPath,
306
314
  cacheDirectory,
307
315
  nowEpochSeconds: NOW,
316
+ selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
308
317
  });
309
318
 
310
319
  expect(output.selectedToken).toBeNull();
@@ -330,6 +339,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
330
339
  tokenListJsonPath: tokenListPath,
331
340
  cacheDirectory,
332
341
  nowEpochSeconds: NOW,
342
+ selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
333
343
  });
334
344
 
335
345
  expect(output.selectedToken).toBe('fake-free');
@@ -343,6 +353,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
343
353
  tokenListJsonPath: null,
344
354
  cacheDirectory,
345
355
  nowEpochSeconds: NOW,
356
+ selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
346
357
  });
347
358
 
348
359
  expect(output.selectedToken).toBeNull();
@@ -357,6 +368,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
357
368
  tokenListJsonPath: tokenListPath,
358
369
  cacheDirectory,
359
370
  nowEpochSeconds: NOW,
371
+ selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
360
372
  });
361
373
 
362
374
  expect(output.selectedToken).toBeNull();
@@ -383,6 +395,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
383
395
  tokenListJsonPath: tokenListPath,
384
396
  cacheDirectory,
385
397
  nowEpochSeconds: NOW,
398
+ selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
386
399
  });
387
400
 
388
401
  expect(output.selectedName).toBe('active');
@@ -419,6 +432,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
419
432
  tokenListJsonPath: tokenListPath,
420
433
  cacheDirectory,
421
434
  nowEpochSeconds: NOW,
435
+ selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
422
436
  });
423
437
 
424
438
  expect(output.selectedName).toBe('active');
@@ -446,6 +460,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
446
460
  tokenListJsonPath: tokenListPath,
447
461
  cacheDirectory,
448
462
  nowEpochSeconds: NOW,
463
+ selectionSettings: DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
449
464
  });
450
465
 
451
466
  expect(output.selectedToken).toBeNull();
@@ -2,13 +2,13 @@ import { ClaudeLiveSessionRepository } from '../../../domain/usecases/adapter-in
2
2
  import {
3
3
  LiveSessionOauthTokenSelectResult,
4
4
  LiveSessionOauthTokenSelectUseCase,
5
+ LiveSessionOauthTokenSelectionSettings,
5
6
  } from '../../../domain/usecases/LiveSessionOauthTokenSelectUseCase';
6
7
  import {
7
8
  DEFAULT_SELECTION_WEIGHT,
8
9
  FIVE_HOUR_MIN_FREE_RATIO,
9
10
  OauthTokenCandidate,
10
11
  SEVEN_DAY_MIN_FREE_RATIO,
11
- SelectionRandom,
12
12
  } from '../../../domain/usecases/OauthTokenSelectUseCase';
13
13
  import { ProcClaudeLiveSessionRepository } from '../../repositories/ProcClaudeLiveSessionRepository';
14
14
  import { FABLE_LIMIT_TYPE, readRateLimit } from '../../proxy/RateLimitCache';
@@ -22,6 +22,7 @@ export type LiveSessionOauthTokenSelectHandlerInput = {
22
22
  tokenListJsonPath: string | null;
23
23
  cacheDirectory: string | null;
24
24
  nowEpochSeconds: number;
25
+ selectionSettings: LiveSessionOauthTokenSelectionSettings;
25
26
  };
26
27
 
27
28
  export type LiveSessionOauthTokenSelectHandlerOutput = {
@@ -34,7 +35,6 @@ export class LiveSessionOauthTokenSelectHandler {
34
35
  constructor(
35
36
  private readonly useCase: LiveSessionOauthTokenSelectUseCase = new LiveSessionOauthTokenSelectUseCase(),
36
37
  private readonly liveSessionRepository: ClaudeLiveSessionRepository = new ProcClaudeLiveSessionRepository(),
37
- private readonly random: SelectionRandom = Math.random,
38
38
  ) {}
39
39
 
40
40
  handle = (
@@ -98,7 +98,7 @@ export class LiveSessionOauthTokenSelectHandler {
98
98
  candidates,
99
99
  liveSessions,
100
100
  input.nowEpochSeconds,
101
- this.random,
101
+ input.selectionSettings,
102
102
  );
103
103
 
104
104
  return {
@@ -119,7 +119,7 @@ export class LiveSessionOauthTokenSelectHandler {
119
119
  const status = metric.eligible
120
120
  ? 'eligible'
121
121
  : `excluded (${metric.exclusionReason})`;
122
- 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}`;
122
+ return `${metric.name}: ${metric.liveSessionCount}/${metric.concurrentSessionLimit} live session(s), 5h ${Math.round(metric.fiveHourFreeRatio * 100)}% free, 7d ${Math.round(metric.sevenDayFreeRatio * 100)}% free, 7d-end in ${secondsUntilSevenDayEnd}s -> ${status}`;
123
123
  });
124
124
 
125
125
  if (result.selected === null) {
@@ -128,7 +128,7 @@ export class LiveSessionOauthTokenSelectHandler {
128
128
  );
129
129
  } else {
130
130
  lines.push(
131
- `Selected ${result.selected.name} (weighted by how soon the 7d window resets, how much of it is free, and how few live sessions the token carries).`,
131
+ `Selected ${result.selected.name} (the soonest-resetting 7d window among tokens still under their concurrent session limit, which is set by the free share of the 5h window alone).`,
132
132
  );
133
133
  }
134
134