github-issue-tower-defence-management 1.95.0 → 1.96.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.
- package/.github/workflows/console-ui.yml +17 -0
- package/CHANGELOG.md +7 -0
- package/README.md +11 -0
- package/bin/adapter/entry-points/cli/index.js +21 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/{index-BeJzGnfH.js → index-B3F4E8LD.js} +33 -33
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +77 -0
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -0
- package/bin/adapter/repositories/ProcClaudeLiveSessionRepository.js +139 -0
- package/bin/adapter/repositories/ProcClaudeLiveSessionRepository.js.map +1 -0
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +59 -0
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.js.map +1 -0
- package/package.json +4 -1
- package/src/adapter/entry-points/cli/index.ts +38 -0
- package/src/adapter/entry-points/console/ui/biome.json +7 -1
- package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +93 -0
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +380 -0
- package/src/adapter/entry-points/console/ui/e2e/playwright.config.ts +30 -0
- package/src/adapter/entry-points/console/ui/e2e/tsconfig.json +18 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +2 -4
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +5 -6
- package/src/adapter/entry-points/console/ui-dist/assets/{index-BeJzGnfH.js → index-B3F4E8LD.js} +33 -33
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +268 -0
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +116 -0
- package/src/adapter/repositories/ProcClaudeLiveSessionRepository.test.ts +146 -0
- package/src/adapter/repositories/ProcClaudeLiveSessionRepository.ts +119 -0
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +147 -0
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +100 -0
- package/src/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.ts +8 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts +20 -0
- package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -0
- package/types/adapter/repositories/ProcClaudeLiveSessionRepository.d.ts +11 -0
- package/types/adapter/repositories/ProcClaudeLiveSessionRepository.d.ts.map +1 -0
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +23 -0
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.d.ts +8 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.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-
|
|
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>
|
|
@@ -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,146 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { ProcClaudeLiveSessionRepository } from './ProcClaudeLiveSessionRepository';
|
|
5
|
+
|
|
6
|
+
type FakeProcess = {
|
|
7
|
+
pid: number;
|
|
8
|
+
cmdline: string;
|
|
9
|
+
environ: Record<string, string>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe('ProcClaudeLiveSessionRepository', () => {
|
|
13
|
+
let procDirectory: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
procDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-proc-'));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
fs.rmSync(procDirectory, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const writeProcess = (fakeProcess: FakeProcess): void => {
|
|
24
|
+
const processDirectory = path.join(procDirectory, String(fakeProcess.pid));
|
|
25
|
+
fs.mkdirSync(processDirectory, { recursive: true });
|
|
26
|
+
fs.writeFileSync(
|
|
27
|
+
path.join(processDirectory, 'cmdline'),
|
|
28
|
+
fakeProcess.cmdline,
|
|
29
|
+
);
|
|
30
|
+
const environBuffer = Object.entries(fakeProcess.environ)
|
|
31
|
+
.map(([key, value]) => `${key}=${value}\0`)
|
|
32
|
+
.join('');
|
|
33
|
+
fs.writeFileSync(path.join(processDirectory, 'environ'), environBuffer);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const writeNonNumericEntry = (name: string): void => {
|
|
37
|
+
fs.mkdirSync(path.join(procDirectory, name), { recursive: true });
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
it('reads token and session id from a claude process environ', () => {
|
|
41
|
+
writeProcess({
|
|
42
|
+
pid: 101,
|
|
43
|
+
cmdline: '/home/user/.local/share/claude/cli.js\0--print\0',
|
|
44
|
+
environ: {
|
|
45
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-a',
|
|
46
|
+
CLAUDE_CODE_SESSION_ID: 'session-a',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const repository = new ProcClaudeLiveSessionRepository(procDirectory);
|
|
51
|
+
|
|
52
|
+
expect(repository.listLiveSessions()).toEqual([
|
|
53
|
+
{ token: 'token-a', sessionId: 'session-a' },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('identifies a claude process by the claude executable name', () => {
|
|
58
|
+
writeProcess({
|
|
59
|
+
pid: 102,
|
|
60
|
+
cmdline: '/usr/local/bin/claude\0',
|
|
61
|
+
environ: {
|
|
62
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-b',
|
|
63
|
+
CLAUDE_CODE_SESSION_ID: 'session-b',
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const repository = new ProcClaudeLiveSessionRepository(procDirectory);
|
|
68
|
+
|
|
69
|
+
expect(repository.listLiveSessions()).toEqual([
|
|
70
|
+
{ token: 'token-b', sessionId: 'session-b' },
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('ignores a process without an oauth token (for example an api-key session)', () => {
|
|
75
|
+
writeProcess({
|
|
76
|
+
pid: 103,
|
|
77
|
+
cmdline: '/usr/local/bin/claude\0',
|
|
78
|
+
environ: {
|
|
79
|
+
CLAUDE_CODE_SESSION_ID: 'session-c',
|
|
80
|
+
ANTHROPIC_API_KEY: 'api-key',
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const repository = new ProcClaudeLiveSessionRepository(procDirectory);
|
|
85
|
+
|
|
86
|
+
expect(repository.listLiveSessions()).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('ignores a non-claude process that happens to carry the token', () => {
|
|
90
|
+
writeProcess({
|
|
91
|
+
pid: 104,
|
|
92
|
+
cmdline: '/usr/bin/bash\0',
|
|
93
|
+
environ: {
|
|
94
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-d',
|
|
95
|
+
CLAUDE_CODE_SESSION_ID: 'session-d',
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const repository = new ProcClaudeLiveSessionRepository(procDirectory);
|
|
100
|
+
|
|
101
|
+
expect(repository.listLiveSessions()).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('returns one entry per child process so the use case can dedupe by session id', () => {
|
|
105
|
+
writeProcess({
|
|
106
|
+
pid: 105,
|
|
107
|
+
cmdline: '/home/user/.local/share/claude/cli.js\0',
|
|
108
|
+
environ: {
|
|
109
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-e',
|
|
110
|
+
CLAUDE_CODE_SESSION_ID: 'session-e',
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
writeProcess({
|
|
114
|
+
pid: 106,
|
|
115
|
+
cmdline: '/home/user/.local/share/claude/cli.js\0',
|
|
116
|
+
environ: {
|
|
117
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-e',
|
|
118
|
+
CLAUDE_CODE_SESSION_ID: 'session-e',
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const repository = new ProcClaudeLiveSessionRepository(procDirectory);
|
|
123
|
+
|
|
124
|
+
expect(repository.listLiveSessions()).toEqual([
|
|
125
|
+
{ token: 'token-e', sessionId: 'session-e' },
|
|
126
|
+
{ token: 'token-e', sessionId: 'session-e' },
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('skips non-numeric proc entries and unreadable processes', () => {
|
|
131
|
+
writeNonNumericEntry('cpuinfo');
|
|
132
|
+
fs.mkdirSync(path.join(procDirectory, '107'), { recursive: true });
|
|
133
|
+
|
|
134
|
+
const repository = new ProcClaudeLiveSessionRepository(procDirectory);
|
|
135
|
+
|
|
136
|
+
expect(repository.listLiveSessions()).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns an empty list when the proc directory does not exist', () => {
|
|
140
|
+
const repository = new ProcClaudeLiveSessionRepository(
|
|
141
|
+
path.join(procDirectory, 'missing'),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(repository.listLiveSessions()).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import {
|
|
4
|
+
ClaudeLiveSession,
|
|
5
|
+
ClaudeLiveSessionRepository,
|
|
6
|
+
} from '../../domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PROC_DIRECTORY = '/proc';
|
|
9
|
+
const OAUTH_TOKEN_ENVIRON_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
|
|
10
|
+
const SESSION_ID_ENVIRON_KEY = 'CLAUDE_CODE_SESSION_ID';
|
|
11
|
+
|
|
12
|
+
const isClaudeProcessCommand = (command: string): boolean => {
|
|
13
|
+
if (command.length === 0) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (command.includes('.local/share/claude')) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
const executableName = path.basename(command.split('\0')[0] ?? '');
|
|
20
|
+
return executableName === 'claude';
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class ProcClaudeLiveSessionRepository implements ClaudeLiveSessionRepository {
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly procDirectory: string = DEFAULT_PROC_DIRECTORY,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
listLiveSessions = (): ClaudeLiveSession[] => {
|
|
29
|
+
const liveSessions: ClaudeLiveSession[] = [];
|
|
30
|
+
for (const pidDirectory of this.listProcessIdDirectories()) {
|
|
31
|
+
const liveSession = this.readLiveSession(pidDirectory);
|
|
32
|
+
if (liveSession !== null) {
|
|
33
|
+
liveSessions.push(liveSession);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return liveSessions;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
private listProcessIdDirectories = (): string[] => {
|
|
40
|
+
let entries: string[];
|
|
41
|
+
try {
|
|
42
|
+
entries = fs.readdirSync(this.procDirectory);
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
return entries.filter((entry) => /^\d+$/.test(entry));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
private readLiveSession = (
|
|
50
|
+
processIdDirectory: string,
|
|
51
|
+
): ClaudeLiveSession | null => {
|
|
52
|
+
const environ = this.readEnviron(processIdDirectory);
|
|
53
|
+
if (environ === null) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const token = environ.get(OAUTH_TOKEN_ENVIRON_KEY);
|
|
57
|
+
const sessionId = environ.get(SESSION_ID_ENVIRON_KEY);
|
|
58
|
+
if (
|
|
59
|
+
token === undefined ||
|
|
60
|
+
token.length === 0 ||
|
|
61
|
+
sessionId === undefined ||
|
|
62
|
+
sessionId.length === 0
|
|
63
|
+
) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
if (!this.isClaudeProcess(processIdDirectory)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return { token, sessionId };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
private isClaudeProcess = (processIdDirectory: string): boolean => {
|
|
73
|
+
const basePath = path.join(this.procDirectory, processIdDirectory);
|
|
74
|
+
try {
|
|
75
|
+
const cmdline = fs.readFileSync(path.join(basePath, 'cmdline'), 'utf8');
|
|
76
|
+
if (isClaudeProcessCommand(cmdline)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const exe = fs.readlinkSync(path.join(basePath, 'exe'));
|
|
84
|
+
return isClaudeProcessCommand(exe);
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
private readEnviron = (
|
|
91
|
+
processIdDirectory: string,
|
|
92
|
+
): Map<string, string> | null => {
|
|
93
|
+
const environPath = path.join(
|
|
94
|
+
this.procDirectory,
|
|
95
|
+
processIdDirectory,
|
|
96
|
+
'environ',
|
|
97
|
+
);
|
|
98
|
+
let raw: string;
|
|
99
|
+
try {
|
|
100
|
+
raw = fs.readFileSync(environPath, 'utf8');
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const environ = new Map<string, string>();
|
|
105
|
+
for (const entry of raw.split('\0')) {
|
|
106
|
+
if (entry.length === 0) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const separatorIndex = entry.indexOf('=');
|
|
110
|
+
if (separatorIndex <= 0) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const key = entry.slice(0, separatorIndex);
|
|
114
|
+
const value = entry.slice(separatorIndex + 1);
|
|
115
|
+
environ.set(key, value);
|
|
116
|
+
}
|
|
117
|
+
return environ;
|
|
118
|
+
};
|
|
119
|
+
}
|